会员积分改版V1
This commit is contained in:
@@ -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';
|
||||
@@ -94,7 +96,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 />} />
|
||||
|
||||
@@ -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 {
|
||||
@@ -1092,3 +1092,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 Fixed Pre-deduct ──────────────────────────────────
|
||||
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,389 @@
|
||||
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,
|
||||
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,
|
||||
});
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
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: '有效期', render: () => '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 label="有效期"><Input value="1 个自然月" disabled /></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 }}>
|
||||
|
||||
@@ -35,6 +35,8 @@ 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 /> },
|
||||
};
|
||||
|
||||
@@ -58,6 +60,8 @@ const recordTypeOptions = [
|
||||
{ value: 'recharge', label: '充值' },
|
||||
{ value: 'consume', label: '消费' },
|
||||
{ value: 'refund', label: '回退' },
|
||||
{ value: 'expire', label: '过期' },
|
||||
{ value: 'revoke', label: '撤销' },
|
||||
{ value: 'team_internal', label: '团队内部' },
|
||||
];
|
||||
|
||||
@@ -280,10 +284,13 @@ 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: 12, maxWidth: 14, align: 'right', numFmt: '#,##0.00', render: (r) => r.amount },
|
||||
{ title: '有效余额变动', minWidth: 14, maxWidth: 16, align: 'right', numFmt: '#,##0.00', render: (r) => r.balanceDelta ?? 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 },
|
||||
@@ -295,6 +302,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.sourceType || '-'}:${a.sourceId || '-'}=${a.amount}`).join(';') },
|
||||
{ title: 'BizKey', maxWidth: 36, render: (r) => r.bizKey || '' },
|
||||
];
|
||||
|
||||
@@ -345,10 +353,13 @@ 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: (v: number) => <Typography.Text strong>{n(v)}</Typography.Text> },
|
||||
{ title: '有效变动', dataIndex: 'balanceDelta', width: 120, render: (v: number | undefined, r: AdminCreditRecord) => { const value = v ?? r.amount; return <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> },
|
||||
@@ -417,7 +428,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: 'allocationAction', width: 150 },
|
||||
{ title: '积分来源', dataIndex: 'sourceType', width: 160, render: (v: string) => v || '-' },
|
||||
{ title: '来源ID', dataIndex: 'sourceId', width: 260, render: (v: string) => v || '-' },
|
||||
{ title: '积分数量', dataIndex: 'amount', width: 120, render: (v: number) => n(v) },
|
||||
{ title: '生效时间', dataIndex: '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 }> = {
|
||||
pre_deducted: { 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.preDeductCredits },
|
||||
{ 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: 'preDeductCredits' },
|
||||
{ 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,51 @@
|
||||
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, pre_deduct_credits: item.preDeductCredits, is_active: item.isActive } : { is_active: true, pre_deduct_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: 'preDeductCredits' },
|
||||
{ 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="pre_deduct_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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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,80 +62,13 @@ const AdminSettings: React.FC = () => {
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const llmBillingEnabled = !['0', 'false', 'no', 'off', 'disabled'].includes(
|
||||
String(values.llm_billing_enabled ?? 'true').trim().toLowerCase(),
|
||||
);
|
||||
if (llmBillingEnabled) {
|
||||
const holdKeys = [
|
||||
'optimize_hold_credits',
|
||||
'llm_hold_credits_default',
|
||||
'llm_hold_credits_generation_record_prompt',
|
||||
'llm_hold_credits_module_image_prompt',
|
||||
'llm_hold_credits_module_video_prompt',
|
||||
'llm_hold_credits_shot_video_analysis',
|
||||
];
|
||||
const invalidKey = holdKeys.find((key) => {
|
||||
const numericValue = Number(values[key]);
|
||||
return !Number.isFinite(numericValue) || numericValue <= 0;
|
||||
});
|
||||
if (invalidKey) {
|
||||
message.error('启用 LLM 统一计费时,所有预扣积分必须大于 0');
|
||||
return;
|
||||
}
|
||||
}
|
||||
setSaving(true);
|
||||
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'),
|
||||
@@ -254,7 +179,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 => {
|
||||
@@ -270,13 +194,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 || '';
|
||||
};
|
||||
@@ -406,7 +323,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" />;
|
||||
@@ -467,26 +384,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>
|
||||
),
|
||||
|
||||
@@ -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: 'sourceType', width: 150, render: (v: string) => v || '-' },
|
||||
{ title: '来源ID', dataIndex: 'sourceId', width: 220, ellipsis: true, render: (v: string) => v || '-' },
|
||||
{ title: '积分等级', dataIndex: 'creditLevel', width: 100, render: (v: string) => v === 'promotional' ? '活动积分' : '普通积分' },
|
||||
{ title: '发放', dataIndex: '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) => <Tag color={v === 'active' ? 'green' : v === 'expired' ? 'orange' : v === 'revoked' ? 'red' : 'default'}>{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>
|
||||
)}
|
||||
|
||||
@@ -884,6 +884,23 @@ export interface AdminCreditRecordSummary {
|
||||
outputTokens: number;
|
||||
}
|
||||
|
||||
export interface AdminCreditRecordAllocation {
|
||||
id: string;
|
||||
creditBalanceId: string;
|
||||
sourceAllocationId?: string | null;
|
||||
allocationAction: string;
|
||||
amount: number;
|
||||
creditLevel?: string;
|
||||
sourceType?: string;
|
||||
sourceId?: string;
|
||||
validFrom?: string;
|
||||
expiresAt?: string;
|
||||
unspentBefore?: number;
|
||||
unspentAfter?: number;
|
||||
consumedBefore?: number;
|
||||
consumedAfter?: number;
|
||||
}
|
||||
|
||||
export interface AdminCreditRecord {
|
||||
id: string;
|
||||
userId: string;
|
||||
@@ -901,6 +918,8 @@ export interface AdminCreditRecord {
|
||||
recordTypeLabel?: string;
|
||||
amount: number;
|
||||
balanceAfter: number;
|
||||
balanceDelta?: number;
|
||||
expiredAmount?: number;
|
||||
description?: string;
|
||||
relatedId?: string;
|
||||
bizKey?: string;
|
||||
@@ -920,6 +939,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;
|
||||
@@ -1426,3 +1451,94 @@ 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;
|
||||
preDeductCredits: 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;
|
||||
preDeductCredits: number;
|
||||
status: string;
|
||||
totalCallCount: number;
|
||||
successfulCallCount: number;
|
||||
failedCallCount: number;
|
||||
totalInputTokens: number;
|
||||
totalOutputTokens: number;
|
||||
totalTokens: number;
|
||||
refundAvailableCredits: number;
|
||||
refundExpiredCredits: number;
|
||||
finalErrorMessage?: string | null;
|
||||
calls: LlmCallAttempt[];
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||
@@ -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
|
||||
@@ -19,6 +21,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,318 @@
|
||||
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 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":
|
||||
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="积分增值包必须配置价格和积分数量")
|
||||
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,
|
||||
"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": effective_balance_status(item, request_time=checked_at),
|
||||
}
|
||||
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}
|
||||
@@ -0,0 +1,89 @@
|
||||
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"])
|
||||
|
||||
|
||||
@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 list(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.pre_deduct_credits,
|
||||
is_active=data.is_active,
|
||||
version=1,
|
||||
created_by=admin.id,
|
||||
updated_by=admin.id,
|
||||
)
|
||||
db.add(policy)
|
||||
await db.flush()
|
||||
log_operation_event(domain="llm_billing", module="admin", event_type="LLM_BILLING_POLICY_CREATED", user_id=admin.id, detail={"scene_code": policy.scene_code, "credits": float(policy.pre_deduct_credits)})
|
||||
return 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)
|
||||
for key, value in payload.items():
|
||||
setattr(policy, key, value)
|
||||
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, "version": policy.version})
|
||||
return 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}
|
||||
@@ -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()]
|
||||
|
||||
@@ -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
|
||||
@@ -49,6 +50,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)
|
||||
|
||||
@@ -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
|
||||
@@ -130,6 +133,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)
|
||||
@@ -178,16 +184,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,
|
||||
@@ -257,7 +276,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(
|
||||
@@ -276,7 +295,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(
|
||||
@@ -1526,6 +1555,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()
|
||||
@@ -1554,6 +1585,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:
|
||||
@@ -1629,10 +1662,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,
|
||||
@@ -1667,10 +1696,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(
|
||||
|
||||
@@ -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"])
|
||||
@@ -93,50 +96,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(
|
||||
@@ -226,7 +236,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",
|
||||
)
|
||||
@@ -342,7 +351,7 @@ async def get_site_info(db: AsyncSession = Depends(get_db)):
|
||||
"""Public endpoint returning site name, logo, agreement and copyright info."""
|
||||
result = await db.execute(
|
||||
select(SystemConfig).where(SystemConfig.key.in_([
|
||||
"site_name", "site_logo", "user_agreement_privacy_url", "site_copyright", "operation_manual", "login_bg_video", "optimize_hold_credits"
|
||||
"site_name", "site_logo", "user_agreement_privacy_url", "site_copyright", "operation_manual", "login_bg_video"
|
||||
]))
|
||||
)
|
||||
configs = result.scalars().all()
|
||||
@@ -366,7 +375,6 @@ async def get_site_info(db: AsyncSession = Depends(get_db)):
|
||||
"site_copyright": info.get("site_copyright", "© 2026 智创 版权所有"),
|
||||
"operation_manual": info.get("operation_manual", ""),
|
||||
"login_bg_video": to_full_url(info.get("login_bg_video")) if info.get("login_bg_video") else "",
|
||||
"optimize_hold_credits": int(info.get("optimize_hold_credits") or 5),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -9,17 +9,16 @@ 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,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/payments", tags=["payments"])
|
||||
@@ -57,27 +56,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
|
||||
|
||||
|
||||
@@ -350,7 +345,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:
|
||||
@@ -373,6 +368,8 @@ async def cancel_order(
|
||||
logger.exception(f"Failed to close WeChat order {order_no}: {e}")
|
||||
|
||||
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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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分析"
|
||||
),
|
||||
|
||||
@@ -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, 1),
|
||||
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()))
|
||||
@@ -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)
|
||||
|
||||
@@ -53,6 +54,7 @@ async def get_current_user_allow_password_pending(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="账号不存在或已禁用",
|
||||
)
|
||||
attach_credit_snapshot(user, await get_available_credits(db, user.id))
|
||||
return user
|
||||
|
||||
|
||||
@@ -92,6 +94,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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
DEFAULT = "default"
|
||||
|
||||
|
||||
@@ -43,3 +44,4 @@ 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"
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
class CreditBalanceStatus(StrEnum):
|
||||
SCHEDULED = "scheduled"
|
||||
ACTIVE = "active"
|
||||
CONSUMED = "consumed"
|
||||
EXPIRED = "expired"
|
||||
REVOKED = "revoked"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
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"
|
||||
@@ -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"
|
||||
@@ -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,12 +115,19 @@ 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.PRE_DEDUCT.value: "固定预扣",
|
||||
CreditRecordAction.REFUND.value: "真实退款",
|
||||
CreditRecordAction.HOLD.value: "预扣占用",
|
||||
CreditRecordAction.HOLD_RELEASE.value: "预扣释放",
|
||||
@@ -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"
|
||||
@@ -3,49 +3,54 @@ 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 = "succeeded" # 历史兼容别名
|
||||
|
||||
|
||||
|
||||
class LlmBillingExecutionStatus(StrEnum):
|
||||
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"
|
||||
PRE_DEDUCT_START = "LLM_PRE_DEDUCT_START"
|
||||
PRE_DEDUCT_SUCCESS = "LLM_PRE_DEDUCT_SUCCESS"
|
||||
PRE_DEDUCT_INSUFFICIENT = "LLM_PRE_DEDUCT_INSUFFICIENT"
|
||||
PRE_DEDUCT_CONFIG_INVALID = "LLM_PRE_DEDUCT_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 +58,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分析",
|
||||
}
|
||||
|
||||
@@ -138,7 +138,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 +153,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 +175,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 +428,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 +462,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
|
||||
|
||||
@@ -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
|
||||
@@ -44,7 +49,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 = 1 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"
|
||||
)
|
||||
|
||||
# 积分增值包字段;当前固定一个自然月有效。
|
||||
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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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,21 +27,25 @@ 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
|
||||
)
|
||||
|
||||
@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
|
||||
|
||||
@@ -176,6 +176,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
|
||||
@@ -192,6 +210,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
|
||||
@@ -212,6 +232,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
|
||||
@@ -222,6 +244,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
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,130 @@
|
||||
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)
|
||||
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")
|
||||
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)
|
||||
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}
|
||||
@@ -0,0 +1,88 @@
|
||||
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)
|
||||
pre_deduct_credits: float = Field(..., ge=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)
|
||||
pre_deduct_credits: float | None = Field(default=None, ge=0, le=999999999.99, multiple_of=0.01)
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class LlmBillingPolicyOut(BaseModel):
|
||||
id: str
|
||||
scene_code: str
|
||||
scene_name: str
|
||||
pre_deduct_credits: float
|
||||
is_active: bool
|
||||
version: int
|
||||
created_by: str | None = None
|
||||
updated_by: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
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
|
||||
pre_deduct_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)
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -20,6 +20,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
|
||||
@@ -165,7 +166,12 @@ 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 _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:
|
||||
@@ -189,6 +195,8 @@ 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,
|
||||
"related_id": record.related_id,
|
||||
@@ -209,6 +217,8 @@ def _record_to_item(record: CreditRecord, user: User | None, deleted_map: dict[t
|
||||
"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,
|
||||
@@ -219,6 +229,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,
|
||||
@@ -285,7 +299,32 @@ 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,
|
||||
"amount": _round2(allocation.amount),
|
||||
"credit_level": allocation.credit_level_snapshot,
|
||||
"source_type": 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]
|
||||
|
||||
summary_query = select(
|
||||
func.coalesce(func.sum(case((CreditRecord.type == "recharge", CreditRecord.amount), else_=0)), 0),
|
||||
|
||||
@@ -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": 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,808 @@
|
||||
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),
|
||||
"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)
|
||||
|
||||
|
||||
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, 1),
|
||||
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
|
||||
@@ -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,11 @@ 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)
|
||||
"""历史兼容函数:LLM 已按业务场景固定预扣,Token 不再折算用户积分。"""
|
||||
return 0.0
|
||||
|
||||
|
||||
async def _get_credit_ratio(
|
||||
@@ -176,31 +163,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)
|
||||
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,
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
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 +191,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 +222,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 +237,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 +253,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 +324,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 +346,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 +370,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)
|
||||
|
||||
@@ -38,7 +38,7 @@ OWNER_SHOT_REPLICATE_TASK_SET = CreditRecordOwnerType.SHOT_REPLICATE_TASK_SET.va
|
||||
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|refund|pre_deduct|hold|hold_release)$"
|
||||
)
|
||||
|
||||
|
||||
@@ -101,8 +101,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", "refund", "pre_deduct", "hold", "hold_release"):
|
||||
raise ValueError("action 仅支持 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}"
|
||||
|
||||
@@ -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,
|
||||
pre_deduct,
|
||||
)
|
||||
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 pre_deduct(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,
|
||||
|
||||
@@ -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_pre_deducted,
|
||||
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,
|
||||
pre_deduct,
|
||||
)
|
||||
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 pre_deduct(
|
||||
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:
|
||||
pre_deduct_validation = await ensure_pre_deducted(db, llm_billing_context)
|
||||
if not pre_deduct_validation.can_execute:
|
||||
step.status = ModuleStepStatusEnum.FAILED.value
|
||||
step.error_message = f"LLM账务状态异常({hold_validation.state.value}),已终止任务"
|
||||
step.error_message = f"LLM账务状态异常({pre_deduct_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 pre_deduct(
|
||||
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:
|
||||
pre_deduct_validation = await ensure_pre_deducted(db, llm_billing_context)
|
||||
if not pre_deduct_validation.can_execute:
|
||||
step.status = ModuleStepStatusEnum.FAILED.value
|
||||
step.error_message = f"LLM账务状态异常({hold_validation.state.value}),已终止任务"
|
||||
step.error_message = f"LLM账务状态异常({pre_deduct_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,
|
||||
|
||||
@@ -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,22 +3,24 @@ from app.services.llm_billing.context import (
|
||||
LlmBillingContext,
|
||||
LlmBillingPolicy,
|
||||
LlmBillingStateError,
|
||||
LlmHoldResult,
|
||||
LlmHoldValidation,
|
||||
LlmPreDeductResult,
|
||||
LlmPreDeductValidation,
|
||||
)
|
||||
from app.services.llm_billing.service import (
|
||||
ensure_hold_exists,
|
||||
ensure_pre_deducted,
|
||||
get_llm_ledger_states,
|
||||
log_celery_dispatch_compensated,
|
||||
log_celery_dispatch_failure,
|
||||
log_celery_dispatch_start,
|
||||
log_celery_dispatch_success,
|
||||
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,
|
||||
pre_deduct,
|
||||
validate_retryable_previous_attempt,
|
||||
)
|
||||
|
||||
@@ -27,19 +29,21 @@ __all__ = [
|
||||
"LlmBillingContext",
|
||||
"LlmBillingPolicy",
|
||||
"LlmBillingStateError",
|
||||
"LlmHoldResult",
|
||||
"LlmHoldValidation",
|
||||
"start_hold",
|
||||
"ensure_hold_exists",
|
||||
"LlmPreDeductResult",
|
||||
"LlmPreDeductValidation",
|
||||
"pre_deduct",
|
||||
"ensure_pre_deducted",
|
||||
"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,271 @@
|
||||
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 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.utils.id_gen import generate_id
|
||||
|
||||
|
||||
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.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,
|
||||
) -> TokenUsage:
|
||||
"""先持久化 TokenUsage,再允许任何外键引用它。
|
||||
|
||||
LlmCallAttempt 仅保存 token_usage_id 字符串,没有 ORM relationship。SQLAlchemy
|
||||
无法仅凭字符串赋值推导 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
|
||||
|
||||
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
|
||||
return usage_row
|
||||
|
||||
|
||||
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 = 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
|
||||
record.token_usage_id = token_usage_id
|
||||
ctx.token_usage_id = token_usage_id
|
||||
|
||||
|
||||
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)[:1000]
|
||||
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)[:1000]
|
||||
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"
|
||||
@@ -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,
|
||||
pre_deduct_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(
|
||||
pre_deduct_credits=0.0,
|
||||
valid=False,
|
||||
error=f"未配置LLM场景固定预扣积分:{resolved_scene}",
|
||||
)
|
||||
amount = float(model.pre_deduct_credits)
|
||||
if not model.is_active or amount <= 0:
|
||||
return LlmBillingPolicy(
|
||||
pre_deduct_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,
|
||||
pre_deduct_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 的固定预扣状态不允许继续执行。"""
|
||||
|
||||
|
||||
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
|
||||
pre_deduct_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,72 +47,63 @@ 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
|
||||
# 供应商响应成功事实先于审计落库设置。即使审计事务暂时失败,
|
||||
# 后续异常处理也不能把真实供应商成功覆盖成 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 hold_biz_key(self) -> str:
|
||||
def pre_deduct_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",
|
||||
action="pre_deduct",
|
||||
)
|
||||
|
||||
@property
|
||||
def hold_release_biz_key(self) -> str:
|
||||
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="hold_release",
|
||||
action="refund",
|
||||
)
|
||||
|
||||
@property
|
||||
def charge_biz_key(self) -> str:
|
||||
return build_credit_biz_key(
|
||||
owner_type=self.owner_type,
|
||||
owner_id=self.owner_id,
|
||||
attempt_no=self.attempt_no,
|
||||
charge_kind=self.charge_kind,
|
||||
action="charge",
|
||||
)
|
||||
|
||||
@property
|
||||
def ledger_biz_keys(self) -> tuple[str, str, str]:
|
||||
return self.hold_biz_key, self.hold_release_biz_key, self.charge_biz_key
|
||||
def billing_biz_key(self) -> str:
|
||||
return self.pre_deduct_biz_key
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class LlmHoldResult:
|
||||
class LlmPreDeductResult:
|
||||
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 LlmPreDeductValidation:
|
||||
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
|
||||
pre_deduct_record_id: str | None = None
|
||||
execution_id: str | None = None
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.llm_billing.call_attempt import LlmCallAttempt
|
||||
from app.models.llm_billing.execution import LlmBillingExecution
|
||||
|
||||
|
||||
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(LlmBillingExecution.status == 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())
|
||||
ids = [item.id for item in executions]
|
||||
calls_by_execution: dict[str, list[LlmCallAttempt]] = {item_id: [] for item_id in ids}
|
||||
if ids:
|
||||
call_result = await db.execute(
|
||||
select(LlmCallAttempt)
|
||||
.where(LlmCallAttempt.billing_execution_id.in_(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 = []
|
||||
for execution in executions:
|
||||
data = {
|
||||
column.name: getattr(execution, column.name)
|
||||
for column in LlmBillingExecution.__table__.columns
|
||||
}
|
||||
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, LlmPreDeductValidation, 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, LlmPreDeductValidation]:
|
||||
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, LlmPreDeductValidation] = {}
|
||||
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.pre_deduct_biz_key,
|
||||
LlmPreDeductValidation(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] = LlmPreDeductValidation(
|
||||
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_pre_deducted,
|
||||
log_provider_failure,
|
||||
log_provider_start,
|
||||
log_provider_success,
|
||||
release_on_failure,
|
||||
settle_success,
|
||||
start_hold,
|
||||
mark_business_success,
|
||||
pre_deduct,
|
||||
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 pre_deduct(
|
||||
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 pre_deduct(
|
||||
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 pre_deduct(
|
||||
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:
|
||||
pre_deduct_validation = await ensure_pre_deducted(db, llm_billing_context)
|
||||
if not pre_deduct_validation.can_execute:
|
||||
step.status = ModuleStepStatusEnum.FAILED.value
|
||||
step.error_message = f"LLM账务状态异常({hold_validation.state.value}),已终止任务"
|
||||
step.error_message = f"LLM账务状态异常({pre_deduct_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
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,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 +29,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
|
||||
|
||||
|
||||
@@ -175,6 +181,8 @@ async def _check_and_expire_order(db: AsyncSession, order: PaymentOrder) -> bool
|
||||
expiry = order.created_at + timedelta(seconds=expire_seconds)
|
||||
if datetime.now(order.created_at.tzinfo) >= expiry:
|
||||
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} "
|
||||
@@ -212,6 +220,8 @@ async def expire_all_pending_orders(db: AsyncSession) -> int:
|
||||
expired_count = 0
|
||||
for o in orders:
|
||||
o.status = "cancelled"
|
||||
if o.upgrade_period_ids_json:
|
||||
await release_upgrade_reservation(db, order=o, released_at=utc_now())
|
||||
expired_count += 1
|
||||
logger.info(
|
||||
f"ORDER_EXPIRED order_no={o.order_no} user={o.user_id} amount={o.amount}"
|
||||
@@ -291,23 +301,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 +326,120 @@ 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
|
||||
if product:
|
||||
await fulfill_payment_product(db, order=order, fulfilled_at=checked_at)
|
||||
else:
|
||||
desc = f"充值{label}({total_credits}积分)"
|
||||
if bonus_credits > 0:
|
||||
desc += f"(含赠送{bonus_credits}积分)"
|
||||
await add_credits(
|
||||
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,
|
||||
),
|
||||
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),
|
||||
payment_order_id=order.id,
|
||||
source_id=order.id,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
@@ -925,6 +981,8 @@ async def sync_pending_orders(db: AsyncSession) -> int:
|
||||
elif trade_status in ("TRADE_CLOSED", "TRADE_CANCELLED"):
|
||||
# Order was closed on Alipay side
|
||||
order.status = "cancelled"
|
||||
if order.upgrade_period_ids_json:
|
||||
await release_upgrade_reservation(db, order=order, released_at=utc_now())
|
||||
await db.flush()
|
||||
updated_count += 1
|
||||
elif order.payment_method == "wechat":
|
||||
@@ -940,6 +998,8 @@ async def sync_pending_orders(db: AsyncSession) -> int:
|
||||
elif trade_state in ("CLOSED", "REVOKED"):
|
||||
# Order was closed on WeChat side
|
||||
order.status = "cancelled"
|
||||
if order.upgrade_period_ids_json:
|
||||
await release_upgrade_reservation(db, order=order, released_at=utc_now())
|
||||
await db.flush()
|
||||
updated_count += 1
|
||||
except Exception as e:
|
||||
@@ -1130,18 +1190,16 @@ async def process_payment_success(db: AsyncSession, order_id: str):
|
||||
|
||||
order.status = "paid"
|
||||
order.paid_at = datetime.now()
|
||||
if order.product_id:
|
||||
await fulfill_payment_product(db, order=order, fulfilled_at=order.paid_at)
|
||||
else:
|
||||
await add_credits(
|
||||
db,
|
||||
order.user_id,
|
||||
order.credits,
|
||||
f"充值成功({order.credits}积分)",
|
||||
db, order.user_id, order.credits, f"充值成功({order.credits}积分)",
|
||||
related_id=order.id,
|
||||
biz_key=_payment_biz_key(
|
||||
order,
|
||||
charge_kind=CreditRecordChargeKind.RECHARGE.value,
|
||||
action=CreditRecordAction.CHARGE.value,
|
||||
),
|
||||
biz_key=_payment_biz_key(order, charge_kind=CreditRecordChargeKind.RECHARGE.value, action=CreditRecordAction.CHARGE.value),
|
||||
record_meta=_payment_recharge_meta(order),
|
||||
payment_order_id=order.id,
|
||||
source_id=order.id,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
@@ -1178,7 +1236,7 @@ async def process_payment_success_by_order_no(
|
||||
return
|
||||
|
||||
# 金额一致性校验
|
||||
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}"
|
||||
)
|
||||
@@ -1194,18 +1252,17 @@ async def process_payment_success_by_order_no(
|
||||
if trade_no:
|
||||
order.trade_no = trade_no
|
||||
|
||||
if order.product_id:
|
||||
await fulfill_payment_product(db, order=order, fulfilled_at=order.paid_at)
|
||||
else:
|
||||
await add_credits(
|
||||
db,
|
||||
order.user_id,
|
||||
order.credits,
|
||||
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,
|
||||
),
|
||||
biz_key=_payment_biz_key(order, charge_kind=CreditRecordChargeKind.RECHARGE.value, action=CreditRecordAction.CHARGE.value),
|
||||
record_meta=_payment_recharge_meta(order),
|
||||
payment_order_id=order.id,
|
||||
source_id=order.id,
|
||||
)
|
||||
await db.commit()
|
||||
logger.info(
|
||||
@@ -1245,10 +1302,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 +1323,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:
|
||||
|
||||
@@ -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_pre_deducted,
|
||||
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,
|
||||
pre_deduct,
|
||||
)
|
||||
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 pre_deduct(
|
||||
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:
|
||||
pre_deduct_validation = await ensure_pre_deducted(db, llm_billing_context)
|
||||
if not pre_deduct_validation.can_execute:
|
||||
step.status = ModuleStepStatusEnum.FAILED.value
|
||||
step.error_message = f"LLM账务状态异常({hold_validation.state.value}),已终止任务"
|
||||
step.error_message = f"LLM账务状态异常({pre_deduct_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 pre_deduct(
|
||||
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:
|
||||
pre_deduct_validation = await ensure_pre_deducted(db, llm_billing_context)
|
||||
if not pre_deduct_validation.can_execute:
|
||||
step.status = ModuleStepStatusEnum.FAILED.value
|
||||
step.error_message = f"LLM账务状态异常({hold_validation.state.value}),已终止任务"
|
||||
step.error_message = f"LLM账务状态异常({pre_deduct_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.pre_deduct_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.pre_deduct_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,
|
||||
pre_deduct,
|
||||
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)}",
|
||||
)
|
||||
@@ -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 pre_deduct(db, build_task_set_analysis_billing_context(task_set))
|
||||
log_module_event_file(
|
||||
module=MODULE,
|
||||
event_type="SHOT_TASK_SET_CREATED",
|
||||
@@ -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 pre_deduct(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,7 +917,7 @@ 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="用户删除自定义拆镜片段,释放未结算的片段分析预扣",
|
||||
@@ -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_pre_deduct_refund_on_cancel": segment.source_mode == ShotSegmentSourceModeEnum.CUSTOM.value,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1037,14 +1034,14 @@ async def delete_task_set(
|
||||
|
||||
# 删除是对未执行/失败任务的最终取消动作;处理中的任务已在上方拦截。
|
||||
# 这里仅做账务补偿,不改变现有逐项目删除流程。
|
||||
await release_on_failure(
|
||||
await refund_on_final_failure(
|
||||
db,
|
||||
build_task_set_analysis_billing_context(task_set),
|
||||
error="用户删除拆镜任务集,释放未结算的原视频分析预扣",
|
||||
)
|
||||
for segment in segments:
|
||||
if segment.source_mode == ShotSegmentSourceModeEnum.CUSTOM.value:
|
||||
await release_on_failure(
|
||||
await refund_on_final_failure(
|
||||
db,
|
||||
build_segment_analysis_billing_context(segment),
|
||||
error="用户删除拆镜任务集,释放未结算的片段分析预扣",
|
||||
@@ -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_pre_deduct_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 pre_deduct(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 pre_deduct(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=(
|
||||
|
||||
@@ -1,25 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.enums.credit_record import (
|
||||
CreditRecordBillingScene,
|
||||
CreditRecordChargeKind,
|
||||
CreditRecordSubject,
|
||||
CreditRecordSourceModule,
|
||||
)
|
||||
from app.enums.team import TeamStatus
|
||||
from app.enums.user import UserType
|
||||
from app.models.team import Team
|
||||
from app.models.user import User
|
||||
from app.services.credits import add_credits, deduct_credits
|
||||
from app.services.credit_record_meta_service import CreditRecordMeta
|
||||
from app.utils.id_gen import generate_id
|
||||
from app.services.credit.query_service import attach_credit_snapshot, get_user_credit_map
|
||||
|
||||
|
||||
async def set_team_manager(db: AsyncSession, team_id: str, user_id: str | None) -> Team:
|
||||
@@ -114,6 +104,9 @@ async def get_team_members(
|
||||
.limit(page_size)
|
||||
)
|
||||
members = list(result.scalars().all())
|
||||
credit_map = await get_user_credit_map(db, [item.id for item in members])
|
||||
for item in members:
|
||||
attach_credit_snapshot(item, credit_map.get(item.id, 0.0))
|
||||
|
||||
return {
|
||||
"items": [
|
||||
@@ -139,104 +132,4 @@ async def transfer_credits_to_member(
|
||||
direction: str = "increase", # "increase" 管理人→成员; "decrease" 成员扣减
|
||||
description: str | None = None,
|
||||
) -> None:
|
||||
"""管理人为团队成员增加或扣减积分。
|
||||
|
||||
direction:
|
||||
- "increase": 管理人从自己余额转积分给成员(管理人减少,成员增加)
|
||||
- "decrease": 从成员扣积分回到管理人(成员减少,管理人增加)
|
||||
"""
|
||||
if amount <= 0:
|
||||
raise HTTPException(status_code=400, description="积分数量必须大于0")
|
||||
if direction not in ("increase", "decrease"):
|
||||
raise HTTPException(status_code=400, description="无效操作方向")
|
||||
|
||||
# 获取管理人
|
||||
manager_result = await db.execute(
|
||||
select(User).where(User.id == manager_id, User.is_active.is_(True)).limit(1)
|
||||
)
|
||||
manager = manager_result.scalar_one_or_none()
|
||||
if not manager:
|
||||
raise HTTPException(status_code=404, detail="管理员不存在")
|
||||
|
||||
# 获取目标成员
|
||||
member_result = await db.execute(
|
||||
select(User).where(User.id == target_member_id, User.is_active.is_(True)).limit(1)
|
||||
)
|
||||
member = member_result.scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(status_code=404, detail="成员不存在")
|
||||
|
||||
# 验证管理人是该团队的管理人且目标是同团队成员
|
||||
if not manager.team_id:
|
||||
raise HTTPException(status_code=400, detail="您不在任何团队中")
|
||||
if member.team_id != manager.team_id:
|
||||
raise HTTPException(status_code=400, detail="只能操作同团队成员")
|
||||
|
||||
team_result = await db.execute(
|
||||
select(Team).where(Team.id == manager.team_id, Team.deleted_at.is_(None)).limit(1)
|
||||
)
|
||||
team = team_result.scalar_one_or_none()
|
||||
if not team or team.manager_id != manager_id:
|
||||
raise HTTPException(status_code=403, detail="只有团队管理人才能分配积分")
|
||||
|
||||
# 禁止管理人给自己转积分
|
||||
if target_member_id == manager_id:
|
||||
raise HTTPException(status_code=400, detail="不能给自己调整积分")
|
||||
|
||||
desc = description or ("团队积分发放" if direction == "increase" else "团队积分扣减")
|
||||
xfer_id = generate_id()
|
||||
|
||||
# 构建团队内部转账的 meta,确保 team_id_snapshot 等字段被正确设置
|
||||
def _build_team_transfer_meta(uid: str) -> CreditRecordMeta:
|
||||
meta = CreditRecordMeta(
|
||||
owner_type="team_internal_transfer",
|
||||
owner_id=xfer_id,
|
||||
charge_kind=CreditRecordChargeKind.TEAM_INTERNAL.value,
|
||||
credit_subject=CreditRecordSubject.TEAM_INTERNAL.value,
|
||||
source_module=CreditRecordSourceModule.TEAM.value,
|
||||
billing_scene=CreditRecordBillingScene.TEAM_INTERNAL_TRANSFER.value,
|
||||
)
|
||||
return meta
|
||||
|
||||
if direction == "increase":
|
||||
# 管理人扣减
|
||||
await deduct_credits(
|
||||
db,
|
||||
manager_id,
|
||||
amount,
|
||||
f"分配给成员 {member.username}: {desc}",
|
||||
record_type="team_internal",
|
||||
biz_key=f"mgr_xfer_out:{manager_id}:{target_member_id}:{xfer_id}",
|
||||
record_meta=_build_team_transfer_meta(manager_id),
|
||||
)
|
||||
# 成员增加
|
||||
await add_credits(
|
||||
db,
|
||||
target_member_id,
|
||||
amount,
|
||||
f"来自团队管理人: {desc}",
|
||||
record_type="team_internal",
|
||||
biz_key=f"mgr_xfer_in:{manager_id}:{target_member_id}:{xfer_id}",
|
||||
record_meta=_build_team_transfer_meta(target_member_id),
|
||||
)
|
||||
else:
|
||||
# 成员扣减
|
||||
await deduct_credits(
|
||||
db,
|
||||
target_member_id,
|
||||
amount,
|
||||
f"扣减给团队管理人: {desc}",
|
||||
record_type="team_internal",
|
||||
biz_key=f"mgr_deduct_out:{manager_id}:{target_member_id}:{xfer_id}",
|
||||
record_meta=_build_team_transfer_meta(target_member_id),
|
||||
)
|
||||
# 管理人增加
|
||||
await add_credits(
|
||||
db,
|
||||
manager_id,
|
||||
amount,
|
||||
f"来自成员 {member.username}: {desc}",
|
||||
record_type="team_internal",
|
||||
biz_key=f"mgr_deduct_in:{manager_id}:{target_member_id}:{xfer_id}",
|
||||
record_meta=_build_team_transfer_meta(manager_id),
|
||||
)
|
||||
raise HTTPException(status_code=409, detail="当前版本积分暂未开放团队转账功能")
|
||||
|
||||
@@ -34,6 +34,7 @@ CELERY_TASK_IMPORTS = (
|
||||
"app.tasks.module_generation_v2_tasks",
|
||||
"app.tasks.private_portrait_asset_tasks",
|
||||
"app.tasks.celery_runtime_tasks",
|
||||
"app.tasks.credit_tasks",
|
||||
)
|
||||
|
||||
|
||||
@@ -120,6 +121,11 @@ def _beat_schedule() -> dict:
|
||||
"schedule": 300,
|
||||
"options": {"queue": CeleryQueue.GEN_PRIVATE_PORTRAIT.value},
|
||||
}
|
||||
schedule["credit-maintenance-every-minute"] = {
|
||||
"task": CeleryTaskName.CREDIT_MAINTENANCE.value,
|
||||
"schedule": 60,
|
||||
"options": {"queue": CeleryQueue.GEN_CREDIT_MAINTENANCE.value},
|
||||
}
|
||||
return schedule
|
||||
|
||||
|
||||
@@ -181,6 +187,7 @@ if broker_url:
|
||||
CeleryTaskName.MODULE_ASYNC_RECOVERY.value: {"ignore_result": True},
|
||||
CeleryTaskName.SHOT_ANALYSIS_RECOVERY.value: {"ignore_result": True},
|
||||
CeleryTaskName.SHOT_SPLIT_RECOVERY.value: {"ignore_result": True},
|
||||
CeleryTaskName.CREDIT_MAINTENANCE.value: {"ignore_result": True},
|
||||
},
|
||||
worker_prefetch_multiplier=1,
|
||||
worker_cancel_long_running_tasks_on_connection_loss=True,
|
||||
@@ -240,6 +247,7 @@ if broker_url:
|
||||
CeleryTaskName.PRIVATE_PORTRAIT_DELETE_GROUP.value: {"queue": CeleryQueue.GEN_PRIVATE_PORTRAIT.value},
|
||||
CeleryTaskName.PRIVATE_PORTRAIT_DELETE_PROJECT.value: {"queue": CeleryQueue.GEN_PRIVATE_PORTRAIT.value},
|
||||
CeleryTaskName.PRIVATE_PORTRAIT_RECOVER_REMOTE_DELETES.value: {"queue": CeleryQueue.GEN_PRIVATE_PORTRAIT.value},
|
||||
CeleryTaskName.CREDIT_MAINTENANCE.value: {"queue": CeleryQueue.GEN_CREDIT_MAINTENANCE.value},
|
||||
},
|
||||
)
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.models.base import async_session
|
||||
from app.services.credit.expiration_service import (
|
||||
archive_expired_user_balances,
|
||||
list_expired_balance_user_limits,
|
||||
)
|
||||
from app.services.credit.subscription_service import (
|
||||
expire_subscription_by_id,
|
||||
grant_due_subscription_period_by_id,
|
||||
list_due_subscription_ids,
|
||||
list_due_subscription_period_candidates,
|
||||
)
|
||||
from app.services.credit.utils import utc_now
|
||||
from app.services.operation_log_service import log_operation_event
|
||||
from app.tasks.async_runner import run_async
|
||||
from app.tasks.celery_app import celery_app
|
||||
|
||||
|
||||
async def _run_credit_maintenance_once(batch_size: int = 500) -> dict[str, Any]:
|
||||
checked_at = utc_now()
|
||||
limit = max(1, min(int(batch_size or 500), 2000))
|
||||
errors: list[dict[str, str]] = []
|
||||
log_operation_event(
|
||||
domain="billing",
|
||||
module="credit_maintenance",
|
||||
event_type="CREDIT_MAINTENANCE_STARTED",
|
||||
event_status="started",
|
||||
source="app.tasks.credit_tasks._run_credit_maintenance_once",
|
||||
message="积分维护批次开始",
|
||||
detail={"checked_at": checked_at.isoformat(), "batch_size": limit},
|
||||
)
|
||||
|
||||
async with async_session() as scan_db:
|
||||
grant_candidates = await list_due_subscription_period_candidates(
|
||||
scan_db, request_time=checked_at, batch_size=limit
|
||||
)
|
||||
subscription_ids = await list_due_subscription_ids(
|
||||
scan_db, request_time=checked_at, batch_size=limit
|
||||
)
|
||||
expired_user_limits = await list_expired_balance_user_limits(
|
||||
scan_db, request_time=checked_at, batch_size=limit
|
||||
)
|
||||
await scan_db.rollback()
|
||||
|
||||
def record_failure(stage: str, item_id: str, exc: Exception) -> None:
|
||||
errors.append({"stage": stage, "id": item_id, "error": str(exc)})
|
||||
log_operation_event(
|
||||
domain="billing",
|
||||
module="credit_maintenance",
|
||||
event_type="CREDIT_MAINTENANCE_ITEM_FAILED",
|
||||
event_status="failed",
|
||||
source="app.tasks.credit_tasks._run_credit_maintenance_once",
|
||||
task_id=item_id,
|
||||
message="积分维护单条处理失败",
|
||||
error=str(exc),
|
||||
detail={"stage": stage, "item_id": item_id},
|
||||
)
|
||||
|
||||
granted = 0
|
||||
for period_id, subscription_id, user_id in grant_candidates:
|
||||
async with async_session() as db:
|
||||
try:
|
||||
changed = await grant_due_subscription_period_by_id(
|
||||
db,
|
||||
period_id=period_id,
|
||||
subscription_id=subscription_id,
|
||||
user_id=user_id,
|
||||
request_time=checked_at,
|
||||
)
|
||||
await db.commit()
|
||||
granted += int(changed)
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
record_failure("subscription_grant", period_id, exc)
|
||||
|
||||
expired_subscriptions = 0
|
||||
for subscription_id in subscription_ids:
|
||||
async with async_session() as db:
|
||||
try:
|
||||
changed = await expire_subscription_by_id(
|
||||
db, subscription_id=subscription_id, request_time=checked_at
|
||||
)
|
||||
await db.commit()
|
||||
expired_subscriptions += int(changed)
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
record_failure("subscription_expire", subscription_id, exc)
|
||||
|
||||
expired = 0
|
||||
for user_id, user_limit in expired_user_limits:
|
||||
async with async_session() as db:
|
||||
try:
|
||||
changed = await archive_expired_user_balances(
|
||||
db, user_id=user_id, request_time=checked_at, limit=user_limit
|
||||
)
|
||||
await db.commit()
|
||||
expired += int(changed)
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
record_failure("credit_expire", user_id, exc)
|
||||
|
||||
result = {
|
||||
"checked_at": checked_at.isoformat(),
|
||||
"subscription_periods_granted": granted,
|
||||
"subscriptions_expired": expired_subscriptions,
|
||||
"credit_balances_archived": expired,
|
||||
"failed_count": len(errors),
|
||||
"errors": errors[:50],
|
||||
}
|
||||
log_operation_event(
|
||||
domain="billing",
|
||||
module="credit_maintenance",
|
||||
event_type="CREDIT_MAINTENANCE_COMPLETED",
|
||||
event_status="failed" if errors else "success",
|
||||
source="app.tasks.credit_tasks._run_credit_maintenance_once",
|
||||
message="积分维护批次完成",
|
||||
detail=result,
|
||||
error=(f"{len(errors)} 条处理失败" if errors else None),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
if celery_app:
|
||||
|
||||
@celery_app.task(
|
||||
name="credit.maintenance_once",
|
||||
bind=True,
|
||||
soft_time_limit=540,
|
||||
time_limit=600,
|
||||
ignore_result=True,
|
||||
)
|
||||
def credit_maintenance_once(self, batch_size: int = 500) -> dict[str, Any]:
|
||||
return run_async(_run_credit_maintenance_once(batch_size))
|
||||
|
||||
else:
|
||||
|
||||
class _DisabledTask:
|
||||
def delay(self, *args: Any, **kwargs: Any) -> None:
|
||||
raise RuntimeError("Celery is disabled")
|
||||
|
||||
def apply_async(self, *args: Any, **kwargs: Any) -> None:
|
||||
raise RuntimeError("Celery is disabled")
|
||||
|
||||
credit_maintenance_once = _DisabledTask()
|
||||
@@ -9,7 +9,6 @@ from sqlalchemy import select, update
|
||||
|
||||
from app.config import settings
|
||||
from app.enums.credit_record import CreditRecordBillingScene, CreditRecordChargeKind, CreditRecordOwnerType, CreditRecordSourceModule, CreditRecordSourceStepCode
|
||||
from app.enums.llm_billing import LlmBillingConfigKey
|
||||
from app.enums.celery_queue import CeleryQueue, CeleryTaskName
|
||||
from app.enums.celery_runtime import CeleryRuntimeDomain
|
||||
from app.enums.shot_replicate import (
|
||||
@@ -40,19 +39,19 @@ from app.services.shot_replicate_taskset_service import (
|
||||
from app.services.shot_video_analysis_service import analyze_video_for_shot_split
|
||||
from app.services.llm_billing import (
|
||||
LlmBillingContext,
|
||||
ensure_hold_exists,
|
||||
ensure_pre_deducted,
|
||||
log_provider_failure,
|
||||
log_provider_start,
|
||||
log_provider_success,
|
||||
release_on_failure,
|
||||
settle_success,
|
||||
mark_business_success,
|
||||
record_provider_exception,
|
||||
finalize_llm_business_failure,
|
||||
)
|
||||
from app.services.shot_video_split_service import cleanup_split_result, finalize_split_result, split_video_segment_async
|
||||
from app.services.upload_video_asset_service import validate_split_range
|
||||
from app.services.upload_resource import record_shot_segment_upload_resource
|
||||
from app.tasks.async_runner import run_async
|
||||
from app.tasks.celery_app import celery_app
|
||||
from app.utils.exceptions import InsufficientCreditsError
|
||||
|
||||
logger = logging.getLogger("video_gen")
|
||||
|
||||
@@ -170,7 +169,7 @@ async def _persist_original_analysis_result(
|
||||
billing_context: LlmBillingContext,
|
||||
allow_business_write: bool,
|
||||
) -> str:
|
||||
"""持久化原视频分析结果并完成账务;供应商成功后禁止再次调用模型。"""
|
||||
"""持久化原视频分析结果;失效结果按最终失败退款,禁止错误结算成功。"""
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(ShotReplicateTaskSet)
|
||||
@@ -190,7 +189,15 @@ async def _persist_original_analysis_result(
|
||||
and str(task_set.video_url) == str(video_url)
|
||||
and task_set.analysis_status == ShotAnalysisStatusEnum.PROCESSING.value
|
||||
)
|
||||
if is_current and task_set is not None:
|
||||
if not is_current or task_set is None:
|
||||
await db.rollback()
|
||||
await log_provider_failure(db, billing_context, error="原视频分析业务 attempt 已失效,供应商结果被丢弃")
|
||||
await finalize_llm_business_failure(
|
||||
billing_context,
|
||||
error="原视频分析业务 attempt 已失效,供应商结果被丢弃",
|
||||
)
|
||||
return "stale_refunded"
|
||||
|
||||
result_json = analyzed.result
|
||||
task_set.original_video_content = str(result_json.get("原视频内容") or "无")
|
||||
task_set.original_video_category = str(result_json.get("原视频分类") or "无")
|
||||
@@ -203,19 +210,14 @@ async def _persist_original_analysis_result(
|
||||
task_set.analysis_claim_token = None
|
||||
task_set.analysis_lease_until = None
|
||||
task_set.analysis_error_message = None
|
||||
description = "拆镜复刻-原视频分析"
|
||||
outcome = "completed"
|
||||
else:
|
||||
description = "拆镜复刻-原视频分析(失效结果结算)"
|
||||
outcome = "stale_settled"
|
||||
await settle_success(
|
||||
await mark_business_success(
|
||||
db,
|
||||
billing_context,
|
||||
usage=analyzed.usage,
|
||||
description=description,
|
||||
description="拆镜复刻-原视频分析",
|
||||
)
|
||||
await db.commit()
|
||||
return outcome
|
||||
return "completed"
|
||||
|
||||
|
||||
async def _persist_segment_analysis_result(
|
||||
@@ -228,7 +230,7 @@ async def _persist_segment_analysis_result(
|
||||
billing_context: LlmBillingContext,
|
||||
allow_business_write: bool,
|
||||
) -> str:
|
||||
"""持久化自定义切片分析结果并完成账务;供应商成功后禁止再次调用模型。"""
|
||||
"""持久化切片分析结果;失效结果按最终失败退款,禁止错误结算成功。"""
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(ShotReplicateSegment)
|
||||
@@ -248,7 +250,15 @@ async def _persist_segment_analysis_result(
|
||||
and str(segment.segment_video_url) == str(video_url)
|
||||
and segment.analysis_status == ShotSegmentAnalysisStatusEnum.PROCESSING.value
|
||||
)
|
||||
if is_current and segment is not None:
|
||||
if not is_current or segment is None:
|
||||
await db.rollback()
|
||||
await log_provider_failure(db, billing_context, error="片段视频分析业务 attempt 已失效,供应商结果被丢弃")
|
||||
await finalize_llm_business_failure(
|
||||
billing_context,
|
||||
error="片段视频分析业务 attempt 已失效,供应商结果被丢弃",
|
||||
)
|
||||
return "stale_refunded"
|
||||
|
||||
result_json = analyzed.result
|
||||
segment.original_video_content = str(result_json.get("原视频内容") or "无")
|
||||
segment.original_video_category = str(result_json.get("原视频分类") or "无")
|
||||
@@ -261,90 +271,81 @@ async def _persist_segment_analysis_result(
|
||||
segment.analysis_claim_token = None
|
||||
segment.analysis_lease_until = None
|
||||
segment.analysis_error_message = None
|
||||
description = "拆镜复刻-片段视频分析"
|
||||
outcome = "completed"
|
||||
else:
|
||||
description = "拆镜复刻-片段视频分析(失效结果结算)"
|
||||
outcome = "stale_settled"
|
||||
await settle_success(
|
||||
await mark_business_success(
|
||||
db,
|
||||
billing_context,
|
||||
usage=analyzed.usage,
|
||||
description=description,
|
||||
description="拆镜复刻-片段视频分析",
|
||||
)
|
||||
await db.commit()
|
||||
return outcome
|
||||
return "completed"
|
||||
|
||||
|
||||
async def _mark_original_provider_success_pending_manual(
|
||||
async def _finalize_original_analysis_failure(
|
||||
*,
|
||||
task_set_id: str,
|
||||
attempt_no: int,
|
||||
token: str,
|
||||
error_message: str,
|
||||
) -> bool:
|
||||
"""供应商已成功但本地结算失败:终止自动恢复,保留 HOLD 等待人工对账。"""
|
||||
billing_context: LlmBillingContext,
|
||||
error: str,
|
||||
) -> None:
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(ShotReplicateTaskSet)
|
||||
.where(
|
||||
ShotReplicateTaskSet.id == task_set_id,
|
||||
ShotReplicateTaskSet.deleted_at.is_(None),
|
||||
)
|
||||
.where(ShotReplicateTaskSet.id == task_set_id, ShotReplicateTaskSet.deleted_at.is_(None))
|
||||
.with_for_update()
|
||||
.limit(1)
|
||||
)
|
||||
task_set = result.scalar_one_or_none()
|
||||
if not (
|
||||
if (
|
||||
task_set
|
||||
and int(task_set.analysis_attempt_no or 1) == attempt_no
|
||||
and task_set.analysis_claim_token == token
|
||||
and task_set.analysis_status == ShotAnalysisStatusEnum.PROCESSING.value
|
||||
):
|
||||
await db.rollback()
|
||||
return False
|
||||
task_set.status = ShotTaskSetStatusEnum.ANALYSIS_FAILED.value
|
||||
task_set.analysis_status = ShotAnalysisStatusEnum.FAILED.value
|
||||
task_set.analysis_claim_token = None
|
||||
task_set.analysis_lease_until = None
|
||||
task_set.analysis_error_message = error_message
|
||||
task_set.analysis_error_message = str(error)[:1000]
|
||||
await db.commit()
|
||||
return True
|
||||
await finalize_llm_business_failure(billing_context, error=error)
|
||||
|
||||
|
||||
async def _mark_segment_provider_success_pending_manual(
|
||||
async def _finalize_segment_analysis_failure(
|
||||
*,
|
||||
segment_id: str,
|
||||
attempt_no: int,
|
||||
token: str,
|
||||
error_message: str,
|
||||
) -> bool:
|
||||
"""供应商已成功但本地结算失败:终止自动恢复,保留 HOLD 等待人工对账。"""
|
||||
billing_context: LlmBillingContext,
|
||||
error: str,
|
||||
) -> tuple[str | None, str | None]:
|
||||
user_id: str | None = None
|
||||
task_set_id: str | None = None
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(ShotReplicateSegment)
|
||||
.where(
|
||||
ShotReplicateSegment.id == segment_id,
|
||||
ShotReplicateSegment.deleted_at.is_(None),
|
||||
)
|
||||
.where(ShotReplicateSegment.id == segment_id, ShotReplicateSegment.deleted_at.is_(None))
|
||||
.with_for_update()
|
||||
.limit(1)
|
||||
)
|
||||
segment = result.scalar_one_or_none()
|
||||
if not (
|
||||
if segment:
|
||||
user_id = str(segment.user_id)
|
||||
task_set_id = str(segment.task_set_id)
|
||||
if (
|
||||
segment
|
||||
and int(segment.analysis_attempt_no or 1) == attempt_no
|
||||
and segment.analysis_claim_token == token
|
||||
and segment.analysis_status == ShotSegmentAnalysisStatusEnum.PROCESSING.value
|
||||
):
|
||||
await db.rollback()
|
||||
return False
|
||||
segment.analysis_status = ShotSegmentAnalysisStatusEnum.FAILED.value
|
||||
segment.analysis_claim_token = None
|
||||
segment.analysis_lease_until = None
|
||||
segment.analysis_error_message = error_message
|
||||
segment.analysis_error_message = str(error)[:1000]
|
||||
await db.commit()
|
||||
return True
|
||||
await finalize_llm_business_failure(billing_context, error=error)
|
||||
return user_id, task_set_id
|
||||
|
||||
|
||||
async def _run_analyze_original_video(task_set_id: str, expected_attempt_no: int | None) -> None:
|
||||
@@ -470,13 +471,12 @@ async def _run_analyze_original_video(task_set_id: str, expected_attempt_no: int
|
||||
source_step_id=task_set_id,
|
||||
source_step_code=CreditRecordSourceStepCode.VIDEO_ANALYSIS.value,
|
||||
related_id=task_set_id,
|
||||
hold_config_key=LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value,
|
||||
description_prefix="拆镜复刻原视频分析",
|
||||
trace_id=f"shot-task-set-analysis:{task_set_id}:attempt:{attempt_no}",
|
||||
)
|
||||
hold_validation = await ensure_hold_exists(db, llm_billing_context)
|
||||
if not hold_validation.can_execute:
|
||||
error_message = f"LLM账务状态异常({hold_validation.state.value}),已终止原视频分析任务"
|
||||
pre_deduct_validation = await ensure_pre_deducted(db, llm_billing_context)
|
||||
if not pre_deduct_validation.can_execute:
|
||||
error_message = f"LLM账务状态异常({pre_deduct_validation.state.value}),已终止原视频分析任务"
|
||||
task_set.status = ShotTaskSetStatusEnum.ANALYSIS_FAILED.value
|
||||
task_set.analysis_status = ShotAnalysisStatusEnum.FAILED.value
|
||||
task_set.analysis_claim_token = None
|
||||
@@ -511,7 +511,7 @@ async def _run_analyze_original_video(task_set_id: str, expected_attempt_no: int
|
||||
|
||||
provider_succeeded = False
|
||||
analyzed = None
|
||||
log_provider_start(
|
||||
await log_provider_start(db,
|
||||
llm_billing_context,
|
||||
detail={"analysis_mode": "full_breakdown", "video_url": video_url},
|
||||
)
|
||||
@@ -523,9 +523,11 @@ async def _run_analyze_original_video(task_set_id: str, expected_attempt_no: int
|
||||
mode="full_breakdown",
|
||||
task_set_id=task_set_id,
|
||||
trace_id=f"shot-task-set-analysis:{task_set_id}:attempt:{attempt_no}",
|
||||
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=analyzed.usage)
|
||||
await log_provider_success(db, llm_billing_context, usage=analyzed.usage)
|
||||
try:
|
||||
await lease.ensure_owned()
|
||||
allow_business_write = True
|
||||
@@ -566,8 +568,11 @@ async def _run_analyze_original_video(task_set_id: str, expected_attempt_no: int
|
||||
except RedisExecutionLockError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
if "llm_billing_context" in locals() and not locals().get("provider_succeeded", False):
|
||||
log_provider_failure(llm_billing_context, error=str(exc))
|
||||
if "llm_billing_context" in locals():
|
||||
if locals().get("provider_succeeded", False):
|
||||
await log_provider_failure(db, llm_billing_context, error=str(exc))
|
||||
else:
|
||||
provider_succeeded, _usage = await record_provider_exception(db, llm_billing_context, exc)
|
||||
if "llm_billing_context" in locals() and locals().get("provider_succeeded", False) and locals().get("analyzed") is not None:
|
||||
try:
|
||||
try:
|
||||
@@ -595,40 +600,16 @@ async def _run_analyze_original_video(task_set_id: str, expected_attempt_no: int
|
||||
)
|
||||
return
|
||||
except Exception as settlement_exc:
|
||||
manual_error = (
|
||||
"供应商已成功,但本地结果保存或积分结算失败;已终止自动恢复并保留冻结积分,"
|
||||
f"需人工对账。error={settlement_exc}"
|
||||
)
|
||||
await _mark_original_provider_success_pending_manual(
|
||||
await log_provider_failure(db, llm_billing_context, error=str(settlement_exc))
|
||||
exc = settlement_exc
|
||||
if "llm_billing_context" in locals():
|
||||
await _finalize_original_analysis_failure(
|
||||
task_set_id=task_set_id,
|
||||
attempt_no=attempt_no,
|
||||
token=token,
|
||||
error_message=manual_error,
|
||||
billing_context=llm_billing_context,
|
||||
error=str(exc),
|
||||
)
|
||||
exc = settlement_exc
|
||||
elif "llm_billing_context" in locals():
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(ShotReplicateTaskSet)
|
||||
.where(ShotReplicateTaskSet.id == task_set_id, ShotReplicateTaskSet.deleted_at.is_(None))
|
||||
.with_for_update()
|
||||
.limit(1)
|
||||
)
|
||||
task_set = result.scalar_one_or_none()
|
||||
if (
|
||||
task_set
|
||||
and int(task_set.analysis_attempt_no or 1) == attempt_no
|
||||
and task_set.analysis_claim_token == token
|
||||
and task_set.analysis_status == ShotAnalysisStatusEnum.PROCESSING.value
|
||||
):
|
||||
task_set_user_id = task_set_user_id or str(task_set.user_id)
|
||||
task_set.status = ShotTaskSetStatusEnum.ANALYSIS_FAILED.value
|
||||
task_set.analysis_status = ShotAnalysisStatusEnum.FAILED.value
|
||||
task_set.analysis_claim_token = None
|
||||
task_set.analysis_lease_until = None
|
||||
task_set.analysis_error_message = str(exc)
|
||||
await release_on_failure(db, llm_billing_context, error=str(exc))
|
||||
await db.commit()
|
||||
log_module_error(
|
||||
module=MODULE,
|
||||
event_type=ShotReplicateLogEventEnum.ANALYSIS_FAILED.value,
|
||||
@@ -762,13 +743,12 @@ async def _run_analyze_custom_segment_video(segment_id: str, expected_attempt_no
|
||||
source_step_id=segment_id,
|
||||
source_step_code=CreditRecordSourceStepCode.VIDEO_ANALYSIS.value,
|
||||
related_id=segment_id,
|
||||
hold_config_key=LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value,
|
||||
description_prefix="拆镜复刻片段视频分析",
|
||||
trace_id=f"shot-segment-analysis:{segment_id}:attempt:{attempt_no}",
|
||||
)
|
||||
hold_validation = await ensure_hold_exists(db, llm_billing_context)
|
||||
if not hold_validation.can_execute:
|
||||
error_message = f"LLM账务状态异常({hold_validation.state.value}),已终止片段视频分析任务"
|
||||
pre_deduct_validation = await ensure_pre_deducted(db, llm_billing_context)
|
||||
if not pre_deduct_validation.can_execute:
|
||||
error_message = f"LLM账务状态异常({pre_deduct_validation.state.value}),已终止片段视频分析任务"
|
||||
segment.analysis_status = ShotSegmentAnalysisStatusEnum.FAILED.value
|
||||
segment.analysis_claim_token = None
|
||||
segment.analysis_lease_until = None
|
||||
@@ -798,7 +778,7 @@ async def _run_analyze_custom_segment_video(segment_id: str, expected_attempt_no
|
||||
|
||||
provider_succeeded = False
|
||||
analyzed = None
|
||||
log_provider_start(
|
||||
await log_provider_start(db,
|
||||
llm_billing_context,
|
||||
detail={"analysis_mode": "summary_only", "video_url": video_url},
|
||||
)
|
||||
@@ -811,9 +791,11 @@ async def _run_analyze_custom_segment_video(segment_id: str, expected_attempt_no
|
||||
task_set_id=task_set_id,
|
||||
segment_id=segment_id,
|
||||
trace_id=f"shot-segment-analysis:{segment_id}:attempt:{attempt_no}",
|
||||
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=analyzed.usage)
|
||||
await log_provider_success(db, llm_billing_context, usage=analyzed.usage)
|
||||
try:
|
||||
await lease.ensure_owned()
|
||||
allow_business_write = True
|
||||
@@ -854,8 +836,11 @@ async def _run_analyze_custom_segment_video(segment_id: str, expected_attempt_no
|
||||
except RedisExecutionLockError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
if "llm_billing_context" in locals() and not locals().get("provider_succeeded", False):
|
||||
log_provider_failure(llm_billing_context, error=str(exc))
|
||||
if "llm_billing_context" in locals():
|
||||
if locals().get("provider_succeeded", False):
|
||||
await log_provider_failure(db, llm_billing_context, error=str(exc))
|
||||
else:
|
||||
provider_succeeded, _usage = await record_provider_exception(db, llm_billing_context, exc)
|
||||
if "llm_billing_context" in locals() and locals().get("provider_succeeded", False) and locals().get("analyzed") is not None:
|
||||
try:
|
||||
try:
|
||||
@@ -884,40 +869,18 @@ async def _run_analyze_custom_segment_video(segment_id: str, expected_attempt_no
|
||||
)
|
||||
return
|
||||
except Exception as settlement_exc:
|
||||
manual_error = (
|
||||
"供应商已成功,但本地结果保存或积分结算失败;已终止自动恢复并保留冻结积分,"
|
||||
f"需人工对账。error={settlement_exc}"
|
||||
)
|
||||
await _mark_segment_provider_success_pending_manual(
|
||||
await log_provider_failure(db, llm_billing_context, error=str(settlement_exc))
|
||||
exc = settlement_exc
|
||||
if "llm_billing_context" in locals():
|
||||
found_user_id, found_task_set_id = await _finalize_segment_analysis_failure(
|
||||
segment_id=segment_id,
|
||||
attempt_no=attempt_no,
|
||||
token=token,
|
||||
error_message=manual_error,
|
||||
billing_context=llm_billing_context,
|
||||
error=str(exc),
|
||||
)
|
||||
exc = settlement_exc
|
||||
elif "llm_billing_context" in locals():
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(ShotReplicateSegment)
|
||||
.where(ShotReplicateSegment.id == segment_id, ShotReplicateSegment.deleted_at.is_(None))
|
||||
.with_for_update()
|
||||
.limit(1)
|
||||
)
|
||||
segment = result.scalar_one_or_none()
|
||||
if (
|
||||
segment
|
||||
and int(segment.analysis_attempt_no or 1) == attempt_no
|
||||
and segment.analysis_claim_token == token
|
||||
and segment.analysis_status == ShotSegmentAnalysisStatusEnum.PROCESSING.value
|
||||
):
|
||||
user_id = user_id or str(segment.user_id)
|
||||
task_set_id = task_set_id or str(segment.task_set_id)
|
||||
segment.analysis_status = ShotSegmentAnalysisStatusEnum.FAILED.value
|
||||
segment.analysis_claim_token = None
|
||||
segment.analysis_lease_until = None
|
||||
segment.analysis_error_message = str(exc)
|
||||
await release_on_failure(db, llm_billing_context, error=str(exc))
|
||||
await db.commit()
|
||||
user_id = user_id or found_user_id
|
||||
task_set_id = task_set_id or found_task_set_id
|
||||
log_module_error(
|
||||
module=MODULE,
|
||||
event_type=ShotReplicateLogEventEnum.SEGMENT_ANALYSIS_FAILED.value,
|
||||
@@ -984,20 +947,17 @@ async def _mark_auto_segment_analysis_dispatch_failed(
|
||||
segment.analysis_started_at = 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,
|
||||
)
|
||||
billing_context = build_segment_analysis_billing_context(segment)
|
||||
await db.commit()
|
||||
|
||||
await finalize_llm_business_failure(billing_context, error=error_message)
|
||||
log_module_error(
|
||||
module=MODULE,
|
||||
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
|
||||
project_id=task_set_id,
|
||||
step_id=segment_id,
|
||||
user_id=user_id,
|
||||
message="自定义切片分析任务投递失败,已标记分析失败并释放冻结积分",
|
||||
message="自定义切片分析任务投递失败,已标记分析失败并退回固定预扣积分",
|
||||
detail={
|
||||
"segment_id": segment_id,
|
||||
"task_set_id": task_set_id,
|
||||
@@ -1256,7 +1216,7 @@ async def _run_split_one_segment(segment_id: str) -> None:
|
||||
segment.split_status = ShotSplitStatusEnum.FAILED.value
|
||||
segment.split_next_retry_at = None
|
||||
final_failed = True
|
||||
# 切片最终失败不释放片段分析 HOLD;手动切片重试继续沿用
|
||||
# 切片最终失败不退回片段分析固定预扣;手动切片重试继续沿用
|
||||
# 原 attempt。用户最终删除片段/任务集时再做取消补偿。
|
||||
else:
|
||||
segment.split_status = ShotSplitStatusEnum.RETRY_WAITING.value
|
||||
|
||||
@@ -280,10 +280,29 @@ export async function retryGeneration(recordId: string): Promise<GenerationRecor
|
||||
return api.post<GenerationRecord>(`/generation-records/${recordId}/retry`);
|
||||
}
|
||||
// ── Credits ───────────────────────────────────────────────
|
||||
export async function getCredits(page = 1, pageSize = 20): Promise<{ credits: number; records: CreditRecord[]; total: number }> {
|
||||
export async function getCredits(page = 1, pageSize = 20): Promise<{ credits: number; availableCredits: number; nextExpiringCredits: number; nextExpiresAt?: string | null; nextLastUsableAt?: string | null; totalGranted: number; totalConsumed: number; totalRefunded: number; totalExpired: number; records: CreditRecord[]; total: number }> {
|
||||
if (USE_MOCK) {
|
||||
const data = await mock.mockGetCredits();
|
||||
return data;
|
||||
const records = data.records || [];
|
||||
const totalGranted = records
|
||||
.filter((record) => record.type === 'recharge' || record.type === 'refund')
|
||||
.reduce((sum, record) => sum + Math.max(0, Number(record.amount || 0)), 0);
|
||||
const totalConsumed = records
|
||||
.filter((record) => record.type === 'consume')
|
||||
.reduce((sum, record) => sum + Math.abs(Number(record.amount || 0)), 0);
|
||||
return {
|
||||
credits: data.credits,
|
||||
availableCredits: data.credits,
|
||||
nextExpiringCredits: 0,
|
||||
nextExpiresAt: null,
|
||||
nextLastUsableAt: null,
|
||||
totalGranted,
|
||||
totalConsumed,
|
||||
totalRefunded: 0,
|
||||
totalExpired: 0,
|
||||
records,
|
||||
total: data.total,
|
||||
};
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', String(page));
|
||||
@@ -301,7 +320,7 @@ export async function verifyCaptcha(captchaId: string, x: number): Promise<strin
|
||||
return res.token;
|
||||
}
|
||||
// ── Site Info ─────────────────────────────────────────────
|
||||
export async function getSiteInfo(): Promise<{ siteName: string; siteLogo: string; userAgreementPrivacyUrl: string; siteCopyright: string; operationManual: string; loginBgVideo: string; optimizeHoldCredits?: number }> {
|
||||
export async function getSiteInfo(): Promise<{ siteName: string; siteLogo: string; userAgreementPrivacyUrl: string; siteCopyright: string; operationManual: string; loginBgVideo: string }> {
|
||||
if (USE_MOCK) return { siteName: '智创', siteLogo: '', userAgreementPrivacyUrl: '', siteCopyright: '© 2026 智创 版权所有', operationManual: '', loginBgVideo: '' };
|
||||
return api.get('/auth/site-info', false);
|
||||
}
|
||||
@@ -1263,3 +1282,14 @@ export function getTeamCreditExportUrl(params: {
|
||||
const base = (import.meta as any).env?.VITE_API_BASE || 'http://localhost:8000';
|
||||
return `${base}/api/team/credit-records/export?${p.toString()}`;
|
||||
}
|
||||
|
||||
// ── Dynamic credit products ───────────────────────────────
|
||||
export async function getCreditProductCatalog(): Promise<import('../types').CreditProductCatalog> {
|
||||
return api.get('/credit-products/catalog');
|
||||
}
|
||||
|
||||
export async function getCreditBalances(page = 1, pageSize = 20, status?: string): Promise<any[]> {
|
||||
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
|
||||
if (status) params.set('status', status);
|
||||
return api.get(`/credits/balances?${params.toString()}`);
|
||||
}
|
||||
|
||||
@@ -207,10 +207,14 @@ export async function mockOptimizePrompt(
|
||||
await delay(1500);
|
||||
|
||||
const project = MOCK_PROJECTS.find((p) => p.id === projectId);
|
||||
const cost = Math.round(80 + params.prompt.length * 0.5 + (params.duration || 0) * 2);
|
||||
// Mock 与正式接口保持一致:提示词优化按场景固定预扣,积分不足直接拦截。
|
||||
const cost = 5;
|
||||
|
||||
if (currentUser) {
|
||||
currentUser.credits -= cost;
|
||||
if (currentUser.credits < cost) {
|
||||
throw new Error('积分不足');
|
||||
}
|
||||
currentUser.credits = Math.round((currentUser.credits - cost) * 100) / 100;
|
||||
}
|
||||
|
||||
const optimizedPromptMap: Record<string, string> = {
|
||||
@@ -356,7 +360,11 @@ export async function mockGetAdminUsers(search?: string): Promise<AdminUser[]> {
|
||||
export async function mockAdjustCredits(userId: string, amount: number, _description: string): Promise<void> {
|
||||
await delay(500);
|
||||
const user = MOCK_ADMIN_USERS.find(u => u.id === userId);
|
||||
if (user) user.credits += amount;
|
||||
if (user) {
|
||||
const nextCredits = Math.round((user.credits + amount) * 100) / 100;
|
||||
if (nextCredits < 0) throw new Error('有效积分不足');
|
||||
user.credits = nextCredits;
|
||||
}
|
||||
}
|
||||
|
||||
export async function mockToggleUserStatus(userId: string, isActive: boolean): Promise<void> {
|
||||
|
||||
@@ -65,7 +65,8 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuthStore } from '../../store/useAuthStore';
|
||||
import { getMenuConfigs, getRechargePackages, getPaymentMethods, createRechargeOrder, getPaymentOrder, cancelPaymentOrder, getSiteInfo, getUnreadCount, createContactRequest, getUser, changePassword, changeUsername } from '../../api';
|
||||
import { getMenuConfigs, getCreditProductCatalog, getPaymentMethods, createRechargeOrder, getPaymentOrder, cancelPaymentOrder, getSiteInfo, getUnreadCount, createContactRequest, getUser, changePassword, changeUsername } from '../../api';
|
||||
import type { CreditProduct, CreditProductCatalog } from '../../types';
|
||||
import NotificationPopup from '../NotificationPopup';
|
||||
import './AppLayout.css';
|
||||
import bg1 from '../../assets/bg1.png';
|
||||
@@ -319,79 +320,33 @@ const MEMBERSHIP_GRADIENTS = [
|
||||
{ gradient: 'linear-gradient(135deg, #5a67d8, #434190)', shadow: 'rgba(90,103,216,0.25)', icon: <BankFilled /> },
|
||||
];
|
||||
|
||||
const MEMBERSHIP_FEATURES = [
|
||||
'每日赠送积分 当日清零',
|
||||
'可充值更多积分',
|
||||
'基础加速通道',
|
||||
'作品去除水印',
|
||||
'可使用网页版和APP上全部AI功能',
|
||||
'高分辨率',
|
||||
'批量创作 更多同时生成任务',
|
||||
];
|
||||
|
||||
const MEMBERSHIP_PLANS = [
|
||||
{
|
||||
tier: '基础会员',
|
||||
desc: '开启创作之旅',
|
||||
monthlyPrice: 79,
|
||||
monthlyCredits: 1000,
|
||||
onlyMonthly: true,
|
||||
features: MEMBERSHIP_FEATURES.map((f) => f.replace('基础加速通道', '基础加速通道')),
|
||||
speedLabel: '基础加速通道',
|
||||
},
|
||||
{
|
||||
tier: '标准会员',
|
||||
desc: '畅享基础创作权益',
|
||||
monthlyPrice: 154,
|
||||
monthlyCredits: 2000,
|
||||
quarterlyPrice: 1029,
|
||||
quarterlyCredits: 5000,
|
||||
yearlyPrice: 7308,
|
||||
yearlyCredits: 10000,
|
||||
features: MEMBERSHIP_FEATURES,
|
||||
speedLabel: '标准加速通道',
|
||||
},
|
||||
{
|
||||
tier: '高级会员',
|
||||
desc: '解锁高级创作权益',
|
||||
monthlyPrice: 221,
|
||||
monthlyCredits: 3000,
|
||||
quarterlyPrice: 1197,
|
||||
quarterlyCredits: 6000,
|
||||
yearlyPrice: 10710,
|
||||
yearlyCredits: 15000,
|
||||
features: MEMBERSHIP_FEATURES,
|
||||
speedLabel: '高级加速通道',
|
||||
},
|
||||
{
|
||||
tier: '超级会员',
|
||||
desc: '释放无限创作生产力',
|
||||
monthlyPrice: 280,
|
||||
monthlyCredits: 4000,
|
||||
quarterlyPrice: 1367,
|
||||
quarterlyCredits: 7000,
|
||||
yearlyPrice: 13440,
|
||||
yearlyCredits: 20000,
|
||||
features: MEMBERSHIP_FEATURES,
|
||||
speedLabel: '高级加速通道',
|
||||
hot: true,
|
||||
},
|
||||
];
|
||||
|
||||
const AppLayout: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user, logout, refreshUser, setOptimizeHoldCredits } = useAuthStore();
|
||||
const { user, logout, refreshUser } = useAuthStore();
|
||||
const [pwdModalOpen, setPwdModalOpen] = useState(false);
|
||||
const [rechargeModalOpen, setRechargeModalOpen] = useState(false);
|
||||
const [contactModalOpen, setContactModalOpen] = useState(false);
|
||||
const [pwdForm] = Form.useForm();
|
||||
const [usernameForm] = Form.useForm();
|
||||
const [selectedPlan, setSelectedPlan] = useState<number | null>(null);
|
||||
const [selectedPlan, setSelectedPlan] = useState<string | null>(null);
|
||||
const [menuItems, setMenuItems] = useState<MenuConfig[]>([]);
|
||||
const [rechargeOptions, setRechargeOptions] = useState<any[]>([]);
|
||||
const [rechargeOptions, setRechargeOptions] = useState<CreditProduct[]>([]);
|
||||
const [subscriptionProducts, setSubscriptionProducts] = useState<CreditProduct[]>([]);
|
||||
const [currentSubscription, setCurrentSubscription] = useState<CreditProductCatalog['currentSubscription']>(null);
|
||||
const loadCreditCatalog = useCallback(async () => {
|
||||
try {
|
||||
const data = await getCreditProductCatalog();
|
||||
setSubscriptionProducts((data.subscriptionProducts || []).filter((product) => product.isActive));
|
||||
setRechargeOptions((data.creditAddons || []).filter((product) => product.isActive));
|
||||
setCurrentSubscription(data.currentSubscription || null);
|
||||
} catch {
|
||||
setSubscriptionProducts([]);
|
||||
setRechargeOptions([]);
|
||||
setCurrentSubscription(null);
|
||||
}
|
||||
}, []);
|
||||
const [creditsModalOpen, setCreditsModalOpen] = useState(false);
|
||||
const [selectedTierIndex, setSelectedTierIndex] = useState<number | null>(null);
|
||||
const [selectedPeriod, setSelectedPeriod] = useState<'monthly' | 'quarterly' | 'yearly'>('monthly');
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
@@ -483,10 +438,6 @@ const AppLayout: React.FC = () => {
|
||||
setOperationManualUrl(info.operationManual);
|
||||
}
|
||||
|
||||
if (info.optimizeHoldCredits !== undefined) {
|
||||
setOptimizeHoldCredits(info.optimizeHoldCredits);
|
||||
} else {
|
||||
}
|
||||
|
||||
localStorage.setItem('siteInfo', JSON.stringify({ siteName: name, siteLogo: logo }));
|
||||
}).catch((err) => {
|
||||
@@ -625,15 +576,13 @@ const AppLayout: React.FC = () => {
|
||||
const items = data.filter((m: any) => m.is_active !== false && m.isActive !== false);
|
||||
setMenuItems(items);
|
||||
}).catch(() => { });
|
||||
getRechargePackages().then(data => {
|
||||
setRechargeOptions(data.filter((p: any) => p.is_active !== false && p.isActive !== false));
|
||||
}).catch(() => { });
|
||||
loadCreditCatalog();
|
||||
getPaymentMethods().then(data => {
|
||||
setEnabledMethods(data);
|
||||
if (data.alipay) setPaymentMethod('alipay');
|
||||
else if (data.wechat) setPaymentMethod('wechat');
|
||||
}).catch(() => { });
|
||||
}, []);
|
||||
}, [loadCreditCatalog]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
@@ -817,6 +766,7 @@ const AppLayout: React.FC = () => {
|
||||
localStorage.removeItem(PENDING_ORDER_KEY);
|
||||
message.success('支付成功!积分已到账');
|
||||
useAuthStore.getState().refreshUser();
|
||||
loadCreditCatalog();
|
||||
setQrCodeModalOpen(false);
|
||||
setCurrentPaymentInfo(null);
|
||||
setSelectedPlan(null);
|
||||
@@ -849,7 +799,7 @@ const AppLayout: React.FC = () => {
|
||||
});
|
||||
}, 1000);
|
||||
countdownTimerRef.current = countdownTimer;
|
||||
}, [stopPolling]);
|
||||
}, [loadCreditCatalog, stopPolling]);
|
||||
|
||||
const renderMenuItem = (item: MenuConfig, depth: number = 0) => {
|
||||
const isActive = item.path === selectedKey;
|
||||
@@ -1432,328 +1382,84 @@ const AppLayout: React.FC = () => {
|
||||
title={null}
|
||||
placement="bottom"
|
||||
open={rechargeModalOpen}
|
||||
onClose={() => { setRechargeModalOpen(false); setSelectedPlan(null); setSelectedTierIndex(null); }}
|
||||
height="95vh"
|
||||
onClose={() => { setRechargeModalOpen(false); setSelectedPlan(null); }}
|
||||
height="92vh"
|
||||
className="recharge-drawer"
|
||||
styles={{
|
||||
header: { display: 'none' },
|
||||
body: { padding: '20px 24px 0', overflowY: 'auto', position: 'relative' },
|
||||
}}
|
||||
styles={{ header: { display: 'none' }, body: { padding: '24px', overflowY: 'auto' } }}
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px 0', borderTop: '1px solid #f0f0f0' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Space><WalletOutlined style={{ color: '#6366f1' }} /><Typography.Text>当前有效积分</Typography.Text><Typography.Text strong style={{ color: '#6366f1', fontSize: 18 }}>{user?.credits ?? 0}</Typography.Text></Space>
|
||||
<Space>
|
||||
<WalletOutlined style={{ color: '#6366f1' }} />
|
||||
<Typography.Text style={{ color: '#64748b' }}>当前积分余额</Typography.Text>
|
||||
<Typography.Text strong style={{ color: '#6366f1', fontSize: 18 }}>{user?.credits ?? 0}</Typography.Text>
|
||||
</Space>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<Button size="large" onClick={() => { setRechargeModalOpen(false); setSelectedPlan(null); setSelectedTierIndex(null); }} style={{ borderRadius: 10 }}>取消</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
disabled={!selectedPlan && selectedTierIndex === null}
|
||||
loading={paying}
|
||||
<Button onClick={() => { setRechargeModalOpen(false); setSelectedPlan(null); }}>取消</Button>
|
||||
<Button type="primary" disabled={!selectedPlan || (!enabledMethods.alipay && !enabledMethods.wechat)} loading={paying}
|
||||
onClick={async () => {
|
||||
if (selectedPlan) {
|
||||
const plan = rechargeOptions.find((opt: any) => opt.id === selectedPlan);
|
||||
if (!plan) return;
|
||||
const totalCredits = (plan.credits || 0) + (plan.bonus_credits || plan.bonusCredits || 0);
|
||||
const product = subscriptionProducts.find((item) => item.id === selectedPlan);
|
||||
if (!product) return;
|
||||
try {
|
||||
setPaying(true);
|
||||
const order = await createRechargeOrder(plan.id, paymentMethod);
|
||||
const order = await createRechargeOrder(product.id, paymentMethod);
|
||||
const qrCode = order.qrUrl || order.codeUrl || order.qr_code || order.code_url;
|
||||
const paymentInfo = { price: Number(order.amount ?? product.currentPrice ?? product.price ?? 0), credits: Number(product.monthlyGrantCredits || 0), qrCode, method: order.paymentMethod };
|
||||
if ((order.paymentMethod === 'alipay' || order.paymentMethod === 'wechat') && qrCode) {
|
||||
const paymentInfo = {
|
||||
price: plan.price,
|
||||
credits: totalCredits,
|
||||
qrCode: qrCode,
|
||||
method: order.paymentMethod,
|
||||
};
|
||||
setCurrentPaymentInfo(paymentInfo);
|
||||
setRechargeModalOpen(false);
|
||||
setQrCodeModalOpen(true);
|
||||
setCurrentPaymentInfo(paymentInfo); setRechargeModalOpen(false); setQrCodeModalOpen(true);
|
||||
currentOrderNoRef.current = order.orderNo;
|
||||
localStorage.setItem(PENDING_ORDER_KEY, JSON.stringify({
|
||||
orderNo: order.orderNo,
|
||||
price: plan.price,
|
||||
credits: totalCredits,
|
||||
qrCode: qrCode,
|
||||
method: order.paymentMethod,
|
||||
createdAt: order.createdAt || new Date().toISOString(),
|
||||
timeoutSeconds: 180,
|
||||
}));
|
||||
localStorage.setItem(PENDING_ORDER_KEY, JSON.stringify({ orderNo: order.orderNo, ...paymentInfo, createdAt: order.createdAt || new Date().toISOString(), timeoutSeconds: 180 }));
|
||||
startPolling(order.orderNo);
|
||||
} else {
|
||||
message.success('充值成功!积分已到账');
|
||||
useAuthStore.getState().refreshUser();
|
||||
setRechargeModalOpen(false);
|
||||
setSelectedPlan(null);
|
||||
message.success('订阅购买成功,首期积分已到账');
|
||||
await useAuthStore.getState().refreshUser();
|
||||
await loadCreditCatalog();
|
||||
setRechargeModalOpen(false); setSelectedPlan(null);
|
||||
}
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '创建订单失败,请重试');
|
||||
} finally {
|
||||
setPaying(false);
|
||||
}
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
borderRadius: 10, fontWeight: 600,
|
||||
background: selectedPlan ? 'linear-gradient(135deg, #6366f1, #8b5cf6)' : '#d1d5db',
|
||||
border: 'none', boxShadow: selectedPlan ? '0 8px 24px rgba(99,102,241,0.3)' : 'none',
|
||||
}}>
|
||||
确认充值
|
||||
</Button>
|
||||
} catch (err: any) { message.error(err?.message || '创建订阅订单失败'); }
|
||||
finally { setPaying(false); }
|
||||
}}>确认购买</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<CloseOutlined style={{ position: 'absolute', right: 0, top: 0, cursor: 'pointer', color: '#94a3b8' }} onClick={() => setRechargeModalOpen(false)} />
|
||||
<div style={{ textAlign: 'center', marginBottom: 20 }}>
|
||||
<Typography.Title level={3} style={{ marginBottom: 8 }}>订阅套餐</Typography.Title>
|
||||
<Typography.Text type="secondary">订阅积分按自然月逐月发放;有效订阅只能升级同周期更高等级套餐,不能提前续费。</Typography.Text>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<div style={{ position: 'absolute', top: 8, right: 16, zIndex: 10 }}>
|
||||
<CloseOutlined
|
||||
style={{ fontSize: 18, color: '#94a3b8', cursor: 'pointer', padding: 8, borderRadius: '50%', transition: 'color 0.2s' }}
|
||||
onClick={() => { setRechargeModalOpen(false); setSelectedPlan(null); setSelectedTierIndex(null); }}
|
||||
onMouseEnter={(e) => { (e.target as HTMLElement).style.color = '#6366f1'; }}
|
||||
onMouseLeave={(e) => { (e.target as HTMLElement).style.color = '#94a3b8'; }}
|
||||
/>
|
||||
{currentSubscription && (
|
||||
<div style={{ marginBottom: 20, padding: 16, borderRadius: 12, background: '#f8fafc', border: '1px solid #e2e8f0' }}>
|
||||
<Space direction="vertical" size={4}>
|
||||
<Typography.Text strong>当前订阅:{currentSubscription.tierCode || '订阅套餐'}({currentSubscription.billingCycle === 'monthly' ? '月' : currentSubscription.billingCycle === 'quarterly' ? '季' : '年'})</Typography.Text>
|
||||
<Typography.Text type="secondary">已发放 {currentSubscription.grantedCount}/{currentSubscription.grantCount} 期,每月 {Number(currentSubscription.monthlyGrantCredits || 0).toLocaleString()} 积分</Typography.Text>
|
||||
<Typography.Text type="secondary">订阅到期:{new Date(currentSubscription.expiresAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })}</Typography.Text>
|
||||
</Space>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', marginBottom: 20, marginTop: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0, fontSize: 20, fontWeight: 700, color: '#1a1a2e' }}>
|
||||
选择合适的计划,助力业务提升
|
||||
</Typography.Title>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 12, marginTop: 12 }}>
|
||||
<div style={{
|
||||
display: 'inline-flex', background: '#f5f5fa', borderRadius: 10, padding: 4,
|
||||
gap: 4,
|
||||
}}>
|
||||
{[
|
||||
{ key: 'yearly', label: '连续包年 8折' },
|
||||
{ key: 'quarterly', label: '连续包季 8折' },
|
||||
{ key: 'monthly', label: '连续包月' },
|
||||
].map((p) => (
|
||||
<div
|
||||
key={p.key}
|
||||
onClick={() => {
|
||||
const period = p.key as 'monthly' | 'quarterly' | 'yearly';
|
||||
setSelectedPeriod(period);
|
||||
if (period !== 'monthly' && selectedTierIndex !== null && MEMBERSHIP_PLANS[selectedTierIndex]?.onlyMonthly) {
|
||||
setSelectedTierIndex(null);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
padding: '6px 16px', borderRadius: 8, cursor: 'pointer', fontSize: 13, fontWeight: 500,
|
||||
background: selectedPeriod === p.key ? 'linear-gradient(135deg, #6366f1, #8b5cf6)' : 'transparent',
|
||||
color: selectedPeriod === p.key ? '#fff' : '#64748b',
|
||||
transition: 'all 0.2s', whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{p.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* <Tag
|
||||
style={{
|
||||
borderRadius: 8, fontSize: 12, padding: '4px 12px', cursor: 'pointer',
|
||||
background: '#f0f0ff', color: '#6366f1', border: '1px solid #e0e0ff',
|
||||
}}
|
||||
>
|
||||
API服务
|
||||
</Tag> */}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ position: 'absolute', top: 12, right: 48, zIndex: 10 }}>
|
||||
<div
|
||||
onClick={() => setCreditsModalOpen(true)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer',
|
||||
padding: '6px 14px', borderRadius: 20,
|
||||
background: 'linear-gradient(135deg, rgba(99,102,241,0.08), rgba(139,92,246,0.08))',
|
||||
border: '1px solid rgba(99,102,241,0.2)',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(99,102,241,0.15), rgba(139,92,246,0.15))';
|
||||
e.currentTarget.style.borderColor = 'rgba(99,102,241,0.4)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(99,102,241,0.08), rgba(139,92,246,0.08))';
|
||||
e.currentTarget.style.borderColor = 'rgba(99,102,241,0.2)';
|
||||
}}
|
||||
>
|
||||
<WalletOutlined style={{ color: '#6366f1', fontSize: 14 }} />
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: '#6366f1' }}>积分充值</span>
|
||||
{/* <Tag color="purple" style={{ marginLeft: 2, borderRadius: 6, fontSize: 10, margin: 0, padding: '0 6px', lineHeight: '18px' }}>8折</Tag> */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 20 }}>
|
||||
{MEMBERSHIP_PLANS
|
||||
.map((plan, idx) => ({ plan, idx }))
|
||||
.filter(({ plan }) => selectedPeriod === 'monthly' || !plan.onlyMonthly)
|
||||
.map(({ plan, idx }) => {
|
||||
const g = MEMBERSHIP_GRADIENTS[idx % MEMBERSHIP_GRADIENTS.length];
|
||||
const price = selectedPeriod === 'monthly' ? plan.monthlyPrice
|
||||
: selectedPeriod === 'quarterly' ? plan.quarterlyPrice : plan.yearlyPrice;
|
||||
const credits = selectedPeriod === 'monthly' ? plan.monthlyCredits
|
||||
: selectedPeriod === 'quarterly' ? plan.quarterlyCredits : plan.yearlyCredits;
|
||||
const creditsPer10 = Math.round((credits / price) * 10);
|
||||
const isSelected = selectedTierIndex === idx;
|
||||
const isHot = plan.hot;
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
onClick={() => setSelectedTierIndex(idx)}
|
||||
style={{
|
||||
flex: 1,
|
||||
borderRadius: 16,
|
||||
padding: '20px 16px 16px',
|
||||
background: isHot
|
||||
? 'linear-gradient(180deg, #f0edff 0%, #fafbff 30%)'
|
||||
: '#fafbff',
|
||||
border: isSelected
|
||||
? '2px solid #6366f1'
|
||||
: isHot
|
||||
? '2px solid #8b5cf6'
|
||||
: '1px solid #f0f0f5',
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
transition: 'all 0.2s',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'stretch',
|
||||
}}
|
||||
>
|
||||
{isHot && (
|
||||
<div style={{
|
||||
position: 'absolute', top: -10, right: 12,
|
||||
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', color: '#fff',
|
||||
fontSize: 11, padding: '3px 10px', borderRadius: 8, fontWeight: 600,
|
||||
}}>最热销</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<Typography.Text strong style={{ fontSize: 15, color: '#1a1a2e' }}>{plan.tier}</Typography.Text>
|
||||
<Typography.Text style={{ fontSize: 12, color: '#94a3b8' }}>{plan.desc}</Typography.Text>
|
||||
</div>
|
||||
<div style={{ marginTop: 8, marginBottom: 4 }}>
|
||||
<span style={{
|
||||
fontSize: 28, fontWeight: 800,
|
||||
background: g.gradient, WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent',
|
||||
}}>¥{price}</span>
|
||||
<span style={{ fontSize: 12, color: '#94a3b8', marginLeft: 4 }}>/月</span>
|
||||
</div>
|
||||
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', marginBottom: 8 }}>
|
||||
自动续订,可随时取消。
|
||||
</Typography.Text>
|
||||
<div style={{
|
||||
display: 'flex', gap: 8, marginBottom: 12, flexWrap: 'wrap',
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: 11, color: '#6366f1', background: 'rgba(99,102,241,0.08)',
|
||||
padding: '3px 8px', borderRadius: 6, fontWeight: 600,
|
||||
}}>
|
||||
每月 {credits.toLocaleString()} 积分
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 11, color: '#94a3b8', background: '#f5f5fa',
|
||||
padding: '3px 8px', borderRadius: 6,
|
||||
}}>
|
||||
¥10 = {creditsPer10} 积分
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
block
|
||||
size="small"
|
||||
onClick={(e) => { e.stopPropagation(); setSelectedTierIndex(idx); }}
|
||||
style={{
|
||||
borderRadius: 10, marginBottom: 12, fontWeight: 600, height: 36,
|
||||
background: isSelected
|
||||
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
|
||||
: isHot
|
||||
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
|
||||
: '#1a1a2e',
|
||||
border: 'none', color: '#fff', fontSize: 13,
|
||||
}}
|
||||
>
|
||||
订阅月卡{plan.tier}
|
||||
</Button>
|
||||
<div style={{
|
||||
borderTop: '1px solid #f0f0f5', paddingTop: 10,
|
||||
display: 'flex', flexDirection: 'column', gap: 6,
|
||||
}}>
|
||||
{plan.features.map((feature: string, fi: number) => (
|
||||
<div key={fi} style={{ display: 'flex', alignItems: 'flex-start', gap: 6 }}>
|
||||
<CheckCircleFilled style={{ fontSize: 12, color: '#6366f1', flexShrink: 0, marginTop: 2 }} />
|
||||
<Typography.Text style={{ fontSize: 11, color: '#64748b', lineHeight: 1.5 }}>{feature}</Typography.Text>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 6 }}>
|
||||
<CheckCircleFilled style={{ fontSize: 12, color: '#6366f1', flexShrink: 0, marginTop: 2 }} />
|
||||
<Typography.Text style={{ fontSize: 11, color: '#64748b', lineHeight: 1.5 }}>
|
||||
解锁抢先购
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{(!enabledMethods.alipay && !enabledMethods.wechat) ? (
|
||||
<div style={{ marginBottom: 16, padding: 16, background: '#fef2f2', borderRadius: 12, border: '1px solid #fecaca' }}>
|
||||
<Typography.Text style={{ color: '#dc2626', fontSize: 13 }}>
|
||||
⚠️ 暂无可用的支付方式,请联系管理员开启支付功能
|
||||
</Typography.Text>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Typography.Text style={{ color: '#64748b', fontSize: 13, marginBottom: 8, display: 'block' }}>选择支付方式</Typography.Text>
|
||||
<Radio.Group value={paymentMethod} onChange={(e) => setPaymentMethod(e.target.value)}
|
||||
style={{ display: 'flex', gap: 12 }}>
|
||||
{enabledMethods.alipay && (
|
||||
<Radio.Button value="alipay" style={{
|
||||
flex: 1, textAlign: 'center', borderRadius: 10, height: 44, lineHeight: '42px',
|
||||
borderColor: paymentMethod === 'alipay' ? '#1677ff' : undefined,
|
||||
color: paymentMethod === 'alipay' ? '#1677ff' : undefined,
|
||||
}}>
|
||||
<AlipayCircleOutlined style={{ fontSize: 16, marginRight: 6 }} />
|
||||
支付宝
|
||||
</Radio.Button>
|
||||
)}
|
||||
{enabledMethods.wechat && (
|
||||
<Radio.Button value="wechat" style={{
|
||||
flex: 1, textAlign: 'center', borderRadius: 10, height: 44, lineHeight: '42px',
|
||||
borderColor: paymentMethod === 'wechat' ? '#07c160' : undefined,
|
||||
color: paymentMethod === 'wechat' ? '#07c160' : undefined,
|
||||
}}>
|
||||
<WechatOutlined style={{ fontSize: 16, marginRight: 6 }} />
|
||||
微信支付
|
||||
</Radio.Button>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: 20 }}>
|
||||
<Radio.Group value={selectedPeriod} onChange={(e) => { setSelectedPeriod(e.target.value); setSelectedPlan(null); }} buttonStyle="solid">
|
||||
<Radio.Button value="monthly">月套餐</Radio.Button><Radio.Button value="quarterly">季套餐</Radio.Button><Radio.Button value="yearly">年套餐</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
background: 'rgba(99, 102, 241, 0.06)',
|
||||
borderRadius: 10,
|
||||
marginBottom: 16,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
}}>
|
||||
<InfoOutlined style={{ color: '#6366f1', fontSize: 14 }} />
|
||||
<Typography.Text style={{ color: '#64748b', fontSize: 13 }}>
|
||||
当前平台仅支持支付宝/微信扫码充值,如需转账支付请
|
||||
<Typography.Text
|
||||
style={{
|
||||
color: '#ff0000ff',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
onClick={() => { setRechargeModalOpen(false); setContactModalOpen(true); }}
|
||||
>联系我们</Typography.Text>
|
||||
</Typography.Text>
|
||||
{subscriptionProducts.filter((item) => item.billingCycle === selectedPeriod).length === 0 ? (
|
||||
<div style={{ padding: 48, textAlign: 'center', color: '#94a3b8' }}>暂无可订阅套餐,请联系管理员配置并上架套餐。</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: 16 }}>
|
||||
{subscriptionProducts.filter((item) => item.billingCycle === selectedPeriod).map((product, index) => {
|
||||
const selected = selectedPlan === product.id;
|
||||
const gradient = MEMBERSHIP_GRADIENTS[index % MEMBERSHIP_GRADIENTS.length];
|
||||
return <div key={product.id} onClick={() => product.canPurchase !== false && setSelectedPlan(product.id)} style={{ border: selected ? '2px solid #6366f1' : '1px solid #e5e7eb', borderRadius: 16, padding: 20, cursor: product.canPurchase === false ? 'not-allowed' : 'pointer', opacity: product.canPurchase === false ? .6 : 1, background: '#fff' }}>
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
<Space><div style={{ width: 36, height: 36, borderRadius: 10, background: gradient.gradient, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff' }}>{gradient.icon}</div><div><Typography.Text strong style={{ fontSize: 16 }}>{product.name}</Typography.Text><div><Typography.Text type="secondary">{product.description || product.tierCode}</Typography.Text></div></div></Space>
|
||||
<div><span style={{ fontSize: 28, fontWeight: 800, color: '#6366f1' }}>¥{product.currentPrice}</span><Tag style={{ marginLeft: 8 }}>{product.priceType === 'first_purchase' ? '首充价' : product.priceType === 'activity' ? '活动价' : product.canUpgrade ? '升级价' : '原价'}</Tag></div>
|
||||
{product.canUpgrade && Number(product.deductionAmount || 0) > 0 && <Typography.Text type="secondary">目标套餐价 ¥{product.targetPrice},已抵扣未生效月份 ¥{product.deductionAmount}</Typography.Text>}
|
||||
<Tag color="purple">每月发放 {Number(product.monthlyGrantCredits || 0).toLocaleString()} 积分,共 {product.grantCount} 次</Tag>
|
||||
{(product.features || []).map((feature) => <div key={feature}><CheckCircleFilled style={{ color: '#6366f1', marginRight: 6 }} />{feature}</div>)}
|
||||
{product.unavailableReason && <Typography.Text type="danger">{product.unavailableReason}</Typography.Text>}
|
||||
</Space>
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: 24 }}><Button type="link" icon={<WalletOutlined />} onClick={() => { setSelectedPlan(null); setRechargeModalOpen(false); setCreditsModalOpen(true); }}>单独购买积分增值包</Button></div>
|
||||
{(!enabledMethods.alipay && !enabledMethods.wechat) && <div style={{ marginTop: 16, padding: 12, background: '#fef2f2', color: '#dc2626', borderRadius: 8 }}>暂无可用支付方式</div>}
|
||||
<div style={{ marginTop: 16 }}><Radio.Group value={paymentMethod} onChange={(e) => setPaymentMethod(e.target.value)}>{enabledMethods.alipay && <Radio.Button value="alipay"><AlipayCircleOutlined /> 支付宝</Radio.Button>}{enabledMethods.wechat && <Radio.Button value="wechat"><WechatOutlined /> 微信支付</Radio.Button>}</Radio.Group></div>
|
||||
</div>
|
||||
</Drawer>
|
||||
|
||||
@@ -1770,14 +1476,14 @@ const AppLayout: React.FC = () => {
|
||||
onClick={async () => {
|
||||
const plan = rechargeOptions.find((opt: any) => opt.id === selectedPlan);
|
||||
if (!plan) return;
|
||||
const totalCredits = (plan.credits || 0) + (plan.bonus_credits || plan.bonusCredits || 0);
|
||||
const totalCredits = Number(plan.grantCredits || 0);
|
||||
try {
|
||||
setPaying(true);
|
||||
const order = await createRechargeOrder(plan.id, paymentMethod);
|
||||
const qrCode = order.qrUrl || order.codeUrl || order.qr_code || order.code_url;
|
||||
if ((order.paymentMethod === 'alipay' || order.paymentMethod === 'wechat') && qrCode) {
|
||||
const paymentInfo = {
|
||||
price: plan.price,
|
||||
price: Number(order.amount ?? plan.currentPrice ?? plan.price ?? 0),
|
||||
credits: totalCredits,
|
||||
qrCode: qrCode,
|
||||
method: order.paymentMethod,
|
||||
@@ -1789,7 +1495,7 @@ const AppLayout: React.FC = () => {
|
||||
|
||||
localStorage.setItem(PENDING_ORDER_KEY, JSON.stringify({
|
||||
orderNo: order.orderNo,
|
||||
price: plan.price,
|
||||
price: Number(order.amount ?? plan.currentPrice ?? plan.price ?? 0),
|
||||
credits: totalCredits,
|
||||
qrCode: qrCode,
|
||||
method: order.paymentMethod,
|
||||
@@ -1800,7 +1506,8 @@ const AppLayout: React.FC = () => {
|
||||
startPolling(order.orderNo);
|
||||
} else {
|
||||
message.success('充值成功!积分已到账');
|
||||
useAuthStore.getState().refreshUser();
|
||||
await useAuthStore.getState().refreshUser();
|
||||
await loadCreditCatalog();
|
||||
setCreditsModalOpen(false);
|
||||
setSelectedPlan(null);
|
||||
}
|
||||
@@ -1863,9 +1570,10 @@ const AppLayout: React.FC = () => {
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
{rechargeOptions.length === 0 && <div style={{ width: '100%', padding: 32, textAlign: 'center', color: '#94a3b8' }}>暂无可购买的积分增值包</div>}
|
||||
{rechargeOptions.map((opt, idx) => {
|
||||
const g = GRADIENTS[idx % GRADIENTS.length];
|
||||
const totalCredits = (opt.credits || 0) + (opt.bonus_credits || opt.bonusCredits || 0);
|
||||
const totalCredits = Number(opt.grantCredits || 0);
|
||||
return (
|
||||
<div key={opt.id} onClick={() => setSelectedPlan(opt.id)} style={{
|
||||
flex: '1 1 45%', minWidth: 180, borderRadius: 16, padding: '18px 14px',
|
||||
@@ -1890,7 +1598,7 @@ const AppLayout: React.FC = () => {
|
||||
<div style={{
|
||||
fontSize: 20, fontWeight: 800, marginTop: 2,
|
||||
background: g.gradient, WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent',
|
||||
}}>¥{opt.price}</div>
|
||||
}}>¥{opt.currentPrice || opt.price}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user