This commit is contained in:
2026-08-14 15:36:22 +08:00
76 changed files with 7529 additions and 5483 deletions
File diff suppressed because one or more lines are too long
+36 -36
View File
@@ -1,37 +1,37 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<title>后台管理</title>
<script>
(function() {
var cached = localStorage.getItem('siteInfo');
if (cached) {
try {
var info = JSON.parse(cached);
if (info.siteName) {
document.title = info.siteName + ' - 管理后台';
}
if (info.siteLogo) {
var link = document.querySelector('link[rel="icon"]');
if (link) {
link.href = info.siteLogo;
link.type = 'image/png';
}
}
} catch (e) {}
}
})();
</script>
<script type="module" crossorigin src="/assets/index-DIVmQY5H.js"></script>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<title>后台管理</title>
<script>
(function() {
var cached = localStorage.getItem('siteInfo');
if (cached) {
try {
var info = JSON.parse(cached);
if (info.siteName) {
document.title = info.siteName + ' - 管理后台';
}
if (info.siteLogo) {
var link = document.querySelector('link[rel="icon"]');
if (link) {
link.href = info.siteLogo;
link.type = 'image/png';
}
}
} catch (e) {}
}
})();
</script>
<script type="module" crossorigin src="/assets/index-CTeXmoRP.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D3fwIbOp.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
</head>
<body>
<div id="root"></div>
</body>
</html>
+36 -84
View File
@@ -209,6 +209,13 @@ export async function getTeamMembersForAdmin(teamId: string, page = 1, pageSize
return api.get(`/admin/users?${params.toString()}`);
}
export async function getAdminTeamDetail(teamId: string): Promise<AdminTeam> { return api.get(`/admin/teams/${teamId}`); }
export async function getAdminTeamSubscriptions(teamId: string): Promise<any[]> { return api.get(`/admin/teams/${teamId}/subscriptions`); }
export async function getAdminTeamMemberUsage(teamId: string, subscriptionId?: string): Promise<any[]> {
const q = subscriptionId ? `?subscription_id=${encodeURIComponent(subscriptionId)}` : ''; return api.get(`/admin/teams/${teamId}/member-usage${q}`);
}
export async function getAdminTeamManagerHistory(teamId: string): Promise<any[]> { return api.get(`/admin/teams/${teamId}/manager-history`); }
export async function adjustCredits(userId: string, amount: number, description: string): Promise<void> {
await api.post(`/admin/users/${userId}/credits`, { amount, description });
}
@@ -427,6 +434,7 @@ export async function getCreditRecords(filters?: AdminCreditRecordQueryParams):
setMaybe(params, 'user_type', filters?.userType);
setMaybe(params, 'frontend_user_kind', filters?.frontendUserKind);
setMaybe(params, 'team_id', filters?.teamId);
setMaybe(params, 'subscription_no', filters?.subscriptionNo);
setMaybe(params, 'record_type', filters?.recordType || filters?.type);
setMaybe(params, 'credit_subject', filters?.creditSubject);
setMaybe(params, 'media_type', filters?.mediaType);
@@ -632,42 +640,23 @@ export async function batchUpdatePaymentConfigs(configs: Record<string, string>)
await api.put('/admin/payment-configs/batch', configs);
}
export async function getPaymentStats(params?: {
paymentMethod?: string;
status?: string;
startDate?: string;
endDate?: string;
}): Promise<{
byStatus: Record<string, { count: number; amount: number }>;
today: { paidCount: number; paidAmount: number };
month: { paidCount: number; paidAmount: number };
recent: any[];
}> {
const searchParams = new URLSearchParams();
if (params?.paymentMethod) searchParams.set('payment_method', params.paymentMethod);
if (params?.status) searchParams.set('status', params.status);
if (params?.startDate) searchParams.set('start_date', params.startDate);
if (params?.endDate) searchParams.set('end_date', params.endDate);
const queryString = searchParams.toString();
const url = queryString ? `/admin/payment-stats?${queryString}` : '/admin/payment-stats';
return api.get(url);
export async function getPaymentStats(params?: { paymentMethod?: string; orderSource?: string; status?: string; startDate?: string; endDate?: string; }): Promise<any> {
const q = new URLSearchParams();
if (params?.paymentMethod) q.set('payment_method', params.paymentMethod); if (params?.orderSource) q.set('order_source', params.orderSource);
if (params?.status) q.set('status', params.status); if (params?.startDate) q.set('start_date', params.startDate); if (params?.endDate) q.set('end_date', params.endDate);
return api.get(`/admin/payment-stats${q.toString() ? `?${q.toString()}` : ''}`);
}
export async function getAdminPaymentOrders(params?: { method?: string; status?: string; phone?: string; startDate?: string; endDate?: string; page?: number; pageSize?: number }): Promise<{ items: any[]; total: number }> {
export async function getAdminPaymentOrders(params?: { method?: string; orderSource?: string; status?: string; phone?: string; startDate?: string; endDate?: string; page?: number; pageSize?: number }): Promise<{ items: any[]; total: number }> {
const qs = new URLSearchParams();
if (params?.method) qs.set('payment_method', params.method);
if (params?.status) qs.set('status', params.status);
if (params?.phone) qs.set('phone', params.phone);
if (params?.startDate) qs.set('start_date', params.startDate);
if (params?.endDate) qs.set('end_date', params.endDate);
if (params?.page) qs.set('page', String(params.page));
if (params?.pageSize) qs.set('page_size', String(params.pageSize));
if (params?.method) qs.set('payment_method', params.method); if (params?.orderSource) qs.set('order_source', params.orderSource); if (params?.status) qs.set('status', params.status);
if (params?.phone) qs.set('phone', params.phone); if (params?.startDate) qs.set('start_date', params.startDate); if (params?.endDate) qs.set('end_date', params.endDate);
if (params?.page) qs.set('page', String(params.page)); if (params?.pageSize) qs.set('page_size', String(params.pageSize));
return api.get(`/admin/payment-orders?${qs.toString()}`);
}
export async function refundPaymentOrder(orderNo: string): Promise<void> {
await api.post(`/admin/payment-orders/${orderNo}/refund`);
}
// 退款接口后端仍保留用于兼容旧调用,但当前版本固定返回“暂未开放订单退款”。
export async function refundPaymentOrder(orderNo: string): Promise<void> { await api.post(`/admin/payment-orders/${orderNo}/refund`); }
// ── Invoice Management ───────────────────────────────────
@@ -1371,63 +1360,26 @@ export async function adminUpdatePrivatePortraitConfig(userId: string, limit: nu
// ── 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,
};
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, isDeleted: raw.isDeleted === 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 getCreditProducts(productType?: 'subscription' | 'team_subscription' | 'credit_addon'): Promise<CreditProduct[]> {
const query = productType ? `?product_type=${encodeURIComponent(productType)}` : ''; return (await api.get<CreditProduct[]>(`/admin/credit-management/products${query}`)).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);
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 setCreditProductStatus(id: string, isActive: boolean): Promise<CreditProduct> { return normalizeCreditProduct(await api.put<CreditProduct>(`/admin/credit-management/products/${id}/status`, { is_active: isActive })); }
export async function softDeleteCreditProduct(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 getAdminUserSubscriptions(userId: string): Promise<any[]> { return api.get(`/admin/credit-management/users/${userId}/subscriptions`); }
export async function createAdminOfflineSubscription(userId: string, payload: { productId: string; quantity: number; paymentMethod: 'bank_transfer' | 'cash' | 'other'; actualPaidAmount?: number; offlineTradeNo?: string; offlinePaymentDetail?: string; remark?: string; }): Promise<any> {
return api.post(`/admin/credit-management/users/${userId}/offline-subscriptions`, { product_id: payload.productId, quantity: payload.quantity, payment_method: payload.paymentMethod, actual_paid_amount: payload.actualPaidAmount, offline_trade_no: payload.offlineTradeNo || null, offline_payment_detail: payload.offlinePaymentDetail || null, remark: payload.remark || null });
}
export async function adminGrantCredits(userId: string, payload: Record<string, unknown>): Promise<any> { return api.post(`/admin/credit-management/users/${userId}/grant`, payload); }
export async function adminDeductCredits(userId: string, payload: Record<string, unknown>): Promise<any> { return api.post(`/admin/credit-management/users/${userId}/deduct`, payload); }
// ── LLM 场景积分配置 ───────────────────────────────────────
export async function getLlmBillingPolicies(): Promise<import('../types').LlmBillingPolicy[]> {
+129 -57
View File
@@ -3,10 +3,17 @@ 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 {
CheckCircleOutlined, DeleteOutlined, EditOutlined, PlusOutlined, ShoppingOutlined, StopOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import {
createCreditProduct, disableCreditProduct, getCreditProducts, setCreditProductRenewal, updateCreditProduct,
createCreditProduct,
getCreditProducts,
setCreditProductRenewal,
setCreditProductStatus,
softDeleteCreditProduct,
updateCreditProduct,
} from '../api';
import type { CreditProduct, CreditProductType } from '../types';
@@ -17,6 +24,8 @@ const formatMoney = (value?: number | null): string => {
return Number.isFinite(amount) ? `¥${amount.toFixed(2)}` : '-';
};
const isSubscriptionType = (value: CreditProductType) => value === 'subscription' || value === 'team_subscription';
const AdminCreditProducts: React.FC = () => {
const [type, setType] = useState<CreditProductType>('subscription');
const [items, setItems] = useState<CreditProduct[]>([]);
@@ -85,7 +94,7 @@ const AdminCreditProducts: React.FC = () => {
sortOrder: 0,
billingCycle: 'monthly',
tierRank: 1,
renewalEnabled: nextRenewalEnabled,
renewalEnabled: true,
validityMonths: 1,
});
};
@@ -95,8 +104,6 @@ const AdminCreditProducts: React.FC = () => {
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 || '')
@@ -105,14 +112,23 @@ const AdminCreditProducts: React.FC = () => {
.filter(Boolean),
credit_level: 'general',
currency: 'CNY',
is_active: values.isActive ?? true,
sort_order: values.sortOrder ?? 0,
};
if (type === 'subscription') {
if (!editing) {
payload.product_code = values.productCode;
payload.product_type = type;
payload.is_active = values.isActive ?? true;
}
if (isSubscriptionType(type)) {
if (!editing) {
Object.assign(payload, {
tier_code: values.tierCode,
tier_rank: values.tierRank,
billing_cycle: values.billingCycle,
});
}
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,
@@ -129,11 +145,16 @@ const AdminCreditProducts: React.FC = () => {
validity_months: values.validityMonths,
});
}
const savedProduct = editing
let savedProduct = editing
? await updateCreditProduct(editing.id, payload)
: await createCreditProduct(payload);
if (type === 'subscription' && savedProduct.renewalEnabled !== renewalEnabled) {
if (editing && savedProduct.isActive !== (values.isActive === true)) {
savedProduct = await setCreditProductStatus(editing.id, values.isActive === true);
}
if (isSubscriptionType(type) && savedProduct.renewalEnabled !== renewalEnabled) {
throw new Error('续费开关保存结果与提交值不一致,请刷新后重试');
}
@@ -190,7 +211,27 @@ const AdminCreditProducts: React.FC = () => {
}
};
const columns = useMemo(() => type === 'subscription' ? [
const toggleStatus = async (row: CreditProduct) => {
try {
const saved = await setCreditProductStatus(row.id, !row.isActive);
replaceItem(saved);
message.success(saved.isActive ? '已上架' : '已下架');
} catch (error: any) {
message.error(error?.message || '商品状态更新失败');
}
};
const removeProduct = async (row: CreditProduct) => {
try {
await softDeleteCreditProduct(row.id);
message.success('商品已软删除,商品编码永久保留且不能恢复');
await load();
} catch (error: any) {
message.error(error?.message || '商品删除失败');
}
};
const columns = useMemo(() => isSubscriptionType(type) ? [
{
title: '套餐',
key: 'name',
@@ -234,36 +275,45 @@ const AdminCreditProducts: React.FC = () => {
{
title: '允许续费',
dataIndex: 'renewalEnabled',
render: (enabled: boolean, row: CreditProduct) => (
<Switch
render: (enabled: boolean, row: CreditProduct) => row.isDeleted
? <Typography.Text type="secondary">-</Typography.Text>
: <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>,
key: 'status',
render: (_: unknown, row: CreditProduct) => <Tag color={row.isDeleted ? 'default' : row.isActive ? 'green' : 'default'}>
{row.isDeleted ? '已删除' : row.isActive ? '上架' : '下架'}
</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>,
render: (_: unknown, row: CreditProduct) => row.isDeleted
? <Typography.Text type="secondary"></Typography.Text>
: <Space>
<Button type="link" icon={<EditOutlined />} onClick={() => openEditor(row)}></Button>
<Popconfirm
title={`确认${row.isActive ? '下架' : '重新上架'}该商品?`}
onConfirm={() => void toggleStatus(row)}
>
<Button type="link" danger={row.isActive} icon={row.isActive ? <StopOutlined /> : <CheckCircleOutlined />}>
{row.isActive ? '下架' : '上架'}
</Button>
</Popconfirm>
<Popconfirm
title="确认软删除该商品?"
description="软删除后不能恢复,商品编码永久保留且不能复用;历史订单和订阅不受影响。"
onConfirm={() => void removeProduct(row)}
>
<Button type="link" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>,
},
] : [
{
@@ -277,37 +327,53 @@ const AdminCreditProducts: React.FC = () => {
{ title: '积分', dataIndex: 'grantCredits', render: (value?: number) => Number(value || 0).toLocaleString() },
{ title: '价格', dataIndex: 'price', render: (value?: number | null) => formatMoney(value) },
{ title: '有效期', dataIndex: 'validityMonths', render: (value?: number | null) => `${Number(value || 1)} 个月` },
{ title: '状态', dataIndex: 'isActive', render: (enabled: boolean) => <Tag color={enabled ? 'green' : 'default'}>{enabled ? '上架' : '下架'}</Tag> },
{
title: '状态', key: 'status', render: (_: unknown, row: CreditProduct) => <Tag color={row.isDeleted ? 'default' : row.isActive ? 'green' : 'default'}>
{row.isDeleted ? '已删除' : row.isActive ? '上架' : '下架'}
</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>,
render: (_: unknown, row: CreditProduct) => row.isDeleted
? <Typography.Text type="secondary"></Typography.Text>
: <Space>
<Button type="link" icon={<EditOutlined />} onClick={() => openEditor(row)}></Button>
<Popconfirm
title={`确认${row.isActive ? '下架' : '重新上架'}该商品?`}
onConfirm={() => void toggleStatus(row)}
>
<Button type="link" danger={row.isActive} icon={row.isActive ? <StopOutlined /> : <CheckCircleOutlined />}>
{row.isActive ? '下架' : '上架'}
</Button>
</Popconfirm>
<Popconfirm
title="确认软删除该商品?"
description="软删除后不能恢复,商品编码永久保留且不能复用。"
onConfirm={() => void removeProduct(row)}
>
<Button type="link" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>,
},
], [type, items]);
], [type, items, editing]);
const createButtonLabel = type === 'subscription'
? '新增个人订阅套餐'
: type === 'team_subscription'
? '新增团队订阅套餐'
: '新增积分增值包';
return <Card
title={<Space><ShoppingOutlined /></Space>}
extra={<Button type="primary" icon={<PlusOutlined />} onClick={() => openEditor()}>
{type === 'subscription' ? '新增订阅套餐' : '新增积分增值包'}
</Button>}
extra={<Button type="primary" icon={<PlusOutlined />} onClick={() => openEditor()}>{createButtonLabel}</Button>}
>
<Tabs
activeKey={type}
onChange={(key) => setType(key as CreditProductType)}
items={[
{ key: 'subscription', label: '订阅套餐' },
{ key: 'subscription', label: '个人订阅套餐' },
{ key: 'team_subscription', label: '团队订阅套餐' },
{ key: 'credit_addon', label: '积分增值包' },
]}
/>
@@ -322,7 +388,12 @@ const AdminCreditProducts: React.FC = () => {
>
<Form form={form} layout="vertical">
<Space align="start" style={{ width: '100%' }} size={16}>
<Form.Item name="productCode" label="商品编码" rules={[{ required: true }]}>
<Form.Item
name="productCode"
label="商品编码"
rules={[{ required: true }]}
extra={editing ? '商品编码创建后永久不可修改。' : undefined}
>
<Input disabled={!!editing} />
</Form.Item>
<Form.Item name="name" label="商品名称" rules={[{ required: true }]}>
@@ -331,12 +402,13 @@ const AdminCreditProducts: React.FC = () => {
<Form.Item name="sortOrder" label="排序"><InputNumber /></Form.Item>
<Form.Item name="isActive" label="上架" valuePropName="checked"><Switch /></Form.Item>
</Space>
{type === 'subscription' ? <>
{isSubscriptionType(type) ? <>
<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="tierCode" label="套餐等级编码" rules={[{ required: true }]}><Input disabled={!!editing} /></Form.Item>
<Form.Item name="tierRank" label="等级顺序" rules={[{ required: true }]}><InputNumber min={1} disabled={!!editing} /></Form.Item>
<Form.Item name="billingCycle" label="订阅周期" rules={[{ required: true }]}>
<Select
disabled={!!editing}
style={{ width: 140 }}
options={[
{ value: 'monthly', label: '月' },
@@ -349,7 +421,7 @@ const AdminCreditProducts: React.FC = () => {
<InputNumber min={0.01} />
</Form.Item>
</Space>
<Space align="start" size={16}>
<Space align="start" size={16} wrap>
<Form.Item name="firstPurchasePrice" label="首充价格" rules={[{ required: true }]}>
<InputNumber min={0} precision={2} />
</Form.Item>
@@ -360,7 +432,7 @@ const AdminCreditProducts: React.FC = () => {
<Form.Item name="activityRange" label="活动周期"><DatePicker.RangePicker showTime /></Form.Item>
<Form.Item
label="允许续费"
tooltip="关闭后,已失去首订资格的用户不会在客户端看到该套餐,也不能通过接口续费或升级购买。"
tooltip="关闭后,已失去对应首购资格的用户不能再次购买该套餐;不影响已创建待付款订单、已持有订阅和季/年卡内部月度发放。"
>
<Switch
checked={renewalEnabled}
+107 -11
View File
@@ -8,7 +8,7 @@ import {
import { exportStyledExcel, type StyledExcelColumn } from '../utils/excelExport';
import dayjs from 'dayjs';
import { getCreditRecords, getTeamOptions } from '../api';
import type { AdminCreditRecord, AdminCreditRecordQueryParams, AdminCreditRecordSummary, AdminTeamOption } from '../types';
import type { AdminCreditRecord, AdminCreditRecordQueryParams, AdminCreditRecordSubscriptionUsage, AdminTeamOption, AdminCreditRecordSummary } from '../types';
import { formatDate } from '../utils/formatDate';
const TEAM_UNASSIGNED_VALUE = '__none__';
@@ -182,6 +182,44 @@ function buildScope(scope: string): Pick<AdminCreditRecordQueryParams, 'userType
return {};
}
function subscriptionTierText(item: AdminCreditRecordSubscriptionUsage): string {
const label = item.tierLabel || item.tierCode || '未知等级';
const code = item.tierCode && item.tierCode !== label ? `${item.tierCode}` : '';
const rank = item.tierRank !== undefined && item.tierRank !== null ? ` / ${item.tierRank}` : '';
return `${label}${code}${rank}`;
}
function subscriptionProductText(item: AdminCreditRecordSubscriptionUsage): string {
return [
item.productTypeLabel,
item.productName,
subscriptionTierText(item),
item.billingCycleLabel,
].filter(Boolean).join(' · ');
}
function subscriptionPeriodText(item: AdminCreditRecordSubscriptionUsage): string {
const range = item.periodValidFrom && item.periodExpiresAt
? `${formatDate(item.periodValidFrom)} ${formatDate(item.periodExpiresAt)}`
: '-';
return `${item.periodLabel || '周期未知'}${range}`;
}
function semanticAllocationText(record: AdminCreditRecord): string {
return (record.allocations || []).map((item) => {
const tier = item.tierLabel || item.tierCode
? `${item.tierLabel || item.tierCode}${item.tierCode && item.tierLabel !== item.tierCode ? `${item.tierCode}` : ''}${item.tierRank !== undefined && item.tierRank !== null ? ` / ${item.tierRank}` : ''}`
: '未知等级';
const subscription = item.subscriptionNo
? `${item.subscriptionNo}${[item.productTypeLabel, item.productName, tier, item.billingCycleLabel].filter(Boolean).join('/')}`
: '非订阅资金';
const period = item.periodLabel
? `${item.periodLabel}${item.periodValidFrom && item.periodExpiresAt ? ` ${formatDate(item.periodValidFrom)}${formatDate(item.periodExpiresAt)}` : ''}`
: '无月度周期';
return `${item.creditScopeLabel || '其他资金域'}${item.allocationActionLabel || '资金变动'}${subscription}${period}${n(item.amount)}积分`;
}).join('');
}
const AdminCreditRecords: React.FC = () => {
const [records, setRecords] = useState<AdminCreditRecord[]>([]);
const [summary, setSummary] = useState<AdminCreditRecordSummary>(DEFAULT_SUMMARY);
@@ -194,6 +232,7 @@ const AdminCreditRecords: React.FC = () => {
const [userScope, setUserScope] = useState('');
const [teamFilter, setTeamFilter] = useState('');
const [subscriptionNoFilter, setSubscriptionNoFilter] = useState('');
const [teamOptions, setTeamOptions] = useState<AdminTeamOption[]>([]);
const [recordType, setRecordType] = useState('');
const [creditSubject, setCreditSubject] = useState('');
@@ -211,6 +250,7 @@ const AdminCreditRecords: React.FC = () => {
pageSize,
userName: userNameFilter || undefined,
teamId: teamFilter || undefined,
subscriptionNo: subscriptionNoFilter || undefined,
recordType: recordType || undefined,
creditSubject: creditSubject || undefined,
mediaType: mediaType || undefined,
@@ -222,7 +262,7 @@ const AdminCreditRecords: React.FC = () => {
startDate: dateRange[0]?.format('YYYY-MM-DD'),
endDate: dateRange[1]?.format('YYYY-MM-DD'),
...buildScope(userScope),
}), [page, pageSize, userNameFilter, teamFilter, recordType, creditSubject, mediaType, chargeKind, chargeAction, sourceModule, sourceStepCode, billingScene, dateRange, userScope]);
}), [page, pageSize, userNameFilter, teamFilter, recordType, creditSubject, mediaType, chargeKind, chargeAction, sourceModule, sourceStepCode, billingScene, dateRange, userScope, subscriptionNoFilter]);
const load = async () => {
setLoading(true);
@@ -247,6 +287,7 @@ const AdminCreditRecords: React.FC = () => {
const handleReset = () => {
setUserScope('');
setTeamFilter('');
setSubscriptionNoFilter('');
setRecordType('');
setCreditSubject('');
setMediaType('');
@@ -285,6 +326,12 @@ const AdminCreditRecords: React.FC = () => {
{ title: '用户类型', maxWidth: 16, render: (r) => r.userTypeLabel || '-' },
{ title: '前台归类', maxWidth: 18, render: (r) => r.frontendUserKindLabel || '-' },
{ title: '归属团队', maxWidth: 20, render: (r) => r.teamNameSnapshot || '未分配团队' },
{ title: '资金域构成', maxWidth: 22, render: (r) => r.fundingScopeLabel || '无资金分摊' },
{ title: '团队积分分摊', minWidth: 14, maxWidth: 16, align: 'right', numFmt: '#,##0.00', render: (r) => r.teamAllocationAmount || 0 },
{ title: '个人积分分摊', minWidth: 14, maxWidth: 16, align: 'right', numFmt: '#,##0.00', render: (r) => r.personalAllocationAmount || 0 },
{ title: '订阅实例', minWidth: 20, maxWidth: 36, render: (r) => (r.subscriptionUsages || []).map((item) => item.subscriptionNo).filter((value, index, arr) => arr.indexOf(value) === index).join('') || '-' },
{ title: '套餐信息', minWidth: 30, maxWidth: 60, render: (r) => (r.subscriptionUsages || []).map((item) => `${item.subscriptionNo}${subscriptionProductText(item)}`).join('') || '-' },
{ title: '月度周期', minWidth: 28, maxWidth: 64, render: (r) => (r.subscriptionUsages || []).map((item) => `${item.subscriptionNo}${subscriptionPeriodText(item)}`).join('') || '-' },
{ title: '流水类型', maxWidth: 14, align: 'center', render: (r) => r.recordTypeLabel || r.type || '-' },
{ title: '交易动作', maxWidth: 16, align: 'center', render: (r) => r.chargeActionLabel || (r.chargeAction ? (CHARGE_ACTION_MAP[r.chargeAction]?.text || r.chargeAction) : '-') },
{ title: '积分类型', maxWidth: 20, render: (r) => r.creditSubjectLabel || '-' },
@@ -308,7 +355,7 @@ const AdminCreditRecords: React.FC = () => {
{ title: '说明', minWidth: 18, maxWidth: 42, render: (r) => r.description || '' },
{ title: '业务归属类型', maxWidth: 18, render: (r) => r.ownerType || '' },
{ title: '业务归属ID', maxWidth: 28, render: (r) => r.ownerId || '' },
{ title: '积分来源分摊', minWidth: 24, maxWidth: 48, render: (r) => (r.allocations || []).map(a => `${a.sourceTypeLabel || a.sourceType || '-'}:${a.sourceId || '-'}=${a.amount}`).join('') },
{ title: '资金溯源明细', minWidth: 42, maxWidth: 100, render: (r) => semanticAllocationText(r) || '-' },
{ title: 'BizKey', maxWidth: 36, render: (r) => r.bizKey || '' },
];
@@ -319,6 +366,7 @@ const AdminCreditRecords: React.FC = () => {
title: '积分流水汇总',
metadataRows: [
['筛选时间', `${dateRange[0]?.format('YYYY-MM-DD') || '不限'}${dateRange[1]?.format('YYYY-MM-DD') || '不限'}`],
['订阅实例筛选', subscriptionNoFilter || '不限'],
['导出时间', dayjs().format('YYYY-MM-DD HH:mm:ss')],
['导出条数', totalRows],
],
@@ -356,6 +404,45 @@ const AdminCreditRecords: React.FC = () => {
{ title: '用户', dataIndex: 'username', width: 130, fixed: 'left' as const, render: (v: string, r: AdminCreditRecord) => <div><Typography.Text strong>{v || '-'}</Typography.Text><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.phone || '-'}</div></div> },
{ title: '用户类型', dataIndex: 'userTypeLabel', width: 120, render: (_: string, r: AdminCreditRecord) => <Tag color={r.userType === 'admin' ? 'orange' : 'blue'}>{r.userTypeLabel || '-'}</Tag> },
{ title: '归属团队', dataIndex: 'teamNameSnapshot', width: 130, render: (v: string) => v ? <Tag color="blue">{v}</Tag> : <Typography.Text type="secondary"></Typography.Text> },
{
title: '资金构成', key: 'fundingScope', width: 190,
render: (_: any, r: AdminCreditRecord) => (
<Space size={[4, 4]} wrap>
{(r.teamAllocationAmount || 0) > 0 && <Tag color="geekblue"> {n(r.teamAllocationAmount)}</Tag>}
{(r.personalAllocationAmount || 0) > 0 && <Tag color="purple"> {n(r.personalAllocationAmount)}</Tag>}
{!r.teamAllocationAmount && !r.personalAllocationAmount && <Typography.Text type="secondary"></Typography.Text>}
</Space>
),
},
{
title: '订阅实例 / 套餐', key: 'subscriptionUsages', width: 330,
render: (_: any, r: AdminCreditRecord) => (r.subscriptionUsages || []).length ? (
<div>
{(r.subscriptionUsages || []).map((item, index) => (
<div key={`${item.subscriptionNo}-${item.subscriptionPeriodId || index}`} style={{ marginBottom: index === (r.subscriptionUsages || []).length - 1 ? 0 : 8 }}>
<Typography.Text strong copyable={{ text: item.subscriptionNo }}>{item.subscriptionNo}</Typography.Text>
<div style={{ fontSize: 12, color: '#64748b', marginTop: 2 }}>{subscriptionProductText(item)}</div>
<div style={{ fontSize: 12, color: '#94a3b8' }}>{item.creditScopeLabel || '-'} · {n(item.amount)} </div>
</div>
))}
</div>
) : <Typography.Text type="secondary"></Typography.Text>,
},
{
title: '月度周期', key: 'subscriptionPeriods', width: 300,
render: (_: any, r: AdminCreditRecord) => (r.subscriptionUsages || []).length ? (
<div>
{(r.subscriptionUsages || []).map((item, index) => (
<div key={`${item.subscriptionNo}-period-${item.subscriptionPeriodId || index}`} style={{ marginBottom: index === (r.subscriptionUsages || []).length - 1 ? 0 : 8 }}>
<Typography.Text strong>{item.periodLabel || '-'}</Typography.Text>
<div style={{ fontSize: 12, color: '#64748b' }}>
{item.periodValidFrom && item.periodExpiresAt ? `${formatDate(item.periodValidFrom)} ${formatDate(item.periodExpiresAt)}` : '-'}
</div>
</div>
))}
</div>
) : '-',
},
{ title: '流水类型', dataIndex: 'recordType', width: 100, render: (v: string, r: AdminCreditRecord) => { const cfg = RECORD_TYPE_MAP[v] || { text: r.recordTypeLabel || v || '-', color: 'default', icon: null }; return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>; } },
{ title: '交易动作', dataIndex: 'chargeAction', width: 110, render: (v: string, r: AdminCreditRecord) => { const cfg = CHARGE_ACTION_MAP[v] || { text: r.chargeActionLabel || v || '-', color: 'default' }; return v ? <Tag color={cfg.color}>{r.chargeActionLabel || cfg.text}</Tag> : <Typography.Text type="secondary"></Typography.Text>; } },
{ title: '积分类型', dataIndex: 'creditSubjectLabel', width: 150, render: (v: string) => <Tag>{v || '-'}</Tag> },
@@ -442,6 +529,7 @@ const AdminCreditRecords: React.FC = () => {
...teamOptions.map(t => ({ value: t.id, label: t.status === 'disabled' ? `${t.name}(禁用)` : t.name })),
]}
/>
<Input placeholder="订阅实例号(PS/TS" value={subscriptionNoFilter} onChange={(e) => { setPage(1); setSubscriptionNoFilter(e.target.value); }} style={{ width: 190 }} allowClear />
<Select value={recordType} onChange={(v) => { setPage(1); setRecordType(v); }} style={{ width: 130 }} options={recordTypeOptions} />
<Select value={creditSubject} onChange={(v) => { setPage(1); setCreditSubject(v); }} style={{ width: 180 }} options={creditSubjectOptions} />
<Select value={mediaType} onChange={(v) => { setPage(1); setMediaType(v); }} style={{ width: 110 }} options={mediaTypeOptions} />
@@ -482,18 +570,26 @@ const AdminCreditRecords: React.FC = () => {
rowKey="id"
dataSource={record.allocations || []}
columns={[
{ title: '动作', dataIndex: 'allocationActionLabel', width: 150, render: (v: string, r: any) => v || r.allocationAction || '-' },
{ title: '积分来源', dataIndex: 'sourceTypeLabel', width: 160, render: (v: string, r: any) => v || r.sourceType || '-' },
{ title: '来源ID', dataIndex: 'sourceId', width: 260, render: (v: string) => v || '-' },
{ title: '积分数量', dataIndex: 'amount', width: 120, render: (v: number) => n(v) },
{ title: '生效时间', dataIndex: 'validFrom', width: 190, render: (v: string) => v ? formatDate(v) : '-' },
{ title: '过期边界', dataIndex: 'expiresAt', width: 190, render: (v: string) => v ? formatDate(v) : '-' },
{ title: '动作', dataIndex: 'allocationActionLabel', width: 150, render: (v: string) => v || '其他动作' },
{ title: '资金域', dataIndex: 'creditScopeLabel', width: 110, render: (v: string) => v || '其他资金域' },
{ title: '分摊积分', dataIndex: 'amount', width: 120, render: (v: number) => <Typography.Text strong>{n(v)}</Typography.Text> },
{ title: '积分等级', dataIndex: 'creditLevelLabel', width: 120, render: (v: string) => v || '其他积分等级' },
{ title: '积分来源', dataIndex: 'sourceTypeLabel', width: 160, render: (v: string) => v || '其他来源' },
{ title: '订阅实例', dataIndex: 'subscriptionNo', width: 210, render: (v: string) => v ? <Typography.Text copyable={{ text: v }}>{v}</Typography.Text> : '非订阅资金' },
{ title: '套餐名称', dataIndex: 'productName', width: 170, render: (v: string) => v || '-' },
{ title: '套餐类型', dataIndex: 'productTypeLabel', width: 150, render: (v: string) => v || '-' },
{ title: '套餐等级', key: 'tier', width: 190, render: (_: any, a: any) => a.tierLabel || a.tierCode ? `${a.tierLabel || a.tierCode}${a.tierCode && a.tierLabel !== a.tierCode ? `${a.tierCode}` : ''}${a.tierRank !== undefined && a.tierRank !== null ? ` / ${a.tierRank}` : ''}` : '-' },
{ title: '套餐周期', dataIndex: 'billingCycleLabel', width: 110, render: (v: string) => v || '-' },
{ title: '月度周期', dataIndex: 'periodLabel', width: 120, render: (v: string) => v || '-' },
{ title: '周期起止', key: 'periodRange', width: 330, render: (_: any, a: any) => a.periodValidFrom && a.periodExpiresAt ? `${formatDate(a.periodValidFrom)} ${formatDate(a.periodExpiresAt)}` : '-' },
{ title: '源资金有效期', key: 'balanceRange', width: 330, render: (_: any, a: any) => a.validFrom && a.expiresAt ? `${formatDate(a.validFrom)} ${formatDate(a.expiresAt)}` : '-' },
{ title: '内部来源ID', dataIndex: 'sourceId', width: 220, render: (v: string) => v || '-' },
]}
scroll={{ x: 1070 }}
scroll={{ x: 2390 }}
/>
),
}}
scroll={{ x: 2520 }}
scroll={{ x: 3350 }}
/>
</Card>
</div>
+61 -311
View File
@@ -1,324 +1,74 @@
import React, { useEffect, useState } from 'react';
import {
Card, Col, Input, Row, Space, Table, Tag, Typography, Statistic, message, Select, DatePicker, Button, ConfigProvider, Popconfirm
} from 'antd';
import React, { useCallback, useEffect, useState } from 'react';
import { Button, Card, Col, ConfigProvider, DatePicker, Input, Row, Select, Space, Statistic, Table, Tag, Typography } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import {
DollarOutlined, CheckCircleOutlined, ClockCircleOutlined, CloseCircleOutlined, ReloadOutlined, UndoOutlined, SearchOutlined
} from '@ant-design/icons';
import { getPaymentStats, getAdminPaymentOrders, refundPaymentOrder } from '../api';
import { formatDate } from '../utils/formatDate';
import { DollarOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { getAdminPaymentOrders, getPaymentStats } from '../api';
const { Option } = Select;
const { RangePicker } = DatePicker;
const SOURCE_LABELS: Record<string, string> = { online_payment: '线上支付', admin_offline: '后台线下成交' };
const METHOD_LABELS: Record<string, string> = { alipay: '支付宝', wechat: '微信支付', bank_transfer: '银行转账', cash: '现金', other: '其他-线下收款' };
const STATUS_LABELS: Record<string, string> = { pending: '待支付', paid: '已支付', refunded: '已退款', cancelled: '已取消', expired: '已过期', failed: '失败' };
const AdminPaymentStats: React.FC = () => {
const [loading, setLoading] = useState(false);
const [stats, setStats] = useState<any>(null);
const [orderPage, setOrderPage] = useState(1);
const [orderPageSize, setOrderPageSize] = useState(10);
const [orderTotal, setOrderTotal] = useState(0);
const [filters, setFilters] = useState<{
paymentMethod?: string;
status?: string;
phone?: string;
startDate: string;
endDate: string;
}>({
startDate: dayjs().format('YYYY-MM-DD'),
endDate: dayjs().format('YYYY-MM-DD'),
});
const [stats, setStats] = useState<any>(null);
const [orders, setOrders] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [filters, setFilters] = useState<any>({ paymentMethod: undefined, orderSource: undefined, status: undefined, phone: '', startDate: dayjs().startOf('month').format('YYYY-MM-DD'), endDate: dayjs().format('YYYY-MM-DD') });
const load = async () => {
try {
setLoading(true);
const [statsData, ordersData] = await Promise.all([
getPaymentStats(filters),
getAdminPaymentOrders({
...filters,
phone: filters.phone,
page: orderPage,
pageSize: orderPageSize,
}),
]);
setStats({ ...statsData, recent: ordersData.items || [] });
setOrderTotal(ordersData.total);
} catch {
message.error('加载支付统计失败');
} finally {
setLoading(false);
}
};
const load = useCallback(async () => {
setLoading(true);
try {
const [s, o] = await Promise.all([
getPaymentStats({ paymentMethod: filters.paymentMethod, orderSource: filters.orderSource, status: filters.status, startDate: filters.startDate, endDate: filters.endDate }),
getAdminPaymentOrders({ method: filters.paymentMethod, orderSource: filters.orderSource, status: filters.status, phone: filters.phone || undefined, startDate: filters.startDate, endDate: filters.endDate, page, pageSize }),
]);
setStats(s); setOrders(o.items || []); setTotal(Number(o.total || 0));
} finally { setLoading(false); }
}, [filters, page, pageSize]);
useEffect(() => { void load(); }, [load]);
useEffect(() => { load(); }, [filters, orderPage, orderPageSize]);
const columns = [
{ title: '订单号', dataIndex: 'orderNo', width: 210, render: (v: string) => <Typography.Text copyable code>{v}</Typography.Text> },
{ title: '用户', width: 150, render: (_: any, r: any) => <div>{r.username || '-'}<div style={{ fontSize: 12, color: '#94a3b8' }}>{r.phone || '-'}</div></div> },
{ title: '来源', dataIndex: 'orderSource', width: 120, render: (v: string, r: any) => <Tag color={v === 'admin_offline' ? 'purple' : 'blue'}>{r.orderSourceLabel || SOURCE_LABELS[v] || '其他来源'}</Tag> },
{ title: '商品', dataIndex: 'productNameSnapshot', width: 170, ellipsis: true, render: (v: string, r: any) => `${v || '-'}${Number(r.quantity || 1) > 1 ? ` × ${r.quantity}` : ''}` },
{ title: '支付方式', dataIndex: 'paymentMethod', width: 135, render: (v: string, r: any) => r.paymentMethodLabel || METHOD_LABELS[v] || '其他支付方式' },
{ title: '系统报价', dataIndex: 'quotedAmountSnapshot', width: 110, align: 'right' as const, render: (v: number) => v == null ? '-' : `¥${Number(v).toFixed(2)}` },
{ title: '实际成交', dataIndex: 'amount', width: 110, align: 'right' as const, render: (v: number) => <b>¥{Number(v || 0).toFixed(2)}</b> },
{ title: '状态', dataIndex: 'status', width: 100, render: (v: string, r: any) => <Tag>{r.statusLabel || STATUS_LABELS[v] || '其他状态'}</Tag> },
{ title: '履约', dataIndex: 'fulfillmentStatusLabel', width: 100, render: (v: string) => v || '-' },
{ title: '创建时间', dataIndex: 'createdAt', width: 180, render: (v: string) => v ? new Date(v).toLocaleString('zh-CN', { hour12: false }) : '-' },
// 当前版本退款按钮明确不开放。后端退款 API 仍保留兼容入口并固定返回中文阻止提示,后续重新开放时再恢复此处按钮。
];
const handleOrderPageChange = (p: number, ps: number) => {
setOrderPage(p);
setOrderPageSize(ps);
};
const reset = () => { setFilters({ paymentMethod: undefined, orderSource: undefined, status: undefined, phone: '', startDate: dayjs().startOf('month').format('YYYY-MM-DD'), endDate: dayjs().format('YYYY-MM-DD') }); setPage(1); };
const today = stats?.today || {};
const month = stats?.month || {};
const bySource = stats?.bySource || {};
const handleReset = () => {
setFilters({
startDate: dayjs().format('YYYY-MM-DD'),
endDate: dayjs().format('YYYY-MM-DD'),
});
setOrderPage(1);
};
const handleRefund = async (orderNo: string) => {
try {
setLoading(true);
await refundPaymentOrder(orderNo);
message.success('退款成功');
await load();
} catch (e: any) {
message.error(e?.response?.data?.detail || '退款失败');
} finally {
setLoading(false);
}
};
const handleDateChange = (dates: any) => {
if (dates && dates.length === 2) {
setFilters(prev => ({
...prev,
startDate: dates[0].format('YYYY-MM-DD'),
endDate: dates[1].format('YYYY-MM-DD'),
}));
}
};
const statusConfig: Record<string, { color: string; label: string; icon: React.ReactNode }> = {
paid: { color: 'green', label: '已支付', icon: <CheckCircleOutlined /> },
pending: { color: 'gold', label: '待支付', icon: <ClockCircleOutlined /> },
cancelled: { color: 'default', label: '已取消', icon: <CloseCircleOutlined /> },
refunded: { color: 'red', label: '已退款', icon: <UndoOutlined /> },
};
const methodConfig: Record<string, { color: string; label: string }> = {
alipay: { color: 'blue', label: '支付宝' },
wechat: { color: 'green', label: '微信' },
};
const columns = [
{ title: '订单号', dataIndex: 'orderNo', key: 'orderNo', width: 200 },
{ title: '用户', dataIndex: 'username', key: 'username', width: 120 },
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 120, render: (v: string) => v || '-' },
{
title: '支付方式', dataIndex: 'paymentMethod', key: 'paymentMethod', width: 100,
render: (m: string) => {
const c = methodConfig[m] || { color: 'default', label: m };
return <Tag color={c.color}>{c.label}</Tag>;
},
},
{
title: '金额', dataIndex: 'amount', key: 'amount', width: 100,
render: (a: number) => <Typography.Text strong style={{ color: '#10b981' }}>¥{a.toFixed(2)}</Typography.Text>,
},
{ title: '积分', dataIndex: 'credits', key: 'credits', width: 80 },
{
title: '状态', dataIndex: 'status', key: 'status', width: 100,
render: (s: string) => {
const c = statusConfig[s] || { color: 'default', label: s, icon: null };
return <Tag color={c.color} icon={c.icon}>{c.label}</Tag>;
},
},
{ title: '支付宝交易号', dataIndex: 'tradeNo', key: 'tradeNo', width: 200, render: (v: string) => v || '-' },
{
title: '创建时间', dataIndex: 'createdAt', key: 'createdAt', width: 160,
render: (d: string) => <span className="date-display" style={{ color: '#94a3b8' }}>{d ? formatDate(d) : '-'}</span>,
},
{
title: '支付时间', dataIndex: 'paidAt', key: 'paidAt', width: 160,
render: (d: string) => <span className="date-display" style={{ color: '#94a3b8' }}>{d ? formatDate(d) : '-'}</span>,
},
{
title: '操作',
key: 'action',
width: 120,
render: (_: any, record: any) => {
if (record.status === 'paid') {
return (
<Popconfirm
title="确认要退款该订单吗?"
description="退款后积分会扣除,金额会原路返回"
onConfirm={() => handleRefund(record.orderNo)}
okText="确认"
cancelText="取消"
>
<Button type="link" danger size="small" icon={<UndoOutlined />}>
退
</Button>
</Popconfirm>
);
}
return null;
},
},
];
if (!stats) {
return <div style={{ padding: 24, color: '#94a3b8' }}></div>;
}
const paidInfo = stats.byStatus?.paid || { count: 0, amount: 0 };
const pendingInfo = stats.byStatus?.pending || { count: 0, amount: 0 };
const cancelledInfo = stats.byStatus?.cancelled || { count: 0, amount: 0 };
const refundedInfo = stats.byStatus?.refunded || { count: 0, amount: 0 };
const totalOrders = paidInfo.count + pendingInfo.count + cancelledInfo.count + refundedInfo.count;
const monthInfo = stats.month || { count: 0, amount: 0 };
return (
<ConfigProvider locale={zhCN}>
<div>
{/* Summary cards */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={12} lg={12}>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Statistic
title="今日收入"
value={stats.today.paidAmount}
precision={2}
prefix={<DollarOutlined style={{ color: '#10b981' }} />}
suffix="元"
valueStyle={{ color: '#10b981', fontWeight: 700 }}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{stats.today.paidCount}
</Typography.Text>
</Card>
</Col>
<Col xs={24} sm={12} lg={12}>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Statistic
title="本月累计"
value={monthInfo.paidAmount}
precision={2}
prefix={<DollarOutlined style={{ color: '#6366f1' }} />}
suffix="元"
valueStyle={{ color: '#6366f1', fontWeight: 700 }}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{monthInfo.paidCount}
</Typography.Text>
</Card>
</Col>
</Row>
{/* Status breakdown */}
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}
title={<Space><DollarOutlined /></Space>}>
<Row gutter={16}>
{['paid', 'pending', 'cancelled', 'refunded'].map(s => {
const info = stats.byStatus?.[s] || { count: 0, amount: 0 };
const c = statusConfig[s];
const pct = totalOrders > 0 ? ((info.count / totalOrders) * 100).toFixed(1) : '0.0';
return (
<Col span={6} key={s}>
<div style={{
padding: 16, borderRadius: 10,
background: '#fafbff', border: '1px solid #f0f0f5',
}}>
<Space>
<Tag color={c.color} icon={c.icon}>{c.label}</Tag>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{pct}%</Typography.Text>
</Space>
<div style={{ marginTop: 8, fontSize: 20, fontWeight: 700 }}>
{info.count} <span style={{ fontSize: 13, color: '#94a3b8', fontWeight: 400 }}></span>
</div>
<div style={{ fontSize: 13, color: '#64748b', marginTop: 4 }}>
¥{info.amount.toFixed(2)}
</div>
</div>
</Col>
);
})}
</Row>
</Card>
{/* Recent orders table */}
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}
title={<Space><DollarOutlined /></Space>}>
{/* Filters */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={8} md={4}>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}></Typography.Text>
<Select
placeholder="全部"
allowClear
style={{ width: '100%' }}
value={filters.paymentMethod}
onChange={(value) => { setFilters(prev => ({ ...prev, paymentMethod: value })); setOrderPage(1); }}
>
<Option value="alipay"></Option>
<Option value="wechat"></Option>
</Select>
</Col>
<Col xs={24} sm={8} md={4}>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}></Typography.Text>
<Select
placeholder="全部"
allowClear
style={{ width: '100%' }}
value={filters.status}
onChange={(value) => { setFilters(prev => ({ ...prev, status: value })); setOrderPage(1); }}
>
<Option value="paid"></Option>
<Option value="pending"></Option>
<Option value="cancelled"></Option>
<Option value="refunded">退</Option>
</Select>
</Col>
<Col xs={24} sm={8} md={5}>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}></Typography.Text>
<Input
placeholder="搜索手机号"
allowClear
value={filters.phone}
onChange={(e) => setFilters(prev => ({ ...prev, phone: e.target.value }))}
onPressEnter={() => { setOrderPage(1); load(); }}
suffix={<SearchOutlined style={{ color: '#94a3b8' }} />}
/>
</Col>
<Col xs={24} sm={12} md={7}>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}></Typography.Text>
<RangePicker
style={{ width: '100%' }}
value={[
dayjs(filters.startDate),
dayjs(filters.endDate),
]}
onChange={handleDateChange}
/>
</Col>
<Col xs={24} sm={12} md={4} style={{ display: 'flex', alignItems: 'flex-end' }}>
<Button icon={<ReloadOutlined />} onClick={handleReset} style={{ marginBottom: 0 }}>
</Button>
</Col>
</Row>
<Table
columns={columns}
dataSource={stats.recent || []}
rowKey="id"
loading={loading}
pagination={{
current: orderPage,
pageSize: orderPageSize,
total: orderTotal,
onChange: handleOrderPageChange,
showSizeChanger: true,
showTotal: (t) => `${t}`,
}}
scroll={{ x: 1000 }}
size="middle"
/>
</Card>
</div>
</ConfigProvider>
);
return <ConfigProvider locale={zhCN}><div>
<Row gutter={[16,16]} style={{ marginBottom: 18 }}>
<Col xs={24} md={8}><Card><Statistic title="今日总真实收入" value={Number(today.paidAmount || 0)} precision={2} suffix="元" prefix={<DollarOutlined />} /><Typography.Text type="secondary">线 {Number(today.onlinePaidAmount || 0).toFixed(2)} · 线 {Number(today.offlinePaidAmount || 0).toFixed(2)} </Typography.Text></Card></Col>
<Col xs={24} md={8}><Card><Statistic title="本月总真实收入" value={Number(month.paidAmount || 0)} precision={2} suffix="元" prefix={<DollarOutlined />} /><Typography.Text type="secondary">线 {Number(month.onlinePaidAmount || 0).toFixed(2)} · 线 {Number(month.offlinePaidAmount || 0).toFixed(2)} </Typography.Text></Card></Col>
<Col xs={24} md={8}><Card><Statistic title="当前筛选总收入" value={Number(stats?.totalIncome?.amount || 0)} precision={2} suffix="元" prefix={<DollarOutlined />} /><Typography.Text type="secondary">{Number(stats?.totalIncome?.count || 0)} </Typography.Text></Card></Col>
</Row>
<Card title="收入来源" style={{ marginBottom: 16 }}><Row gutter={16}>{['online_payment','admin_offline'].map((source) => <Col span={12} key={source}><div style={{ padding: 14, background: '#fafafa', borderRadius: 10 }}><Tag color={source === 'online_payment' ? 'blue' : 'purple'}>{bySource[source]?.label || SOURCE_LABELS[source]}</Tag><div style={{ fontSize: 24, fontWeight: 700, marginTop: 8 }}>¥{Number(bySource[source]?.amount || 0).toFixed(2)}</div><div style={{ color: '#94a3b8' }}>{Number(bySource[source]?.count || 0)} </div></div></Col>)}</Row></Card>
<Card title="订单列表">
<Space wrap style={{ marginBottom: 16 }}>
<Select allowClear placeholder="订单来源" style={{ width: 160 }} value={filters.orderSource} onChange={(v) => { setFilters((x:any) => ({...x, orderSource:v})); setPage(1); }} options={Object.entries(SOURCE_LABELS).map(([value,label]) => ({value,label}))} />
<Select allowClear placeholder="支付方式" style={{ width: 150 }} value={filters.paymentMethod} onChange={(v) => { setFilters((x:any) => ({...x, paymentMethod:v})); setPage(1); }} options={Object.entries(METHOD_LABELS).map(([value,label]) => ({value,label}))} />
<Select allowClear placeholder="订单状态" style={{ width: 130 }} value={filters.status} onChange={(v) => { setFilters((x:any) => ({...x, status:v})); setPage(1); }} options={Object.entries(STATUS_LABELS).map(([value,label]) => ({value,label}))} />
<Input allowClear placeholder="手机号" style={{ width: 150 }} value={filters.phone} onChange={(e) => setFilters((x:any) => ({...x, phone:e.target.value}))} onPressEnter={() => { setPage(1); void load(); }} suffix={<SearchOutlined />} />
<RangePicker value={[dayjs(filters.startDate), dayjs(filters.endDate)]} onChange={(v) => { setFilters((x:any) => ({...x, startDate:v?.[0]?.format('YYYY-MM-DD'), endDate:v?.[1]?.format('YYYY-MM-DD')})); setPage(1); }} />
<Button icon={<ReloadOutlined />} onClick={reset}></Button>
</Space>
<Table rowKey="id" loading={loading} columns={columns} dataSource={orders} scroll={{ x: 1450 }} pagination={{ current:page, pageSize, total, showSizeChanger:true, showTotal:(t)=>`${t}`, onChange:(p,ps)=>{setPage(p);setPageSize(ps);} }} />
</Card>
</div></ConfigProvider>;
};
export default AdminPaymentStats;
+66 -365
View File
@@ -1,378 +1,79 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Button, Card, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Table, Tag, Typography } from 'antd';
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, SettingOutlined, TeamOutlined, UserOutlined } from '@ant-design/icons';
import { deleteAdminTeam, getAdminTeams, getTeamMembersForAdmin, saveAdminTeam, setTeamManager } from '../api';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Alert, Button, Card, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Table, Tabs, Tag, Typography } from 'antd';
import { DeleteOutlined, EditOutlined, EyeOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, TeamOutlined, UserSwitchOutlined } from '@ant-design/icons';
import {
deleteAdminTeam, getAdminTeamManagerHistory, getAdminTeamMemberUsage, getAdminTeams, getAdminTeamSubscriptions,
getTeamMembersForAdmin, saveAdminTeam, setTeamManager,
} from '../api';
import type { AdminTeam, AdminUser } from '../types';
import { formatDate } from '../utils/formatDate';
const statusOptions = [
{ value: '', label: '全部状态' },
{ value: 'active', label: '启用' },
{ value: 'disabled', label: '禁用' },
];
const editStatusOptions = [
{ value: 'active', label: '启用' },
{ value: 'disabled', label: '禁用' },
];
function statusLabel(status?: string): string {
if (status === 'active') return '启用';
if (status === 'disabled') return '禁用';
return status || '-';
}
const STATUS = { active: '启用', disabled: '禁用' } as Record<string,string>;
const fmt = (v?: string | null) => v ? new Date(v).toLocaleString('zh-CN', { hour12: false }) : '-';
const AdminTeams: React.FC = () => {
const [items, setItems] = useState<AdminTeam[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [total, setTotal] = useState(0);
const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState('');
const [modal, setModal] = useState<{ open: boolean; item: AdminTeam | null }>({ open: false, item: null });
const [form] = Form.useForm();
const [items, setItems] = useState<AdminTeam[]>([]); const [loading,setLoading]=useState(false); const [page,setPage]=useState(1); const [pageSize,setPageSize]=useState(20); const [total,setTotal]=useState(0);
const [keyword,setKeyword]=useState(''); const [status,setStatus]=useState<string>(); const [form]=Form.useForm(); const [edit,setEdit]=useState<AdminTeam|null>(null); const [editOpen,setEditOpen]=useState(false);
const [detail,setDetail]=useState<AdminTeam|null>(null); const [members,setMembers]=useState<AdminUser[]>([]); const [subscriptions,setSubscriptions]=useState<any[]>([]); const [usage,setUsage]=useState<any[]>([]); const [history,setHistory]=useState<any[]>([]); const [detailLoading,setDetailLoading]=useState(false);
const query=useMemo(()=>({page,pageSize,keyword:keyword||undefined,status}),[page,pageSize,keyword,status]);
const load=useCallback(async()=>{setLoading(true);try{const r=await getAdminTeams(query);setItems(r.items||[]);setTotal(Number(r.total||0));}catch(e:any){message.error(e?.message||'团队列表加载失败');}finally{setLoading(false);}},[query]);
useEffect(()=>{void load();},[load]);
// 管理人弹窗状态
const [managerModal, setManagerModal] = useState<{ open: boolean; team: AdminTeam | null }>({ open: false, team: null });
const [managerMembers, setManagerMembers] = useState<AdminUser[]>([]);
const [managerLoading, setManagerLoading] = useState(false);
const [selectedManagerId, setSelectedManagerId] = useState<string | null>(null);
const openEdit=(team?:AdminTeam)=>{const item=team||null;setEdit(item);form.resetFields();form.setFieldsValue(item?{name:item.name,code:item.code,description:item.description,status:item.status,sortOrder:item.sortOrder}:{status:'active',sortOrder:0});setEditOpen(true);};
const save=async()=>{try{const v=await form.validateFields();await saveAdminTeam({id:edit?.id,name:v.name,code:v.code||null,description:v.description||null,status:v.status,sort_order:Number(v.sortOrder||0)});message.success(edit?'团队已更新':'团队已创建');setEditOpen(false);await load();}catch(e:any){if(!e?.errorFields)message.error(e?.message||'保存失败');}};
const openDetail=async(team:AdminTeam)=>{setDetail(team);setDetailLoading(true);try{const [m,s,u,h]=await Promise.all([getTeamMembersForAdmin(team.id,1,500),getAdminTeamSubscriptions(team.id),getAdminTeamMemberUsage(team.id),getAdminTeamManagerHistory(team.id)]);setMembers(m.items||[]);setSubscriptions(s||[]);setUsage(u||[]);setHistory(h||[]);}catch(e:any){message.error(e?.message||'团队详情加载失败');}finally{setDetailLoading(false);}};
// 查看成员弹窗状态
const [membersModal, setMembersModal] = useState<{ open: boolean; team: AdminTeam | null }>({ open: false, team: null });
const [teamMembers, setTeamMembers] = useState<AdminUser[]>([]);
const [membersLoading, setMembersLoading] = useState(false);
const query = useMemo(() => ({
page,
pageSize,
keyword: keyword || undefined,
status: status || undefined,
}), [page, pageSize, keyword, status]);
const load = async () => {
setLoading(true);
try {
const res = await getAdminTeams(query);
setItems(res.items || []);
setTotal(res.total || 0);
} catch (e: any) {
message.error(e?.message || '加载团队列表失败');
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, [query]);
const openCreate = () => {
form.resetFields();
form.setFieldsValue({ status: 'active', sortOrder: 0 });
setModal({ open: true, item: null });
};
const openEdit = (item: AdminTeam) => {
form.setFieldsValue({
name: item.name,
code: item.code || '',
description: item.description || '',
status: item.status || 'active',
sortOrder: item.sortOrder || 0,
});
setModal({ open: true, item });
};
const handleSave = async () => {
try {
const values = await form.validateFields();
setSaving(true);
await saveAdminTeam({
id: modal.item?.id,
name: values.name,
code: values.code || null,
description: values.description || null,
status: values.status || 'active',
sort_order: values.sortOrder || 0,
});
message.success(modal.item ? '团队已更新' : '团队已创建');
setModal({ open: false, item: null });
form.resetFields();
load();
} catch (e: any) {
if (e?.errorFields) return;
message.error(e?.message || '保存失败');
} finally {
setSaving(false);
}
};
const handleDelete = async (item: AdminTeam) => {
try {
await deleteAdminTeam(item.id);
message.success('团队已删除');
load();
} catch (e: any) {
message.error(e?.message || '删除失败');
}
};
const loadTeamMembers = async (teamId: string): Promise<AdminUser[]> => {
try {
const res = await getTeamMembersForAdmin(teamId);
return res.items || [];
} catch (e: any) {
message.error(e?.message || '加载成员失败');
return [];
}
};
const openManagerModal = async (team: AdminTeam) => {
setManagerModal({ open: true, team });
setSelectedManagerId(team.managerId || null);
setManagerLoading(true);
const data = await loadTeamMembers(team.id);
setManagerMembers(data);
setManagerLoading(false);
};
const openMembersModal = async (team: AdminTeam) => {
setMembersModal({ open: true, team });
setMembersLoading(true);
const data = await loadTeamMembers(team.id);
setTeamMembers(data);
setMembersLoading(false);
};
const handleSetManager = async () => {
if (!managerModal.team) return;
try {
setManagerLoading(true);
await setTeamManager(managerModal.team.id, selectedManagerId);
message.success(selectedManagerId ? '已设置管理人' : '已取消管理人');
setManagerModal({ open: false, team: null });
load();
} catch (e: any) {
message.error(e?.message || '设置失败');
} finally {
setManagerLoading(false);
}
};
const columns = [
{
title: '团队名称',
dataIndex: 'name',
width: 220,
render: (v: string, r: AdminTeam) => (
<Space>
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'rgba(99,102,241,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#6366f1' }}>
<TeamOutlined />
</div>
<div>
<Typography.Text strong>{v}</Typography.Text>
<div style={{ fontSize: 12, color: '#94a3b8' }}>{r.code || '-'}</div>
</div>
</Space>
),
},
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (v: string) => <Tag color={v === 'active' ? 'green' : 'default'}>{statusLabel(v)}</Tag>,
},
{
title: '成员数',
dataIndex: 'memberCount',
width: 100,
render: (v: number) => <Typography.Text strong>{Number(v || 0).toLocaleString()}</Typography.Text>,
},
{
title: '管理人',
key: 'manager',
width: 140,
render: (_: any, r: AdminTeam) => (
<Typography.Text style={{ fontSize: 13 }}>
{r.managerName || <span style={{ color: '#94a3b8' }}></span>}
</Typography.Text>
),
},
{
title: '排序',
dataIndex: 'sortOrder',
width: 90,
render: (v: number) => v ?? 0,
},
{
title: '备注',
dataIndex: 'description',
ellipsis: true,
render: (v: string) => v || '-',
},
{
title: '创建时间',
dataIndex: 'createdAt',
width: 160,
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text>,
},
{
title: '操作',
key: 'action',
width: 230,
fixed: 'right' as const,
render: (_: any, r: AdminTeam) => (
<Space size={4}>
<Button type="link" size="small" icon={<UserOutlined />} onClick={() => openMembersModal(r)}></Button>
<Button type="link" size="small" icon={<SettingOutlined />} onClick={() => openManagerModal(r)}></Button>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}></Button>
<Popconfirm
title="确定删除该团队?"
description={r.memberCount > 0 ? '该团队下仍有成员,后端会拒绝删除。' : '删除后团队不再出现在设置下拉中。'}
onConfirm={() => handleDelete(r)}
>
<Button type="link" size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
),
},
const columns=[
{title:'团队',dataIndex:'name',width:220,render:(v:string,r:AdminTeam)=><Space><TeamOutlined/><div><b>{v}</b><div style={{fontSize:12,color:'#94a3b8'}}>{r.code||'-'}</div></div></Space>},
{title:'状态',dataIndex:'status',width:100,render:(v:string,r:AdminTeam)=><Tag color={v==='active'?'green':'red'}>{r.statusLabel||STATUS[v]||'其他状态'}</Tag>},
{title:'成员数',dataIndex:'memberCount',width:90,align:'right' as const},
{title:'当前队长',dataIndex:'managerName',width:140,render:(v:string)=>v||'-'},
{title:'团队首购时间',dataIndex:'firstSubscriptionPaidAt',width:180,render:(v:string)=>fmt(v)},
{title:'排序',dataIndex:'sortOrder',width:80},
{title:'备注',dataIndex:'description',ellipsis:true,render:(v:string)=>v||'-'},
{title:'操作',fixed:'right' as const,width:220,render:(_:any,r:AdminTeam)=><Space><Button size="small" icon={<EyeOutlined/>} onClick={()=>openDetail(r)}></Button><Button size="small" icon={<EditOutlined/>} onClick={()=>openEdit(r)}>{r.status==='disabled'?'重新启用':'编辑'}</Button><Popconfirm title="确认软删除团队?" description="仅在无有效团队订阅、无未完成团队订单且无成员时允许删除。" onConfirm={async()=>{try{await deleteAdminTeam(r.id);message.success('团队已删除');await load();}catch(e:any){message.error(e?.message||'删除失败');}}}><Button 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, flexWrap: 'wrap', gap: 12 }}>
<Space wrap>
<Input
placeholder="搜索团队名称/编码/备注"
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
value={keyword}
onChange={(e) => { setPage(1); setKeyword(e.target.value); }}
onPressEnter={load}
style={{ width: 260 }}
allowClear
/>
<Select value={status} onChange={(v) => { setPage(1); setStatus(v); }} style={{ width: 130 }} options={statusOptions} />
<Button icon={<ReloadOutlined />} onClick={load}></Button>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}></Button>
</div>
const subColumns=[
{title:'订阅实例',width:190,render:(_:any,r:any)=><Typography.Text copyable={r.subscription?.subscriptionNo?{text:r.subscription.subscriptionNo}:false}>{r.subscription?.subscriptionNo||'-'}</Typography.Text>},
{title:'套餐',render:(_:any,r:any)=>r.subscription?.productNameSnapshot||'-'}, {title:'状态',render:(_:any,r:any)=><Tag>{r.subscription?.statusLabel||'其他状态'}</Tag>},
{title:'数量N',render:(_:any,r:any)=>r.subscription?.quantitySnapshot||1}, {title:'席位',render:(_:any,r:any)=>`${r.activeSeatCount||0}/${r.seatLimit||0}`},
{title:'本期总积分',dataIndex:'periodTotalCredits',align:'right' as const},{title:'本期剩余',dataIndex:'periodUnspentCredits',align:'right' as const},{title:'未分配',dataIndex:'periodUnallocatedCredits',align:'right' as const},
{title:'订阅到期',render:(_:any,r:any)=>fmt(r.subscription?.expiresAt)},
];
const memberColumns=[{title:'用户名',dataIndex:'username'},{title:'手机号',dataIndex:'phone',render:(v:string)=>v||'-'},{title:'个人积分',dataIndex:'personalCredits',align:'right' as const,render:(v:number)=>Number(v||0).toLocaleString()},{title:'团队可用',dataIndex:'teamAvailableCredits',align:'right' as const,render:(v:number)=>Number(v||0).toLocaleString()},{title:'团队冻结',dataIndex:'teamFrozenCredits',align:'right' as const,render:(v:number)=>Number(v||0).toLocaleString()},{title:'设为队长',render:(_:any,r:AdminUser)=>r.id===detail?.managerId? <Tag color="purple"></Tag>:<Popconfirm title="确认更换队长?" description="只有团队启用、所有团队订阅结束且没有未完成团队订单时才能更换。" onConfirm={async()=>{if(!detail)return;try{await setTeamManager(detail.id,r.id);message.success('队长已更换');await openDetail({...detail,managerId:r.id,managerName:r.username});await load();}catch(e:any){message.error(e?.message||'更换队长失败');}}}><Button size="small" disabled={detail?.status==='disabled'} icon={<UserSwitchOutlined/>}></Button></Popconfirm>}];
<Table
columns={columns}
dataSource={items}
rowKey="id"
loading={loading}
pagination={{
current: page,
pageSize,
total,
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
showSizeChanger: true,
showTotal: (t) => `${t} 个团队`,
}}
scroll={{ x: 1100 }}
/>
</Card>
return <div><Card><Space wrap style={{marginBottom:16}}><Input allowClear value={keyword} onChange={e=>{setKeyword(e.target.value);setPage(1);}} placeholder="团队名称/编码/备注" prefix={<SearchOutlined/>} style={{width:260}}/><Select allowClear value={status} onChange={v=>{setStatus(v);setPage(1);}} placeholder="全部状态" style={{width:130}} options={[{value:'active',label:'启用'},{value:'disabled',label:'禁用'}]}/><Button icon={<ReloadOutlined/>} onClick={load}></Button><Button type="primary" icon={<PlusOutlined/>} onClick={()=>openEdit()}></Button></Space><Table rowKey="id" loading={loading} columns={columns} dataSource={items} scroll={{x:1250}} pagination={{current:page,pageSize,total,showSizeChanger:true,onChange:(p,ps)=>{setPage(p);setPageSize(ps);}}}/></Card>
<Modal
title={<Space><TeamOutlined />{modal.item ? '编辑团队' : '新增团队'}</Space>}
open={modal.open}
confirmLoading={saving}
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 maxLength={128} placeholder="请输入团队名称" size="large" />
</Form.Item>
<Form.Item name="code" label="团队编码" extra="选填,用于内部标识或后续外部系统对接。">
<Input maxLength={64} placeholder="例如 sales_a" size="large" />
</Form.Item>
<Form.Item name="status" label="状态" rules={[{ required: true, message: '请选择状态' }]}>
<Select size="large" options={editStatusOptions} />
</Form.Item>
<Form.Item name="sortOrder" label="排序" initialValue={0}>
<InputNumber min={0} max={999999} precision={0} style={{ width: '100%' }} size="large" />
</Form.Item>
<Form.Item name="description" label="备注">
<Input.TextArea rows={3} maxLength={512} placeholder="选填" />
</Form.Item>
</Form>
</Modal>
<Modal width={560} title={edit?'编辑团队':'新增团队'} open={editOpen} onCancel={()=>setEditOpen(false)} onOk={save} okText={edit?.status==='disabled'?'重新启用':'保存'} cancelText="取消">
{edit?.status==='disabled'&&<Alert style={{marginBottom:14}} type="warning" showIcon message="禁用团队当前仅允许重新启用" description="团队禁用期间所有团队业务保持只读;Subscription、Period、Seat 和积分仍正常滚期、发放与过期。"/>}
<Form form={form} layout="vertical"><Form.Item name="name" label="团队名称" rules={[{required:true}]}><Input disabled={edit?.status==='disabled'}/></Form.Item><Form.Item name="code" label="团队编码"><Input disabled={edit?.status==='disabled'}/></Form.Item><Form.Item name="description" label="备注"><Input.TextArea rows={3} disabled={edit?.status==='disabled'}/></Form.Item><Form.Item name="status" label="状态" rules={[{required:true}]}><Select options={edit?.status==='disabled'?[{value:'active',label:'重新启用'}]:[{value:'active',label:'启用'},{value:'disabled',label:'禁用'}]}/></Form.Item><Form.Item name="sortOrder" label="排序"><InputNumber min={0} max={999999} disabled={edit?.status==='disabled'} style={{width:'100%'}}/></Form.Item></Form>
</Modal>
{/* 设置管理人弹窗 */}
<Modal
title={<Space><UserOutlined /></Space>}
open={managerModal.open}
confirmLoading={managerLoading}
onOk={handleSetManager}
onCancel={() => { setManagerModal({ open: false, team: null }); setSelectedManagerId(null); }}
okText="保存"
cancelText="取消"
width={480}
>
<div style={{ marginTop: 16 }}>
<Typography.Text style={{ fontSize: 13, color: '#64748b', display: 'block', marginBottom: 8 }}>
{managerModal.team?.name || '-'}
</Typography.Text>
<Typography.Text style={{ fontSize: 13, color: '#64748b', display: 'block', marginBottom: 12 }}>
</Typography.Text>
<Select
style={{ width: '100%' }}
placeholder="选择管理人(可清空取消)"
value={selectedManagerId}
onChange={(v) => setSelectedManagerId(v || null)}
allowClear
loading={managerLoading}
optionFilterProp="label"
options={managerMembers.map((m) => ({
value: m.id,
label: `${m.username}${m.phone ? ` (${m.phone})` : ''}`,
}))}
showSearch
/>
</div>
</Modal>
{/* 查看成员弹窗 */}
<Modal
title={<Space><UserOutlined /> - {membersModal.team?.name}</Space>}
open={membersModal.open}
onCancel={() => setMembersModal({ open: false, team: null })}
footer={null}
width={600}
>
<div style={{ marginTop: 8 }}>
{membersModal.team && (
<Table
size="small"
rowKey="id"
loading={membersLoading}
dataSource={teamMembers}
pagination={false}
scroll={{ y: 400 }}
columns={[
{ title: '用户名', dataIndex: 'username', width: 140, render: (v: string) => <Typography.Text strong>{v}</Typography.Text> },
{ title: '手机号', dataIndex: 'phone', width: 130, render: (v: string) => v || '-' },
{ title: '积分', dataIndex: 'credits', width: 100, render: (v: number) => <Typography.Text style={{ color: '#6366f1' }}>{(v ?? 0).toFixed(2)}</Typography.Text> },
{
title: '状态', dataIndex: 'isActive', width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'red'}>{v ? '启用' : '禁用'}</Tag>,
},
]}
locale={{ emptyText: '该团队暂无成员' }}
/>
)}
</div>
</Modal>
</div>
);
<Modal width={1100} footer={null} title={detail?`团队详情:${detail.name}`:'团队详情'} open={!!detail} onCancel={()=>setDetail(null)}>
{detail?.status==='disabled'&&<Alert type="warning" showIcon style={{marginBottom:14}} message="团队已禁用:当前仅允许只读查看" description="团队积分处于冻结状态,但团队订阅仍继续正常发放和到期。"/>}
<Tabs items={[
{key:'subscriptions',label:'订阅与席位',children:<Table loading={detailLoading} rowKey={(r:any)=>r.subscription?.id} dataSource={subscriptions} columns={subColumns} pagination={false} scroll={{x:1120}} expandable={{expandedRowRender:(r:any)=><Table rowKey="id" size="small" pagination={false} dataSource={r.seats||[]} columns={[{title:'席位用户',dataIndex:'username'},{title:'月额度',dataIndex:'monthlyAllocatedCredits',align:'right' as const},{title:'本期净消耗',dataIndex:'currentPeriodUsedCredits',align:'right' as const},{title:'本期剩余',dataIndex:'currentPeriodRemainingCredits',align:'right' as const},{title:'状态',dataIndex:'statusLabel'}]}/>}}/>},
{key:'members',label:'成员与队长',children:<Table loading={detailLoading} rowKey="id" dataSource={members} columns={memberColumns} pagination={false} scroll={{x:850}}/>},
{key:'usage',label:'成员月净消耗',children:<Table
loading={detailLoading}
rowKey={(r:any)=>`${r.userId}-${r.subscriptionPeriodId}`}
dataSource={usage}
pagination={false}
scroll={{x:1400}}
columns={[
{title:'成员',dataIndex:'username',width:120},
{title:'订阅实例',dataIndex:'subscriptionNo',width:190,render:(v:string)=><Typography.Text copyable={v&&v!=='历史订阅'?{text:v}:false}>{v||'历史订阅'}</Typography.Text>},
{title:'套餐名称',dataIndex:'subscriptionName',width:180,render:(v:string)=><Typography.Text strong>{v||'历史团队订阅'}</Typography.Text>},
{title:'等级编码',width:190,render:(_:any,r:any)=><span>{r.tierLabel||'未知等级'}{r.tierCode?`${r.tierCode}`:''}{r.tierRank?` / ${r.tierRank}`:''}</span>},
{title:'套餐周期',dataIndex:'billingCycleLabel',width:110,render:(v:string)=><Tag>{v||'未知周期'}</Tag>},
{title:'月度周期',dataIndex:'periodLabel',width:110,render:(v:string,r:any)=>v||(Number(r.periodSequence)>0?`${r.periodSequence}个月`:'历史周期')},
{title:'周期时间',width:320,render:(_:any,r:any)=>`${fmt(r.periodStartAt)} ${fmt(r.periodExpiresAt)}`},
{title:'本期净消耗',dataIndex:'consumedCredits',width:130,align:'right' as const,render:(v:number)=>Number(v||0).toLocaleString()},
]}
/>},
{key:'history',label:'队长任期历史',children:<Table loading={detailLoading} rowKey="id" dataSource={history} pagination={false} columns={[{title:'队长',dataIndex:'managerName'},{title:'开始',dataIndex:'startedAt',render:(v:string)=>fmt(v)},{title:'结束',dataIndex:'endedAt',render:(v:string)=>v?fmt(v):<Tag color="green"></Tag>}]}/>},
]}/>
</Modal>
</div>;
};
export default AdminTeams;
+147 -9
View File
@@ -11,8 +11,10 @@ import {
adminGetPrivatePortraitConfig,
adminUpdatePrivatePortraitConfig,
createUser,
createAdminOfflineSubscription,
deleteUserResourceCapacity,
getAdminUsers,
getCreditProducts,
getAdminUserCreditBalances,
getAdminUserCreditSummary,
getMenuConfigs,
@@ -29,7 +31,7 @@ import {
updateUserMenus,
updateUserAdminStatus,
} from '../api';
import type { AdminTeamOption, AdminUser, AdminUserResourceCapacityOut, PrivatePortraitConfig, ResourceCapacityUnit, ResourceCapacityUsage, SystemConfig } from '../types';
import type { AdminTeamOption, AdminUser, AdminUserResourceCapacityOut, CreditProduct, PrivatePortraitConfig, ResourceCapacityUnit, ResourceCapacityUsage, SystemConfig } from '../types';
import { formatDate } from '../utils/formatDate';
const TEAM_UNASSIGNED_VALUE = '__none__';
@@ -71,6 +73,9 @@ const AdminUsers: React.FC = () => {
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 [offlineModal, setOfflineModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
const [offlineProducts, setOfflineProducts] = useState<CreditProduct[]>([]);
const [offlineSaving, setOfflineSaving] = useState(false);
const [creditDetailLoading, setCreditDetailLoading] = useState(false);
const [creditSummary, setCreditSummary] = useState<any>(null);
const [creditBalances, setCreditBalances] = useState<any[]>([]);
@@ -100,6 +105,9 @@ const AdminUsers: React.FC = () => {
const [capacityForm] = Form.useForm();
const [teamForm] = Form.useForm();
const [portraitForm] = Form.useForm();
const [offlineForm] = Form.useForm();
const offlineProductId = Form.useWatch('productId', offlineForm);
const selectedOfflineProduct = offlineProducts.find((item) => item.id === offlineProductId);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
@@ -203,6 +211,56 @@ const AdminUsers: React.FC = () => {
}
};
const openOfflineModal = async (user: AdminUser) => {
try {
const products = await getCreditProducts();
const available = products.filter((item) =>
(item.productType === 'subscription' || item.productType === 'team_subscription')
&& item.isActive
&& !item.isDeleted
);
setOfflineProducts(available);
offlineForm.resetFields();
offlineForm.setFieldsValue({ quantity: 1, paymentMethod: 'bank_transfer' });
setOfflineModal({ open: true, user });
} catch (e: any) {
message.error(e?.message || '加载可成交套餐失败');
}
};
const handleOfflineSubscription = async () => {
const user = offlineModal.user;
if (!user) return;
try {
const values = await offlineForm.validateFields();
const product = offlineProducts.find((item) => item.id === values.productId);
if (!product) {
message.error('请选择有效套餐');
return;
}
const quantity = product.productType === 'team_subscription' ? Number(values.quantity || 2) : 1;
setOfflineSaving(true);
await createAdminOfflineSubscription(user.id, {
productId: product.id,
quantity,
paymentMethod: values.paymentMethod,
actualPaidAmount: values.actualPaidAmount === undefined || values.actualPaidAmount === null ? undefined : Number(values.actualPaidAmount),
offlineTradeNo: values.offlineTradeNo,
offlinePaymentDetail: values.offlinePaymentDetail,
remark: values.remark,
});
message.success('线下订阅成交已完成,订单、套餐与首期权益已同步生效');
setOfflineModal({ open: false, user: null });
offlineForm.resetFields();
await load();
} catch (e: any) {
if (e?.errorFields) return;
message.error(e?.message || '线下订阅成交失败');
} finally {
setOfflineSaving(false);
}
};
const openCreditDetailModal = async (user: AdminUser, status = '') => {
setCreditDetailModal({ open: true, user });
setCreditBalanceStatus(status);
@@ -480,11 +538,15 @@ const AdminUsers: React.FC = () => {
),
},
...(!isAdminTab ? [{
title: '积分余额', dataIndex: 'credits', width: 120, sorter: (a: AdminUser, b: AdminUser) => a.credits - b.credits,
render: (v: number) => (
<Typography.Text strong style={{ color: v > 0 ? '#10b981' : '#ef4444', fontSize: 15 }}>
{v.toLocaleString()}
</Typography.Text>
title: '积分余额', dataIndex: 'credits', width: 210, sorter: (a: AdminUser, b: AdminUser) => a.credits - b.credits,
render: (v: number, r: AdminUser) => (
<div style={{ lineHeight: 1.7 }}>
<Typography.Text strong style={{ color: v > 0 ? '#10b981' : '#ef4444', fontSize: 15 }}> {Number(v || 0).toLocaleString()}</Typography.Text>
<div style={{ color: '#64748b', fontSize: 12 }}>
{Number(r.personalCredits || 0).toLocaleString()} / {Number(r.teamAvailableCredits || 0).toLocaleString()}
</div>
{Number(r.teamFrozenCredits || 0) > 0 && <div style={{ color: '#f59e0b', fontSize: 12 }}> {Number(r.teamFrozenCredits || 0).toLocaleString()}</div>}
</div>
),
}] : []),
{
@@ -551,7 +613,7 @@ const AdminUsers: React.FC = () => {
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text>,
},
{
title: '操作', key: 'action', width: 560, fixed: 'right' as const,
title: '操作', key: 'action', width: 650, fixed: 'right' as const,
render: (_: any, r: AdminUser) => (
<Space size={4} wrap>
{!isAdminTab && (
@@ -570,6 +632,11 @@ const AdminUsers: React.FC = () => {
</Button>
)}
{!isAdminTab && (
<Button type="link" size="small" icon={<PlusOutlined />} onClick={() => openOfflineModal(r)}>
线
</Button>
)}
{!isAdminTab && (
<Button type="link" size="small" icon={<DatabaseOutlined />}
onClick={() => openCapacityModal(r)}>
@@ -806,6 +873,74 @@ const AdminUsers: React.FC = () => {
</Form>
</Modal>
<Modal
title={<Space><WalletOutlined />线 - {offlineModal.user?.username}</Space>}
open={offlineModal.open}
onOk={handleOfflineSubscription}
confirmLoading={offlineSaving}
okText="确认成交"
cancelText="取消"
width={620}
onCancel={() => {
setOfflineModal({ open: false, user: null });
offlineForm.resetFields();
}}
>
<Typography.Paragraph type="secondary" style={{ marginBottom: 16 }}>
线 使 0
</Typography.Paragraph>
<Form form={offlineForm} layout="vertical" initialValues={{ quantity: 1, paymentMethod: 'bank_transfer' }}>
<Form.Item name="productId" label="成交套餐" rules={[{ required: true, message: '请选择成交套餐' }]}>
<Select
showSearch
optionFilterProp="label"
placeholder="请选择个人订阅或团队订阅套餐"
options={offlineProducts.map((item) => ({
value: item.id,
label: `${item.productType === 'team_subscription' ? '团队订阅' : '个人订阅'}${item.name}`,
}))}
onChange={(value) => {
const product = offlineProducts.find((item) => item.id === value);
offlineForm.setFieldValue('quantity', product?.productType === 'team_subscription' ? 2 : 1);
}}
/>
</Form.Item>
{selectedOfflineProduct?.productType === 'team_subscription' && (
<Form.Item
name="quantity"
label="团队席位数量"
rules={[{ required: true, message: '请输入团队席位数量' }]}
extra="后台单张团队订阅允许 2~1000 席;成交后数量永久按订单快照固定。"
>
<InputNumber min={2} max={1000} precision={0} style={{ width: '100%' }} />
</Form.Item>
)}
<Form.Item
name="actualPaidAmount"
label="实际成交总额(元)"
extra="留空使用后端定价器计算出的系统报价;填写后以该整单总额作为实际收入。0 元属于合法真实成交,不按赠送处理。"
>
<InputNumber min={0} precision={2} stringMode style={{ width: '100%' }} placeholder="留空则使用系统报价" />
</Form.Item>
<Form.Item name="paymentMethod" label="线下收款方式" rules={[{ required: true, message: '请选择收款方式' }]}>
<Select options={[
{ value: 'bank_transfer', label: '银行转账' },
{ value: 'cash', label: '现金' },
{ value: 'other', label: '其他' },
]} />
</Form.Item>
<Form.Item name="offlineTradeNo" label="线下流水/凭证号(选填)">
<Input maxLength={128} placeholder="可填写银行流水号、收款凭证号等" />
</Form.Item>
<Form.Item name="offlinePaymentDetail" label="具体收款方式(选填)">
<Input maxLength={128} placeholder="未填写且选择“其他”时,系统显示“其他-线下收款”" />
</Form.Item>
<Form.Item name="remark" label="成交备注(选填)">
<Input.TextArea rows={3} maxLength={500} showCount placeholder="填写商务成交、售后置换等必要说明" />
</Form.Item>
</Form>
</Modal>
<Modal
title={<Space><WalletOutlined /> - {creditDetailModal.user?.username}</Space>}
open={creditDetailModal.open}
@@ -820,7 +955,10 @@ const AdminUsers: React.FC = () => {
>
<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?.personalCredits || 0).toLocaleString()}</div></Card>
<Card size="small"><Typography.Text type="secondary"></Typography.Text><div style={{ fontSize: 22, fontWeight: 700 }}>{Number(creditSummary?.teamAvailableCredits || 0).toLocaleString()}</div></Card>
<Card size="small"><Typography.Text type="secondary"></Typography.Text><div style={{ fontSize: 22, fontWeight: 700 }}>{Number(creditSummary?.teamFrozenCredits || 0).toLocaleString()}</div></Card>
<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>
@@ -857,7 +995,7 @@ const AdminUsers: React.FC = () => {
{ title: '已撤销', dataIndex: 'revokedAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() },
{ title: '生效时间', dataIndex: 'validFrom', width: 170, render: (v: string) => formatDate(v) },
{ title: '最后可用时间', dataIndex: 'lastUsableAt', width: 180, render: (v: string) => formatDate(v) },
{ title: '状态', dataIndex: 'status', width: 90, render: (v: string, r: any) => <Tag color={v === 'active' ? 'green' : v === 'expired' ? 'orange' : v === 'revoked' ? 'red' : 'default'}>{r.statusLabel || v || '-'}</Tag> },
{ title: '状态', dataIndex: 'status', width: 90, render: (v: string, r: any) => <Tag color={v === 'active' ? 'green' : v === 'expired' ? 'orange' : v === 'revoked' ? 'red' : 'default'}>{r.statusLabel || '其他状态'}</Tag> },
]}
scroll={{ x: 1410, y: 460 }}
/>
+72 -42
View File
@@ -143,12 +143,16 @@ export interface AdminTeam {
code?: string | null;
description?: string | null;
status: AdminTeamStatus;
statusLabel?: string;
isReadOnly?: boolean;
teamCreditFrozen?: boolean;
sortOrder: number;
memberCount: number;
createdAt: string;
updatedAt?: string | null;
managerId?: string | null;
managerName?: string | null;
firstSubscriptionPaidAt?: string | null;
}
export interface AdminTeamOption {
@@ -186,6 +190,9 @@ export interface AdminUser {
email: string;
phone?: string;
credits: number;
personalCredits?: number;
teamAvailableCredits?: number;
teamFrozenCredits?: number;
isActive: boolean;
isAdmin: boolean;
userType: string;
@@ -249,26 +256,21 @@ export interface AdminStats {
}
export interface PaymentStats {
byStatus: Record<string, { count: number; amount: number }>;
today: { paidCount: number; paidAmount: number };
month: { paidCount: number; paidAmount: number };
recent: PaymentOrder[];
byStatus: Record<string, { label?: string; count: number; amount: number }>;
bySource: Record<string, { label?: string; count: number; amount: number }>;
totalIncome: { label?: string; count: number; amount: number };
today: { paidCount: number; paidAmount: number; onlinePaidCount: number; onlinePaidAmount: number; offlinePaidCount: number; offlinePaidAmount: number };
month: { paidCount: number; paidAmount: number; onlinePaidCount: number; onlinePaidAmount: number; offlinePaidCount: number; offlinePaidAmount: number };
recent?: PaymentOrder[];
}
export interface PaymentOrder {
id: string;
orderNo: string;
userId: string;
username?: string;
amount: number;
credits: number;
paymentMethod: string;
status: string;
tradeNo?: string;
paidAt?: string;
createdAt: string;
refundedAt?: string;
refundAmount?: number;
id: string; orderNo: string; userId: string; username?: string; phone?: string; amount: number; credits: number; quantity: number;
paymentMethod: string; paymentMethodLabel?: string; orderSource: 'online_payment' | 'admin_offline' | string; orderSourceLabel?: string;
status: string; statusLabel?: string; productId?: string | null; productType?: string | null; productNameSnapshot?: string | null;
quotedUnitPriceSnapshot?: number | null; quotedAmountSnapshot?: number | null; actualUnitPriceSnapshot?: number | null;
fulfillmentStatus?: string | null; fulfillmentStatusLabel?: string | null; tradeNo?: string | null; offlineTradeNo?: string | null; offlinePaymentDetail?: string | null; remark?: string | null;
paidAt?: string | null; createdAt: string; refundedAt?: string | null; refundAmount?: number | null; refundTradeNo?: string | null; refundEntitlementStatus?: string | null;
}
export interface ModelConfig {
@@ -910,6 +912,26 @@ export interface AdminCreditRecordAllocation {
amount: number;
creditLevel?: string;
creditLevelLabel?: string;
creditScope?: string;
creditScopeLabel?: string;
teamId?: string | null;
teamManagerId?: string | null;
subscriptionId?: string | null;
subscriptionNo?: string | null;
productName?: string | null;
productType?: string | null;
productTypeLabel?: string | null;
tierCode?: string | null;
tierLabel?: string | null;
tierRank?: number | null;
billingCycle?: string | null;
billingCycleLabel?: string | null;
subscriptionPeriodId?: string | null;
periodSequence?: number | null;
periodLabel?: string | null;
periodValidFrom?: string | null;
periodExpiresAt?: string | null;
seatId?: string | null;
sourceType?: string;
sourceTypeLabel?: string;
sourceId?: string;
@@ -921,6 +943,27 @@ export interface AdminCreditRecordAllocation {
consumedAfter?: number;
}
export interface AdminCreditRecordSubscriptionUsage {
creditScope?: string | null;
creditScopeLabel?: string | null;
subscriptionId?: string | null;
subscriptionNo: string;
productName?: string | null;
productType?: string | null;
productTypeLabel?: string | null;
tierCode?: string | null;
tierLabel?: string | null;
tierRank?: number | null;
billingCycle?: string | null;
billingCycleLabel?: string | null;
subscriptionPeriodId?: string | null;
periodSequence?: number | null;
periodLabel?: string | null;
periodValidFrom?: string | null;
periodExpiresAt?: string | null;
amount: number;
}
export interface AdminCreditRecord {
id: string;
userId: string;
@@ -964,6 +1007,11 @@ export interface AdminCreditRecord {
llmCallCount?: number;
llmSuccessCallCount?: number;
llmFailedCallCount?: number;
fundingScope?: string;
fundingScopeLabel?: string;
teamAllocationAmount?: number;
personalAllocationAmount?: number;
subscriptionUsages?: AdminCreditRecordSubscriptionUsage[];
allocations?: AdminCreditRecordAllocation[];
sourceModule?: string;
sourceModuleLabel?: string;
@@ -997,6 +1045,7 @@ export interface AdminCreditRecordQueryParams {
userType?: string;
frontendUserKind?: string;
teamId?: string;
subscriptionNo?: string;
recordType?: string;
type?: string;
creditSubject?: string;
@@ -1473,34 +1522,15 @@ export interface VideoUpscaleConfigSavePayload {
}
// ── Dynamic Credit Products / LLM Billing ─────────────────
export type CreditProductType = 'subscription' | 'credit_addon';
export type CreditProductType = 'subscription' | 'team_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;
id: string; productCode: string; productType: CreditProductType; productTypeLabel?: string; name: string; description?: string | null; features?: string[];
tierCode?: string | null; tierLabel?: string | null; tierRank?: number | null; billingCycle?: SubscriptionBillingCycle | null; billingCycleLabel?: string | null;
monthlyGrantCredits?: number; grantCount?: number; firstPurchasePrice?: number; regularPrice?: number; activityPrice?: number | null; activityStartAt?: string | null; activityEndAt?: string | null; renewalEnabled: boolean;
price: number; currentPrice?: number; grantCredits?: number; validityMonths?: number | null; creditLevel: 'promotional' | 'general'; creditLevelLabel?: string; currency: string;
isActive: boolean; isDeleted?: boolean; deletedAt?: string | null; statusLabel?: string; sortOrder: number;
}
export interface LlmBillingPolicy {
@@ -0,0 +1,444 @@
"""个人/团队订阅多实例、团队席位与统一线下订单
Revision ID: 20260814_multi_sub_team
Revises: 20260813_split_device_type
Create Date: 2026-08-14 11:31:00
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "20260814_multi_sub_team"
down_revision = "20260813_bank_scheduled"
branch_labels = None
depends_on = None
def _columns(table_name: str) -> set[str]:
inspector = sa.inspect(op.get_bind())
if table_name not in inspector.get_table_names():
return set()
return {item["name"] for item in inspector.get_columns(table_name)}
def _indexes(table_name: str) -> set[str]:
inspector = sa.inspect(op.get_bind())
if table_name not in inspector.get_table_names():
return set()
return {item["name"] for item in inspector.get_indexes(table_name)}
def _constraints(table_name: str) -> set[str]:
inspector = sa.inspect(op.get_bind())
if table_name not in inspector.get_table_names():
return set()
names = {item.get("name") for item in inspector.get_check_constraints(table_name)}
names |= {item.get("name") for item in inspector.get_unique_constraints(table_name)}
names |= {item.get("name") for item in inspector.get_foreign_keys(table_name)}
return {name for name in names if name}
def _add_column(table: str, column: sa.Column) -> None:
if column.name not in _columns(table):
op.add_column(table, column)
def _drop_column(table: str, name: str) -> None:
if name in _columns(table):
op.drop_column(table, name)
def _drop_index(table: str, name: str) -> None:
if name in _indexes(table):
op.drop_index(name, table_name=table)
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
tables = set(inspector.get_table_names())
# Product:上下架与软删除分离;保留续费开关;product_code 保持原全局唯一索引。
_add_column("credit_products", sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True))
if "uq_credit_products_code" not in _indexes("credit_products"):
op.create_index("uq_credit_products_code", "credit_products", ["product_code"], unique=True)
if "ix_credit_products_deleted_at" not in _indexes("credit_products"):
op.create_index("ix_credit_products_deleted_at", "credit_products", ["deleted_at"], unique=False)
# 旧版本同名索引不含 deleted_at,必须重建,不能只按名称判断存在。
_drop_index("credit_products", "ix_credit_products_public")
op.create_index("ix_credit_products_public", "credit_products", ["product_type", "deleted_at", "is_active", "sort_order"])
_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'")
if "ck_credit_products_type_required_fields" in _constraints("credit_products"):
op.drop_constraint("ck_credit_products_type_required_fields", "credit_products", type_="check")
op.create_check_constraint(
"ck_credit_products_type_required_fields",
"credit_products",
"(product_type IN ('subscription', 'team_subscription') "
"AND tier_code IS NOT NULL AND tier_rank IS NOT NULL "
"AND billing_cycle IS NOT NULL AND monthly_grant_credits IS NOT NULL "
"AND first_purchase_price IS NOT NULL AND regular_price IS NOT NULL "
"AND grant_credits IS NULL AND validity_months IS NULL) "
"OR (product_type = 'credit_addon' AND grant_credits IS NOT NULL "
"AND validity_months BETWEEN 1 AND 36 AND tier_code IS NULL AND tier_rank IS NULL "
"AND billing_cycle IS NULL AND monthly_grant_credits IS NULL "
"AND first_purchase_price IS NULL AND regular_price IS NULL "
"AND activity_price IS NULL AND activity_start_at IS NULL AND activity_end_at IS NULL)",
)
# Subscription:个人/团队共表,多实例独立,不再保留升级链。
_add_column("user_credit_subscriptions", sa.Column("team_id", sa.String(32), nullable=True))
_add_column("user_credit_subscriptions", sa.Column("team_manager_id_snapshot", sa.String(32), nullable=True))
_add_column("user_credit_subscriptions", sa.Column("product_type_snapshot", sa.String(24), nullable=True))
_add_column("user_credit_subscriptions", sa.Column("product_name_snapshot", sa.String(96), nullable=True))
_add_column("user_credit_subscriptions", sa.Column("monthly_total_credits_snapshot", sa.Numeric(20, 2), nullable=True))
_add_column("user_credit_subscriptions", sa.Column("quantity_snapshot", sa.Integer(), nullable=True, server_default="1"))
_add_column("user_credit_subscriptions", sa.Column("first_purchase_price_snapshot", sa.Numeric(20, 2), nullable=True, server_default="0"))
_add_column("user_credit_subscriptions", sa.Column("regular_price_snapshot", sa.Numeric(20, 2), nullable=True, server_default="0"))
_add_column("user_credit_subscriptions", sa.Column("activity_price_snapshot", sa.Numeric(20, 2), nullable=True))
_add_column("user_credit_subscriptions", sa.Column("actual_unit_price_snapshot", sa.Numeric(20, 6), nullable=True, server_default="0"))
if "fk_user_credit_subscriptions_team" not in _constraints("user_credit_subscriptions"):
op.create_foreign_key(
"fk_user_credit_subscriptions_team", "user_credit_subscriptions", "teams", ["team_id"], ["id"], ondelete="RESTRICT"
)
op.execute(
"UPDATE user_credit_subscriptions s SET "
"product_type_snapshot = COALESCE(product_type_snapshot, 'subscription'), "
"product_name_snapshot = COALESCE(product_name_snapshot, NULLIF(s.product_snapshot_json->>'name',''), '历史订阅套餐'), "
"monthly_total_credits_snapshot = COALESCE(monthly_total_credits_snapshot, monthly_grant_credits_snapshot), "
"quantity_snapshot = COALESCE(quantity_snapshot, 1), "
"first_purchase_price_snapshot = COALESCE(first_purchase_price_snapshot, NULLIF(s.product_snapshot_json->>'first_purchase_price','')::numeric, 0), "
"regular_price_snapshot = COALESCE(regular_price_snapshot, NULLIF(s.product_snapshot_json->>'regular_price','')::numeric, paid_amount_snapshot), "
"activity_price_snapshot = COALESCE(activity_price_snapshot, NULLIF(s.product_snapshot_json->>'activity_price','')::numeric), "
"actual_unit_price_snapshot = COALESCE(actual_unit_price_snapshot, paid_amount_snapshot)"
)
for col in (
"product_type_snapshot", "product_name_snapshot", "monthly_total_credits_snapshot",
"quantity_snapshot", "first_purchase_price_snapshot", "regular_price_snapshot", "actual_unit_price_snapshot",
):
if col in _columns("user_credit_subscriptions"):
op.alter_column("user_credit_subscriptions", col, nullable=False)
_drop_column("user_credit_subscriptions", "source_subscription_id")
_drop_column("user_credit_subscriptions", "upgrade_order_id")
_drop_index("user_credit_subscriptions", "ix_user_credit_subscriptions_current")
if "ix_user_credit_subscriptions_user_active" not in _indexes("user_credit_subscriptions"):
op.create_index("ix_user_credit_subscriptions_user_active", "user_credit_subscriptions", ["user_id", "status", "expires_at"])
if "ix_user_credit_subscriptions_team_active" not in _indexes("user_credit_subscriptions"):
op.create_index("ix_user_credit_subscriptions_team_active", "user_credit_subscriptions", ["team_id", "status", "expires_at"])
# Period:删除升级预留字段,补充窗口索引。
_drop_index("user_credit_subscription_periods", "ix_user_credit_subscription_periods_upgrade")
_drop_column("user_credit_subscription_periods", "upgrade_order_id")
_drop_column("user_credit_subscription_periods", "reserved_at")
_drop_column("user_credit_subscription_periods", "revoked_at")
if "ix_user_credit_subscription_periods_window" not in _indexes("user_credit_subscription_periods"):
op.create_index(
"ix_user_credit_subscription_periods_window",
"user_credit_subscription_periods",
["subscription_id", "valid_from", "expires_at"],
)
# Balance:增加个人/团队资金域。
_add_column("user_credit_balances", sa.Column("credit_scope", sa.String(16), nullable=True, server_default="personal"))
_add_column("user_credit_balances", sa.Column("team_id", sa.String(32), nullable=True))
op.execute("UPDATE user_credit_balances SET credit_scope='personal' WHERE credit_scope IS NULL")
op.alter_column("user_credit_balances", "credit_scope", nullable=False, server_default="personal")
if "fk_user_credit_balances_team" not in _constraints("user_credit_balances"):
op.create_foreign_key("fk_user_credit_balances_team", "user_credit_balances", "teams", ["team_id"], ["id"], ondelete="RESTRICT")
if "ck_user_credit_balances_scope_fields" not in _constraints("user_credit_balances"):
op.create_check_constraint(
"ck_user_credit_balances_scope_fields",
"user_credit_balances",
"(credit_scope='personal' AND team_id IS NULL) OR "
"(credit_scope='team' AND team_id IS NOT NULL AND subscription_id IS NOT NULL AND subscription_period_id IS NOT NULL)",
)
_drop_index("user_credit_balances", "ix_user_credit_balances_spendable")
op.create_index(
"ix_user_credit_balances_spendable",
"user_credit_balances",
["user_id", "credit_scope", "credit_level_rank", "expires_at", "valid_from", "id"],
postgresql_where=sa.text("unspent_amount > 0 AND revoked_at IS NULL"),
)
if "ix_user_credit_balances_team_spendable" not in _indexes("user_credit_balances"):
op.create_index(
"ix_user_credit_balances_team_spendable",
"user_credit_balances",
["team_id", "subscription_id", "subscription_period_id", "credit_level_rank", "expires_at", "id"],
postgresql_where=sa.text("credit_scope = 'team' AND unspent_amount > 0 AND revoked_at IS NULL"),
)
# Allocation:资金来源、团队任期、Subscription/Period/Seat 全部冷备。
allocation_columns = [
sa.Column("credit_scope_snapshot", sa.String(16), nullable=True, server_default="personal"),
sa.Column("team_id_snapshot", sa.String(32), nullable=True),
sa.Column("team_manager_id_snapshot", sa.String(32), nullable=True),
sa.Column("subscription_id_snapshot", sa.String(32), nullable=True),
sa.Column("subscription_period_id_snapshot", sa.String(32), nullable=True),
sa.Column("seat_id_snapshot", sa.String(32), nullable=True),
]
for column in allocation_columns:
_add_column("credit_record_allocations", column)
op.execute("UPDATE credit_record_allocations SET credit_scope_snapshot='personal' WHERE credit_scope_snapshot IS NULL")
op.alter_column("credit_record_allocations", "credit_scope_snapshot", nullable=False, server_default="personal")
for name, cols in (
("ix_credit_record_allocations_team_time", ["team_id_snapshot", "created_at", "id"]),
("ix_credit_record_allocations_team_manager_time", ["team_id_snapshot", "team_manager_id_snapshot", "created_at", "id"]),
("ix_credit_record_allocations_team_period_user", ["subscription_period_id_snapshot", "user_id", "allocation_action"]),
):
if name not in _indexes("credit_record_allocations"):
op.create_index(name, "credit_record_allocations", cols)
# PaymentOrder:线上/线下统一主表,删除升级价格字段。
payment_columns = [
sa.Column("order_source", sa.String(24), nullable=True, server_default="online_payment"),
sa.Column("quantity", sa.Integer(), nullable=True, server_default="1"),
sa.Column("quoted_unit_price_snapshot", sa.Numeric(20, 2), nullable=True),
sa.Column("quoted_amount_snapshot", sa.Numeric(20, 2), nullable=True),
sa.Column("actual_unit_price_snapshot", sa.Numeric(20, 6), nullable=True),
sa.Column("team_id_snapshot", sa.String(32), nullable=True),
sa.Column("operator_admin_id", sa.String(32), nullable=True),
sa.Column("offline_trade_no", sa.String(128), nullable=True),
sa.Column("offline_payment_detail", sa.String(128), nullable=True),
sa.Column("remark", sa.String(512), nullable=True),
sa.Column("refund_entitlement_status", sa.String(32), nullable=True),
]
for column in payment_columns:
_add_column("payment_orders", column)
op.execute(
"UPDATE payment_orders SET order_source=COALESCE(order_source,'online_payment'), quantity=COALESCE(quantity,1), "
"quoted_amount_snapshot=COALESCE(quoted_amount_snapshot, amount), "
"quoted_unit_price_snapshot=COALESCE(quoted_unit_price_snapshot, amount), "
"actual_unit_price_snapshot=COALESCE(actual_unit_price_snapshot, amount)"
)
op.alter_column("payment_orders", "order_source", nullable=False, server_default="online_payment")
op.alter_column("payment_orders", "quantity", nullable=False, server_default="1")
for col in (
"source_subscription_id", "upgrade_period_ids_json", "target_price_snapshot",
"deduction_amount_snapshot", "payable_amount_snapshot",
):
_drop_column("payment_orders", col)
if "fk_payment_orders_team_snapshot" not in _constraints("payment_orders"):
op.create_foreign_key("fk_payment_orders_team_snapshot", "payment_orders", "teams", ["team_id_snapshot"], ["id"], ondelete="RESTRICT")
if "fk_payment_orders_operator_admin" not in _constraints("payment_orders"):
op.create_foreign_key("fk_payment_orders_operator_admin", "payment_orders", "users", ["operator_admin_id"], ["id"], ondelete="SET NULL")
if "ix_payorder_source_status_created" not in _indexes("payment_orders"):
op.create_index("ix_payorder_source_status_created", "payment_orders", ["order_source", "status", "created_at"])
if "ix_payorder_team_status_created" not in _indexes("payment_orders"):
op.create_index("ix_payorder_team_status_created", "payment_orders", ["team_id_snapshot", "status", "created_at"])
_add_column("teams", sa.Column("first_subscription_paid_at", sa.DateTime(timezone=True), nullable=True))
if "ix_teams_first_subscription_paid_at" not in _indexes("teams"):
op.create_index("ix_teams_first_subscription_paid_at", "teams", ["first_subscription_paid_at"])
if bind.dialect.name == "postgresql":
op.execute("CREATE SEQUENCE IF NOT EXISTS team_auto_name_seq START WITH 1 INCREMENT BY 1")
# 避免历史上已经存在“团队000N”时从1开始产生重名;is_called=false 让下一次 nextval 直接返回 max+1。
op.execute(
"SELECT setval('team_auto_name_seq', "
"GREATEST(COALESCE(MAX((substring(name from '^团队([0-9]+)$'))::bigint), 0) + 1, 1), false) "
"FROM teams WHERE name ~ '^团队[0-9]+$'"
)
if "team_manager_history" not in tables:
op.create_table(
"team_manager_history",
sa.Column("id", sa.String(32), primary_key=True),
sa.Column("team_id", sa.String(32), sa.ForeignKey("teams.id", ondelete="RESTRICT"), nullable=False),
sa.Column("manager_user_id", sa.String(32), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("ended_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
op.create_index("ix_team_manager_history_team_time", "team_manager_history", ["team_id", "started_at", "ended_at"])
op.create_index("ix_team_manager_history_manager_time", "team_manager_history", ["manager_user_id", "started_at", "ended_at"])
op.create_index(
"uq_team_manager_history_current",
"team_manager_history",
["team_id"],
unique=True,
postgresql_where=sa.text("ended_at IS NULL"),
)
op.execute(
"INSERT INTO team_manager_history(id, team_id, manager_user_id, started_at, created_at, updated_at) "
"SELECT md5(random()::text || clock_timestamp()::text), id, manager_id, COALESCE(created_at, now()), now(), now() "
"FROM teams WHERE manager_id IS NOT NULL AND deleted_at IS NULL"
)
if "team_subscription_seats" not in tables:
op.create_table(
"team_subscription_seats",
sa.Column("id", sa.String(32), primary_key=True),
sa.Column("team_id", sa.String(32), sa.ForeignKey("teams.id", ondelete="RESTRICT"), nullable=False),
sa.Column("subscription_id", sa.String(32), sa.ForeignKey("user_credit_subscriptions.id", ondelete="RESTRICT"), nullable=False),
sa.Column("user_id", sa.String(32), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False),
sa.Column("monthly_allocated_credits", sa.Numeric(20, 2), nullable=False),
sa.Column("created_by_user_id", sa.String(32), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False),
sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.CheckConstraint("monthly_allocated_credits > 0", name="ck_team_subscription_seat_allocation_positive"),
)
op.create_index("ix_team_subscription_seats_subscription", "team_subscription_seats", ["subscription_id", "created_at"])
op.create_index("ix_team_subscription_seats_team_id", "team_subscription_seats", ["team_id"])
op.create_index("ix_team_subscription_seats_deleted_at", "team_subscription_seats", ["deleted_at"])
op.create_index("ix_team_subscription_seats_user", "team_subscription_seats", ["user_id", "subscription_id"])
op.create_index(
"uq_team_subscription_seats_active_user",
"team_subscription_seats",
["subscription_id", "user_id"],
unique=True,
postgresql_where=sa.text("deleted_at IS NULL AND cancelled_at IS NULL"),
)
if "team_subscription_seat_usages" not in tables:
op.create_table(
"team_subscription_seat_usages",
sa.Column("id", sa.String(32), primary_key=True),
sa.Column("seat_id", sa.String(32), sa.ForeignKey("team_subscription_seats.id", ondelete="RESTRICT"), nullable=False),
sa.Column("subscription_id", sa.String(32), sa.ForeignKey("user_credit_subscriptions.id", ondelete="RESTRICT"), nullable=False),
sa.Column("subscription_period_id", sa.String(32), sa.ForeignKey("user_credit_subscription_periods.id", ondelete="RESTRICT"), nullable=False),
sa.Column("user_id", sa.String(32), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False),
sa.Column("used_credits", sa.Numeric(20, 2), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.CheckConstraint("used_credits >= 0", name="ck_team_subscription_seat_usage_nonnegative"),
)
op.create_index(
"uq_team_subscription_seat_usage_period",
"team_subscription_seat_usages",
["seat_id", "subscription_period_id"],
unique=True,
)
op.create_index("ix_team_subscription_seat_usage_member", "team_subscription_seat_usages", ["subscription_period_id", "user_id"])
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
tables = set(inspector.get_table_names())
if "team_subscription_seat_usages" in tables:
op.drop_table("team_subscription_seat_usages")
if "team_subscription_seats" in tables:
op.drop_table("team_subscription_seats")
if "team_manager_history" in tables:
op.drop_table("team_manager_history")
if bind.dialect.name == "postgresql":
op.execute("DROP SEQUENCE IF EXISTS team_auto_name_seq")
_drop_index("teams", "ix_teams_first_subscription_paid_at")
_drop_column("teams", "first_subscription_paid_at")
for name in ("ix_payorder_team_status_created", "ix_payorder_source_status_created"):
_drop_index("payment_orders", name)
for fk in ("fk_payment_orders_operator_admin", "fk_payment_orders_team_snapshot"):
if fk in _constraints("payment_orders"):
op.drop_constraint(fk, "payment_orders", type_="foreignkey")
for col in (
"refund_entitlement_status", "remark", "offline_payment_detail", "offline_trade_no",
"operator_admin_id", "team_id_snapshot", "actual_unit_price_snapshot", "quoted_amount_snapshot",
"quoted_unit_price_snapshot", "quantity", "order_source",
):
_drop_column("payment_orders", col)
_add_column("payment_orders", sa.Column("source_subscription_id", sa.String(32), nullable=True))
if "ix_payment_orders_source_subscription_id" not in _indexes("payment_orders"):
op.create_index("ix_payment_orders_source_subscription_id", "payment_orders", ["source_subscription_id"])
_add_column("payment_orders", sa.Column("upgrade_period_ids_json", sa.JSON(), nullable=True))
_add_column("payment_orders", sa.Column("target_price_snapshot", sa.Numeric(20, 2), nullable=True))
_add_column("payment_orders", sa.Column("deduction_amount_snapshot", sa.Numeric(20, 2), nullable=True))
_add_column("payment_orders", sa.Column("payable_amount_snapshot", sa.Numeric(20, 2), nullable=True))
for name in (
"ix_credit_record_allocations_team_period_user",
"ix_credit_record_allocations_team_manager_time",
"ix_credit_record_allocations_team_time",
):
_drop_index("credit_record_allocations", name)
for col in (
"seat_id_snapshot", "subscription_period_id_snapshot", "subscription_id_snapshot",
"team_manager_id_snapshot", "team_id_snapshot", "credit_scope_snapshot",
):
_drop_column("credit_record_allocations", col)
_drop_index("user_credit_balances", "ix_user_credit_balances_team_spendable")
_drop_index("user_credit_balances", "ix_user_credit_balances_spendable")
if "ck_user_credit_balances_scope_fields" in _constraints("user_credit_balances"):
op.drop_constraint("ck_user_credit_balances_scope_fields", "user_credit_balances", type_="check")
if "fk_user_credit_balances_team" in _constraints("user_credit_balances"):
op.drop_constraint("fk_user_credit_balances_team", "user_credit_balances", type_="foreignkey")
_drop_column("user_credit_balances", "team_id")
_drop_column("user_credit_balances", "credit_scope")
op.create_index(
"ix_user_credit_balances_spendable",
"user_credit_balances",
["user_id", "credit_level_rank", "expires_at", "valid_from", "id"],
postgresql_where=sa.text("unspent_amount > 0 AND revoked_at IS NULL"),
)
_drop_index("user_credit_subscription_periods", "ix_user_credit_subscription_periods_window")
_add_column("user_credit_subscription_periods", sa.Column("upgrade_order_id", sa.String(32), nullable=True))
if "fk_user_credit_subscription_periods_upgrade_order" not in _constraints("user_credit_subscription_periods"):
op.create_foreign_key(
"fk_user_credit_subscription_periods_upgrade_order",
"user_credit_subscription_periods", "payment_orders", ["upgrade_order_id"], ["id"], ondelete="SET NULL"
)
_add_column("user_credit_subscription_periods", sa.Column("reserved_at", sa.DateTime(timezone=True), nullable=True))
_add_column("user_credit_subscription_periods", sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True))
op.create_index("ix_user_credit_subscription_periods_upgrade", "user_credit_subscription_periods", ["upgrade_order_id", "status"])
_drop_index("user_credit_subscriptions", "ix_user_credit_subscriptions_team_active")
_drop_index("user_credit_subscriptions", "ix_user_credit_subscriptions_user_active")
if "fk_user_credit_subscriptions_team" in _constraints("user_credit_subscriptions"):
op.drop_constraint("fk_user_credit_subscriptions_team", "user_credit_subscriptions", type_="foreignkey")
for col in (
"actual_unit_price_snapshot", "activity_price_snapshot", "regular_price_snapshot",
"first_purchase_price_snapshot", "quantity_snapshot", "monthly_total_credits_snapshot",
"product_name_snapshot", "product_type_snapshot", "team_manager_id_snapshot", "team_id",
):
_drop_column("user_credit_subscriptions", col)
_add_column("user_credit_subscriptions", sa.Column("source_subscription_id", sa.String(32), nullable=True))
_add_column("user_credit_subscriptions", sa.Column("upgrade_order_id", sa.String(32), nullable=True))
if "fk_user_credit_subscriptions_source_subscription" not in _constraints("user_credit_subscriptions"):
op.create_foreign_key(
"fk_user_credit_subscriptions_source_subscription",
"user_credit_subscriptions", "user_credit_subscriptions", ["source_subscription_id"], ["id"], ondelete="SET NULL"
)
if "fk_user_credit_subscriptions_upgrade_order" not in _constraints("user_credit_subscriptions"):
op.create_foreign_key(
"fk_user_credit_subscriptions_upgrade_order",
"user_credit_subscriptions", "payment_orders", ["upgrade_order_id"], ["id"], ondelete="SET NULL"
)
op.create_index("ix_user_credit_subscriptions_current", "user_credit_subscriptions", ["user_id", "status", "expires_at"])
if "ck_credit_products_type_required_fields" in _constraints("credit_products"):
op.drop_constraint("ck_credit_products_type_required_fields", "credit_products", type_="check")
# 旧版本不认识 team_subscription。降级时保留商品与永久唯一 product_code
# 但将团队套餐转换为旧版可识别的 subscription 并强制下架,避免旧代码误售。
op.execute(
"UPDATE credit_products "
"SET product_type='subscription', is_active=false "
"WHERE product_type='team_subscription'"
)
op.create_check_constraint(
"ck_credit_products_type_required_fields",
"credit_products",
"(product_type = 'subscription' AND tier_code IS NOT NULL AND tier_rank IS NOT NULL "
"AND billing_cycle IS NOT NULL AND monthly_grant_credits IS NOT NULL "
"AND first_purchase_price IS NOT NULL AND regular_price IS NOT NULL "
"AND grant_credits IS NULL AND validity_months IS NULL) "
"OR (product_type = 'credit_addon' AND grant_credits IS NOT NULL "
"AND validity_months BETWEEN 1 AND 36 AND tier_code IS NULL AND tier_rank IS NULL "
"AND billing_cycle IS NULL AND monthly_grant_credits IS NULL "
"AND first_purchase_price IS NULL AND regular_price IS NULL "
"AND activity_price IS NULL AND activity_start_at IS NULL AND activity_end_at IS NULL)",
)
_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'")
_drop_index("credit_products", "ix_credit_products_public")
op.create_index("ix_credit_products_public", "credit_products", ["product_type", "is_active", "sort_order"])
_drop_index("credit_products", "ix_credit_products_deleted_at")
_drop_column("credit_products", "deleted_at")
@@ -0,0 +1,50 @@
"""兼容恢复积分套餐续费开关
Revision ID: 20260814_restore_renewal
Revises: 20260814_multi_sub_team
Create Date: 2026-08-14 13:25:00
说明:
- 修正版 20260814_multi_sub_team 已不再删除 renewal_enabled;新环境执行到本迁移时为 no-op。
- 如果数据库已经执行过上一版会删除 renewal_enabled 的同 revision 迁移,本迁移负责安全补回字段。
- 已经被旧迁移删除的历史 false 值无法从数据库自身恢复,补回时订阅套餐默认 true,积分增值包统一 false。
"""
from alembic import op
import sqlalchemy as sa
revision = "20260814_restore_renewal"
down_revision = "20260814_multi_sub_team"
branch_labels = None
depends_on = None
def _columns(table_name: str) -> set[str]:
inspector = sa.inspect(op.get_bind())
if table_name not in inspector.get_table_names():
return set()
return {item["name"] for item in inspector.get_columns(table_name)}
def upgrade() -> None:
if "renewal_enabled" not in _columns("credit_products"):
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'"
)
def downgrade() -> None:
# 当前修正版上一个 revision 本身就保留 renewal_enabled,降级到它时字段也应继续存在。
pass
@@ -0,0 +1,92 @@
"""订阅实例业务流水号
Revision ID: 20260814_subscription_no
Revises: 20260814_restore_renewal
Create Date: 2026-08-14 14:23:00
说明:
- 每张 user_credit_subscriptions 增加永久唯一 subscription_no。
- 个人订阅前缀 PS,团队订阅前缀 TS。
- 全局共用 PostgreSQL Sequence,避免 COUNT/MAX 并发冲突。
- 历史记录按原 created_at 日期生成业务编号;编号只用于展示/客服定位,不参与业务排序和结算。
"""
from alembic import op
import sqlalchemy as sa
revision = "20260814_subscription_no"
down_revision = "20260814_restore_renewal"
branch_labels = None
depends_on = None
def _columns(table_name: str) -> set[str]:
inspector = sa.inspect(op.get_bind())
if table_name not in inspector.get_table_names():
return set()
return {item["name"] for item in inspector.get_columns(table_name)}
def _indexes(table_name: str) -> set[str]:
inspector = sa.inspect(op.get_bind())
if table_name not in inspector.get_table_names():
return set()
return {item["name"] for item in inspector.get_indexes(table_name)}
def upgrade() -> None:
bind = op.get_bind()
if bind.dialect.name != "postgresql":
raise RuntimeError("订阅实例流水号迁移当前仅支持 PostgreSQL")
op.execute("CREATE SEQUENCE IF NOT EXISTS credit_subscription_no_seq START WITH 1 INCREMENT BY 1")
if "subscription_no" not in _columns("user_credit_subscriptions"):
op.add_column(
"user_credit_subscriptions",
sa.Column(
"subscription_no",
sa.String(length=32),
nullable=True,
comment="订阅业务实例编号,供用户/客服/开发定位",
),
)
# 历史数据一次性补号。日期使用东八区业务日期;Sequence 只保证唯一,不要求无跳号。
op.execute(
"""
UPDATE user_credit_subscriptions
SET subscription_no =
CASE
WHEN product_type_snapshot = 'team_subscription' THEN 'TS'
ELSE 'PS'
END
|| to_char(COALESCE(created_at, start_at, now()) AT TIME ZONE 'Asia/Shanghai', 'YYYYMMDD')
|| lpad(nextval('credit_subscription_no_seq')::text, 6, '0')
WHERE subscription_no IS NULL OR btrim(subscription_no) = ''
"""
)
op.alter_column(
"user_credit_subscriptions",
"subscription_no",
existing_type=sa.String(length=32),
nullable=False,
)
if "uq_user_credit_subscriptions_no" not in _indexes("user_credit_subscriptions"):
op.create_index(
"uq_user_credit_subscriptions_no",
"user_credit_subscriptions",
["subscription_no"],
unique=True,
)
def downgrade() -> None:
if "uq_user_credit_subscriptions_no" in _indexes("user_credit_subscriptions"):
op.drop_index("uq_user_credit_subscriptions_no", table_name="user_credit_subscriptions")
if "subscription_no" in _columns("user_credit_subscriptions"):
op.drop_column("user_credit_subscriptions", "subscription_no")
op.execute("DROP SEQUENCE IF EXISTS credit_subscription_no_seq")
@@ -0,0 +1,25 @@
"""merge changes from remote
Revision ID: e7f2527691bb
Revises: 20260814_bank_tx, 20260814_subscription_no
Create Date: 2026-08-14 15:32:14.351079
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e7f2527691bb'
down_revision: Union[str, None] = ('20260814_bank_tx', '20260814_subscription_no')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
+195 -104
View File
@@ -12,20 +12,22 @@ from app.enums.credit_balance import (
CREDIT_BALANCE_SOURCE_TYPE_LABELS,
CREDIT_BALANCE_STATUS_LABELS,
CREDIT_LEVEL_LABELS,
CREDIT_SCOPE_LABELS,
CreditBalanceSourceType,
CreditScope,
)
from app.enums.credit_product import CreditProductType
from app.models.credit.balance import UserCreditBalance
from app.models.credit.product import CreditProduct
from app.models.credit.subscription import UserCreditSubscription
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.schemas.credit_product import CreditProductCreate, CreditProductRenewalUpdate, CreditProductStatusUpdate, CreditProductUpdate
from app.schemas.credit_subscription import AdminOfflineSubscriptionCreate
from app.services.credit.ledger_service import deduct_credits, grant_credits
from app.services.credit.offline_subscription_service import create_offline_subscription_order
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.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
@@ -40,16 +42,17 @@ def _apply_product_payload(product: CreditProduct, payload: dict) -> None:
mapping = {"features": "features_json"}
for key, value in payload.items():
setattr(product, mapping.get(key, key), value)
if product.product_type == "credit_addon" and product.validity_months is None:
# 兼容旧管理端未提交有效期的请求,新建增值包仍默认1个月;
# 更新时未提交该字段则保留原值。
product.validity_months = 1
if product.product_type == "subscription":
if product.product_type in {
CreditProductType.SUBSCRIPTION.value,
CreditProductType.TEAM_SUBSCRIPTION.value,
}:
product.price = product.regular_price or 0
product.grant_credits = None
product.validity_months = None
else:
elif product.product_type == CreditProductType.CREDIT_ADDON.value:
product.renewal_enabled = False
if product.validity_months is None:
product.validity_months = 1
product.tier_code = None
product.tier_rank = None
product.billing_cycle = None
@@ -62,14 +65,17 @@ def _apply_product_payload(product: CreditProduct, payload: dict) -> None:
def _validate_product_entity(product: CreditProduct) -> None:
if product.product_type == "subscription":
if product.product_type in {
CreditProductType.SUBSCRIPTION.value,
CreditProductType.TEAM_SUBSCRIPTION.value,
}:
required = {
"套餐等级编码": product.tier_code,
"套餐等级顺序": product.tier_rank,
"订阅周期": product.billing_cycle,
"每月积分": product.monthly_grant_credits,
"价格": product.first_purchase_price,
"原价": product.regular_price,
"价格": product.first_purchase_price,
"常规价格": product.regular_price,
}
missing = [label for label, value in required.items() if value is None]
if missing:
@@ -81,9 +87,9 @@ def _validate_product_entity(product: CreditProduct) -> 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="积分增值包必须配置价格和积分数量")
elif product.product_type == CreditProductType.CREDIT_ADDON.value:
if product.grant_credits is None:
raise HTTPException(status_code=400, detail="积分增值包必须配置积分数量")
if product.validity_months is None or not 1 <= int(product.validity_months) <= 36:
raise HTTPException(status_code=400, detail="积分增值包有效期必须为1-36个月")
else:
@@ -99,7 +105,9 @@ async def list_products(
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))
result = await db.execute(
stmt.order_by(CreditProduct.deleted_at.asc(), CreditProduct.product_type, CreditProduct.sort_order, CreditProduct.id)
)
return [product_to_dict(item) for item in result.scalars().all()]
@@ -109,19 +117,25 @@ async def create_product(
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))
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="商品编码已存在")
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 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, message="积分商品创建成功", detail={"product_id": product.id},
)
await db.commit()
return snapshot
@@ -133,33 +147,26 @@ async def update_product(
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())
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.deleted_at is not None:
raise HTTPException(status_code=409, 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)
_apply_product_payload(product, data.model_dump(exclude_unset=True))
_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 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),
)
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)
return after
@router.put("/products/{product_id}/renewal")
@@ -170,61 +177,151 @@ async def update_product_renewal(
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(CreditProduct)
.where(CreditProduct.id == product_id)
.limit(1)
.with_for_update()
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":
if product.deleted_at is not None:
raise HTTPException(status_code=409, detail="商品已软删除,不能修改续费开关")
if product.product_type not in {
CreditProductType.SUBSCRIPTION.value,
CreditProductType.TEAM_SUBSCRIPTION.value,
}:
raise HTTPException(status_code=400, detail="积分增值包不支持续费开关")
before = bool(product.renewal_enabled)
product.renewal_enabled = bool(data.renewal_enabled)
await db.flush()
after = product_to_dict(product)
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,
),
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),
)
log_operation_event(
domain="credit_product", module="credit", event_type="CREDIT_PRODUCT_RENEWAL_UPDATED",
user_id=admin.id, message="积分套餐续费开关已更新",
detail={"product_id": product.id, "renewal_enabled": bool(product.renewal_enabled)},
)
await db.commit()
return after
# 提交后重新查询,返回数据库真实持久化结果,避免前端误用事务内快照。
refreshed = await db.execute(
select(CreditProduct).where(CreditProduct.id == product_id).limit(1)
@router.put("/products/{product_id}/status")
async def update_product_status(
product_id: str,
data: CreditProductStatusUpdate,
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()
)
persisted = refreshed.scalar_one_or_none()
if persisted is None:
product = result.scalar_one_or_none()
if not product:
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)
if product.deleted_at is not None:
raise HTTPException(status_code=409, detail="已软删除商品不能重新上架")
product.is_active = bool(data.is_active)
await db.flush()
await log_operation(
db, admin.id, admin.username,
f"{'上架' if product.is_active else '下架'}积分商品 {product.name}", "PUT",
f"/admin/credit-management/products/{product_id}/status",
)
await db.commit()
return product_to_dict(product)
@router.delete("/products/{product_id}")
async def disable_product(
async def soft_delete_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())
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}
if product.deleted_at is None:
product.is_active = False
product.deleted_at = utc_now()
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, "message": "商品已软删除,商品编码永久保留且不能恢复"}
@router.post("/users/{user_id}/offline-subscriptions")
async def create_offline_subscription(
user_id: str,
data: AdminOfflineSubscriptionCreate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
try:
order = await create_offline_subscription_order(
db,
target_user_id=user_id,
product_id=data.product_id,
operator_admin_id=admin.id,
payment_method=data.payment_method,
quantity=data.quantity,
actual_paid_amount=data.actual_paid_amount,
offline_trade_no=data.offline_trade_no,
offline_payment_detail=data.offline_payment_detail,
remark=data.remark,
)
order_no = str(order.order_no)
subscription_id = order.subscription_id
amount = float(order.amount)
await log_operation(
db, admin.id, admin.username, f"为用户 {user_id} 创建线下真实订阅成交", "POST",
f"/admin/credit-management/users/{user_id}/offline-subscriptions",
detail=json.dumps({"order_no": order_no, "subscription_id": subscription_id, "actual_paid_amount": amount}, ensure_ascii=False),
)
await db.commit()
return {"ok": True, "order_no": order_no, "subscription_id": subscription_id, "actual_paid_amount": amount}
except Exception:
await db.rollback()
raise
@router.get("/users/{user_id}/subscriptions")
async def list_user_subscriptions(
user_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(UserCreditSubscription)
.where(UserCreditSubscription.user_id == user_id)
.order_by(UserCreditSubscription.created_at.desc(), UserCreditSubscription.id.desc())
)
return [
{
"id": item.id,
"product_name": item.product_name_snapshot,
"product_type": item.product_type_snapshot,
"product_type_label": "团队订阅套餐" if item.product_type_snapshot == "team_subscription" else "个人订阅套餐",
"team_id": item.team_id,
"status": item.status,
"status_label": {"active": "有效", "expired": "已过期", "cancelled": "已取消", "pending": "待生效"}.get(item.status, "其他状态"),
"quantity": item.quantity_snapshot,
"monthly_total_credits": float(item.monthly_total_credits_snapshot),
"paid_amount": float(item.paid_amount_snapshot),
"start_at": item.start_at,
"expires_at": item.expires_at,
}
for item in result.scalars().all()
]
@router.get("/users/{user_id}/summary")
@@ -248,14 +345,20 @@ async def list_user_credit_balances(
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))
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_scope": item.credit_scope,
"credit_scope_label": CREDIT_SCOPE_LABELS.get(item.credit_scope, "其他积分"),
"team_id": item.team_id,
"credit_level": item.credit_level,
"credit_level_label": CREDIT_LEVEL_LABELS.get(item.credit_level, item.credit_level),
"credit_level_label": CREDIT_LEVEL_LABELS.get(item.credit_level, "其他积分等级"),
"source_type": item.source_type,
"source_type_label": CREDIT_BALANCE_SOURCE_TYPE_LABELS.get(item.source_type, item.source_type),
"source_type_label": CREDIT_BALANCE_SOURCE_TYPE_LABELS.get(item.source_type, "其他来源"),
"source_id": item.source_id,
"grant_amount": float(item.grant_amount),
"unspent_amount": float(item.unspent_amount),
@@ -266,7 +369,7 @@ async def list_user_credit_balances(
"expires_at": item.expires_at,
"last_usable_at": last_usable_at(item.expires_at),
"status": (status_value := effective_balance_status(item, request_time=checked_at)),
"status_label": CREDIT_BALANCE_STATUS_LABELS.get(status_value, status_value),
"status_label": CREDIT_BALANCE_STATUS_LABELS.get(status_value, "其他状态"),
}
for item in result.scalars().all()
]
@@ -280,24 +383,18 @@ async def admin_grant_credit(
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)
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",
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}
@@ -311,20 +408,14 @@ async def admin_deduct_credit(
):
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()}",
db, user_id=user_id, amount=data.amount, description=data.description,
related_id=admin.id, biz_key=f"admin-deduct:{admin.id}:{generate_id()}",
allowed_scopes={CreditScope.PERSONAL.value},
)
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 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}
@@ -18,7 +18,10 @@ async def admin_list_packages(
):
result = await db.execute(
select(CreditProduct)
.where(CreditProduct.product_type == CreditProductType.CREDIT_ADDON.value)
.where(
CreditProduct.product_type == CreditProductType.CREDIT_ADDON.value,
CreditProduct.deleted_at.is_(None),
)
.order_by(CreditProduct.sort_order, CreditProduct.id)
)
return [product_to_dict(item) for item in result.scalars().all()]
+86 -40
View File
@@ -3,54 +3,57 @@ from __future__ import annotations
import json
from fastapi import APIRouter, Body, Depends, Query
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_admin_user, get_db
from app.enums.team import TEAM_STATUS_LABELS, TeamStatus
from app.enums.user import UserType
from app.models.team import Team
from app.models.user import User
from app.schemas.team import TeamCreate, TeamListOut, TeamOptionOut, TeamUpdate
from app.schemas.team_manager import SetManagerRequest
from app.services.credit.team_subscription_service import (
list_member_period_usage,
list_team_subscriptions_for_management,
)
from app.services.operation_log import log_operation
from app.services.team_manager_service import set_team_manager
from app.services.team_manager_service import get_manager_history, set_team_manager
from app.services.team_service import create_team, list_team_options, list_teams, soft_delete_team, update_team
router = APIRouter(prefix="/admin/teams", tags=["admin-teams"])
async def _team_detail_payload(db: AsyncSession, team: Team) -> dict:
"""构造返回团队详情,包含 manager_name。"""
payload = {
"id": team.id,
"name": team.name,
"code": getattr(team, "code", None),
"description": getattr(team, "description", None),
"status": getattr(team, "status", "active"),
"sort_order": getattr(team, "sort_order", 0) or 0,
"member_count": 0,
"created_at": team.created_at,
"updated_at": team.updated_at,
"manager_id": getattr(team, "manager_id", None),
"manager_name": None,
}
# 查询成员数和管理人用户名
from sqlalchemy import func
from app.enums.user import UserType
member_count = (await db.execute(
select(func.count(User.id)).where(
User.user_type == UserType.FRONTEND.value,
User.team_id == team.id,
)
)).scalar() or 0
payload["member_count"] = int(member_count)
if getattr(team, "manager_id", None):
mgr = await db.execute(
manager_name = None
if team.manager_id:
manager_name = (await db.execute(
select(User.username).where(User.id == team.manager_id).limit(1)
)
payload["manager_name"] = mgr.scalar_one_or_none()
return payload
)).scalar_one_or_none()
status = team.status or TeamStatus.ACTIVE.value
return {
"id": team.id,
"name": team.name,
"code": team.code,
"description": team.description,
"status": status,
"status_label": TEAM_STATUS_LABELS.get(status, "其他状态"),
"is_read_only": status == TeamStatus.DISABLED.value,
"team_credit_frozen": status == TeamStatus.DISABLED.value,
"sort_order": team.sort_order or 0,
"member_count": int(member_count),
"created_at": team.created_at,
"updated_at": team.updated_at,
"manager_id": team.manager_id,
"manager_name": manager_name,
"first_subscription_paid_at": team.first_subscription_paid_at,
}
@router.get("", response_model=TeamListOut)
@@ -62,6 +65,7 @@ async def list_admin_teams(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
del admin
return await list_teams(db, page=page, page_size=page_size, keyword=keyword, status=status)
@@ -71,10 +75,11 @@ async def list_admin_team_options(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
del admin
return await list_team_options(db, include_disabled=include_disabled)
@router.post("", )
@router.post("")
async def create_admin_team(
req: TeamCreate,
admin: User = Depends(get_admin_user),
@@ -93,7 +98,22 @@ async def create_admin_team(
return await _team_detail_payload(db, team)
@router.put("/{team_id}", )
@router.get("/{team_id}")
async def get_admin_team(
team_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
del admin
result = await db.execute(select(Team).where(Team.id == team_id, Team.deleted_at.is_(None)).limit(1))
team = result.scalar_one_or_none()
if not team:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="团队不存在")
return await _team_detail_payload(db, team)
@router.put("/{team_id}")
async def update_admin_team(
team_id: str,
req: TeamUpdate,
@@ -113,7 +133,7 @@ async def update_admin_team(
return await _team_detail_payload(db, team)
@router.put("/{team_id}/manager", )
@router.put("/{team_id}/manager")
async def set_team_manager_endpoint(
team_id: str,
req: SetManagerRequest = Body(...),
@@ -121,19 +141,14 @@ async def set_team_manager_endpoint(
db: AsyncSession = Depends(get_db),
):
team = await set_team_manager(db, team_id, req.user_id)
manager_name = None
# 使用 req.user_id 避免访问 team.manager_id 触发懒加载
if req.user_id:
mgr = await db.execute(
select(User.username).where(User.id == req.user_id).limit(1)
)
manager_name = mgr.scalar_one_or_none()
team_name = team.name
manager_name = (await db.execute(
select(User.username).where(User.id == req.user_id).limit(1)
)).scalar_one_or_none()
await log_operation(
db,
admin.id,
admin.username,
f"设置团队管理人 {team_name}: {manager_name or '取消'}",
f"更换团队队长 {team.name}: {manager_name or req.user_id}",
"PUT",
f"/admin/teams/{team_id}/manager",
detail=json.dumps({"manager_id": req.user_id}, ensure_ascii=False),
@@ -141,6 +156,37 @@ async def set_team_manager_endpoint(
return await _team_detail_payload(db, team)
@router.get("/{team_id}/subscriptions")
async def list_admin_team_subscriptions(
team_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
del admin
return await list_team_subscriptions_for_management(db, team_id=team_id)
@router.get("/{team_id}/member-usage")
async def list_admin_team_member_usage(
team_id: str,
subscription_id: str | None = Query(None),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
del admin
return await list_member_period_usage(db, team_id=team_id, subscription_id=subscription_id)
@router.get("/{team_id}/manager-history")
async def list_admin_team_manager_history(
team_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
del admin
return await get_manager_history(db, team_id)
@router.delete("/{team_id}")
async def delete_admin_team(
team_id: str,
@@ -157,4 +203,4 @@ async def delete_admin_team(
f"/admin/teams/{team_id}",
detail=json.dumps({"before": before, "after": {"deleted_at": str(team.deleted_at)}}, ensure_ascii=False),
)
return {"message": "ok"}
return {"message": "团队已删除"}
+174 -134
View File
@@ -24,6 +24,8 @@ from app.models.credit_ratio import CreditRatio
from app.models.operation_log import OperationLog
from app.enums.user import FrontendUserKind, UserType
from app.enums.team import TEAM_UNASSIGNED_VALUE
from app.enums.common import PAYMENT_ORDER_SOURCE_LABELS
from app.schemas.payment import PAYMENT_METHOD_LABELS, PAYMENT_STATUS_LABELS, FULFILLMENT_STATUS_LABELS
from app.schemas.admin import (
CreditAdjustRequest,
ModelConfigCreate,
@@ -49,7 +51,7 @@ 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.query_service import attach_credit_snapshot, get_balance_summary, get_user_credit_summary_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
@@ -60,7 +62,6 @@ from app.services.auth import hash_password, verify_password
from app.services.operation_log import log_operation
from app.services.private_portrait.reference_resolver import batch_resolve_private_portrait_reference_display_urls
from app.services.resource_signed_url_service import build_resource_signed_url
from app.services.payment import process_refund
from app.services.resource_capacity_service import batch_get_user_resource_capacity_usage, get_user_resource_capacity_usage
from app.services.team_service import batch_get_team_name_map, set_frontend_user_team
from app.schemas.invoice import InvoiceStatusUpdateRequest
@@ -134,9 +135,10 @@ 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)
credit_summary_map = await get_user_credit_summary_map(db, user_ids)
for item in users:
attach_credit_snapshot(item, credit_map.get(item.id, 0.0))
summary = credit_summary_map.get(item.id)
attach_credit_snapshot(item, summary.available_credits if summary else 0)
return {
"items": [
AdminUserOut.model_validate(user)
@@ -144,6 +146,9 @@ async def list_users(
update={
"resource_capacity": capacity_map.get(user.id),
"team_name": team_name_map.get(getattr(user, "team_id", None)),
"personal_credits": float(credit_summary_map[user.id].personal_credits) if user.id in credit_summary_map else 0.0,
"team_available_credits": float(credit_summary_map[user.id].team_available_credits) if user.id in credit_summary_map else 0.0,
"team_frozen_credits": float(credit_summary_map[user.id].team_frozen_credits) if user.id in credit_summary_map else 0.0,
}
)
.model_dump(mode="json")
@@ -277,13 +282,17 @@ async def get_user(
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
attach_credit_snapshot(user, await get_available_credits(db, user.id))
credit_summary = await get_balance_summary(db, user.id)
attach_credit_snapshot(user, credit_summary.available_credits)
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(
update={
"resource_capacity": resource_capacity,
"team_name": team_name_map.get(getattr(user, "team_id", None)),
"personal_credits": float(credit_summary.personal_credits),
"team_available_credits": float(credit_summary.team_available_credits),
"team_frozen_credits": float(credit_summary.team_frozen_credits),
}
)
@@ -308,7 +317,14 @@ async def adjust_credits(
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 deduct_credits(
db,
user_id,
abs(req.amount),
f"管理员调整: {req.description}",
record_meta=build_admin_adjust_meta(),
allowed_scopes={"personal"},
)
await create_notification(
db, user_id, "积分变动通知",
f"您的积分已{'增加' if req.amount > 0 else '扣除'}{abs(req.amount)}积分。原因:{req.description}",
@@ -568,6 +584,7 @@ async def list_credit_records(
user_type: str | None = Query(None),
frontend_user_kind: str | None = Query(None),
team_id: str | None = Query(None),
subscription_no: str | None = Query(None),
record_type: str | None = Query(None),
type: str | None = Query(None),
credit_subject: str | None = Query(None),
@@ -592,6 +609,7 @@ async def list_credit_records(
user_type=user_type,
frontend_user_kind=frontend_user_kind,
team_id=team_id,
subscription_no=subscription_no,
record_type=record_type or type,
credit_subject=credit_subject,
media_type=media_type,
@@ -806,107 +824,112 @@ async def batch_update_payment_configs(
@router.get("/payment-stats")
async def get_payment_stats(
payment_method: str | None = Query(None),
order_source: str | None = Query(None, pattern="^(online_payment|admin_offline)$"),
status: str | None = Query(None),
start_date: str | None = Query(None),
end_date: str | None = Query(None),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Return payment statistics for admin dashboard with filters."""
from sqlalchemy import func
# Ensure by_status has all expected statuses with defaults
"""支付统计:线上、后台线下和总真实收入可分别统计。"""
del admin
by_status = {
"pending": {"count": 0, "amount": 0.0},
"paid": {"count": 0, "amount": 0.0},
"cancelled": {"count": 0, "amount": 0.0},
"refunded": {"count": 0, "amount": 0.0},
"pending": {"label": "待支付", "count": 0, "amount": 0.0},
"paid": {"label": "已支付", "count": 0, "amount": 0.0},
"cancelled": {"label": "已取消", "count": 0, "amount": 0.0},
"expired": {"label": "已过期", "count": 0, "amount": 0.0},
"failed": {"label": "失败", "count": 0, "amount": 0.0},
"refunded": {"label": "已退款", "count": 0, "amount": 0.0},
}
# Parse dates and build base query filters
now_cst = datetime.now(CST)
today_start = now_cst.replace(hour=0, minute=0, second=0, microsecond=0)
today_end = today_start + timedelta(days=1)
query_start = datetime.fromisoformat(start_date).replace(tzinfo=CST) if start_date else today_start
query_end = (
(datetime.fromisoformat(end_date) + timedelta(days=1)).replace(tzinfo=CST)
if end_date else today_end
)
# Default to today if no date range provided
query_start = today_start
query_end = today_end
if start_date:
query_start = datetime.fromisoformat(start_date).replace(tzinfo=CST)
if end_date:
query_end = (datetime.fromisoformat(end_date) + timedelta(days=1)).replace(tzinfo=CST)
# Build filter list for status breakdown
breakdown_filters = []
filters = [PaymentOrder.created_at >= query_start, PaymentOrder.created_at < query_end]
if payment_method:
breakdown_filters.append(PaymentOrder.payment_method == payment_method)
filters.append(PaymentOrder.payment_method == payment_method)
if order_source:
filters.append(PaymentOrder.order_source == order_source)
if status:
breakdown_filters.append(PaymentOrder.status == status)
# Always apply date range to breakdown
breakdown_filters.append(PaymentOrder.created_at >= query_start)
breakdown_filters.append(PaymentOrder.created_at < query_end)
filters.append(PaymentOrder.status == status)
# Status breakdown
status_result = await db.execute(
select(
PaymentOrder.status,
func.count().label("count"),
func.coalesce(func.sum(PaymentOrder.amount), 0).label("amount"),
)
.where(*breakdown_filters)
select(PaymentOrder.status, func.count().label("count"), func.coalesce(func.sum(PaymentOrder.amount), 0).label("amount"))
.where(*filters)
.group_by(PaymentOrder.status)
)
for row in status_result.all():
if row.status in by_status:
if row.status not in by_status:
by_status[row.status] = {
"count": row.count,
"amount": round(float(row.amount), 2)
"label": PAYMENT_STATUS_LABELS.get(row.status, "其他状态"),
"count": 0,
"amount": 0.0,
}
else:
# Map any unexpected status to cancelled
by_status["cancelled"]["count"] += row.count
by_status["cancelled"]["amount"] += round(float(row.amount), 2)
by_status[row.status]["count"] = int(row.count or 0)
by_status[row.status]["amount"] = round(float(row.amount or 0), 2)
# Today's stats (CST time zone) - independent of filter
today_result = await db.execute(
select(
func.count().label("paid_count"),
func.coalesce(func.sum(PaymentOrder.amount), 0).label("paid_amount"),
).where(
PaymentOrder.status == "paid",
PaymentOrder.paid_at >= today_start,
PaymentOrder.paid_at < today_end,
)
source_filters = [PaymentOrder.status == "paid", PaymentOrder.created_at >= query_start, PaymentOrder.created_at < query_end]
if payment_method:
source_filters.append(PaymentOrder.payment_method == payment_method)
if order_source:
source_filters.append(PaymentOrder.order_source == order_source)
source_result = await db.execute(
select(PaymentOrder.order_source, func.count().label("count"), func.coalesce(func.sum(PaymentOrder.amount), 0).label("amount"))
.where(*source_filters)
.group_by(PaymentOrder.order_source)
)
today_row = today_result.one()
by_source = {
"online_payment": {"label": "线上支付", "count": 0, "amount": 0.0},
"admin_offline": {"label": "后台线下成交", "count": 0, "amount": 0.0},
}
for row in source_result.all():
target = by_source.setdefault(
row.order_source,
{"label": PAYMENT_ORDER_SOURCE_LABELS.get(row.order_source, "其他订单来源"), "count": 0, "amount": 0.0},
)
target["count"] = int(row.count or 0)
target["amount"] = round(float(row.amount or 0), 2)
total_income = {
"label": "总真实收入",
"count": sum(int(item["count"]) for item in by_source.values()),
"amount": round(sum(float(item["amount"]) for item in by_source.values()), 2),
}
async def _period_income(start_at: datetime, end_at: datetime) -> dict:
result = await db.execute(
select(PaymentOrder.order_source, func.count().label("count"), func.coalesce(func.sum(PaymentOrder.amount), 0).label("amount"))
.where(
PaymentOrder.status == "paid",
PaymentOrder.paid_at >= start_at,
PaymentOrder.paid_at < end_at,
)
.group_by(PaymentOrder.order_source)
)
source_map = {row.order_source: (int(row.count or 0), round(float(row.amount or 0), 2)) for row in result.all()}
online_count, online_amount = source_map.get("online_payment", (0, 0.0))
offline_count, offline_amount = source_map.get("admin_offline", (0, 0.0))
return {
"paid_count": online_count + offline_count,
"paid_amount": round(online_amount + offline_amount, 2),
"online_paid_count": online_count,
"online_paid_amount": online_amount,
"offline_paid_count": offline_count,
"offline_paid_amount": offline_amount,
}
# Monthly cumulative stats
month_start = now_cst.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
month_end = (month_start + timedelta(days=32)).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
month_result = await db.execute(
select(
func.count().label("paid_count"),
func.coalesce(func.sum(PaymentOrder.amount), 0).label("paid_amount"),
).where(
PaymentOrder.status == "paid",
PaymentOrder.paid_at >= month_start,
PaymentOrder.paid_at < month_end,
)
)
month_row = month_result.one()
return {
"by_status": by_status,
"today": {
"paid_count": today_row.paid_count,
"paid_amount": round(float(today_row.paid_amount), 2),
},
"month": {
"paid_count": month_row.paid_count,
"paid_amount": round(float(month_row.paid_amount), 2),
},
"by_source": by_source,
"total_income": total_income,
"today": await _period_income(today_start, today_end),
"month": await _period_income(month_start, month_end),
}
@@ -915,6 +938,7 @@ async def list_payment_orders(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=500),
payment_method: str | None = Query(None),
order_source: str | None = Query(None, pattern="^(online_payment|admin_offline)$"),
status: str | None = Query(None),
phone: str | None = Query(None, description="按用户手机号模糊搜索"),
start_date: str | None = Query(None),
@@ -922,13 +946,15 @@ async def list_payment_orders(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Return paginated payment orders for admin dashboard."""
"""管理后台统一订单列表;线上和后台线下成交均来自 payment_orders"""
del admin
query = select(PaymentOrder, User.username, User.phone).join(User, PaymentOrder.user_id == User.id)
count_query = select(func.count(PaymentOrder.id))
count_query = select(func.count(PaymentOrder.id)).join(User, PaymentOrder.user_id == User.id)
filters = []
if payment_method:
filters.append(PaymentOrder.payment_method == payment_method)
if order_source:
filters.append(PaymentOrder.order_source == order_source)
if status:
filters.append(PaymentOrder.status == status)
if phone:
@@ -937,43 +963,68 @@ async def list_payment_orders(
filters.append(PaymentOrder.created_at >= datetime.fromisoformat(start_date).replace(tzinfo=CST))
if end_date:
filters.append(PaymentOrder.created_at < (datetime.fromisoformat(end_date) + timedelta(days=1)).replace(tzinfo=CST))
if filters:
query = query.where(*filters)
count_query = count_query.where(*filters)
for f in filters:
query = query.where(f)
count_query = count_query.where(f)
total = (await db.execute(count_query)).scalar() or 0
result = await db.execute(
query.order_by(PaymentOrder.created_at.desc()).offset((page - 1) * page_size).limit(page_size)
)
rows = result.all()
items = [
{
"id": o.id,
"orderNo": o.order_no,
"order_no": o.order_no,
"userId": o.user_id,
"user_id": o.user_id,
"username": username,
"phone": user_phone,
"amount": round(float(o.amount), 2),
"credits": round(float(o.credits), 2),
"paymentMethod": o.payment_method,
"payment_method": o.payment_method,
"status": o.status,
"tradeNo": o.trade_no,
"trade_no": o.trade_no,
"paidAt": o.paid_at.isoformat() if o.paid_at else None,
"paid_at": o.paid_at.isoformat() if o.paid_at else None,
"createdAt": o.created_at.isoformat() if o.created_at else None,
"created_at": o.created_at.isoformat() if o.created_at else None,
}
for o, username, user_phone in rows
]
total = int((await db.execute(count_query)).scalar() or 0)
rows = (await db.execute(
query.order_by(PaymentOrder.created_at.desc(), PaymentOrder.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)).all()
items = []
for order, username, user_phone in rows:
payment_label = PAYMENT_METHOD_LABELS.get(order.payment_method, "其他支付方式")
if order.payment_method == "other" and order.offline_payment_detail:
payment_label = f"其他-线下收款({order.offline_payment_detail}"
items.append(
{
"id": order.id,
"orderNo": order.order_no,
"order_no": order.order_no,
"userId": order.user_id,
"user_id": order.user_id,
"username": username,
"phone": user_phone,
"amount": round(float(order.amount), 2),
"credits": round(float(order.credits), 2),
"quantity": int(order.quantity or 1),
"quoted_unit_price_snapshot": float(order.quoted_unit_price_snapshot) if order.quoted_unit_price_snapshot is not None else None,
"quoted_amount_snapshot": float(order.quoted_amount_snapshot) if order.quoted_amount_snapshot is not None else None,
"actual_unit_price_snapshot": float(order.actual_unit_price_snapshot) if order.actual_unit_price_snapshot is not None else None,
"paymentMethod": order.payment_method,
"payment_method": order.payment_method,
"payment_method_label": payment_label,
"order_source": order.order_source,
"order_source_label": PAYMENT_ORDER_SOURCE_LABELS.get(order.order_source, "其他订单来源"),
"status": order.status,
"status_label": PAYMENT_STATUS_LABELS.get(order.status, "其他状态"),
"product_id": order.product_id,
"product_type": order.product_type,
"product_name_snapshot": order.product_name_snapshot,
"team_id_snapshot": order.team_id_snapshot,
"fulfillment_status": order.fulfillment_status,
"fulfillment_status_label": FULFILLMENT_STATUS_LABELS.get(order.fulfillment_status, "其他履约状态") if order.fulfillment_status else None,
"tradeNo": order.trade_no,
"trade_no": order.trade_no,
"offline_trade_no": order.offline_trade_no,
"offline_payment_detail": order.offline_payment_detail,
"remark": order.remark,
"refund_amount": float(order.refund_amount) if order.refund_amount is not None else None,
"refund_trade_no": order.refund_trade_no,
"refund_entitlement_status": order.refund_entitlement_status,
"paidAt": order.paid_at.isoformat() if order.paid_at else None,
"paid_at": order.paid_at.isoformat() if order.paid_at else None,
"refunded_at": order.refunded_at.isoformat() if order.refunded_at else None,
"createdAt": order.created_at.isoformat() if order.created_at else None,
"created_at": order.created_at.isoformat() if order.created_at else None,
}
)
return {"items": items, "total": total, "page": page, "page_size": page_size}
@router.put("/payment-configs/{config_id}")
async def update_payment_config(
config_id: str,
@@ -1024,25 +1075,14 @@ async def refund_payment_order(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Refund a paid payment order."""
result = await process_refund(db, order_no)
if not result.get("success"):
raise HTTPException(status_code=400, detail=result.get("message", "退款失败"))
await log_operation(
db,
admin.id,
admin.username,
f"订单退款: {order_no}",
"POST",
f"/admin/payment-orders/{order_no}/refund",
detail=json.dumps(
{
"order_no": order_no,
},
ensure_ascii=False,
),
)
return result
"""保留退款 API 路由兼容旧客户端,但本版本明确不开放主动订单退款。"""
del admin
exists = (await db.execute(
select(PaymentOrder.id).where(PaymentOrder.order_no == order_no).limit(1)
)).scalar_one_or_none()
if not exists:
raise HTTPException(status_code=404, detail="订单不存在")
raise HTTPException(status_code=409, detail="当前版本暂未开放订单退款")
# ── Industry Config ──────────────────────────────────────
+69 -2
View File
@@ -4,7 +4,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, get_db
from app.models.credit.balance import UserCreditBalance
from app.models.credit.allocation import CreditRecordAllocation
from app.models.credit_record import CreditRecord
from app.enums.credit_balance import (
CREDIT_BALANCE_SOURCE_TYPE_LABELS,
CREDIT_BALANCE_STATUS_LABELS,
CREDIT_LEVEL_LABELS,
CREDIT_SCOPE_LABELS,
CreditScope,
)
from app.enums.credit_record import CREDIT_RECORD_BILLING_SCENE_LABELS, CREDIT_RECORD_TYPE_LABELS
from app.models.credit_ratio import CreditRatio
from app.models.image_engine import ImageEngine
from app.models.user import User
@@ -28,8 +37,13 @@ router = APIRouter(prefix="/credits", tags=["credits"])
def _balance_to_out(item: UserCreditBalance, *, checked_at) -> CreditBalanceItemOut:
return CreditBalanceItemOut(
id=item.id,
credit_scope=item.credit_scope,
credit_scope_label=CREDIT_SCOPE_LABELS.get(item.credit_scope, "其他积分"),
team_id=item.team_id,
credit_level=item.credit_level,
credit_level_label=CREDIT_LEVEL_LABELS.get(item.credit_level, "其他积分等级"),
source_type=item.source_type,
source_type_label=CREDIT_BALANCE_SOURCE_TYPE_LABELS.get(item.source_type, "其他来源"),
source_id=item.source_id,
product_id=item.product_id,
payment_order_id=item.payment_order_id,
@@ -44,10 +58,58 @@ def _balance_to_out(item: UserCreditBalance, *, checked_at) -> CreditBalanceItem
expires_at=item.expires_at,
last_usable_at=last_usable_at(item.expires_at),
status=effective_balance_status(item, request_time=checked_at),
status_label=CREDIT_BALANCE_STATUS_LABELS.get(
effective_balance_status(item, request_time=checked_at), "其他状态"
),
created_at=item.created_at,
)
async def _records_with_scope_amounts(db: AsyncSession, records: list[CreditRecord]) -> list[dict]:
if not records:
return []
ids = [item.id for item in records]
result = await db.execute(
select(
CreditRecordAllocation.credit_record_id,
CreditRecordAllocation.credit_scope_snapshot,
func.coalesce(func.sum(CreditRecordAllocation.amount), 0).label("amount"),
)
.where(CreditRecordAllocation.credit_record_id.in_(ids))
.group_by(CreditRecordAllocation.credit_record_id, CreditRecordAllocation.credit_scope_snapshot)
)
scope_map: dict[str, dict[str, float]] = {}
for row in result.all():
scope_map.setdefault(str(row.credit_record_id), {})[str(row.credit_scope_snapshot)] = float(row.amount or 0)
output = []
for record in records:
parts = scope_map.get(record.id, {})
sign = -1.0 if float(record.amount or 0) < 0 else 1.0
output.append({
"id": record.id,
"type": record.type,
"type_label": CREDIT_RECORD_TYPE_LABELS.get(record.type, "其他"),
"amount": float(record.amount),
"personal_amount": sign * float(parts.get("personal", 0)),
"team_amount": sign * float(parts.get("team", 0)),
"balance_delta": float(record.balance_delta or 0),
"expired_amount": float(record.expired_amount or 0),
"balance_after": float(record.balance_after or 0),
"description": record.description,
"billing_scene": record.billing_scene,
"billing_scene_label": CREDIT_RECORD_BILLING_SCENE_LABELS.get(record.billing_scene, "其他场景") if record.billing_scene else None,
"scene_name_snapshot": record.scene_name_snapshot,
"input_tokens": record.input_tokens,
"output_tokens": record.output_tokens,
"total_tokens": record.total_tokens,
"llm_call_count": record.llm_call_count,
"llm_success_call_count": record.llm_success_call_count,
"llm_failed_call_count": record.llm_failed_call_count,
"created_at": record.created_at,
})
return output
@router.get("")
async def get_credits(
page: int = Query(1, ge=1),
@@ -68,7 +130,7 @@ async def get_credits(
total_granted, total_consumed, total_refunded, total_expired = totals_result.one()
return {
**summary.to_dict(),
"records": [CreditRecordOut.model_validate(r) for r in records],
"records": await _records_with_scope_amounts(db, records),
"total": total,
"total_granted": float(total_granted or 0),
"total_consumed": float(total_consumed or 0),
@@ -86,7 +148,12 @@ async def list_credit_balances(
db: AsyncSession = Depends(get_db),
):
checked_at = utc_now()
stmt = select(UserCreditBalance).where(UserCreditBalance.user_id == current_user.id)
# 通用积分页只展示用户自己的个人积分批次。团队资金池归属成交时队长,
# 不能因为 Balance.owner 是队长就在这里展示整个团队资金池;团队席位与资金池明细统一在团队管理页查看。
stmt = select(UserCreditBalance).where(
UserCreditBalance.user_id == current_user.id,
UserCreditBalance.credit_scope == CreditScope.PERSONAL.value,
)
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))
+26 -15
View File
@@ -10,7 +10,6 @@ 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.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 (
@@ -60,6 +59,7 @@ async def recharge(
select(CreditProduct).where(
CreditProduct.id == req.plan,
CreditProduct.is_active.is_(True),
CreditProduct.deleted_at.is_(None),
).limit(1)
)
product = result.scalar_one_or_none()
@@ -71,6 +71,7 @@ async def recharge(
current_user.id,
method=req.method,
product_id=product.id,
quantity=req.quantity,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@@ -243,28 +244,39 @@ async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)):
)
return {"code": "SUCCESS", "message": "OK"}
# 处理退款回调
# 处理退款回调:本版本只记录渠道退款事实,不撤销订阅、Period、Seat、积分或首购资格。
elif event_type == "REFUND.SUCCESS":
order_no = decrypted_data.get("out_trade_no", "")
refund_id = decrypted_data.get("refund_id", "")
refund_status = decrypted_data.get("status", "")
refund_amount_info = decrypted_data.get("amount", {}) or {}
refund_amount = refund_amount_info.get("refund", 0) / 100
if order_no and refund_status == "SUCCESS":
# 更新订单状态为已退款
from app.models import PaymentOrder
from sqlalchemy import select
result = await db.execute(select(PaymentOrder).where(PaymentOrder.order_no == order_no))
result = await db.execute(
select(PaymentOrder)
.where(PaymentOrder.order_no == order_no)
.with_for_update()
.limit(1)
)
order = result.scalar_one_or_none()
if order and order.status == "refunding":
if order:
# 幂等记录渠道退款事实:即便升级前本地已经写成 refunded,
# 也要补齐渠道退款号/金额/时间;本版本绝不触碰任何订阅或积分权益。
order.status = "refunded"
order.transaction_id = refund_id
if refund_id:
order.refund_trade_no = refund_id
if refund_amount > 0:
order.refund_amount = refund_amount
elif order.refund_amount is None:
order.refund_amount = order.amount
if order.refunded_at is None:
order.refunded_at = utc_now()
order.refund_entitlement_status = "record_only"
await db.commit()
logger.info(
f"WeChat refund callback processed: order_no={order_no}, "
f"refund_id={refund_id}, status={refund_status}"
f"WECHAT_REFUND_CALLBACK_RECORDED order_no={order_no} "
f"refund_id={refund_id} amount={refund_amount} entitlement=record_only"
)
return {"code": "SUCCESS", "message": "OK"}
@@ -325,6 +337,7 @@ async def list_orders(
conditions.append(PaymentOrder.status == status_filter)
if invoice_mode:
conditions.append(PaymentOrder.status == "paid")
conditions.append(PaymentOrder.order_source == "online_payment")
if start_date:
start_dt = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
conditions.append(PaymentOrder.created_at >= start_dt)
@@ -451,8 +464,6 @@ async def cancel_order(
)
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}"
@@ -22,6 +22,7 @@ async def list_active_packages(
.where(
CreditProduct.product_type == CreditProductType.CREDIT_ADDON.value,
CreditProduct.is_active.is_(True),
CreditProduct.deleted_at.is_(None),
)
.order_by(CreditProduct.sort_order.asc(), CreditProduct.id.asc())
)
+267 -208
View File
@@ -1,38 +1,74 @@
from __future__ import annotations
from datetime import datetime, timezone, timedelta
import csv
import io
from datetime import datetime, timedelta, timezone
from urllib.parse import quote
CST = timezone(timedelta(hours=8))
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import StreamingResponse
from app.dependencies import get_current_user, get_db, get_optional_current_user
from app.enums.team import TEAM_STATUS_LABELS, TeamStatus
from app.enums.user import UserType
from app.models.team import Team
from app.models.team_join_request import TeamJoinRequest
from app.models.user import User
from app.schemas.team_invitation import TeamInvitationCreate, TeamInvitationOut
from app.schemas.team_join_request import (
JoinByCodeRequest,
JoinRequestHandle,
JoinRequestOut,
JoinTeamInfoOut,
)
from app.schemas.team_manager import (
ManagerTransferRequest,
)
from app.schemas.team_join_request import JoinByCodeRequest, JoinRequestHandle, JoinRequestOut, JoinTeamInfoOut
from app.schemas.team_manager import SetManagerRequest
from app.schemas.team_subscription import TeamSeatCreateRequest, TeamSeatUpdateRequest
from app.services import team_invitation_service
from app.services.credit.team_subscription_service import (
cancel_seat,
create_seat,
list_member_period_usage,
list_team_subscriptions_for_management,
update_seat,
)
from app.services.team_credit_record_service import list_team_credit_records as query_team_credit_records
from app.services.team_manager_service import (
get_managed_team,
get_manager_history,
get_team_members,
list_manager_access_teams,
set_team_manager,
)
router = APIRouter(prefix="/team", tags=["team"])
CST = timezone(timedelta(hours=8))
def _team_payload(team: Team, *, manager_name: str | None, member_count: int) -> dict:
status = team.status or TeamStatus.ACTIVE.value
return {
"id": team.id,
"name": team.name,
"code": team.code,
"description": team.description,
"status": status,
"status_label": TEAM_STATUS_LABELS.get(status, "其他状态"),
"is_read_only": status == TeamStatus.DISABLED.value,
"team_credit_frozen": status == TeamStatus.DISABLED.value,
"member_count": int(member_count),
"manager_id": team.manager_id,
"manager_name": manager_name,
"first_subscription_paid_at": team.first_subscription_paid_at,
}
async def _resolve_flow_team_id(db: AsyncSession, *, current_user: User, team_id: str | None) -> str:
if team_id:
# 真正的当前/历史队长权限由流水 Service 根据 TeamManagerHistory 再校验。
return team_id
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=400, detail="请指定需要查看的历史团队")
return team.id
# ── 获取当前用户管理的团队 ──────────────────────────────
@router.get("/managed")
async def get_managed_team_info(
current_user: User = Depends(get_current_user),
@@ -40,30 +76,25 @@ async def get_managed_team_info(
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=404, detail="您不是任何团队的管理人")
from sqlalchemy import func
from app.enums.user import UserType
raise HTTPException(status_code=404, detail="当前不是任何团队的队长")
member_count = (await db.execute(
select(func.count(User.id)).where(
User.user_type == UserType.FRONTEND.value,
User.team_id == team.id,
)
)).scalar() or 0
return _team_payload(team, manager_name=current_user.username, member_count=int(member_count))
return {
"id": team.id,
"name": team.name,
"code": team.code,
"description": team.description,
"status": team.status,
"member_count": int(member_count),
"manager_id": team.manager_id,
"manager_name": current_user.username,
}
@router.get("/manager-access")
async def list_manager_access(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""当前及历史队长可访问的团队列表,用于历史团队流水入口。"""
return await list_manager_access_teams(db, current_user.id)
# ── 团队成员列表 ──────────────────────────────────────
@router.get("/members")
async def list_team_members(
page: int = Query(1, ge=1),
@@ -73,23 +104,123 @@ async def list_team_members(
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有团队管理人可查看")
raise HTTPException(status_code=403, detail="只有当前团队队长可以查看成员列表")
return await get_team_members(db, team.id, page=page, page_size=page_size)
# ── 转账积分给成员 ────────────────────────────────────
@router.post("/members/{member_id}/credits")
async def transfer_credits(
member_id: str,
req: ManagerTransferRequest,
@router.put("/manager")
async def transfer_manager(
req: SetManagerRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
raise HTTPException(status_code=409, detail="当前版本积分暂未开放团队转账功能")
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有当前团队队长可以转让队长")
await set_team_manager(db, team.id, req.user_id)
return {"message": "团队队长已更换"}
# ── 邀请码管理 ────────────────────────────────────────
@router.post("/invitations", )
@router.post("/members/{member_id}/credits")
async def transfer_credits(
member_id: str,
req: dict = Body(default_factory=dict),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
del member_id, req, current_user, db
raise HTTPException(status_code=409, detail="当前版本不支持团队积分转账,请使用团队订阅席位额度")
@router.get("/subscriptions")
async def list_team_subscriptions(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有当前团队队长可以管理团队订阅席位")
return await list_team_subscriptions_for_management(db, team_id=team.id)
@router.post("/subscriptions/{subscription_id}/seats")
async def create_subscription_seat(
subscription_id: str,
req: TeamSeatCreateRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有当前团队队长可以管理团队订阅席位")
seat = await create_seat(
db,
team_id=team.id,
subscription_id=subscription_id,
manager_user_id=current_user.id,
user_id=req.user_id,
monthly_allocated_credits=req.monthly_allocated_credits,
)
return {"message": "席位已创建", "seat_id": seat.id}
@router.put("/seats/{seat_id}")
async def update_subscription_seat(
seat_id: str,
req: TeamSeatUpdateRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有当前团队队长可以管理团队订阅席位")
seat = await update_seat(
db,
team_id=team.id,
seat_id=seat_id,
manager_user_id=current_user.id,
monthly_allocated_credits=req.monthly_allocated_credits,
)
return {"message": "席位额度已更新", "seat_id": seat.id}
@router.delete("/seats/{seat_id}")
async def cancel_subscription_seat(
seat_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有当前团队队长可以管理团队订阅席位")
await cancel_seat(db, team_id=team.id, seat_id=seat_id, manager_user_id=current_user.id)
return {"message": "席位已取消"}
@router.get("/member-usage")
async def get_member_usage(
subscription_id: str | None = Query(None),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有当前团队队长可以查看成员团队积分消耗")
return await list_member_period_usage(db, team_id=team.id, subscription_id=subscription_id)
@router.get("/manager-history")
async def manager_history(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有当前团队队长可以查看完整队长任期历史")
return await get_manager_history(db, team.id)
@router.post("/invitations")
async def create_invitation(
req: TeamInvitationCreate,
current_user: User = Depends(get_current_user),
@@ -97,15 +228,13 @@ async def create_invitation(
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有团队管理人可创建邀请码")
raise HTTPException(status_code=403, detail="只有团队队长可创建邀请码")
expires_at = None
if req.expires_at:
try:
expires_at = datetime.fromisoformat(req.expires_at)
except ValueError:
raise HTTPException(status_code=400, detail="过期时间格式错误")
except ValueError as exc:
raise HTTPException(status_code=400, detail="过期时间格式错误") from exc
invitation = await team_invitation_service.create_invitation(
db, team.id, current_user.id, req.max_uses, expires_at
)
@@ -128,7 +257,7 @@ async def list_invitations(
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有团队管理人可查看")
raise HTTPException(status_code=403, detail="只有团队队长可查看邀请码")
invitations = await team_invitation_service.get_invitations_for_team(db, team.id)
return [
{
@@ -152,10 +281,9 @@ async def revoke_invitation(
db: AsyncSession = Depends(get_db),
):
await team_invitation_service.revoke_invitation(db, invitation_id, current_user.id)
return {"message": "ok"}
return {"message": "邀请码已撤销"}
# ── 加入申请 ──────────────────────────────────────────
@router.post("/join")
async def join_by_code(
req: JoinByCodeRequest,
@@ -163,42 +291,43 @@ async def join_by_code(
db: AsyncSession = Depends(get_db),
):
await team_invitation_service.create_join_request(db, current_user.id, req.invitation_code)
return {"message": "申请已提交,请等待团队管理人审批"}
return {"message": "申请已提交,请等待团队队长审批"}
@router.get("/join-info", )
async def get_join_info(
code: str = Query(...),
current_user: User | None = Depends(get_optional_current_user),
db: AsyncSession = Depends(get_db),
):
"""验证邀请码并返回团队信息(用于加入页面展示)。"""
async def _join_info_payload(
db: AsyncSession,
*,
code: str,
current_user: User | None,
) -> JoinTeamInfoOut:
invitation = await team_invitation_service.get_invitation_by_code(db, code)
if not invitation:
return JoinTeamInfoOut(team_name="", team_id="", valid=False, already_in_team=False, has_pending_request=False)
team = await db.execute(
select(Team.name).where(Team.id == invitation.team_id, Team.deleted_at.is_(None)).limit(1)
team_result = await db.execute(
select(Team).where(Team.id == invitation.team_id, Team.deleted_at.is_(None)).limit(1)
)
team_name = team.scalar_one_or_none() or ""
already_in_team = current_user and current_user.team_id == invitation.team_id
team = team_result.scalar_one_or_none()
if not team or team.status != TeamStatus.ACTIVE.value:
return JoinTeamInfoOut(
team_name=team.name if team else "",
team_id=invitation.team_id,
valid=False,
already_in_team=False,
has_pending_request=False,
)
already_in_team = bool(current_user and current_user.team_id == invitation.team_id)
has_pending_request = False
if current_user:
from app.models.team_join_request import TeamJoinRequest
pending = await db.execute(
select(TeamJoinRequest).where(
select(TeamJoinRequest.id).where(
TeamJoinRequest.user_id == current_user.id,
TeamJoinRequest.team_id == invitation.team_id,
TeamJoinRequest.status == "pending",
).limit(1)
)
has_pending = pending.scalar_one_or_none()
has_pending_request = has_pending is not None
has_pending_request = pending.scalar_one_or_none() is not None
return JoinTeamInfoOut(
team_name=team_name,
team_name=team.name,
team_id=invitation.team_id,
valid=True,
already_in_team=already_in_team,
@@ -206,31 +335,21 @@ async def get_join_info(
)
@router.get("/join-info/public", )
async def get_join_info_public(
@router.get("/join-info")
async def get_join_info(
code: str = Query(...),
current_user: User | None = Depends(get_optional_current_user),
db: AsyncSession = Depends(get_db),
):
"""公开接口:验证邀请码并返回团队信息(无需登录)。"""
invitation = await team_invitation_service.get_invitation_by_code(db, code)
if not invitation:
return {"team_name": "", "team_id": "", "valid": False, "already_in_team": False, "has_pending_request": False}
team = await db.execute(
select(Team.name).where(Team.id == invitation.team_id, Team.deleted_at.is_(None)).limit(1)
)
team_name = team.scalar_one_or_none() or ""
return {
"team_name": team_name,
"team_id": invitation.team_id,
"valid": True,
"already_in_team": False,
"has_pending_request": False,
}
return await _join_info_payload(db, code=code, current_user=current_user)
@router.get("/join-requests", )
@router.get("/join-info/public")
async def get_join_info_public(code: str = Query(...), db: AsyncSession = Depends(get_db)):
return await _join_info_payload(db, code=code, current_user=None)
@router.get("/join-requests")
async def list_join_requests(
status: str | None = Query(None),
current_user: User = Depends(get_current_user),
@@ -238,29 +357,22 @@ async def list_join_requests(
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有团队管理人可查看")
raise HTTPException(status_code=403, detail="只有团队队长可查看加入申请")
requests = await team_invitation_service.get_all_requests(db, team.id, status)
# 获取团队名
team_name_result = await db.execute(
select(Team.name).where(Team.id == team.id).limit(1)
)
team_name = team_name_result.scalar_one_or_none() or ""
return [
JoinRequestOut(
id=r["id"],
team_id=r["team_id"],
team_name=team_name,
user_id=r["user_id"],
username=r["username"],
phone=r.get("phone"),
status=r["status"],
note=r.get("note"),
created_at=r["created_at"],
handled_at=r.get("handled_at"),
id=item["id"],
team_id=item["team_id"],
team_name=team.name,
user_id=item["user_id"],
username=item["username"],
phone=item.get("phone"),
status=item["status"],
note=item.get("note"),
created_at=item["created_at"],
handled_at=item.get("handled_at"),
)
for r in requests
for item in requests
]
@@ -271,157 +383,104 @@ async def handle_join_request(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await team_invitation_service.handle_join_request(
db, request_id, current_user.id, req.action, req.note
)
return {"message": "ok"}
await team_invitation_service.handle_join_request(db, request_id, current_user.id, req.action, req.note)
return {"message": "申请已处理"}
# ── 团队积分变动记录 ────────────────────────────────────
@router.get("/credit-records")
async def list_team_credit_records(
team_id: str | None = Query(None, description="历史队长查看旧团队时传团队ID"),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
user_id: str | None = Query(None),
phone: str | None = Query(None, description="按手机号搜索"),
record_type: str | None = Query(None, pattern="^(recharge|consume|refund|team_internal)$", description="流水类型"),
subscription_id: str | None = Query(None),
record_type: str | None = Query(None, pattern="^(recharge|consume|refund|team_internal|expire|revoke)$"),
start_date: str | None = Query(None, description="起始日期 YYYY-MM-DD"),
end_date: str | None = Query(None, description="截止日期 YYYY-MM-DD"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""查看团队所有成员的积分变动记录(仅管理人)。"""
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有团队管理人可查看")
from app.services.admin_credit_record_service import list_admin_credit_records
# 如果传了 phone,先找到对应的 user_id
resolved_team_id = await _resolve_flow_team_id(db, current_user=current_user, team_id=team_id)
resolved_user_id = user_id
if phone and not user_id:
phone_result = await db.execute(
select(User.id).where(
User.team_id == team.id,
User.phone == phone,
User.is_active.is_(True),
).limit(1)
)
if phone and not resolved_user_id:
phone_result = await db.execute(select(User.id).where(User.phone == phone).limit(1))
resolved_user_id = phone_result.scalar_one_or_none()
if not resolved_user_id:
return {"items": [], "total": 0, "summary": {}}
return await list_admin_credit_records(
return {"items": [], "total": 0, "page": page, "page_size": page_size}
return await query_team_credit_records(
db,
team_id=resolved_team_id,
viewer_user_id=current_user.id,
page=page,
page_size=page_size,
team_id=team.id,
user_id=resolved_user_id,
member_user_id=resolved_user_id,
subscription_id=subscription_id,
record_type=record_type,
start_date=start_date,
end_date=end_date,
)
# ── 团队积分导出 Excel ──────────────────────────────────
@router.get("/credit-records/export")
async def export_team_credit_records(
team_id: str | None = Query(None),
user_id: str | None = Query(None),
phone: str | None = Query(None),
record_type: str | None = Query(None, pattern="^(recharge|consume|refund|team_internal)$", description="流水类型"),
subscription_id: str | None = Query(None),
record_type: str | None = Query(None, pattern="^(recharge|consume|refund|team_internal|expire|revoke)$"),
start_date: str | None = Query(None),
end_date: str | None = Query(None),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""导出团队积分变动记录为 Excel(仅管理人)。"""
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有团队管理人可查看")
from app.services.admin_credit_record_service import list_admin_credit_records
resolved_team_id = await _resolve_flow_team_id(db, current_user=current_user, team_id=team_id)
resolved_user_id = user_id
if phone and not user_id:
phone_result = await db.execute(
select(User.id).where(
User.team_id == team.id,
User.phone == phone,
User.is_active.is_(True),
).limit(1)
)
if phone and not resolved_user_id:
phone_result = await db.execute(select(User.id).where(User.phone == phone).limit(1))
resolved_user_id = phone_result.scalar_one_or_none()
# 拉取全部记录(不分页)
result = await list_admin_credit_records(
if not resolved_user_id:
# 导出筛选手机号不存在时必须返回空结果,不能因为 user_id=None 退化成导出整个团队流水。
resolved_user_id = "__not_found__"
result = await query_team_credit_records(
db,
team_id=resolved_team_id,
viewer_user_id=current_user.id,
page=1,
page_size=10000,
team_id=team.id,
user_id=resolved_user_id,
member_user_id=resolved_user_id,
subscription_id=subscription_id,
record_type=record_type,
start_date=start_date,
end_date=end_date,
)
# 生成 CSV(兼容 Excel 打开,UTF-8 BOM
import csv
import io
from datetime import datetime as _dt
def _format_dt(val):
if val is None:
return "-"
try:
# 情况 1:已经是 datetime
if isinstance(val, _dt):
dt = val
elif isinstance(val, (int, float)):
# 情况 2:Unix 时间戳(极少,兼容旧代码)
dt = _dt.fromtimestamp(val)
elif isinstance(val, str):
# 情况 3ISO 字符串(admin_credit_record_service._iso 返回的格式)
s = val.strip()
if s.endswith("Z"):
s = s[:-1] + "+00:00"
try:
dt = _dt.fromisoformat(s)
except ValueError:
# 兼容旧格式 YYYY-MM-DD HH:MM:SS
dt = _dt.strptime(s, "%Y-%m-%d %H:%M:%S")
else:
return str(val)
# 统一转东八区展示
if getattr(dt, "tzinfo", None) is None:
dt = dt.replace(tzinfo=CST)
else:
dt = dt.astimezone(CST)
return dt.strftime("%Y-%m-%d %H:%M:%S")
except Exception: # noqa: BLE001
return str(val) if val else "-"
team_result = await db.execute(select(Team).where(Team.id == resolved_team_id).limit(1))
team = team_result.scalar_one_or_none()
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["用户名", "手机号", "类型", "积分变动", "余额", "说明", "时间"])
writer.writerow(["用户名", "流水类型", "团队积分变动", "说明", "订阅实例", "周期ID", "席位ID", "时间"])
for item in result.get("items", []):
created_at = item.get("created_at")
if isinstance(created_at, datetime):
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=CST)
else:
created_at = created_at.astimezone(CST)
created_at = created_at.strftime("%Y-%m-%d %H:%M:%S")
writer.writerow([
item.get("username") or "-",
item.get("phone") or "-",
item.get("record_type_label") or item.get("type") or "-",
item.get("amount", 0),
item.get("balance_after", 0),
item.get("record_type_label") or "-",
item.get("team_amount", 0),
item.get("description") or "-",
_format_dt(item.get("created_at")),
item.get("subscription_no") or "历史订阅",
item.get("subscription_period_id") or "-",
item.get("seat_id") or "-",
created_at or "-",
])
from starlette.responses import StreamingResponse
from urllib.parse import quote
filename = f"团队积分_{(team.name if team else resolved_team_id)}_{datetime.now(CST).strftime('%Y%m%d_%H%M%S')}.csv"
output.seek(0)
safe_team_name = team.name or "team"
filename = f"团队积分_{safe_team_name}_{datetime.now(CST).strftime('%Y%m%d_%H%M%S')}.csv"
encoded_filename = quote(filename)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv; charset=utf-8-sig",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"},
iter(["\ufeff" + output.getvalue()]),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
)
+28
View File
@@ -133,3 +133,31 @@ class BillingBlockEventEnum(StrEnum):
INSUFFICIENT_CREDITS = "BILLING_BLOCKED_INSUFFICIENT_CREDITS"
NEGATIVE_BALANCE = "BILLING_BLOCKED_NEGATIVE_BALANCE"
class PaymentOrderSourceEnum(StrEnum):
"""订单来源。"""
ONLINE_PAYMENT = "online_payment"
ADMIN_OFFLINE = "admin_offline"
PAYMENT_ORDER_SOURCE_LABELS = {
PaymentOrderSourceEnum.ONLINE_PAYMENT.value: "线上支付",
PaymentOrderSourceEnum.ADMIN_OFFLINE.value: "后台线下成交",
}
class OfflinePaymentMethodEnum(StrEnum):
"""后台线下收款方式。"""
BANK_TRANSFER = "bank_transfer"
CASH = "cash"
OTHER = "other"
OFFLINE_PAYMENT_METHOD_LABELS = {
OfflinePaymentMethodEnum.BANK_TRANSFER.value: "银行转账",
OfflinePaymentMethodEnum.CASH.value: "现金",
OfflinePaymentMethodEnum.OTHER.value: "其他-线下收款",
}
+17 -4
View File
@@ -19,6 +19,23 @@ CREDIT_LEVEL_LABELS = {
}
class CreditScope(StrEnum):
PERSONAL = "personal"
TEAM = "team"
CREDIT_SCOPE_LABELS = {
CreditScope.PERSONAL.value: "个人积分",
CreditScope.TEAM.value: "团队积分",
}
CREDIT_SCOPE_SORT = {
CreditScope.TEAM.value: 10,
CreditScope.PERSONAL.value: 20,
}
class CreditBalanceSourceType(StrEnum):
REGISTER_GIFT = "register_gift"
DAILY_LOGIN = "daily_login"
@@ -70,8 +87,6 @@ class CreditAllocationAction(StrEnum):
REFUND_EXPIRED = "refund_expired"
EXPIRE = "expire"
REVOKE = "revoke"
UPGRADE_SOURCE_TRANSFER_OUT = "upgrade_source_transfer_out"
UPGRADE_SOURCE_TRANSFER_IN = "upgrade_source_transfer_in"
CREDIT_ALLOCATION_ACTION_LABELS = {
@@ -81,6 +96,4 @@ CREDIT_ALLOCATION_ACTION_LABELS = {
CreditAllocationAction.REFUND_EXPIRED.value: "过期积分退款",
CreditAllocationAction.EXPIRE.value: "积分过期",
CreditAllocationAction.REVOKE.value: "积分撤销",
CreditAllocationAction.UPGRADE_SOURCE_TRANSFER_OUT.value: "升级积分转出",
CreditAllocationAction.UPGRADE_SOURCE_TRANSFER_IN.value: "升级积分转入",
}
+30 -1
View File
@@ -5,15 +5,30 @@ from enum import StrEnum
class CreditProductType(StrEnum):
SUBSCRIPTION = "subscription"
TEAM_SUBSCRIPTION = "team_subscription"
CREDIT_ADDON = "credit_addon"
CREDIT_PRODUCT_TYPE_LABELS = {
CreditProductType.SUBSCRIPTION.value: "个人订阅套餐",
CreditProductType.TEAM_SUBSCRIPTION.value: "团队订阅套餐",
CreditProductType.CREDIT_ADDON.value: "积分增值包",
}
class SubscriptionBillingCycle(StrEnum):
MONTHLY = "monthly"
QUARTERLY = "quarterly"
YEARLY = "yearly"
SUBSCRIPTION_BILLING_CYCLE_LABELS = {
SubscriptionBillingCycle.MONTHLY.value: "月卡",
SubscriptionBillingCycle.QUARTERLY.value: "季卡",
SubscriptionBillingCycle.YEARLY.value: "年卡",
}
SUBSCRIPTION_GRANT_COUNT = {
SubscriptionBillingCycle.MONTHLY.value: 1,
SubscriptionBillingCycle.QUARTERLY.value: 3,
@@ -28,8 +43,22 @@ class SubscriptionTierCode(StrEnum):
SUPER = "super"
SUBSCRIPTION_TIER_LABELS = {
SubscriptionTierCode.STARTER.value: "入门",
SubscriptionTierCode.STANDARD.value: "标准",
SubscriptionTierCode.ADVANCED.value: "高级",
SubscriptionTierCode.SUPER.value: "超级",
}
class ProductPriceType(StrEnum):
FIRST_PURCHASE = "first_purchase"
REGULAR = "regular"
ACTIVITY = "activity"
UPGRADE = "upgrade"
PRODUCT_PRICE_TYPE_LABELS = {
ProductPriceType.FIRST_PURCHASE.value: "首购价",
ProductPriceType.REGULAR.value: "常规价",
ProductPriceType.ACTIVITY.value: "活动价",
}
+16 -6
View File
@@ -7,17 +7,27 @@ class CreditSubscriptionStatus(StrEnum):
PENDING = "pending"
ACTIVE = "active"
EXPIRED = "expired"
UPGRADED = "upgraded"
CANCELLED = "cancelled"
REFUNDED = "refunded"
UPGRADE_RECONCILE_FAILED = "upgrade_reconcile_failed"
CREDIT_SUBSCRIPTION_STATUS_LABELS = {
CreditSubscriptionStatus.PENDING.value: "待生效",
CreditSubscriptionStatus.ACTIVE.value: "生效中",
CreditSubscriptionStatus.EXPIRED.value: "已到期",
CreditSubscriptionStatus.CANCELLED.value: "已取消",
}
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"
CREDIT_SUBSCRIPTION_PERIOD_STATUS_LABELS = {
CreditSubscriptionPeriodStatus.SCHEDULED.value: "待发放",
CreditSubscriptionPeriodStatus.GRANTED.value: "已发放",
CreditSubscriptionPeriodStatus.CANCELLED.value: "已取消",
CreditSubscriptionPeriodStatus.EXPIRED.value: "已到期",
}
+13
View File
@@ -25,5 +25,18 @@ TEAM_JOIN_REQUEST_STATUS_LABELS = {
}
class TeamSeatStatus(str, Enum):
ACTIVE = "active"
CANCELLED = "cancelled"
EXPIRED = "expired"
TEAM_SEAT_STATUS_LABELS = {
TeamSeatStatus.ACTIVE.value: "使用中",
TeamSeatStatus.CANCELLED.value: "已取消",
TeamSeatStatus.EXPIRED.value: "已到期",
}
# 前端筛选“未分配团队”时使用的稳定哨兵值,不与真实团队ID混用。
TEAM_UNASSIGNED_VALUE = "__none__"
+3
View File
@@ -1,6 +1,7 @@
from app.models.base import Base, TimestampMixin, SoftDeleteMixin, engine, async_session, init_database, close_database
from app.models.user import User
from app.models.team import Team
from app.models.team_manager_history import TeamManagerHistory
from app.models.team_invitation import TeamInvitation
from app.models.team_join_request import TeamJoinRequest
from app.models.project import Project
@@ -20,6 +21,7 @@ from app.models.recharge_package import RechargePackage
from app.models.credit import (
CreditProduct, CreditRecordAllocation, UserCreditBalance,
UserCreditSubscription, UserCreditSubscriptionPeriod,
TeamSubscriptionSeat, TeamSubscriptionSeatUsage,
)
from app.models.llm_billing import LlmBillingPolicyModel, LlmBillingExecution, LlmCallAttempt
from app.models.operation_log import OperationLog
@@ -56,6 +58,7 @@ __all__ = [
"TokenUsage", "IndustryConfig", "VideoEngine", "CreditRatio",
"MenuConfig", "RechargePackage", "CreditProduct", "CreditRecordAllocation", "UserCreditBalance",
"UserCreditSubscription", "UserCreditSubscriptionPeriod",
"TeamSubscriptionSeat", "TeamSubscriptionSeatUsage", "TeamManagerHistory",
"LlmBillingPolicyModel", "LlmBillingExecution", "LlmCallAttempt",
"OperationLog", "ContactRequest",
"ChatGenerationTask", "ChatGenerationTaskEvent", "ChatProviderCallLog", "VideoUpscaleTask",
@@ -3,6 +3,8 @@ 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.team_seat import TeamSubscriptionSeat
from app.models.credit.team_seat_usage import TeamSubscriptionSeatUsage
__all__ = [
"CreditRecordAllocation",
@@ -10,4 +12,6 @@ __all__ = [
"CreditProduct",
"UserCreditSubscription",
"UserCreditSubscriptionPeriod",
"TeamSubscriptionSeat",
"TeamSubscriptionSeatUsage",
]
+22 -1
View File
@@ -6,6 +6,7 @@ from decimal import Decimal
from sqlalchemy import DateTime, ForeignKey, Index, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.enums.credit_balance import CreditScope
from app.models.base import Base, TimestampMixin
@@ -16,6 +17,15 @@ class CreditRecordAllocation(Base, TimestampMixin):
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"),
Index("ix_credit_record_allocations_team_time", "team_id_snapshot", "created_at", "id"),
Index(
"ix_credit_record_allocations_team_manager_time",
"team_id_snapshot", "team_manager_id_snapshot", "created_at", "id",
),
Index(
"ix_credit_record_allocations_team_period_user",
"subscription_period_id_snapshot", "user_id", "allocation_action",
),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
@@ -26,7 +36,8 @@ class CreditRecordAllocation(Base, TimestampMixin):
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
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False,
comment="真实业务消费者;发放类记录为资金所有人",
)
source_allocation_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("credit_record_allocations.id", ondelete="SET NULL"), nullable=True
@@ -36,8 +47,18 @@ class CreditRecordAllocation(Base, TimestampMixin):
request_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
credit_level_snapshot: Mapped[str] = mapped_column(String(32), nullable=False)
credit_scope_snapshot: Mapped[str] = mapped_column(
String(16), nullable=False, default=CreditScope.PERSONAL.value,
server_default=CreditScope.PERSONAL.value,
)
source_type_snapshot: Mapped[str] = mapped_column(String(48), nullable=False)
source_id_snapshot: Mapped[str | None] = mapped_column(String(64), nullable=True)
team_id_snapshot: Mapped[str | None] = mapped_column(String(32), nullable=True)
team_manager_id_snapshot: Mapped[str | None] = mapped_column(String(32), nullable=True)
subscription_id_snapshot: Mapped[str | None] = mapped_column(String(32), nullable=True)
subscription_period_id_snapshot: Mapped[str | None] = mapped_column(String(32), nullable=True)
seat_id_snapshot: Mapped[str | None] = mapped_column(String(32), 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)
+25 -10
View File
@@ -6,7 +6,7 @@ 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.enums.credit_balance import CreditBalanceStatus, CreditScope
from app.models.base import Base, TimestampMixin
@@ -23,19 +23,25 @@ class UserCreditBalance(Base, TimestampMixin):
name="ck_user_credit_balances_amount_reconciled",
),
CheckConstraint("expires_at > valid_from", name="ck_user_credit_balances_valid_window"),
CheckConstraint(
"(credit_scope = 'personal' AND team_id IS NULL) OR "
"(credit_scope = 'team' AND team_id IS NOT NULL AND subscription_id IS NOT NULL "
"AND subscription_period_id IS NOT NULL)",
name="ck_user_credit_balances_scope_fields",
),
Index(
"ix_user_credit_balances_spendable",
"user_id",
"credit_level_rank",
"expires_at",
"valid_from",
"id",
"user_id", "credit_scope", "credit_level_rank", "expires_at", "valid_from", "id",
postgresql_where=text("unspent_amount > 0 AND revoked_at IS NULL"),
),
Index(
"ix_user_credit_balances_team_spendable",
"team_id", "subscription_id", "subscription_period_id", "credit_level_rank", "expires_at", "id",
postgresql_where=text("credit_scope = 'team' AND unspent_amount > 0 AND revoked_at IS NULL"),
),
Index(
"ix_user_credit_balances_expire_due",
"expires_at",
"id",
"expires_at", "id",
postgresql_where=text(
"unspent_amount > 0 AND expired_processed_at IS NULL AND revoked_at IS NULL"
),
@@ -48,7 +54,15 @@ class UserCreditBalance(Base, TimestampMixin):
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
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True,
comment="资金所有人;团队积分为成交时队长",
)
credit_scope: Mapped[str] = mapped_column(
String(16), nullable=False, default=CreditScope.PERSONAL.value,
server_default=CreditScope.PERSONAL.value,
)
team_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("teams.id", ondelete="RESTRICT"), nullable=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")
@@ -86,7 +100,8 @@ class UserCreditBalance(Base, TimestampMixin):
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
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)
+46 -15
View File
@@ -8,22 +8,38 @@ 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
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
class CreditProduct(Base, TimestampMixin):
class CreditProduct(Base, TimestampMixin, SoftDeleteMixin):
__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"),
Index("ix_credit_products_public", "product_type", "deleted_at", "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 "
"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 IN ('subscription', 'team_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) "
@@ -49,7 +65,7 @@ class CreditProduct(Base, TimestampMixin):
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)
@@ -59,6 +75,7 @@ class CreditProduct(Base, TimestampMixin):
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"
)
@@ -67,17 +84,31 @@ class CreditProduct(Base, TimestampMixin):
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")
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
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"
)
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
return self.product_type in {
CreditProductType.SUBSCRIPTION.value,
CreditProductType.TEAM_SUBSCRIPTION.value,
}
@property
def is_team_subscription(self) -> bool:
return self.product_type == CreditProductType.TEAM_SUBSCRIPTION.value
@property
def is_credit_addon(self) -> bool:
@@ -13,16 +13,26 @@ 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_user_active", "user_id", "status", "expires_at"),
Index("ix_user_credit_subscriptions_team_active", "team_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),
Index("uq_user_credit_subscriptions_no", "subscription_no", 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
subscription_no: Mapped[str] = mapped_column(
String(32), nullable=False, comment="订阅业务实例编号,供用户/客服/开发定位"
)
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True,
comment="成交时的个人用户;团队订阅为成交时队长",
)
team_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("teams.id", ondelete="RESTRICT"), nullable=True
)
team_manager_id_snapshot: Mapped[str | None] = mapped_column(String(32), nullable=True)
product_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("credit_products.id", ondelete="SET NULL"), nullable=True
)
@@ -34,21 +44,27 @@ class UserCreditSubscription(Base, TimestampMixin):
server_default=CreditSubscriptionStatus.PENDING.value,
)
purchase_scene: Mapped[str] = mapped_column(String(24), nullable=False)
product_type_snapshot: Mapped[str] = mapped_column(String(24), nullable=False)
product_name_snapshot: Mapped[str] = mapped_column(String(96), 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)
monthly_total_credits_snapshot: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
quantity_snapshot: Mapped[int] = mapped_column(nullable=False, default=1, server_default="1")
grant_count: Mapped[int] = mapped_column(nullable=False)
granted_count: Mapped[int] = mapped_column(nullable=False, default=0, server_default="0")
first_purchase_price_snapshot: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
regular_price_snapshot: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
activity_price_snapshot: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
actual_unit_price_snapshot: Mapped[Decimal] = mapped_column(Numeric(20, 6), nullable=False)
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
)
@@ -15,8 +15,8 @@ class UserCreditSubscriptionPeriod(Base, TimestampMixin):
__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),
Index("ix_user_credit_subscription_periods_window", "subscription_id", "valid_from", "expires_at"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
@@ -37,9 +37,4 @@ class UserCreditSubscriptionPeriod(Base, TimestampMixin):
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)
@@ -0,0 +1,41 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, Numeric, String, text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
class TeamSubscriptionSeat(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "team_subscription_seats"
__table_args__ = (
Index("ix_team_subscription_seats_subscription", "subscription_id", "created_at"),
Index("ix_team_subscription_seats_team_id", "team_id"),
CheckConstraint("monthly_allocated_credits > 0", name="ck_team_subscription_seat_allocation_positive"),
Index("ix_team_subscription_seats_user", "user_id", "subscription_id"),
Index(
"uq_team_subscription_seats_active_user",
"subscription_id", "user_id",
unique=True,
postgresql_where=text("deleted_at IS NULL AND cancelled_at IS NULL"),
),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
team_id: Mapped[str] = mapped_column(
String(32), ForeignKey("teams.id", ondelete="RESTRICT"), nullable=False
)
subscription_id: Mapped[str] = mapped_column(
String(32), ForeignKey("user_credit_subscriptions.id", ondelete="RESTRICT"), nullable=False
)
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="RESTRICT"), nullable=False
)
monthly_allocated_credits: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
created_by_user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="RESTRICT"), nullable=False
)
cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
@@ -0,0 +1,34 @@
from __future__ import annotations
from decimal import Decimal
from sqlalchemy import CheckConstraint, ForeignKey, Index, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class TeamSubscriptionSeatUsage(Base, TimestampMixin):
__tablename__ = "team_subscription_seat_usages"
__table_args__ = (
Index("uq_team_subscription_seat_usage_period", "seat_id", "subscription_period_id", unique=True),
CheckConstraint("used_credits >= 0", name="ck_team_subscription_seat_usage_nonnegative"),
Index("ix_team_subscription_seat_usage_member", "subscription_period_id", "user_id"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
seat_id: Mapped[str] = mapped_column(
String(32), ForeignKey("team_subscription_seats.id", ondelete="RESTRICT"), nullable=False
)
subscription_id: Mapped[str] = mapped_column(
String(32), ForeignKey("user_credit_subscriptions.id", ondelete="RESTRICT"), nullable=False
)
subscription_period_id: Mapped[str] = mapped_column(
String(32), ForeignKey("user_credit_subscription_periods.id", ondelete="RESTRICT"), nullable=False
)
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="RESTRICT"), nullable=False
)
used_credits: Mapped[Decimal] = mapped_column(
Numeric(20, 2), nullable=False, default=Decimal("0.00"), server_default="0"
)
+29 -8
View File
@@ -3,9 +3,10 @@ from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import DateTime, ForeignKey, Index, JSON, Numeric, String
from sqlalchemy import DateTime, ForeignKey, Index, Integer, JSON, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.enums.common import PaymentOrderSourceEnum
from app.models.base import Base, TimestampMixin
@@ -14,6 +15,8 @@ class PaymentOrder(Base, TimestampMixin):
__table_args__ = (
Index("idx_payorder_user_status_created", "user_id", "status", "created_at"),
Index("idx_payorder_status_created", "status", "created_at"),
Index("ix_payorder_source_status_created", "order_source", "status", "created_at"),
Index("ix_payorder_team_status_created", "team_id_snapshot", "status", "created_at"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
@@ -21,15 +24,22 @@ class PaymentOrder(Base, TimestampMixin):
String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True
)
order_no: Mapped[str] = mapped_column(String(64), unique=True)
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")
amount: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False, comment="实际整单实收金额")
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))
order_source: Mapped[str] = mapped_column(
String(24), nullable=False, default=PaymentOrderSourceEnum.ONLINE_PAYMENT.value,
server_default=PaymentOrderSourceEnum.ONLINE_PAYMENT.value,
)
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[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
refund_entitlement_status: Mapped[str | None] = mapped_column(String(32), nullable=True)
product_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("credit_products.id", ondelete="SET NULL"), nullable=True, index=True
@@ -40,11 +50,22 @@ class PaymentOrder(Base, TimestampMixin):
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)
quantity: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default="1")
quoted_unit_price_snapshot: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
quoted_amount_snapshot: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
actual_unit_price_snapshot: Mapped[Decimal | None] = mapped_column(Numeric(20, 6), nullable=True)
team_id_snapshot: Mapped[str | None] = mapped_column(
String(32), ForeignKey("teams.id", ondelete="RESTRICT"), nullable=True
)
operator_admin_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
offline_trade_no: Mapped[str | None] = mapped_column(String(128), nullable=True)
offline_payment_detail: Mapped[str | None] = mapped_column(String(128), nullable=True)
remark: Mapped[str | None] = mapped_column(String(512), 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)
+9 -13
View File
@@ -1,4 +1,6 @@
from sqlalchemy import ForeignKey, Index, Integer, String
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.enums.team import TeamStatus
@@ -17,21 +19,15 @@ class Team(Base, TimestampMixin, SoftDeleteMixin):
code: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="团队编码")
description: Mapped[str | None] = mapped_column(String(512), nullable=True, comment="团队备注")
status: Mapped[str] = mapped_column(
String(16),
default=TeamStatus.ACTIVE.value,
server_default=TeamStatus.ACTIVE.value,
nullable=False,
index=True,
comment="团队状态:active启用,disabled禁用",
String(16), default=TeamStatus.ACTIVE.value, server_default=TeamStatus.ACTIVE.value,
nullable=False, index=True, comment="团队状态:active启用,disabled禁用",
)
sort_order: Mapped[int] = mapped_column(
Integer,
default=0,
server_default="0",
nullable=False,
index=True,
comment="排序值,越小越靠前",
Integer, default=0, server_default="0", nullable=False, index=True, comment="排序值,越小越靠前",
)
manager_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("users.id"), nullable=True, index=True, comment="团队管理人ID"
)
first_subscription_paid_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True, comment="团队首次真实订阅成交时间"
)
@@ -0,0 +1,32 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, String, text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class TeamManagerHistory(Base, TimestampMixin):
__tablename__ = "team_manager_history"
__table_args__ = (
Index("ix_team_manager_history_team_time", "team_id", "started_at", "ended_at"),
Index("ix_team_manager_history_manager_time", "manager_user_id", "started_at", "ended_at"),
Index(
"uq_team_manager_history_current",
"team_id",
unique=True,
postgresql_where=text("ended_at IS NULL"),
),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
team_id: Mapped[str] = mapped_column(
String(32), ForeignKey("teams.id", ondelete="RESTRICT"), nullable=False
)
manager_user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="RESTRICT"), nullable=False
)
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+53
View File
@@ -55,6 +55,9 @@ class AdminUserOut(BaseModel):
email: str | None = None
phone: str | None = None
credits: float
personal_credits: float = 0
team_available_credits: float = 0
team_frozen_credits: float = 0
is_active: bool
is_admin: bool
user_type: str = "frontend"
@@ -196,9 +199,32 @@ class AdminCreditRecordAllocationOut(BaseModel):
credit_balance_id: str
source_allocation_id: str | None = None
allocation_action: str
allocation_action_label: str | None = None
amount: float
credit_level: str
credit_level_label: str | None = None
credit_scope: str | None = None
credit_scope_label: str | None = None
team_id: str | None = None
team_manager_id: str | None = None
subscription_id: str | None = None
subscription_no: str | None = None
product_name: str | None = None
product_type: str | None = None
product_type_label: str | None = None
tier_code: str | None = None
tier_label: str | None = None
tier_rank: int | None = None
billing_cycle: str | None = None
billing_cycle_label: str | None = None
subscription_period_id: str | None = None
period_sequence: int | None = None
period_label: str | None = None
period_valid_from: str | None = None
period_expires_at: str | None = None
seat_id: str | None = None
source_type: str
source_type_label: str | None = None
source_id: str | None = None
valid_from: str | None = None
expires_at: str | None = None
@@ -207,6 +233,28 @@ class AdminCreditRecordAllocationOut(BaseModel):
consumed_before: float = 0.0
consumed_after: float = 0.0
class AdminCreditRecordSubscriptionUsageOut(BaseModel):
credit_scope: str | None = None
credit_scope_label: str | None = None
subscription_id: str | None = None
subscription_no: str
product_name: str | None = None
product_type: str | None = None
product_type_label: str | None = None
tier_code: str | None = None
tier_label: str | None = None
tier_rank: int | None = None
billing_cycle: str | None = None
billing_cycle_label: str | None = None
subscription_period_id: str | None = None
period_sequence: int | None = None
period_label: str | None = None
period_valid_from: str | None = None
period_expires_at: str | None = None
amount: float = 0.0
class AdminCreditRecordOut(BaseModel):
id: str
user_id: str
@@ -260,6 +308,11 @@ class AdminCreditRecordOut(BaseModel):
llm_call_count: int = 0
llm_success_call_count: int = 0
llm_failed_call_count: int = 0
funding_scope: str = "none"
funding_scope_label: str = "无资金分摊"
team_allocation_amount: float = 0.0
personal_allocation_amount: float = 0.0
subscription_usages: list[AdminCreditRecordSubscriptionUsageOut] = Field(default_factory=list)
allocations: list[AdminCreditRecordAllocationOut] = Field(default_factory=list)
engine_type: str | None = None
engine_id: str | None = None
+9 -2
View File
@@ -2,18 +2,22 @@ from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel
from pydantic import BaseModel, Field
class CreditRecordOut(BaseModel):
id: str
type: str
type_label: str = ""
amount: float
personal_amount: float = 0
team_amount: float = 0
balance_delta: float = 0
expired_amount: float = 0
balance_after: float
description: str
billing_scene: str | None = None
billing_scene_label: str | None = None
scene_name_snapshot: str | None = None
input_tokens: int | None = None
output_tokens: int | None = None
@@ -29,8 +33,11 @@ class CreditRecordOut(BaseModel):
class CreditBalanceOut(BaseModel):
credits: float
available_credits: float
personal_credits: float = 0
team_available_credits: float = 0
team_frozen_credits: float = 0
next_expiring_credits: float = 0
next_expires_at: datetime | None = None
next_last_usable_at: datetime | None = None
records: list[CreditRecordOut]
records: list[CreditRecordOut] = Field(default_factory=list)
total: int = 0
@@ -7,8 +7,13 @@ from pydantic import BaseModel, Field
class CreditBalanceItemOut(BaseModel):
id: str
credit_scope: str
credit_scope_label: str = ""
team_id: str | None = None
credit_level: str
credit_level_label: str = ""
source_type: str
source_type_label: str = ""
source_id: str | None = None
product_id: str | None = None
payment_order_id: str | None = None
@@ -23,12 +28,16 @@ class CreditBalanceItemOut(BaseModel):
expires_at: datetime
last_usable_at: datetime
status: str
status_label: str = ""
created_at: datetime
class CreditBalanceSummaryOut(BaseModel):
credits: float
available_credits: float
personal_credits: float = 0
team_available_credits: float = 0
team_frozen_credits: float = 0
next_expiring_credits: float
next_expires_at: datetime | None = None
next_last_usable_at: datetime | None = None
+50 -19
View File
@@ -6,16 +6,21 @@ from typing import Literal
from pydantic import BaseModel, Field, model_validator
ProductType = Literal["subscription", "team_subscription", "credit_addon"]
BillingCycle = Literal["monthly", "quarterly", "yearly"]
CreditLevelType = Literal["promotional", "general"]
class CreditProductBase(BaseModel):
product_code: str = Field(..., min_length=1, max_length=64)
product_type: Literal["subscription", "credit_addon"]
product_type: ProductType
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
billing_cycle: BillingCycle | 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)
@@ -27,14 +32,14 @@ class CreditProductBase(BaseModel):
price: float = Field(default=0, ge=0, le=999999999.99)
grant_credits: float | None = Field(default=None, gt=0, le=999999999.99)
validity_months: int | None = Field(default=None, ge=1, le=36)
credit_level: Literal["promotional", "general"] = "general"
credit_level: CreditLevelType = "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":
if self.product_type in {"subscription", "team_subscription"}:
required = {
"tier_code": self.tier_code,
"tier_rank": self.tier_rank,
@@ -46,6 +51,8 @@ class CreditProductBase(BaseModel):
missing = [name for name, value in required.items() if value is None]
if missing:
raise ValueError(f"订阅套餐缺少字段: {', '.join(missing)}")
if self.grant_credits is not None or self.validity_months is not None:
raise ValueError("订阅套餐不能配置积分增值包字段")
if self.activity_price is not None:
if not self.activity_start_at or not self.activity_end_at:
raise ValueError("配置活动价时必须同时配置活动开始和结束时间")
@@ -54,10 +61,23 @@ class CreditProductBase(BaseModel):
else:
if self.grant_credits is None:
raise ValueError("积分增值包必须配置积分数量")
if self.price < 0:
raise ValueError("增值包价格不能小于0")
if self.validity_months is not None and not 1 <= self.validity_months <= 36:
raise ValueError("积分增值包有效期必须为1-36个月")
if self.validity_months is None:
self.validity_months = 1
if any(
value is not None
for value in (
self.tier_code,
self.tier_rank,
self.billing_cycle,
self.monthly_grant_credits,
self.first_purchase_price,
self.regular_price,
self.activity_price,
self.activity_start_at,
self.activity_end_at,
)
):
raise ValueError("积分增值包不能配置订阅套餐字段")
return self
@@ -66,13 +86,10 @@ class CreditProductCreate(CreditProductBase):
class CreditProductUpdate(BaseModel):
product_code: str | None = Field(default=None, min_length=1, max_length=64)
# product_code / product_type / tier_code / tier_rank / billing_cycle 创建后永久不可修改。
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)
@@ -83,9 +100,8 @@ class CreditProductUpdate(BaseModel):
price: float | None = Field(default=None, ge=0, le=999999999.99)
grant_credits: float | None = Field(default=None, gt=0, le=999999999.99)
validity_months: int | None = Field(default=None, ge=1, le=36)
credit_level: Literal["promotional", "general"] | None = None
credit_level: CreditLevelType | 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)
@@ -93,16 +109,23 @@ class CreditProductRenewalUpdate(BaseModel):
renewal_enabled: bool
class CreditProductStatusUpdate(BaseModel):
is_active: bool
class CreditProductOut(BaseModel):
id: str
product_code: str
product_type: str
product_type_label: str = ""
name: str
description: str | None = None
features: list[str] = Field(default_factory=list)
tier_code: str | None = None
tier_label: str | None = None
tier_rank: int | None = None
billing_cycle: str | None = None
billing_cycle_label: str | None = None
monthly_grant_credits: float = 0
grant_count: int = 1
first_purchase_price: float = 0
@@ -115,20 +138,28 @@ class CreditProductOut(BaseModel):
validity_months: int | None = None
price: float
current_price: float
target_price: float | None = None
deduction_amount: float = 0
price_type: str | None = None
price_type_label: str | None = None
credit_level: str
credit_level_label: str = ""
currency: str
is_active: bool
is_deleted: bool = False
deleted_at: datetime | None = None
status_label: str = ""
sort_order: int
can_purchase: bool | None = None
can_upgrade: bool = False
unavailable_reason: str | None = None
class CreditProductCatalogOut(BaseModel):
subscription_products: list[CreditProductOut]
team_subscription_products: list[CreditProductOut]
credit_addons: list[CreditProductOut]
first_purchase_available: bool
current_subscription: dict | None = None
personal_first_purchase_available: bool
team_first_purchase_available: bool
active_personal_subscriptions: list[dict] = Field(default_factory=list)
personal_entitlement: dict | None = None
team_entitlement: dict | None = None
team_purchase_available: bool = False
team_purchase_unavailable_reason: str | None = None
@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel
from pydantic import BaseModel, Field
class CreditSubscriptionPeriodOut(BaseModel):
@@ -14,6 +14,7 @@ class CreditSubscriptionPeriodOut(BaseModel):
grant_credits: float
allocated_paid_amount: float
status: str
status_label: str = ""
issued_balance_id: str | None = None
issued_at: datetime | None = None
@@ -22,22 +23,46 @@ class CreditSubscriptionPeriodOut(BaseModel):
class CreditSubscriptionOut(BaseModel):
id: str
subscription_no: str
user_id: str
team_id: str | None = None
team_manager_id_snapshot: str | None = None
product_id: str | None = None
payment_order_id: str
status: str
status_label: str = ""
purchase_scene: str
product_type_snapshot: str
product_type_label: str = ""
product_name_snapshot: str
tier_code: str
tier_rank: int
billing_cycle: str
billing_cycle_label: str = ""
anchor_at: datetime
start_at: datetime
expires_at: datetime
next_grant_at: datetime | None = None
monthly_grant_credits_snapshot: float
monthly_total_credits_snapshot: float
quantity_snapshot: int = 1
grant_count: int
granted_count: int
first_purchase_price_snapshot: float
regular_price_snapshot: float
activity_price_snapshot: float | None = None
actual_unit_price_snapshot: float
paid_amount_snapshot: float
source_subscription_id: str | None = None
periods: list[CreditSubscriptionPeriodOut] = []
periods: list[CreditSubscriptionPeriodOut] = Field(default_factory=list)
model_config = {"from_attributes": True}
class AdminOfflineSubscriptionCreate(BaseModel):
product_id: str
quantity: int = Field(default=1, ge=1, le=1000)
payment_method: str = Field(pattern="^(bank_transfer|cash|other)$")
actual_paid_amount: float | None = Field(default=None, ge=0)
offline_trade_no: str | None = Field(default=None, max_length=128)
offline_payment_detail: str | None = Field(default=None, max_length=128)
remark: str | None = Field(default=None, max_length=512)
+66 -5
View File
@@ -2,12 +2,40 @@ from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator
from app.enums.common import (
OFFLINE_PAYMENT_METHOD_LABELS,
PAYMENT_ORDER_SOURCE_LABELS,
)
from app.enums.credit_product import CREDIT_PRODUCT_TYPE_LABELS, PRODUCT_PRICE_TYPE_LABELS
PAYMENT_METHOD_LABELS = {
"alipay": "支付宝",
"wechat": "微信支付",
**OFFLINE_PAYMENT_METHOD_LABELS,
}
PAYMENT_STATUS_LABELS = {
"pending": "待支付",
"paid": "已支付",
"fulfilled": "已履约",
"refunded": "已退款",
"cancelled": "已取消",
"expired": "已过期",
"failed": "失败",
}
FULFILLMENT_STATUS_LABELS = {
"pending": "待履约",
"fulfilled": "已履约",
"failed": "履约失败",
}
class RechargeRequest(BaseModel):
plan: str = Field(..., description="积分商品ID;兼容旧字段名plan")
method: str = "wechat"
method: str = Field(default="wechat", pattern="^(wechat|alipay)$")
quantity: int = Field(default=1, ge=1, le=20, description="团队订阅购买席位数;个人商品固定为1")
class PaymentOrderOut(BaseModel):
@@ -16,18 +44,51 @@ class PaymentOrderOut(BaseModel):
amount: float
credits: float
payment_method: str
payment_method_label: str = ""
order_source: str = "online_payment"
order_source_label: str = ""
status: str
status_label: str = ""
product_id: str | None = None
product_type: str | None = None
product_type_label: str | None = None
purchase_scene: str | None = None
price_type: str | None = None
price_type_label: 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
quantity: int = 1
quoted_unit_price_snapshot: float | None = None
quoted_amount_snapshot: float | None = None
actual_unit_price_snapshot: float | None = None
team_id_snapshot: str | None = None
fulfillment_status: str | None = None
fulfillment_status_label: str | None = None
offline_trade_no: str | None = None
offline_payment_detail: str | None = None
remark: str | None = None
qr_url: str | None = None
created_at: datetime
paid_at: datetime | None = None
refund_amount: float | None = None
refunded_at: datetime | None = None
refund_trade_no: str | None = None
model_config = {"from_attributes": True}
@model_validator(mode="after")
def fill_chinese_labels(self):
if self.payment_method == "other" and self.offline_payment_detail:
self.payment_method_label = self.offline_payment_detail
else:
self.payment_method_label = PAYMENT_METHOD_LABELS.get(self.payment_method, "其他支付方式")
self.order_source_label = PAYMENT_ORDER_SOURCE_LABELS.get(self.order_source, "其他订单来源")
self.status_label = PAYMENT_STATUS_LABELS.get(self.status, "其他状态")
if self.product_type:
self.product_type_label = CREDIT_PRODUCT_TYPE_LABELS.get(self.product_type, "其他商品")
if self.price_type:
self.price_type_label = PRODUCT_PRICE_TYPE_LABELS.get(self.price_type, "其他价格")
if self.fulfillment_status:
self.fulfillment_status_label = FULFILLMENT_STATUS_LABELS.get(
self.fulfillment_status, "其他履约状态"
)
return self
+4
View File
@@ -26,11 +26,15 @@ class TeamUpdate(BaseModel):
class TeamOut(TeamBase):
id: str
status_label: str = ""
is_read_only: bool = False
team_credit_frozen: bool = False
member_count: int = 0
created_at: NaiveDatetime
updated_at: NaiveDatetimeOptional = None
manager_id: str | None = None
manager_name: str | None = None
first_subscription_paid_at: NaiveDatetimeOptional = None
model_config = {"from_attributes": True}
+5 -8
View File
@@ -4,7 +4,7 @@ from app.schemas.common import NaiveDatetime
class SetManagerRequest(BaseModel):
user_id: str | None = Field(None, description="设为管理人的前台用户ID;传 null 表示取消管理人")
user_id: str = Field(..., min_length=1, max_length=32, description="新团队队长的前台用户ID")
class TeamMemberOut(BaseModel):
@@ -12,25 +12,22 @@ class TeamMemberOut(BaseModel):
username: str
phone: str | None = None
credits: float
personal_credits: float = 0
team_available_credits: float = 0
team_frozen_credits: float = 0
is_active: bool = True
joined_at: NaiveDatetime
model_config = {"from_attributes": True}
class ManagerTransferRequest(BaseModel):
target_user_id: str = Field(..., description="接收积分的成员用户ID")
amount: float = Field(gt=0, description="转账积分数量(正数)")
direction: str = Field(default="increase", pattern="^(increase|decrease)$", description="increase=管理人转给成员;decrease=从成员扣减回管理人")
description: str | None = Field(None, max_length=256, description="转账说明")
class ManagedTeamOut(BaseModel):
id: str
name: str
code: str | None = None
description: str | None = None
status: str
status_label: str = ""
member_count: int = 0
manager_id: str | None = None
manager_name: str | None = None
@@ -0,0 +1,74 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
from app.schemas.credit_subscription import CreditSubscriptionOut
class TeamSeatCreateRequest(BaseModel):
user_id: str = Field(..., min_length=1, max_length=32)
monthly_allocated_credits: float = Field(..., gt=0, le=999999999.99, multiple_of=0.01)
class TeamSeatUpdateRequest(BaseModel):
monthly_allocated_credits: float = Field(..., gt=0, le=999999999.99, multiple_of=0.01)
class TeamSeatOut(BaseModel):
id: str
team_id: str
subscription_id: str
user_id: str
username: str | None = None
monthly_allocated_credits: float
current_period_id: str | None = None
current_period_used_credits: float = 0
current_period_remaining_credits: float = 0
status: str
status_label: str
created_at: datetime
cancelled_at: datetime | None = None
class TeamSubscriptionManageOut(BaseModel):
subscription: CreditSubscriptionOut
current_period_id: str | None = None
current_period_start_at: datetime | None = None
current_period_expires_at: datetime | None = None
period_total_credits: float = 0
period_unspent_credits: float = 0
period_unallocated_credits: float = 0
seat_limit: int
active_seat_count: int
seats: list[TeamSeatOut] = Field(default_factory=list)
class TeamMemberUsageOut(BaseModel):
user_id: str
username: str | None = None
# 以下两个 ID 保留给内部关联/筛选,前端业务表格不直接展示。
subscription_id: str
subscription_no: str
subscription_period_id: str
subscription_name: str
tier_code: str
tier_label: str
tier_rank: int
billing_cycle: str
billing_cycle_label: str
period_sequence: int
period_label: str
period_start_at: datetime | None = None
period_expires_at: datetime | None = None
consumed_credits: float
class TeamManagerHistoryOut(BaseModel):
id: str
team_id: str
manager_user_id: str
manager_name: str | None = None
started_at: datetime
ended_at: datetime | None = None
@@ -1,6 +1,7 @@
from __future__ import annotations
from datetime import datetime, timezone, timedelta
from decimal import Decimal
from typing import Any
from sqlalchemy import and_, case, distinct, func, or_, select
@@ -10,6 +11,12 @@ from app.enums.credit_balance import (
CREDIT_ALLOCATION_ACTION_LABELS,
CREDIT_BALANCE_SOURCE_TYPE_LABELS,
CREDIT_LEVEL_LABELS,
CREDIT_SCOPE_LABELS,
)
from app.enums.credit_product import (
CREDIT_PRODUCT_TYPE_LABELS,
SUBSCRIPTION_BILLING_CYCLE_LABELS,
SUBSCRIPTION_TIER_LABELS,
)
from app.enums.credit_record import (
CREDIT_RECORD_ACTION_LABELS,
@@ -26,6 +33,8 @@ 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.subscription import UserCreditSubscription
from app.models.credit.subscription_period import UserCreditSubscriptionPeriod
from app.models.credit_record import CreditRecord
from app.models.generation_record import GenerationRecord
from app.models.module_generation_project import ModuleGenerationProject
@@ -98,6 +107,7 @@ def _build_filters(
user_type: str | None = None,
frontend_user_kind: str | None = None,
team_id: str | None = None,
subscription_no: str | None = None,
record_type: str | None = None,
credit_subject: str | None = None,
media_type: str | None = None,
@@ -125,6 +135,20 @@ def _build_filters(
filters.append(CreditRecord.team_id_snapshot.is_(None))
else:
filters.append(CreditRecord.team_id_snapshot == team_id)
if subscription_no:
subscription_no_value = subscription_no.strip()
if subscription_no_value:
subscription_like = f"%{subscription_no_value}%"
filters.append(
CreditRecord.id.in_(
select(CreditRecordAllocation.credit_record_id)
.join(
UserCreditSubscription,
UserCreditSubscription.id == CreditRecordAllocation.subscription_id_snapshot,
)
.where(UserCreditSubscription.subscription_no.ilike(subscription_like))
)
)
if record_type:
filters.append(CreditRecord.type == record_type)
if credit_subject:
@@ -212,6 +236,75 @@ def _normalized_description(record: CreditRecord) -> str | None:
return description
def _build_funding_summary(allocations: list[dict[str, Any]]) -> dict[str, Any]:
team_amount = Decimal("0.00")
personal_amount = Decimal("0.00")
usage_map: dict[tuple[str, str, str], dict[str, Any]] = {}
for allocation in allocations:
amount = Decimal(str(allocation.get("amount") or 0))
scope = allocation.get("credit_scope")
if scope == "team":
team_amount += amount
elif scope == "personal":
personal_amount += amount
subscription_no = allocation.get("subscription_no")
if not subscription_no:
continue
period_id = allocation.get("subscription_period_id") or ""
key = (scope or "", subscription_no, period_id)
usage = usage_map.get(key)
if usage is None:
usage = {
"credit_scope": scope,
"credit_scope_label": allocation.get("credit_scope_label"),
"subscription_id": allocation.get("subscription_id"),
"subscription_no": subscription_no,
"product_name": allocation.get("product_name"),
"product_type": allocation.get("product_type"),
"product_type_label": allocation.get("product_type_label"),
"tier_code": allocation.get("tier_code"),
"tier_label": allocation.get("tier_label"),
"tier_rank": allocation.get("tier_rank"),
"billing_cycle": allocation.get("billing_cycle"),
"billing_cycle_label": allocation.get("billing_cycle_label"),
"subscription_period_id": allocation.get("subscription_period_id"),
"period_sequence": allocation.get("period_sequence"),
"period_label": allocation.get("period_label"),
"period_valid_from": allocation.get("period_valid_from"),
"period_expires_at": allocation.get("period_expires_at"),
"amount": 0.0,
}
usage_map[key] = usage
usage["amount"] = _round2(Decimal(str(usage.get("amount") or 0)) + amount)
if team_amount > 0 and personal_amount > 0:
scope_value, scope_label = "mixed", "团队积分 + 个人积分"
elif team_amount > 0:
scope_value, scope_label = "team", "团队积分"
elif personal_amount > 0:
scope_value, scope_label = "personal", "个人积分"
else:
scope_value, scope_label = "none", "无资金分摊"
usages = list(usage_map.values())
usages.sort(
key=lambda item: (
str(item.get("subscription_no") or ""),
int(item.get("period_sequence") or 0),
str(item.get("credit_scope") or ""),
)
)
return {
"funding_scope": scope_value,
"funding_scope_label": scope_label,
"team_allocation_amount": _round2(team_amount),
"personal_allocation_amount": _round2(personal_amount),
"subscription_usages": usages,
}
def _record_to_item(
record: CreditRecord,
user: User | None,
@@ -226,6 +319,8 @@ def _record_to_item(
user_type = record.user_type_snapshot or (user.user_type if user else None)
frontend_kind = record.frontend_user_kind_snapshot or (getattr(user, "frontend_user_kind", None) if user else None)
charge_action = _normalized_charge_action(record)
allocations = allocation_map.get(record.id, [])
funding_summary = _build_funding_summary(allocations)
return {
"id": record.id,
"user_id": record.user_id,
@@ -279,7 +374,8 @@ def _record_to_item(
"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, []),
"allocations": allocations,
**funding_summary,
"engine_type": record.engine_type,
"engine_id": record.engine_id,
"engine_name": record.engine_name,
@@ -299,6 +395,7 @@ async def list_admin_credit_records(
user_type: str | None = None,
frontend_user_kind: str | None = None,
team_id: str | None = None,
subscription_no: str | None = None,
record_type: str | None = None,
credit_subject: str | None = None,
media_type: str | None = None,
@@ -320,6 +417,7 @@ async def list_admin_credit_records(
user_type=user_type,
frontend_user_kind=frontend_user_kind,
team_id=team_id,
subscription_no=subscription_no,
record_type=record_type,
credit_subject=credit_subject,
media_type=media_type,
@@ -354,9 +452,45 @@ async def list_admin_credit_records(
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())
.order_by(
CreditRecordAllocation.credit_record_id.asc(),
CreditRecordAllocation.created_at.asc(),
CreditRecordAllocation.id.asc(),
)
)
for allocation in allocation_result.scalars().all():
allocations = list(allocation_result.scalars().all())
subscription_ids = {
allocation.subscription_id_snapshot
for allocation in allocations
if allocation.subscription_id_snapshot
}
period_ids = {
allocation.subscription_period_id_snapshot
for allocation in allocations
if allocation.subscription_period_id_snapshot
}
subscription_map: dict[str, UserCreditSubscription] = {}
if subscription_ids:
subscription_result = await db.execute(
select(UserCreditSubscription).where(UserCreditSubscription.id.in_(subscription_ids))
)
subscription_map = {item.id: item for item in subscription_result.scalars().all()}
period_map: dict[str, UserCreditSubscriptionPeriod] = {}
if period_ids:
period_result = await db.execute(
select(UserCreditSubscriptionPeriod).where(UserCreditSubscriptionPeriod.id.in_(period_ids))
)
period_map = {item.id: item for item in period_result.scalars().all()}
for allocation in allocations:
subscription = subscription_map.get(allocation.subscription_id_snapshot or "")
period = period_map.get(allocation.subscription_period_id_snapshot or "")
tier_code = subscription.tier_code if subscription else None
billing_cycle = subscription.billing_cycle if subscription else None
product_type = subscription.product_type_snapshot if subscription else None
period_sequence = (int(period.sequence) + 1) if period is not None else None
allocation_map.setdefault(allocation.credit_record_id, []).append({
"id": allocation.id,
"credit_balance_id": allocation.credit_balance_id,
@@ -366,6 +500,26 @@ async def list_admin_credit_records(
"amount": _round2(allocation.amount),
"credit_level": allocation.credit_level_snapshot,
"credit_level_label": _label(CREDIT_LEVEL_LABELS, allocation.credit_level_snapshot),
"credit_scope": allocation.credit_scope_snapshot,
"credit_scope_label": _label(CREDIT_SCOPE_LABELS, allocation.credit_scope_snapshot),
"team_id": allocation.team_id_snapshot,
"team_manager_id": allocation.team_manager_id_snapshot,
"subscription_id": allocation.subscription_id_snapshot,
"subscription_no": subscription.subscription_no if subscription else None,
"product_name": subscription.product_name_snapshot if subscription else None,
"product_type": product_type,
"product_type_label": _label(CREDIT_PRODUCT_TYPE_LABELS, product_type),
"tier_code": tier_code,
"tier_label": _label(SUBSCRIPTION_TIER_LABELS, tier_code),
"tier_rank": subscription.tier_rank if subscription else None,
"billing_cycle": billing_cycle,
"billing_cycle_label": _label(SUBSCRIPTION_BILLING_CYCLE_LABELS, billing_cycle),
"subscription_period_id": allocation.subscription_period_id_snapshot,
"period_sequence": period_sequence,
"period_label": f"{period_sequence}个月" if period_sequence is not None else None,
"period_valid_from": _iso(period.valid_from) if period else _iso(allocation.valid_from_snapshot),
"period_expires_at": _iso(period.expires_at) if period else _iso(allocation.expires_at_snapshot),
"seat_id": allocation.seat_id_snapshot,
"source_type": allocation.source_type_snapshot,
"source_type_label": _label(CREDIT_BALANCE_SOURCE_TYPE_LABELS, allocation.source_type_snapshot),
"source_id": allocation.source_id_snapshot,
@@ -0,0 +1,112 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import case, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.credit_product import CreditProductType, SubscriptionBillingCycle
from app.enums.credit_subscription import CreditSubscriptionStatus
from app.enums.team import TeamStatus
from app.models.credit.product import CreditProduct
from app.models.credit.subscription import UserCreditSubscription
from app.models.credit.team_seat import TeamSubscriptionSeat
from app.models.team import Team
from app.services.credit.utils import utc_now
_BILLING_CYCLE_RANK = case(
(CreditProduct.billing_cycle == SubscriptionBillingCycle.YEARLY.value, 3),
(CreditProduct.billing_cycle == SubscriptionBillingCycle.QUARTERLY.value, 2),
(CreditProduct.billing_cycle == SubscriptionBillingCycle.MONTHLY.value, 1),
else_=0,
)
def _entitlement_payload(subscription: UserCreditSubscription, product: CreditProduct) -> dict:
return {
"subscription_id": subscription.id,
"product_id": product.id,
"product_name": product.name,
"product_type": subscription.product_type_snapshot,
"billing_cycle": product.billing_cycle,
"tier_code": product.tier_code,
"tier_rank": product.tier_rank,
"sort_order": product.sort_order,
"start_at": subscription.start_at,
"expires_at": subscription.expires_at,
"team_id": subscription.team_id,
}
async def get_personal_entitlement(
db: AsyncSession,
*,
user_id: str,
request_time: datetime | None = None,
) -> dict | None:
checked_at = request_time or utc_now()
result = await db.execute(
select(UserCreditSubscription, CreditProduct)
.join(CreditProduct, CreditProduct.id == UserCreditSubscription.product_id)
.where(
UserCreditSubscription.user_id == user_id,
UserCreditSubscription.product_type_snapshot == CreditProductType.SUBSCRIPTION.value,
UserCreditSubscription.status == CreditSubscriptionStatus.ACTIVE.value,
UserCreditSubscription.start_at <= checked_at,
UserCreditSubscription.expires_at > checked_at,
)
.order_by(
_BILLING_CYCLE_RANK.desc(),
CreditProduct.tier_rank.desc(),
CreditProduct.sort_order.asc(),
CreditProduct.created_at.desc(),
CreditProduct.id.desc(),
)
.limit(1)
)
row = result.first()
return _entitlement_payload(row[0], row[1]) if row else None
async def get_team_entitlement(
db: AsyncSession,
*,
user_id: str,
request_time: datetime | None = None,
) -> dict | None:
checked_at = request_time or utc_now()
result = await db.execute(
select(UserCreditSubscription, CreditProduct, TeamSubscriptionSeat)
.join(CreditProduct, CreditProduct.id == UserCreditSubscription.product_id)
.join(
TeamSubscriptionSeat,
TeamSubscriptionSeat.subscription_id == UserCreditSubscription.id,
)
.join(Team, Team.id == UserCreditSubscription.team_id)
.where(
TeamSubscriptionSeat.user_id == user_id,
TeamSubscriptionSeat.deleted_at.is_(None),
TeamSubscriptionSeat.cancelled_at.is_(None),
UserCreditSubscription.product_type_snapshot == CreditProductType.TEAM_SUBSCRIPTION.value,
UserCreditSubscription.status == CreditSubscriptionStatus.ACTIVE.value,
UserCreditSubscription.start_at <= checked_at,
UserCreditSubscription.expires_at > checked_at,
Team.deleted_at.is_(None),
Team.status == TeamStatus.ACTIVE.value,
)
.order_by(
_BILLING_CYCLE_RANK.desc(),
CreditProduct.tier_rank.desc(),
CreditProduct.sort_order.asc(),
CreditProduct.created_at.desc(),
CreditProduct.id.desc(),
)
.limit(1)
)
row = result.first()
if not row:
return None
payload = _entitlement_payload(row[0], row[1])
payload["seat_id"] = row[2].id
return payload
@@ -7,10 +7,11 @@ 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_balance import CreditAllocationAction, CreditBalanceStatus, CreditScope
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.subscription import UserCreditSubscription
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
@@ -45,6 +46,14 @@ async def archive_expired_user_balances(
return 0
current_available = await get_available_credits(db, user_id, request_time=checked_at)
subscription_ids = [item.subscription_id for item in balances if item.subscription_id]
manager_map: dict[str, str | None] = {}
if subscription_ids:
sub_result = await db.execute(
select(UserCreditSubscription.id, UserCreditSubscription.team_manager_id_snapshot)
.where(UserCreditSubscription.id.in_(subscription_ids))
)
manager_map = {str(row.id): row.team_manager_id_snapshot for row in sub_result.all()}
for balance in balances:
amount = to_credit_decimal(balance.unspent_amount)
if amount <= 0:
@@ -77,8 +86,14 @@ async def archive_expired_user_balances(
amount=amount,
request_time=checked_at,
credit_level_snapshot=balance.credit_level,
credit_scope_snapshot=balance.credit_scope or CreditScope.PERSONAL.value,
source_type_snapshot=balance.source_type,
source_id_snapshot=balance.source_id,
team_id_snapshot=balance.team_id,
team_manager_id_snapshot=manager_map.get(balance.subscription_id or ""),
subscription_id_snapshot=balance.subscription_id,
subscription_period_id_snapshot=balance.subscription_period_id,
seat_id_snapshot=None,
valid_from_snapshot=balance.valid_from,
expires_at_snapshot=balance.expires_at,
unspent_before=amount,
File diff suppressed because it is too large Load Diff
+25 -3
View File
@@ -1,15 +1,37 @@
from __future__ import annotations
from collections.abc import Iterable
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
async def acquire_user_credit_lock(db: AsyncSession, user_id: str) -> None:
"""PostgreSQL 事务级用户锁SQLite 调试环境无需额外锁。"""
async def _acquire_key_lock(db: AsyncSession, lock_key: str) -> None:
"""PostgreSQL事务级 advisory lockSQLite 本地调试环境无需额外锁。"""
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}"},
{"lock_key": lock_key},
)
async def acquire_user_credit_lock(db: AsyncSession, user_id: str) -> None:
await _acquire_key_lock(db, f"credit:user:{user_id}")
async def acquire_team_business_lock(db: AsyncSession, team_id: str) -> None:
await _acquire_key_lock(db, f"credit:team:{team_id}")
async def acquire_subscription_credit_lock(db: AsyncSession, subscription_id: str) -> None:
await _acquire_key_lock(db, f"credit:subscription:{subscription_id}")
async def acquire_subscription_credit_locks(
db: AsyncSession,
subscription_ids: Iterable[str],
) -> None:
for subscription_id in sorted({str(item) for item in subscription_ids if item}):
await acquire_subscription_credit_lock(db, subscription_id)
@@ -0,0 +1,218 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.common import OfflinePaymentMethodEnum, PaymentOrderSourceEnum
from app.enums.credit_product import CreditProductType
from app.models.credit.product import CreditProduct
from app.models.payment_order import PaymentOrder
from app.models.team import Team
from app.models.user import User
from app.services.credit.locking import acquire_team_business_lock, acquire_user_credit_lock
from app.services.credit.product_service import ensure_repeat_purchase_allowed, quote_product, resolve_team_purchase_context
from app.services.credit.subscription_service import fulfill_payment_product
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, generate_order_no
ALLOWED_OFFLINE_METHODS = {
OfflinePaymentMethodEnum.BANK_TRANSFER.value,
OfflinePaymentMethodEnum.CASH.value,
OfflinePaymentMethodEnum.OTHER.value,
}
def _snapshot(product: CreditProduct) -> 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),
"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.isoformat() if product.activity_start_at else None,
"activity_end_at": product.activity_end_at.isoformat() if product.activity_end_at else None,
"renewal_enabled": bool(product.renewal_enabled),
"credit_level": product.credit_level,
"currency": product.currency,
}
async def create_offline_subscription_order(
db: AsyncSession,
*,
target_user_id: str,
product_id: str,
operator_admin_id: str,
payment_method: str,
quantity: int = 1,
actual_paid_amount: Decimal | float | int | None = None,
offline_trade_no: str | None = None,
offline_payment_detail: str | None = None,
remark: str | None = None,
request_time: datetime | None = None,
) -> PaymentOrder:
"""创建后台线下真实成交。
本函数不 commit调用方必须在同一事务中提交任何异常均应 rollback数据库不保留失败订单
"""
checked_at = request_time or utc_now()
if payment_method not in ALLOWED_OFFLINE_METHODS:
raise HTTPException(status_code=400, detail="线下收款方式仅支持银行转账、现金或其他")
await acquire_user_credit_lock(db, target_user_id)
user_result = await db.execute(
select(User).where(User.id == target_user_id).limit(1)
)
user = user_result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
product_result = await db.execute(
select(CreditProduct)
.where(
CreditProduct.id == product_id,
CreditProduct.deleted_at.is_(None),
CreditProduct.is_active.is_(True),
CreditProduct.product_type.in_([
CreditProductType.SUBSCRIPTION.value,
CreditProductType.TEAM_SUBSCRIPTION.value,
]),
)
.limit(1)
)
product = product_result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="订阅套餐不存在、已下架或已删除")
incomplete = await db.execute(
select(PaymentOrder.id)
.where(
PaymentOrder.user_id == target_user_id,
PaymentOrder.product_type.in_([
CreditProductType.SUBSCRIPTION.value,
CreditProductType.TEAM_SUBSCRIPTION.value,
]),
(
(PaymentOrder.status == "pending")
| ((PaymentOrder.status == "paid") & (PaymentOrder.fulfillment_status != "fulfilled"))
),
)
.limit(1)
)
if incomplete.scalar_one_or_none():
raise HTTPException(status_code=409, detail="用户存在未完成的订阅订单,请先处理原订单")
team: Team | None = None
if product.product_type == CreditProductType.TEAM_SUBSCRIPTION.value:
if not 2 <= int(quantity) <= 1000:
raise HTTPException(status_code=400, detail="后台团队套餐数量必须在2到1000之间")
team, available, reason = await resolve_team_purchase_context(db, user=user)
if not available:
raise HTTPException(status_code=409, detail=reason or "当前用户不能配置团队订阅")
if team is not None:
await acquire_team_business_lock(db, team.id)
team, available, reason = await resolve_team_purchase_context(db, user=user)
if not available or team is None:
raise HTTPException(status_code=409, detail=reason or "当前用户不能配置团队订阅")
first_purchase = bool(team is None or team.first_subscription_paid_at is None)
else:
quantity = 1
first_purchase = user.first_membership_paid_at is None
try:
ensure_repeat_purchase_allowed(product, first_purchase=first_purchase)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
locked_user_result = await db.execute(
select(User).where(User.id == target_user_id).limit(1).with_for_update()
)
locked_user = locked_user_result.scalar_one_or_none()
if not locked_user:
raise HTTPException(status_code=404, detail="用户不存在")
if locked_user.team_id != user.team_id:
raise HTTPException(status_code=409, detail="用户团队关系已发生变化,请刷新后重试")
user = locked_user
quote = quote_product(
product,
first_purchase=first_purchase,
request_time=checked_at,
quantity=int(quantity),
)
actual_amount = quote.quoted_amount if actual_paid_amount is None else to_credit_decimal(actual_paid_amount)
if actual_amount < 0:
raise HTTPException(status_code=400, detail="线下实际成交金额不能小于0")
actual_unit = (
Decimal(str(actual_amount)) / Decimal(int(quantity))
).quantize(Decimal("0.000001"))
order = PaymentOrder(
id=generate_id(),
user_id=target_user_id,
order_no=generate_order_no(),
amount=actual_amount,
credits=to_credit_decimal((product.monthly_grant_credits or 0) * int(quantity)),
payment_method=payment_method,
order_source=PaymentOrderSourceEnum.ADMIN_OFFLINE.value,
status="paid",
paid_at=checked_at,
product_id=product.id,
product_type=product.product_type,
purchase_scene=quote.purchase_scene,
price_type=quote.price_type,
product_code_snapshot=product.product_code,
product_name_snapshot=product.name,
product_snapshot_json=_snapshot(product),
quantity=int(quantity),
quoted_unit_price_snapshot=quote.quoted_unit_price,
quoted_amount_snapshot=quote.quoted_amount,
actual_unit_price_snapshot=actual_unit,
team_id_snapshot=team.id if team else None,
operator_admin_id=operator_admin_id,
offline_trade_no=(offline_trade_no or "").strip() or None,
offline_payment_detail=(offline_payment_detail or "").strip() or None,
remark=(remark or "").strip() or None,
fulfillment_status="pending",
)
db.add(order)
await db.flush()
await fulfill_payment_product(db, order=order, fulfilled_at=checked_at)
if order.fulfillment_status != "fulfilled":
raise RuntimeError("线下订阅成交履约未完成,事务必须回滚")
log_operation_event(
domain="payment",
module="offline_subscription",
event_type="ADMIN_OFFLINE_SUBSCRIPTION_CREATED",
user_id=target_user_id,
message="后台线下订阅真实成交成功",
detail={
"order_no": order.order_no,
"operator_admin_id": operator_admin_id,
"product_id": product.id,
"product_type": product.product_type,
"quantity": int(quantity),
"quoted_amount": float(quote.quoted_amount),
"actual_paid_amount": float(actual_amount),
"payment_method": payment_method,
"subscription_id": order.subscription_id,
"team_id": order.team_id_snapshot,
},
)
return order
@@ -7,20 +7,23 @@ from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.credit_balance import CREDIT_LEVEL_LABELS
from app.enums.credit_product import (
CREDIT_PRODUCT_TYPE_LABELS,
PRODUCT_PRICE_TYPE_LABELS,
SUBSCRIPTION_BILLING_CYCLE_LABELS,
SUBSCRIPTION_GRANT_COUNT,
SUBSCRIPTION_TIER_LABELS,
CreditProductType,
ProductPriceType,
SUBSCRIPTION_GRANT_COUNT,
SubscriptionBillingCycle,
)
from app.enums.credit_subscription import (
CreditSubscriptionPeriodStatus,
CreditSubscriptionStatus,
)
from app.enums.credit_subscription import CREDIT_SUBSCRIPTION_STATUS_LABELS, CreditSubscriptionStatus
from app.enums.team import TeamStatus
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.team import Team
from app.models.user import User
from app.services.credit.entitlement_service import get_personal_entitlement, get_team_entitlement
from app.services.credit.utils import to_credit_decimal, utc_now
@@ -29,13 +32,16 @@ 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, ...] = ()
quoted_unit_price: Decimal
quoted_amount: Decimal
quantity: int
first_purchase: bool
SUBSCRIPTION_PRODUCT_TYPES = {
CreditProductType.SUBSCRIPTION.value,
CreditProductType.TEAM_SUBSCRIPTION.value,
}
def grant_count_for_cycle(cycle: str | None) -> int:
@@ -60,87 +66,153 @@ def current_product_price(
*,
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
)
if first_purchase:
first_price = to_credit_decimal(product.first_purchase_price)
return first_price, activity, first_price, ProductPriceType.FIRST_PURCHASE.value
regular_price = to_credit_decimal(product.regular_price)
if activity is not None:
return regular_price, activity, activity, ProductPriceType.ACTIVITY.value
return regular_price, None, regular_price, ProductPriceType.REGULAR.value
async def get_active_subscription(
db: AsyncSession,
user_id: str,
def ensure_repeat_purchase_allowed(
product: CreditProduct,
*,
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()
first_purchase: bool,
) -> None:
"""校验订阅套餐续购开关。
renewal_enabled 仅控制失去首购资格后的再次购买不代表自动续费
已创建订单后续履约只读取订单快照不再动态检查此开关
"""
if (
product.product_type in SUBSCRIPTION_PRODUCT_TYPES
and not first_purchase
and not bool(product.renewal_enabled)
):
raise ValueError("该套餐当前未开启续费,已失去首购资格后不能再次购买")
async def get_upgrade_deduction_preview(
db: AsyncSession,
def quote_product(
product: CreditProduct,
*,
subscription: UserCreditSubscription,
first_purchase: bool,
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,
)
quantity: int = 1,
) -> ProductPriceQuote:
if quantity < 1:
raise ValueError("购买数量必须大于0")
if product.product_type != CreditProductType.TEAM_SUBSCRIPTION.value and quantity != 1:
raise ValueError("个人订阅和积分增值包不支持购买数量")
_, _, unit_price, price_type = current_product_price(
product, first_purchase=first_purchase, request_time=request_time
)
quoted_amount = to_credit_decimal(unit_price * quantity)
return ProductPriceQuote(
product=product,
purchase_scene=price_type,
price_type=price_type,
quoted_unit_price=unit_price,
quoted_amount=quoted_amount,
quantity=quantity,
first_purchase=first_purchase,
)
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))
.where(CreditProduct.deleted_at.is_(None), 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:
async def get_product(
db: AsyncSession,
product_id: str,
*,
for_update: bool = False,
include_deleted: bool = False,
) -> CreditProduct | None:
stmt = select(CreditProduct).where(CreditProduct.id == product_id).limit(1)
if not include_deleted:
stmt = stmt.where(CreditProduct.deleted_at.is_(None))
if for_update:
stmt = stmt.with_for_update()
result = await db.execute(stmt)
return result.scalar_one_or_none()
async def list_active_personal_subscriptions(
db: AsyncSession,
*,
user_id: str,
request_time: datetime | None = None,
) -> list[UserCreditSubscription]:
checked_at = request_time or utc_now()
result = await db.execute(
select(UserCreditSubscription)
.where(
UserCreditSubscription.user_id == user_id,
UserCreditSubscription.product_type_snapshot == CreditProductType.SUBSCRIPTION.value,
UserCreditSubscription.status == CreditSubscriptionStatus.ACTIVE.value,
UserCreditSubscription.start_at <= checked_at,
UserCreditSubscription.expires_at > checked_at,
)
.order_by(UserCreditSubscription.expires_at.asc(), UserCreditSubscription.id.asc())
)
return list(result.scalars().all())
async def resolve_team_purchase_context(
db: AsyncSession,
*,
user: User,
) -> tuple[Team | None, bool, str | None]:
if not user.team_id:
return None, True, None
result = await db.execute(
select(Team).where(Team.id == user.team_id, Team.deleted_at.is_(None)).limit(1)
)
team = result.scalar_one_or_none()
if team is None:
return None, False, "当前团队不存在"
if team.manager_id != user.id:
return team, False, "普通团队成员不能购买团队订阅套餐"
if team.status != TeamStatus.ACTIVE.value:
return team, False, "团队已禁用,当前只能查看团队数据,不能购买新的团队订阅"
return team, True, None
def _subscription_to_dict(subscription: UserCreditSubscription) -> dict:
return {
"id": subscription.id,
"subscription_no": subscription.subscription_no,
"product_id": subscription.product_id,
"product_name": subscription.product_name_snapshot,
"product_type": subscription.product_type_snapshot,
"status": subscription.status,
"status_label": CREDIT_SUBSCRIPTION_STATUS_LABELS.get(subscription.status, "其他状态"),
"billing_cycle": subscription.billing_cycle,
"billing_cycle_label": SUBSCRIPTION_BILLING_CYCLE_LABELS.get(
subscription.billing_cycle, "其他周期"
),
"tier_code": subscription.tier_code,
"tier_rank": subscription.tier_rank,
"start_at": subscription.start_at,
"expires_at": subscription.expires_at,
"monthly_grant_credits": float(subscription.monthly_grant_credits_snapshot),
"paid_amount": float(subscription.paid_amount_snapshot),
}
async def build_product_catalog(
db: AsyncSession,
*,
@@ -149,65 +221,66 @@ async def build_product_catalog(
) -> 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
personal_first_purchase = user.first_membership_paid_at is None
team, team_purchase_available, team_reason = await resolve_team_purchase_context(db, user=user)
team_first_purchase = bool(team is None or team.first_subscription_paid_at is None)
subscription_products: list[dict] = []
team_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):
credit_addons.append(
product_to_dict(
product,
user_price=to_credit_decimal(product.price),
price_type=ProductPriceType.REGULAR.value,
can_purchase=True,
)
)
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(
is_team = product.product_type == CreditProductType.TEAM_SUBSCRIPTION.value
first_purchase = team_first_purchase if is_team else personal_first_purchase
if not first_purchase and not bool(product.renewal_enabled):
continue
_, _, unit_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,
user_price=unit_price,
price_type=price_type,
can_purchase=can_purchase,
target_price=target_price,
deduction_amount=deduction_amount,
can_purchase=(team_purchase_available if is_team else True),
)
item["can_upgrade"] = can_upgrade
item["unavailable_reason"] = reason
subscription_products.append(item)
if is_team:
item["unavailable_reason"] = team_reason
if team_purchase_available:
team_subscription_products.append(item)
else:
subscription_products.append(item)
active_personal = await list_active_personal_subscriptions(
db, user_id=user.id, request_time=checked_at
)
return {
"subscription_products": subscription_products,
"team_subscription_products": team_subscription_products,
"credit_addons": credit_addons,
"first_purchase_available": first_purchase,
"current_subscription": subscription_to_dict(current) if current else None,
"personal_first_purchase_available": personal_first_purchase,
"team_first_purchase_available": team_first_purchase,
"active_personal_subscriptions": [_subscription_to_dict(item) for item in active_personal],
"personal_entitlement": await get_personal_entitlement(
db, user_id=user.id, request_time=checked_at
),
"team_entitlement": await get_team_entitlement(
db, user_id=user.id, request_time=checked_at
),
"team_purchase_available": team_purchase_available,
"team_purchase_unavailable_reason": team_reason,
}
@@ -217,19 +290,27 @@ def product_to_dict(
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:
deleted = product.deleted_at is not None
if deleted:
status_label = "已删除"
elif product.is_active:
status_label = "已上架"
else:
status_label = "已下架"
return {
"id": product.id,
"product_code": product.product_code,
"product_type": product.product_type,
"product_type_label": CREDIT_PRODUCT_TYPE_LABELS.get(product.product_type, "其他套餐类型"),
"name": product.name,
"description": product.description,
"features": product.features_json or [],
"tier_code": product.tier_code,
"tier_label": SUBSCRIPTION_TIER_LABELS.get(str(product.tier_code)) if product.tier_code else None,
"tier_rank": product.tier_rank,
"billing_cycle": product.billing_cycle,
"billing_cycle_label": SUBSCRIPTION_BILLING_CYCLE_LABELS.get(str(product.billing_cycle)) if product.billing_cycle else None,
"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),
@@ -242,30 +323,16 @@ def product_to_dict(
"validity_months": int(product.validity_months or 1) if product.is_credit_addon else None,
"price": float(user_price if user_price is not None else product.price),
"current_price": float(user_price if user_price is not None else product.price),
"target_price": float(target_price) if target_price is not None else None,
"deduction_amount": float(deduction_amount or Decimal("0.00")),
"price_type": price_type,
"price_type_label": PRODUCT_PRICE_TYPE_LABELS.get(price_type or "") if price_type else None,
"credit_level": product.credit_level,
"credit_level_label": CREDIT_LEVEL_LABELS.get(product.credit_level, "其他积分等级"),
"currency": product.currency,
"is_active": product.is_active,
"is_active": bool(product.is_active),
"is_deleted": deleted,
"deleted_at": product.deleted_at,
"status_label": status_label,
"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,
"unavailable_reason": None,
}
@@ -8,14 +8,25 @@ 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.enums.credit_balance import CreditBalanceStatus, CreditScope
from app.enums.credit_subscription import CreditSubscriptionStatus
from app.enums.team import TeamStatus
from app.models.credit.balance import UserCreditBalance
from app.models.credit.subscription import UserCreditSubscription
from app.models.credit.subscription_period import UserCreditSubscriptionPeriod
from app.models.credit.team_seat import TeamSubscriptionSeat
from app.models.credit.team_seat_usage import TeamSubscriptionSeatUsage
from app.models.team import Team
from app.models.user import User
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:
personal_credits: Decimal
team_available_credits: Decimal
team_frozen_credits: Decimal
available_credits: Decimal
next_expiring_credits: Decimal
next_expires_at: datetime | None
@@ -23,6 +34,9 @@ class CreditBalanceSummary:
def to_dict(self) -> dict:
return {
"personal_credits": to_float(self.personal_credits),
"team_available_credits": to_float(self.team_available_credits),
"team_frozen_credits": to_float(self.team_frozen_credits),
"available_credits": to_float(self.available_credits),
"credits": to_float(self.available_credits),
"next_expiring_credits": to_float(self.next_expiring_credits),
@@ -31,23 +45,114 @@ class CreditBalanceSummary:
}
async def get_available_credits(
def _blank_summary() -> CreditBalanceSummary:
zero = Decimal("0.00")
return CreditBalanceSummary(zero, zero, zero, zero, zero, None, None)
async def get_user_credit_summary_map(
db: AsyncSession,
user_id: str,
user_ids: Iterable[str],
*,
request_time: datetime | None = None,
) -> Decimal:
) -> dict[str, CreditBalanceSummary]:
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(func.coalesce(func.sum(UserCreditBalance.unspent_amount), 0)).where(
UserCreditBalance.user_id == user_id,
personal_map = {user_id: Decimal("0.00") for user_id in ids}
team_available_map = {user_id: Decimal("0.00") for user_id in ids}
team_frozen_map = {user_id: Decimal("0.00") for user_id in ids}
expiry_buckets: dict[str, dict[datetime, Decimal]] = {user_id: {} for user_id in ids}
personal_result = await db.execute(
select(UserCreditBalance.user_id, UserCreditBalance.expires_at, UserCreditBalance.unspent_amount)
.where(
UserCreditBalance.user_id.in_(ids),
UserCreditBalance.credit_scope == CreditScope.PERSONAL.value,
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())
for row in personal_result.all():
amount = to_credit_decimal(row.unspent_amount)
user_id = str(row.user_id)
personal_map[user_id] += amount
expiry_buckets[user_id][row.expires_at] = expiry_buckets[user_id].get(row.expires_at, Decimal("0.00")) + amount
team_result = await db.execute(
select(
TeamSubscriptionSeat.user_id,
Team.status.label("team_status"),
UserCreditBalance.expires_at,
UserCreditBalance.unspent_amount,
TeamSubscriptionSeat.monthly_allocated_credits,
TeamSubscriptionSeatUsage.used_credits,
)
.join(UserCreditSubscription, UserCreditSubscription.id == TeamSubscriptionSeat.subscription_id)
.join(Team, Team.id == TeamSubscriptionSeat.team_id)
.join(
UserCreditSubscriptionPeriod,
UserCreditSubscriptionPeriod.subscription_id == UserCreditSubscription.id,
)
.join(UserCreditBalance, UserCreditBalance.id == UserCreditSubscriptionPeriod.issued_balance_id)
.outerjoin(
TeamSubscriptionSeatUsage,
(TeamSubscriptionSeatUsage.seat_id == TeamSubscriptionSeat.id)
& (TeamSubscriptionSeatUsage.subscription_period_id == UserCreditSubscriptionPeriod.id),
)
.where(
TeamSubscriptionSeat.user_id.in_(ids),
TeamSubscriptionSeat.deleted_at.is_(None),
TeamSubscriptionSeat.cancelled_at.is_(None),
Team.deleted_at.is_(None),
UserCreditSubscription.status == CreditSubscriptionStatus.ACTIVE.value,
UserCreditSubscription.start_at <= checked_at,
UserCreditSubscription.expires_at > checked_at,
UserCreditSubscriptionPeriod.valid_from <= checked_at,
UserCreditSubscriptionPeriod.expires_at > checked_at,
UserCreditBalance.credit_scope == CreditScope.TEAM.value,
UserCreditBalance.valid_from <= checked_at,
UserCreditBalance.expires_at > checked_at,
UserCreditBalance.unspent_amount > 0,
UserCreditBalance.revoked_at.is_(None),
)
)
for row in team_result.all():
allocated = to_credit_decimal(row.monthly_allocated_credits)
used = to_credit_decimal(row.used_credits or 0)
seat_remaining = max(Decimal("0.00"), allocated - used)
amount = min(to_credit_decimal(row.unspent_amount), seat_remaining)
if amount <= 0:
continue
user_id = str(row.user_id)
if row.team_status == TeamStatus.ACTIVE.value:
team_available_map[user_id] += amount
expiry_buckets[user_id][row.expires_at] = expiry_buckets[user_id].get(row.expires_at, Decimal("0.00")) + amount
else:
team_frozen_map[user_id] += amount
output: dict[str, CreditBalanceSummary] = {}
for user_id in ids:
personal = personal_map[user_id]
team_available = team_available_map[user_id]
frozen = team_frozen_map[user_id]
available = personal + team_available
bucket = expiry_buckets[user_id]
next_expires_at = min(bucket.keys()) if bucket else None
next_expiring = bucket.get(next_expires_at, Decimal("0.00")) if next_expires_at else Decimal("0.00")
output[user_id] = CreditBalanceSummary(
personal_credits=personal,
team_available_credits=team_available,
team_frozen_credits=frozen,
available_credits=available,
next_expiring_credits=next_expiring,
next_expires_at=next_expires_at,
next_last_usable_at=last_usable_at(next_expires_at) if next_expires_at else None,
)
return output
async def get_balance_summary(
@@ -56,35 +161,20 @@ async def get_balance_summary(
*,
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,
return (await get_user_credit_summary_map(db, [user_id], request_time=request_time)).get(
user_id, _blank_summary()
)
async def get_available_credits(
db: AsyncSession,
user_id: str,
*,
request_time: datetime | None = None,
) -> Decimal:
return (await get_balance_summary(db, user_id, request_time=request_time)).available_credits
async def get_user_credit_map(
db: AsyncSession,
user_ids: Iterable[str],
@@ -92,31 +182,11 @@ async def get_user_credit_map(
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
summaries = await get_user_credit_summary_map(db, ids, request_time=request_time)
return {user_id: to_float(summaries.get(user_id, _blank_summary()).available_credits) for user_id in ids}
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
@@ -138,13 +208,8 @@ def apply_balance_status_filter(stmt, status: str | None, *, request_time: datet
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,
)
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:
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,890 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from typing import Any
from fastapi import HTTPException
from sqlalchemy import case, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.credit_balance import CreditAllocationAction, CreditScope
from app.enums.credit_product import (
SUBSCRIPTION_BILLING_CYCLE_LABELS,
SUBSCRIPTION_TIER_LABELS,
CreditProductType,
)
from app.enums.credit_subscription import (
CREDIT_SUBSCRIPTION_PERIOD_STATUS_LABELS,
CREDIT_SUBSCRIPTION_STATUS_LABELS,
CreditSubscriptionStatus,
)
from app.enums.team import TEAM_SEAT_STATUS_LABELS, TeamSeatStatus, TeamStatus
from app.models.credit.allocation import CreditRecordAllocation
from app.models.credit.balance import UserCreditBalance
from app.models.credit.subscription import UserCreditSubscription
from app.models.credit.subscription_period import UserCreditSubscriptionPeriod
from app.models.credit.team_seat import TeamSubscriptionSeat
from app.models.credit.team_seat_usage import TeamSubscriptionSeatUsage
from app.models.team import Team
from app.models.user import User
from app.services.credit.locking import (
acquire_subscription_credit_lock,
acquire_subscription_credit_locks,
acquire_team_business_lock,
)
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
@dataclass(slots=True)
class TeamCreditCandidate:
balance: UserCreditBalance
seat: TeamSubscriptionSeat
usage: TeamSubscriptionSeatUsage | None
subscription: UserCreditSubscription
period: UserCreditSubscriptionPeriod
available: Decimal
def _remaining(seat: TeamSubscriptionSeat, usage: TeamSubscriptionSeatUsage | None) -> Decimal:
allocated = to_credit_decimal(seat.monthly_allocated_credits)
used = to_credit_decimal(usage.used_credits if usage else 0)
return max(Decimal("0.00"), allocated - used)
def _seat_status(
seat: TeamSubscriptionSeat,
subscription: UserCreditSubscription,
checked_at: datetime,
) -> str:
if seat.deleted_at is not None or seat.cancelled_at is not None:
return TeamSeatStatus.CANCELLED.value
if subscription.expires_at <= checked_at or subscription.status != CreditSubscriptionStatus.ACTIVE.value:
return TeamSeatStatus.EXPIRED.value
return TeamSeatStatus.ACTIVE.value
async def _load_team(db: AsyncSession, team_id: str, *, for_update: bool = False) -> Team:
stmt = select(Team).where(Team.id == team_id, Team.deleted_at.is_(None)).limit(1)
if for_update:
stmt = stmt.with_for_update()
result = await db.execute(stmt)
team = result.scalar_one_or_none()
if not team:
raise HTTPException(status_code=404, detail="团队不存在")
return team
async def _assert_manager_active_team(
db: AsyncSession,
*,
team_id: str,
manager_user_id: str,
) -> Team:
await acquire_team_business_lock(db, team_id)
team = await _load_team(db, team_id, for_update=True)
if team.manager_id != manager_user_id:
raise HTTPException(status_code=403, detail="只有当前团队队长才能管理团队订阅席位")
if team.status != TeamStatus.ACTIVE.value:
raise HTTPException(status_code=409, detail="团队已禁用,当前仅允许查看,不能修改团队订阅席位")
return team
async def _load_subscription(
db: AsyncSession,
*,
subscription_id: str,
team_id: str | None = None,
request_time: datetime | None = None,
for_update: bool = False,
require_active: bool = False,
) -> UserCreditSubscription:
checked_at = request_time or utc_now()
stmt = select(UserCreditSubscription).where(
UserCreditSubscription.id == subscription_id,
UserCreditSubscription.product_type_snapshot == CreditProductType.TEAM_SUBSCRIPTION.value,
)
if team_id:
stmt = stmt.where(UserCreditSubscription.team_id == team_id)
if require_active:
stmt = stmt.where(
UserCreditSubscription.status == CreditSubscriptionStatus.ACTIVE.value,
UserCreditSubscription.start_at <= checked_at,
UserCreditSubscription.expires_at > checked_at,
)
stmt = stmt.limit(1)
if for_update:
stmt = stmt.with_for_update()
result = await db.execute(stmt)
subscription = result.scalar_one_or_none()
if not subscription:
raise HTTPException(status_code=404, detail="团队订阅不存在或当前不可用")
return subscription
async def _load_current_period(
db: AsyncSession,
*,
subscription_id: str,
request_time: datetime,
) -> UserCreditSubscriptionPeriod | None:
result = await db.execute(
select(UserCreditSubscriptionPeriod)
.where(
UserCreditSubscriptionPeriod.subscription_id == subscription_id,
UserCreditSubscriptionPeriod.valid_from <= request_time,
UserCreditSubscriptionPeriod.expires_at > request_time,
)
.order_by(UserCreditSubscriptionPeriod.sequence.asc())
.limit(1)
)
return result.scalar_one_or_none()
async def _load_period_balance(
db: AsyncSession,
*,
period: UserCreditSubscriptionPeriod | None,
for_update: bool = False,
) -> UserCreditBalance | None:
if not period or not period.issued_balance_id:
return None
stmt = select(UserCreditBalance).where(UserCreditBalance.id == period.issued_balance_id).limit(1)
if for_update:
stmt = stmt.with_for_update()
result = await db.execute(stmt)
return result.scalar_one_or_none()
async def _usage_map(
db: AsyncSession,
*,
seat_ids: list[str],
period_id: str,
for_update: bool = False,
) -> dict[str, TeamSubscriptionSeatUsage]:
if not seat_ids:
return {}
stmt = select(TeamSubscriptionSeatUsage).where(
TeamSubscriptionSeatUsage.seat_id.in_(seat_ids),
TeamSubscriptionSeatUsage.subscription_period_id == period_id,
)
if for_update:
stmt = stmt.with_for_update()
result = await db.execute(stmt)
return {item.seat_id: item for item in result.scalars().all()}
async def _validate_allocation_pool(
db: AsyncSession,
*,
subscription: UserCreditSubscription,
target_seat_id: str | None,
target_allocated: Decimal,
request_time: datetime,
) -> None:
period = await _load_current_period(
db, subscription_id=subscription.id, request_time=request_time
)
balance = await _load_period_balance(db, period=period, for_update=True)
if not period or not balance:
raise HTTPException(status_code=409, detail="当前团队订阅周期积分尚未发放,暂不能调整席位额度")
result = await db.execute(
select(TeamSubscriptionSeat)
.where(
TeamSubscriptionSeat.subscription_id == subscription.id,
TeamSubscriptionSeat.deleted_at.is_(None),
TeamSubscriptionSeat.cancelled_at.is_(None),
)
.order_by(TeamSubscriptionSeat.id.asc())
.with_for_update()
)
seats = list(result.scalars().all())
usage_map = await _usage_map(
db,
seat_ids=[item.id for item in seats],
period_id=period.id,
for_update=True,
)
total_remaining = Decimal("0.00")
for seat in seats:
if seat.id == target_seat_id:
used = to_credit_decimal(usage_map.get(seat.id).used_credits if usage_map.get(seat.id) else 0)
total_remaining += max(Decimal("0.00"), target_allocated - used)
else:
total_remaining += _remaining(seat, usage_map.get(seat.id))
if target_seat_id is None:
total_remaining += target_allocated
if total_remaining > to_credit_decimal(balance.unspent_amount):
raise HTTPException(
status_code=409,
detail=(
f"席位剩余可消费额度合计不能超过当前周期剩余团队积分,"
f"当前最多可分配 {float(max(Decimal('0.00'), to_credit_decimal(balance.unspent_amount) - (total_remaining - target_allocated))):.2f} 积分"
),
)
async def create_seat(
db: AsyncSession,
*,
team_id: str,
subscription_id: str,
user_id: str,
monthly_allocated_credits: Decimal | float | int,
manager_user_id: str,
request_time: datetime | None = None,
) -> TeamSubscriptionSeat:
checked_at = request_time or utc_now()
allocated = to_credit_decimal(monthly_allocated_credits)
if allocated <= 0:
raise HTTPException(status_code=400, detail="席位月额度必须大于0")
await _assert_manager_active_team(db, team_id=team_id, manager_user_id=manager_user_id)
await acquire_subscription_credit_lock(db, subscription_id)
subscription = await _load_subscription(
db, subscription_id=subscription_id, team_id=team_id,
request_time=checked_at, for_update=True, require_active=True,
)
user_result = await db.execute(select(User).where(User.id == user_id).with_for_update().limit(1))
user = user_result.scalar_one_or_none()
if not user or user.team_id != team_id or not user.is_active:
raise HTTPException(status_code=400, detail="席位用户必须是当前团队的有效成员")
existing = (await db.execute(
select(TeamSubscriptionSeat.id).where(
TeamSubscriptionSeat.subscription_id == subscription_id,
TeamSubscriptionSeat.user_id == user_id,
TeamSubscriptionSeat.deleted_at.is_(None),
TeamSubscriptionSeat.cancelled_at.is_(None),
).limit(1)
)).scalar_one_or_none()
if existing:
raise HTTPException(status_code=409, detail="该用户已经占用当前团队订阅的席位")
active_count = (await db.execute(
select(func.count(TeamSubscriptionSeat.id)).where(
TeamSubscriptionSeat.subscription_id == subscription_id,
TeamSubscriptionSeat.deleted_at.is_(None),
TeamSubscriptionSeat.cancelled_at.is_(None),
)
)).scalar() or 0
if int(active_count) >= int(subscription.quantity_snapshot):
raise HTTPException(status_code=409, detail="当前团队订阅席位已经分配完毕")
await _validate_allocation_pool(
db,
subscription=subscription,
target_seat_id=None,
target_allocated=allocated,
request_time=checked_at,
)
seat = TeamSubscriptionSeat(
id=generate_id(),
team_id=team_id,
subscription_id=subscription_id,
user_id=user_id,
monthly_allocated_credits=allocated,
created_by_user_id=manager_user_id,
)
db.add(seat)
await db.flush()
log_operation_event(
domain="team",
module="team_subscription",
event_type="TEAM_SUBSCRIPTION_SEAT_CREATED",
user_id=manager_user_id,
message="团队订阅席位创建成功",
detail={
"team_id": team_id,
"subscription_id": subscription_id,
"seat_id": seat.id,
"seat_user_id": user_id,
"monthly_allocated_credits": float(allocated),
},
)
return seat
async def update_seat(
db: AsyncSession,
*,
team_id: str,
seat_id: str,
monthly_allocated_credits: Decimal | float | int,
manager_user_id: str,
request_time: datetime | None = None,
) -> TeamSubscriptionSeat:
checked_at = request_time or utc_now()
allocated = to_credit_decimal(monthly_allocated_credits)
if allocated <= 0:
raise HTTPException(status_code=400, detail="席位月额度必须大于0")
await _assert_manager_active_team(db, team_id=team_id, manager_user_id=manager_user_id)
seat_result = await db.execute(
select(TeamSubscriptionSeat)
.where(
TeamSubscriptionSeat.id == seat_id,
TeamSubscriptionSeat.team_id == team_id,
TeamSubscriptionSeat.deleted_at.is_(None),
TeamSubscriptionSeat.cancelled_at.is_(None),
)
.with_for_update()
.limit(1)
)
seat = seat_result.scalar_one_or_none()
if not seat:
raise HTTPException(status_code=404, detail="团队订阅席位不存在或已取消")
await acquire_subscription_credit_lock(db, seat.subscription_id)
subscription = await _load_subscription(
db, subscription_id=seat.subscription_id, team_id=team_id,
request_time=checked_at, for_update=True, require_active=True,
)
await _validate_allocation_pool(
db,
subscription=subscription,
target_seat_id=seat.id,
target_allocated=allocated,
request_time=checked_at,
)
before_allocated = to_credit_decimal(seat.monthly_allocated_credits)
seat.monthly_allocated_credits = allocated
await db.flush()
log_operation_event(
domain="team",
module="team_subscription",
event_type="TEAM_SUBSCRIPTION_SEAT_UPDATED",
user_id=manager_user_id,
message="团队订阅席位额度修改成功",
detail={
"team_id": team_id,
"subscription_id": seat.subscription_id,
"seat_id": seat.id,
"seat_user_id": seat.user_id,
"before_monthly_allocated_credits": float(before_allocated),
"monthly_allocated_credits": float(allocated),
},
)
return seat
async def cancel_seat(
db: AsyncSession,
*,
team_id: str,
seat_id: str,
manager_user_id: str,
request_time: datetime | None = None,
) -> TeamSubscriptionSeat:
checked_at = request_time or utc_now()
await _assert_manager_active_team(db, team_id=team_id, manager_user_id=manager_user_id)
result = await db.execute(
select(TeamSubscriptionSeat)
.where(
TeamSubscriptionSeat.id == seat_id,
TeamSubscriptionSeat.team_id == team_id,
TeamSubscriptionSeat.deleted_at.is_(None),
TeamSubscriptionSeat.cancelled_at.is_(None),
)
.with_for_update()
.limit(1)
)
seat = result.scalar_one_or_none()
if not seat:
raise HTTPException(status_code=404, detail="团队订阅席位不存在或已取消")
seat.cancelled_at = checked_at
seat.deleted_at = checked_at
await db.flush()
log_operation_event(
domain="team",
module="team_subscription",
event_type="TEAM_SUBSCRIPTION_SEAT_CANCELLED",
user_id=manager_user_id,
message="团队订阅席位取消成功",
detail={
"team_id": team_id,
"subscription_id": seat.subscription_id,
"seat_id": seat.id,
"seat_user_id": seat.user_id,
"monthly_allocated_credits": float(to_credit_decimal(seat.monthly_allocated_credits)),
},
)
return seat
async def get_or_create_usage_for_update(
db: AsyncSession,
*,
seat: TeamSubscriptionSeat,
period: UserCreditSubscriptionPeriod,
) -> TeamSubscriptionSeatUsage:
result = await db.execute(
select(TeamSubscriptionSeatUsage)
.where(
TeamSubscriptionSeatUsage.seat_id == seat.id,
TeamSubscriptionSeatUsage.subscription_period_id == period.id,
)
.with_for_update()
.limit(1)
)
usage = result.scalar_one_or_none()
if usage:
return usage
usage = TeamSubscriptionSeatUsage(
id=generate_id(),
seat_id=seat.id,
subscription_id=seat.subscription_id,
subscription_period_id=period.id,
user_id=seat.user_id,
used_credits=Decimal("0.00"),
)
db.add(usage)
await db.flush()
return usage
async def list_user_team_credit_candidates(
db: AsyncSession,
*,
user_id: str,
request_time: datetime | None = None,
require_team_active: bool = True,
) -> list[TeamCreditCandidate]:
checked_at = request_time or utc_now()
user_team_id = (await db.execute(select(User.team_id).where(User.id == user_id).limit(1))).scalar_one_or_none()
if not user_team_id:
return []
team = (await db.execute(
select(Team).where(Team.id == user_team_id, Team.deleted_at.is_(None)).limit(1)
)).scalar_one_or_none()
if not team:
return []
if require_team_active and team.status != TeamStatus.ACTIVE.value:
return []
result = await db.execute(
select(
TeamSubscriptionSeat,
UserCreditSubscription,
UserCreditSubscriptionPeriod,
UserCreditBalance,
TeamSubscriptionSeatUsage,
)
.join(UserCreditSubscription, UserCreditSubscription.id == TeamSubscriptionSeat.subscription_id)
.join(
UserCreditSubscriptionPeriod,
UserCreditSubscriptionPeriod.subscription_id == UserCreditSubscription.id,
)
.join(
UserCreditBalance,
UserCreditBalance.id == UserCreditSubscriptionPeriod.issued_balance_id,
)
.outerjoin(
TeamSubscriptionSeatUsage,
(TeamSubscriptionSeatUsage.seat_id == TeamSubscriptionSeat.id)
& (TeamSubscriptionSeatUsage.subscription_period_id == UserCreditSubscriptionPeriod.id),
)
.where(
TeamSubscriptionSeat.user_id == user_id,
TeamSubscriptionSeat.team_id == user_team_id,
TeamSubscriptionSeat.deleted_at.is_(None),
TeamSubscriptionSeat.cancelled_at.is_(None),
UserCreditSubscription.product_type_snapshot == CreditProductType.TEAM_SUBSCRIPTION.value,
UserCreditSubscription.status == CreditSubscriptionStatus.ACTIVE.value,
UserCreditSubscription.start_at <= checked_at,
UserCreditSubscription.expires_at > checked_at,
UserCreditSubscriptionPeriod.valid_from <= checked_at,
UserCreditSubscriptionPeriod.expires_at > checked_at,
UserCreditBalance.credit_scope == CreditScope.TEAM.value,
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(),
)
)
output: list[TeamCreditCandidate] = []
for seat, subscription, period, balance, usage in result.all():
available = min(to_credit_decimal(balance.unspent_amount), _remaining(seat, usage))
if available > 0:
output.append(TeamCreditCandidate(balance, seat, usage, subscription, period, available))
return output
async def lock_user_team_credit_candidates(
db: AsyncSession,
*,
user_id: str,
request_time: datetime,
) -> list[TeamCreditCandidate]:
user_team_id = (await db.execute(select(User.team_id).where(User.id == user_id).limit(1))).scalar_one_or_none()
if not user_team_id:
return []
await acquire_team_business_lock(db, user_team_id)
team = await _load_team(db, user_team_id, for_update=True)
if team.status != TeamStatus.ACTIVE.value:
return []
ids_result = await db.execute(
select(
TeamSubscriptionSeat.id,
TeamSubscriptionSeat.subscription_id,
UserCreditSubscriptionPeriod.id.label("period_id"),
UserCreditSubscriptionPeriod.issued_balance_id,
)
.join(UserCreditSubscription, UserCreditSubscription.id == TeamSubscriptionSeat.subscription_id)
.join(
UserCreditSubscriptionPeriod,
UserCreditSubscriptionPeriod.subscription_id == UserCreditSubscription.id,
)
.where(
TeamSubscriptionSeat.user_id == user_id,
TeamSubscriptionSeat.team_id == user_team_id,
TeamSubscriptionSeat.deleted_at.is_(None),
TeamSubscriptionSeat.cancelled_at.is_(None),
UserCreditSubscription.status == CreditSubscriptionStatus.ACTIVE.value,
UserCreditSubscription.start_at <= request_time,
UserCreditSubscription.expires_at > request_time,
UserCreditSubscriptionPeriod.valid_from <= request_time,
UserCreditSubscriptionPeriod.expires_at > request_time,
UserCreditSubscriptionPeriod.issued_balance_id.is_not(None),
)
)
raw = list(ids_result.all())
if not raw:
return []
await acquire_subscription_credit_locks(db, [str(row.subscription_id) for row in raw])
seat_ids = [str(row.id) for row in raw]
period_ids = [str(row.period_id) for row in raw]
balance_ids = [str(row.issued_balance_id) for row in raw]
seats_result = await db.execute(
select(TeamSubscriptionSeat)
.where(TeamSubscriptionSeat.id.in_(seat_ids))
.order_by(TeamSubscriptionSeat.id.asc())
.with_for_update()
)
seats = {item.id: item for item in seats_result.scalars().all()}
subs_result = await db.execute(
select(UserCreditSubscription)
.where(UserCreditSubscription.id.in_([str(row.subscription_id) for row in raw]))
.order_by(UserCreditSubscription.id.asc())
.with_for_update()
)
subs = {item.id: item for item in subs_result.scalars().all()}
periods_result = await db.execute(
select(UserCreditSubscriptionPeriod)
.where(UserCreditSubscriptionPeriod.id.in_(period_ids))
.order_by(UserCreditSubscriptionPeriod.id.asc())
.with_for_update()
)
periods = {item.id: item for item in periods_result.scalars().all()}
balances_result = await db.execute(
select(UserCreditBalance)
.where(
UserCreditBalance.id.in_(balance_ids),
UserCreditBalance.credit_scope == CreditScope.TEAM.value,
UserCreditBalance.valid_from <= request_time,
UserCreditBalance.expires_at > request_time,
UserCreditBalance.unspent_amount > 0,
UserCreditBalance.revoked_at.is_(None),
)
.order_by(UserCreditBalance.id.asc())
.with_for_update()
)
balances = {item.id: item for item in balances_result.scalars().all()}
usage_result = await db.execute(
select(TeamSubscriptionSeatUsage)
.where(
TeamSubscriptionSeatUsage.seat_id.in_(seat_ids),
TeamSubscriptionSeatUsage.subscription_period_id.in_(period_ids),
)
.order_by(TeamSubscriptionSeatUsage.id.asc())
.with_for_update()
)
usages = {(item.seat_id, item.subscription_period_id): item for item in usage_result.scalars().all()}
output: list[TeamCreditCandidate] = []
for row in raw:
seat = seats.get(str(row.id))
subscription = subs.get(str(row.subscription_id))
period = periods.get(str(row.period_id))
balance = balances.get(str(row.issued_balance_id))
if not seat or not subscription or not period or not balance:
continue
if seat.deleted_at is not None or seat.cancelled_at is not None:
continue
usage = usages.get((seat.id, period.id))
available = min(to_credit_decimal(balance.unspent_amount), _remaining(seat, usage))
if available > 0:
output.append(TeamCreditCandidate(balance, seat, usage, subscription, period, available))
return output
async def list_team_subscriptions_for_management(
db: AsyncSession,
*,
team_id: str,
request_time: datetime | None = None,
) -> list[dict[str, Any]]:
"""按 Subscription 实例返回团队席位管理视图;全程批量查询,避免 N+1。"""
checked_at = request_time or utc_now()
team = await _load_team(db, team_id)
subs_result = await db.execute(
select(UserCreditSubscription)
.where(
UserCreditSubscription.team_id == team_id,
UserCreditSubscription.product_type_snapshot == CreditProductType.TEAM_SUBSCRIPTION.value,
)
.order_by(UserCreditSubscription.created_at.desc(), UserCreditSubscription.id.desc())
)
subscriptions = list(subs_result.scalars().all())
if not subscriptions:
return []
subscription_ids = [item.id for item in subscriptions]
periods_result = await db.execute(
select(UserCreditSubscriptionPeriod)
.where(
UserCreditSubscriptionPeriod.subscription_id.in_(subscription_ids),
UserCreditSubscriptionPeriod.valid_from <= checked_at,
UserCreditSubscriptionPeriod.expires_at > checked_at,
)
.order_by(UserCreditSubscriptionPeriod.subscription_id.asc(), UserCreditSubscriptionPeriod.sequence.asc())
)
period_map: dict[str, UserCreditSubscriptionPeriod] = {}
for period in periods_result.scalars().all():
period_map.setdefault(period.subscription_id, period)
balance_ids = [period.issued_balance_id for period in period_map.values() if period.issued_balance_id]
balance_map: dict[str, UserCreditBalance] = {}
if balance_ids:
balances_result = await db.execute(select(UserCreditBalance).where(UserCreditBalance.id.in_(balance_ids)))
balance_map = {item.id: item for item in balances_result.scalars().all()}
seats_result = await db.execute(
select(TeamSubscriptionSeat, User.username)
.join(User, User.id == TeamSubscriptionSeat.user_id)
.where(TeamSubscriptionSeat.subscription_id.in_(subscription_ids))
.order_by(
TeamSubscriptionSeat.subscription_id.asc(),
TeamSubscriptionSeat.created_at.asc(),
TeamSubscriptionSeat.id.asc(),
)
)
seat_rows_by_subscription: dict[str, list[tuple[TeamSubscriptionSeat, str]]] = {}
all_seat_ids: list[str] = []
for seat, username in seats_result.all():
seat_rows_by_subscription.setdefault(seat.subscription_id, []).append((seat, username))
all_seat_ids.append(seat.id)
current_period_ids = [period.id for period in period_map.values()]
usage_map: dict[tuple[str, str], TeamSubscriptionSeatUsage] = {}
if all_seat_ids and current_period_ids:
usage_result = await db.execute(
select(TeamSubscriptionSeatUsage).where(
TeamSubscriptionSeatUsage.seat_id.in_(all_seat_ids),
TeamSubscriptionSeatUsage.subscription_period_id.in_(current_period_ids),
)
)
usage_map = {
(item.seat_id, item.subscription_period_id): item
for item in usage_result.scalars().all()
}
output: list[dict[str, Any]] = []
for subscription in subscriptions:
period = period_map.get(subscription.id)
balance = balance_map.get(period.issued_balance_id) if period and period.issued_balance_id else None
rows = seat_rows_by_subscription.get(subscription.id, [])
active_seats = [seat for seat, _ in rows if seat.deleted_at is None and seat.cancelled_at is None]
seats_payload = []
active_remaining = Decimal("0.00")
for seat, username in rows:
usage = usage_map.get((seat.id, period.id)) if period else None
remaining = (
_remaining(seat, usage)
if seat.deleted_at is None and seat.cancelled_at is None
else Decimal("0.00")
)
active_remaining += remaining
status = _seat_status(seat, subscription, checked_at)
seats_payload.append(
{
"id": seat.id,
"team_id": seat.team_id,
"subscription_id": seat.subscription_id,
"user_id": seat.user_id,
"username": username,
"monthly_allocated_credits": float(seat.monthly_allocated_credits),
"current_period_id": period.id if period else None,
"current_period_used_credits": float(usage.used_credits if usage else 0),
"current_period_remaining_credits": float(remaining),
"status": status,
"status_label": TEAM_SEAT_STATUS_LABELS.get(status, "其他状态"),
"created_at": seat.created_at,
"cancelled_at": seat.cancelled_at,
}
)
period_unspent = to_credit_decimal(balance.unspent_amount if balance else 0)
output.append(
{
"subscription": {
"id": subscription.id,
"subscription_no": subscription.subscription_no,
"user_id": subscription.user_id,
"team_id": subscription.team_id,
"team_manager_id_snapshot": subscription.team_manager_id_snapshot,
"product_id": subscription.product_id,
"payment_order_id": subscription.payment_order_id,
"status": subscription.status,
"status_label": CREDIT_SUBSCRIPTION_STATUS_LABELS.get(subscription.status, "其他状态"),
"purchase_scene": subscription.purchase_scene,
"product_type_snapshot": subscription.product_type_snapshot,
"product_type_label": "团队订阅套餐",
"product_name_snapshot": subscription.product_name_snapshot,
"tier_code": subscription.tier_code,
"tier_rank": subscription.tier_rank,
"billing_cycle": subscription.billing_cycle,
"billing_cycle_label": {"monthly": "月卡", "quarterly": "季卡", "yearly": "年卡"}.get(subscription.billing_cycle, "其他周期"),
"anchor_at": subscription.anchor_at,
"start_at": subscription.start_at,
"expires_at": subscription.expires_at,
"next_grant_at": subscription.next_grant_at,
"monthly_grant_credits_snapshot": float(subscription.monthly_grant_credits_snapshot),
"monthly_total_credits_snapshot": float(subscription.monthly_total_credits_snapshot),
"quantity_snapshot": subscription.quantity_snapshot,
"grant_count": subscription.grant_count,
"granted_count": subscription.granted_count,
"first_purchase_price_snapshot": float(subscription.first_purchase_price_snapshot),
"regular_price_snapshot": float(subscription.regular_price_snapshot),
"activity_price_snapshot": float(subscription.activity_price_snapshot) if subscription.activity_price_snapshot is not None else None,
"actual_unit_price_snapshot": float(subscription.actual_unit_price_snapshot),
"paid_amount_snapshot": float(subscription.paid_amount_snapshot),
"periods": [],
},
"current_period_id": period.id if period else None,
"current_period_start_at": period.valid_from if period else None,
"current_period_expires_at": period.expires_at if period else None,
"period_total_credits": float(period.grant_credits if period else 0),
"period_unspent_credits": float(period_unspent),
"period_unallocated_credits": float(max(Decimal("0.00"), period_unspent - active_remaining)),
"seat_limit": int(subscription.quantity_snapshot),
"active_seat_count": len(active_seats),
"seats": seats_payload,
"team_status": team.status,
"team_status_label": "启用" if team.status == TeamStatus.ACTIVE.value else "禁用",
}
)
return output
async def list_member_period_usage(
db: AsyncSession,
*,
team_id: str,
subscription_id: str | None = None,
) -> list[dict[str, Any]]:
conditions = [
CreditRecordAllocation.credit_scope_snapshot == CreditScope.TEAM.value,
CreditRecordAllocation.team_id_snapshot == team_id,
CreditRecordAllocation.allocation_action.in_([
CreditAllocationAction.CONSUME.value,
CreditAllocationAction.REFUND_AVAILABLE.value,
CreditAllocationAction.REFUND_EXPIRED.value,
]),
]
if subscription_id:
conditions.append(CreditRecordAllocation.subscription_id_snapshot == subscription_id)
result = await db.execute(
select(
CreditRecordAllocation.user_id,
CreditRecordAllocation.subscription_id_snapshot,
CreditRecordAllocation.subscription_period_id_snapshot,
User.username,
func.sum(
case(
(CreditRecordAllocation.allocation_action == CreditAllocationAction.CONSUME.value, CreditRecordAllocation.amount),
else_=-CreditRecordAllocation.amount,
)
).label("net_used"),
)
.join(User, User.id == CreditRecordAllocation.user_id)
.where(*conditions)
.group_by(
CreditRecordAllocation.user_id,
CreditRecordAllocation.subscription_id_snapshot,
CreditRecordAllocation.subscription_period_id_snapshot,
User.username,
)
)
rows = list(result.all())
if not rows:
return []
subscription_ids = [str(row.subscription_id_snapshot) for row in rows if row.subscription_id_snapshot]
period_ids = [str(row.subscription_period_id_snapshot) for row in rows if row.subscription_period_id_snapshot]
subscriptions_result = await db.execute(
select(
UserCreditSubscription.id,
UserCreditSubscription.subscription_no,
UserCreditSubscription.product_name_snapshot,
UserCreditSubscription.tier_code,
UserCreditSubscription.tier_rank,
UserCreditSubscription.billing_cycle,
).where(UserCreditSubscription.id.in_(subscription_ids))
) if subscription_ids else None
subscription_map = {
row.id: row for row in subscriptions_result.all()
} if subscriptions_result is not None else {}
periods_result = await db.execute(
select(
UserCreditSubscriptionPeriod.id,
UserCreditSubscriptionPeriod.sequence,
UserCreditSubscriptionPeriod.valid_from,
UserCreditSubscriptionPeriod.expires_at,
).where(UserCreditSubscriptionPeriod.id.in_(period_ids))
) if period_ids else None
period_map = {
row.id: row for row in periods_result.all()
} if periods_result is not None else {}
output: list[dict[str, Any]] = []
for row in rows:
subscription = subscription_map.get(row.subscription_id_snapshot)
period = period_map.get(row.subscription_period_id_snapshot)
tier_code = str(subscription.tier_code) if subscription else ""
billing_cycle = str(subscription.billing_cycle) if subscription else ""
period_sequence = int(period.sequence) + 1 if period else 0
output.append(
{
"user_id": row.user_id,
"username": row.username,
# ID 仍用于接口内部关联/筛选,但客户端不直接展示。
"subscription_id": row.subscription_id_snapshot,
"subscription_no": subscription.subscription_no if subscription else "历史订阅",
"subscription_name": subscription.product_name_snapshot if subscription else "历史团队订阅",
"tier_code": tier_code,
"tier_label": SUBSCRIPTION_TIER_LABELS.get(tier_code, tier_code or "未知等级"),
"tier_rank": int(subscription.tier_rank) if subscription else 0,
"billing_cycle": billing_cycle,
"billing_cycle_label": SUBSCRIPTION_BILLING_CYCLE_LABELS.get(
billing_cycle, billing_cycle or "未知周期"
),
"subscription_period_id": row.subscription_period_id_snapshot,
"period_sequence": period_sequence,
"period_label": f"{period_sequence}个月" if period_sequence > 0 else "历史周期",
"period_start_at": period.valid_from if period else None,
"period_expires_at": period.expires_at if period else None,
"consumed_credits": float(max(Decimal("0.00"), to_credit_decimal(row.net_used))),
}
)
return output
@@ -1,163 +0,0 @@
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)
+4
View File
@@ -187,6 +187,7 @@ async def deduct_credits_result(
allow_negative: bool = False,
create_zero_record: bool = False,
request_time: datetime | None = None,
allowed_scopes: set[str] | None = None,
) -> CreditMutationResult:
"""旧调用兼容门面;新账本始终足额同步扣除,allow_negative 不再生效。"""
return await deduct_dynamic_credits(
@@ -201,6 +202,7 @@ async def deduct_credits_result(
record_type=record_type,
create_zero_record=create_zero_record,
request_time=request_time,
allowed_scopes=allowed_scopes,
)
@@ -218,6 +220,7 @@ async def deduct_credits(
allow_negative: bool = False,
create_zero_record: bool = False,
request_time: datetime | None = None,
allowed_scopes: set[str] | None = None,
) -> User:
return (
await deduct_credits_result(
@@ -233,6 +236,7 @@ async def deduct_credits(
allow_negative=allow_negative,
create_zero_record=create_zero_record,
request_time=request_time,
allowed_scopes=allowed_scopes,
)
).user
+5
View File
@@ -84,6 +84,11 @@ async def create_invoice(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"订单 {order.order_no} 未支付,无法开票",
)
if getattr(order, "order_source", "online_payment") != "online_payment":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"订单 {order.order_no} 为后台线下成交订单,本版本不支持申请发票",
)
# 2. 检查订单唯一性
occupied = await check_orders_available(db, data.order_ids)
+170 -180
View File
@@ -30,9 +30,12 @@ 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.enums.common import PaymentOrderSourceEnum
from app.enums.credit_product import CreditProductType
from app.models.team import Team
from app.services.credit.locking import acquire_team_business_lock, acquire_user_credit_lock
from app.services.credit.product_service import ensure_repeat_purchase_allowed, quote_product, resolve_team_purchase_context
from app.services.credit.subscription_service import fulfill_payment_product
from app.services.credit.utils import to_credit_decimal, utc_now
from app.utils.id_gen import generate_id, generate_order_no
@@ -259,8 +262,6 @@ async def _check_and_expire_order(db: AsyncSession, order: PaymentOrder) -> bool
return False
order.status = "cancelled"
if order.upgrade_period_ids_json:
await release_upgrade_reservation(db, order=order, released_at=utc_now())
await db.flush()
logger.info(
f"ORDER_EXPIRED order_no={order.order_no} user={order.user_id} "
@@ -334,10 +335,6 @@ async def expire_all_pending_orders(db: AsyncSession) -> int:
log_user_id = str(order.user_id)
log_amount = order.amount
order.status = "cancelled"
if order.upgrade_period_ids_json:
await release_upgrade_reservation(
db, order=order, released_at=utc_now()
)
await db.commit()
expired_count += 1
@@ -420,9 +417,14 @@ async def create_recharge_order(
method: str = "wechat",
*,
product_id: str | None = None,
quantity: int = 1,
request_time: datetime | None = None,
) -> PaymentOrder:
"""创建支付订单;支付渠道流程保持原样,仅增加积分商品快照和订阅升级预留。"""
"""创建线上支付订单
商品订单在创建时冻结商品定价和数量快照之后商品改价下架或软删除均不改变订单合同
个人/团队订阅存在未完成订单时全局阻止再次创建订阅订单
"""
db_configs = await _get_payment_configs(db)
mock_mode = _is_mock_mode(db_configs)
if not mock_mode:
@@ -434,120 +436,184 @@ async def create_recharge_order(
raise ValueError("支付宝支付未完成配置,请联系管理员")
elif method == "wechat":
required_configs = [
"payment_wechat_appid",
"payment_wechat_mch_id",
"payment_wechat_private_key",
"payment_wechat_cert_serial_no",
"payment_wechat_api_v3_key",
"payment_wechat_appid", "payment_wechat_mch_id", "payment_wechat_private_key",
"payment_wechat_cert_serial_no", "payment_wechat_api_v3_key",
]
missing_configs = [key for key in required_configs if not db_configs.get(key)]
if missing_configs:
raise ValueError(f"微信支付未完成配置,缺少: {', '.join(missing_configs)},请联系管理员")
else:
raise ValueError("不支持的线上支付方式")
checked_at = request_time or utc_now()
await acquire_user_credit_lock(db, user_id)
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("用户不存在")
order_id = generate_id()
order_no = generate_order_no()
product: CreditProduct | None = None
quote = None
final_price = to_credit_decimal(price)
total_credits = to_credit_decimal(float(credits or 0) + float(bonus_credits or 0))
snapshot = None
product_type = None
team_id_snapshot = None
quoted_unit = final_price
quoted_amount = final_price
actual_unit = final_price
purchase_scene = "legacy_recharge"
price_type = "regular"
if product_id:
product_result = await db.execute(
select(CreditProduct)
.where(
CreditProduct.id == product_id,
CreditProduct.deleted_at.is_(None),
CreditProduct.is_active.is_(True),
)
.limit(1)
)
product = product_result.scalar_one_or_none()
if product is None:
raise ValueError("积分商品不存在、已下架或已删除")
product_type = product.product_type
if product_type == CreditProductType.TEAM_SUBSCRIPTION.value:
if not 2 <= int(quantity) <= 20:
raise ValueError("客户端团队套餐单次购买数量必须在2到20之间")
team, available, reason = await resolve_team_purchase_context(db, user=user)
if not available:
raise ValueError(reason or "当前不能购买团队订阅套餐")
if team is not None:
await acquire_team_business_lock(db, team.id)
team, available, reason = await resolve_team_purchase_context(db, user=user)
if not available or team is None:
raise ValueError(reason or "当前不能购买团队订阅套餐")
team_id_snapshot = team.id if team else None
first_purchase = bool(team is None or team.first_subscription_paid_at is None)
else:
if int(quantity) != 1:
raise ValueError("个人订阅和积分增值包购买数量固定为1")
quantity = 1
first_purchase = user.first_membership_paid_at is None if product_type == CreditProductType.SUBSCRIPTION.value else False
if product_type in {CreditProductType.SUBSCRIPTION.value, CreditProductType.TEAM_SUBSCRIPTION.value}:
ensure_repeat_purchase_allowed(product, first_purchase=first_purchase)
incomplete_result = await db.execute(
select(PaymentOrder.id).where(
PaymentOrder.user_id == user_id,
PaymentOrder.product_type.in_([
CreditProductType.SUBSCRIPTION.value,
CreditProductType.TEAM_SUBSCRIPTION.value,
]),
(
(PaymentOrder.status == "pending")
| ((PaymentOrder.status == "paid") & (PaymentOrder.fulfillment_status != "fulfilled"))
),
).limit(1)
)
if incomplete_result.scalar_one_or_none():
raise ValueError("存在未完成的订阅订单,请先前往订单记录完成支付或处理原订单")
quote = quote_product(
product,
first_purchase=first_purchase,
request_time=checked_at,
quantity=int(quantity),
)
quoted_unit = quote.quoted_unit_price
quoted_amount = quote.quoted_amount
final_price = quoted_amount
actual_unit = quoted_unit
purchase_scene = quote.purchase_scene
price_type = quote.price_type
label = product.name
if product_type == CreditProductType.TEAM_SUBSCRIPTION.value:
total_credits = to_credit_decimal((product.monthly_grant_credits or 0) * int(quantity))
elif product_type == CreditProductType.SUBSCRIPTION.value:
total_credits = to_credit_decimal(product.monthly_grant_credits or 0)
else:
total_credits = to_credit_decimal(product.grant_credits or 0)
snapshot = {
"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),
"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.isoformat() if product.activity_start_at else None,
"activity_end_at": product.activity_end_at.isoformat() if product.activity_end_at else None,
"renewal_enabled": bool(product.renewal_enabled),
"grant_credits": float(product.grant_credits or 0),
"validity_months": product.validity_months,
"credit_level": product.credit_level,
"currency": product.currency,
}
# 固定锁序:user advisory 已获取;团队订单已先获取 Team advisory,最后才锁 User 行。
locked_user_result = await db.execute(select(User).where(User.id == user_id).limit(1).with_for_update())
locked_user = locked_user_result.scalar_one_or_none()
if locked_user is None:
raise ValueError("用户不存在")
if locked_user.team_id != user.team_id:
raise ValueError("用户团队关系已发生变化,请刷新后重试")
user = locked_user
# 先持久化订单主记录,再做升级周期预留。订阅周期的 upgrade_order_id
# 有外键约束,若先更新周期后插入订单,flush 顺序可能触发外键异常。
order = PaymentOrder(
id=order_id,
user_id=user_id,
order_no=order_no,
amount=price,
credits=round(float(credits or 0) + float(bonus_credits or 0), 2),
amount=final_price,
credits=total_credits,
payment_method=method,
order_source=PaymentOrderSourceEnum.ONLINE_PAYMENT.value,
status="pending",
purchase_scene="legacy_recharge",
price_type="regular",
product_id=product.id if product else None,
product_type=product_type,
purchase_scene=purchase_scene,
price_type=price_type,
product_code_snapshot=product.product_code if product else None,
product_name_snapshot=label,
target_price_snapshot=price,
deduction_amount_snapshot=0,
payable_amount_snapshot=price,
product_snapshot_json=snapshot,
quantity=int(quantity),
quoted_unit_price_snapshot=quoted_unit,
quoted_amount_snapshot=quoted_amount,
actual_unit_price_snapshot=actual_unit,
team_id_snapshot=team_id_snapshot,
fulfillment_status="pending" if product else None,
)
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} amount={price} "
f"credits={total_credits} method={method} product_id={product_id} mock={mock_mode}"
f"ORDER_CREATED order_no={order.order_no} user={user_id} amount={order.amount} "
f"credits={order.credits} method={method} product_id={product_id} quantity={quantity} mock={mock_mode}"
)
if mock_mode:
order.status = "paid"
order.paid_at = checked_at
desc = f"充值{label}({total_credits}积分)"
if bonus_credits > 0:
desc += f"(含赠送{bonus_credits}积分)"
await _fulfill_paid_order(
db,
order=order,
fulfilled_at=checked_at,
legacy_description=desc,
)
desc = f"充值{label}({order.credits}积分)"
await _fulfill_paid_order(db, order=order, fulfilled_at=checked_at, legacy_description=desc)
await db.flush()
else:
if method == "wechat":
qr_code_content = _create_wechat_order(order, db_configs)
if qr_code_content:
order.qr_url = qr_code_content # type: ignore[attr-defined]
else:
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:
order.qr_url = qr_url # type: ignore[attr-defined]
else:
if quote and quote.upgrade_period_ids:
await release_upgrade_reservation(db, order=order, released_at=checked_at)
raise ValueError("支付宝预下单失败,请检查配置或稍后重试")
elif method == "wechat":
qr_code_content = _create_wechat_order(order, db_configs)
if not qr_code_content:
raise ValueError("微信支付预下单失败,请检查配置或稍后重试")
order.qr_url = qr_code_content # type: ignore[attr-defined]
elif method == "alipay":
qr_url = _create_alipay_order(order, db_configs)
if not qr_url:
raise ValueError("支付预下单失败,请检查配置或稍后重试")
order.qr_url = qr_url # type: ignore[attr-defined]
return order
@@ -1238,10 +1304,6 @@ async def sync_pending_orders(db: AsyncSession) -> int:
updated_count += 1
elif trade_status in ("TRADE_CLOSED", "TRADE_CANCELLED"):
order.status = "cancelled"
if order.upgrade_period_ids_json:
await release_upgrade_reservation(
db, order=order, released_at=utc_now()
)
await db.commit()
updated_count += 1
@@ -1259,10 +1321,6 @@ async def sync_pending_orders(db: AsyncSession) -> int:
updated_count += 1
elif trade_state in ("CLOSED", "REVOKED"):
order.status = "cancelled"
if order.upgrade_period_ids_json:
await release_upgrade_reservation(
db, order=order, released_at=utc_now()
)
await db.commit()
updated_count += 1
except Exception as e:
@@ -1596,86 +1654,18 @@ async def process_refund(
db: AsyncSession,
order_no: str,
refund_amount: float | None = None,
refund_reason: str = "管理员退款"
refund_reason: str = "管理员退款",
) -> dict:
"""Process a refund for a paid order.
Args:
db: async database session
order_no: merchant order number
refund_amount: amount to refund (defaults to full order amount)
refund_reason: reason for refund
Returns:
dict with refund result
"""
result = await db.execute(
select(PaymentOrder).where(PaymentOrder.order_no == order_no).with_for_update().limit(1)
)
"""本版本保留退款 Service 入口,但主动订单退款统一关闭。"""
result = await db.execute(select(PaymentOrder).where(PaymentOrder.order_no == order_no).limit(1))
order = result.scalar_one_or_none()
if not order:
return {"success": False, "message": "订单不存在"}
if order.status != "paid":
return {"success": False, "message": f"订单状态为{order.status},无法退款"}
if order.refunded_at is not None:
return {"success": False, "message": "订单已退款"}
refund_amount = to_credit_decimal(refund_amount if refund_amount is not None else order.amount)
# 金额校验
if refund_amount > to_credit_decimal(order.amount):
return {"success": False, "message": "退款金额超过订单金额"}
# 根据支付方式调用相应的退款API
db_configs = await _get_payment_configs(db)
if order.payment_method == "alipay":
refund_result = await _refund_alipay_order(
db, order, refund_amount, refund_reason, db_configs
)
if not refund_result.get("success"):
return refund_result
elif order.payment_method == "wechat":
refund_result = await _refund_wechat_order(
db, order, refund_amount, refund_reason, db_configs
)
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),
record_meta=_payment_refund_meta(order),
)
except Exception as e:
logger.exception(f"Failed to deduct credits for refund: {e}")
return {"success": False, "message": "积分扣除失败"}
# 更新订单状态
order.status = "refunded"
order.refund_amount = refund_amount
order.refunded_at = utc_now()
if order.payment_method == "alipay":
order.refund_trade_no = db_configs.get("refund_trade_no", "")
await db.commit()
logger.info(
f"REFUND_SUCCESS order_no={order_no} user={order.user_id} "
f"refund_amount={refund_amount}"
logger.warning(
"REFUND_BLOCKED order_no=%s user=%s requested_amount=%s reason=%s",
order_no, order.user_id, refund_amount, refund_reason,
)
return {"success": True, "message": "退款成功"}
return {"success": False, "message": "当前版本暂未开放订单退款"}
async def _refund_alipay_order(
@@ -0,0 +1,163 @@
from __future__ import annotations
from datetime import datetime, time, timezone
from decimal import Decimal
from fastapi import HTTPException
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.credit_balance import CREDIT_ALLOCATION_ACTION_LABELS, CreditAllocationAction, CreditScope
from app.enums.credit_record import CREDIT_RECORD_TYPE_LABELS
from app.models.credit.allocation import CreditRecordAllocation
from app.models.credit.subscription import UserCreditSubscription
from app.models.credit_record import CreditRecord
from app.models.team import Team
from app.models.team_manager_history import TeamManagerHistory
from app.models.user import User
from app.services.credit.utils import to_credit_decimal
POSITIVE_ACTIONS = {
CreditAllocationAction.GRANT.value,
CreditAllocationAction.REFUND_AVAILABLE.value,
CreditAllocationAction.REFUND_EXPIRED.value,
}
NEGATIVE_ACTIONS = {
CreditAllocationAction.CONSUME.value,
CreditAllocationAction.EXPIRE.value,
CreditAllocationAction.REVOKE.value,
}
def _signed_amount(action: str, amount) -> float:
value = to_credit_decimal(amount)
if action in NEGATIVE_ACTIONS:
value = -value
return float(value)
async def _resolve_team_flow_permission(
db: AsyncSession,
*,
team_id: str,
viewer_user_id: str,
) -> tuple[Team, bool]:
team_result = await db.execute(
select(Team).where(Team.id == team_id).limit(1)
)
team = team_result.scalar_one_or_none()
if not team:
raise HTTPException(status_code=404, detail="团队不存在")
if team.manager_id == viewer_user_id:
return team, True
history_result = await db.execute(
select(TeamManagerHistory.id).where(
TeamManagerHistory.team_id == team_id,
TeamManagerHistory.manager_user_id == viewer_user_id,
).limit(1)
)
if not history_result.scalar_one_or_none():
raise HTTPException(status_code=403, detail="仅当前队长或历史队长可以查看团队积分流水")
return team, False
async def list_team_credit_records(
db: AsyncSession,
*,
team_id: str,
viewer_user_id: str,
page: int = 1,
page_size: int = 20,
member_user_id: str | None = None,
subscription_id: str | None = None,
record_type: str | None = None,
start_date: str | None = None,
end_date: str | None = None,
) -> dict:
_, current_manager = await _resolve_team_flow_permission(
db, team_id=team_id, viewer_user_id=viewer_user_id
)
conditions = [
CreditRecordAllocation.credit_scope_snapshot == CreditScope.TEAM.value,
CreditRecordAllocation.team_id_snapshot == team_id,
]
if not current_manager:
conditions.append(CreditRecordAllocation.team_manager_id_snapshot == viewer_user_id)
if member_user_id:
conditions.append(CreditRecordAllocation.user_id == member_user_id)
if subscription_id:
conditions.append(CreditRecordAllocation.subscription_id_snapshot == subscription_id)
if record_type:
conditions.append(CreditRecord.type == record_type)
if start_date:
try:
start_at = datetime.combine(datetime.strptime(start_date, "%Y-%m-%d").date(), time.min).replace(tzinfo=timezone.utc)
except ValueError as exc:
raise HTTPException(status_code=400, detail="起始日期格式应为 YYYY-MM-DD") from exc
conditions.append(CreditRecord.created_at >= start_at)
if end_date:
try:
end_at = datetime.combine(datetime.strptime(end_date, "%Y-%m-%d").date(), time.max).replace(tzinfo=timezone.utc)
except ValueError as exc:
raise HTTPException(status_code=400, detail="截止日期格式应为 YYYY-MM-DD") from exc
conditions.append(CreditRecord.created_at <= end_at)
total_result = await db.execute(
select(func.count(CreditRecordAllocation.id))
.join(CreditRecord, CreditRecord.id == CreditRecordAllocation.credit_record_id)
.where(*conditions)
)
total = int(total_result.scalar_one() or 0)
result = await db.execute(
select(
CreditRecordAllocation,
CreditRecord,
User.username,
UserCreditSubscription.subscription_no,
)
.join(CreditRecord, CreditRecord.id == CreditRecordAllocation.credit_record_id)
.join(User, User.id == CreditRecordAllocation.user_id)
.outerjoin(
UserCreditSubscription,
UserCreditSubscription.id == CreditRecordAllocation.subscription_id_snapshot,
)
.where(*conditions)
.order_by(CreditRecordAllocation.created_at.desc(), CreditRecordAllocation.id.desc())
.offset(max(0, page - 1) * max(1, page_size))
.limit(max(1, min(page_size, 10000)))
)
items = []
for allocation, record, username, subscription_no in result.all():
items.append(
{
"id": allocation.id,
"credit_record_id": record.id,
"user_id": allocation.user_id,
"username": username,
"record_type": record.type,
"record_type_label": CREDIT_RECORD_TYPE_LABELS.get(record.type, "其他"),
"allocation_action": allocation.allocation_action,
"allocation_action_label": CREDIT_ALLOCATION_ACTION_LABELS.get(
allocation.allocation_action, "其他"
),
"team_amount": _signed_amount(allocation.allocation_action, allocation.amount),
"description": record.description,
"request_time": record.request_time,
"created_at": record.created_at,
"credit_level": allocation.credit_level_snapshot,
"subscription_id": allocation.subscription_id_snapshot,
"subscription_no": subscription_no or "历史订阅",
"subscription_period_id": allocation.subscription_period_id_snapshot,
"seat_id": allocation.seat_id_snapshot,
"team_manager_id_snapshot": allocation.team_manager_id_snapshot,
# 团队流水明确不输出总 CreditRecord 金额、个人 Allocation 和 Token 相关字段。
}
)
return {
"items": items,
"total": total,
"page": page,
"page_size": page_size,
"is_current_manager": current_manager,
}
@@ -11,6 +11,8 @@ 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.services.credit.locking import acquire_team_business_lock, acquire_user_credit_lock
from app.services.team_service import assert_team_active, set_frontend_user_team
from app.utils.id_gen import generate_id
import secrets
@@ -44,7 +46,9 @@ async def create_invitation(
expires_at: datetime | None = None,
) -> TeamInvitation:
"""创建邀请码(仅团队管理人)。"""
await acquire_team_business_lock(db, team_id)
await _assert_is_manager(db, created_by, team_id)
await assert_team_active(db, team_id, for_update=True)
code = _generate_invite_code()
if expires_at is None:
expires_at = datetime.now(timezone.utc) + timedelta(days=1)
@@ -92,7 +96,9 @@ async def revoke_invitation(db: AsyncSession, invitation_id: str, revoked_by: st
invitation = result.scalar_one_or_none()
if not invitation:
raise HTTPException(status_code=404, detail="邀请码不存在")
await acquire_team_business_lock(db, invitation.team_id)
await _assert_is_manager(db, revoked_by, invitation.team_id)
await assert_team_active(db, invitation.team_id, for_update=True)
invitation.status = "revoked"
await db.flush()
@@ -106,6 +112,8 @@ async def create_join_request(
invitation = await get_invitation_by_code(db, invitation_code)
if not invitation:
raise HTTPException(status_code=400, detail="邀请码无效或已过期/已用完")
await acquire_team_business_lock(db, invitation.team_id)
await assert_team_active(db, invitation.team_id, for_update=True)
# 验证用户存在
user_result = await db.execute(
@@ -166,8 +174,20 @@ async def handle_join_request(
note: str | None = None,
) -> None:
"""审批/拒绝加入申请(仅团队管理人)。"""
preview = await db.execute(
select(TeamJoinRequest.user_id, TeamJoinRequest.team_id)
.where(TeamJoinRequest.id == request_id)
.limit(1)
)
preview_row = preview.first()
if not preview_row:
raise HTTPException(status_code=404, detail="申请不存在")
# 固定锁序:user advisory -> team advisory -> request/user row。
await acquire_user_credit_lock(db, str(preview_row.user_id))
await acquire_team_business_lock(db, str(preview_row.team_id))
result = await db.execute(
select(TeamJoinRequest).where(TeamJoinRequest.id == request_id).limit(1)
select(TeamJoinRequest).where(TeamJoinRequest.id == request_id).with_for_update().limit(1)
)
request = result.scalar_one_or_none()
if not request:
@@ -176,9 +196,9 @@ async def handle_join_request(
raise HTTPException(status_code=400, detail="该申请已处理")
await _assert_is_manager(db, manager_id, request.team_id)
await assert_team_active(db, request.team_id, for_update=True)
if action == "approve":
# 检查用户是否已在其他团队
user_result = await db.execute(
select(User).where(User.id == request.user_id, User.is_active == True).limit(1)
)
@@ -187,8 +207,7 @@ async def handle_join_request(
raise HTTPException(status_code=404, detail="用户不存在")
if user.team_id and user.team_id != request.team_id:
raise HTTPException(status_code=400, detail="用户已在其他团队中,无法加入")
user.team_id = request.team_id
await set_frontend_user_team(db, user_id=user.id, team_id=request.team_id)
request.status = "approved"
elif action == "reject":
request.status = "rejected"
@@ -3,61 +3,139 @@ from __future__ import annotations
from typing import Any
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.credit_product import CreditProductType
from app.enums.credit_subscription import CreditSubscriptionStatus
from app.enums.team import TEAM_STATUS_LABELS, TeamStatus
from app.enums.user import UserType
from app.models.credit.subscription import UserCreditSubscription
from app.models.payment_order import PaymentOrder
from app.models.team import Team
from app.models.team_manager_history import TeamManagerHistory
from app.models.user import User
from app.services.credit.query_service import attach_credit_snapshot, get_user_credit_map
from app.services.credit.locking import acquire_team_business_lock
from app.services.credit.query_service import attach_credit_snapshot, get_user_credit_summary_map
from app.services.credit.utils import utc_now
from app.services.operation_log_service import log_operation_event
from app.utils.id_gen import generate_id
async def set_team_manager(db: AsyncSession, team_id: str, user_id: str | None) -> Team:
"""设置团队管理人。user_id 为 None 表示取消管理人。"""
async def _assert_transfer_allowed(db: AsyncSession, team: Team) -> None:
if team.status != TeamStatus.ACTIVE.value:
raise HTTPException(status_code=409, detail="团队已禁用,当前仅允许查看,不能更换队长")
checked_at = utc_now()
active_subscription = (await db.execute(
select(UserCreditSubscription.id)
.where(
UserCreditSubscription.team_id == team.id,
UserCreditSubscription.product_type_snapshot == CreditProductType.TEAM_SUBSCRIPTION.value,
UserCreditSubscription.status == CreditSubscriptionStatus.ACTIVE.value,
UserCreditSubscription.start_at <= checked_at,
UserCreditSubscription.expires_at > checked_at,
)
.limit(1)
)).scalar_one_or_none()
if active_subscription:
raise HTTPException(status_code=409, detail="团队仍有有效团队订阅,全部订阅结束后才能更换队长")
incomplete_order = (await db.execute(
select(PaymentOrder.id)
.where(
PaymentOrder.team_id_snapshot == team.id,
PaymentOrder.product_type == CreditProductType.TEAM_SUBSCRIPTION.value,
or_(
PaymentOrder.status == "pending",
and_(PaymentOrder.status == "paid", PaymentOrder.fulfillment_status != "fulfilled"),
),
)
.limit(1)
)).scalar_one_or_none()
if incomplete_order:
raise HTTPException(status_code=409, detail="团队仍有待支付或待履约团队订阅订单,暂不能更换队长")
async def set_team_manager(db: AsyncSession, team_id: str, user_id: str) -> Team:
"""更换团队队长;仅允许转给当前团队成员。"""
await acquire_team_business_lock(db, team_id)
result = await db.execute(
select(Team).where(Team.id == team_id, Team.deleted_at.is_(None)).limit(1)
select(Team)
.where(Team.id == team_id, Team.deleted_at.is_(None))
.with_for_update()
.limit(1)
)
team = result.scalar_one_or_none()
if not team:
raise HTTPException(status_code=404, detail="团队不存在")
if user_id is None:
team.manager_id = None
await db.flush()
await db.refresh(team)
if team.manager_id == user_id:
return team
await _assert_transfer_allowed(db, team)
# 验证用户存在、是前台用户、属于该团队
user_result = await db.execute(
select(User).where(User.id == user_id, User.is_active.is_(True)).limit(1)
select(User).where(User.id == user_id, User.is_active.is_(True)).with_for_update().limit(1)
)
user = user_result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
if user.user_type != UserType.FRONTEND.value:
raise HTTPException(status_code=400, detail="仅前台用户可设为团队管理人")
raise HTTPException(status_code=400, detail="仅前台用户可设为团队队长")
if user.team_id != team_id:
raise HTTPException(status_code=400, detail="用户不属于该团队,请先将其加入团队")
raise HTTPException(status_code=400, detail="新队长必须是当前团队成员")
checked_at = utc_now()
if team.manager_id:
current_history_result = await db.execute(
select(TeamManagerHistory)
.where(
TeamManagerHistory.team_id == team.id,
TeamManagerHistory.manager_user_id == team.manager_id,
TeamManagerHistory.ended_at.is_(None),
)
.order_by(TeamManagerHistory.started_at.desc(), TeamManagerHistory.id.desc())
.with_for_update()
.limit(1)
)
current_history = current_history_result.scalar_one_or_none()
if current_history:
current_history.ended_at = checked_at
previous_manager_id = team.manager_id
db.add(
TeamManagerHistory(
id=generate_id(),
team_id=team.id,
manager_user_id=user_id,
started_at=checked_at,
)
)
team.manager_id = user_id
await db.flush()
await db.refresh(team)
log_operation_event(
domain="team",
module="team_manager",
event_type="TEAM_MANAGER_CHANGED",
user_id=user_id,
message="团队队长更换成功",
detail={
"team_id": team.id,
"previous_manager_user_id": previous_manager_id,
"new_manager_user_id": user_id,
},
)
return team
async def is_team_manager(db: AsyncSession, user_id: str, team_id: str | None) -> bool:
"""判断用户是否是指定团队的管理人。"""
if not team_id:
return False
result = await db.execute(
select(Team.manager_id).where(Team.id == team_id, Team.deleted_at.is_(None)).limit(1)
)
manager_id = result.scalar_one_or_none()
return manager_id == user_id
return result.scalar_one_or_none() == user_id
async def get_managed_team(db: AsyncSession, user_id: str) -> Team | None:
"""获取用户管理的团队。"""
result = await db.execute(
select(Team).where(Team.manager_id == user_id, Team.deleted_at.is_(None)).limit(1)
)
@@ -71,19 +149,14 @@ async def get_team_members(
page: int = 1,
page_size: int = 20,
) -> dict[str, Any]:
"""列出团队成员(仅前台用户)。"""
page = max(int(page or 1), 1)
page_size = min(max(int(page_size or 20), 1), 100)
# 验证团队存在
team_result = await db.execute(
select(Team).where(Team.id == team_id, Team.deleted_at.is_(None)).limit(1)
)
if not team_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="团队不存在")
# 总数
from sqlalchemy import func
total = (await db.execute(
select(func.count(User.id)).where(
User.user_type == UserType.FRONTEND.value,
@@ -91,10 +164,9 @@ async def get_team_members(
User.is_active.is_(True),
)
)).scalar() or 0
# 列表
result = await db.execute(
select(User).where(
select(User)
.where(
User.user_type == UserType.FRONTEND.value,
User.team_id == team_id,
User.is_active.is_(True),
@@ -104,32 +176,100 @@ 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": [
summary_map = await get_user_credit_summary_map(db, [item.id for item in members])
items = []
for member in members:
summary = summary_map.get(member.id)
credits = float(summary.available_credits if summary else 0)
attach_credit_snapshot(member, credits)
items.append(
{
"id": m.id,
"username": m.username,
"phone": m.phone,
"credits": m.credits,
"is_active": m.is_active,
"joined_at": m.created_at,
"id": member.id,
"username": member.username,
"phone": member.phone,
"credits": credits,
"personal_credits": float(summary.personal_credits if summary else 0),
"team_available_credits": float(summary.team_available_credits if summary else 0),
"team_frozen_credits": float(summary.team_frozen_credits if summary else 0),
"is_active": member.is_active,
"joined_at": member.created_at,
}
for m in members
],
"total": total,
}
)
return {"items": items, "total": int(total)}
async def transfer_credits_to_member(
db: AsyncSession,
manager_id: str,
target_member_id: str,
amount: float,
direction: str = "increase", # "increase" 管理人→成员; "decrease" 成员扣减
description: str | None = None,
) -> None:
raise HTTPException(status_code=409, detail="当前版本积分暂未开放团队转账功能")
async def get_manager_history(db: AsyncSession, team_id: str) -> list[dict[str, Any]]:
result = await db.execute(
select(TeamManagerHistory, User.username)
.join(User, User.id == TeamManagerHistory.manager_user_id)
.where(TeamManagerHistory.team_id == team_id)
.order_by(TeamManagerHistory.started_at.desc(), TeamManagerHistory.id.desc())
)
return [
{
"id": history.id,
"team_id": history.team_id,
"manager_user_id": history.manager_user_id,
"manager_name": username,
"started_at": history.started_at,
"ended_at": history.ended_at,
}
for history, username in result.all()
]
async def transfer_credits_to_member(*args, **kwargs) -> None:
raise HTTPException(status_code=409, detail="当前版本不支持团队积分转账,请使用团队订阅席位额度")
async def list_manager_access_teams(db: AsyncSession, user_id: str) -> list[dict[str, Any]]:
"""返回用户作为当前/历史队长可查看团队流水的团队,按当前团队优先、最近任期倒序。"""
history_result = await db.execute(
select(TeamManagerHistory.team_id, func.max(TeamManagerHistory.started_at).label("last_started_at"))
.where(TeamManagerHistory.manager_user_id == user_id)
.group_by(TeamManagerHistory.team_id)
)
history_rows = list(history_result.all())
team_ids = [row.team_id for row in history_rows]
current_result = await db.execute(
select(Team.id).where(
Team.manager_id == user_id,
Team.deleted_at.is_(None),
)
)
for team_id in current_result.scalars().all():
if team_id not in team_ids:
team_ids.append(team_id)
if not team_ids:
return []
teams_result = await db.execute(select(Team).where(Team.id.in_(team_ids)))
team_map = {item.id: item for item in teams_result.scalars().all()}
started_map = {row.team_id: row.last_started_at for row in history_rows}
items: list[dict[str, Any]] = []
for team_id in team_ids:
team = team_map.get(team_id)
if not team:
continue
is_current = team.deleted_at is None and team.manager_id == user_id
items.append(
{
"id": team.id,
"name": team.name,
"code": team.code,
"status": team.status,
"status_label": TEAM_STATUS_LABELS.get(team.status, "其他状态"),
"is_current_manager": is_current,
"last_managed_at": started_map.get(team.id),
"deleted": team.deleted_at is not None,
}
)
items.sort(
key=lambda item: (
0 if item["is_current_manager"] else 1,
-(item["last_managed_at"].timestamp() if item["last_managed_at"] else 0),
item["id"],
)
)
return items
+282 -61
View File
@@ -1,20 +1,32 @@
from __future__ import annotations
from datetime import datetime, timezone
from datetime import datetime
from typing import Any
from fastapi import HTTPException
from sqlalchemy import and_, func, or_, select
from sqlalchemy import and_, func, or_, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.team import TeamStatus
from app.enums.credit_product import CreditProductType
from app.enums.credit_subscription import CreditSubscriptionStatus
from app.enums.team import TEAM_STATUS_LABELS, TeamStatus
from app.enums.user import UserType
from app.models.credit.subscription import UserCreditSubscription
from app.models.credit.team_seat import TeamSubscriptionSeat
from app.models.payment_order import PaymentOrder
from app.models.team import Team
from app.models.team_manager_history import TeamManagerHistory
from app.models.user import User
from app.schemas.team import TeamCreate, TeamUpdate
from app.services.credit.locking import acquire_team_business_lock, acquire_user_credit_lock
from app.services.credit.utils import utc_now
from app.services.operation_log_service import log_operation_event
from app.utils.id_gen import generate_id
INCOMPLETE_ORDER_STATUSES = ("pending", "paid")
def _clean_text(value: str | None) -> str | None:
if value is None:
return None
@@ -25,36 +37,64 @@ def _clean_text(value: str | None) -> str | None:
def _team_snapshot(team: Team | None) -> dict[str, Any]:
if not team:
return {"team_id": None, "team_name": None}
return {
"team_id": team.id,
"team_name": getattr(team, "name", None),
}
return {"team_id": team.id, "team_name": getattr(team, "name", None)}
def _team_out_payload(team: Team, member_count: int = 0, manager_name: str | None = None) -> dict[str, Any]:
def _team_out_payload(
team: Team,
member_count: int = 0,
manager_name: str | None = None,
) -> dict[str, Any]:
status = getattr(team, "status", TeamStatus.ACTIVE.value)
return {
"id": team.id,
"name": team.name,
"code": getattr(team, "code", None),
"description": getattr(team, "description", None),
"status": getattr(team, "status", TeamStatus.ACTIVE.value),
"status": status,
"status_label": TEAM_STATUS_LABELS.get(status, "其他状态"),
"is_read_only": status == TeamStatus.DISABLED.value,
"team_credit_frozen": status == TeamStatus.DISABLED.value,
"sort_order": getattr(team, "sort_order", 0) or 0,
"member_count": int(member_count or 0),
"created_at": team.created_at,
"updated_at": team.updated_at,
"manager_id": getattr(team, "manager_id", None),
"manager_name": manager_name,
"first_subscription_paid_at": team.first_subscription_paid_at,
}
async def _get_team(db: AsyncSession, team_id: str, *, include_deleted: bool = False) -> Team | None:
async def _get_team(
db: AsyncSession,
team_id: str,
*,
include_deleted: bool = False,
for_update: bool = False,
) -> Team | None:
query = select(Team).where(Team.id == team_id).limit(1)
if not include_deleted:
query = query.where(Team.deleted_at.is_(None))
if for_update:
query = query.with_for_update()
result = await db.execute(query)
return result.scalar_one_or_none()
async def assert_team_active(
db: AsyncSession,
team_id: str,
*,
for_update: bool = False,
) -> Team:
team = await _get_team(db, team_id, for_update=for_update)
if not team:
raise HTTPException(status_code=404, detail="团队不存在")
if team.status != TeamStatus.ACTIVE.value:
raise HTTPException(status_code=409, detail="团队已禁用,当前仅允许查看,不能执行团队业务操作")
return team
async def _get_team_name(db: AsyncSession, team_id: str | None) -> str | None:
if not team_id:
return None
@@ -62,7 +102,13 @@ async def _get_team_name(db: AsyncSession, team_id: str | None) -> str | None:
return result.scalar_one_or_none()
async def _assert_unique_team(db: AsyncSession, *, name: str, code: str | None, exclude_id: str | None = None) -> None:
async def _assert_unique_team(
db: AsyncSession,
*,
name: str,
code: str | None,
exclude_id: str | None = None,
) -> None:
conditions = [Team.deleted_at.is_(None)]
duplicate_filters = [Team.name == name]
if code:
@@ -75,6 +121,22 @@ async def _assert_unique_team(db: AsyncSession, *, name: str, code: str | None,
raise HTTPException(status_code=400, detail="团队名称或编码已存在")
async def _has_incomplete_team_order(db: AsyncSession, team_id: str) -> bool:
result = await db.execute(
select(PaymentOrder.id)
.where(
PaymentOrder.team_id_snapshot == team_id,
PaymentOrder.product_type == CreditProductType.TEAM_SUBSCRIPTION.value,
or_(
PaymentOrder.status == "pending",
and_(PaymentOrder.status == "paid", PaymentOrder.fulfillment_status != "fulfilled"),
),
)
.limit(1)
)
return result.scalar_one_or_none() is not None
async def list_teams(
db: AsyncSession,
*,
@@ -85,7 +147,6 @@ async def list_teams(
) -> dict[str, Any]:
page = max(int(page or 1), 1)
page_size = min(max(int(page_size or 20), 1), 500)
filters: list[Any] = [Team.deleted_at.is_(None)]
kw = _clean_text(keyword)
if kw:
@@ -105,7 +166,7 @@ async def list_teams(
)
teams = list(result.scalars().all())
if not teams:
return {"items": [], "total": total}
return {"items": [], "total": int(total)}
team_ids = [team.id for team in teams]
member_result = await db.execute(
@@ -114,26 +175,18 @@ async def list_teams(
.group_by(User.team_id)
)
member_map = {row[0]: int(row[1] or 0) for row in member_result.all()}
# 批量获取管理人用户名
manager_ids = [getattr(t, "manager_id", None) for t in teams if getattr(t, "manager_id", None)]
manager_ids = [team.manager_id for team in teams if team.manager_id]
manager_name_map: dict[str, str] = {}
if manager_ids:
mgr_result = await db.execute(
select(User.id, User.username).where(User.id.in_(manager_ids))
)
mgr_result = await db.execute(select(User.id, User.username).where(User.id.in_(manager_ids)))
manager_name_map = {row[0]: row[1] for row in mgr_result.all()}
return {
"items": [
_team_out_payload(
team,
member_map.get(team.id, 0),
manager_name_map.get(getattr(team, "manager_id", None)),
)
_team_out_payload(team, member_map.get(team.id, 0), manager_name_map.get(team.manager_id))
for team in teams
],
"total": total,
"total": int(total),
}
@@ -150,22 +203,22 @@ async def list_team_options(db: AsyncSession, *, include_disabled: bool = True)
{
"id": team.id,
"name": team.name,
"code": getattr(team, "code", None),
"status": getattr(team, "status", TeamStatus.ACTIVE.value),
"code": team.code,
"status": team.status,
"status_label": TEAM_STATUS_LABELS.get(team.status, "其他状态"),
}
for team in result.scalars().all()
]
async def batch_get_team_name_map(db: AsyncSession, team_ids: list[str] | set[str] | tuple[str, ...]) -> dict[str, str]:
"""Batch load team names for list pages. Avoid joining teams in high-frequency user queries."""
async def batch_get_team_name_map(
db: AsyncSession,
team_ids: list[str] | set[str] | tuple[str, ...],
) -> dict[str, str]:
ids = [team_id for team_id in dict.fromkeys(team_ids or []) if team_id]
if not ids:
return {}
result = await db.execute(
select(Team.id, Team.name)
.where(Team.id.in_(ids), Team.deleted_at.is_(None))
)
result = await db.execute(select(Team.id, Team.name).where(Team.id.in_(ids)))
return {row[0]: row[1] for row in result.all()}
@@ -183,48 +236,122 @@ async def create_team(db: AsyncSession, req: TeamCreate) -> Team:
)
db.add(team)
await db.flush()
await db.refresh(team)
return team
async def update_team(db: AsyncSession, team_id: str, req: TeamUpdate) -> tuple[Team, dict[str, Any], dict[str, Any]]:
team = await _get_team(db, team_id)
async def _next_auto_team_name(db: AsyncSession) -> str:
bind = db.get_bind()
dialect = bind.dialect.name if bind is not None else ""
if dialect == "postgresql":
value = (await db.execute(text("SELECT nextval('team_auto_name_seq')"))).scalar_one()
return f"团队{int(value):04d}"
# SQLite/本地调试兜底;正式 PostgreSQL 始终走 Sequence,不使用 COUNT(*) + 1。
return f"团队{generate_id()[-6:]}"
async def create_team_for_subscription(
db: AsyncSession,
*,
manager_user: User,
started_at: datetime | None = None,
) -> Team:
checked_at = started_at or utc_now()
if manager_user.team_id:
raise HTTPException(status_code=409, detail="用户已加入团队,不能自动创建新团队")
team = Team(
id=generate_id(),
name=await _next_auto_team_name(db),
status=TeamStatus.ACTIVE.value,
sort_order=0,
manager_id=manager_user.id,
)
db.add(team)
await db.flush()
db.add(
TeamManagerHistory(
id=generate_id(),
team_id=team.id,
manager_user_id=manager_user.id,
started_at=checked_at,
)
)
manager_user.team_id = team.id
await db.flush()
log_operation_event(
domain="team",
module="team",
event_type="TEAM_AUTO_CREATED_FOR_SUBSCRIPTION",
user_id=manager_user.id,
message="团队订阅履约自动创建团队成功",
detail={
"team_id": team.id,
"team_name": team.name,
"manager_user_id": manager_user.id,
},
)
return team
async def update_team(
db: AsyncSession,
team_id: str,
req: TeamUpdate,
) -> tuple[Team, dict[str, Any], dict[str, Any]]:
await acquire_team_business_lock(db, team_id)
team = await _get_team(db, team_id, for_update=True)
if not team:
raise HTTPException(status_code=404, detail="团队不存在")
before = {
"id": team.id,
"name": team.name,
"code": getattr(team, "code", None),
"description": getattr(team, "description", None),
"status": getattr(team, "status", TeamStatus.ACTIVE.value),
"sort_order": getattr(team, "sort_order", 0) or 0,
"code": team.code,
"description": team.description,
"status": team.status,
"sort_order": team.sort_order or 0,
}
name = req.name.strip()
code = _clean_text(req.code)
await _assert_unique_team(db, name=name, code=code, exclude_id=team_id)
requested_status = req.status or TeamStatus.ACTIVE.value
# 禁用后的团队只能“重新启用”,不能趁禁用状态修改名称、编码、备注、排序等业务数据。
if team.status == TeamStatus.DISABLED.value:
unchanged = (
name == team.name
and code == _clean_text(team.code)
and _clean_text(req.description) == _clean_text(team.description)
and int(req.sort_order or 0) == int(team.sort_order or 0)
)
if requested_status != TeamStatus.ACTIVE.value or not unchanged:
raise HTTPException(
status_code=409,
detail="团队已禁用,当前仅允许查看;如需继续操作,请先保持其他信息不变并重新启用团队",
)
else:
await _assert_unique_team(db, name=name, code=code, exclude_id=team_id)
if requested_status == TeamStatus.DISABLED.value and await _has_incomplete_team_order(db, team_id):
raise HTTPException(status_code=409, detail="团队存在待支付或待履约的团队订阅订单,暂不能禁用")
team.name = name
team.code = code
team.description = _clean_text(req.description)
team.status = req.status or TeamStatus.ACTIVE.value
team.status = requested_status
team.sort_order = req.sort_order or 0
await db.flush()
await db.refresh(team)
after = {
"id": team.id,
"name": team.name,
"code": getattr(team, "code", None),
"description": getattr(team, "description", None),
"status": getattr(team, "status", TeamStatus.ACTIVE.value),
"sort_order": getattr(team, "sort_order", 0) or 0,
"code": team.code,
"description": team.description,
"status": team.status,
"sort_order": team.sort_order or 0,
}
return team, before, after
async def soft_delete_team(db: AsyncSession, team_id: str) -> tuple[Team, dict[str, Any]]:
team = await _get_team(db, team_id)
await acquire_team_business_lock(db, team_id)
team = await _get_team(db, team_id, for_update=True)
if not team:
raise HTTPException(status_code=404, detail="团队不存在")
@@ -235,46 +362,140 @@ async def soft_delete_team(db: AsyncSession, team_id: str) -> tuple[Team, dict[s
)
)).scalar() or 0
if member_count > 0:
raise HTTPException(status_code=400, detail="该团队下仍有前台用户,请先迁移或取消团队归属")
raise HTTPException(status_code=400, detail="该团队下仍有成员,请先迁移或解除团队关系")
checked_at = utc_now()
active_subscription = (await db.execute(
select(UserCreditSubscription.id)
.where(
UserCreditSubscription.team_id == team_id,
UserCreditSubscription.product_type_snapshot == CreditProductType.TEAM_SUBSCRIPTION.value,
UserCreditSubscription.status == CreditSubscriptionStatus.ACTIVE.value,
UserCreditSubscription.start_at <= checked_at,
UserCreditSubscription.expires_at > checked_at,
)
.limit(1)
)).scalar_one_or_none()
if active_subscription:
raise HTTPException(status_code=409, detail="团队仍有有效团队订阅,不能删除")
if await _has_incomplete_team_order(db, team_id):
raise HTTPException(status_code=409, detail="团队仍有待支付或待履约团队订阅订单,不能删除")
before = {
"id": team.id,
"name": team.name,
"code": getattr(team, "code", None),
"status": getattr(team, "status", TeamStatus.ACTIVE.value),
"code": team.code,
"status": team.status,
"member_count": int(member_count or 0),
}
team.deleted_at = datetime.now(timezone.utc)
team.deleted_at = checked_at
await db.flush()
return team, before
async def _cancel_user_active_seats_for_team(
db: AsyncSession,
*,
team_id: str,
user_id: str,
cancelled_at: datetime,
) -> None:
result = await db.execute(
select(TeamSubscriptionSeat)
.where(
TeamSubscriptionSeat.team_id == team_id,
TeamSubscriptionSeat.user_id == user_id,
TeamSubscriptionSeat.deleted_at.is_(None),
TeamSubscriptionSeat.cancelled_at.is_(None),
)
.order_by(TeamSubscriptionSeat.id.asc())
.with_for_update()
)
for seat in result.scalars().all():
seat.cancelled_at = cancelled_at
seat.deleted_at = cancelled_at
async def _has_pending_team_purchase_without_team(db: AsyncSession, user_id: str) -> bool:
result = await db.execute(
select(PaymentOrder.id)
.where(
PaymentOrder.user_id == user_id,
PaymentOrder.product_type == CreditProductType.TEAM_SUBSCRIPTION.value,
PaymentOrder.team_id_snapshot.is_(None),
or_(
PaymentOrder.status == "pending",
and_(PaymentOrder.status == "paid", PaymentOrder.fulfillment_status != "fulfilled"),
),
)
.limit(1)
)
return result.scalar_one_or_none() is not None
async def set_frontend_user_team(
db: AsyncSession,
*,
user_id: str,
team_id: str | None,
) -> tuple[User, dict[str, Any], dict[str, Any]]:
result = await db.execute(select(User).where(User.id == user_id).limit(1))
# 固定锁顺序:user advisory -> team advisory(按ID排序)-> User row -> Seat row。
await acquire_user_credit_lock(db, user_id)
initial = await db.execute(select(User.user_type, User.team_id).where(User.id == user_id).limit(1))
initial_row = initial.first()
if not initial_row:
raise HTTPException(status_code=404, detail="用户不存在")
if initial_row.user_type != UserType.FRONTEND.value:
raise HTTPException(status_code=400, detail="仅前台用户支持设置团队")
old_team_id = initial_row.team_id
for lock_team_id in sorted({item for item in (old_team_id, team_id) if item}):
await acquire_team_business_lock(db, lock_team_id)
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 HTTPException(status_code=404, detail="用户不存在")
if user.user_type != UserType.FRONTEND.value:
raise HTTPException(status_code=400, detail="仅前台用户支持设置团队")
# user advisory lock 下理论上不会变化;仍显式复核,避免旁路代码破坏锁约定。
if user.team_id != old_team_id:
raise HTTPException(status_code=409, detail="用户团队关系已发生变化,请刷新后重试")
old_team_id = getattr(user, "team_id", None)
old_team = await _get_team(db, old_team_id, include_deleted=True) if old_team_id else None
old_team = await _get_team(db, old_team_id, include_deleted=True, for_update=bool(old_team_id)) if old_team_id else None
before = _team_snapshot(old_team)
if old_team_id == team_id:
return user, before, before
checked_at = utc_now()
if old_team:
if old_team.deleted_at is None and old_team.status != TeamStatus.ACTIVE.value:
raise HTTPException(status_code=409, detail="团队已禁用,当前仅允许查看,不能变更成员关系")
if old_team.manager_id == user.id:
raise HTTPException(status_code=409, detail="当前用户是团队队长,请先完成队长转让")
new_team: Team | None = None
if team_id:
new_team = await _get_team(db, team_id)
if not new_team:
raise HTTPException(status_code=404, detail="团队不存在")
if getattr(new_team, "status", TeamStatus.ACTIVE.value) != TeamStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="禁用团队不能设置给用户")
if not old_team_id and await _has_pending_team_purchase_without_team(db, user.id):
raise HTTPException(status_code=409, detail="当前存在待处理的团队订阅订单,请先完成或取消订单")
new_team = await assert_team_active(db, team_id, for_update=True)
if old_team_id:
await _cancel_user_active_seats_for_team(
db, team_id=old_team_id, user_id=user.id, cancelled_at=checked_at
)
user.team_id = team_id or None
await db.flush()
after = _team_snapshot(new_team)
log_operation_event(
domain="team",
module="team",
event_type="TEAM_MEMBER_RELATION_CHANGED",
user_id=user.id,
message="用户团队关系变更成功",
detail={
"user_id": user.id,
"old_team_id": old_team_id,
"new_team_id": team_id,
},
)
return user, before, after
+2 -1
View File
@@ -60,7 +60,7 @@ async def _run_credit_maintenance_once(batch_size: int = 500) -> dict[str, Any]:
)
granted = 0
for period_id, subscription_id, user_id in grant_candidates:
for period_id, subscription_id, user_id, team_id in grant_candidates:
async with async_session() as db:
try:
changed = await grant_due_subscription_period_by_id(
@@ -68,6 +68,7 @@ async def _run_credit_maintenance_once(batch_size: int = 500) -> dict[str, Any]:
period_id=period_id,
subscription_id=subscription_id,
user_id=user_id,
team_id=team_id,
request_time=checked_at,
)
await db.commit()
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+35 -35
View File
@@ -1,36 +1,36 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" id="favicon" />
<script>
// 立即从 localStorage 设置 favicon,避免闪烁
(function() {
try {
var cached = localStorage.getItem('siteInfo');
if (cached) {
var info = JSON.parse(cached);
if (info.siteLogo) {
var link = document.getElementById('favicon');
link.href = info.siteLogo;
link.type = 'image/png';
}
if (info.siteName) {
document.title = info.siteName;
}
}
} catch (e) {}
})();
</script>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<title>民众智创</title>
<script type="module" crossorigin src="/assets/index-dJGCDdnM.js"></script>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" id="favicon" />
<script>
// 立即从 localStorage 设置 favicon,避免闪烁
(function() {
try {
var cached = localStorage.getItem('siteInfo');
if (cached) {
var info = JSON.parse(cached);
if (info.siteLogo) {
var link = document.getElementById('favicon');
link.href = info.siteLogo;
link.type = 'image/png';
}
if (info.siteName) {
document.title = info.siteName;
}
}
} catch (e) {}
})();
</script>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<title>民众智创</title>
<script type="module" crossorigin src="/assets/index-BMPNcnJK.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DSYnuUvx.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
</head>
<body>
<div id="root"></div>
</body>
</html>
+42 -89
View File
@@ -280,33 +280,22 @@ 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; availableCredits: number; nextExpiringCredits: number; nextExpiresAt?: string | null; nextLastUsableAt?: string | null; totalGranted: number; totalConsumed: number; totalRefunded: number; totalExpired: number; records: CreditRecord[]; total: number }> {
export async function getCredits(page = 1, pageSize = 20): Promise<{
credits: number; availableCredits: number; personalCredits: number; teamAvailableCredits: number; teamFrozenCredits: 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();
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);
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,
credits: data.credits, availableCredits: data.credits, personalCredits: data.credits, teamAvailableCredits: 0, teamFrozenCredits: 0,
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));
params.set('page_size', String(pageSize));
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
return api.get(`/credits?${params.toString()}`);
}
// ── Captcha ───────────────────────────────────────────────
@@ -460,10 +449,11 @@ export async function getPaymentMethods(): Promise<{ alipay: boolean; wechat: bo
return api.get('/payments/methods');
}
export async function createRechargeOrder(planId: string, method: string = 'wechat'): Promise<any> {
return api.post('/payments/recharge', { plan: planId, method });
export async function createRechargeOrder(planId: string, method: string = 'wechat', quantity = 1): Promise<any> {
return api.post('/payments/recharge', { plan: planId, method, quantity });
}
export async function getPaymentOrders(
page = 1,
pageSize = 20,
@@ -1337,87 +1327,50 @@ export async function getPrivatePortraitVirtualSelectableAssets(params: { projec
}
// ── Team Management APIs ──────────────────────────────
export async function getManagedTeam(): Promise<any> {
return api.get('/team/managed');
}
export async function getManagedTeam(): Promise<any> { return api.get('/team/managed'); }
export async function getManagerAccessTeams(): Promise<any[]> { return api.get('/team/manager-access'); }
export async function getTeamMembers(page = 1, pageSize = 20): Promise<any> {
const params = new URLSearchParams();
params.set('page', String(page));
params.set('page_size', String(pageSize));
return api.get(`/team/members?${params.toString()}`);
return api.get(`/team/members?page=${page}&page_size=${pageSize}`);
}
export async function transferCredits(memberId: string, amount: number, direction: string = "increase", description?: string): Promise<void> {
await api.post(`/team/members/${memberId}/credits`, { target_user_id: memberId, amount, direction, description: description || null });
export async function transferTeamManager(userId: string): Promise<void> { await api.put('/team/manager', { user_id: userId }); }
export async function getTeamSubscriptions(): Promise<any[]> { return api.get('/team/subscriptions'); }
export async function createTeamSeat(subscriptionId: string, userId: string, monthlyAllocatedCredits: number): Promise<any> {
return api.post(`/team/subscriptions/${subscriptionId}/seats`, { user_id: userId, monthly_allocated_credits: monthlyAllocatedCredits });
}
export async function getTeamInvitations(): Promise<any[]> {
return api.get('/team/invitations');
export async function updateTeamSeat(seatId: string, monthlyAllocatedCredits: number): Promise<any> {
return api.put(`/team/seats/${seatId}`, { monthly_allocated_credits: monthlyAllocatedCredits });
}
export async function cancelTeamSeat(seatId: string): Promise<void> { await api.delete(`/team/seats/${seatId}`); }
export async function getTeamMemberUsage(subscriptionId?: string): Promise<any[]> {
const p = new URLSearchParams(); if (subscriptionId) p.set('subscription_id', subscriptionId);
return api.get(`/team/member-usage${p.toString() ? `?${p.toString()}` : ''}`);
}
export async function getTeamManagerHistory(): Promise<any[]> { return api.get('/team/manager-history'); }
export async function getTeamInvitations(): Promise<any[]> { return api.get('/team/invitations'); }
export async function createTeamInvitation(maxUses?: number, expiresAt?: string): Promise<any> {
return api.post('/team/invitations', { max_uses: maxUses || null, expires_at: expiresAt || null });
}
export async function revokeInvitation(invitationId: string): Promise<void> {
await api.delete(`/team/invitations/${invitationId}`);
}
export async function revokeInvitation(invitationId: string): Promise<void> { await api.delete(`/team/invitations/${invitationId}`); }
export async function getPendingJoinRequests(status?: string): Promise<any[]> {
const url = status ? `/team/join-requests?status=${status}` : '/team/join-requests';
return api.get(url);
return api.get(status ? `/team/join-requests?status=${encodeURIComponent(status)}` : '/team/join-requests');
}
export async function handleJoinRequest(requestId: string, action: 'approve' | 'reject', note?: string): Promise<void> {
await api.post(`/team/join-requests/${requestId}`, { action, note: note || null });
}
export async function getJoinTeamInfo(code: string): Promise<any> {
return api.get(`/team/join-info?code=${encodeURIComponent(code)}`, { auth: true, skipAuthRedirect: true });
}
export async function getJoinTeamInfoPublic(code: string): Promise<any> {
return api.get(`/team/join-info/public?code=${encodeURIComponent(code)}`, false);
}
export async function submitJoinRequest(code: string): Promise<void> {
await api.post('/team/join', { invitation_code: code });
}
export async function getTeamCreditRecords(params: {
page?: number;
pageSize?: number;
userId?: string;
phone?: string;
recordType?: string;
startDate?: string;
endDate?: string;
}): Promise<any> {
export async function getJoinTeamInfo(code: string): Promise<any> { return api.get(`/team/join-info?code=${encodeURIComponent(code)}`, { auth: true, skipAuthRedirect: true }); }
export async function getJoinTeamInfoPublic(code: string): Promise<any> { return api.get(`/team/join-info/public?code=${encodeURIComponent(code)}`, false); }
export async function submitJoinRequest(code: string): Promise<void> { await api.post('/team/join', { invitation_code: code }); }
export async function getTeamCreditRecords(params: { teamId?: string; page?: number; pageSize?: number; userId?: string; phone?: string; subscriptionId?: string; recordType?: string; startDate?: string; endDate?: string; }): Promise<any> {
const p = new URLSearchParams();
if (params.page) p.set('page', String(params.page));
if (params.pageSize) p.set('page_size', String(params.pageSize));
if (params.userId) p.set('user_id', params.userId);
if (params.phone) p.set('phone', params.phone);
if (params.recordType) p.set('record_type', params.recordType);
if (params.startDate) p.set('start_date', params.startDate);
if (params.endDate) p.set('end_date', params.endDate);
if (params.teamId) p.set('team_id', params.teamId); if (params.page) p.set('page', String(params.page)); if (params.pageSize) p.set('page_size', String(params.pageSize));
if (params.userId) p.set('user_id', params.userId); if (params.phone) p.set('phone', params.phone); if (params.subscriptionId) p.set('subscription_id', params.subscriptionId);
if (params.recordType) p.set('record_type', params.recordType); if (params.startDate) p.set('start_date', params.startDate); if (params.endDate) p.set('end_date', params.endDate);
return api.get(`/team/credit-records?${p.toString()}`);
}
export function getTeamCreditExportUrl(params: {
phone?: string;
recordType?: string;
startDate?: string;
endDate?: string;
}): string {
const p = new URLSearchParams();
if (params.phone) p.set('phone', params.phone);
if (params.recordType) p.set('record_type', params.recordType);
if (params.startDate) p.set('start_date', params.startDate);
if (params.endDate) p.set('end_date', params.endDate);
const base = (import.meta as any).env?.VITE_API_BASE || 'http://localhost:8000';
return `${base}/api/team/credit-records/export?${p.toString()}`;
export function getTeamCreditExportUrl(params: { teamId?: string; phone?: string; subscriptionId?: string; recordType?: string; startDate?: string; endDate?: string; }): string {
const p = new URLSearchParams(); if (params.teamId) p.set('team_id', params.teamId); if (params.phone) p.set('phone', params.phone); if (params.subscriptionId) p.set('subscription_id', params.subscriptionId);
if (params.recordType) p.set('record_type', params.recordType); if (params.startDate) p.set('start_date', params.startDate); if (params.endDate) p.set('end_date', params.endDate);
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 ───────────────────────────────
+90 -265
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { Layout, Avatar, Dropdown, Space, Modal, Form, Input, message, Tag, Button, Typography, Radio, Drawer, Tabs, Progress, Collapse, ConfigProvider } from 'antd';
import { Layout, Avatar, Dropdown, Space, Modal, Form, Input, InputNumber, message, Tag, Button, Typography, Radio, Drawer, Tabs, Progress, Collapse, ConfigProvider } from 'antd';
import { QRCodeSVG } from 'qrcode.react';
import {
PlayCircleOutlined,
@@ -48,7 +48,6 @@ import {
CrownFilled,
FireOutlined,
BankFilled,
BankOutlined,
CheckCircleFilled,
WechatOutlined,
AlipayCircleOutlined,
@@ -444,21 +443,34 @@ const AppLayout: React.FC = () => {
const [menuItems, setMenuItems] = useState<MenuConfig[]>([]);
const [rechargeOptions, setRechargeOptions] = useState<CreditProduct[]>([]);
const [subscriptionProducts, setSubscriptionProducts] = useState<CreditProduct[]>([]);
const [currentSubscription, setCurrentSubscription] = useState<CreditProductCatalog['currentSubscription']>(null);
const [teamSubscriptionProducts, setTeamSubscriptionProducts] = useState<CreditProduct[]>([]);
const [selectedSubscriptionType, setSelectedSubscriptionType] = useState<'personal' | 'team'>('personal');
const [activePersonalSubscriptions, setActivePersonalSubscriptions] = useState<CreditProductCatalog['activePersonalSubscriptions']>([]);
const loadCreditCatalog = useCallback(async () => {
try {
const data = await getCreditProductCatalog();
setSubscriptionProducts((data.subscriptionProducts || []).filter((product) => product.isActive));
const personalProducts = (data.subscriptionProducts || []).filter((product) => product.isActive);
const teamProducts = (data.teamSubscriptionProducts || []).filter((product) => product.isActive);
setSubscriptionProducts(personalProducts);
setTeamSubscriptionProducts(teamProducts);
setRechargeOptions((data.creditAddons || []).filter((product) => product.isActive));
setCurrentSubscription(data.currentSubscription || null);
setActivePersonalSubscriptions(data.activePersonalSubscriptions || []);
if (teamProducts.length === 0) {
setSelectedSubscriptionType('personal');
}
} catch {
setSubscriptionProducts([]);
setTeamSubscriptionProducts([]);
setRechargeOptions([]);
setCurrentSubscription(null);
setActivePersonalSubscriptions([]);
setSelectedSubscriptionType('personal');
}
}, []);
const [creditsModalOpen, setCreditsModalOpen] = useState(false);
const [selectedPeriod, setSelectedPeriod] = useState<'monthly' | 'quarterly' | 'yearly'>('monthly');
const visibleSubscriptionProducts = (selectedSubscriptionType === 'team' ? teamSubscriptionProducts : subscriptionProducts)
.filter((item) => item.billingCycle === selectedPeriod)
.sort((a, b) => ((a.tierRank || 0) as number) - ((b.tierRank || 0) as number));
const [qrCodeModalOpen, setQrCodeModalOpen] = useState(false);
const [paymentMethod, setPaymentMethod] = useState<string>('alipay');
const [paying, setPaying] = useState(false);
@@ -480,17 +492,9 @@ const AppLayout: React.FC = () => {
isSubscription?: boolean;
} | null>(null);
const [pendingProduct, setPendingProduct] = useState<{ id: string; product: any; } | null>(null);
const [pendingProduct, setPendingProduct] = useState<{ id: string; product: CreditProduct; } | null>(null);
const [purchaseQuantity, setPurchaseQuantity] = useState(1);
const [qrRevealed, setQrRevealed] = useState(false);
const [paymentTab, setPaymentTab] = useState<'alipay' | 'corporate'>('alipay');
const [corporateTransferForm] = Form.useForm();
const [corporateSubmitting, setCorporateSubmitting] = useState(false);
const COMPANY_BANK_INFO = {
accountName: '深圳市某某科技有限公司',
bankName: '招商银行',
bankAccount: '7559 0188 1010 1001',
};
const PENDING_ORDER_KEY = 'pending_payment_order';
const [unreadCount, setUnreadCount] = useState(0);
@@ -873,15 +877,15 @@ const AppLayout: React.FC = () => {
const handleDirectPurchase = useCallback(async (
productId: string,
product: { name?: string; price?: number; currentPrice?: number; monthlyGrantCredits?: number | string; credits?: number; grantCredits?: number; regularPrice?: number; originalPrice?: number; billingCycle?: string; tierCode?: string; description?: string; }
product: CreditProduct
) => {
if (!enabledMethods.alipay && !enabledMethods.wechat) {
message.error('暂无可用支付方式');
return;
}
setPendingProduct({ id: productId, product });
setPurchaseQuantity(product.productType === 'team_subscription' ? 2 : 1);
setQrRevealed(false);
setPaymentTab('alipay');
setCurrentPaymentInfo(null);
setCreditsModalOpen(false);
setQrCodeModalOpen(true);
@@ -892,11 +896,11 @@ const AppLayout: React.FC = () => {
setPaying(true);
try {
const { id: productId, product } = pendingProduct;
const order = await createRechargeOrder(productId, paymentMethod);
const order = await createRechargeOrder(productId, paymentMethod, purchaseQuantity);
const qrCode = order.qrUrl || order.codeUrl || order.qr_code || order.code_url;
const finalPrice = Number(order.amount ?? product.currentPrice ?? product.price ?? 0);
const credits = Number(product.grantCredits || product.monthlyGrantCredits || 0);
const originalPrice = product.regularPrice ?? product.originalPrice;
const credits = Number(product.grantCredits || product.monthlyGrantCredits || 0) * (product.productType === 'team_subscription' ? purchaseQuantity : 1);
const originalPrice = product.regularPrice;
const cycleMap: Record<string, string> = { monthly: '月套餐', quarterly: '季套餐', yearly: '年套餐' };
const isSubscription = !!product.billingCycle;
const periodLabel = product.billingCycle ? cycleMap[product.billingCycle] : '';
@@ -938,51 +942,7 @@ const AppLayout: React.FC = () => {
} finally {
setPaying(false);
}
}, [pendingProduct, paymentMethod]);
const submitCorporateTransfer = useCallback(async () => {
if (!pendingProduct) return;
try {
const values: any = await corporateTransferForm.validateFields();
setCorporateSubmitting(true);
const { id: productId, product } = pendingProduct;
const order = await createRechargeOrder(productId, 'corporate');
const finalPrice = Number(order.amount ?? product.currentPrice ?? product.price ?? 0);
const credits = Number(product.grantCredits || product.monthlyGrantCredits || 0);
const originalPrice = product.regularPrice ?? product.originalPrice;
const cycleMap: Record<string, string> = { monthly: '月套餐', quarterly: '季套餐', yearly: '年套餐' };
const isSubscription = !!product.billingCycle;
const periodLabel = product.billingCycle ? cycleMap[product.billingCycle] : '';
const tierName = product.name || '积分充值';
const displayTitle = isSubscription ? `${tierName} ${periodLabel}` : tierName;
const paymentInfo = {
price: finalPrice,
credits,
qrCode: '',
method: 'corporate',
title: displayTitle,
originalPrice: originalPrice ? Number(originalPrice) : undefined,
tierName,
periodLabel,
isSubscription,
};
void values;
setCurrentPaymentInfo(paymentInfo);
message.success('已提交对公转账信息,客服将在1-3个工作日内审核到账');
setQrCodeModalOpen(false);
setPendingProduct(null);
corporateTransferForm.resetFields();
await useAuthStore.getState().refreshUser();
await loadCreditCatalog();
} catch (err: any) {
if (err?.errorFields) {
return;
}
message.error(err?.message || '提交失败,请重试');
} finally {
setCorporateSubmitting(false);
}
}, [pendingProduct, corporateTransferForm]);
}, [pendingProduct, paymentMethod, purchaseQuantity]);
const startPolling = useCallback((orderNo: string, timeoutSeconds: number = 180) => {
stopPolling();
@@ -1665,45 +1625,37 @@ const AppLayout: React.FC = () => {
<div style={{ marginBottom: 24 }}>
<div style={{ textAlign: 'center', marginBottom: 20 }}>
<Typography.Title level={3} style={{ marginBottom: 8 }}></Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
<Typography.Text type="secondary"></Typography.Text>
</div>
{currentSubscription && (
<div style={{ marginBottom: 16, padding: '14px 16px', borderRadius: 12, background: '#fff', border: '1px solid #e8eaed', display: 'flex', alignItems: 'center', gap: 16 }}>
<div style={{
width: 40, height: 40, borderRadius: 10, flexShrink: 0,
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: 18,
}}>
<CrownOutlined />
{selectedSubscriptionType === 'personal' && activePersonalSubscriptions.length > 0 && (
<div style={{ marginBottom: 16, padding: '12px 16px', borderRadius: 12, background: '#fff', border: '1px solid #e8eaed' }}>
<Typography.Text strong>{activePersonalSubscriptions.length} </Typography.Text>
<div style={{ marginTop: 8, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{activePersonalSubscriptions.map((item) => (
<Tag key={item.id} color="purple">{item.productName} · {item.billingCycleLabel || '其他周期'} · {new Date(item.expiresAt).toLocaleString('zh-CN', { hour12: false })}</Tag>
))}
</div>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
<Typography.Text strong style={{ fontSize: 14 }}>
{currentSubscription.tierCode || '订阅套餐'}
</Typography.Text>
<Tag color="purple" style={{ fontSize: 11, margin: 0, borderRadius: 6 }}>
{currentSubscription.billingCycle === 'monthly' ? '月' : currentSubscription.billingCycle === 'quarterly' ? '季' : '年'}
</Tag>
</div>
<div style={{ fontSize: 12, color: '#64748b', marginBottom: 2 }}>
<span style={{ color: '#6366f1', fontWeight: 600 }}>{currentSubscription.grantedCount}</span>/{currentSubscription.grantCount}
· <span style={{ color: '#6366f1', fontWeight: 600 }}>{Number(currentSubscription.monthlyGrantCredits || 0).toLocaleString()}</span>
</div>
<div style={{ fontSize: 11, color: '#94a3b8' }}>
{new Date(currentSubscription.expiresAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })}
</div>
</div>
<Progress
percent={Math.round(((currentSubscription.grantedCount || 0) / (currentSubscription.grantCount || 1)) * 100)}
size="small"
strokeColor={{ from: '#6366f1', to: '#8b5cf6' }}
style={{ width: 80 }}
/>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: 10 }}>
<Tabs
activeKey={selectedSubscriptionType}
onChange={(key) => {
setSelectedSubscriptionType(key as 'personal' | 'team');
setSelectedPlan(null);
}}
items={[
{ key: 'personal', label: '普通订阅' },
...(teamSubscriptionProducts.length > 0 ? [{ key: 'team', label: '团队订阅' }] : []),
]}
centered
size="large"
tabBarStyle={{ marginBottom: 0 }}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 16, marginBottom: 20 }}>
<Radio.Group value={selectedPeriod} onChange={(e) => { setSelectedPeriod(e.target.value); setSelectedPlan(null); }} buttonStyle="solid">
<Radio.Button value="monthly"></Radio.Button>
@@ -1712,11 +1664,15 @@ const AppLayout: React.FC = () => {
</Radio.Group>
</div>
{subscriptionProducts.filter((item) => item.billingCycle === selectedPeriod).length === 0 ? (
<div style={{ padding: 48, textAlign: 'center', color: '#94a3b8' }}></div>
{visibleSubscriptionProducts.length === 0 ? (
<div style={{ padding: 48, textAlign: 'center', color: '#94a3b8' }}>
{selectedSubscriptionType === 'team'
? '暂无可购买的团队订阅套餐,请联系管理员配置并上架套餐。'
: '暂无可订阅套餐,请联系管理员配置并上架套餐。'}
</div>
) : (
<div style={{ display: 'flex', justifyContent: 'center', gap: 16, flexWrap: 'wrap' }}>
{subscriptionProducts.filter((item) => item.billingCycle === selectedPeriod).sort((a, b) => ((a.tierRank || 0) as number) - ((b.tierRank || 0) as number)).map((product) => {
{visibleSubscriptionProducts.map((product) => {
const gradient = MEMBERSHIP_GRADIENTS[product.tierCode || ''] || MEMBERSHIP_GRADIENTS.default;
const originalPrice = product.regularPrice || product.price;
const currentPrice = product.currentPrice ?? product.price;
@@ -1745,7 +1701,8 @@ const AppLayout: React.FC = () => {
<div style={{ width: 32, height: 32, borderRadius: 8, background: gradient.gradient, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: 14 }}>{gradient.icon}</div>
<div>
<Typography.Text strong style={{ fontSize: 14 }}>{product.name}</Typography.Text>
<div style={{ fontSize: 11, color: '#94a3b8' }}>{product.description || product.tierCode}</div>
{product.productType === 'team_subscription' && <Tag color="blue" style={{ marginLeft: 6, fontSize: 10 }}></Tag>}
<div style={{ fontSize: 11, color: '#94a3b8' }}>{product.description || product.tierLabel || '订阅套餐'}</div>
</div>
</div>
<div style={{ marginBottom: 8 }}>
@@ -1753,15 +1710,7 @@ const AppLayout: React.FC = () => {
{hasDiscount && (
<span style={{ fontSize: 13, color: '#94a3b8', textDecoration: 'line-through', marginLeft: 6 }}>¥{originalPrice}</span>
)}
{product.canUpgrade && (
<Tag color="orange" style={{ marginLeft: 6, fontSize: 10, padding: '0 6px', lineHeight: '16px' }}></Tag>
)}
</div>
{product.canUpgrade && Number(product.deductionAmount || 0) > 0 && (
<div style={{ fontSize: 11, color: '#94a3b8', marginBottom: 4 }}>
¥{product.targetPrice}¥{product.deductionAmount}
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', fontSize: 12, color: '#64748b' }}>
<span>{creditsLabel}</span>
<span> {product.grantCount} </span>
@@ -1972,8 +1921,6 @@ const AppLayout: React.FC = () => {
setCurrentPaymentInfo(null);
setPendingProduct(null);
setQrRevealed(false);
setPaymentTab('alipay');
corporateTransferForm.resetFields();
}}
footer={null}
width={760}
@@ -2005,7 +1952,7 @@ const AppLayout: React.FC = () => {
)}
</div>
<div style={{ fontSize: 22, fontWeight: 700, color: '#6366f1', lineHeight: 1.4 }}>
¥{currentPaymentInfo?.price || pendingProduct?.product?.currentPrice || pendingProduct?.product?.price || 0}
¥{currentPaymentInfo?.price ?? (Number(pendingProduct?.product?.currentPrice || pendingProduct?.product?.price || 0) * (pendingProduct?.product?.productType === 'team_subscription' ? purchaseQuantity : 1))}
{((currentPaymentInfo?.originalPrice || pendingProduct?.product?.regularPrice) && Number(currentPaymentInfo?.originalPrice || pendingProduct?.product?.regularPrice || 0) > Number(currentPaymentInfo?.price || pendingProduct?.product?.currentPrice || pendingProduct?.product?.price || 0)) && (
<span style={{ fontSize: 13, color: '#94a3b8', textDecoration: 'line-through', marginLeft: 8, fontWeight: 400 }}>
¥{currentPaymentInfo?.originalPrice || pendingProduct?.product?.regularPrice}
@@ -2023,10 +1970,16 @@ const AppLayout: React.FC = () => {
{((currentPaymentInfo?.credits != null) ? currentPaymentInfo.credits : (pendingProduct?.product?.grantCredits || pendingProduct?.product?.monthlyGrantCredits)) ? `${Number(currentPaymentInfo?.credits || pendingProduct?.product?.grantCredits || pendingProduct?.product?.monthlyGrantCredits || 0).toLocaleString()} 积分` : '-'}
</span>
</div>
{pendingProduct?.product?.productType === 'team_subscription' && (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '4px 0', fontSize: 12 }}>
<span style={{ color: '#64748b' }}></span>
{qrRevealed ? <span>{purchaseQuantity} </span> : <InputNumber min={2} max={20} precision={0} value={purchaseQuantity} onChange={(v) => setPurchaseQuantity(Number(v || 2))} />}
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 0', fontSize: 12 }}>
<span style={{ color: '#64748b' }}></span>
<span style={{ color: '#64748b' }}></span>
<span style={{ color: '#1a1a2e' }}>
{(currentPaymentInfo?.isSubscription ?? !!pendingProduct?.product?.billingCycle) ? '次年按原价 可随时取消' : '一次性购买'}
{(currentPaymentInfo?.isSubscription ?? !!pendingProduct?.product?.billingCycle) ? '独立订阅实例,不自动续费' : '一次性购买'}
</span>
</div>
<div style={{ height: 1, background: '#e2e8f0', margin: '8px 0' }} />
@@ -2038,166 +1991,38 @@ const AppLayout: React.FC = () => {
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '4px 0' }}>
<span style={{ fontSize: 14, fontWeight: 500, color: '#1a1a2e' }}></span>
<span style={{ fontSize: 18, fontWeight: 700, color: '#6366f1' }}>
¥{currentPaymentInfo?.price || pendingProduct?.product?.currentPrice || pendingProduct?.product?.price || 0}
¥{currentPaymentInfo?.price ?? (Number(pendingProduct?.product?.currentPrice || pendingProduct?.product?.price || 0) * (pendingProduct?.product?.productType === 'team_subscription' ? purchaseQuantity : 1))}
</span>
</div>
</div>
</div>
</div>
{/* 右侧:支付方式 */}
{/* 右侧:在线支付方式。后台线下成交不在客户端创建。 */}
<div style={{ width: 300, padding: 20, background: '#fff', display: 'flex', flexDirection: 'column' }}>
{/* Tab 切换 */}
<div style={{ display: 'flex', background: '#f3f4f6', borderRadius: 10, padding: 4, marginBottom: 20 }}>
<div
onClick={() => setPaymentTab('alipay')}
style={{
flex: 1, textAlign: 'center', padding: '8px 0', borderRadius: 8,
cursor: 'pointer', fontSize: 14, fontWeight: 600,
background: paymentTab === 'alipay' ? '#fff' : 'transparent',
color: paymentTab === 'alipay' ? '#1677ff' : '#64748b',
boxShadow: paymentTab === 'alipay' ? '0 2px 8px rgba(0,0,0,0.08)' : 'none',
transition: 'all 0.2s',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
}}
>
<AlipayCircleOutlined style={{ color: paymentTab === 'alipay' ? '#1677ff' : '#94a3b8' }} />
</div>
<div
onClick={() => setPaymentTab('corporate')}
style={{
flex: 1, textAlign: 'center', padding: '8px 0', borderRadius: 8,
cursor: 'pointer', fontSize: 14, fontWeight: 600,
background: paymentTab === 'corporate' ? '#fff' : 'transparent',
color: paymentTab === 'corporate' ? '#6366f1' : '#64748b',
boxShadow: paymentTab === 'corporate' ? '0 2px 8px rgba(0,0,0,0.08)' : 'none',
transition: 'all 0.2s',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
}}
>
<BankOutlined style={{ color: paymentTab === 'corporate' ? '#6366f1' : '#94a3b8' }} />
</div>
<Typography.Text strong style={{ marginBottom: 12 }}></Typography.Text>
<div style={{ display: 'flex', gap: 8, marginBottom: 18 }}>
{enabledMethods.alipay && <Button type={paymentMethod === 'alipay' ? 'primary' : 'default'} onClick={() => { if (!qrRevealed) setPaymentMethod('alipay'); }} icon={<AlipayCircleOutlined />}></Button>}
{enabledMethods.wechat && <Button type={paymentMethod === 'wechat' ? 'primary' : 'default'} onClick={() => { if (!qrRevealed) setPaymentMethod('wechat'); }} icon={<WechatOutlined />}></Button>}
</div>
{/* 支付宝 Tab */}
{paymentTab === 'alipay' && (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 12 }}>
<AlipayCircleOutlined style={{ fontSize: 18, color: '#1677ff' }} />
<span style={{ fontSize: 14, color: '#64748b' }}></span>
</div>
<div style={{ position: 'relative', width: 180, height: 180, borderRadius: 12, border: '1px solid #f0f0f0', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#fff' }}>
{qrRevealed && currentPaymentInfo?.qrCode ? (
<QRCodeSVG value={currentPaymentInfo.qrCode} size={150} level="M" />
) : (
<div style={{ position: 'absolute', inset: 0, background: '#fff', borderRadius: 12, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12, zIndex: 10 }}>
<div style={{ fontSize: 13, color: '#64748b', textAlign: 'center', padding: '0 10px' }}>
<br/>
</div>
<Button
type="primary"
size="large"
loading={paying}
onClick={confirmPayment}
style={{ borderRadius: 8, background: 'linear-gradient(135deg, #1677ff, #4096ff)', border: 'none', fontWeight: 600, height: 36, fontSize: 13, minWidth: 140 }}
>
使
</Button>
</div>
)}
</div>
{qrRevealed && (
<div style={{ marginTop: 12, fontSize: 12, color: '#94a3b8' }}>
{countdown}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ marginBottom: 12, color: '#64748b' }}>{paymentMethod === 'wechat' ? '请用微信扫码支付' : '请用支付宝扫码支付'}</div>
<div style={{ position: 'relative', width: 180, height: 180, borderRadius: 12, border: '1px solid #f0f0f0', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#fff' }}>
{qrRevealed && currentPaymentInfo?.qrCode ? <QRCodeSVG value={currentPaymentInfo.qrCode} size={150} level="M" /> : (
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 13, color: '#64748b', marginBottom: 12 }}></div>
<Button type="primary" loading={paying} onClick={confirmPayment}></Button>
</div>
)}
<div style={{ marginTop: 8, fontSize: 11, color: '#94a3b8', textAlign: 'center' }}>
<br/>
</div>
{qrRevealed && (
<Button
danger
size="large"
onClick={async () => {
stopPolling();
if (currentOrderNoRef.current) {
try { await cancelPaymentOrder(currentOrderNoRef.current); } catch { }
currentOrderNoRef.current = null;
}
localStorage.removeItem(PENDING_ORDER_KEY);
setQrCodeModalOpen(false);
setCurrentPaymentInfo(null);
setPendingProduct(null);
setQrRevealed(false);
message.info('已取消支付');
}}
style={{ borderRadius: 10, minWidth: 140, fontSize: 14, marginTop: 16 }}
>
</Button>
)}
</div>
)}
{/* 对公转账 Tab */}
{paymentTab === 'corporate' && (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflowY: 'auto' }}>
{/* 汇款信息 */}
<div style={{ marginBottom: 16 }}>
<Typography.Text style={{ fontSize: 12, color: '#64748b', marginBottom: 8, display: 'block', fontWeight: 600 }}></Typography.Text>
<div style={{ padding: 12, background: '#f8fafc', borderRadius: 8, border: '1px solid #e2e8f0' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '4px 0', fontSize: 12, marginBottom: 4 }}>
<span style={{ color: '#64748b' }}></span>
<span style={{ color: '#1a1a2e', fontWeight: 500 }}>{COMPANY_BANK_INFO.accountName}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '4px 0', fontSize: 12, marginBottom: 4 }}>
<span style={{ color: '#64748b' }}></span>
<span style={{ color: '#1a1a2e', fontWeight: 500, display: 'flex', alignItems: 'center', gap: 4 }}>
<BankFilled style={{ color: '#c8161d', fontSize: 14 }} />
{COMPANY_BANK_INFO.bankName}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '4px 0', fontSize: 12 }}>
<span style={{ color: '#64748b' }}></span>
<span style={{ color: '#1a1a2e', fontWeight: 500, fontFamily: 'monospace' }}>{COMPANY_BANK_INFO.bankAccount}</span>
</div>
</div>
</div>
{/* 打款信息填写 */}
<div style={{ marginBottom: 12 }}>
<Typography.Text style={{ fontSize: 12, color: '#64748b', marginBottom: 4, display: 'block', fontWeight: 600 }}></Typography.Text>
<Typography.Text style={{ fontSize: 11, color: '#ef4444', marginBottom: 8, display: 'block' }}></Typography.Text>
<Form form={corporateTransferForm} layout="vertical" size="small" requiredMark={false}>
<Form.Item name="accountName" label="账户名称" rules={[{ required: true, message: '请输入账户名称' }]} style={{ marginBottom: 10 }}>
<Input placeholder="请输入付款方账户名称" />
</Form.Item>
<Form.Item name="bankName" label="开户银行" rules={[{ required: true, message: '请输入开户银行' }]} style={{ marginBottom: 10 }}>
<Input placeholder="请输入开户银行" prefix={<BankOutlined style={{ color: '#94a3b8' }} />} />
</Form.Item>
<Form.Item name="bankAccount" label="账号" rules={[{ required: true, message: '请输入账号' }]} style={{ marginBottom: 8 }}>
<Input placeholder="请输入付款方账号" />
</Form.Item>
</Form>
</div>
<Button
type="primary"
block
size="large"
loading={corporateSubmitting}
onClick={submitCorporateTransfer}
style={{ borderRadius: 8, background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', border: 'none', fontWeight: 600, height: 38, fontSize: 14 }}
>
</Button>
<div style={{ marginTop: 8, fontSize: 11, color: '#94a3b8', textAlign: 'center' }}>
12
</div>
</div>
)}
{qrRevealed && <div style={{ marginTop: 12, fontSize: 12, color: '#94a3b8' }}>{countdown} </div>}
{qrRevealed && <Button danger style={{ marginTop: 14 }} onClick={async () => {
stopPolling();
if (currentOrderNoRef.current) { try { await cancelPaymentOrder(currentOrderNoRef.current); } catch { } currentOrderNoRef.current = null; }
localStorage.removeItem(PENDING_ORDER_KEY); setQrCodeModalOpen(false); setCurrentPaymentInfo(null); setPendingProduct(null); setQrRevealed(false); message.info('已取消支付');
}}></Button>}
</div>
<div style={{ marginTop: 8, fontSize: 11, color: '#94a3b8', textAlign: 'center' }}>线/线</div>
</div>
</div>
</Modal>
+33 -169
View File
@@ -1,186 +1,50 @@
import React, { useEffect, useState } from 'react';
import { Table, Tag, Empty, Spin, Typography, Row, Col, Pagination } from 'antd';
import { WalletOutlined, PlusCircleOutlined, ThunderboltOutlined } from '@ant-design/icons';
import { Col, Empty, Pagination, Row, Spin, Table, Tag, Typography } from 'antd';
import { LockOutlined, TeamOutlined, ThunderboltOutlined, WalletOutlined } from '@ant-design/icons';
import { getCredits } from '../api';
const CreditRecordsPage: React.FC = () => {
const [loading, setLoading] = useState(true);
const [records, setRecords] = useState<any[]>([]);
const [credits, setCredits] = useState(0);
const [total, setTotal] = useState(0);
const [totalGranted, setTotalGranted] = useState(0);
const [totalConsumed, setTotalConsumed] = useState(0);
const [data, setData] = useState<any>({ records: [], total: 0, credits: 0, personalCredits: 0, teamAvailableCredits: 0, teamFrozenCredits: 0, totalGranted: 0, totalConsumed: 0 });
const [page, setPage] = useState(1);
const [pageSize] = useState(10);
const [pageSize, setPageSize] = useState(10);
useEffect(() => {
loadData();
}, [page]);
const loadData = async () => {
setLoading(true);
try {
const data = await getCredits(page, pageSize);
setRecords(data.records || []);
setCredits(data.availableCredits ?? data.credits ?? 0);
setTotal(data.total || 0);
setTotalGranted(data.totalGranted ?? 0);
setTotalConsumed(data.totalConsumed ?? 0);
} catch {
setRecords([]);
}
setLoading(false);
};
const handlePageChange = (newPage: number) => {
setPage(newPage);
};
const load = async () => {
setLoading(true);
try { setData(await getCredits(page, pageSize)); } finally { setLoading(false); }
};
void load();
}, [page, pageSize]);
const columns = [
{
title: '变动类型',
dataIndex: 'type',
key: 'type',
width: 120,
render: (text: string) => {
const typeConfig: Record<string, { color: string; label: string; bg: string }> = {
recharge: { color: '#10b981', label: '充值', bg: 'rgba(16,185,129,0.1)' },
consume: { color: '#ef4444', label: '消费', bg: 'rgba(239,68,68,0.1)' },
admin: { color: '#f59e0b', label: '管理员调整', bg: 'rgba(245,158,11,0.1)' },
refund: { color: '#8b5cf6', label: '退款', bg: 'rgba(139,92,246,0.1)' },
team_internal: { color: '#0958d9', label: '历史团队流转', bg: 'rgba(9,88,217,0.1)' },
expire: { color: '#64748b', label: '积分过期', bg: 'rgba(100,116,139,0.1)' },
revoke: { color: '#dc2626', label: '积分撤销', bg: 'rgba(220,38,38,0.1)' },
};
const config = typeConfig[text] || { color: '#64748b', label: text, bg: 'rgba(100,116,139,0.1)' };
return (
<Tag color={config.color} style={{ background: config.bg, fontWeight: 500, padding: '4px 12px' }}>
{config.label}
</Tag>
);
},
},
{
title: '描述',
dataIndex: 'description',
key: 'description',
ellipsis: true,
},
{
title: '变动积分',
dataIndex: 'amount',
key: 'amount',
width: 140,
align: 'right' as const,
render: (value: number) => {
const delta = Number(value ?? 0);
return (
<Typography.Text strong style={{ fontWeight: 700, color: delta > 0 ? '#10b981' : delta < 0 ? '#ef4444' : '#64748b', fontSize: 15 }}>
{delta > 0 ? '+' : ''}{delta}
</Typography.Text>
);
},
},
{
title: '创建时间',
key: 'createdAt',
width: 180,
render: (_, record: any) => {
const createdAt = record.createdAt || record.created_at || record.createTime || record.create_time;
return createdAt ? new Date(createdAt).toLocaleString('zh-CN') : '-';
},
},
{ title: '类型', dataIndex: 'typeLabel', width: 110, render: (v: string, r: any) => <Tag>{v || ({ recharge: '发放/充值', consume: '消费', refund: '业务退款', expire: '积分过期', revoke: '积分撤销', team_internal: '历史团队流转' } as any)[r.type] || '其他'}</Tag> },
{ title: '说明', dataIndex: 'description', ellipsis: true },
{ title: '总变动', dataIndex: 'amount', width: 110, align: 'right' as const, render: (v: number) => <Typography.Text strong style={{ color: Number(v) < 0 ? '#ef4444' : '#10b981' }}>{Number(v) > 0 ? '+' : ''}{Number(v || 0)}</Typography.Text> },
{ title: '个人积分', dataIndex: 'personalAmount', width: 110, align: 'right' as const, render: (v: number) => Number(v || 0) === 0 ? '-' : Number(v || 0) },
{ title: '团队积分', dataIndex: 'teamAmount', width: 110, align: 'right' as const, render: (v: number) => Number(v || 0) === 0 ? '-' : Number(v || 0) },
{ title: '业务场景', dataIndex: 'billingSceneLabel', width: 140, render: (v: string) => v || '-' },
{ title: 'Token', dataIndex: 'totalTokens', width: 100, align: 'right' as const, render: (v: number) => v == null ? '-' : Number(v).toLocaleString() },
{ title: '时间', dataIndex: 'createdAt', width: 180, render: (v: string) => v ? new Date(v).toLocaleString('zh-CN', { hour12: false }) : '-' },
];
const cards = [
['总可消费积分', Number(data.credits || 0), <WalletOutlined />],
['个人可用积分', Number(data.personalCredits || 0), <WalletOutlined />],
['团队可用积分', Number(data.teamAvailableCredits || 0), <TeamOutlined />],
['团队冻结积分', Number(data.teamFrozenCredits || 0), <LockOutlined />],
];
return (
<div>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={8}>
<div style={{
borderRadius: 16, padding: 24,
background: 'linear-gradient(135deg, #f59e0b 0%, #ea580c 100%)',
position: 'relative', overflow: 'hidden',
}}>
<div style={{ position: 'absolute', right: -20, top: -20, width: 120, height: 120, borderRadius: '50%', background: 'rgba(255,255,255,0.1)' }} />
<div style={{ position: 'relative' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
<WalletOutlined style={{ color: 'rgba(255,255,255,0.8)', fontSize: 16 }} />
<span style={{ color: 'rgba(255,255,255,0.8)', fontSize: 14 }}></span>
</div>
<div style={{ color: '#fff', fontSize: 38, fontWeight: 800, lineHeight: 1 }}>
{credits.toLocaleString()}
</div>
</div>
</div>
</Col>
<Col xs={12} sm={8}>
<div style={{
borderRadius: 16, padding: 24, background: '#fff', border: '1px solid #f0f0f5',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'rgba(16,185,129,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<PlusCircleOutlined style={{ color: '#10b981', fontSize: 14 }} />
</div>
<span style={{ color: '#94a3b8', fontSize: 14 }}></span>
</div>
<div style={{ color: '#10b981', fontSize: 28, fontWeight: 800 }}>
{totalGranted.toLocaleString()}
</div>
</div>
</Col>
<Col xs={12} sm={8}>
<div style={{
borderRadius: 16, padding: 24, background: '#fff', border: '1px solid #f0f0f5',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'rgba(239,68,68,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<ThunderboltOutlined style={{ color: '#ef4444', fontSize: 14 }} />
</div>
<span style={{ color: '#94a3b8', fontSize: 14 }}></span>
</div>
<div style={{ color: '#ef4444', fontSize: 28, fontWeight: 800 }}>
{totalConsumed.toLocaleString()}
</div>
</div>
</Col>
<Spin spinning={loading}>
<Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
{cards.map(([label, value, icon]: any) => <Col xs={12} lg={6} key={label}><div style={{ border: '1px solid #f0f0f5', background: '#fff', padding: 18, borderRadius: 14 }}><div style={{ color: '#64748b' }}>{icon} {label}</div><div style={{ fontSize: 26, fontWeight: 800, marginTop: 8 }}>{Number(value).toLocaleString()}</div></div></Col>)}
</Row>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<ThunderboltOutlined style={{ color: '#f59e0b', fontSize: 16 }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
</div>
<div style={{ borderRadius: 16, background: '#fff', border: '1px solid #f0f0f5', overflow: 'hidden' }}>
<Spin spinning={loading}>
{records.length === 0 ? (
<Empty
description={<span style={{ color: '#94a3b8' }}></span>}
/>
) : (
<>
<Table
dataSource={records}
columns={columns}
rowKey="id"
pagination={false}
bordered={false}
/>
<div style={{ padding: '16px', textAlign: 'right' }}>
<Pagination
current={page}
pageSize={pageSize}
total={total}
onChange={handlePageChange}
size="small"
/>
</div>
</>
)}
</Spin>
</div>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 10 }}><Typography.Title level={5} style={{ margin: 0 }}><ThunderboltOutlined /> </Typography.Title><span style={{ color: '#94a3b8' }}> {Number(data.totalGranted || 0).toLocaleString()} · {Number(data.totalConsumed || 0).toLocaleString()}</span></div>
{(data.records || []).length === 0 ? <Empty description="暂无积分变动记录" /> : <Table rowKey="id" columns={columns} dataSource={data.records || []} pagination={false} scroll={{ x: 1050 }} />}
<div style={{ marginTop: 16, textAlign: 'right' }}><Pagination current={page} pageSize={pageSize} total={Number(data.total || 0)} onChange={(p, ps) => { setPage(p); setPageSize(ps); }} /></div>
</Spin>
);
};
export default CreditRecordsPage;
export default CreditRecordsPage;
+56 -153
View File
@@ -1,175 +1,78 @@
import React, { useEffect, useState, useRef } from 'react';
import {
Col, Row, Space, Table, Tag, Typography,
} from 'antd';
import {
WalletOutlined, ArrowUpOutlined, ArrowDownOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
import { getCredits } from '../api';
import { useAuthStore } from '../store/useAuthStore';
import { formatDate, formatDatePrecise } from '../utils/formatDate';
import type { CreditRecord } from '../types';
const AnimatedNumber: React.FC<{ value: number; duration?: number }> = ({ value, duration = 1200 }) => {
const [display, setDisplay] = useState(0);
const ref = useRef<number>(0);
useEffect(() => {
const start = display;
const diff = value - start;
if (diff === 0) return;
const startTime = performance.now();
const animate = (now: number) => {
const elapsed = now - startTime;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
setDisplay(start + diff * eased);
if (progress < 1) ref.current = requestAnimationFrame(animate);
};
ref.current = requestAnimationFrame(animate);
return () => cancelAnimationFrame(ref.current);
}, [value]);
return <>{display.toLocaleString()}</>;
};
import React, { useEffect, useState } from 'react';
import { Col, Empty, Pagination, Row, Spin, Table, Tag, Typography } from 'antd';
import { LockOutlined, TeamOutlined, WalletOutlined } from '@ant-design/icons';
import { getCreditBalances, getCredits } from '../api';
import { formatDatePrecise } from '../utils/formatDate';
const CreditsPage: React.FC = () => {
const { user } = useAuthStore();
const [records, setRecords] = useState<CreditRecord[]>([]);
const [loading, setLoading] = useState(false);
const [summary, setSummary] = useState({ personalCredits: 0, teamAvailableCredits: 0, teamFrozenCredits: 0, credits: 0, nextExpiringCredits: 0, nextLastUsableAt: null as string | null });
const [balances, setBalances] = useState<any[]>([]);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [total, setTotal] = useState(0);
const [availableCredits, setAvailableCredits] = useState(0);
const [nextExpiringCredits, setNextExpiringCredits] = useState(0);
const [nextLastUsableAt, setNextLastUsableAt] = useState<string | null>(null);
const [totalConsumed, setTotalConsumed] = useState(0);
const [pageSize, setPageSize] = useState(20);
useEffect(() => {
const fetch = async () => {
const load = async () => {
setLoading(true);
const data = await getCredits(page, pageSize);
setRecords(data.records);
setTotal(data.total);
setAvailableCredits(data.availableCredits ?? data.credits ?? 0);
setNextExpiringCredits(data.nextExpiringCredits ?? 0);
setNextLastUsableAt(data.nextLastUsableAt ?? null);
setTotalConsumed(data.totalConsumed ?? 0);
setLoading(false);
try {
const [creditData, balanceData] = await Promise.all([getCredits(1, 1), getCreditBalances(page, pageSize)]);
setSummary({
personalCredits: Number(creditData.personalCredits || 0),
teamAvailableCredits: Number(creditData.teamAvailableCredits || 0),
teamFrozenCredits: Number(creditData.teamFrozenCredits || 0),
credits: Number(creditData.credits || creditData.availableCredits || 0),
nextExpiringCredits: Number(creditData.nextExpiringCredits || 0),
nextLastUsableAt: creditData.nextLastUsableAt || null,
});
setBalances(balanceData || []);
} finally {
setLoading(false);
}
};
fetch();
void load();
}, [page, pageSize]);
const handlePageChange = (p: number, ps: number) => {
setPage(p);
setPageSize(ps);
};
const cards = [
{ title: '个人可用积分', value: summary.personalCredits, icon: <WalletOutlined />, note: '个人积分可正常用于业务消费' },
{ title: '团队可用积分', value: summary.teamAvailableCredits, icon: <TeamOutlined />, note: '仅统计当前有效席位可消费额度' },
{ title: '团队冻结积分', value: summary.teamFrozenCredits, icon: <LockOutlined />, note: '团队禁用期间继续发放和过期,但不可消费' },
{ title: '总可消费积分', value: summary.credits, icon: <WalletOutlined />, note: '个人积分 + 当前可用团队积分' },
];
const columns = [
{ title: '类型', dataIndex: 'type', key: 'type', width: 100,
render: (type: string) => {
const labels: Record<string, { label: string; color: string; positive: boolean }> = {
recharge: { label: '发放/充值', color: 'green', positive: true },
consume: { label: '消费', color: 'orange', positive: false },
refund: { label: '业务退款', color: 'purple', positive: true },
expire: { label: '积分过期', color: 'default', positive: false },
revoke: { label: '积分撤销', color: 'red', positive: false },
team_internal: { label: '历史团队流转', color: 'blue', positive: false },
};
const config = labels[type] || { label: type, color: 'default', positive: false };
return <Tag color={config.color} icon={config.positive ? <ArrowUpOutlined /> : <ArrowDownOutlined />}>{config.label}</Tag>;
},
},
{ title: '有效积分变动', key: 'balanceDelta', width: 150,
render: (_: unknown, record: CreditRecord) => {
const delta = record.balanceDelta ?? record.amount ?? 0;
const expired = record.expiredAmount ?? 0;
return (
<div>
<Typography.Text strong style={{ color: delta > 0 ? '#10b981' : delta < 0 ? '#ef4444' : '#64748b', fontSize: 15 }}>
{delta > 0 ? '+' : ''}{delta}
</Typography.Text>
{expired > 0 && <div style={{ color: '#94a3b8', fontSize: 12 }}> {expired}</div>}
</div>
);
},
},
{ title: '说明', dataIndex: 'description', key: 'description' },
{ title: '时间', dataIndex: 'createdAt', key: 'createdAt', width: 160,
render: (d: string) => <span className="date-display" translate="no" style={{ color: '#94a3b8' }}>{formatDate(d)}</span>,
},
{ title: '资金域', dataIndex: 'creditScopeLabel', width: 110, render: (v: string, r: any) => <Tag color={r.creditScope === 'team' ? 'blue' : 'green'}>{v || (r.creditScope === 'team' ? '团队积分' : '个人积分')}</Tag> },
{ title: '积分等级', dataIndex: 'creditLevelLabel', width: 120, render: (v: string) => v || '-' },
{ title: '来源', dataIndex: 'sourceTypeLabel', width: 150, render: (v: string) => v || '-' },
{ title: '发放积分', dataIndex: 'grantAmount', width: 110, align: 'right' as const },
{ title: '剩余积分', dataIndex: 'unspentAmount', width: 110, align: 'right' as const, render: (v: number) => <Typography.Text strong>{Number(v || 0).toLocaleString()}</Typography.Text> },
{ title: '状态', dataIndex: 'statusLabel', width: 100, render: (v: string) => <Tag>{v || '其他状态'}</Tag> },
{ title: '最后可用时间', dataIndex: 'lastUsableAt', width: 190, render: (v: string) => v ? formatDatePrecise(v) : '-' },
];
return (
<div>
{/* Balance cards */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={8}>
<div className="animate-fadeInUp" style={{
borderRadius: 16, padding: 24,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
position: 'relative', overflow: 'hidden',
}}>
<div style={{ position: 'absolute', right: -20, top: -20, width: 120, height: 120, borderRadius: '50%', background: 'rgba(255,255,255,0.1)' }} />
<div style={{ position: 'relative' }}>
<Space style={{ marginBottom: 10 }}>
<WalletOutlined style={{ color: 'rgba(255,255,255,0.8)', fontSize: 16 }} />
<span style={{ color: 'rgba(255,255,255,0.8)', fontSize: 14 }}></span>
</Space>
<div style={{ color: '#fff', fontSize: 38, fontWeight: 800, lineHeight: 1 }}>
<AnimatedNumber value={availableCredits} />
</div>
<Spin spinning={loading}>
<Row gutter={[16, 16]} style={{ marginBottom: 18 }}>
{cards.map((card) => (
<Col xs={24} sm={12} lg={6} key={card.title}>
<div style={{ background: '#fff', border: '1px solid #f0f0f5', borderRadius: 16, padding: 20, minHeight: 132 }}>
<div style={{ display: 'flex', gap: 8, color: '#64748b', alignItems: 'center' }}>{card.icon}<span>{card.title}</span></div>
<div style={{ fontSize: 30, fontWeight: 800, color: '#1e293b', margin: '10px 0 6px' }}>{card.value.toLocaleString()}</div>
<div style={{ fontSize: 12, color: '#94a3b8' }}>{card.note}</div>
</div>
</div>
</Col>
<Col xs={12} sm={8}>
<div className="animate-fadeInUp" style={{
borderRadius: 16, padding: 24, background: '#fff', border: '1px solid #f0f0f5', animationDelay: '0.06s',
}}>
<Space style={{ marginBottom: 10 }}>
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'rgba(16,185,129,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<ArrowUpOutlined style={{ color: '#10b981', fontSize: 14 }} />
</div>
<span style={{ color: '#94a3b8', fontSize: 14 }}></span>
</Space>
<div style={{ color: '#f59e0b', fontSize: 28, fontWeight: 800 }}><AnimatedNumber value={nextExpiringCredits} /></div>
<div style={{ color: '#94a3b8', fontSize: 12, marginTop: 6 }}>{nextLastUsableAt ? `最后可用:${formatDatePrecise(nextLastUsableAt)}` : '暂无即将过期积分'}</div>
</div>
</Col>
<Col xs={12} sm={8}>
<div className="animate-fadeInUp" style={{
borderRadius: 16, padding: 24, background: '#fff', border: '1px solid #f0f0f5', animationDelay: '0.12s',
}}>
<Space style={{ marginBottom: 10 }}>
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'rgba(239,68,68,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<ArrowDownOutlined style={{ color: '#ef4444', fontSize: 14 }} />
</div>
<span style={{ color: '#94a3b8', fontSize: 14 }}></span>
</Space>
<div style={{ color: '#ef4444', fontSize: 28, fontWeight: 800 }}><AnimatedNumber value={totalConsumed} /></div>
</div>
</Col>
</Col>
))}
</Row>
{/* Records */}
<div className="animate-fadeInUp" style={{ animationDelay: '0.15s' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<ThunderboltOutlined style={{ color: '#6366f1', fontSize: 16 }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
</div>
<div style={{ borderRadius: 16, background: '#fff', border: '1px solid #f0f0f5', overflow: 'hidden' }}>
<Table columns={columns} dataSource={records} rowKey="id" loading={loading}
pagination={{
current: page,
pageSize: pageSize,
total: total,
onChange: handlePageChange,
showSizeChanger: true,
showTotal: (t) => `${t}`,
}}
scroll={{ x: 500 }} />
{summary.nextExpiringCredits > 0 && (
<div style={{ marginBottom: 16, padding: '10px 14px', borderRadius: 10, background: '#fffbe6', color: '#8c6d1f' }}>
{summary.nextExpiringCredits.toLocaleString()} {summary.nextLastUsableAt ? formatDatePrecise(summary.nextLastUsableAt) : '-'}
</div>
)}
<Typography.Title level={5}></Typography.Title>
{balances.length === 0 ? <Empty description="暂无个人积分批次" /> : <Table rowKey="id" columns={columns} dataSource={balances} pagination={false} scroll={{ x: 900 }} />}
<div style={{ marginTop: 16, textAlign: 'right' }}>
<Pagination current={page} pageSize={pageSize} onChange={(p, ps) => { setPage(p); setPageSize(ps); }} showSizeChanger pageSizeOptions={[20, 50, 100]} />
</div>
</div>
</Spin>
);
};
+31 -197
View File
@@ -1,215 +1,49 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Table, Tag, Empty, Spin, Typography, Pagination, Select, DatePicker, Space } from 'antd';
import { FileTextOutlined, AlipayCircleOutlined, WechatOutlined } from '@ant-design/icons';
import { DatePicker, Empty, Pagination, Select, Spin, Table, Tag, Typography } from 'antd';
import { FileTextOutlined } from '@ant-design/icons';
import { getPaymentOrders } from '../api';
const PAYMENT_METHOD_LABELS: Record<string, string> = { alipay: '支付宝', wechat: '微信支付', bank_transfer: '银行转账', cash: '现金', other: '其他-线下收款' };
const SOURCE_LABELS: Record<string, string> = { online_payment: '线上支付', admin_offline: '后台线下成交' };
const STATUS_LABELS: Record<string, string> = { pending: '待支付', paid: '已支付', fulfilled: '已履约', refunded: '已退款', cancelled: '已取消', expired: '已过期', failed: '失败', processing: '处理中' };
const OrderRecordsPage: React.FC = () => {
const [loading, setLoading] = useState(true);
const [orders, setOrders] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(10);
const [statusFilter, setStatusFilter] = useState<string>('');
const [pageSize, setPageSize] = useState(10);
const [statusFilter, setStatusFilter] = useState<string>();
const [dateRange, setDateRange] = useState<any>([null, null]);
const loadData = useCallback(async () => {
const load = useCallback(async () => {
setLoading(true);
try {
const data = await getPaymentOrders(page, pageSize, {
statusFilter: statusFilter || undefined,
startDate: dateRange[0]?.format?.('YYYY-MM-DD'),
endDate: dateRange[1]?.format?.('YYYY-MM-DD'),
});
setOrders(data.items || []);
setTotal(data.total || 0);
} catch {
setOrders([]);
}
setLoading(false);
}, [page, statusFilter, dateRange]);
useEffect(() => {
loadData();
}, [loadData]);
const handlePageChange = (newPage: number) => {
setPage(newPage);
};
const handleReset = () => {
setStatusFilter('');
setDateRange([null, null]);
setPage(1);
};
const data = await getPaymentOrders(page, pageSize, { statusFilter, startDate: dateRange[0]?.format?.('YYYY-MM-DD'), endDate: dateRange[1]?.format?.('YYYY-MM-DD') });
setOrders(data.items || []); setTotal(Number(data.total || 0));
} finally { setLoading(false); }
}, [page, pageSize, statusFilter, dateRange]);
useEffect(() => { void load(); }, [load]);
const columns = [
{
title: '订单号',
key: 'orderNo',
width: 200,
render: (_, record: any) => {
const orderNo = record.order_no || record.orderNo || record.id;
return (
<span style={{ fontFamily: 'monospace', fontSize: 13, color: '#64748b' }}>
{orderNo}
</span>
);
},
},
{
title: '支付方式',
key: 'paymentMethod',
width: 100,
render: (_, record: any) => {
const method = record.payment_method || record.paymentMethod || 'wechat';
const methodConfig: Record<string, { icon: React.ReactNode; label: string; color: string }> = {
alipay: { icon: <AlipayCircleOutlined style={{ fontSize: 16 }} />, label: '支付宝', color: '#1677ff' },
wechat: { icon: <WechatOutlined style={{ fontSize: 16 }} />, label: '微信支付', color: '#07c160' },
};
const config = methodConfig[method] || methodConfig.wechat;
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ color: config.color }}>{config.icon}</span>
<span style={{ fontSize: 13, color: '#374151' }}>{config.label}</span>
</div>
);
},
},
{
title: '金额',
key: 'amount',
width: 100,
align: 'right' as const,
render: (_, record: any) => {
const amount = record.amount || record.total_amount || 0;
return (
<Typography.Text strong style={{ fontWeight: 700, fontSize: 14, color: '#1e293b' }}>
¥{amount}
</Typography.Text>
);
},
},
{
title: '获得积分',
key: 'credits',
width: 100,
align: 'center' as const,
render: (_, record: any) => {
const credits = record.credits || record.credit_amount || 0;
return (
<Typography.Text strong style={{ fontWeight: 600, fontSize: 14, color: '#f59e0b' }}>
{credits}
</Typography.Text>
);
},
},
{
title: '状态',
key: 'status',
width: 100,
render: (_, record: any) => {
const status = record.status || 'pending';
const statusConfig: Record<string, { color: string; label: string; bg: string }> = {
pending: { color: '#f59e0b', label: '待支付', bg: 'rgba(245,158,11,0.1)' },
paid: { color: '#10b981', label: '已支付', bg: 'rgba(16,185,129,0.1)' },
refunded: { color: '#94a3b8', label: '已退款', bg: 'rgba(148,163,184,0.1)' },
failed: { color: '#ef4444', label: '支付失败', bg: 'rgba(239,68,68,0.1)' },
cancelled: { color: '#64748b', label: '已取消', bg: 'rgba(100,116,139,0.1)' },
processing: { color: '#0ea5e9', label: '处理中', bg: 'rgba(14,165,233,0.1)' },
};
const config = statusConfig[status] || { color: '#64748b', label: status, bg: 'rgba(100,116,139,0.1)' };
return (
<Tag color={config.color} style={{ background: config.bg, fontWeight: 500, padding: '4px 12px' }}>
{config.label}
</Tag>
);
},
},
{
title: '创建时间',
key: 'createdAt',
width: 180,
render: (_, record: any) => {
const createdAt = record.created_at || record.createdAt;
return createdAt ? new Date(createdAt).toLocaleString('zh-CN') : '-';
},
},
{
title: '支付时间',
key: 'paidAt',
width: 180,
render: (_, record: any) => {
const paidAt = record.paid_at || record.paidAt;
return paidAt ? new Date(paidAt).toLocaleString('zh-CN') : '-';
},
},
{ title: '订单号', dataIndex: 'orderNo', width: 210, render: (v: string) => <Typography.Text copyable code>{v}</Typography.Text> },
{ title: '订单来源', dataIndex: 'orderSource', width: 120, render: (v: string, r: any) => <Tag color={v === 'admin_offline' ? 'purple' : 'blue'}>{r.orderSourceLabel || SOURCE_LABELS[v] || '其他来源'}</Tag> },
{ title: '商品', dataIndex: 'productNameSnapshot', width: 180, ellipsis: true, render: (v: string, r: any) => <span>{v || '-'}{Number(r.quantity || 1) > 1 ? ` × ${r.quantity}` : ''}</span> },
{ title: '支付方式', dataIndex: 'paymentMethod', width: 125, render: (v: string, r: any) => r.paymentMethodLabel || (v === 'other' && r.offlinePaymentDetail ? r.offlinePaymentDetail : PAYMENT_METHOD_LABELS[v]) || '其他支付方式' },
{ title: '实际金额', dataIndex: 'amount', width: 110, align: 'right' as const, render: (v: number) => `¥${Number(v || 0).toFixed(2)}` },
{ title: '状态', dataIndex: 'status', width: 100, render: (v: string, r: any) => <Tag>{r.statusLabel || STATUS_LABELS[v] || '其他状态'}</Tag> },
{ title: '创建时间', dataIndex: 'createdAt', width: 180, render: (v: string) => v ? new Date(v).toLocaleString('zh-CN', { hour12: false }) : '-' },
{ title: '支付时间', dataIndex: 'paidAt', width: 180, render: (v: string) => v ? new Date(v).toLocaleString('zh-CN', { hour12: false }) : '-' },
];
return (
<div>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<FileTextOutlined style={{ color: '#0ea5e9', fontSize: 16 }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<span style={{ color: '#94a3b8', fontSize: 13 }}> {total} </span>
</div>
{/* 搜索栏 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
<Select
placeholder="支付状态"
allowClear
style={{ width: 130 }}
value={statusFilter || undefined}
onChange={(v) => { setStatusFilter(v || ''); setPage(1); }}
options={[
{ value: 'pending', label: '待支付' },
{ value: 'paid', label: '已支付' },
{ value: 'refunded', label: '已退款' },
{ value: 'failed', label: '支付失败' },
{ value: 'cancelled', label: '已取消' },
]}
/>
<DatePicker.RangePicker
value={dateRange}
onChange={(dates) => { setDateRange(dates); setPage(1); }}
allowClear
placeholder={['开始日期', '结束日期']}
/>
{(statusFilter || dateRange[0] || dateRange[1]) && (
<Typography.Link onClick={handleReset}></Typography.Link>
)}
</div>
<div style={{ borderRadius: 16, background: '#fff', border: '1px solid #f0f0f5', overflow: 'hidden' }}>
<Spin spinning={loading}>
{orders.length === 0 ? (
<Empty
description={<span style={{ color: '#94a3b8' }}></span>}
/>
) : (
<>
<Table
dataSource={orders}
columns={columns}
rowKey="order_no"
pagination={false}
bordered={false}
/>
<div style={{ padding: '16px', textAlign: 'right' }}>
<Pagination
current={page}
pageSize={pageSize}
total={total}
onChange={handlePageChange}
size="small"
/>
</div>
</>
)}
</Spin>
</div>
</div>
return <div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 14 }}><FileTextOutlined /><Typography.Title level={5} style={{ margin: 0 }}></Typography.Title><span style={{ color: '#94a3b8' }}>线</span></div>
<div style={{ display: 'flex', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
<Select allowClear placeholder="订单状态" style={{ width: 140 }} value={statusFilter} onChange={(v) => { setStatusFilter(v); setPage(1); }} options={Object.entries(STATUS_LABELS).filter(([k]) => ['pending','paid','refunded','cancelled','expired','failed'].includes(k)).map(([value,label]) => ({ value,label }))} />
<DatePicker.RangePicker value={dateRange} onChange={(v) => { setDateRange(v || [null, null]); setPage(1); }} placeholder={['开始日期','结束日期']} />
</div>
);
<Spin spinning={loading}>{orders.length === 0 ? <Empty description="暂无订单记录" /> : <Table rowKey="id" columns={columns} dataSource={orders} pagination={false} scroll={{ x: 1150 }} />}</Spin>
<div style={{ marginTop: 16, textAlign: 'right' }}><Pagination current={page} pageSize={pageSize} total={total} showSizeChanger onChange={(p, ps) => { setPage(p); setPageSize(ps); }} /></div>
</div>;
};
export default OrderRecordsPage;
export default OrderRecordsPage;
+197 -634
View File
@@ -1,676 +1,239 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import {
Button, Empty, Form, Input, InputNumber, message, Modal, Pagination, Radio, Select, Segmented, Space, Table, Tabs, Tag, Tooltip, Typography,
Alert, Button, Card, Col, DatePicker, Empty, Form, Input, InputNumber, message, Modal,
Pagination, Popconfirm, Row, Select, Space, Table, Tabs, Tag, Typography,
} from 'antd';
import { DatePicker } from 'antd';
import {
DownloadOutlined, LockOutlined, PlusOutlined, ReloadOutlined,
TeamOutlined, UserSwitchOutlined, WalletOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import {
CopyOutlined, DownloadOutlined, PlusOutlined, ReloadOutlined, UserOutlined, HistoryOutlined, WalletOutlined, BellOutlined, ClockCircleOutlined, CheckOutlined, CloseOutlined,
} from '@ant-design/icons';
cancelTeamSeat, createTeamInvitation, createTeamSeat, getManagerAccessTeams, getManagedTeam,
getPendingJoinRequests, getTeamCreditExportUrl, getTeamCreditRecords, getTeamInvitations,
getTeamManagerHistory, getTeamMembers, getTeamMemberUsage, getTeamSubscriptions,
handleJoinRequest, revokeInvitation, transferTeamManager, updateTeamSeat,
} from '../api';
import type { ManagedTeam, TeamInvitation, TeamJoinRequest, TeamMember, TeamMemberUsage, TeamSeat, TeamSubscriptionManage } from '../types';
const { RangePicker } = DatePicker;
import {
createTeamInvitation, getJoinTeamInfo, getManagedTeam, getPendingJoinRequests,
getTeamCreditExportUrl, getTeamCreditRecords, getTeamInvitations, getTeamMembers, handleJoinRequest, revokeInvitation, submitJoinRequest,
} from '../api';
import type { ManagedTeam, TeamInvitation, TeamJoinRequest, TeamMember } from '../types';
import { useAuthStore } from '../store/useAuthStore';
const fmt = (value?: string | null) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '-';
/* ── 工具函数 ────────────────────────────────────────── */
function formatDateTime(value: any): string {
if (!value) return '-';
try {
return new Date(value).toLocaleString('zh-CN', { hour12: false });
} catch {
return '-';
}
}
const RECORD_TYPE_CONFIG: Record<string, { color: string; label: string }> = {
recharge: { color: 'green', label: '充值' },
consume: { color: 'red', label: '消费' },
refund: { color: 'orange', label: '退款' },
team_internal: { color: 'blue', label: '团队内部' },
};
/* ── 主组件 ──────────────────────────────────────────── */
const TeamManagementPage: React.FC = () => {
const [team, setTeam] = useState<ManagedTeam | null>(null);
const [teamLoading, setTeamLoading] = useState(false);
const loadTeam = useCallback(async () => {
setTeamLoading(true);
try {
const data = await getManagedTeam();
setTeam(data);
} catch (e: any) {
message.error(e?.message || '获取团队信息失败');
} finally {
setTeamLoading(false);
}
}, []);
useEffect(() => { loadTeam(); }, [loadTeam]);
// ── Tab 1: 成员 ──
const [accessTeams, setAccessTeams] = useState<any[]>([]);
const [selectedFlowTeamId, setSelectedFlowTeamId] = useState<string>();
const [members, setMembers] = useState<TeamMember[]>([]);
const [membersTotal, setMembersTotal] = useState(0);
const [membersLoading, setMembersLoading] = useState(false);
const [membersPage, setMembersPage] = useState(1);
const loadMembers = useCallback(async () => {
if (!team) return;
setMembersLoading(true);
try {
const res = await getTeamMembers(membersPage);
setMembers(res.items || []);
setMembersTotal(res.total || 0);
} catch (e: any) {
message.error(e?.message || '加载成员失败');
} finally {
setMembersLoading(false);
}
}, [team, membersPage]);
useEffect(() => { loadMembers(); }, [loadMembers]);
// ── Tab 2: 邀请码 ──
const [memberTotal, setMemberTotal] = useState(0);
const [memberPage, setMemberPage] = useState(1);
const [subscriptions, setSubscriptions] = useState<TeamSubscriptionManage[]>([]);
const [usage, setUsage] = useState<TeamMemberUsage[]>([]);
const [history, setHistory] = useState<any[]>([]);
const [invitations, setInvitations] = useState<TeamInvitation[]>([]);
const [invLoading, setInvLoading] = useState(false);
const [invModal, setInvModal] = useState(false);
const [invForm] = Form.useForm();
const [invSaving, setInvSaving] = useState(false);
const loadInvitations = useCallback(async () => {
setInvLoading(true);
try {
const data = await getTeamInvitations();
setInvitations(data || []);
} catch (e: any) {
message.error(e?.message || '加载邀请码失败');
} finally {
setInvLoading(false);
}
}, []);
useEffect(() => { loadInvitations(); }, [loadInvitations]);
const handleCreateInvitation = async () => {
try {
const values = await invForm.validateFields();
setInvSaving(true);
await createTeamInvitation(values.maxUses || null, null);
message.success('邀请码已生成');
setInvModal(false);
invForm.resetFields();
loadInvitations();
} catch (e: any) {
if (e?.errorFields) return;
message.error(e?.message || '创建失败');
} finally {
setInvSaving(false);
}
};
const handleRevoke = async (invId: string) => {
try {
await revokeInvitation(invId);
message.success('已撤销');
loadInvitations();
} catch (e: any) {
message.error(e?.message || '撤销失败');
}
};
const copyInviteLink = (link: string) => {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(link).then(() => {
message.success('邀请链接已复制');
}).catch(() => {
fallbackCopy(link);
});
} else {
fallbackCopy(link);
}
};
const fallbackCopy = (text: string) => {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-9999px';
textArea.style.top = '-9999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy');
message.success('邀请链接已复制');
} catch {
message.warning('复制失败,请手动复制');
}
document.body.removeChild(textArea);
};
// ── Tab 3: 加入申请 ──
const [requests, setRequests] = useState<TeamJoinRequest[]>([]);
const [reqLoading, setReqLoading] = useState(false);
const [reqStatusFilter, setReqStatusFilter] = useState<string>('pending');
const [activeTab, setActiveTab] = useState('members');
const initialNoticeShownRef = useRef(false);
const lastRequestCountRef = useRef(0);
const [flow, setFlow] = useState<any>({ items: [], total: 0 });
const [flowPage, setFlowPage] = useState(1);
const [flowType, setFlowType] = useState<string>();
const [flowPhone, setFlowPhone] = useState('');
const [flowSubscriptionId, setFlowSubscriptionId] = useState<string>();
const [flowDates, setFlowDates] = useState<any>([null, null]);
const [loading, setLoading] = useState(false);
const [seatModal, setSeatModal] = useState<{ open: boolean; subscriptionId?: string; seat?: TeamSeat }>({ open: false });
const [seatForm] = Form.useForm();
const [inviteModal, setInviteModal] = useState(false);
const [inviteForm] = Form.useForm();
const loadRequests = useCallback(async (status: string, isInitial = false) => {
setReqLoading(true);
const readOnly = !!team?.isReadOnly;
const currentManager = !!team;
const loadBase = useCallback(async () => {
setLoading(true);
try {
const data = await getPendingJoinRequests(status);
const currentCount = data?.length || 0;
setRequests(data || []);
// 只有待处理状态才显示弹窗通知
if (status === 'pending' && currentCount > 0) {
if (isInitial && !initialNoticeShownRef.current) {
initialNoticeShownRef.current = true;
Modal.confirm({
title: (
<Space>
<BellOutlined style={{ color: '#f59e0b' }} />
</Space>
),
content: (
<div>
<p> <strong style={{ color: '#ef4444' }}>{currentCount}</strong> </p>
<p style={{ color: '#64748b', fontSize: 13, marginBottom: 0 }}></p>
</div>
),
okText: '立即处理',
cancelText: '稍后处理',
onOk: () => {
setActiveTab('requests');
},
});
} else if (!isInitial && currentCount > lastRequestCountRef.current) {
message.info({
content: `${currentCount - lastRequestCountRef.current} 条新的加入申请待处理`,
duration: 5,
});
}
const access = await getManagerAccessTeams().catch(() => []);
setAccessTeams(access || []);
const current = await getManagedTeam().catch(() => null);
setTeam(current);
const defaultTeam = current?.id || access?.[0]?.id;
setSelectedFlowTeamId((old) => old || defaultTeam);
if (current) {
const [memberData, subData, usageData, historyData, invData, requestData] = await Promise.all([
getTeamMembers(memberPage, 20), getTeamSubscriptions(), getTeamMemberUsage(), getTeamManagerHistory(), getTeamInvitations(), getPendingJoinRequests(),
]);
setMembers(memberData?.items || []); setMemberTotal(Number(memberData?.total || 0));
setSubscriptions(subData || []); setUsage(usageData || []); setHistory(historyData || []);
setInvitations(invData || []); setRequests(requestData || []);
} else {
setMembers([]); setSubscriptions([]); setUsage([]); setHistory([]); setInvitations([]); setRequests([]);
}
} finally { setLoading(false); }
}, [memberPage]);
if (status === 'pending') {
lastRequestCountRef.current = currentCount;
}
} catch (e: any) {
message.error(e?.message || '加载申请失败');
} finally {
setReqLoading(false);
}
}, []);
useEffect(() => {
loadRequests('pending', true);
}, [loadRequests]);
useEffect(() => {
if (!team) return;
const interval = setInterval(() => {
loadRequests('pending', false);
}, 60000);
return () => clearInterval(interval);
}, [team, loadRequests]);
const handleRequest = async (requestId: string, action: 'approve' | 'reject', note?: string) => {
const loadFlow = useCallback(async () => {
if (!selectedFlowTeamId) { setFlow({ items: [], total: 0 }); return; }
try {
await handleJoinRequest(requestId, action, note);
message.success(action === 'approve' ? '已通过' : '已拒绝');
loadRequests(reqStatusFilter);
loadMembers();
} catch (e: any) {
message.error(e?.message || '操作失败');
}
};
// ── Tab 4: 团队积分变动 ──
const [creditRecords, setCreditRecords] = useState<any[]>([]);
const [creditTotal, setCreditTotal] = useState(0);
const [creditSummary, setCreditSummary] = useState<any>(null);
const [creditLoading, setCreditLoading] = useState(false);
const [creditPage, setCreditPage] = useState(1);
const [creditFilterType, setCreditFilterType] = useState<string>('');
const [creditFilterPhone, setCreditFilterPhone] = useState<string>('');
const [creditDateRange, setCreditDateRange] = useState<[string, string] | null>(() => {
const today = dayjs().format('YYYY-MM-DD');
return [today, today];
});
const loadCreditRecords = useCallback(async () => {
setCreditLoading(true);
try {
const res = await getTeamCreditRecords({
page: creditPage,
pageSize: 10,
phone: creditFilterPhone || undefined,
recordType: creditFilterType || undefined,
startDate: creditDateRange?.[0] || undefined,
endDate: creditDateRange?.[1] || undefined,
const data = await getTeamCreditRecords({
teamId: selectedFlowTeamId, page: flowPage, pageSize: 20, phone: flowPhone || undefined,
subscriptionId: flowSubscriptionId, recordType: flowType,
startDate: flowDates[0]?.format?.('YYYY-MM-DD'), endDate: flowDates[1]?.format?.('YYYY-MM-DD'),
});
setCreditRecords(res.items || []);
setCreditTotal(res.total || 0);
setCreditSummary(res.summary || null);
} catch (e: any) {
message.error(e?.message || '加载积分记录失败');
} finally {
setCreditLoading(false);
}
}, [creditPage, creditFilterType, creditFilterPhone, creditDateRange]);
setFlow(data || { items: [], total: 0 });
} catch (e: any) { message.error(e?.message || '团队流水加载失败'); }
}, [selectedFlowTeamId, flowPage, flowPhone, flowSubscriptionId, flowType, flowDates]);
useEffect(() => { loadCreditRecords(); }, [loadCreditRecords]);
useEffect(() => { void loadBase(); }, [loadBase]);
useEffect(() => { void loadFlow(); }, [loadFlow]);
const resetCreditFilters = () => {
setCreditFilterType('');
setCreditFilterPhone('');
const today = dayjs().format('YYYY-MM-DD');
setCreditDateRange([today, today]);
setCreditPage(1);
const openSeatCreate = (subscriptionId: string) => {
seatForm.resetFields();
const target = subscriptions.find((item) => item.subscription?.id === subscriptionId);
const activeSeats = Math.max(1, Number(target?.seatLimit || 1) - Number(target?.activeSeatCount || 0));
const defaultAmount = Number(target?.periodUnallocatedCredits || 0) > 0 ? Math.floor((Number(target?.periodUnallocatedCredits || 0) / activeSeats) * 100) / 100 : undefined;
seatForm.setFieldsValue({ monthlyAllocatedCredits: defaultAmount });
setSeatModal({ open: true, subscriptionId });
};
const handleExportCredits = () => {
const url = getTeamCreditExportUrl({
phone: creditFilterPhone || undefined,
recordType: creditFilterType || undefined,
startDate: creditDateRange?.[0] || undefined,
endDate: creditDateRange?.[1] || undefined,
});
const token = localStorage.getItem('auth_token');
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {};
fetch(url, { headers })
.then((res) => res.blob())
.then((blob) => {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `团队积分_${dayjs().format('YYYYMMDD_HHmmss')}.csv`;
a.click();
URL.revokeObjectURL(a.href);
})
.catch(() => message.error('导出失败'));
const openSeatEdit = (seat: TeamSeat) => {
seatForm.setFieldsValue({ userId: seat.userId, monthlyAllocatedCredits: seat.monthlyAllocatedCredits });
setSeatModal({ open: true, subscriptionId: seat.subscriptionId, seat });
};
const saveSeat = async () => {
try {
const values = await seatForm.validateFields();
if (seatModal.seat) await updateTeamSeat(seatModal.seat.id, Number(values.monthlyAllocatedCredits));
else await createTeamSeat(String(seatModal.subscriptionId), String(values.userId), Number(values.monthlyAllocatedCredits));
message.success(seatModal.seat ? '席位额度已更新' : '席位已创建');
setSeatModal({ open: false }); await loadBase();
} catch (e: any) { if (!e?.errorFields) message.error(e?.message || '席位保存失败'); }
};
const removeSeat = async (seatId: string) => {
try { await cancelTeamSeat(seatId); message.success('席位已取消,未使用额度已释放'); await loadBase(); }
catch (e: any) { message.error(e?.message || '席位取消失败'); }
};
/* ── 表格列定义 ──────────────────────────────────────── */
const memberColumns = [
{ title: '用户名', dataIndex: 'username', width: 140, render: (v: string) => <Typography.Text strong>{v}</Typography.Text> },
{ title: '手机号', dataIndex: 'phone', width: 130, render: (v: string) => v || '-' },
{ title: '积分', dataIndex: 'credits', width: 100, render: (v: number) => <Typography.Text style={{ color: '#6366f1' }}>{(v ?? 0).toFixed(2)}</Typography.Text> },
{ title: '状态', dataIndex: 'isActive', width: 80, render: (v: boolean) => <Tag color={v ? 'green' : 'red'}>{v ? '启用' : '禁用'}</Tag> },
{ title: '加入时间', dataIndex: 'joinedAt', width: 170, render: (v: string) => formatDateTime(v) },
{
title: '操作', key: 'action', width: 100,
render: () => (
<Tooltip title="当前版本积分暂未开放团队转账功能">
<Button size="small" type="link" style={{ padding: 0, color: '#999' }} disabled></Button>
</Tooltip>
),
},
{ title: '成员', dataIndex: 'username', render: (v: string, r: TeamMember) => <Space><span>{v}</span>{r.id === team?.managerId && <Tag color="purple"></Tag>}</Space> },
{ title: '手机号', dataIndex: 'phone', render: (v: string) => v || '-' },
{ title: '个人积分', dataIndex: 'personalCredits', align: 'right' as const, render: (v: number) => Number(v || 0).toLocaleString() },
{ title: '团队可用', dataIndex: 'teamAvailableCredits', align: 'right' as const, render: (v: number) => Number(v || 0).toLocaleString() },
{ title: '团队冻结', dataIndex: 'teamFrozenCredits', align: 'right' as const, render: (v: number) => Number(v || 0).toLocaleString() },
{ title: '操作', width: 110, render: (_: any, r: TeamMember) => r.id !== team?.managerId ? <Popconfirm title="确认将该成员设为新队长?" description="仅当所有团队订阅均已结束且没有未完成团队订单时可操作。" onConfirm={async () => { try { await transferTeamManager(r.id); message.success('团队队长已更换'); await loadBase(); } catch (e: any) { message.error(e?.message || '更换队长失败'); } }}><Button type="link" disabled={readOnly} icon={<UserSwitchOutlined />}></Button></Popconfirm> : '-' },
];
const buildInviteLink = (code: string) => {
const base = window.location.origin;
return `${base}/join-team?code=${code}`;
};
const invColumns = [
{ title: '邀请码', dataIndex: 'code', width: 200, render: (v: string) => <Typography.Text copyable style={{ fontFamily: 'monospace' }}>{v}</Typography.Text> },
{
title: '邀请链接', dataIndex: 'code',
render: (v: string) => {
const link = buildInviteLink(v);
return (
<Space style={{ width: '100%' }}>
<Typography.Text
style={{
flex: 1,
fontSize: 12,
wordBreak: 'break-all',
fontFamily: 'monospace',
color: '#64748b',
}}
>
{link}
</Typography.Text>
<Tooltip title="复制链接">
<Button
size="small"
type="text"
icon={<CopyOutlined />}
onClick={() => copyInviteLink(link)}
/>
</Tooltip>
</Space>
);
},
},
{ title: '状态', dataIndex: 'status', width: 80, render: (v: string) => <Tag color={v === 'active' ? 'green' : 'default'}>{v === 'active' ? '有效' : '已撤销'}</Tag> },
{ title: '使用次数', key: 'uses', width: 100, render: (_: any, r: TeamInvitation) => `${r.useCount}${r.maxUses ? `/${r.maxUses}` : ''}` },
{ title: '过期时间', dataIndex: 'expiresAt', width: 170, render: (v: string) => v ? formatDateTime(v) : '永不过期' },
{
title: '操作', key: 'action', width: 80,
render: (_: any, r: TeamInvitation) => r.status === 'active' ? (
<Button size="small" type="link" danger onClick={() => handleRevoke(r.id)}></Button>
) : null,
},
const seatColumns = (subscription: TeamSubscriptionManage) => [
{ title: '成员', dataIndex: 'username', render: (v: string) => v || '-' },
{ title: '月分配额度', dataIndex: 'monthlyAllocatedCredits', align: 'right' as const },
{ title: '本期净消耗', dataIndex: 'currentPeriodUsedCredits', align: 'right' as const },
{ title: '本期剩余额度', dataIndex: 'currentPeriodRemainingCredits', align: 'right' as const },
{ title: '状态', dataIndex: 'statusLabel', render: (v: string) => <Tag>{v || '其他状态'}</Tag> },
{ title: '操作', width: 140, render: (_: any, seat: TeamSeat) => seat.status === 'active' ? <Space><Button size="small" onClick={() => openSeatEdit(seat)} disabled={readOnly}></Button><Popconfirm title="确认取消席位?" onConfirm={() => removeSeat(seat.id)}><Button size="small" danger disabled={readOnly}></Button></Popconfirm></Space> : '-' },
];
const renderStatusTag = (status: string) => {
const map: Record<string, { color: string; text: string }> = {
pending: { color: 'orange', text: '待处理' },
approved: { color: 'green', text: '已通过' },
rejected: { color: 'red', text: '已拒绝' },
};
const info = map[status] || { color: 'default', text: status };
return <Tag color={info.color}>{info.text}</Tag>;
};
const subscriptionView = subscriptions.length === 0 ? <Empty description="暂无团队订阅" /> : <Space direction="vertical" size={16} style={{ width: '100%' }}>
{subscriptions.map((item) => {
const sub = item.subscription || {};
return <Card key={sub.id} title={<Space><WalletOutlined /><span>{sub.productNameSnapshot || '团队订阅套餐'}</span><Tag>{sub.billingCycleLabel || '订阅周期'}</Tag><Tag color={sub.status === 'active' ? 'green' : 'default'}>{sub.statusLabel || '其他状态'}</Tag></Space>} extra={<Button icon={<PlusOutlined />} disabled={readOnly || sub.status !== 'active' || item.activeSeatCount >= item.seatLimit} onClick={() => openSeatCreate(sub.id)}></Button>}>
<Row gutter={[12, 12]} style={{ marginBottom: 12 }}>
<Col span={6}><b>{item.activeSeatCount}/{item.seatLimit}</b></Col><Col span={6}><b>{Number(item.periodTotalCredits || 0).toLocaleString()}</b></Col>
<Col span={6}><b>{Number(item.periodUnspentCredits || 0).toLocaleString()}</b></Col><Col span={6}><b>{Number(item.periodUnallocatedCredits || 0).toLocaleString()}</b></Col>
<Col span={12}><Typography.Text copyable={sub.subscriptionNo ? { text: sub.subscriptionNo } : false}>{sub.subscriptionNo || '-'}</Typography.Text></Col><Col span={12}>{fmt(sub.expiresAt)}</Col>
<Col span={24}>{fmt(item.currentPeriodStartAt)} {fmt(item.currentPeriodExpiresAt)}</Col>
</Row>
<Table rowKey="id" size="small" pagination={false} columns={seatColumns(item)} dataSource={item.seats || []} scroll={{ x: 850 }} />
</Card>;
})}
</Space>;
const reqColumns = [
{ title: '申请人', dataIndex: 'username', width: 140, render: (v: string) => <Typography.Text strong>{v}</Typography.Text> },
{ title: '手机号', dataIndex: 'phone', width: 130, render: (v: string) => v || '-' },
{ title: '状态', dataIndex: 'status', width: 100, render: (v: string) => renderStatusTag(v) },
{ title: '申请时间', dataIndex: 'createdAt', width: 170, render: (v: string) => formatDateTime(v) },
{ title: '处理时间', dataIndex: 'handledAt', width: 170, render: (v: string) => v ? formatDateTime(v) : '-' },
{ title: '备注', dataIndex: 'note', width: 180, ellipsis: true, render: (v: string) => v || '-' },
{
title: '操作', key: 'action', width: 160,
render: (_: any, r: TeamJoinRequest) => {
if (r.status !== 'pending') return <span style={{ color: '#94a3b8', fontSize: 12 }}></span>;
return (
<Space size={4}>
<Button size="small" type="link" icon={<CheckOutlined />} style={{ color: '#16a34a', padding: 0 }} onClick={() => handleRequest(r.id, 'approve')}></Button>
<Button size="small" type="link" icon={<CloseOutlined />} danger style={{ padding: 0 }} onClick={() => {
Modal.confirm({
title: '拒绝申请',
content: (
<Form layout="vertical" style={{ marginTop: 12 }}>
<Form.Item name="note" label="拒绝原因(可选)">
<Input.TextArea rows={2} placeholder="选填" id="reject-note-input" />
</Form.Item>
</Form>
),
onOk: () => {
const note = (document.getElementById('reject-note-input') as HTMLTextAreaElement)?.value || undefined;
handleRequest(r.id, 'reject', note);
},
});
}}></Button>
</Space>
);
},
},
const invitationColumns = [
{ title: '邀请码', dataIndex: 'code', render: (v: string) => <Typography.Text copyable>{v}</Typography.Text> },
{ title: '状态', dataIndex: 'status', render: (v: string) => <Tag>{v === 'active' ? '有效' : '已撤销'}</Tag> },
{ title: '使用次数', render: (_: any, r: TeamInvitation) => `${r.useCount || 0}${r.maxUses ? ` / ${r.maxUses}` : ''}` },
{ title: '过期时间', dataIndex: 'expiresAt', render: (v: string) => v ? fmt(v) : '长期有效' },
{ title: '操作', render: (_: any, r: TeamInvitation) => r.status === 'active' ? <Button type="link" danger disabled={readOnly} onClick={async () => { try { await revokeInvitation(r.id); await loadBase(); message.success('邀请码已撤销'); } catch (e: any) { message.error(e?.message || '撤销失败'); } }}></Button> : '-' },
];
const creditColumns = [
{ title: '用户名', dataIndex: 'username', width: 120, render: (v: string) => <Typography.Text strong>{v || '-'}</Typography.Text> },
{ title: '手机号', dataIndex: 'phone', width: 120, render: (v: string) => v || '-' },
{
title: '类型', dataIndex: 'type', width: 100,
render: (_: any, r: any) => {
const cfg = RECORD_TYPE_CONFIG[r.type] || { color: 'default', label: r.type || '-' };
return <Tag color={cfg.color}>{cfg.label}</Tag>;
},
},
{
title: '变动积分', dataIndex: 'amount', width: 110, align: 'right' as const,
render: (_: any, r: any) => (
<Typography.Text strong style={{ color: r.amount >= 0 ? '#10b981' : '#ef4444', fontSize: 14 }}>
{r.amount >= 0 ? '+' : ''}{(r.amount ?? 0).toFixed(2)}
</Typography.Text>
),
},
{ title: '余额', dataIndex: 'balanceAfter', width: 100, align: 'right' as const, render: (v: number) => (v ?? 0).toFixed(2) },
{ title: '说明', dataIndex: 'description', ellipsis: true, minWidth: 160, render: (v: string) => v || '-' },
{ title: '时间', dataIndex: 'createdAt', width: 170, render: (v: string) => formatDateTime(v) },
const requestColumns = [
{ title: '申请人', dataIndex: 'username' }, { title: '手机号', dataIndex: 'phone', render: (v: string) => v || '-' },
{ title: '状态', dataIndex: 'status', render: (v: string) => <Tag>{({ pending: '待审批', approved: '已通过', rejected: '已拒绝' } as any)[v] || '其他状态'}</Tag> },
{ title: '申请时间', dataIndex: 'createdAt', render: (v: string) => fmt(v) },
{ title: '操作', render: (_: any, r: TeamJoinRequest) => r.status === 'pending' ? <Space><Button size="small" type="primary" disabled={readOnly} onClick={async () => { try { await handleJoinRequest(r.id, 'approve'); await loadBase(); message.success('已通过申请'); } catch (e: any) { message.error(e?.message || '操作失败'); } }}></Button><Button size="small" danger disabled={readOnly} onClick={async () => { try { await handleJoinRequest(r.id, 'reject'); await loadBase(); message.success('已拒绝申请'); } catch (e: any) { message.error(e?.message || '操作失败'); } }}></Button></Space> : '-' },
];
/* ── Tab 配置 ────────────────────────────────────────── */
const tableWrapper: React.CSSProperties = { borderRadius: 16, background: '#fff', border: '1px solid #f0f0f5', overflow: 'hidden' };
const paginationStyle: React.CSSProperties = { padding: '16px', textAlign: 'right' };
const tabItems = [
{
key: 'members',
label: <Space><UserOutlined />{membersTotal > 0 && <span style={{ color: '#94a3b8', fontSize: 12 }}>({membersTotal})</span>}</Space>,
children: (
<div style={tableWrapper}>
<Table
columns={memberColumns}
dataSource={members}
rowKey="id"
loading={membersLoading}
pagination={false}
bordered={false}
scroll={{ x: 800 }}
locale={{ emptyText: <Empty description="暂无成员" /> }}
/>
{membersTotal > 0 && (
<div style={paginationStyle}>
<Pagination current={membersPage} pageSize={20} total={membersTotal} onChange={(p) => setMembersPage(p)} size="small" />
</div>
)}
</div>
),
},
{
key: 'credits',
label: <Space><WalletOutlined /></Space>,
children: (
<div>
{/* 搜索栏 */}
<div style={{ marginBottom: 12, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<Select
value={creditFilterType || undefined}
onChange={(v) => { setCreditFilterType(v || ''); setCreditPage(1); }}
allowClear
placeholder="交易类型"
style={{ width: 130 }}
options={[
{ value: 'recharge', label: '充值' },
{ value: 'consume', label: '消费' },
{ value: 'team_internal', label: '团队内部' },
{ value: 'refund', label: '退款' },
]}
/>
<Input
placeholder="搜索手机号"
value={creditFilterPhone}
onChange={(e) => { setCreditFilterPhone(e.target.value); setCreditPage(1); }}
style={{ width: 160 }}
allowClear
/>
<RangePicker
value={creditDateRange ? [dayjs(creditDateRange[0]), dayjs(creditDateRange[1])] : undefined}
onChange={(dates) => {
if (dates && dates[0] && dates[1]) {
setCreditDateRange([dates[0].format('YYYY-MM-DD'), dates[1].format('YYYY-MM-DD')]);
} else {
setCreditDateRange(null);
}
setCreditPage(1);
}}
/>
<Button onClick={resetCreditFilters}></Button>
<Button type="primary" icon={<DownloadOutlined />} onClick={handleExportCredits}> Excel</Button>
</div>
{/* 汇总统计:净消耗 = 消费 - 退款,同时展示消费 / 退款 / 充值辅助详情 */}
<div style={{ marginBottom: 12, padding: '12px 18px', background: '#f8f9fc', borderRadius: 10, display: 'flex', gap: 28, flexWrap: 'wrap', fontSize: 13, alignItems: 'center' }}>
<span>
<strong style={{ color: '#ef4444', fontSize: 16, marginLeft: 4 }}>
{creditSummary?.netConsume ?? 0}
</strong>
</span>
<span style={{ color: '#94a3b8' }}>|</span>
<span>
<strong style={{ color: '#f97316', marginLeft: 4 }}>
{creditSummary?.totalConsume ?? 0}
</strong>
</span>
<span>
退
<strong style={{ color: '#10b981', marginLeft: 4 }}>
{creditSummary?.totalRefund ?? 0}
</strong>
</span>
<span>
<strong style={{ color: '#6366f1', marginLeft: 4 }}>
+ {creditSummary?.totalRecharge ?? 0}
</strong>
</span>
</div>
<div style={tableWrapper}>
<Table
rowKey="id"
loading={creditLoading}
dataSource={creditRecords}
pagination={false}
bordered={false}
scroll={{ x: 950 }}
columns={creditColumns}
locale={{ emptyText: <Empty description="暂无积分记录" /> }}
/>
{creditTotal > 0 && (
<div style={paginationStyle}>
<Pagination current={creditPage} pageSize={10} total={creditTotal} onChange={(p) => setCreditPage(p)} size="small" showTotal={(t) => `${t}`} />
</div>
)}
</div>
</div>
),
},
{
key: 'invitations',
label: <Space><CopyOutlined /></Space>,
children: (
<div style={tableWrapper}>
<div style={{ padding: 16, paddingBottom: 0 }}>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setInvModal(true)}></Button>
</div>
<Table
columns={invColumns}
dataSource={invitations}
rowKey="id"
loading={invLoading}
pagination={false}
bordered={false}
scroll={{ x: 900 }}
locale={{ emptyText: <Empty description="暂无邀请码" /> }}
/>
</div>
),
},
{
key: 'requests',
label: <Space><HistoryOutlined />{requests.length > 0 && reqStatusFilter === 'pending' && <Tag color="red">{requests.length}</Tag>}</Space>,
children: (
<>
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center' }}>
<Segmented
value={reqStatusFilter}
onChange={(v) => {
setReqStatusFilter(v as string);
loadRequests(v as string);
}}
options={[
{ label: '待处理', value: 'pending' },
{ label: '已通过', value: 'approved' },
{ label: '已拒绝', value: 'rejected' },
{ label: '全部', value: '' },
]}
style={{
borderRadius: 12,
border: '1px solid #e2e8f0',
padding: 3,
background: '#f8fafc',
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
'--ant-segmented-item-selected-bg': '#6366f1',
'--ant-segmented-item-selected-color': '#ffffff',
} as React.CSSProperties}
size="middle"
/>
</div>
<div style={tableWrapper}>
<Table
columns={reqColumns}
dataSource={requests}
rowKey="id"
loading={reqLoading}
pagination={false}
bordered={false}
scroll={{ x: 1000 }}
locale={{ emptyText: <Empty description={reqStatusFilter === 'pending' ? '暂无待审批申请' : '暂无记录'} /> }}
/>
</div>
</>
),
},
const flowColumns = [
{ title: '成员', dataIndex: 'username', width: 120 }, { title: '类型', dataIndex: 'recordTypeLabel', width: 110, render: (v: string) => <Tag>{v || '其他'}</Tag> },
{ title: '团队积分变动', dataIndex: 'teamAmount', width: 130, align: 'right' as const, render: (v: number) => <Typography.Text strong style={{ color: Number(v) < 0 ? '#ef4444' : '#10b981' }}>{Number(v) > 0 ? '+' : ''}{Number(v || 0)}</Typography.Text> },
{ title: '说明', dataIndex: 'description', ellipsis: true }, { title: '订阅实例', dataIndex: 'subscriptionNo', width: 190, render: (v: string) => <Typography.Text copyable={v && v !== '历史订阅' ? { text: v } : false}>{v || '历史订阅'}</Typography.Text> },
{ title: '周期ID', dataIndex: 'subscriptionPeriodId', width: 180, ellipsis: true }, { title: '席位ID', dataIndex: 'seatId', width: 180, ellipsis: true },
{ title: '时间', dataIndex: 'createdAt', width: 180, render: (v: string) => fmt(v) },
];
/* ── 渲染 ────────────────────────────────────────────── */
return (
<div style={{ padding: 24 }}>
{/* 顶部团队信息 */}
<div style={{ marginBottom: 24 }}>
{teamLoading ? (
<Typography.Text type="secondary">...</Typography.Text>
) : team ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12 }}>
<div>
<Typography.Title level={4} style={{ margin: 0 }}>{team.name}</Typography.Title>
<Typography.Text type="secondary">: {team.code || '-'} : {team.memberCount} </Typography.Text>
</div>
<Button icon={<ReloadOutlined />} onClick={() => { loadTeam(); loadMembers(); loadInvitations(); loadRequests(reqStatusFilter); loadCreditRecords(); }}></Button>
</div>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</div>
const currentOnlyTabs = currentManager ? [
{ key: 'subscriptions', label: '订阅与席位', children: subscriptionView },
{ key: 'members', label: '团队成员', children: <><Table rowKey="id" columns={memberColumns} dataSource={members} pagination={false} scroll={{ x: 850 }} /><Pagination style={{ marginTop: 12, textAlign: 'right' }} current={memberPage} pageSize={20} total={memberTotal} onChange={setMemberPage} /></> },
{ key: 'usage', label: '成员月消耗', children: usage.length ? <Table
rowKey={(r) => `${r.userId}-${r.subscriptionPeriodId}`}
pagination={false}
dataSource={usage}
scroll={{ x: 1400 }}
columns={[
{ title: '成员', dataIndex: 'username', width: 120 },
{ title: '订阅实例', dataIndex: 'subscriptionNo', width: 190, render: (v: string) => <Typography.Text copyable={v && v !== '历史订阅' ? { text: v } : false}>{v || '历史订阅'}</Typography.Text> },
{ title: '套餐名称', dataIndex: 'subscriptionName', width: 180, render: (v: string) => <Typography.Text strong>{v || '历史团队订阅'}</Typography.Text> },
{
title: '等级编码',
width: 190,
render: (_: any, r: TeamMemberUsage) => <span>
{r.tierLabel || '未知等级'}{r.tierCode ? `${r.tierCode}` : ''}{r.tierRank ? ` / ${r.tierRank}` : ''}
</span>,
},
{ title: '套餐周期', dataIndex: 'billingCycleLabel', width: 110, render: (v: string) => <Tag>{v || '未知周期'}</Tag> },
{ title: '月度周期', dataIndex: 'periodLabel', width: 110, render: (v: string, r: TeamMemberUsage) => v || (r.periodSequence > 0 ? `${r.periodSequence}个月` : '历史周期') },
{
title: '周期时间',
width: 320,
render: (_: any, r: TeamMemberUsage) => `${fmt(r.periodStartAt)} ${fmt(r.periodExpiresAt)}`,
},
{ title: '本期净消耗', dataIndex: 'consumedCredits', width: 130, align: 'right' as const, render: (v: number) => Number(v || 0).toLocaleString() },
]}
/> : <Empty description="暂无团队积分消耗" /> },
{ key: 'invite', label: '邀请与审批', children: <Space direction="vertical" style={{ width: '100%' }} size={18}><Button type="primary" icon={<PlusOutlined />} disabled={readOnly} onClick={() => setInviteModal(true)}></Button><Table rowKey="id" size="small" pagination={false} columns={invitationColumns} dataSource={invitations} /><Typography.Title level={5}></Typography.Title><Table rowKey="id" size="small" pagination={false} columns={requestColumns} dataSource={requests} /></Space> },
{ key: 'history', label: '队长任期', children: history.length ? <Table rowKey="id" pagination={false} dataSource={history} columns={[{ title: '队长', dataIndex: 'managerName' }, { title: '任期开始', dataIndex: 'startedAt', render: (v) => fmt(v) }, { title: '任期结束', dataIndex: 'endedAt', render: (v) => v ? fmt(v) : <Tag color="green"></Tag> }]} /> : <Empty description="暂无任期记录" /> },
] : [];
{/* 标签页 */}
<Tabs items={tabItems} activeKey={activeTab} onChange={setActiveTab} size="large" />
const flowTab = { key: 'flow', label: '团队积分流水', children: <>
<Space wrap style={{ marginBottom: 12 }}>
<Select style={{ width: 220 }} value={selectedFlowTeamId} onChange={(v) => { setSelectedFlowTeamId(v); setFlowPage(1); }} options={accessTeams.map((t) => ({ value: t.id, label: `${t.name}${t.isCurrentManager ? '(当前队长)' : '(历史任期)'}` }))} />
<Input allowClear placeholder="成员手机号" style={{ width: 150 }} value={flowPhone} onChange={(e) => setFlowPhone(e.target.value)} />
<Select allowClear placeholder="流水类型" style={{ width: 130 }} value={flowType} onChange={(v) => { setFlowType(v); setFlowPage(1); }} options={[{ value: 'recharge', label: '发放/充值' }, { value: 'consume', label: '消费' }, { value: 'refund', label: '业务退款' }, { value: 'expire', label: '积分过期' }, { value: 'revoke', label: '积分撤销' }]} />
{currentManager && <Select allowClear placeholder="团队订阅" style={{ width: 220 }} value={flowSubscriptionId} onChange={(v) => { setFlowSubscriptionId(v); setFlowPage(1); }} options={subscriptions.map((s) => ({ value: s.subscription.id, label: `${s.subscription.subscriptionNo || '未编号'} · ${s.subscription.productNameSnapshot || '团队订阅套餐'}` }))} />}
<RangePicker value={flowDates} onChange={(v) => { setFlowDates(v || [null, null]); setFlowPage(1); }} />
<Button icon={<ReloadOutlined />} onClick={loadFlow}></Button>
<Button icon={<DownloadOutlined />} onClick={() => { const url = getTeamCreditExportUrl({ teamId: selectedFlowTeamId, phone: flowPhone || undefined, subscriptionId: flowSubscriptionId, recordType: flowType, startDate: flowDates[0]?.format?.('YYYY-MM-DD'), endDate: flowDates[1]?.format?.('YYYY-MM-DD') }); window.open(url, '_blank'); }}></Button>
</Space>
<Table rowKey="id" columns={flowColumns} dataSource={flow.items || []} pagination={false} scroll={{ x: 1200 }} />
<Pagination style={{ marginTop: 12, textAlign: 'right' }} current={flowPage} pageSize={20} total={Number(flow.total || 0)} onChange={setFlowPage} />
</> };
if (!team && accessTeams.length === 0 && !loading) return <Empty description="您当前不是团队队长,也没有可查看的历史队长任期" />;
{/* 生成邀请码弹窗 */}
<Modal
title={<Space><CopyOutlined /></Space>}
open={invModal}
confirmLoading={invSaving}
onOk={handleCreateInvitation}
onCancel={() => { setInvModal(false); invForm.resetFields(); }}
okText="生成"
width={480}
>
<Form form={invForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="maxUses" label="最大使用次数">
<InputNumber style={{ width: '100%' }} min={1} placeholder="留空表示不限" size="large" />
</Form.Item>
<div style={{
padding: '12px 16px',
background: '#eef2ff',
borderRadius: 8,
fontSize: 13,
color: '#4f46e5',
display: 'flex',
alignItems: 'center',
gap: 8,
}}>
<ClockCircleOutlined />
<span> <strong>24 </strong> </span>
</div>
</Form>
</Modal>
</div>
);
return <div>
{team && <Card loading={loading} style={{ marginBottom: 16 }}><Row align="middle" justify="space-between"><Col><Space><TeamOutlined /><Typography.Title level={4} style={{ margin: 0 }}>{team.name}</Typography.Title><Tag color={team.status === 'active' ? 'green' : 'red'}>{team.statusLabel || (team.status === 'active' ? '启用' : '禁用')}</Tag></Space><div style={{ color: '#64748b', marginTop: 8 }}> {team.memberCount} · {team.managerName || '-'}</div></Col><Col>{readOnly && <Tag icon={<LockOutlined />} color="red"></Tag>}</Col></Row></Card>}
{readOnly && <Alert type="warning" showIcon message="团队业务已冻结" description="团队订阅仍会正常发放积分、自然滚期和过期;当前团队积分显示为冻结且不会参与结算。团队重新启用后,仅恢复届时仍有效的团队积分。" style={{ marginBottom: 16 }} />}
{!currentManager && <Alert type="info" showIcon message="历史队长只读权限" description="您可以查看自己担任队长任期内的整个团队积分流水,但没有当前团队管理权限。" style={{ marginBottom: 16 }} />}
<Tabs items={[...currentOnlyTabs, flowTab]} />
<Modal open={seatModal.open} title={seatModal.seat ? '修改席位月额度' : '分配团队订阅席位'} onCancel={() => setSeatModal({ open: false })} onOk={saveSeat} okText="确认保存" cancelText="取消">
<Form form={seatForm} layout="vertical">
{!seatModal.seat && <Form.Item name="userId" label="团队成员" rules={[{ required: true, message: '请选择团队成员' }]}><Select showSearch optionFilterProp="label" options={members.map((m) => ({ value: m.id, label: `${m.username}${m.id === team?.managerId ? '(队长)' : ''}` }))} /></Form.Item>}
<Form.Item name="monthlyAllocatedCredits" label="每周期可消费团队积分" rules={[{ required: true, message: '请输入席位额度' }]} extra="额度必须大于0;修改低于已用额度时,本期剩余额度按0处理,不追回已完成消费。"><InputNumber min={0.01} precision={2} style={{ width: '100%' }} /></Form.Item>
</Form>
</Modal>
<Modal open={inviteModal} title="创建团队邀请码" onCancel={() => setInviteModal(false)} onOk={async () => { try { const v = await inviteForm.validateFields(); const inv = await createTeamInvitation(v.maxUses, v.expiresAt?.toISOString()); setInviteModal(false); inviteForm.resetFields(); await loadBase(); Modal.success({ title: '邀请码已创建', content: <Typography.Text copyable>{inv.code}</Typography.Text> }); } catch (e: any) { if (!e?.errorFields) message.error(e?.message || '创建邀请码失败'); } }} okText="创建" cancelText="取消">
<Form form={inviteForm} layout="vertical"><Form.Item name="maxUses" label="最大使用次数"><InputNumber min={1} precision={0} style={{ width: '100%' }} placeholder="留空表示不限制" /></Form.Item><Form.Item name="expiresAt" label="过期时间"><DatePicker showTime style={{ width: '100%' }} disabledDate={(d) => d && d.isBefore(dayjs().startOf('day'))} /></Form.Item></Form>
</Modal>
</div>;
};
export default TeamManagementPage;
+113 -20
View File
@@ -34,6 +34,9 @@ export interface TeamMember {
username: string;
phone?: string | null;
credits: number;
personalCredits?: number;
teamAvailableCredits?: number;
teamFrozenCredits?: number;
isActive: boolean;
joinedAt: string;
}
@@ -45,7 +48,7 @@ export interface TeamInvitation {
maxUses?: number | null;
useCount: number;
expiresAt?: string | null;
inviteLink: string;
inviteLink?: string;
createdAt?: string | null;
}
@@ -67,15 +70,27 @@ export interface ManagedTeam {
name: string;
code?: string | null;
description?: string | null;
status: string;
status: 'active' | 'disabled' | string;
statusLabel?: string;
isReadOnly?: boolean;
teamCreditFrozen?: boolean;
memberCount: number;
managerId?: string | null;
managerName?: string | null;
firstSubscriptionPaidAt?: string | null;
}
export interface ManagerAccessTeam {
teamId: string;
teamName: string;
isCurrentManager: boolean;
startedAt: string;
endedAt?: string | null;
}
export interface JoinTeamInfo {
teamName: string;
teamId: string;
teamName: string;
valid: boolean;
alreadyInTeam: boolean;
hasPendingRequest: boolean;
@@ -84,12 +99,16 @@ export interface JoinTeamInfo {
export interface CreditRecord {
id: string;
type: 'consume' | 'recharge' | 'refund' | 'expire' | 'revoke' | 'team_internal';
typeLabel?: string;
amount: number;
personalAmount?: number;
teamAmount?: number;
balanceDelta?: number;
expiredAmount?: number;
balanceAfter?: number;
description: string;
billingScene?: string | null;
billingSceneLabel?: string | null;
sceneNameSnapshot?: string | null;
inputTokens?: number | null;
outputTokens?: number | null;
@@ -527,22 +546,28 @@ export interface UploadResourceHistoryDayItems {
// ── Dynamic credits and products ──────────────────────────
export interface CreditBalanceSummary {
credits: number;
available_credits: number;
next_expiring_credits: number;
next_expires_at?: string | null;
next_last_usable_at?: string | null;
availableCredits: number;
personalCredits: number;
teamAvailableCredits: number;
teamFrozenCredits: number;
nextExpiringCredits: number;
nextExpiresAt?: string | null;
nextLastUsableAt?: string | null;
}
export interface CreditProduct {
id: string;
productCode: string;
productType: 'subscription' | 'credit_addon';
productType: 'subscription' | 'team_subscription' | 'credit_addon';
productTypeLabel?: string;
name: string;
description?: string | null;
features: string[];
tierCode?: string | null;
tierLabel?: string | null;
tierRank?: number | null;
billingCycle?: 'monthly' | 'quarterly' | 'yearly' | null;
billingCycleLabel?: string | null;
monthlyGrantCredits: number;
grantCount: number;
firstPurchasePrice: number;
@@ -555,37 +580,105 @@ export interface CreditProduct {
validityMonths?: number | null;
price: number;
currentPrice: number;
targetPrice?: number | null;
deductionAmount?: number;
priceType?: string | null;
priceTypeLabel?: string | null;
creditLevel: string;
creditLevelLabel?: string;
currency: string;
isActive: boolean;
isDeleted?: boolean;
deletedAt?: string | null;
statusLabel?: string;
sortOrder: number;
canPurchase?: boolean | null;
canUpgrade: boolean;
unavailableReason?: string | null;
}
export interface CurrentCreditSubscription {
export interface ActiveCreditSubscription {
id: string;
productId: string;
subscriptionNo: string;
productId?: string | null;
productName: string;
productType: 'subscription' | 'team_subscription';
status: string;
purchaseScene: string;
statusLabel?: string;
billingCycle: 'monthly' | 'quarterly' | 'yearly';
billingCycleLabel?: string;
tierCode: string;
tierRank: number;
billingCycle: 'monthly' | 'quarterly' | 'yearly';
anchorAt: string;
startAt: string;
expiresAt: string;
monthlyGrantCredits: number;
grantCount: number;
grantedCount: number;
paidAmount: number;
}
export interface CreditProductCatalog {
subscriptionProducts: CreditProduct[];
teamSubscriptionProducts: CreditProduct[];
creditAddons: CreditProduct[];
firstPurchaseAvailable: boolean;
currentSubscription?: CurrentCreditSubscription | null;
personalFirstPurchaseAvailable: boolean;
teamFirstPurchaseAvailable: boolean;
activePersonalSubscriptions: ActiveCreditSubscription[];
personalEntitlement?: any | null;
teamEntitlement?: any | null;
teamPurchaseAvailable: boolean;
teamPurchaseUnavailableReason?: string | null;
}
export interface TeamSeat {
id: string;
teamId: string;
subscriptionId: string;
userId: string;
username?: string | null;
monthlyAllocatedCredits: number;
currentPeriodId?: string | null;
currentPeriodUsedCredits: number;
currentPeriodRemainingCredits: number;
status: string;
statusLabel: string;
createdAt: string;
cancelledAt?: string | null;
}
export interface TeamSubscriptionManage {
subscription: any;
currentPeriodId?: string | null;
currentPeriodStartAt?: string | null;
currentPeriodExpiresAt?: string | null;
periodTotalCredits: number;
periodUnspentCredits: number;
periodUnallocatedCredits: number;
seatLimit: number;
activeSeatCount: number;
seats: TeamSeat[];
}
export interface TeamMemberUsage {
userId: string;
username?: string | null;
// ID 仅用于内部关联/rowKey,不直接展示给用户。
subscriptionId: string;
subscriptionNo: string;
subscriptionPeriodId: string;
subscriptionName: string;
tierCode: string;
tierLabel: string;
tierRank: number;
billingCycle: string;
billingCycleLabel: string;
periodSequence: number;
periodLabel: string;
periodStartAt?: string | null;
periodExpiresAt?: string | null;
consumedCredits: number;
}
export interface TeamManagerHistory {
id: string;
teamId: string;
managerUserId: string;
managerName?: string | null;
startedAt: string;
endedAt?: string | null;
}