Merge branch 'main' of https://gitlab.minzhong.cn/mz/video-gen
This commit is contained in:
Vendored
+149
-149
File diff suppressed because one or more lines are too long
Vendored
+36
-36
@@ -1,37 +1,37 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<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" />
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
<title>后台管理</title>
|
<title>后台管理</title>
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
var cached = localStorage.getItem('siteInfo');
|
var cached = localStorage.getItem('siteInfo');
|
||||||
if (cached) {
|
if (cached) {
|
||||||
try {
|
try {
|
||||||
var info = JSON.parse(cached);
|
var info = JSON.parse(cached);
|
||||||
if (info.siteName) {
|
if (info.siteName) {
|
||||||
document.title = info.siteName + ' - 管理后台';
|
document.title = info.siteName + ' - 管理后台';
|
||||||
}
|
}
|
||||||
if (info.siteLogo) {
|
if (info.siteLogo) {
|
||||||
var link = document.querySelector('link[rel="icon"]');
|
var link = document.querySelector('link[rel="icon"]');
|
||||||
if (link) {
|
if (link) {
|
||||||
link.href = info.siteLogo;
|
link.href = info.siteLogo;
|
||||||
link.type = 'image/png';
|
link.type = 'image/png';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<script type="module" crossorigin src="/assets/index-DIVmQY5H.js"></script>
|
<script type="module" crossorigin src="/assets/index-CTeXmoRP.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-D3fwIbOp.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-D3fwIbOp.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>␍
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -209,6 +209,13 @@ export async function getTeamMembersForAdmin(teamId: string, page = 1, pageSize
|
|||||||
return api.get(`/admin/users?${params.toString()}`);
|
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> {
|
export async function adjustCredits(userId: string, amount: number, description: string): Promise<void> {
|
||||||
await api.post(`/admin/users/${userId}/credits`, { amount, description });
|
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, 'user_type', filters?.userType);
|
||||||
setMaybe(params, 'frontend_user_kind', filters?.frontendUserKind);
|
setMaybe(params, 'frontend_user_kind', filters?.frontendUserKind);
|
||||||
setMaybe(params, 'team_id', filters?.teamId);
|
setMaybe(params, 'team_id', filters?.teamId);
|
||||||
|
setMaybe(params, 'subscription_no', filters?.subscriptionNo);
|
||||||
setMaybe(params, 'record_type', filters?.recordType || filters?.type);
|
setMaybe(params, 'record_type', filters?.recordType || filters?.type);
|
||||||
setMaybe(params, 'credit_subject', filters?.creditSubject);
|
setMaybe(params, 'credit_subject', filters?.creditSubject);
|
||||||
setMaybe(params, 'media_type', filters?.mediaType);
|
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);
|
await api.put('/admin/payment-configs/batch', configs);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPaymentStats(params?: {
|
export async function getPaymentStats(params?: { paymentMethod?: string; orderSource?: string; status?: string; startDate?: string; endDate?: string; }): Promise<any> {
|
||||||
paymentMethod?: string;
|
const q = new URLSearchParams();
|
||||||
status?: string;
|
if (params?.paymentMethod) q.set('payment_method', params.paymentMethod); if (params?.orderSource) q.set('order_source', params.orderSource);
|
||||||
startDate?: string;
|
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);
|
||||||
endDate?: string;
|
return api.get(`/admin/payment-stats${q.toString() ? `?${q.toString()}` : ''}`);
|
||||||
}): 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 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();
|
const qs = new URLSearchParams();
|
||||||
if (params?.method) qs.set('payment_method', params.method);
|
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?.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?.phone) qs.set('phone', params.phone);
|
if (params?.page) qs.set('page', String(params.page)); if (params?.pageSize) qs.set('page_size', String(params.pageSize));
|
||||||
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()}`);
|
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 ───────────────────────────────────
|
// ── Invoice Management ───────────────────────────────────
|
||||||
|
|
||||||
@@ -1371,63 +1360,26 @@ export async function adminUpdatePrivatePortraitConfig(userId: string, limit: nu
|
|||||||
|
|
||||||
// ── Dynamic Credit Products ───────────────────────────────
|
// ── Dynamic Credit Products ───────────────────────────────
|
||||||
function normalizeCreditProduct(raw: CreditProduct): CreditProduct {
|
function normalizeCreditProduct(raw: CreditProduct): CreditProduct {
|
||||||
return {
|
return { ...raw, renewalEnabled: raw.renewalEnabled === true, tierRank: raw.tierRank == null ? null : Number(raw.tierRank), monthlyGrantCredits: Number(raw.monthlyGrantCredits || 0),
|
||||||
...raw,
|
firstPurchasePrice: Number(raw.firstPurchasePrice || 0), regularPrice: Number(raw.regularPrice || 0), activityPrice: raw.activityPrice == null ? null : Number(raw.activityPrice),
|
||||||
renewalEnabled: raw.renewalEnabled === true,
|
price: Number(raw.price || 0), grantCredits: Number(raw.grantCredits || 0), sortOrder: Number(raw.sortOrder || 0), isActive: raw.isActive === true, isDeleted: raw.isDeleted === true };
|
||||||
tierRank: raw.tierRank == null ? null : Number(raw.tierRank),
|
|
||||||
monthlyGrantCredits: Number(raw.monthlyGrantCredits || 0),
|
|
||||||
firstPurchasePrice: Number(raw.firstPurchasePrice || 0),
|
|
||||||
regularPrice: Number(raw.regularPrice || 0),
|
|
||||||
activityPrice: raw.activityPrice == null ? null : Number(raw.activityPrice),
|
|
||||||
price: Number(raw.price || 0),
|
|
||||||
grantCredits: Number(raw.grantCredits || 0),
|
|
||||||
sortOrder: Number(raw.sortOrder || 0),
|
|
||||||
isActive: raw.isActive === true,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
export async function getCreditProducts(productType?: 'subscription' | 'team_subscription' | 'credit_addon'): Promise<CreditProduct[]> {
|
||||||
export async function getCreditProducts(productType?: 'subscription' | 'credit_addon'): Promise<CreditProduct[]> {
|
const query = productType ? `?product_type=${encodeURIComponent(productType)}` : ''; return (await api.get<CreditProduct[]>(`/admin/credit-management/products${query}`)).map(normalizeCreditProduct);
|
||||||
const query = productType ? `?product_type=${encodeURIComponent(productType)}` : '';
|
|
||||||
const products = await api.get<CreditProduct[]>(`/admin/credit-management/products${query}`);
|
|
||||||
return products.map(normalizeCreditProduct);
|
|
||||||
}
|
}
|
||||||
|
export async function createCreditProduct(payload: Record<string, unknown>): Promise<CreditProduct> { return normalizeCreditProduct(await api.post<CreditProduct>('/admin/credit-management/products', payload)); }
|
||||||
export async function createCreditProduct(payload: Record<string, unknown>): Promise<CreditProduct> {
|
export async function updateCreditProduct(id: string, payload: Record<string, unknown>): Promise<CreditProduct> { return normalizeCreditProduct(await api.put<CreditProduct>(`/admin/credit-management/products/${id}`, payload)); }
|
||||||
return normalizeCreditProduct(await api.post<CreditProduct>('/admin/credit-management/products', 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 updateCreditProduct(id: string, payload: Record<string, unknown>): Promise<CreditProduct> {
|
export async function getAdminUserCreditSummary(userId: string): Promise<any> { return api.get(`/admin/credit-management/users/${userId}/summary`); }
|
||||||
return normalizeCreditProduct(await api.put<CreditProduct>(`/admin/credit-management/products/${id}`, payload));
|
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> {
|
||||||
export async function setCreditProductRenewal(id: string, renewalEnabled: boolean): Promise<CreditProduct> {
|
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 });
|
||||||
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 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 场景积分配置 ───────────────────────────────────────
|
// ── LLM 场景积分配置 ───────────────────────────────────────
|
||||||
export async function getLlmBillingPolicies(): Promise<import('../types').LlmBillingPolicy[]> {
|
export async function getLlmBillingPolicies(): Promise<import('../types').LlmBillingPolicy[]> {
|
||||||
|
|||||||
@@ -3,10 +3,17 @@ import {
|
|||||||
Button, Card, DatePicker, Form, Input, InputNumber, message, Modal, Popconfirm,
|
Button, Card, DatePicker, Form, Input, InputNumber, message, Modal, Popconfirm,
|
||||||
Select, Space, Switch, Table, Tabs, Tag, Typography,
|
Select, Space, Switch, Table, Tabs, Tag, Typography,
|
||||||
} from 'antd';
|
} 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 dayjs from 'dayjs';
|
||||||
import {
|
import {
|
||||||
createCreditProduct, disableCreditProduct, getCreditProducts, setCreditProductRenewal, updateCreditProduct,
|
createCreditProduct,
|
||||||
|
getCreditProducts,
|
||||||
|
setCreditProductRenewal,
|
||||||
|
setCreditProductStatus,
|
||||||
|
softDeleteCreditProduct,
|
||||||
|
updateCreditProduct,
|
||||||
} from '../api';
|
} from '../api';
|
||||||
import type { CreditProduct, CreditProductType } from '../types';
|
import type { CreditProduct, CreditProductType } from '../types';
|
||||||
|
|
||||||
@@ -17,6 +24,8 @@ const formatMoney = (value?: number | null): string => {
|
|||||||
return Number.isFinite(amount) ? `¥${amount.toFixed(2)}` : '-';
|
return Number.isFinite(amount) ? `¥${amount.toFixed(2)}` : '-';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isSubscriptionType = (value: CreditProductType) => value === 'subscription' || value === 'team_subscription';
|
||||||
|
|
||||||
const AdminCreditProducts: React.FC = () => {
|
const AdminCreditProducts: React.FC = () => {
|
||||||
const [type, setType] = useState<CreditProductType>('subscription');
|
const [type, setType] = useState<CreditProductType>('subscription');
|
||||||
const [items, setItems] = useState<CreditProduct[]>([]);
|
const [items, setItems] = useState<CreditProduct[]>([]);
|
||||||
@@ -85,7 +94,7 @@ const AdminCreditProducts: React.FC = () => {
|
|||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
billingCycle: 'monthly',
|
billingCycle: 'monthly',
|
||||||
tierRank: 1,
|
tierRank: 1,
|
||||||
renewalEnabled: nextRenewalEnabled,
|
renewalEnabled: true,
|
||||||
validityMonths: 1,
|
validityMonths: 1,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -95,8 +104,6 @@ const AdminCreditProducts: React.FC = () => {
|
|||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
const activityRange = values.activityRange || [];
|
const activityRange = values.activityRange || [];
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
product_code: values.productCode,
|
|
||||||
product_type: type,
|
|
||||||
name: values.name,
|
name: values.name,
|
||||||
description: values.description || null,
|
description: values.description || null,
|
||||||
features: String(values.featuresText || '')
|
features: String(values.featuresText || '')
|
||||||
@@ -105,14 +112,23 @@ const AdminCreditProducts: React.FC = () => {
|
|||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
credit_level: 'general',
|
credit_level: 'general',
|
||||||
currency: 'CNY',
|
currency: 'CNY',
|
||||||
is_active: values.isActive ?? true,
|
|
||||||
sort_order: values.sortOrder ?? 0,
|
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, {
|
Object.assign(payload, {
|
||||||
tier_code: values.tierCode,
|
|
||||||
tier_rank: values.tierRank,
|
|
||||||
billing_cycle: values.billingCycle,
|
|
||||||
monthly_grant_credits: values.monthlyGrantCredits,
|
monthly_grant_credits: values.monthlyGrantCredits,
|
||||||
first_purchase_price: values.firstPurchasePrice,
|
first_purchase_price: values.firstPurchasePrice,
|
||||||
regular_price: values.regularPrice,
|
regular_price: values.regularPrice,
|
||||||
@@ -129,11 +145,16 @@ const AdminCreditProducts: React.FC = () => {
|
|||||||
validity_months: values.validityMonths,
|
validity_months: values.validityMonths,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const savedProduct = editing
|
|
||||||
|
let savedProduct = editing
|
||||||
? await updateCreditProduct(editing.id, payload)
|
? await updateCreditProduct(editing.id, payload)
|
||||||
: await createCreditProduct(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('续费开关保存结果与提交值不一致,请刷新后重试');
|
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: '套餐',
|
title: '套餐',
|
||||||
key: 'name',
|
key: 'name',
|
||||||
@@ -234,36 +275,45 @@ const AdminCreditProducts: React.FC = () => {
|
|||||||
{
|
{
|
||||||
title: '允许续费',
|
title: '允许续费',
|
||||||
dataIndex: 'renewalEnabled',
|
dataIndex: 'renewalEnabled',
|
||||||
render: (enabled: boolean, row: CreditProduct) => (
|
render: (enabled: boolean, row: CreditProduct) => row.isDeleted
|
||||||
<Switch
|
? <Typography.Text type="secondary">-</Typography.Text>
|
||||||
|
: <Switch
|
||||||
checked={enabled === true}
|
checked={enabled === true}
|
||||||
checkedChildren="开启"
|
checkedChildren="开启"
|
||||||
unCheckedChildren="关闭"
|
unCheckedChildren="关闭"
|
||||||
onChange={(checked) => void toggleRenewal(row, checked)}
|
onChange={(checked) => void toggleRenewal(row, checked)}
|
||||||
/>
|
/>,
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'isActive',
|
key: 'status',
|
||||||
render: (enabled: boolean) => <Tag color={enabled ? 'green' : 'default'}>{enabled ? '上架' : '下架'}</Tag>,
|
render: (_: unknown, row: CreditProduct) => <Tag color={row.isDeleted ? 'default' : row.isActive ? 'green' : 'default'}>
|
||||||
|
{row.isDeleted ? '已删除' : row.isActive ? '上架' : '下架'}
|
||||||
|
</Tag>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
key: 'action',
|
key: 'action',
|
||||||
render: (_: unknown, row: CreditProduct) => <Space>
|
render: (_: unknown, row: CreditProduct) => row.isDeleted
|
||||||
<Button type="link" icon={<EditOutlined />} onClick={() => openEditor(row)}>编辑</Button>
|
? <Typography.Text type="secondary">已软删除,不可恢复</Typography.Text>
|
||||||
<Popconfirm
|
: <Space>
|
||||||
title="确认下架该商品?"
|
<Button type="link" icon={<EditOutlined />} onClick={() => openEditor(row)}>编辑</Button>
|
||||||
onConfirm={async () => {
|
<Popconfirm
|
||||||
await disableCreditProduct(row.id);
|
title={`确认${row.isActive ? '下架' : '重新上架'}该商品?`}
|
||||||
message.success('已下架');
|
onConfirm={() => void toggleStatus(row)}
|
||||||
await load();
|
>
|
||||||
}}
|
<Button type="link" danger={row.isActive} icon={row.isActive ? <StopOutlined /> : <CheckCircleOutlined />}>
|
||||||
>
|
{row.isActive ? '下架' : '上架'}
|
||||||
<Button type="link" danger icon={<StopOutlined />}>下架</Button>
|
</Button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</Space>,
|
<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: 'grantCredits', render: (value?: number) => Number(value || 0).toLocaleString() },
|
||||||
{ title: '价格', dataIndex: 'price', render: (value?: number | null) => formatMoney(value) },
|
{ title: '价格', dataIndex: 'price', render: (value?: number | null) => formatMoney(value) },
|
||||||
{ title: '有效期', dataIndex: 'validityMonths', render: (value?: number | null) => `${Number(value || 1)} 个月` },
|
{ 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: '操作',
|
title: '操作',
|
||||||
key: 'action',
|
key: 'action',
|
||||||
render: (_: unknown, row: CreditProduct) => <Space>
|
render: (_: unknown, row: CreditProduct) => row.isDeleted
|
||||||
<Button type="link" icon={<EditOutlined />} onClick={() => openEditor(row)}>编辑</Button>
|
? <Typography.Text type="secondary">已软删除,不可恢复</Typography.Text>
|
||||||
<Popconfirm
|
: <Space>
|
||||||
title="确认下架该商品?"
|
<Button type="link" icon={<EditOutlined />} onClick={() => openEditor(row)}>编辑</Button>
|
||||||
onConfirm={async () => {
|
<Popconfirm
|
||||||
await disableCreditProduct(row.id);
|
title={`确认${row.isActive ? '下架' : '重新上架'}该商品?`}
|
||||||
message.success('已下架');
|
onConfirm={() => void toggleStatus(row)}
|
||||||
await load();
|
>
|
||||||
}}
|
<Button type="link" danger={row.isActive} icon={row.isActive ? <StopOutlined /> : <CheckCircleOutlined />}>
|
||||||
>
|
{row.isActive ? '下架' : '上架'}
|
||||||
<Button type="link" danger icon={<StopOutlined />}>下架</Button>
|
</Button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</Space>,
|
<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
|
return <Card
|
||||||
title={<Space><ShoppingOutlined />积分产品</Space>}
|
title={<Space><ShoppingOutlined />积分产品</Space>}
|
||||||
extra={<Button type="primary" icon={<PlusOutlined />} onClick={() => openEditor()}>
|
extra={<Button type="primary" icon={<PlusOutlined />} onClick={() => openEditor()}>{createButtonLabel}</Button>}
|
||||||
{type === 'subscription' ? '新增订阅套餐' : '新增积分增值包'}
|
|
||||||
</Button>}
|
|
||||||
>
|
>
|
||||||
<Tabs
|
<Tabs
|
||||||
activeKey={type}
|
activeKey={type}
|
||||||
onChange={(key) => setType(key as CreditProductType)}
|
onChange={(key) => setType(key as CreditProductType)}
|
||||||
items={[
|
items={[
|
||||||
{ key: 'subscription', label: '订阅套餐' },
|
{ key: 'subscription', label: '个人订阅套餐' },
|
||||||
|
{ key: 'team_subscription', label: '团队订阅套餐' },
|
||||||
{ key: 'credit_addon', label: '积分增值包' },
|
{ key: 'credit_addon', label: '积分增值包' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -322,7 +388,12 @@ const AdminCreditProducts: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<Form form={form} layout="vertical">
|
<Form form={form} layout="vertical">
|
||||||
<Space align="start" style={{ width: '100%' }} size={16}>
|
<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} />
|
<Input disabled={!!editing} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="name" label="商品名称" rules={[{ required: true }]}>
|
<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="sortOrder" label="排序"><InputNumber /></Form.Item>
|
||||||
<Form.Item name="isActive" label="上架" valuePropName="checked"><Switch /></Form.Item>
|
<Form.Item name="isActive" label="上架" valuePropName="checked"><Switch /></Form.Item>
|
||||||
</Space>
|
</Space>
|
||||||
{type === 'subscription' ? <>
|
{isSubscriptionType(type) ? <>
|
||||||
<Space align="start" style={{ width: '100%' }} size={16}>
|
<Space align="start" style={{ width: '100%' }} size={16}>
|
||||||
<Form.Item name="tierCode" label="套餐等级编码" rules={[{ required: true }]}><Input /></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} /></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 }]}>
|
<Form.Item name="billingCycle" label="订阅周期" rules={[{ required: true }]}>
|
||||||
<Select
|
<Select
|
||||||
|
disabled={!!editing}
|
||||||
style={{ width: 140 }}
|
style={{ width: 140 }}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'monthly', label: '月' },
|
{ value: 'monthly', label: '月' },
|
||||||
@@ -349,7 +421,7 @@ const AdminCreditProducts: React.FC = () => {
|
|||||||
<InputNumber min={0.01} />
|
<InputNumber min={0.01} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Space>
|
</Space>
|
||||||
<Space align="start" size={16}>
|
<Space align="start" size={16} wrap>
|
||||||
<Form.Item name="firstPurchasePrice" label="首充价格" rules={[{ required: true }]}>
|
<Form.Item name="firstPurchasePrice" label="首充价格" rules={[{ required: true }]}>
|
||||||
<InputNumber min={0} precision={2} />
|
<InputNumber min={0} precision={2} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -360,7 +432,7 @@ const AdminCreditProducts: React.FC = () => {
|
|||||||
<Form.Item name="activityRange" label="活动周期"><DatePicker.RangePicker showTime /></Form.Item>
|
<Form.Item name="activityRange" label="活动周期"><DatePicker.RangePicker showTime /></Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="允许续费"
|
label="允许续费"
|
||||||
tooltip="关闭后,已失去首订资格的用户不会在客户端看到该套餐,也不能通过接口续费或升级购买。"
|
tooltip="关闭后,已失去对应首购资格的用户不能再次购买该套餐;不影响已创建待付款订单、已持有订阅和季/年卡内部月度发放。"
|
||||||
>
|
>
|
||||||
<Switch
|
<Switch
|
||||||
checked={renewalEnabled}
|
checked={renewalEnabled}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
import { exportStyledExcel, type StyledExcelColumn } from '../utils/excelExport';
|
import { exportStyledExcel, type StyledExcelColumn } from '../utils/excelExport';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { getCreditRecords, getTeamOptions } from '../api';
|
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';
|
import { formatDate } from '../utils/formatDate';
|
||||||
|
|
||||||
const TEAM_UNASSIGNED_VALUE = '__none__';
|
const TEAM_UNASSIGNED_VALUE = '__none__';
|
||||||
@@ -182,6 +182,44 @@ function buildScope(scope: string): Pick<AdminCreditRecordQueryParams, 'userType
|
|||||||
return {};
|
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 AdminCreditRecords: React.FC = () => {
|
||||||
const [records, setRecords] = useState<AdminCreditRecord[]>([]);
|
const [records, setRecords] = useState<AdminCreditRecord[]>([]);
|
||||||
const [summary, setSummary] = useState<AdminCreditRecordSummary>(DEFAULT_SUMMARY);
|
const [summary, setSummary] = useState<AdminCreditRecordSummary>(DEFAULT_SUMMARY);
|
||||||
@@ -194,6 +232,7 @@ const AdminCreditRecords: React.FC = () => {
|
|||||||
|
|
||||||
const [userScope, setUserScope] = useState('');
|
const [userScope, setUserScope] = useState('');
|
||||||
const [teamFilter, setTeamFilter] = useState('');
|
const [teamFilter, setTeamFilter] = useState('');
|
||||||
|
const [subscriptionNoFilter, setSubscriptionNoFilter] = useState('');
|
||||||
const [teamOptions, setTeamOptions] = useState<AdminTeamOption[]>([]);
|
const [teamOptions, setTeamOptions] = useState<AdminTeamOption[]>([]);
|
||||||
const [recordType, setRecordType] = useState('');
|
const [recordType, setRecordType] = useState('');
|
||||||
const [creditSubject, setCreditSubject] = useState('');
|
const [creditSubject, setCreditSubject] = useState('');
|
||||||
@@ -211,6 +250,7 @@ const AdminCreditRecords: React.FC = () => {
|
|||||||
pageSize,
|
pageSize,
|
||||||
userName: userNameFilter || undefined,
|
userName: userNameFilter || undefined,
|
||||||
teamId: teamFilter || undefined,
|
teamId: teamFilter || undefined,
|
||||||
|
subscriptionNo: subscriptionNoFilter || undefined,
|
||||||
recordType: recordType || undefined,
|
recordType: recordType || undefined,
|
||||||
creditSubject: creditSubject || undefined,
|
creditSubject: creditSubject || undefined,
|
||||||
mediaType: mediaType || undefined,
|
mediaType: mediaType || undefined,
|
||||||
@@ -222,7 +262,7 @@ const AdminCreditRecords: React.FC = () => {
|
|||||||
startDate: dateRange[0]?.format('YYYY-MM-DD'),
|
startDate: dateRange[0]?.format('YYYY-MM-DD'),
|
||||||
endDate: dateRange[1]?.format('YYYY-MM-DD'),
|
endDate: dateRange[1]?.format('YYYY-MM-DD'),
|
||||||
...buildScope(userScope),
|
...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 () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -247,6 +287,7 @@ const AdminCreditRecords: React.FC = () => {
|
|||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
setUserScope('');
|
setUserScope('');
|
||||||
setTeamFilter('');
|
setTeamFilter('');
|
||||||
|
setSubscriptionNoFilter('');
|
||||||
setRecordType('');
|
setRecordType('');
|
||||||
setCreditSubject('');
|
setCreditSubject('');
|
||||||
setMediaType('');
|
setMediaType('');
|
||||||
@@ -285,6 +326,12 @@ const AdminCreditRecords: React.FC = () => {
|
|||||||
{ title: '用户类型', maxWidth: 16, render: (r) => r.userTypeLabel || '-' },
|
{ title: '用户类型', maxWidth: 16, render: (r) => r.userTypeLabel || '-' },
|
||||||
{ title: '前台归类', maxWidth: 18, render: (r) => r.frontendUserKindLabel || '-' },
|
{ title: '前台归类', maxWidth: 18, render: (r) => r.frontendUserKindLabel || '-' },
|
||||||
{ title: '归属团队', maxWidth: 20, render: (r) => r.teamNameSnapshot || '未分配团队' },
|
{ 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: 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: 16, align: 'center', render: (r) => r.chargeActionLabel || (r.chargeAction ? (CHARGE_ACTION_MAP[r.chargeAction]?.text || r.chargeAction) : '-') },
|
||||||
{ title: '积分类型', maxWidth: 20, render: (r) => r.creditSubjectLabel || '-' },
|
{ 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: '说明', minWidth: 18, maxWidth: 42, render: (r) => r.description || '' },
|
||||||
{ title: '业务归属类型', maxWidth: 18, render: (r) => r.ownerType || '' },
|
{ title: '业务归属类型', maxWidth: 18, render: (r) => r.ownerType || '' },
|
||||||
{ title: '业务归属ID', maxWidth: 28, render: (r) => r.ownerId || '' },
|
{ title: '业务归属ID', maxWidth: 28, render: (r) => r.ownerId || '' },
|
||||||
{ title: '积分来源分摊', minWidth: 24, maxWidth: 48, render: (r) => (r.allocations || []).map(a => `${a.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 || '' },
|
{ title: 'BizKey', maxWidth: 36, render: (r) => r.bizKey || '' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -319,6 +366,7 @@ const AdminCreditRecords: React.FC = () => {
|
|||||||
title: '积分流水汇总',
|
title: '积分流水汇总',
|
||||||
metadataRows: [
|
metadataRows: [
|
||||||
['筛选时间', `${dateRange[0]?.format('YYYY-MM-DD') || '不限'} 至 ${dateRange[1]?.format('YYYY-MM-DD') || '不限'}`],
|
['筛选时间', `${dateRange[0]?.format('YYYY-MM-DD') || '不限'} 至 ${dateRange[1]?.format('YYYY-MM-DD') || '不限'}`],
|
||||||
|
['订阅实例筛选', subscriptionNoFilter || '不限'],
|
||||||
['导出时间', dayjs().format('YYYY-MM-DD HH:mm:ss')],
|
['导出时间', dayjs().format('YYYY-MM-DD HH:mm:ss')],
|
||||||
['导出条数', totalRows],
|
['导出条数', 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: '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: '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: '归属团队', 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: '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: 'chargeAction', width: 110, render: (v: string, r: AdminCreditRecord) => { const cfg = CHARGE_ACTION_MAP[v] || { text: r.chargeActionLabel || v || '-', color: 'default' }; return v ? <Tag color={cfg.color}>{r.chargeActionLabel || cfg.text}</Tag> : <Typography.Text type="secondary">历史</Typography.Text>; } },
|
||||||
{ title: '积分类型', dataIndex: 'creditSubjectLabel', width: 150, render: (v: string) => <Tag>{v || '-'}</Tag> },
|
{ title: '积分类型', dataIndex: '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 })),
|
...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={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={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} />
|
<Select value={mediaType} onChange={(v) => { setPage(1); setMediaType(v); }} style={{ width: 110 }} options={mediaTypeOptions} />
|
||||||
@@ -482,18 +570,26 @@ const AdminCreditRecords: React.FC = () => {
|
|||||||
rowKey="id"
|
rowKey="id"
|
||||||
dataSource={record.allocations || []}
|
dataSource={record.allocations || []}
|
||||||
columns={[
|
columns={[
|
||||||
{ title: '动作', dataIndex: 'allocationActionLabel', width: 150, render: (v: string, r: any) => v || r.allocationAction || '-' },
|
{ title: '动作', dataIndex: 'allocationActionLabel', width: 150, render: (v: string) => v || '其他动作' },
|
||||||
{ title: '积分来源', dataIndex: 'sourceTypeLabel', width: 160, render: (v: string, r: any) => v || r.sourceType || '-' },
|
{ title: '资金域', dataIndex: 'creditScopeLabel', width: 110, render: (v: string) => v || '其他资金域' },
|
||||||
{ title: '来源ID', dataIndex: 'sourceId', width: 260, render: (v: string) => v || '-' },
|
{ title: '分摊积分', dataIndex: 'amount', width: 120, render: (v: number) => <Typography.Text strong>{n(v)}</Typography.Text> },
|
||||||
{ title: '积分数量', dataIndex: 'amount', width: 120, render: (v: number) => n(v) },
|
{ title: '积分等级', dataIndex: 'creditLevelLabel', width: 120, render: (v: string) => v || '其他积分等级' },
|
||||||
{ title: '生效时间', dataIndex: 'validFrom', width: 190, render: (v: string) => v ? formatDate(v) : '-' },
|
{ title: '积分来源', dataIndex: 'sourceTypeLabel', width: 160, render: (v: string) => v || '其他来源' },
|
||||||
{ title: '过期边界', dataIndex: 'expiresAt', width: 190, render: (v: string) => v ? formatDate(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>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,324 +1,74 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import {
|
import { Button, Card, Col, ConfigProvider, DatePicker, Input, Row, Select, Space, Statistic, Table, Tag, Typography } from 'antd';
|
||||||
Card, Col, Input, Row, Space, Table, Tag, Typography, Statistic, message, Select, DatePicker, Button, ConfigProvider, Popconfirm
|
|
||||||
} from 'antd';
|
|
||||||
import zhCN from 'antd/locale/zh_CN';
|
import zhCN from 'antd/locale/zh_CN';
|
||||||
import {
|
import { DollarOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
||||||
DollarOutlined, CheckCircleOutlined, ClockCircleOutlined, CloseCircleOutlined, ReloadOutlined, UndoOutlined, SearchOutlined
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import { getPaymentStats, getAdminPaymentOrders, refundPaymentOrder } from '../api';
|
|
||||||
import { formatDate } from '../utils/formatDate';
|
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
import { getAdminPaymentOrders, getPaymentStats } from '../api';
|
||||||
|
|
||||||
const { Option } = Select;
|
|
||||||
const { RangePicker } = DatePicker;
|
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 AdminPaymentStats: React.FC = () => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [stats, setStats] = useState<any>(null);
|
||||||
const [stats, setStats] = useState<any>(null);
|
const [orders, setOrders] = useState<any[]>([]);
|
||||||
const [orderPage, setOrderPage] = useState(1);
|
const [total, setTotal] = useState(0);
|
||||||
const [orderPageSize, setOrderPageSize] = useState(10);
|
const [loading, setLoading] = useState(false);
|
||||||
const [orderTotal, setOrderTotal] = useState(0);
|
const [page, setPage] = useState(1);
|
||||||
const [filters, setFilters] = useState<{
|
const [pageSize, setPageSize] = useState(20);
|
||||||
paymentMethod?: string;
|
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') });
|
||||||
status?: string;
|
|
||||||
phone?: string;
|
|
||||||
startDate: string;
|
|
||||||
endDate: string;
|
|
||||||
}>({
|
|
||||||
startDate: dayjs().format('YYYY-MM-DD'),
|
|
||||||
endDate: dayjs().format('YYYY-MM-DD'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const load = async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
setLoading(true);
|
||||||
setLoading(true);
|
try {
|
||||||
const [statsData, ordersData] = await Promise.all([
|
const [s, o] = await Promise.all([
|
||||||
getPaymentStats(filters),
|
getPaymentStats({ paymentMethod: filters.paymentMethod, orderSource: filters.orderSource, status: filters.status, startDate: filters.startDate, endDate: filters.endDate }),
|
||||||
getAdminPaymentOrders({
|
getAdminPaymentOrders({ method: filters.paymentMethod, orderSource: filters.orderSource, status: filters.status, phone: filters.phone || undefined, startDate: filters.startDate, endDate: filters.endDate, page, pageSize }),
|
||||||
...filters,
|
]);
|
||||||
phone: filters.phone,
|
setStats(s); setOrders(o.items || []); setTotal(Number(o.total || 0));
|
||||||
page: orderPage,
|
} finally { setLoading(false); }
|
||||||
pageSize: orderPageSize,
|
}, [filters, page, pageSize]);
|
||||||
}),
|
useEffect(() => { void load(); }, [load]);
|
||||||
]);
|
|
||||||
setStats({ ...statsData, recent: ordersData.items || [] });
|
|
||||||
setOrderTotal(ordersData.total);
|
|
||||||
} catch {
|
|
||||||
message.error('加载支付统计失败');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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) => {
|
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); };
|
||||||
setOrderPage(p);
|
const today = stats?.today || {};
|
||||||
setOrderPageSize(ps);
|
const month = stats?.month || {};
|
||||||
};
|
const bySource = stats?.bySource || {};
|
||||||
|
|
||||||
const handleReset = () => {
|
return <ConfigProvider locale={zhCN}><div>
|
||||||
setFilters({
|
<Row gutter={[16,16]} style={{ marginBottom: 18 }}>
|
||||||
startDate: dayjs().format('YYYY-MM-DD'),
|
<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>
|
||||||
endDate: dayjs().format('YYYY-MM-DD'),
|
<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>
|
||||||
setOrderPage(1);
|
</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="订单列表">
|
||||||
const handleRefund = async (orderNo: string) => {
|
<Space wrap style={{ marginBottom: 16 }}>
|
||||||
try {
|
<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}))} />
|
||||||
setLoading(true);
|
<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}))} />
|
||||||
await refundPaymentOrder(orderNo);
|
<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}))} />
|
||||||
message.success('退款成功');
|
<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 />} />
|
||||||
await load();
|
<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); }} />
|
||||||
} catch (e: any) {
|
<Button icon={<ReloadOutlined />} onClick={reset}>重置</Button>
|
||||||
message.error(e?.response?.data?.detail || '退款失败');
|
</Space>
|
||||||
} finally {
|
<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);} }} />
|
||||||
setLoading(false);
|
</Card>
|
||||||
}
|
</div></ConfigProvider>;
|
||||||
};
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AdminPaymentStats;
|
export default AdminPaymentStats;
|
||||||
|
|||||||
@@ -1,378 +1,79 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Button, Card, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Table, Tag, Typography } from 'antd';
|
import { Alert, Button, Card, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Table, Tabs, Tag, Typography } from 'antd';
|
||||||
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, SettingOutlined, TeamOutlined, UserOutlined } from '@ant-design/icons';
|
import { DeleteOutlined, EditOutlined, EyeOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, TeamOutlined, UserSwitchOutlined } from '@ant-design/icons';
|
||||||
import { deleteAdminTeam, getAdminTeams, getTeamMembersForAdmin, saveAdminTeam, setTeamManager } from '../api';
|
import {
|
||||||
|
deleteAdminTeam, getAdminTeamManagerHistory, getAdminTeamMemberUsage, getAdminTeams, getAdminTeamSubscriptions,
|
||||||
|
getTeamMembersForAdmin, saveAdminTeam, setTeamManager,
|
||||||
|
} from '../api';
|
||||||
import type { AdminTeam, AdminUser } from '../types';
|
import type { AdminTeam, AdminUser } from '../types';
|
||||||
import { formatDate } from '../utils/formatDate';
|
|
||||||
|
|
||||||
const statusOptions = [
|
const STATUS = { active: '启用', disabled: '禁用' } as Record<string,string>;
|
||||||
{ value: '', label: '全部状态' },
|
const fmt = (v?: string | null) => v ? new Date(v).toLocaleString('zh-CN', { hour12: false }) : '-';
|
||||||
{ 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 AdminTeams: React.FC = () => {
|
const AdminTeams: React.FC = () => {
|
||||||
const [items, setItems] = useState<AdminTeam[]>([]);
|
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 [loading, setLoading] = useState(false);
|
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 [saving, setSaving] = 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 [page, setPage] = useState(1);
|
const query=useMemo(()=>({page,pageSize,keyword:keyword||undefined,status}),[page,pageSize,keyword,status]);
|
||||||
const [pageSize, setPageSize] = useState(20);
|
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]);
|
||||||
const [total, setTotal] = useState(0);
|
useEffect(()=>{void load();},[load]);
|
||||||
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 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 [managerModal, setManagerModal] = useState<{ open: boolean; team: AdminTeam | null }>({ open: false, team: null });
|
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 [managerMembers, setManagerMembers] = useState<AdminUser[]>([]);
|
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 [managerLoading, setManagerLoading] = useState(false);
|
|
||||||
const [selectedManagerId, setSelectedManagerId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// 查看成员弹窗状态
|
const columns=[
|
||||||
const [membersModal, setMembersModal] = useState<{ open: boolean; team: AdminTeam | null }>({ open: false, team: null });
|
{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>},
|
||||||
const [teamMembers, setTeamMembers] = useState<AdminUser[]>([]);
|
{title:'状态',dataIndex:'status',width:100,render:(v:string,r:AdminTeam)=><Tag color={v==='active'?'green':'red'}>{r.statusLabel||STATUS[v]||'其他状态'}</Tag>},
|
||||||
const [membersLoading, setMembersLoading] = useState(false);
|
{title:'成员数',dataIndex:'memberCount',width:90,align:'right' as const},
|
||||||
|
{title:'当前队长',dataIndex:'managerName',width:140,render:(v:string)=>v||'-'},
|
||||||
const query = useMemo(() => ({
|
{title:'团队首购时间',dataIndex:'firstSubscriptionPaidAt',width:180,render:(v:string)=>fmt(v)},
|
||||||
page,
|
{title:'排序',dataIndex:'sortOrder',width:80},
|
||||||
pageSize,
|
{title:'备注',dataIndex:'description',ellipsis:true,render:(v:string)=>v||'-'},
|
||||||
keyword: keyword || undefined,
|
{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>},
|
||||||
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>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
const subColumns=[
|
||||||
<div>
|
{title:'订阅实例',width:190,render:(_:any,r:any)=><Typography.Text copyable={r.subscription?.subscriptionNo?{text:r.subscription.subscriptionNo}:false}>{r.subscription?.subscriptionNo||'-'}</Typography.Text>},
|
||||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
{title:'套餐',render:(_:any,r:any)=>r.subscription?.productNameSnapshot||'-'}, {title:'状态',render:(_:any,r:any)=><Tag>{r.subscription?.statusLabel||'其他状态'}</Tag>},
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
|
{title:'数量N',render:(_:any,r:any)=>r.subscription?.quantitySnapshot||1}, {title:'席位',render:(_:any,r:any)=>`${r.activeSeatCount||0}/${r.seatLimit||0}`},
|
||||||
<Space wrap>
|
{title:'本期总积分',dataIndex:'periodTotalCredits',align:'right' as const},{title:'本期剩余',dataIndex:'periodUnspentCredits',align:'right' as const},{title:'未分配',dataIndex:'periodUnallocatedCredits',align:'right' as const},
|
||||||
<Input
|
{title:'订阅到期',render:(_:any,r:any)=>fmt(r.subscription?.expiresAt)},
|
||||||
placeholder="搜索团队名称/编码/备注"
|
];
|
||||||
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
|
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>}];
|
||||||
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>
|
|
||||||
|
|
||||||
<Table
|
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>
|
||||||
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>
|
|
||||||
|
|
||||||
<Modal
|
<Modal width={560} title={edit?'编辑团队':'新增团队'} open={editOpen} onCancel={()=>setEditOpen(false)} onOk={save} okText={edit?.status==='disabled'?'重新启用':'保存'} cancelText="取消">
|
||||||
title={<Space><TeamOutlined />{modal.item ? '编辑团队' : '新增团队'}</Space>}
|
{edit?.status==='disabled'&&<Alert style={{marginBottom:14}} type="warning" showIcon message="禁用团队当前仅允许重新启用" description="团队禁用期间所有团队业务保持只读;Subscription、Period、Seat 和积分仍正常滚期、发放与过期。"/>}
|
||||||
open={modal.open}
|
<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>
|
||||||
confirmLoading={saving}
|
</Modal>
|
||||||
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={1100} footer={null} title={detail?`团队详情:${detail.name}`:'团队详情'} open={!!detail} onCancel={()=>setDetail(null)}>
|
||||||
<Modal
|
{detail?.status==='disabled'&&<Alert type="warning" showIcon style={{marginBottom:14}} message="团队已禁用:当前仅允许只读查看" description="团队积分处于冻结状态,但团队订阅仍继续正常发放和到期。"/>}
|
||||||
title={<Space><UserOutlined />设置团队管理人</Space>}
|
<Tabs items={[
|
||||||
open={managerModal.open}
|
{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'}]}/>}}/>},
|
||||||
confirmLoading={managerLoading}
|
{key:'members',label:'成员与队长',children:<Table loading={detailLoading} rowKey="id" dataSource={members} columns={memberColumns} pagination={false} scroll={{x:850}}/>},
|
||||||
onOk={handleSetManager}
|
{key:'usage',label:'成员月净消耗',children:<Table
|
||||||
onCancel={() => { setManagerModal({ open: false, team: null }); setSelectedManagerId(null); }}
|
loading={detailLoading}
|
||||||
okText="保存"
|
rowKey={(r:any)=>`${r.userId}-${r.subscriptionPeriodId}`}
|
||||||
cancelText="取消"
|
dataSource={usage}
|
||||||
width={480}
|
pagination={false}
|
||||||
>
|
scroll={{x:1400}}
|
||||||
<div style={{ marginTop: 16 }}>
|
columns={[
|
||||||
<Typography.Text style={{ fontSize: 13, color: '#64748b', display: 'block', marginBottom: 8 }}>
|
{title:'成员',dataIndex:'username',width:120},
|
||||||
团队:{managerModal.team?.name || '-'}
|
{title:'订阅实例',dataIndex:'subscriptionNo',width:190,render:(v:string)=><Typography.Text copyable={v&&v!=='历史订阅'?{text:v}:false}>{v||'历史订阅'}</Typography.Text>},
|
||||||
</Typography.Text>
|
{title:'套餐名称',dataIndex:'subscriptionName',width:180,render:(v:string)=><Typography.Text strong>{v||'历史团队订阅'}</Typography.Text>},
|
||||||
<Typography.Text style={{ fontSize: 13, color: '#64748b', display: 'block', marginBottom: 12 }}>
|
{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>},
|
||||||
</Typography.Text>
|
{title:'月度周期',dataIndex:'periodLabel',width:110,render:(v:string,r:any)=>v||(Number(r.periodSequence)>0?`第${r.periodSequence}个月`:'历史周期')},
|
||||||
<Select
|
{title:'周期时间',width:320,render:(_:any,r:any)=>`${fmt(r.periodStartAt)} ~ ${fmt(r.periodExpiresAt)}`},
|
||||||
style={{ width: '100%' }}
|
{title:'本期净消耗',dataIndex:'consumedCredits',width:130,align:'right' as const,render:(v:number)=>Number(v||0).toLocaleString()},
|
||||||
placeholder="选择管理人(可清空取消)"
|
]}
|
||||||
value={selectedManagerId}
|
/>},
|
||||||
onChange={(v) => setSelectedManagerId(v || null)}
|
{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>}]}/>},
|
||||||
allowClear
|
]}/>
|
||||||
loading={managerLoading}
|
</Modal>
|
||||||
optionFilterProp="label"
|
</div>;
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AdminTeams;
|
export default AdminTeams;
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import {
|
|||||||
adminGetPrivatePortraitConfig,
|
adminGetPrivatePortraitConfig,
|
||||||
adminUpdatePrivatePortraitConfig,
|
adminUpdatePrivatePortraitConfig,
|
||||||
createUser,
|
createUser,
|
||||||
|
createAdminOfflineSubscription,
|
||||||
deleteUserResourceCapacity,
|
deleteUserResourceCapacity,
|
||||||
getAdminUsers,
|
getAdminUsers,
|
||||||
|
getCreditProducts,
|
||||||
getAdminUserCreditBalances,
|
getAdminUserCreditBalances,
|
||||||
getAdminUserCreditSummary,
|
getAdminUserCreditSummary,
|
||||||
getMenuConfigs,
|
getMenuConfigs,
|
||||||
@@ -29,7 +31,7 @@ import {
|
|||||||
updateUserMenus,
|
updateUserMenus,
|
||||||
updateUserAdminStatus,
|
updateUserAdminStatus,
|
||||||
} from '../api';
|
} 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';
|
import { formatDate } from '../utils/formatDate';
|
||||||
|
|
||||||
const TEAM_UNASSIGNED_VALUE = '__none__';
|
const TEAM_UNASSIGNED_VALUE = '__none__';
|
||||||
@@ -71,6 +73,9 @@ const AdminUsers: React.FC = () => {
|
|||||||
const [teamOptions, setTeamOptions] = useState<AdminTeamOption[]>([]);
|
const [teamOptions, setTeamOptions] = useState<AdminTeamOption[]>([]);
|
||||||
const [creditModal, setCreditModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
|
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 [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 [creditDetailLoading, setCreditDetailLoading] = useState(false);
|
||||||
const [creditSummary, setCreditSummary] = useState<any>(null);
|
const [creditSummary, setCreditSummary] = useState<any>(null);
|
||||||
const [creditBalances, setCreditBalances] = useState<any[]>([]);
|
const [creditBalances, setCreditBalances] = useState<any[]>([]);
|
||||||
@@ -100,6 +105,9 @@ const AdminUsers: React.FC = () => {
|
|||||||
const [capacityForm] = Form.useForm();
|
const [capacityForm] = Form.useForm();
|
||||||
const [teamForm] = Form.useForm();
|
const [teamForm] = Form.useForm();
|
||||||
const [portraitForm] = 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 [page, setPage] = useState(1);
|
||||||
const [pageSize, setPageSize] = useState(20);
|
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 = '') => {
|
const openCreditDetailModal = async (user: AdminUser, status = '') => {
|
||||||
setCreditDetailModal({ open: true, user });
|
setCreditDetailModal({ open: true, user });
|
||||||
setCreditBalanceStatus(status);
|
setCreditBalanceStatus(status);
|
||||||
@@ -480,11 +538,15 @@ const AdminUsers: React.FC = () => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
...(!isAdminTab ? [{
|
...(!isAdminTab ? [{
|
||||||
title: '积分余额', dataIndex: 'credits', width: 120, sorter: (a: AdminUser, b: AdminUser) => a.credits - b.credits,
|
title: '积分余额', dataIndex: 'credits', width: 210, sorter: (a: AdminUser, b: AdminUser) => a.credits - b.credits,
|
||||||
render: (v: number) => (
|
render: (v: number, r: AdminUser) => (
|
||||||
<Typography.Text strong style={{ color: v > 0 ? '#10b981' : '#ef4444', fontSize: 15 }}>
|
<div style={{ lineHeight: 1.7 }}>
|
||||||
{v.toLocaleString()}
|
<Typography.Text strong style={{ color: v > 0 ? '#10b981' : '#ef4444', fontSize: 15 }}>可消费 {Number(v || 0).toLocaleString()}</Typography.Text>
|
||||||
</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>,
|
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) => (
|
render: (_: any, r: AdminUser) => (
|
||||||
<Space size={4} wrap>
|
<Space size={4} wrap>
|
||||||
{!isAdminTab && (
|
{!isAdminTab && (
|
||||||
@@ -570,6 +632,11 @@ const AdminUsers: React.FC = () => {
|
|||||||
积分明细
|
积分明细
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{!isAdminTab && (
|
||||||
|
<Button type="link" size="small" icon={<PlusOutlined />} onClick={() => openOfflineModal(r)}>
|
||||||
|
线下订阅成交
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{!isAdminTab && (
|
{!isAdminTab && (
|
||||||
<Button type="link" size="small" icon={<DatabaseOutlined />}
|
<Button type="link" size="small" icon={<DatabaseOutlined />}
|
||||||
onClick={() => openCapacityModal(r)}>
|
onClick={() => openCapacityModal(r)}>
|
||||||
@@ -806,6 +873,74 @@ const AdminUsers: React.FC = () => {
|
|||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</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
|
<Modal
|
||||||
title={<Space><WalletOutlined />积分明细 - {creditDetailModal.user?.username}</Space>}
|
title={<Space><WalletOutlined />积分明细 - {creditDetailModal.user?.username}</Space>}
|
||||||
open={creditDetailModal.open}
|
open={creditDetailModal.open}
|
||||||
@@ -820,7 +955,10 @@ const AdminUsers: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 12 }}>
|
<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: 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>
|
<Card size="small"><Typography.Text type="secondary">最近最后可用时间</Typography.Text><div style={{ fontSize: 15, fontWeight: 600 }}>{creditSummary?.nextLastUsableAt ? formatDate(creditSummary.nextLastUsableAt) : '-'}</div></Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -857,7 +995,7 @@ const AdminUsers: React.FC = () => {
|
|||||||
{ title: '已撤销', dataIndex: 'revokedAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() },
|
{ title: '已撤销', dataIndex: 'revokedAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() },
|
||||||
{ title: '生效时间', dataIndex: 'validFrom', width: 170, render: (v: string) => formatDate(v) },
|
{ title: '生效时间', dataIndex: 'validFrom', width: 170, render: (v: string) => formatDate(v) },
|
||||||
{ title: '最后可用时间', dataIndex: 'lastUsableAt', width: 180, render: (v: string) => formatDate(v) },
|
{ title: '最后可用时间', dataIndex: 'lastUsableAt', width: 180, render: (v: string) => formatDate(v) },
|
||||||
{ title: '状态', dataIndex: 'status', width: 90, render: (v: string, 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 }}
|
scroll={{ x: 1410, y: 460 }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -143,12 +143,16 @@ export interface AdminTeam {
|
|||||||
code?: string | null;
|
code?: string | null;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
status: AdminTeamStatus;
|
status: AdminTeamStatus;
|
||||||
|
statusLabel?: string;
|
||||||
|
isReadOnly?: boolean;
|
||||||
|
teamCreditFrozen?: boolean;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
memberCount: number;
|
memberCount: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt?: string | null;
|
updatedAt?: string | null;
|
||||||
managerId?: string | null;
|
managerId?: string | null;
|
||||||
managerName?: string | null;
|
managerName?: string | null;
|
||||||
|
firstSubscriptionPaidAt?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminTeamOption {
|
export interface AdminTeamOption {
|
||||||
@@ -186,6 +190,9 @@ export interface AdminUser {
|
|||||||
email: string;
|
email: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
credits: number;
|
credits: number;
|
||||||
|
personalCredits?: number;
|
||||||
|
teamAvailableCredits?: number;
|
||||||
|
teamFrozenCredits?: number;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
userType: string;
|
userType: string;
|
||||||
@@ -249,26 +256,21 @@ export interface AdminStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PaymentStats {
|
export interface PaymentStats {
|
||||||
byStatus: Record<string, { count: number; amount: number }>;
|
byStatus: Record<string, { label?: string; count: number; amount: number }>;
|
||||||
today: { paidCount: number; paidAmount: number };
|
bySource: Record<string, { label?: string; count: number; amount: number }>;
|
||||||
month: { paidCount: number; paidAmount: number };
|
totalIncome: { label?: string; count: number; amount: number };
|
||||||
recent: PaymentOrder[];
|
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 {
|
export interface PaymentOrder {
|
||||||
id: string;
|
id: string; orderNo: string; userId: string; username?: string; phone?: string; amount: number; credits: number; quantity: number;
|
||||||
orderNo: string;
|
paymentMethod: string; paymentMethodLabel?: string; orderSource: 'online_payment' | 'admin_offline' | string; orderSourceLabel?: string;
|
||||||
userId: string;
|
status: string; statusLabel?: string; productId?: string | null; productType?: string | null; productNameSnapshot?: string | null;
|
||||||
username?: string;
|
quotedUnitPriceSnapshot?: number | null; quotedAmountSnapshot?: number | null; actualUnitPriceSnapshot?: number | null;
|
||||||
amount: number;
|
fulfillmentStatus?: string | null; fulfillmentStatusLabel?: string | null; tradeNo?: string | null; offlineTradeNo?: string | null; offlinePaymentDetail?: string | null; remark?: string | null;
|
||||||
credits: number;
|
paidAt?: string | null; createdAt: string; refundedAt?: string | null; refundAmount?: number | null; refundTradeNo?: string | null; refundEntitlementStatus?: string | null;
|
||||||
paymentMethod: string;
|
|
||||||
status: string;
|
|
||||||
tradeNo?: string;
|
|
||||||
paidAt?: string;
|
|
||||||
createdAt: string;
|
|
||||||
refundedAt?: string;
|
|
||||||
refundAmount?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelConfig {
|
export interface ModelConfig {
|
||||||
@@ -910,6 +912,26 @@ export interface AdminCreditRecordAllocation {
|
|||||||
amount: number;
|
amount: number;
|
||||||
creditLevel?: string;
|
creditLevel?: string;
|
||||||
creditLevelLabel?: 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;
|
sourceType?: string;
|
||||||
sourceTypeLabel?: string;
|
sourceTypeLabel?: string;
|
||||||
sourceId?: string;
|
sourceId?: string;
|
||||||
@@ -921,6 +943,27 @@ export interface AdminCreditRecordAllocation {
|
|||||||
consumedAfter?: number;
|
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 {
|
export interface AdminCreditRecord {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -964,6 +1007,11 @@ export interface AdminCreditRecord {
|
|||||||
llmCallCount?: number;
|
llmCallCount?: number;
|
||||||
llmSuccessCallCount?: number;
|
llmSuccessCallCount?: number;
|
||||||
llmFailedCallCount?: number;
|
llmFailedCallCount?: number;
|
||||||
|
fundingScope?: string;
|
||||||
|
fundingScopeLabel?: string;
|
||||||
|
teamAllocationAmount?: number;
|
||||||
|
personalAllocationAmount?: number;
|
||||||
|
subscriptionUsages?: AdminCreditRecordSubscriptionUsage[];
|
||||||
allocations?: AdminCreditRecordAllocation[];
|
allocations?: AdminCreditRecordAllocation[];
|
||||||
sourceModule?: string;
|
sourceModule?: string;
|
||||||
sourceModuleLabel?: string;
|
sourceModuleLabel?: string;
|
||||||
@@ -997,6 +1045,7 @@ export interface AdminCreditRecordQueryParams {
|
|||||||
userType?: string;
|
userType?: string;
|
||||||
frontendUserKind?: string;
|
frontendUserKind?: string;
|
||||||
teamId?: string;
|
teamId?: string;
|
||||||
|
subscriptionNo?: string;
|
||||||
recordType?: string;
|
recordType?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
creditSubject?: string;
|
creditSubject?: string;
|
||||||
@@ -1473,34 +1522,15 @@ export interface VideoUpscaleConfigSavePayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Dynamic Credit Products / LLM Billing ─────────────────
|
// ── 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 type SubscriptionBillingCycle = 'monthly' | 'quarterly' | 'yearly';
|
||||||
|
|
||||||
export interface CreditProduct {
|
export interface CreditProduct {
|
||||||
id: string;
|
id: string; productCode: string; productType: CreditProductType; productTypeLabel?: string; name: string; description?: string | null; features?: string[];
|
||||||
productCode: string;
|
tierCode?: string | null; tierLabel?: string | null; tierRank?: number | null; billingCycle?: SubscriptionBillingCycle | null; billingCycleLabel?: string | null;
|
||||||
productType: CreditProductType;
|
monthlyGrantCredits?: number; grantCount?: number; firstPurchasePrice?: number; regularPrice?: number; activityPrice?: number | null; activityStartAt?: string | null; activityEndAt?: string | null; renewalEnabled: boolean;
|
||||||
name: string;
|
price: number; currentPrice?: number; grantCredits?: number; validityMonths?: number | null; creditLevel: 'promotional' | 'general'; creditLevelLabel?: string; currency: string;
|
||||||
description?: string | null;
|
isActive: boolean; isDeleted?: boolean; deletedAt?: string | null; statusLabel?: string; sortOrder: number;
|
||||||
features?: string[];
|
|
||||||
tierCode?: string | null;
|
|
||||||
tierRank?: number | null;
|
|
||||||
billingCycle?: SubscriptionBillingCycle | null;
|
|
||||||
monthlyGrantCredits?: number;
|
|
||||||
grantCount?: number;
|
|
||||||
firstPurchasePrice?: number;
|
|
||||||
regularPrice?: number;
|
|
||||||
activityPrice?: number | null;
|
|
||||||
activityStartAt?: string | null;
|
|
||||||
activityEndAt?: string | null;
|
|
||||||
renewalEnabled: boolean;
|
|
||||||
price: number;
|
|
||||||
grantCredits?: number;
|
|
||||||
validityMonths?: number | null;
|
|
||||||
creditLevel: 'promotional' | 'general';
|
|
||||||
currency: string;
|
|
||||||
isActive: boolean;
|
|
||||||
sortOrder: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LlmBillingPolicy {
|
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
|
||||||
@@ -12,20 +12,22 @@ from app.enums.credit_balance import (
|
|||||||
CREDIT_BALANCE_SOURCE_TYPE_LABELS,
|
CREDIT_BALANCE_SOURCE_TYPE_LABELS,
|
||||||
CREDIT_BALANCE_STATUS_LABELS,
|
CREDIT_BALANCE_STATUS_LABELS,
|
||||||
CREDIT_LEVEL_LABELS,
|
CREDIT_LEVEL_LABELS,
|
||||||
|
CREDIT_SCOPE_LABELS,
|
||||||
CreditBalanceSourceType,
|
CreditBalanceSourceType,
|
||||||
|
CreditScope,
|
||||||
)
|
)
|
||||||
|
from app.enums.credit_product import CreditProductType
|
||||||
from app.models.credit.balance import UserCreditBalance
|
from app.models.credit.balance import UserCreditBalance
|
||||||
from app.models.credit.product import CreditProduct
|
from app.models.credit.product import CreditProduct
|
||||||
|
from app.models.credit.subscription import UserCreditSubscription
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.credit_balance import AdminCreditDeductRequest, AdminCreditGrantRequest
|
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.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.product_service import product_to_dict
|
||||||
from app.services.credit.query_service import (
|
from app.services.credit.query_service import apply_balance_status_filter, effective_balance_status, get_balance_summary
|
||||||
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.time_policy import add_natural_months, last_usable_at
|
||||||
from app.services.credit.utils import utc_now
|
from app.services.credit.utils import utc_now
|
||||||
from app.services.notification import create_notification
|
from app.services.notification import create_notification
|
||||||
@@ -40,16 +42,17 @@ def _apply_product_payload(product: CreditProduct, payload: dict) -> None:
|
|||||||
mapping = {"features": "features_json"}
|
mapping = {"features": "features_json"}
|
||||||
for key, value in payload.items():
|
for key, value in payload.items():
|
||||||
setattr(product, mapping.get(key, key), value)
|
setattr(product, mapping.get(key, key), value)
|
||||||
if product.product_type == "credit_addon" and product.validity_months is None:
|
if product.product_type in {
|
||||||
# 兼容旧管理端未提交有效期的请求,新建增值包仍默认1个月;
|
CreditProductType.SUBSCRIPTION.value,
|
||||||
# 更新时未提交该字段则保留原值。
|
CreditProductType.TEAM_SUBSCRIPTION.value,
|
||||||
product.validity_months = 1
|
}:
|
||||||
if product.product_type == "subscription":
|
|
||||||
product.price = product.regular_price or 0
|
product.price = product.regular_price or 0
|
||||||
product.grant_credits = None
|
product.grant_credits = None
|
||||||
product.validity_months = None
|
product.validity_months = None
|
||||||
else:
|
elif product.product_type == CreditProductType.CREDIT_ADDON.value:
|
||||||
product.renewal_enabled = False
|
product.renewal_enabled = False
|
||||||
|
if product.validity_months is None:
|
||||||
|
product.validity_months = 1
|
||||||
product.tier_code = None
|
product.tier_code = None
|
||||||
product.tier_rank = None
|
product.tier_rank = None
|
||||||
product.billing_cycle = 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:
|
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 = {
|
required = {
|
||||||
"套餐等级编码": product.tier_code,
|
"套餐等级编码": product.tier_code,
|
||||||
"套餐等级顺序": product.tier_rank,
|
"套餐等级顺序": product.tier_rank,
|
||||||
"订阅周期": product.billing_cycle,
|
"订阅周期": product.billing_cycle,
|
||||||
"每月积分": product.monthly_grant_credits,
|
"每月积分": product.monthly_grant_credits,
|
||||||
"首充价格": product.first_purchase_price,
|
"首购价格": product.first_purchase_price,
|
||||||
"原价": product.regular_price,
|
"常规价格": product.regular_price,
|
||||||
}
|
}
|
||||||
missing = [label for label, value in required.items() if value is None]
|
missing = [label for label, value in required.items() if value is None]
|
||||||
if missing:
|
if missing:
|
||||||
@@ -81,9 +87,9 @@ def _validate_product_entity(product: CreditProduct) -> None:
|
|||||||
raise HTTPException(status_code=400, detail="配置活动价时必须同时配置活动开始和结束时间")
|
raise HTTPException(status_code=400, detail="配置活动价时必须同时配置活动开始和结束时间")
|
||||||
elif product.activity_end_at <= product.activity_start_at:
|
elif product.activity_end_at <= product.activity_start_at:
|
||||||
raise HTTPException(status_code=400, detail="活动结束时间必须晚于开始时间")
|
raise HTTPException(status_code=400, detail="活动结束时间必须晚于开始时间")
|
||||||
elif product.product_type == "credit_addon":
|
elif product.product_type == CreditProductType.CREDIT_ADDON.value:
|
||||||
if product.grant_credits is None or product.price is None:
|
if product.grant_credits is None:
|
||||||
raise HTTPException(status_code=400, detail="积分增值包必须配置价格和积分数量")
|
raise HTTPException(status_code=400, detail="积分增值包必须配置积分数量")
|
||||||
if product.validity_months is None or not 1 <= int(product.validity_months) <= 36:
|
if product.validity_months is None or not 1 <= int(product.validity_months) <= 36:
|
||||||
raise HTTPException(status_code=400, detail="积分增值包有效期必须为1-36个月")
|
raise HTTPException(status_code=400, detail="积分增值包有效期必须为1-36个月")
|
||||||
else:
|
else:
|
||||||
@@ -99,7 +105,9 @@ async def list_products(
|
|||||||
stmt = select(CreditProduct)
|
stmt = select(CreditProduct)
|
||||||
if product_type:
|
if product_type:
|
||||||
stmt = stmt.where(CreditProduct.product_type == 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()]
|
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),
|
admin: User = Depends(get_admin_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
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():
|
if exists.scalar_one_or_none():
|
||||||
raise HTTPException(status_code=409, detail="商品编码已存在")
|
raise HTTPException(status_code=409, detail="商品编码已被使用,商品编码永久唯一且不可复用")
|
||||||
product = CreditProduct(id=generate_id())
|
product = CreditProduct(id=generate_id())
|
||||||
_apply_product_payload(product, data.model_dump())
|
_apply_product_payload(product, data.model_dump())
|
||||||
_validate_product_entity(product)
|
_validate_product_entity(product)
|
||||||
db.add(product)
|
db.add(product)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
snapshot = product_to_dict(product)
|
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))
|
await log_operation(
|
||||||
log_operation_event(domain="credit_product", module="credit", event_type="CREDIT_PRODUCT_CREATED", user_id=admin.id, detail=snapshot)
|
db, admin.id, admin.username, f"创建积分商品 {product.name}", "POST",
|
||||||
# 商品保存后前端会立即使用返回值刷新列表。这里显式提交,避免依赖
|
"/admin/credit-management/products", detail=json.dumps(snapshot, ensure_ascii=False, default=str),
|
||||||
# yield 依赖退出阶段提交时出现紧随其后的 GET 读到旧状态。
|
)
|
||||||
|
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()
|
await db.commit()
|
||||||
return snapshot
|
return snapshot
|
||||||
|
|
||||||
@@ -133,33 +147,26 @@ async def update_product(
|
|||||||
admin: User = Depends(get_admin_user),
|
admin: User = Depends(get_admin_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
result = await db.execute(select(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()
|
product = result.scalar_one_or_none()
|
||||||
if not product:
|
if not product:
|
||||||
raise HTTPException(status_code=404, detail="商品不存在")
|
raise HTTPException(status_code=404, detail="商品不存在")
|
||||||
|
if product.deleted_at is not None:
|
||||||
|
raise HTTPException(status_code=409, detail="商品已软删除,不能恢复或继续编辑")
|
||||||
before = product_to_dict(product)
|
before = product_to_dict(product)
|
||||||
payload = data.model_dump(exclude_unset=True)
|
_apply_product_payload(product, data.model_dump(exclude_unset=True))
|
||||||
new_code = payload.get("product_code")
|
|
||||||
if new_code and new_code != product.product_code:
|
|
||||||
duplicate = await db.execute(
|
|
||||||
select(CreditProduct.id).where(
|
|
||||||
CreditProduct.product_code == new_code, CreditProduct.id != product.id
|
|
||||||
).limit(1)
|
|
||||||
)
|
|
||||||
if duplicate.scalar_one_or_none():
|
|
||||||
raise HTTPException(status_code=409, detail="商品编码已存在")
|
|
||||||
_apply_product_payload(product, payload)
|
|
||||||
_validate_product_entity(product)
|
_validate_product_entity(product)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
after = product_to_dict(product)
|
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))
|
await log_operation(
|
||||||
log_operation_event(domain="credit_product", module="credit", event_type="CREDIT_PRODUCT_UPDATED", user_id=admin.id, detail={"product_id": product_id})
|
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()
|
await db.commit()
|
||||||
refreshed = await db.execute(select(CreditProduct).where(CreditProduct.id == product_id).limit(1))
|
return after
|
||||||
persisted = refreshed.scalar_one_or_none()
|
|
||||||
if persisted is None:
|
|
||||||
raise HTTPException(status_code=404, detail="商品不存在")
|
|
||||||
return product_to_dict(persisted)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/products/{product_id}/renewal")
|
@router.put("/products/{product_id}/renewal")
|
||||||
@@ -170,61 +177,151 @@ async def update_product_renewal(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(CreditProduct)
|
select(CreditProduct).where(CreditProduct.id == product_id).limit(1).with_for_update()
|
||||||
.where(CreditProduct.id == product_id)
|
|
||||||
.limit(1)
|
|
||||||
.with_for_update()
|
|
||||||
)
|
)
|
||||||
product = result.scalar_one_or_none()
|
product = result.scalar_one_or_none()
|
||||||
if not product:
|
if not product:
|
||||||
raise HTTPException(status_code=404, detail="商品不存在")
|
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="积分增值包不支持续费开关")
|
raise HTTPException(status_code=400, detail="积分增值包不支持续费开关")
|
||||||
|
|
||||||
before = bool(product.renewal_enabled)
|
before = bool(product.renewal_enabled)
|
||||||
product.renewal_enabled = bool(data.renewal_enabled)
|
product.renewal_enabled = bool(data.renewal_enabled)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
after = product_to_dict(product)
|
||||||
await log_operation(
|
await log_operation(
|
||||||
db,
|
db, admin.id, admin.username,
|
||||||
admin.id,
|
f"{'开启' if product.renewal_enabled else '关闭'}积分套餐续费 {product.name}",
|
||||||
admin.username,
|
"PUT", f"/admin/credit-management/products/{product_id}/renewal",
|
||||||
f"{'开启' if product.renewal_enabled else '关闭'}积分商品续费 {product.name}",
|
detail=json.dumps({"before": before, "after": bool(product.renewal_enabled)}, ensure_ascii=False),
|
||||||
"PUT",
|
)
|
||||||
f"/admin/credit-management/products/{product_id}/renewal",
|
log_operation_event(
|
||||||
detail=json.dumps(
|
domain="credit_product", module="credit", event_type="CREDIT_PRODUCT_RENEWAL_UPDATED",
|
||||||
{"before": before, "after": bool(product.renewal_enabled)},
|
user_id=admin.id, message="积分套餐续费开关已更新",
|
||||||
ensure_ascii=False,
|
detail={"product_id": product.id, "renewal_enabled": bool(product.renewal_enabled)},
|
||||||
),
|
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
return after
|
||||||
|
|
||||||
# 提交后重新查询,返回数据库真实持久化结果,避免前端误用事务内快照。
|
|
||||||
refreshed = await db.execute(
|
@router.put("/products/{product_id}/status")
|
||||||
select(CreditProduct).where(CreditProduct.id == product_id).limit(1)
|
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()
|
product = result.scalar_one_or_none()
|
||||||
if persisted is None:
|
if not product:
|
||||||
raise HTTPException(status_code=404, detail="商品不存在")
|
raise HTTPException(status_code=404, detail="商品不存在")
|
||||||
if bool(persisted.renewal_enabled) != bool(data.renewal_enabled):
|
if product.deleted_at is not None:
|
||||||
raise HTTPException(status_code=500, detail="续费状态保存后校验失败")
|
raise HTTPException(status_code=409, detail="已软删除商品不能重新上架")
|
||||||
return product_to_dict(persisted)
|
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}")
|
@router.delete("/products/{product_id}")
|
||||||
async def disable_product(
|
async def soft_delete_product(
|
||||||
product_id: str,
|
product_id: str,
|
||||||
admin: User = Depends(get_admin_user),
|
admin: User = Depends(get_admin_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
result = await db.execute(select(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()
|
product = result.scalar_one_or_none()
|
||||||
if not product:
|
if not product:
|
||||||
raise HTTPException(status_code=404, detail="商品不存在")
|
raise HTTPException(status_code=404, detail="商品不存在")
|
||||||
product.is_active = False
|
if product.deleted_at is None:
|
||||||
await db.flush()
|
product.is_active = False
|
||||||
await log_operation(db, admin.id, admin.username, f"下架积分商品 {product.name}", "DELETE", f"/admin/credit-management/products/{product_id}")
|
product.deleted_at = utc_now()
|
||||||
await db.commit()
|
await db.flush()
|
||||||
return {"ok": True}
|
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")
|
@router.get("/users/{user_id}/summary")
|
||||||
@@ -248,14 +345,20 @@ async def list_user_credit_balances(
|
|||||||
checked_at = utc_now()
|
checked_at = utc_now()
|
||||||
stmt = select(UserCreditBalance).where(UserCreditBalance.user_id == user_id)
|
stmt = select(UserCreditBalance).where(UserCreditBalance.user_id == user_id)
|
||||||
stmt = apply_balance_status_filter(stmt, status, request_time=checked_at)
|
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 [
|
return [
|
||||||
{
|
{
|
||||||
"id": item.id,
|
"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": 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": 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,
|
"source_id": item.source_id,
|
||||||
"grant_amount": float(item.grant_amount),
|
"grant_amount": float(item.grant_amount),
|
||||||
"unspent_amount": float(item.unspent_amount),
|
"unspent_amount": float(item.unspent_amount),
|
||||||
@@ -266,7 +369,7 @@ async def list_user_credit_balances(
|
|||||||
"expires_at": item.expires_at,
|
"expires_at": item.expires_at,
|
||||||
"last_usable_at": last_usable_at(item.expires_at),
|
"last_usable_at": last_usable_at(item.expires_at),
|
||||||
"status": (status_value := effective_balance_status(item, request_time=checked_at)),
|
"status": (status_value := effective_balance_status(item, request_time=checked_at)),
|
||||||
"status_label": CREDIT_BALANCE_STATUS_LABELS.get(status_value, status_value),
|
"status_label": CREDIT_BALANCE_STATUS_LABELS.get(status_value, "其他状态"),
|
||||||
}
|
}
|
||||||
for item in result.scalars().all()
|
for item in result.scalars().all()
|
||||||
]
|
]
|
||||||
@@ -280,24 +383,18 @@ async def admin_grant_credit(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
starts_at = data.valid_from or utc_now()
|
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(
|
result = await grant_credits(
|
||||||
db,
|
db, user_id=user_id, amount=data.amount, description=data.description,
|
||||||
user_id=user_id,
|
source_type=CreditBalanceSourceType.ADMIN_GRANT.value, source_id=admin.id,
|
||||||
amount=data.amount,
|
valid_from=starts_at, expires_at=ends_at, credit_level=data.credit_level,
|
||||||
description=data.description,
|
related_id=admin.id, biz_key=f"admin-grant:{admin.id}:{generate_id()}",
|
||||||
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 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")
|
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}
|
return {"ok": True, "credits": result.balance_after}
|
||||||
|
|
||||||
@@ -311,20 +408,14 @@ async def admin_deduct_credit(
|
|||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
result = await deduct_credits(
|
result = await deduct_credits(
|
||||||
db,
|
db, user_id=user_id, amount=data.amount, description=data.description,
|
||||||
user_id=user_id,
|
related_id=admin.id, biz_key=f"admin-deduct:{admin.id}:{generate_id()}",
|
||||||
amount=data.amount,
|
allowed_scopes={CreditScope.PERSONAL.value},
|
||||||
description=data.description,
|
|
||||||
related_id=admin.id,
|
|
||||||
biz_key=f"admin-deduct:{admin.id}:{generate_id()}",
|
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if exc.__class__.__name__ == "InsufficientCreditsError":
|
if exc.__class__.__name__ == "InsufficientCreditsError":
|
||||||
raise HTTPException(status_code=400, detail="用户有效积分不足") from exc
|
raise HTTPException(status_code=400, detail="用户有效积分不足") from exc
|
||||||
raise
|
raise
|
||||||
await create_notification(
|
await create_notification(db, user_id, "积分变动通知", f"您的积分已扣除{data.amount}积分。原因:{data.description}", "credit")
|
||||||
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")
|
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}
|
return {"ok": True, "credits": result.balance_after}
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ async def admin_list_packages(
|
|||||||
):
|
):
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(CreditProduct)
|
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)
|
.order_by(CreditProduct.sort_order, CreditProduct.id)
|
||||||
)
|
)
|
||||||
return [product_to_dict(item) for item in result.scalars().all()]
|
return [product_to_dict(item) for item in result.scalars().all()]
|
||||||
|
|||||||
@@ -3,54 +3,57 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from fastapi import APIRouter, Body, Depends, Query
|
from fastapi import APIRouter, Body, Depends, Query
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.dependencies import get_admin_user, get_db
|
from app.dependencies import get_admin_user, get_db
|
||||||
|
from app.enums.team import TEAM_STATUS_LABELS, TeamStatus
|
||||||
|
from app.enums.user import UserType
|
||||||
from app.models.team import Team
|
from app.models.team import Team
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.team import TeamCreate, TeamListOut, TeamOptionOut, TeamUpdate
|
from app.schemas.team import TeamCreate, TeamListOut, TeamOptionOut, TeamUpdate
|
||||||
from app.schemas.team_manager import SetManagerRequest
|
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.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
|
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"])
|
router = APIRouter(prefix="/admin/teams", tags=["admin-teams"])
|
||||||
|
|
||||||
|
|
||||||
async def _team_detail_payload(db: AsyncSession, team: Team) -> dict:
|
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(
|
member_count = (await db.execute(
|
||||||
select(func.count(User.id)).where(
|
select(func.count(User.id)).where(
|
||||||
User.user_type == UserType.FRONTEND.value,
|
User.user_type == UserType.FRONTEND.value,
|
||||||
User.team_id == team.id,
|
User.team_id == team.id,
|
||||||
)
|
)
|
||||||
)).scalar() or 0
|
)).scalar() or 0
|
||||||
payload["member_count"] = int(member_count)
|
manager_name = None
|
||||||
|
if team.manager_id:
|
||||||
if getattr(team, "manager_id", None):
|
manager_name = (await db.execute(
|
||||||
mgr = await db.execute(
|
|
||||||
select(User.username).where(User.id == team.manager_id).limit(1)
|
select(User.username).where(User.id == team.manager_id).limit(1)
|
||||||
)
|
)).scalar_one_or_none()
|
||||||
payload["manager_name"] = mgr.scalar_one_or_none()
|
status = team.status or TeamStatus.ACTIVE.value
|
||||||
|
return {
|
||||||
return payload
|
"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)
|
@router.get("", response_model=TeamListOut)
|
||||||
@@ -62,6 +65,7 @@ async def list_admin_teams(
|
|||||||
admin: User = Depends(get_admin_user),
|
admin: User = Depends(get_admin_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
del admin
|
||||||
return await list_teams(db, page=page, page_size=page_size, keyword=keyword, status=status)
|
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),
|
admin: User = Depends(get_admin_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
del admin
|
||||||
return await list_team_options(db, include_disabled=include_disabled)
|
return await list_team_options(db, include_disabled=include_disabled)
|
||||||
|
|
||||||
|
|
||||||
@router.post("", )
|
@router.post("")
|
||||||
async def create_admin_team(
|
async def create_admin_team(
|
||||||
req: TeamCreate,
|
req: TeamCreate,
|
||||||
admin: User = Depends(get_admin_user),
|
admin: User = Depends(get_admin_user),
|
||||||
@@ -93,7 +98,22 @@ async def create_admin_team(
|
|||||||
return await _team_detail_payload(db, 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(
|
async def update_admin_team(
|
||||||
team_id: str,
|
team_id: str,
|
||||||
req: TeamUpdate,
|
req: TeamUpdate,
|
||||||
@@ -113,7 +133,7 @@ async def update_admin_team(
|
|||||||
return await _team_detail_payload(db, team)
|
return await _team_detail_payload(db, team)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{team_id}/manager", )
|
@router.put("/{team_id}/manager")
|
||||||
async def set_team_manager_endpoint(
|
async def set_team_manager_endpoint(
|
||||||
team_id: str,
|
team_id: str,
|
||||||
req: SetManagerRequest = Body(...),
|
req: SetManagerRequest = Body(...),
|
||||||
@@ -121,19 +141,14 @@ async def set_team_manager_endpoint(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
team = await set_team_manager(db, team_id, req.user_id)
|
team = await set_team_manager(db, team_id, req.user_id)
|
||||||
manager_name = None
|
manager_name = (await db.execute(
|
||||||
# 使用 req.user_id 避免访问 team.manager_id 触发懒加载
|
select(User.username).where(User.id == req.user_id).limit(1)
|
||||||
if req.user_id:
|
)).scalar_one_or_none()
|
||||||
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
|
|
||||||
await log_operation(
|
await log_operation(
|
||||||
db,
|
db,
|
||||||
admin.id,
|
admin.id,
|
||||||
admin.username,
|
admin.username,
|
||||||
f"设置团队管理人 {team_name}: {manager_name or '取消'}",
|
f"更换团队队长 {team.name}: {manager_name or req.user_id}",
|
||||||
"PUT",
|
"PUT",
|
||||||
f"/admin/teams/{team_id}/manager",
|
f"/admin/teams/{team_id}/manager",
|
||||||
detail=json.dumps({"manager_id": req.user_id}, ensure_ascii=False),
|
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)
|
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}")
|
@router.delete("/{team_id}")
|
||||||
async def delete_admin_team(
|
async def delete_admin_team(
|
||||||
team_id: str,
|
team_id: str,
|
||||||
@@ -157,4 +203,4 @@ async def delete_admin_team(
|
|||||||
f"/admin/teams/{team_id}",
|
f"/admin/teams/{team_id}",
|
||||||
detail=json.dumps({"before": before, "after": {"deleted_at": str(team.deleted_at)}}, ensure_ascii=False),
|
detail=json.dumps({"before": before, "after": {"deleted_at": str(team.deleted_at)}}, ensure_ascii=False),
|
||||||
)
|
)
|
||||||
return {"message": "ok"}
|
return {"message": "团队已删除"}
|
||||||
|
|||||||
+174
-134
@@ -24,6 +24,8 @@ from app.models.credit_ratio import CreditRatio
|
|||||||
from app.models.operation_log import OperationLog
|
from app.models.operation_log import OperationLog
|
||||||
from app.enums.user import FrontendUserKind, UserType
|
from app.enums.user import FrontendUserKind, UserType
|
||||||
from app.enums.team import TEAM_UNASSIGNED_VALUE
|
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 (
|
from app.schemas.admin import (
|
||||||
CreditAdjustRequest,
|
CreditAdjustRequest,
|
||||||
ModelConfigCreate,
|
ModelConfigCreate,
|
||||||
@@ -49,7 +51,7 @@ from app.schemas.image_engine import ImageEngineCreate, ImageEngineOut
|
|||||||
from app.schemas.credit_ratio import CreditRatioCreate, CreditRatioOut
|
from app.schemas.credit_ratio import CreditRatioCreate, CreditRatioOut
|
||||||
from app.services.credits import add_credits, deduct_credits
|
from app.services.credits import add_credits, deduct_credits
|
||||||
from app.enums.credit_balance import CreditBalanceSourceType, CreditLevel
|
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.time_policy import add_natural_months
|
||||||
from app.services.credit.utils import utc_now
|
from app.services.credit.utils import utc_now
|
||||||
from app.services.credit_record_meta_service import build_admin_adjust_meta
|
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.operation_log import log_operation
|
||||||
from app.services.private_portrait.reference_resolver import batch_resolve_private_portrait_reference_display_urls
|
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.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.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.services.team_service import batch_get_team_name_map, set_frontend_user_team
|
||||||
from app.schemas.invoice import InvoiceStatusUpdateRequest
|
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)]
|
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)
|
capacity_map = await batch_get_user_resource_capacity_usage(db, user_ids)
|
||||||
team_name_map = await batch_get_team_name_map(db, team_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:
|
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 {
|
return {
|
||||||
"items": [
|
"items": [
|
||||||
AdminUserOut.model_validate(user)
|
AdminUserOut.model_validate(user)
|
||||||
@@ -144,6 +146,9 @@ async def list_users(
|
|||||||
update={
|
update={
|
||||||
"resource_capacity": capacity_map.get(user.id),
|
"resource_capacity": capacity_map.get(user.id),
|
||||||
"team_name": team_name_map.get(getattr(user, "team_id", None)),
|
"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")
|
.model_dump(mode="json")
|
||||||
@@ -277,13 +282,17 @@ async def get_user(
|
|||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="用户不存在")
|
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)
|
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)])
|
team_name_map = await batch_get_team_name_map(db, [getattr(user, "team_id", None)])
|
||||||
return AdminUserOut.model_validate(user).model_copy(
|
return AdminUserOut.model_validate(user).model_copy(
|
||||||
update={
|
update={
|
||||||
"resource_capacity": resource_capacity,
|
"resource_capacity": resource_capacity,
|
||||||
"team_name": team_name_map.get(getattr(user, "team_id", None)),
|
"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()}",
|
biz_key=f"admin-adjust-credit:{admin.id}:{generate_id()}",
|
||||||
)
|
)
|
||||||
else:
|
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(
|
await create_notification(
|
||||||
db, user_id, "积分变动通知",
|
db, user_id, "积分变动通知",
|
||||||
f"您的积分已{'增加' if req.amount > 0 else '扣除'}{abs(req.amount)}积分。原因:{req.description}",
|
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),
|
user_type: str | None = Query(None),
|
||||||
frontend_user_kind: str | None = Query(None),
|
frontend_user_kind: str | None = Query(None),
|
||||||
team_id: str | None = Query(None),
|
team_id: str | None = Query(None),
|
||||||
|
subscription_no: str | None = Query(None),
|
||||||
record_type: str | None = Query(None),
|
record_type: str | None = Query(None),
|
||||||
type: str | None = Query(None),
|
type: str | None = Query(None),
|
||||||
credit_subject: str | None = Query(None),
|
credit_subject: str | None = Query(None),
|
||||||
@@ -592,6 +609,7 @@ async def list_credit_records(
|
|||||||
user_type=user_type,
|
user_type=user_type,
|
||||||
frontend_user_kind=frontend_user_kind,
|
frontend_user_kind=frontend_user_kind,
|
||||||
team_id=team_id,
|
team_id=team_id,
|
||||||
|
subscription_no=subscription_no,
|
||||||
record_type=record_type or type,
|
record_type=record_type or type,
|
||||||
credit_subject=credit_subject,
|
credit_subject=credit_subject,
|
||||||
media_type=media_type,
|
media_type=media_type,
|
||||||
@@ -806,107 +824,112 @@ async def batch_update_payment_configs(
|
|||||||
@router.get("/payment-stats")
|
@router.get("/payment-stats")
|
||||||
async def get_payment_stats(
|
async def get_payment_stats(
|
||||||
payment_method: str | None = Query(None),
|
payment_method: str | None = Query(None),
|
||||||
|
order_source: str | None = Query(None, pattern="^(online_payment|admin_offline)$"),
|
||||||
status: str | None = Query(None),
|
status: str | None = Query(None),
|
||||||
start_date: str | None = Query(None),
|
start_date: str | None = Query(None),
|
||||||
end_date: str | None = Query(None),
|
end_date: str | None = Query(None),
|
||||||
admin: User = Depends(get_admin_user),
|
admin: User = Depends(get_admin_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Return payment statistics for admin dashboard with filters."""
|
"""支付统计:线上、后台线下和总真实收入可分别统计。"""
|
||||||
from sqlalchemy import func
|
del admin
|
||||||
|
|
||||||
# Ensure by_status has all expected statuses with defaults
|
|
||||||
by_status = {
|
by_status = {
|
||||||
"pending": {"count": 0, "amount": 0.0},
|
"pending": {"label": "待支付", "count": 0, "amount": 0.0},
|
||||||
"paid": {"count": 0, "amount": 0.0},
|
"paid": {"label": "已支付", "count": 0, "amount": 0.0},
|
||||||
"cancelled": {"count": 0, "amount": 0.0},
|
"cancelled": {"label": "已取消", "count": 0, "amount": 0.0},
|
||||||
"refunded": {"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)
|
now_cst = datetime.now(CST)
|
||||||
today_start = now_cst.replace(hour=0, minute=0, second=0, microsecond=0)
|
today_start = now_cst.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
today_end = today_start + timedelta(days=1)
|
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
|
filters = [PaymentOrder.created_at >= query_start, PaymentOrder.created_at < query_end]
|
||||||
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 = []
|
|
||||||
if payment_method:
|
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:
|
if status:
|
||||||
breakdown_filters.append(PaymentOrder.status == status)
|
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)
|
|
||||||
|
|
||||||
# Status breakdown
|
|
||||||
status_result = await db.execute(
|
status_result = await db.execute(
|
||||||
select(
|
select(PaymentOrder.status, func.count().label("count"), func.coalesce(func.sum(PaymentOrder.amount), 0).label("amount"))
|
||||||
PaymentOrder.status,
|
.where(*filters)
|
||||||
func.count().label("count"),
|
|
||||||
func.coalesce(func.sum(PaymentOrder.amount), 0).label("amount"),
|
|
||||||
)
|
|
||||||
.where(*breakdown_filters)
|
|
||||||
.group_by(PaymentOrder.status)
|
.group_by(PaymentOrder.status)
|
||||||
)
|
)
|
||||||
for row in status_result.all():
|
for row in status_result.all():
|
||||||
if row.status in by_status:
|
if row.status not in by_status:
|
||||||
by_status[row.status] = {
|
by_status[row.status] = {
|
||||||
"count": row.count,
|
"label": PAYMENT_STATUS_LABELS.get(row.status, "其他状态"),
|
||||||
"amount": round(float(row.amount), 2)
|
"count": 0,
|
||||||
|
"amount": 0.0,
|
||||||
}
|
}
|
||||||
else:
|
by_status[row.status]["count"] = int(row.count or 0)
|
||||||
# Map any unexpected status to cancelled
|
by_status[row.status]["amount"] = round(float(row.amount or 0), 2)
|
||||||
by_status["cancelled"]["count"] += row.count
|
|
||||||
by_status["cancelled"]["amount"] += round(float(row.amount), 2)
|
|
||||||
|
|
||||||
# Today's stats (CST time zone) - independent of filter
|
source_filters = [PaymentOrder.status == "paid", PaymentOrder.created_at >= query_start, PaymentOrder.created_at < query_end]
|
||||||
today_result = await db.execute(
|
if payment_method:
|
||||||
select(
|
source_filters.append(PaymentOrder.payment_method == payment_method)
|
||||||
func.count().label("paid_count"),
|
if order_source:
|
||||||
func.coalesce(func.sum(PaymentOrder.amount), 0).label("paid_amount"),
|
source_filters.append(PaymentOrder.order_source == order_source)
|
||||||
).where(
|
source_result = await db.execute(
|
||||||
PaymentOrder.status == "paid",
|
select(PaymentOrder.order_source, func.count().label("count"), func.coalesce(func.sum(PaymentOrder.amount), 0).label("amount"))
|
||||||
PaymentOrder.paid_at >= today_start,
|
.where(*source_filters)
|
||||||
PaymentOrder.paid_at < today_end,
|
.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_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_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 {
|
return {
|
||||||
"by_status": by_status,
|
"by_status": by_status,
|
||||||
"today": {
|
"by_source": by_source,
|
||||||
"paid_count": today_row.paid_count,
|
"total_income": total_income,
|
||||||
"paid_amount": round(float(today_row.paid_amount), 2),
|
"today": await _period_income(today_start, today_end),
|
||||||
},
|
"month": await _period_income(month_start, month_end),
|
||||||
"month": {
|
|
||||||
"paid_count": month_row.paid_count,
|
|
||||||
"paid_amount": round(float(month_row.paid_amount), 2),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -915,6 +938,7 @@ async def list_payment_orders(
|
|||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
page_size: int = Query(20, ge=1, le=500),
|
page_size: int = Query(20, ge=1, le=500),
|
||||||
payment_method: str | None = Query(None),
|
payment_method: str | None = Query(None),
|
||||||
|
order_source: str | None = Query(None, pattern="^(online_payment|admin_offline)$"),
|
||||||
status: str | None = Query(None),
|
status: str | None = Query(None),
|
||||||
phone: str | None = Query(None, description="按用户手机号模糊搜索"),
|
phone: str | None = Query(None, description="按用户手机号模糊搜索"),
|
||||||
start_date: str | None = Query(None),
|
start_date: str | None = Query(None),
|
||||||
@@ -922,13 +946,15 @@ async def list_payment_orders(
|
|||||||
admin: User = Depends(get_admin_user),
|
admin: User = Depends(get_admin_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
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)
|
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 = []
|
filters = []
|
||||||
if payment_method:
|
if payment_method:
|
||||||
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:
|
if status:
|
||||||
filters.append(PaymentOrder.status == status)
|
filters.append(PaymentOrder.status == status)
|
||||||
if phone:
|
if phone:
|
||||||
@@ -937,43 +963,68 @@ async def list_payment_orders(
|
|||||||
filters.append(PaymentOrder.created_at >= datetime.fromisoformat(start_date).replace(tzinfo=CST))
|
filters.append(PaymentOrder.created_at >= datetime.fromisoformat(start_date).replace(tzinfo=CST))
|
||||||
if end_date:
|
if end_date:
|
||||||
filters.append(PaymentOrder.created_at < (datetime.fromisoformat(end_date) + timedelta(days=1)).replace(tzinfo=CST))
|
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:
|
total = int((await db.execute(count_query)).scalar() or 0)
|
||||||
query = query.where(f)
|
rows = (await db.execute(
|
||||||
count_query = count_query.where(f)
|
query.order_by(PaymentOrder.created_at.desc(), PaymentOrder.id.desc())
|
||||||
|
.offset((page - 1) * page_size)
|
||||||
total = (await db.execute(count_query)).scalar() or 0
|
.limit(page_size)
|
||||||
result = await db.execute(
|
)).all()
|
||||||
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
|
|
||||||
]
|
|
||||||
|
|
||||||
|
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}
|
return {"items": items, "total": total, "page": page, "page_size": page_size}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/payment-configs/{config_id}")
|
@router.put("/payment-configs/{config_id}")
|
||||||
async def update_payment_config(
|
async def update_payment_config(
|
||||||
config_id: str,
|
config_id: str,
|
||||||
@@ -1024,25 +1075,14 @@ async def refund_payment_order(
|
|||||||
admin: User = Depends(get_admin_user),
|
admin: User = Depends(get_admin_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Refund a paid payment order."""
|
"""保留退款 API 路由兼容旧客户端,但本版本明确不开放主动订单退款。"""
|
||||||
result = await process_refund(db, order_no)
|
del admin
|
||||||
if not result.get("success"):
|
exists = (await db.execute(
|
||||||
raise HTTPException(status_code=400, detail=result.get("message", "退款失败"))
|
select(PaymentOrder.id).where(PaymentOrder.order_no == order_no).limit(1)
|
||||||
await log_operation(
|
)).scalar_one_or_none()
|
||||||
db,
|
if not exists:
|
||||||
admin.id,
|
raise HTTPException(status_code=404, detail="订单不存在")
|
||||||
admin.username,
|
raise HTTPException(status_code=409, detail="当前版本暂未开放订单退款")
|
||||||
f"订单退款: {order_no}",
|
|
||||||
"POST",
|
|
||||||
f"/admin/payment-orders/{order_no}/refund",
|
|
||||||
detail=json.dumps(
|
|
||||||
{
|
|
||||||
"order_no": order_no,
|
|
||||||
},
|
|
||||||
ensure_ascii=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
# ── Industry Config ──────────────────────────────────────
|
# ── Industry Config ──────────────────────────────────────
|
||||||
|
|||||||
@@ -4,7 +4,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.dependencies import get_current_user, get_db
|
from app.dependencies import get_current_user, get_db
|
||||||
from app.models.credit.balance import UserCreditBalance
|
from app.models.credit.balance import UserCreditBalance
|
||||||
|
from app.models.credit.allocation import CreditRecordAllocation
|
||||||
from app.models.credit_record import CreditRecord
|
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.credit_ratio import CreditRatio
|
||||||
from app.models.image_engine import ImageEngine
|
from app.models.image_engine import ImageEngine
|
||||||
from app.models.user import User
|
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:
|
def _balance_to_out(item: UserCreditBalance, *, checked_at) -> CreditBalanceItemOut:
|
||||||
return CreditBalanceItemOut(
|
return CreditBalanceItemOut(
|
||||||
id=item.id,
|
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=item.credit_level,
|
||||||
|
credit_level_label=CREDIT_LEVEL_LABELS.get(item.credit_level, "其他积分等级"),
|
||||||
source_type=item.source_type,
|
source_type=item.source_type,
|
||||||
|
source_type_label=CREDIT_BALANCE_SOURCE_TYPE_LABELS.get(item.source_type, "其他来源"),
|
||||||
source_id=item.source_id,
|
source_id=item.source_id,
|
||||||
product_id=item.product_id,
|
product_id=item.product_id,
|
||||||
payment_order_id=item.payment_order_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,
|
expires_at=item.expires_at,
|
||||||
last_usable_at=last_usable_at(item.expires_at),
|
last_usable_at=last_usable_at(item.expires_at),
|
||||||
status=effective_balance_status(item, request_time=checked_at),
|
status=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,
|
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("")
|
@router.get("")
|
||||||
async def get_credits(
|
async def get_credits(
|
||||||
page: int = Query(1, ge=1),
|
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()
|
total_granted, total_consumed, total_refunded, total_expired = totals_result.one()
|
||||||
return {
|
return {
|
||||||
**summary.to_dict(),
|
**summary.to_dict(),
|
||||||
"records": [CreditRecordOut.model_validate(r) for r in records],
|
"records": await _records_with_scope_amounts(db, records),
|
||||||
"total": total,
|
"total": total,
|
||||||
"total_granted": float(total_granted or 0),
|
"total_granted": float(total_granted or 0),
|
||||||
"total_consumed": float(total_consumed or 0),
|
"total_consumed": float(total_consumed or 0),
|
||||||
@@ -86,7 +148,12 @@ async def list_credit_balances(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
checked_at = utc_now()
|
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 = apply_balance_status_filter(stmt, status, request_time=checked_at)
|
||||||
stmt = stmt.order_by(UserCreditBalance.expires_at.asc(), UserCreditBalance.id.asc())
|
stmt = stmt.order_by(UserCreditBalance.expires_at.asc(), UserCreditBalance.id.asc())
|
||||||
result = await db.execute(stmt.offset((page - 1) * page_size).limit(page_size))
|
result = await db.execute(stmt.offset((page - 1) * page_size).limit(page_size))
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from app.dependencies import get_db, get_current_user
|
|||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.payment_order import PaymentOrder
|
from app.models.payment_order import PaymentOrder
|
||||||
from app.models.credit.product import CreditProduct
|
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.services.credit.utils import utc_now
|
||||||
from app.schemas.payment import RechargeRequest, PaymentOrderOut
|
from app.schemas.payment import RechargeRequest, PaymentOrderOut
|
||||||
from app.services.payment import (
|
from app.services.payment import (
|
||||||
@@ -60,6 +59,7 @@ async def recharge(
|
|||||||
select(CreditProduct).where(
|
select(CreditProduct).where(
|
||||||
CreditProduct.id == req.plan,
|
CreditProduct.id == req.plan,
|
||||||
CreditProduct.is_active.is_(True),
|
CreditProduct.is_active.is_(True),
|
||||||
|
CreditProduct.deleted_at.is_(None),
|
||||||
).limit(1)
|
).limit(1)
|
||||||
)
|
)
|
||||||
product = result.scalar_one_or_none()
|
product = result.scalar_one_or_none()
|
||||||
@@ -71,6 +71,7 @@ async def recharge(
|
|||||||
current_user.id,
|
current_user.id,
|
||||||
method=req.method,
|
method=req.method,
|
||||||
product_id=product.id,
|
product_id=product.id,
|
||||||
|
quantity=req.quantity,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from 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"}
|
return {"code": "SUCCESS", "message": "OK"}
|
||||||
|
|
||||||
# 处理退款回调
|
# 处理退款回调:本版本只记录渠道退款事实,不撤销订阅、Period、Seat、积分或首购资格。
|
||||||
elif event_type == "REFUND.SUCCESS":
|
elif event_type == "REFUND.SUCCESS":
|
||||||
order_no = decrypted_data.get("out_trade_no", "")
|
order_no = decrypted_data.get("out_trade_no", "")
|
||||||
refund_id = decrypted_data.get("refund_id", "")
|
refund_id = decrypted_data.get("refund_id", "")
|
||||||
refund_status = decrypted_data.get("status", "")
|
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":
|
if order_no and refund_status == "SUCCESS":
|
||||||
# 更新订单状态为已退款
|
result = await db.execute(
|
||||||
from app.models import PaymentOrder
|
select(PaymentOrder)
|
||||||
from sqlalchemy import select
|
.where(PaymentOrder.order_no == order_no)
|
||||||
|
.with_for_update()
|
||||||
result = await db.execute(select(PaymentOrder).where(PaymentOrder.order_no == order_no))
|
.limit(1)
|
||||||
|
)
|
||||||
order = result.scalar_one_or_none()
|
order = result.scalar_one_or_none()
|
||||||
|
if order:
|
||||||
if order and order.status == "refunding":
|
# 幂等记录渠道退款事实:即便升级前本地已经写成 refunded,
|
||||||
|
# 也要补齐渠道退款号/金额/时间;本版本绝不触碰任何订阅或积分权益。
|
||||||
order.status = "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()
|
await db.commit()
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"WeChat refund callback processed: order_no={order_no}, "
|
f"WECHAT_REFUND_CALLBACK_RECORDED order_no={order_no} "
|
||||||
f"refund_id={refund_id}, status={refund_status}"
|
f"refund_id={refund_id} amount={refund_amount} entitlement=record_only"
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"code": "SUCCESS", "message": "OK"}
|
return {"code": "SUCCESS", "message": "OK"}
|
||||||
@@ -325,6 +337,7 @@ async def list_orders(
|
|||||||
conditions.append(PaymentOrder.status == status_filter)
|
conditions.append(PaymentOrder.status == status_filter)
|
||||||
if invoice_mode:
|
if invoice_mode:
|
||||||
conditions.append(PaymentOrder.status == "paid")
|
conditions.append(PaymentOrder.status == "paid")
|
||||||
|
conditions.append(PaymentOrder.order_source == "online_payment")
|
||||||
if start_date:
|
if start_date:
|
||||||
start_dt = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
start_dt = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
||||||
conditions.append(PaymentOrder.created_at >= start_dt)
|
conditions.append(PaymentOrder.created_at >= start_dt)
|
||||||
@@ -451,8 +464,6 @@ async def cancel_order(
|
|||||||
)
|
)
|
||||||
|
|
||||||
order.status = "cancelled"
|
order.status = "cancelled"
|
||||||
if order.upgrade_period_ids_json:
|
|
||||||
await release_upgrade_reservation(db, order=order, released_at=utc_now())
|
|
||||||
await db.flush()
|
await db.flush()
|
||||||
logger.info(
|
logger.info(
|
||||||
f"ORDER_CANCELLED order_no={order_no} user={current_user.id} amount={order.amount}"
|
f"ORDER_CANCELLED order_no={order_no} user={current_user.id} amount={order.amount}"
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ async def list_active_packages(
|
|||||||
.where(
|
.where(
|
||||||
CreditProduct.product_type == CreditProductType.CREDIT_ADDON.value,
|
CreditProduct.product_type == CreditProductType.CREDIT_ADDON.value,
|
||||||
CreditProduct.is_active.is_(True),
|
CreditProduct.is_active.is_(True),
|
||||||
|
CreditProduct.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
.order_by(CreditProduct.sort_order.asc(), CreditProduct.id.asc())
|
.order_by(CreditProduct.sort_order.asc(), CreditProduct.id.asc())
|
||||||
)
|
)
|
||||||
|
|||||||
+267
-208
@@ -1,38 +1,74 @@
|
|||||||
from __future__ import annotations
|
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, Body, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy import func, select
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from starlette.responses import StreamingResponse
|
from starlette.responses import StreamingResponse
|
||||||
|
|
||||||
from app.dependencies import get_current_user, get_db, get_optional_current_user
|
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 import Team
|
||||||
from app.models.team_join_request import TeamJoinRequest
|
from app.models.team_join_request import TeamJoinRequest
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.team_invitation import TeamInvitationCreate, TeamInvitationOut
|
from app.schemas.team_invitation import TeamInvitationCreate, TeamInvitationOut
|
||||||
from app.schemas.team_join_request import (
|
from app.schemas.team_join_request import JoinByCodeRequest, JoinRequestHandle, JoinRequestOut, JoinTeamInfoOut
|
||||||
JoinByCodeRequest,
|
from app.schemas.team_manager import SetManagerRequest
|
||||||
JoinRequestHandle,
|
from app.schemas.team_subscription import TeamSeatCreateRequest, TeamSeatUpdateRequest
|
||||||
JoinRequestOut,
|
|
||||||
JoinTeamInfoOut,
|
|
||||||
)
|
|
||||||
from app.schemas.team_manager import (
|
|
||||||
ManagerTransferRequest,
|
|
||||||
)
|
|
||||||
from app.services import team_invitation_service
|
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 (
|
from app.services.team_manager_service import (
|
||||||
get_managed_team,
|
get_managed_team,
|
||||||
|
get_manager_history,
|
||||||
get_team_members,
|
get_team_members,
|
||||||
|
list_manager_access_teams,
|
||||||
|
set_team_manager,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/team", tags=["team"])
|
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")
|
@router.get("/managed")
|
||||||
async def get_managed_team_info(
|
async def get_managed_team_info(
|
||||||
current_user: User = Depends(get_current_user),
|
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)
|
team = await get_managed_team(db, current_user.id)
|
||||||
if not team:
|
if not team:
|
||||||
raise HTTPException(status_code=404, detail="您不是任何团队的管理人")
|
raise HTTPException(status_code=404, detail="您当前不是任何团队的队长")
|
||||||
|
|
||||||
from sqlalchemy import func
|
|
||||||
from app.enums.user import UserType
|
|
||||||
member_count = (await db.execute(
|
member_count = (await db.execute(
|
||||||
select(func.count(User.id)).where(
|
select(func.count(User.id)).where(
|
||||||
User.user_type == UserType.FRONTEND.value,
|
User.user_type == UserType.FRONTEND.value,
|
||||||
User.team_id == team.id,
|
User.team_id == team.id,
|
||||||
)
|
)
|
||||||
)).scalar() or 0
|
)).scalar() or 0
|
||||||
|
return _team_payload(team, manager_name=current_user.username, member_count=int(member_count))
|
||||||
|
|
||||||
return {
|
|
||||||
"id": team.id,
|
@router.get("/manager-access")
|
||||||
"name": team.name,
|
async def list_manager_access(
|
||||||
"code": team.code,
|
current_user: User = Depends(get_current_user),
|
||||||
"description": team.description,
|
db: AsyncSession = Depends(get_db),
|
||||||
"status": team.status,
|
):
|
||||||
"member_count": int(member_count),
|
"""当前及历史队长可访问的团队列表,用于历史团队流水入口。"""
|
||||||
"manager_id": team.manager_id,
|
return await list_manager_access_teams(db, current_user.id)
|
||||||
"manager_name": current_user.username,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ── 团队成员列表 ──────────────────────────────────────
|
|
||||||
@router.get("/members")
|
@router.get("/members")
|
||||||
async def list_team_members(
|
async def list_team_members(
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
@@ -73,23 +104,123 @@ async def list_team_members(
|
|||||||
):
|
):
|
||||||
team = await get_managed_team(db, current_user.id)
|
team = await get_managed_team(db, current_user.id)
|
||||||
if not team:
|
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)
|
return await get_team_members(db, team.id, page=page, page_size=page_size)
|
||||||
|
|
||||||
|
|
||||||
# ── 转账积分给成员 ────────────────────────────────────
|
@router.put("/manager")
|
||||||
@router.post("/members/{member_id}/credits")
|
async def transfer_manager(
|
||||||
async def transfer_credits(
|
req: SetManagerRequest,
|
||||||
member_id: str,
|
|
||||||
req: ManagerTransferRequest,
|
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
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("/members/{member_id}/credits")
|
||||||
@router.post("/invitations", )
|
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(
|
async def create_invitation(
|
||||||
req: TeamInvitationCreate,
|
req: TeamInvitationCreate,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
@@ -97,15 +228,13 @@ async def create_invitation(
|
|||||||
):
|
):
|
||||||
team = await get_managed_team(db, current_user.id)
|
team = await get_managed_team(db, current_user.id)
|
||||||
if not team:
|
if not team:
|
||||||
raise HTTPException(status_code=403, detail="只有团队管理人可创建邀请码")
|
raise HTTPException(status_code=403, detail="只有团队队长可创建邀请码")
|
||||||
|
|
||||||
expires_at = None
|
expires_at = None
|
||||||
if req.expires_at:
|
if req.expires_at:
|
||||||
try:
|
try:
|
||||||
expires_at = datetime.fromisoformat(req.expires_at)
|
expires_at = datetime.fromisoformat(req.expires_at)
|
||||||
except ValueError:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail="过期时间格式错误")
|
raise HTTPException(status_code=400, detail="过期时间格式错误") from exc
|
||||||
|
|
||||||
invitation = await team_invitation_service.create_invitation(
|
invitation = await team_invitation_service.create_invitation(
|
||||||
db, team.id, current_user.id, req.max_uses, expires_at
|
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)
|
team = await get_managed_team(db, current_user.id)
|
||||||
if not team:
|
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)
|
invitations = await team_invitation_service.get_invitations_for_team(db, team.id)
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -152,10 +281,9 @@ async def revoke_invitation(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
await team_invitation_service.revoke_invitation(db, invitation_id, current_user.id)
|
await team_invitation_service.revoke_invitation(db, invitation_id, current_user.id)
|
||||||
return {"message": "ok"}
|
return {"message": "邀请码已撤销"}
|
||||||
|
|
||||||
|
|
||||||
# ── 加入申请 ──────────────────────────────────────────
|
|
||||||
@router.post("/join")
|
@router.post("/join")
|
||||||
async def join_by_code(
|
async def join_by_code(
|
||||||
req: JoinByCodeRequest,
|
req: JoinByCodeRequest,
|
||||||
@@ -163,42 +291,43 @@ async def join_by_code(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
await team_invitation_service.create_join_request(db, current_user.id, req.invitation_code)
|
await team_invitation_service.create_join_request(db, current_user.id, req.invitation_code)
|
||||||
return {"message": "申请已提交,请等待团队管理人审批"}
|
return {"message": "申请已提交,请等待团队队长审批"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/join-info", )
|
async def _join_info_payload(
|
||||||
async def get_join_info(
|
db: AsyncSession,
|
||||||
code: str = Query(...),
|
*,
|
||||||
current_user: User | None = Depends(get_optional_current_user),
|
code: str,
|
||||||
db: AsyncSession = Depends(get_db),
|
current_user: User | None,
|
||||||
):
|
) -> JoinTeamInfoOut:
|
||||||
"""验证邀请码并返回团队信息(用于加入页面展示)。"""
|
|
||||||
invitation = await team_invitation_service.get_invitation_by_code(db, code)
|
invitation = await team_invitation_service.get_invitation_by_code(db, code)
|
||||||
if not invitation:
|
if not invitation:
|
||||||
return JoinTeamInfoOut(team_name="", team_id="", valid=False, already_in_team=False, has_pending_request=False)
|
return JoinTeamInfoOut(team_name="", team_id="", valid=False, already_in_team=False, has_pending_request=False)
|
||||||
|
team_result = await db.execute(
|
||||||
team = await db.execute(
|
select(Team).where(Team.id == invitation.team_id, Team.deleted_at.is_(None)).limit(1)
|
||||||
select(Team.name).where(Team.id == invitation.team_id, Team.deleted_at.is_(None)).limit(1)
|
|
||||||
)
|
)
|
||||||
team_name = team.scalar_one_or_none() or ""
|
team = team_result.scalar_one_or_none()
|
||||||
|
if not team or team.status != TeamStatus.ACTIVE.value:
|
||||||
already_in_team = current_user and current_user.team_id == invitation.team_id
|
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
|
has_pending_request = False
|
||||||
if current_user:
|
if current_user:
|
||||||
from app.models.team_join_request import TeamJoinRequest
|
|
||||||
pending = await db.execute(
|
pending = await db.execute(
|
||||||
select(TeamJoinRequest).where(
|
select(TeamJoinRequest.id).where(
|
||||||
TeamJoinRequest.user_id == current_user.id,
|
TeamJoinRequest.user_id == current_user.id,
|
||||||
TeamJoinRequest.team_id == invitation.team_id,
|
TeamJoinRequest.team_id == invitation.team_id,
|
||||||
TeamJoinRequest.status == "pending",
|
TeamJoinRequest.status == "pending",
|
||||||
).limit(1)
|
).limit(1)
|
||||||
)
|
)
|
||||||
has_pending = pending.scalar_one_or_none()
|
has_pending_request = pending.scalar_one_or_none() is not None
|
||||||
has_pending_request = has_pending is not None
|
|
||||||
|
|
||||||
return JoinTeamInfoOut(
|
return JoinTeamInfoOut(
|
||||||
team_name=team_name,
|
team_name=team.name,
|
||||||
team_id=invitation.team_id,
|
team_id=invitation.team_id,
|
||||||
valid=True,
|
valid=True,
|
||||||
already_in_team=already_in_team,
|
already_in_team=already_in_team,
|
||||||
@@ -206,31 +335,21 @@ async def get_join_info(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/join-info/public", )
|
@router.get("/join-info")
|
||||||
async def get_join_info_public(
|
async def get_join_info(
|
||||||
code: str = Query(...),
|
code: str = Query(...),
|
||||||
|
current_user: User | None = Depends(get_optional_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""公开接口:验证邀请码并返回团队信息(无需登录)。"""
|
return await _join_info_payload(db, code=code, current_user=current_user)
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@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(
|
async def list_join_requests(
|
||||||
status: str | None = Query(None),
|
status: str | None = Query(None),
|
||||||
current_user: User = Depends(get_current_user),
|
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)
|
team = await get_managed_team(db, current_user.id)
|
||||||
if not team:
|
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)
|
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 [
|
return [
|
||||||
JoinRequestOut(
|
JoinRequestOut(
|
||||||
id=r["id"],
|
id=item["id"],
|
||||||
team_id=r["team_id"],
|
team_id=item["team_id"],
|
||||||
team_name=team_name,
|
team_name=team.name,
|
||||||
user_id=r["user_id"],
|
user_id=item["user_id"],
|
||||||
username=r["username"],
|
username=item["username"],
|
||||||
phone=r.get("phone"),
|
phone=item.get("phone"),
|
||||||
status=r["status"],
|
status=item["status"],
|
||||||
note=r.get("note"),
|
note=item.get("note"),
|
||||||
created_at=r["created_at"],
|
created_at=item["created_at"],
|
||||||
handled_at=r.get("handled_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),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
await team_invitation_service.handle_join_request(
|
await team_invitation_service.handle_join_request(db, request_id, current_user.id, req.action, req.note)
|
||||||
db, request_id, current_user.id, req.action, req.note
|
return {"message": "申请已处理"}
|
||||||
)
|
|
||||||
return {"message": "ok"}
|
|
||||||
|
|
||||||
|
|
||||||
# ── 团队积分变动记录 ────────────────────────────────────
|
|
||||||
@router.get("/credit-records")
|
@router.get("/credit-records")
|
||||||
async def list_team_credit_records(
|
async def list_team_credit_records(
|
||||||
|
team_id: str | None = Query(None, description="历史队长查看旧团队时传团队ID"),
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
page_size: int = Query(20, ge=1, le=100),
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
user_id: str | None = Query(None),
|
user_id: str | None = Query(None),
|
||||||
phone: str | None = Query(None, description="按手机号搜索"),
|
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"),
|
start_date: str | None = Query(None, description="起始日期 YYYY-MM-DD"),
|
||||||
end_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),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""查看团队所有成员的积分变动记录(仅管理人)。"""
|
resolved_team_id = await _resolve_flow_team_id(db, current_user=current_user, team_id=team_id)
|
||||||
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_user_id = user_id
|
resolved_user_id = user_id
|
||||||
if phone and not user_id:
|
if phone and not resolved_user_id:
|
||||||
phone_result = await db.execute(
|
phone_result = await db.execute(select(User.id).where(User.phone == phone).limit(1))
|
||||||
select(User.id).where(
|
|
||||||
User.team_id == team.id,
|
|
||||||
User.phone == phone,
|
|
||||||
User.is_active.is_(True),
|
|
||||||
).limit(1)
|
|
||||||
)
|
|
||||||
resolved_user_id = phone_result.scalar_one_or_none()
|
resolved_user_id = phone_result.scalar_one_or_none()
|
||||||
if not resolved_user_id:
|
if not resolved_user_id:
|
||||||
return {"items": [], "total": 0, "summary": {}}
|
return {"items": [], "total": 0, "page": page, "page_size": page_size}
|
||||||
|
return await query_team_credit_records(
|
||||||
return await list_admin_credit_records(
|
|
||||||
db,
|
db,
|
||||||
|
team_id=resolved_team_id,
|
||||||
|
viewer_user_id=current_user.id,
|
||||||
page=page,
|
page=page,
|
||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
team_id=team.id,
|
member_user_id=resolved_user_id,
|
||||||
user_id=resolved_user_id,
|
subscription_id=subscription_id,
|
||||||
record_type=record_type,
|
record_type=record_type,
|
||||||
start_date=start_date,
|
start_date=start_date,
|
||||||
end_date=end_date,
|
end_date=end_date,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── 团队积分导出 Excel ──────────────────────────────────
|
|
||||||
@router.get("/credit-records/export")
|
@router.get("/credit-records/export")
|
||||||
async def export_team_credit_records(
|
async def export_team_credit_records(
|
||||||
|
team_id: str | None = Query(None),
|
||||||
user_id: str | None = Query(None),
|
user_id: str | None = Query(None),
|
||||||
phone: 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),
|
start_date: str | None = Query(None),
|
||||||
end_date: str | None = Query(None),
|
end_date: str | None = Query(None),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""导出团队积分变动记录为 Excel(仅管理人)。"""
|
resolved_team_id = await _resolve_flow_team_id(db, current_user=current_user, team_id=team_id)
|
||||||
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_user_id = user_id
|
resolved_user_id = user_id
|
||||||
if phone and not user_id:
|
if phone and not resolved_user_id:
|
||||||
phone_result = await db.execute(
|
phone_result = await db.execute(select(User.id).where(User.phone == phone).limit(1))
|
||||||
select(User.id).where(
|
|
||||||
User.team_id == team.id,
|
|
||||||
User.phone == phone,
|
|
||||||
User.is_active.is_(True),
|
|
||||||
).limit(1)
|
|
||||||
)
|
|
||||||
resolved_user_id = phone_result.scalar_one_or_none()
|
resolved_user_id = phone_result.scalar_one_or_none()
|
||||||
|
if not resolved_user_id:
|
||||||
# 拉取全部记录(不分页)
|
# 导出筛选手机号不存在时必须返回空结果,不能因为 user_id=None 退化成导出整个团队流水。
|
||||||
result = await list_admin_credit_records(
|
resolved_user_id = "__not_found__"
|
||||||
|
result = await query_team_credit_records(
|
||||||
db,
|
db,
|
||||||
|
team_id=resolved_team_id,
|
||||||
|
viewer_user_id=current_user.id,
|
||||||
page=1,
|
page=1,
|
||||||
page_size=10000,
|
page_size=10000,
|
||||||
team_id=team.id,
|
member_user_id=resolved_user_id,
|
||||||
user_id=resolved_user_id,
|
subscription_id=subscription_id,
|
||||||
record_type=record_type,
|
record_type=record_type,
|
||||||
start_date=start_date,
|
start_date=start_date,
|
||||||
end_date=end_date,
|
end_date=end_date,
|
||||||
)
|
)
|
||||||
|
team_result = await db.execute(select(Team).where(Team.id == resolved_team_id).limit(1))
|
||||||
# 生成 CSV(兼容 Excel 打开,UTF-8 BOM)
|
team = team_result.scalar_one_or_none()
|
||||||
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):
|
|
||||||
# 情况 3:ISO 字符串(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 "-"
|
|
||||||
|
|
||||||
output = io.StringIO()
|
output = io.StringIO()
|
||||||
writer = csv.writer(output)
|
writer = csv.writer(output)
|
||||||
writer.writerow(["用户名", "手机号", "类型", "积分变动", "余额", "说明", "时间"])
|
writer.writerow(["用户名", "流水类型", "团队积分变动", "说明", "订阅实例", "周期ID", "席位ID", "时间"])
|
||||||
for item in result.get("items", []):
|
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([
|
writer.writerow([
|
||||||
item.get("username") or "-",
|
item.get("username") or "-",
|
||||||
item.get("phone") or "-",
|
item.get("record_type_label") or "-",
|
||||||
item.get("record_type_label") or item.get("type") or "-",
|
item.get("team_amount", 0),
|
||||||
item.get("amount", 0),
|
|
||||||
item.get("balance_after", 0),
|
|
||||||
item.get("description") or "-",
|
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 "-",
|
||||||
])
|
])
|
||||||
|
filename = f"团队积分_{(team.name if team else resolved_team_id)}_{datetime.now(CST).strftime('%Y%m%d_%H%M%S')}.csv"
|
||||||
from starlette.responses import StreamingResponse
|
|
||||||
from urllib.parse import quote
|
|
||||||
output.seek(0)
|
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(
|
return StreamingResponse(
|
||||||
iter([output.getvalue()]),
|
iter(["\ufeff" + output.getvalue()]),
|
||||||
media_type="text/csv; charset=utf-8-sig",
|
media_type="text/csv; charset=utf-8",
|
||||||
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"},
|
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -133,3 +133,31 @@ class BillingBlockEventEnum(StrEnum):
|
|||||||
|
|
||||||
INSUFFICIENT_CREDITS = "BILLING_BLOCKED_INSUFFICIENT_CREDITS"
|
INSUFFICIENT_CREDITS = "BILLING_BLOCKED_INSUFFICIENT_CREDITS"
|
||||||
NEGATIVE_BALANCE = "BILLING_BLOCKED_NEGATIVE_BALANCE"
|
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: "其他-线下收款",
|
||||||
|
}
|
||||||
|
|||||||
@@ -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):
|
class CreditBalanceSourceType(StrEnum):
|
||||||
REGISTER_GIFT = "register_gift"
|
REGISTER_GIFT = "register_gift"
|
||||||
DAILY_LOGIN = "daily_login"
|
DAILY_LOGIN = "daily_login"
|
||||||
@@ -70,8 +87,6 @@ class CreditAllocationAction(StrEnum):
|
|||||||
REFUND_EXPIRED = "refund_expired"
|
REFUND_EXPIRED = "refund_expired"
|
||||||
EXPIRE = "expire"
|
EXPIRE = "expire"
|
||||||
REVOKE = "revoke"
|
REVOKE = "revoke"
|
||||||
UPGRADE_SOURCE_TRANSFER_OUT = "upgrade_source_transfer_out"
|
|
||||||
UPGRADE_SOURCE_TRANSFER_IN = "upgrade_source_transfer_in"
|
|
||||||
|
|
||||||
|
|
||||||
CREDIT_ALLOCATION_ACTION_LABELS = {
|
CREDIT_ALLOCATION_ACTION_LABELS = {
|
||||||
@@ -81,6 +96,4 @@ CREDIT_ALLOCATION_ACTION_LABELS = {
|
|||||||
CreditAllocationAction.REFUND_EXPIRED.value: "过期积分退款",
|
CreditAllocationAction.REFUND_EXPIRED.value: "过期积分退款",
|
||||||
CreditAllocationAction.EXPIRE.value: "积分过期",
|
CreditAllocationAction.EXPIRE.value: "积分过期",
|
||||||
CreditAllocationAction.REVOKE.value: "积分撤销",
|
CreditAllocationAction.REVOKE.value: "积分撤销",
|
||||||
CreditAllocationAction.UPGRADE_SOURCE_TRANSFER_OUT.value: "升级积分转出",
|
|
||||||
CreditAllocationAction.UPGRADE_SOURCE_TRANSFER_IN.value: "升级积分转入",
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,15 +5,30 @@ from enum import StrEnum
|
|||||||
|
|
||||||
class CreditProductType(StrEnum):
|
class CreditProductType(StrEnum):
|
||||||
SUBSCRIPTION = "subscription"
|
SUBSCRIPTION = "subscription"
|
||||||
|
TEAM_SUBSCRIPTION = "team_subscription"
|
||||||
CREDIT_ADDON = "credit_addon"
|
CREDIT_ADDON = "credit_addon"
|
||||||
|
|
||||||
|
|
||||||
|
CREDIT_PRODUCT_TYPE_LABELS = {
|
||||||
|
CreditProductType.SUBSCRIPTION.value: "个人订阅套餐",
|
||||||
|
CreditProductType.TEAM_SUBSCRIPTION.value: "团队订阅套餐",
|
||||||
|
CreditProductType.CREDIT_ADDON.value: "积分增值包",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionBillingCycle(StrEnum):
|
class SubscriptionBillingCycle(StrEnum):
|
||||||
MONTHLY = "monthly"
|
MONTHLY = "monthly"
|
||||||
QUARTERLY = "quarterly"
|
QUARTERLY = "quarterly"
|
||||||
YEARLY = "yearly"
|
YEARLY = "yearly"
|
||||||
|
|
||||||
|
|
||||||
|
SUBSCRIPTION_BILLING_CYCLE_LABELS = {
|
||||||
|
SubscriptionBillingCycle.MONTHLY.value: "月卡",
|
||||||
|
SubscriptionBillingCycle.QUARTERLY.value: "季卡",
|
||||||
|
SubscriptionBillingCycle.YEARLY.value: "年卡",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
SUBSCRIPTION_GRANT_COUNT = {
|
SUBSCRIPTION_GRANT_COUNT = {
|
||||||
SubscriptionBillingCycle.MONTHLY.value: 1,
|
SubscriptionBillingCycle.MONTHLY.value: 1,
|
||||||
SubscriptionBillingCycle.QUARTERLY.value: 3,
|
SubscriptionBillingCycle.QUARTERLY.value: 3,
|
||||||
@@ -28,8 +43,22 @@ class SubscriptionTierCode(StrEnum):
|
|||||||
SUPER = "super"
|
SUPER = "super"
|
||||||
|
|
||||||
|
|
||||||
|
SUBSCRIPTION_TIER_LABELS = {
|
||||||
|
SubscriptionTierCode.STARTER.value: "入门",
|
||||||
|
SubscriptionTierCode.STANDARD.value: "标准",
|
||||||
|
SubscriptionTierCode.ADVANCED.value: "高级",
|
||||||
|
SubscriptionTierCode.SUPER.value: "超级",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class ProductPriceType(StrEnum):
|
class ProductPriceType(StrEnum):
|
||||||
FIRST_PURCHASE = "first_purchase"
|
FIRST_PURCHASE = "first_purchase"
|
||||||
REGULAR = "regular"
|
REGULAR = "regular"
|
||||||
ACTIVITY = "activity"
|
ACTIVITY = "activity"
|
||||||
UPGRADE = "upgrade"
|
|
||||||
|
|
||||||
|
PRODUCT_PRICE_TYPE_LABELS = {
|
||||||
|
ProductPriceType.FIRST_PURCHASE.value: "首购价",
|
||||||
|
ProductPriceType.REGULAR.value: "常规价",
|
||||||
|
ProductPriceType.ACTIVITY.value: "活动价",
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,17 +7,27 @@ class CreditSubscriptionStatus(StrEnum):
|
|||||||
PENDING = "pending"
|
PENDING = "pending"
|
||||||
ACTIVE = "active"
|
ACTIVE = "active"
|
||||||
EXPIRED = "expired"
|
EXPIRED = "expired"
|
||||||
UPGRADED = "upgraded"
|
|
||||||
CANCELLED = "cancelled"
|
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):
|
class CreditSubscriptionPeriodStatus(StrEnum):
|
||||||
SCHEDULED = "scheduled"
|
SCHEDULED = "scheduled"
|
||||||
UPGRADE_RESERVED = "upgrade_reserved"
|
|
||||||
GRANTED = "granted"
|
GRANTED = "granted"
|
||||||
CANCELLED_BY_UPGRADE = "cancelled_by_upgrade"
|
|
||||||
REVOKED_BY_UPGRADE = "revoked_by_upgrade"
|
|
||||||
CANCELLED = "cancelled"
|
CANCELLED = "cancelled"
|
||||||
EXPIRED = "expired"
|
EXPIRED = "expired"
|
||||||
|
|
||||||
|
|
||||||
|
CREDIT_SUBSCRIPTION_PERIOD_STATUS_LABELS = {
|
||||||
|
CreditSubscriptionPeriodStatus.SCHEDULED.value: "待发放",
|
||||||
|
CreditSubscriptionPeriodStatus.GRANTED.value: "已发放",
|
||||||
|
CreditSubscriptionPeriodStatus.CANCELLED.value: "已取消",
|
||||||
|
CreditSubscriptionPeriodStatus.EXPIRED.value: "已到期",
|
||||||
|
}
|
||||||
|
|||||||
@@ -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混用。
|
# 前端筛选“未分配团队”时使用的稳定哨兵值,不与真实团队ID混用。
|
||||||
TEAM_UNASSIGNED_VALUE = "__none__"
|
TEAM_UNASSIGNED_VALUE = "__none__"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from app.models.base import Base, TimestampMixin, SoftDeleteMixin, engine, async_session, init_database, close_database
|
from app.models.base import Base, TimestampMixin, SoftDeleteMixin, engine, async_session, init_database, close_database
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.team import Team
|
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_invitation import TeamInvitation
|
||||||
from app.models.team_join_request import TeamJoinRequest
|
from app.models.team_join_request import TeamJoinRequest
|
||||||
from app.models.project import Project
|
from app.models.project import Project
|
||||||
@@ -20,6 +21,7 @@ from app.models.recharge_package import RechargePackage
|
|||||||
from app.models.credit import (
|
from app.models.credit import (
|
||||||
CreditProduct, CreditRecordAllocation, UserCreditBalance,
|
CreditProduct, CreditRecordAllocation, UserCreditBalance,
|
||||||
UserCreditSubscription, UserCreditSubscriptionPeriod,
|
UserCreditSubscription, UserCreditSubscriptionPeriod,
|
||||||
|
TeamSubscriptionSeat, TeamSubscriptionSeatUsage,
|
||||||
)
|
)
|
||||||
from app.models.llm_billing import LlmBillingPolicyModel, LlmBillingExecution, LlmCallAttempt
|
from app.models.llm_billing import LlmBillingPolicyModel, LlmBillingExecution, LlmCallAttempt
|
||||||
from app.models.operation_log import OperationLog
|
from app.models.operation_log import OperationLog
|
||||||
@@ -56,6 +58,7 @@ __all__ = [
|
|||||||
"TokenUsage", "IndustryConfig", "VideoEngine", "CreditRatio",
|
"TokenUsage", "IndustryConfig", "VideoEngine", "CreditRatio",
|
||||||
"MenuConfig", "RechargePackage", "CreditProduct", "CreditRecordAllocation", "UserCreditBalance",
|
"MenuConfig", "RechargePackage", "CreditProduct", "CreditRecordAllocation", "UserCreditBalance",
|
||||||
"UserCreditSubscription", "UserCreditSubscriptionPeriod",
|
"UserCreditSubscription", "UserCreditSubscriptionPeriod",
|
||||||
|
"TeamSubscriptionSeat", "TeamSubscriptionSeatUsage", "TeamManagerHistory",
|
||||||
"LlmBillingPolicyModel", "LlmBillingExecution", "LlmCallAttempt",
|
"LlmBillingPolicyModel", "LlmBillingExecution", "LlmCallAttempt",
|
||||||
"OperationLog", "ContactRequest",
|
"OperationLog", "ContactRequest",
|
||||||
"ChatGenerationTask", "ChatGenerationTaskEvent", "ChatProviderCallLog", "VideoUpscaleTask",
|
"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.product import CreditProduct
|
||||||
from app.models.credit.subscription import UserCreditSubscription
|
from app.models.credit.subscription import UserCreditSubscription
|
||||||
from app.models.credit.subscription_period import UserCreditSubscriptionPeriod
|
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__ = [
|
__all__ = [
|
||||||
"CreditRecordAllocation",
|
"CreditRecordAllocation",
|
||||||
@@ -10,4 +12,6 @@ __all__ = [
|
|||||||
"CreditProduct",
|
"CreditProduct",
|
||||||
"UserCreditSubscription",
|
"UserCreditSubscription",
|
||||||
"UserCreditSubscriptionPeriod",
|
"UserCreditSubscriptionPeriod",
|
||||||
|
"TeamSubscriptionSeat",
|
||||||
|
"TeamSubscriptionSeatUsage",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from decimal import Decimal
|
|||||||
from sqlalchemy import DateTime, ForeignKey, Index, Numeric, String
|
from sqlalchemy import DateTime, ForeignKey, Index, Numeric, String
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.enums.credit_balance import CreditScope
|
||||||
from app.models.base import Base, TimestampMixin
|
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_balance", "credit_balance_id", "created_at"),
|
||||||
Index("ix_credit_record_allocations_user_time", "user_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_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)
|
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
|
String(32), ForeignKey("user_credit_balances.id", ondelete="RESTRICT"), nullable=False
|
||||||
)
|
)
|
||||||
user_id: Mapped[str] = mapped_column(
|
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(
|
source_allocation_id: Mapped[str | None] = mapped_column(
|
||||||
String(32), ForeignKey("credit_record_allocations.id", ondelete="SET NULL"), nullable=True
|
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)
|
request_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
|
||||||
credit_level_snapshot: Mapped[str] = mapped_column(String(32), 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_type_snapshot: Mapped[str] = mapped_column(String(48), nullable=False)
|
||||||
source_id_snapshot: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
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)
|
valid_from_snapshot: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
expires_at_snapshot: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
expires_at_snapshot: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from decimal import Decimal
|
|||||||
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, JSON, Numeric, String, text
|
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, JSON, Numeric, String, text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
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
|
from app.models.base import Base, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
@@ -23,19 +23,25 @@ class UserCreditBalance(Base, TimestampMixin):
|
|||||||
name="ck_user_credit_balances_amount_reconciled",
|
name="ck_user_credit_balances_amount_reconciled",
|
||||||
),
|
),
|
||||||
CheckConstraint("expires_at > valid_from", name="ck_user_credit_balances_valid_window"),
|
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(
|
Index(
|
||||||
"ix_user_credit_balances_spendable",
|
"ix_user_credit_balances_spendable",
|
||||||
"user_id",
|
"user_id", "credit_scope", "credit_level_rank", "expires_at", "valid_from", "id",
|
||||||
"credit_level_rank",
|
|
||||||
"expires_at",
|
|
||||||
"valid_from",
|
|
||||||
"id",
|
|
||||||
postgresql_where=text("unspent_amount > 0 AND revoked_at IS NULL"),
|
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(
|
Index(
|
||||||
"ix_user_credit_balances_expire_due",
|
"ix_user_credit_balances_expire_due",
|
||||||
"expires_at",
|
"expires_at", "id",
|
||||||
"id",
|
|
||||||
postgresql_where=text(
|
postgresql_where=text(
|
||||||
"unspent_amount > 0 AND expired_processed_at IS NULL AND revoked_at IS NULL"
|
"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)
|
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||||
user_id: Mapped[str] = mapped_column(
|
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: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||||
credit_level_rank: Mapped[int] = mapped_column(nullable=False, default=20, server_default="20")
|
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)
|
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)
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
|
||||||
status: Mapped[str] = mapped_column(
|
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)
|
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)
|
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|||||||
@@ -8,22 +8,38 @@ from sqlalchemy.orm import Mapped, mapped_column
|
|||||||
|
|
||||||
from app.enums.credit_balance import CreditLevel
|
from app.enums.credit_balance import CreditLevel
|
||||||
from app.enums.credit_product import CreditProductType
|
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"
|
__tablename__ = "credit_products"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index("uq_credit_products_code", "product_code", unique=True),
|
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("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(
|
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 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 first_purchase_price IS NOT NULL AND regular_price IS NOT NULL "
|
||||||
"AND grant_credits IS NULL AND validity_months IS 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)
|
description: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
features_json: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
features_json: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
# 订阅套餐字段
|
# 个人/团队订阅套餐字段
|
||||||
tier_code: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
tier_code: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||||
tier_rank: Mapped[int | None] = mapped_column(nullable=True)
|
tier_rank: Mapped[int | None] = mapped_column(nullable=True)
|
||||||
billing_cycle: Mapped[str | None] = mapped_column(String(24), 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_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_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)
|
activity_end_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
# 是否允许失去首购资格后的再次购买。不是自动续费开关,不会自动创建订单或扣款。
|
||||||
renewal_enabled: Mapped[bool] = mapped_column(
|
renewal_enabled: Mapped[bool] = mapped_column(
|
||||||
Boolean, nullable=False, default=True, server_default="true"
|
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)
|
grant_credits: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
|
||||||
validity_months: Mapped[int | None] = mapped_column(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(
|
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")
|
sort_order: Mapped[int] = mapped_column(nullable=False, default=0, server_default="0")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_subscription(self) -> bool:
|
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
|
@property
|
||||||
def is_credit_addon(self) -> bool:
|
def is_credit_addon(self) -> bool:
|
||||||
|
|||||||
@@ -13,16 +13,26 @@ from app.models.base import Base, TimestampMixin
|
|||||||
class UserCreditSubscription(Base, TimestampMixin):
|
class UserCreditSubscription(Base, TimestampMixin):
|
||||||
__tablename__ = "user_credit_subscriptions"
|
__tablename__ = "user_credit_subscriptions"
|
||||||
__table_args__ = (
|
__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_expire_due", "status", "expires_at", "id"),
|
||||||
Index("ix_user_credit_subscriptions_grant_due", "status", "next_grant_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_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)
|
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||||
user_id: Mapped[str] = mapped_column(
|
subscription_no: Mapped[str] = mapped_column(
|
||||||
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
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(
|
product_id: Mapped[str | None] = mapped_column(
|
||||||
String(32), ForeignKey("credit_products.id", ondelete="SET NULL"), nullable=True
|
String(32), ForeignKey("credit_products.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
@@ -34,21 +44,27 @@ class UserCreditSubscription(Base, TimestampMixin):
|
|||||||
server_default=CreditSubscriptionStatus.PENDING.value,
|
server_default=CreditSubscriptionStatus.PENDING.value,
|
||||||
)
|
)
|
||||||
purchase_scene: Mapped[str] = mapped_column(String(24), nullable=False)
|
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_code: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||||
tier_rank: Mapped[int] = mapped_column(nullable=False)
|
tier_rank: Mapped[int] = mapped_column(nullable=False)
|
||||||
billing_cycle: Mapped[str] = mapped_column(String(24), nullable=False)
|
billing_cycle: Mapped[str] = mapped_column(String(24), nullable=False)
|
||||||
|
|
||||||
anchor_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
anchor_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
start_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)
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
next_grant_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
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_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)
|
grant_count: Mapped[int] = mapped_column(nullable=False)
|
||||||
granted_count: Mapped[int] = mapped_column(nullable=False, default=0, server_default="0")
|
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)
|
paid_amount_snapshot: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
|
||||||
product_snapshot_json: Mapped[dict] = mapped_column(JSON, 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__ = (
|
__table_args__ = (
|
||||||
Index("uq_user_credit_subscription_periods_sequence", "subscription_id", "sequence", unique=True),
|
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_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("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)
|
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
|
String(32), ForeignKey("user_credit_balances.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
issued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), 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)
|
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"
|
||||||
|
)
|
||||||
@@ -3,9 +3,10 @@ from __future__ import annotations
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from decimal import Decimal
|
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 sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.enums.common import PaymentOrderSourceEnum
|
||||||
from app.models.base import Base, TimestampMixin
|
from app.models.base import Base, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
@@ -14,6 +15,8 @@ class PaymentOrder(Base, TimestampMixin):
|
|||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index("idx_payorder_user_status_created", "user_id", "status", "created_at"),
|
Index("idx_payorder_user_status_created", "user_id", "status", "created_at"),
|
||||||
Index("idx_payorder_status_created", "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)
|
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
|
String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||||
)
|
)
|
||||||
order_no: Mapped[str] = mapped_column(String(64), unique=True)
|
order_no: Mapped[str] = mapped_column(String(64), unique=True)
|
||||||
amount: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
|
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")
|
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))
|
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")
|
status: Mapped[str] = mapped_column(String(32), default="pending")
|
||||||
paid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
paid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
trade_no: Mapped[str | None] = mapped_column(String(128), 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)
|
refund_trade_no: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
refunded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), 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_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(
|
product_id: Mapped[str | None] = mapped_column(
|
||||||
String(32), ForeignKey("credit_products.id", ondelete="SET NULL"), nullable=True, index=True
|
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_code_snapshot: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
product_name_snapshot: Mapped[str | None] = mapped_column(String(96), 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)
|
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)
|
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)
|
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)
|
fulfilled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|||||||
@@ -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 sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from app.enums.team import TeamStatus
|
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="团队编码")
|
code: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="团队编码")
|
||||||
description: Mapped[str | None] = mapped_column(String(512), nullable=True, comment="团队备注")
|
description: Mapped[str | None] = mapped_column(String(512), nullable=True, comment="团队备注")
|
||||||
status: Mapped[str] = mapped_column(
|
status: Mapped[str] = mapped_column(
|
||||||
String(16),
|
String(16), default=TeamStatus.ACTIVE.value, server_default=TeamStatus.ACTIVE.value,
|
||||||
default=TeamStatus.ACTIVE.value,
|
nullable=False, index=True, comment="团队状态:active启用,disabled禁用",
|
||||||
server_default=TeamStatus.ACTIVE.value,
|
|
||||||
nullable=False,
|
|
||||||
index=True,
|
|
||||||
comment="团队状态:active启用,disabled禁用",
|
|
||||||
)
|
)
|
||||||
sort_order: Mapped[int] = mapped_column(
|
sort_order: Mapped[int] = mapped_column(
|
||||||
Integer,
|
Integer, default=0, server_default="0", nullable=False, index=True, comment="排序值,越小越靠前",
|
||||||
default=0,
|
|
||||||
server_default="0",
|
|
||||||
nullable=False,
|
|
||||||
index=True,
|
|
||||||
comment="排序值,越小越靠前",
|
|
||||||
)
|
)
|
||||||
manager_id: Mapped[str | None] = mapped_column(
|
manager_id: Mapped[str | None] = mapped_column(
|
||||||
String(32), ForeignKey("users.id"), nullable=True, index=True, comment="团队管理人ID"
|
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)
|
||||||
@@ -55,6 +55,9 @@ class AdminUserOut(BaseModel):
|
|||||||
email: str | None = None
|
email: str | None = None
|
||||||
phone: str | None = None
|
phone: str | None = None
|
||||||
credits: float
|
credits: float
|
||||||
|
personal_credits: float = 0
|
||||||
|
team_available_credits: float = 0
|
||||||
|
team_frozen_credits: float = 0
|
||||||
is_active: bool
|
is_active: bool
|
||||||
is_admin: bool
|
is_admin: bool
|
||||||
user_type: str = "frontend"
|
user_type: str = "frontend"
|
||||||
@@ -196,9 +199,32 @@ class AdminCreditRecordAllocationOut(BaseModel):
|
|||||||
credit_balance_id: str
|
credit_balance_id: str
|
||||||
source_allocation_id: str | None = None
|
source_allocation_id: str | None = None
|
||||||
allocation_action: str
|
allocation_action: str
|
||||||
|
allocation_action_label: str | None = None
|
||||||
amount: float
|
amount: float
|
||||||
credit_level: str
|
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: str
|
||||||
|
source_type_label: str | None = None
|
||||||
source_id: str | None = None
|
source_id: str | None = None
|
||||||
valid_from: str | None = None
|
valid_from: str | None = None
|
||||||
expires_at: str | None = None
|
expires_at: str | None = None
|
||||||
@@ -207,6 +233,28 @@ class AdminCreditRecordAllocationOut(BaseModel):
|
|||||||
consumed_before: float = 0.0
|
consumed_before: float = 0.0
|
||||||
consumed_after: 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):
|
class AdminCreditRecordOut(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
user_id: str
|
user_id: str
|
||||||
@@ -260,6 +308,11 @@ class AdminCreditRecordOut(BaseModel):
|
|||||||
llm_call_count: int = 0
|
llm_call_count: int = 0
|
||||||
llm_success_call_count: int = 0
|
llm_success_call_count: int = 0
|
||||||
llm_failed_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)
|
allocations: list[AdminCreditRecordAllocationOut] = Field(default_factory=list)
|
||||||
engine_type: str | None = None
|
engine_type: str | None = None
|
||||||
engine_id: str | None = None
|
engine_id: str | None = None
|
||||||
|
|||||||
@@ -2,18 +2,22 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
class CreditRecordOut(BaseModel):
|
class CreditRecordOut(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
type: str
|
type: str
|
||||||
|
type_label: str = ""
|
||||||
amount: float
|
amount: float
|
||||||
|
personal_amount: float = 0
|
||||||
|
team_amount: float = 0
|
||||||
balance_delta: float = 0
|
balance_delta: float = 0
|
||||||
expired_amount: float = 0
|
expired_amount: float = 0
|
||||||
balance_after: float
|
balance_after: float
|
||||||
description: str
|
description: str
|
||||||
billing_scene: str | None = None
|
billing_scene: str | None = None
|
||||||
|
billing_scene_label: str | None = None
|
||||||
scene_name_snapshot: str | None = None
|
scene_name_snapshot: str | None = None
|
||||||
input_tokens: int | None = None
|
input_tokens: int | None = None
|
||||||
output_tokens: int | None = None
|
output_tokens: int | None = None
|
||||||
@@ -29,8 +33,11 @@ class CreditRecordOut(BaseModel):
|
|||||||
class CreditBalanceOut(BaseModel):
|
class CreditBalanceOut(BaseModel):
|
||||||
credits: float
|
credits: float
|
||||||
available_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_expiring_credits: float = 0
|
||||||
next_expires_at: datetime | None = None
|
next_expires_at: datetime | None = None
|
||||||
next_last_usable_at: datetime | None = None
|
next_last_usable_at: datetime | None = None
|
||||||
records: list[CreditRecordOut]
|
records: list[CreditRecordOut] = Field(default_factory=list)
|
||||||
total: int = 0
|
total: int = 0
|
||||||
|
|||||||
@@ -7,8 +7,13 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
class CreditBalanceItemOut(BaseModel):
|
class CreditBalanceItemOut(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
|
credit_scope: str
|
||||||
|
credit_scope_label: str = ""
|
||||||
|
team_id: str | None = None
|
||||||
credit_level: str
|
credit_level: str
|
||||||
|
credit_level_label: str = ""
|
||||||
source_type: str
|
source_type: str
|
||||||
|
source_type_label: str = ""
|
||||||
source_id: str | None = None
|
source_id: str | None = None
|
||||||
product_id: str | None = None
|
product_id: str | None = None
|
||||||
payment_order_id: str | None = None
|
payment_order_id: str | None = None
|
||||||
@@ -23,12 +28,16 @@ class CreditBalanceItemOut(BaseModel):
|
|||||||
expires_at: datetime
|
expires_at: datetime
|
||||||
last_usable_at: datetime
|
last_usable_at: datetime
|
||||||
status: str
|
status: str
|
||||||
|
status_label: str = ""
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class CreditBalanceSummaryOut(BaseModel):
|
class CreditBalanceSummaryOut(BaseModel):
|
||||||
credits: float
|
credits: float
|
||||||
available_credits: float
|
available_credits: float
|
||||||
|
personal_credits: float = 0
|
||||||
|
team_available_credits: float = 0
|
||||||
|
team_frozen_credits: float = 0
|
||||||
next_expiring_credits: float
|
next_expiring_credits: float
|
||||||
next_expires_at: datetime | None = None
|
next_expires_at: datetime | None = None
|
||||||
next_last_usable_at: datetime | None = None
|
next_last_usable_at: datetime | None = None
|
||||||
|
|||||||
@@ -6,16 +6,21 @@ from typing import Literal
|
|||||||
from pydantic import BaseModel, Field, model_validator
|
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):
|
class CreditProductBase(BaseModel):
|
||||||
product_code: str = Field(..., min_length=1, max_length=64)
|
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)
|
name: str = Field(..., min_length=1, max_length=96)
|
||||||
description: str | None = Field(default=None, max_length=512)
|
description: str | None = Field(default=None, max_length=512)
|
||||||
features: list[str] = Field(default_factory=list)
|
features: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
tier_code: str | None = Field(default=None, max_length=32)
|
tier_code: str | None = Field(default=None, max_length=32)
|
||||||
tier_rank: int | None = Field(default=None, ge=1, le=999)
|
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)
|
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)
|
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)
|
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)
|
price: float = Field(default=0, ge=0, le=999999999.99)
|
||||||
grant_credits: float | None = Field(default=None, gt=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)
|
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)
|
currency: str = Field(default="CNY", min_length=1, max_length=8)
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
sort_order: int = Field(default=0, ge=-999999, le=999999)
|
sort_order: int = Field(default=0, ge=-999999, le=999999)
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_product_fields(self):
|
def validate_product_fields(self):
|
||||||
if self.product_type == "subscription":
|
if self.product_type in {"subscription", "team_subscription"}:
|
||||||
required = {
|
required = {
|
||||||
"tier_code": self.tier_code,
|
"tier_code": self.tier_code,
|
||||||
"tier_rank": self.tier_rank,
|
"tier_rank": self.tier_rank,
|
||||||
@@ -46,6 +51,8 @@ class CreditProductBase(BaseModel):
|
|||||||
missing = [name for name, value in required.items() if value is None]
|
missing = [name for name, value in required.items() if value is None]
|
||||||
if missing:
|
if missing:
|
||||||
raise ValueError(f"订阅套餐缺少字段: {', '.join(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 self.activity_price is not None:
|
||||||
if not self.activity_start_at or not self.activity_end_at:
|
if not self.activity_start_at or not self.activity_end_at:
|
||||||
raise ValueError("配置活动价时必须同时配置活动开始和结束时间")
|
raise ValueError("配置活动价时必须同时配置活动开始和结束时间")
|
||||||
@@ -54,10 +61,23 @@ class CreditProductBase(BaseModel):
|
|||||||
else:
|
else:
|
||||||
if self.grant_credits is None:
|
if self.grant_credits is None:
|
||||||
raise ValueError("积分增值包必须配置积分数量")
|
raise ValueError("积分增值包必须配置积分数量")
|
||||||
if self.price < 0:
|
if self.validity_months is None:
|
||||||
raise ValueError("增值包价格不能小于0")
|
self.validity_months = 1
|
||||||
if self.validity_months is not None and not 1 <= self.validity_months <= 36:
|
if any(
|
||||||
raise ValueError("积分增值包有效期必须为1-36个月")
|
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
|
return self
|
||||||
|
|
||||||
|
|
||||||
@@ -66,13 +86,10 @@ class CreditProductCreate(CreditProductBase):
|
|||||||
|
|
||||||
|
|
||||||
class CreditProductUpdate(BaseModel):
|
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)
|
name: str | None = Field(default=None, min_length=1, max_length=96)
|
||||||
description: str | None = Field(default=None, max_length=512)
|
description: str | None = Field(default=None, max_length=512)
|
||||||
features: list[str] | None = None
|
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)
|
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)
|
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)
|
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)
|
price: float | None = Field(default=None, ge=0, le=999999999.99)
|
||||||
grant_credits: float | None = Field(default=None, gt=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)
|
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)
|
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)
|
sort_order: int | None = Field(default=None, ge=-999999, le=999999)
|
||||||
|
|
||||||
|
|
||||||
@@ -93,16 +109,23 @@ class CreditProductRenewalUpdate(BaseModel):
|
|||||||
renewal_enabled: bool
|
renewal_enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
class CreditProductStatusUpdate(BaseModel):
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
class CreditProductOut(BaseModel):
|
class CreditProductOut(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
product_code: str
|
product_code: str
|
||||||
product_type: str
|
product_type: str
|
||||||
|
product_type_label: str = ""
|
||||||
name: str
|
name: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
features: list[str] = Field(default_factory=list)
|
features: list[str] = Field(default_factory=list)
|
||||||
tier_code: str | None = None
|
tier_code: str | None = None
|
||||||
|
tier_label: str | None = None
|
||||||
tier_rank: int | None = None
|
tier_rank: int | None = None
|
||||||
billing_cycle: str | None = None
|
billing_cycle: str | None = None
|
||||||
|
billing_cycle_label: str | None = None
|
||||||
monthly_grant_credits: float = 0
|
monthly_grant_credits: float = 0
|
||||||
grant_count: int = 1
|
grant_count: int = 1
|
||||||
first_purchase_price: float = 0
|
first_purchase_price: float = 0
|
||||||
@@ -115,20 +138,28 @@ class CreditProductOut(BaseModel):
|
|||||||
validity_months: int | None = None
|
validity_months: int | None = None
|
||||||
price: float
|
price: float
|
||||||
current_price: float
|
current_price: float
|
||||||
target_price: float | None = None
|
|
||||||
deduction_amount: float = 0
|
|
||||||
price_type: str | None = None
|
price_type: str | None = None
|
||||||
|
price_type_label: str | None = None
|
||||||
credit_level: str
|
credit_level: str
|
||||||
|
credit_level_label: str = ""
|
||||||
currency: str
|
currency: str
|
||||||
is_active: bool
|
is_active: bool
|
||||||
|
is_deleted: bool = False
|
||||||
|
deleted_at: datetime | None = None
|
||||||
|
status_label: str = ""
|
||||||
sort_order: int
|
sort_order: int
|
||||||
can_purchase: bool | None = None
|
can_purchase: bool | None = None
|
||||||
can_upgrade: bool = False
|
|
||||||
unavailable_reason: str | None = None
|
unavailable_reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class CreditProductCatalogOut(BaseModel):
|
class CreditProductCatalogOut(BaseModel):
|
||||||
subscription_products: list[CreditProductOut]
|
subscription_products: list[CreditProductOut]
|
||||||
|
team_subscription_products: list[CreditProductOut]
|
||||||
credit_addons: list[CreditProductOut]
|
credit_addons: list[CreditProductOut]
|
||||||
first_purchase_available: bool
|
personal_first_purchase_available: bool
|
||||||
current_subscription: dict | None = None
|
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 datetime import datetime
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
class CreditSubscriptionPeriodOut(BaseModel):
|
class CreditSubscriptionPeriodOut(BaseModel):
|
||||||
@@ -14,6 +14,7 @@ class CreditSubscriptionPeriodOut(BaseModel):
|
|||||||
grant_credits: float
|
grant_credits: float
|
||||||
allocated_paid_amount: float
|
allocated_paid_amount: float
|
||||||
status: str
|
status: str
|
||||||
|
status_label: str = ""
|
||||||
issued_balance_id: str | None = None
|
issued_balance_id: str | None = None
|
||||||
issued_at: datetime | None = None
|
issued_at: datetime | None = None
|
||||||
|
|
||||||
@@ -22,22 +23,46 @@ class CreditSubscriptionPeriodOut(BaseModel):
|
|||||||
|
|
||||||
class CreditSubscriptionOut(BaseModel):
|
class CreditSubscriptionOut(BaseModel):
|
||||||
id: str
|
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
|
product_id: str | None = None
|
||||||
payment_order_id: str
|
payment_order_id: str
|
||||||
status: str
|
status: str
|
||||||
|
status_label: str = ""
|
||||||
purchase_scene: str
|
purchase_scene: str
|
||||||
|
product_type_snapshot: str
|
||||||
|
product_type_label: str = ""
|
||||||
|
product_name_snapshot: str
|
||||||
tier_code: str
|
tier_code: str
|
||||||
tier_rank: int
|
tier_rank: int
|
||||||
billing_cycle: str
|
billing_cycle: str
|
||||||
|
billing_cycle_label: str = ""
|
||||||
anchor_at: datetime
|
anchor_at: datetime
|
||||||
start_at: datetime
|
start_at: datetime
|
||||||
expires_at: datetime
|
expires_at: datetime
|
||||||
next_grant_at: datetime | None = None
|
next_grant_at: datetime | None = None
|
||||||
monthly_grant_credits_snapshot: float
|
monthly_grant_credits_snapshot: float
|
||||||
|
monthly_total_credits_snapshot: float
|
||||||
|
quantity_snapshot: int = 1
|
||||||
grant_count: int
|
grant_count: int
|
||||||
granted_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
|
paid_amount_snapshot: float
|
||||||
source_subscription_id: str | None = None
|
periods: list[CreditSubscriptionPeriodOut] = Field(default_factory=list)
|
||||||
periods: list[CreditSubscriptionPeriodOut] = []
|
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
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)
|
||||||
|
|||||||
@@ -2,12 +2,40 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import datetime
|
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):
|
class RechargeRequest(BaseModel):
|
||||||
plan: str = Field(..., description="积分商品ID;兼容旧字段名plan")
|
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):
|
class PaymentOrderOut(BaseModel):
|
||||||
@@ -16,18 +44,51 @@ class PaymentOrderOut(BaseModel):
|
|||||||
amount: float
|
amount: float
|
||||||
credits: float
|
credits: float
|
||||||
payment_method: str
|
payment_method: str
|
||||||
|
payment_method_label: str = ""
|
||||||
|
order_source: str = "online_payment"
|
||||||
|
order_source_label: str = ""
|
||||||
status: str
|
status: str
|
||||||
|
status_label: str = ""
|
||||||
product_id: str | None = None
|
product_id: str | None = None
|
||||||
product_type: str | None = None
|
product_type: str | None = None
|
||||||
|
product_type_label: str | None = None
|
||||||
purchase_scene: str | None = None
|
purchase_scene: str | None = None
|
||||||
price_type: str | None = None
|
price_type: str | None = None
|
||||||
|
price_type_label: str | None = None
|
||||||
product_name_snapshot: str | None = None
|
product_name_snapshot: str | None = None
|
||||||
target_price_snapshot: float | None = None
|
quantity: int = 1
|
||||||
deduction_amount_snapshot: float | None = None
|
quoted_unit_price_snapshot: float | None = None
|
||||||
payable_amount_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: 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
|
qr_url: str | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
paid_at: datetime | None = None
|
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_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
|
||||||
|
|||||||
@@ -26,11 +26,15 @@ class TeamUpdate(BaseModel):
|
|||||||
|
|
||||||
class TeamOut(TeamBase):
|
class TeamOut(TeamBase):
|
||||||
id: str
|
id: str
|
||||||
|
status_label: str = ""
|
||||||
|
is_read_only: bool = False
|
||||||
|
team_credit_frozen: bool = False
|
||||||
member_count: int = 0
|
member_count: int = 0
|
||||||
created_at: NaiveDatetime
|
created_at: NaiveDatetime
|
||||||
updated_at: NaiveDatetimeOptional = None
|
updated_at: NaiveDatetimeOptional = None
|
||||||
manager_id: str | None = None
|
manager_id: str | None = None
|
||||||
manager_name: str | None = None
|
manager_name: str | None = None
|
||||||
|
first_subscription_paid_at: NaiveDatetimeOptional = None
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from app.schemas.common import NaiveDatetime
|
|||||||
|
|
||||||
|
|
||||||
class SetManagerRequest(BaseModel):
|
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):
|
class TeamMemberOut(BaseModel):
|
||||||
@@ -12,25 +12,22 @@ class TeamMemberOut(BaseModel):
|
|||||||
username: str
|
username: str
|
||||||
phone: str | None = None
|
phone: str | None = None
|
||||||
credits: float
|
credits: float
|
||||||
|
personal_credits: float = 0
|
||||||
|
team_available_credits: float = 0
|
||||||
|
team_frozen_credits: float = 0
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
joined_at: NaiveDatetime
|
joined_at: NaiveDatetime
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
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):
|
class ManagedTeamOut(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
code: str | None = None
|
code: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
status: str
|
status: str
|
||||||
|
status_label: str = ""
|
||||||
member_count: int = 0
|
member_count: int = 0
|
||||||
manager_id: str | None = None
|
manager_id: str | None = None
|
||||||
manager_name: 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 __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from decimal import Decimal
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import and_, case, distinct, func, or_, select
|
from sqlalchemy import and_, case, distinct, func, or_, select
|
||||||
@@ -10,6 +11,12 @@ from app.enums.credit_balance import (
|
|||||||
CREDIT_ALLOCATION_ACTION_LABELS,
|
CREDIT_ALLOCATION_ACTION_LABELS,
|
||||||
CREDIT_BALANCE_SOURCE_TYPE_LABELS,
|
CREDIT_BALANCE_SOURCE_TYPE_LABELS,
|
||||||
CREDIT_LEVEL_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 (
|
from app.enums.credit_record import (
|
||||||
CREDIT_RECORD_ACTION_LABELS,
|
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.enums.team import TEAM_UNASSIGNED_VALUE
|
||||||
from app.models.chat_generation_task import ChatGenerationTask
|
from app.models.chat_generation_task import ChatGenerationTask
|
||||||
from app.models.credit.allocation import CreditRecordAllocation
|
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.credit_record import CreditRecord
|
||||||
from app.models.generation_record import GenerationRecord
|
from app.models.generation_record import GenerationRecord
|
||||||
from app.models.module_generation_project import ModuleGenerationProject
|
from app.models.module_generation_project import ModuleGenerationProject
|
||||||
@@ -98,6 +107,7 @@ def _build_filters(
|
|||||||
user_type: str | None = None,
|
user_type: str | None = None,
|
||||||
frontend_user_kind: str | None = None,
|
frontend_user_kind: str | None = None,
|
||||||
team_id: str | None = None,
|
team_id: str | None = None,
|
||||||
|
subscription_no: str | None = None,
|
||||||
record_type: str | None = None,
|
record_type: str | None = None,
|
||||||
credit_subject: str | None = None,
|
credit_subject: str | None = None,
|
||||||
media_type: str | None = None,
|
media_type: str | None = None,
|
||||||
@@ -125,6 +135,20 @@ def _build_filters(
|
|||||||
filters.append(CreditRecord.team_id_snapshot.is_(None))
|
filters.append(CreditRecord.team_id_snapshot.is_(None))
|
||||||
else:
|
else:
|
||||||
filters.append(CreditRecord.team_id_snapshot == team_id)
|
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:
|
if record_type:
|
||||||
filters.append(CreditRecord.type == record_type)
|
filters.append(CreditRecord.type == record_type)
|
||||||
if credit_subject:
|
if credit_subject:
|
||||||
@@ -212,6 +236,75 @@ def _normalized_description(record: CreditRecord) -> str | None:
|
|||||||
return description
|
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(
|
def _record_to_item(
|
||||||
record: CreditRecord,
|
record: CreditRecord,
|
||||||
user: User | None,
|
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)
|
user_type = record.user_type_snapshot or (user.user_type if user else None)
|
||||||
frontend_kind = record.frontend_user_kind_snapshot or (getattr(user, "frontend_user_kind", None) if user else None)
|
frontend_kind = record.frontend_user_kind_snapshot or (getattr(user, "frontend_user_kind", None) if user else None)
|
||||||
charge_action = _normalized_charge_action(record)
|
charge_action = _normalized_charge_action(record)
|
||||||
|
allocations = allocation_map.get(record.id, [])
|
||||||
|
funding_summary = _build_funding_summary(allocations)
|
||||||
return {
|
return {
|
||||||
"id": record.id,
|
"id": record.id,
|
||||||
"user_id": record.user_id,
|
"user_id": record.user_id,
|
||||||
@@ -279,7 +374,8 @@ def _record_to_item(
|
|||||||
"llm_call_count": record.llm_call_count or 0,
|
"llm_call_count": record.llm_call_count or 0,
|
||||||
"llm_success_call_count": record.llm_success_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,
|
"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_type": record.engine_type,
|
||||||
"engine_id": record.engine_id,
|
"engine_id": record.engine_id,
|
||||||
"engine_name": record.engine_name,
|
"engine_name": record.engine_name,
|
||||||
@@ -299,6 +395,7 @@ async def list_admin_credit_records(
|
|||||||
user_type: str | None = None,
|
user_type: str | None = None,
|
||||||
frontend_user_kind: str | None = None,
|
frontend_user_kind: str | None = None,
|
||||||
team_id: str | None = None,
|
team_id: str | None = None,
|
||||||
|
subscription_no: str | None = None,
|
||||||
record_type: str | None = None,
|
record_type: str | None = None,
|
||||||
credit_subject: str | None = None,
|
credit_subject: str | None = None,
|
||||||
media_type: str | None = None,
|
media_type: str | None = None,
|
||||||
@@ -320,6 +417,7 @@ async def list_admin_credit_records(
|
|||||||
user_type=user_type,
|
user_type=user_type,
|
||||||
frontend_user_kind=frontend_user_kind,
|
frontend_user_kind=frontend_user_kind,
|
||||||
team_id=team_id,
|
team_id=team_id,
|
||||||
|
subscription_no=subscription_no,
|
||||||
record_type=record_type,
|
record_type=record_type,
|
||||||
credit_subject=credit_subject,
|
credit_subject=credit_subject,
|
||||||
media_type=media_type,
|
media_type=media_type,
|
||||||
@@ -354,9 +452,45 @@ async def list_admin_credit_records(
|
|||||||
allocation_result = await db.execute(
|
allocation_result = await db.execute(
|
||||||
select(CreditRecordAllocation)
|
select(CreditRecordAllocation)
|
||||||
.where(CreditRecordAllocation.credit_record_id.in_(record_ids))
|
.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({
|
allocation_map.setdefault(allocation.credit_record_id, []).append({
|
||||||
"id": allocation.id,
|
"id": allocation.id,
|
||||||
"credit_balance_id": allocation.credit_balance_id,
|
"credit_balance_id": allocation.credit_balance_id,
|
||||||
@@ -366,6 +500,26 @@ async def list_admin_credit_records(
|
|||||||
"amount": _round2(allocation.amount),
|
"amount": _round2(allocation.amount),
|
||||||
"credit_level": allocation.credit_level_snapshot,
|
"credit_level": allocation.credit_level_snapshot,
|
||||||
"credit_level_label": _label(CREDIT_LEVEL_LABELS, allocation.credit_level_snapshot),
|
"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": allocation.source_type_snapshot,
|
||||||
"source_type_label": _label(CREDIT_BALANCE_SOURCE_TYPE_LABELS, allocation.source_type_snapshot),
|
"source_type_label": _label(CREDIT_BALANCE_SOURCE_TYPE_LABELS, allocation.source_type_snapshot),
|
||||||
"source_id": allocation.source_id_snapshot,
|
"source_id": allocation.source_id_snapshot,
|
||||||
|
|||||||
@@ -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 import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.enums.credit_record import CreditRecordBillingScene, CreditRecordType
|
||||||
from app.models.credit.allocation import CreditRecordAllocation
|
from app.models.credit.allocation import CreditRecordAllocation
|
||||||
from app.models.credit.balance import UserCreditBalance
|
from app.models.credit.balance import UserCreditBalance
|
||||||
|
from app.models.credit.subscription import UserCreditSubscription
|
||||||
from app.models.credit_record import CreditRecord
|
from app.models.credit_record import CreditRecord
|
||||||
from app.services.credit.locking import acquire_user_credit_lock
|
from app.services.credit.locking import acquire_user_credit_lock
|
||||||
from app.services.credit.query_service import get_available_credits
|
from app.services.credit.query_service import get_available_credits
|
||||||
@@ -45,6 +46,14 @@ async def archive_expired_user_balances(
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
current_available = await get_available_credits(db, user_id, request_time=checked_at)
|
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:
|
for balance in balances:
|
||||||
amount = to_credit_decimal(balance.unspent_amount)
|
amount = to_credit_decimal(balance.unspent_amount)
|
||||||
if amount <= 0:
|
if amount <= 0:
|
||||||
@@ -77,8 +86,14 @@ async def archive_expired_user_balances(
|
|||||||
amount=amount,
|
amount=amount,
|
||||||
request_time=checked_at,
|
request_time=checked_at,
|
||||||
credit_level_snapshot=balance.credit_level,
|
credit_level_snapshot=balance.credit_level,
|
||||||
|
credit_scope_snapshot=balance.credit_scope or CreditScope.PERSONAL.value,
|
||||||
source_type_snapshot=balance.source_type,
|
source_type_snapshot=balance.source_type,
|
||||||
source_id_snapshot=balance.source_id,
|
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,
|
valid_from_snapshot=balance.valid_from,
|
||||||
expires_at_snapshot=balance.expires_at,
|
expires_at_snapshot=balance.expires_at,
|
||||||
unspent_before=amount,
|
unspent_before=amount,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,37 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
|
||||||
async def acquire_user_credit_lock(db: AsyncSession, user_id: str) -> None:
|
async def _acquire_key_lock(db: AsyncSession, lock_key: str) -> None:
|
||||||
"""PostgreSQL 事务级用户锁;SQLite 调试环境无需额外锁。"""
|
"""PostgreSQL事务级 advisory lock;SQLite 本地调试环境无需额外锁。"""
|
||||||
bind = db.get_bind()
|
bind = db.get_bind()
|
||||||
dialect_name = bind.dialect.name if bind is not None else ""
|
dialect_name = bind.dialect.name if bind is not None else ""
|
||||||
if dialect_name == "postgresql":
|
if dialect_name == "postgresql":
|
||||||
await db.execute(
|
await db.execute(
|
||||||
text("SELECT pg_advisory_xact_lock(hashtextextended(:lock_key, 0))"),
|
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 import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.enums.credit_balance import CREDIT_LEVEL_LABELS
|
||||||
from app.enums.credit_product import (
|
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,
|
CreditProductType,
|
||||||
ProductPriceType,
|
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.product import CreditProduct
|
||||||
from app.models.credit.subscription import UserCreditSubscription
|
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.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
|
from app.services.credit.utils import to_credit_decimal, utc_now
|
||||||
|
|
||||||
|
|
||||||
@@ -29,13 +32,16 @@ class ProductPriceQuote:
|
|||||||
product: CreditProduct
|
product: CreditProduct
|
||||||
purchase_scene: str
|
purchase_scene: str
|
||||||
price_type: str
|
price_type: str
|
||||||
base_price: Decimal
|
quoted_unit_price: Decimal
|
||||||
activity_price: Decimal | None
|
quoted_amount: Decimal
|
||||||
target_price: Decimal
|
quantity: int
|
||||||
deduction_amount: Decimal
|
first_purchase: bool
|
||||||
payable_amount: Decimal
|
|
||||||
source_subscription_id: str | None = None
|
|
||||||
upgrade_period_ids: tuple[str, ...] = ()
|
SUBSCRIPTION_PRODUCT_TYPES = {
|
||||||
|
CreditProductType.SUBSCRIPTION.value,
|
||||||
|
CreditProductType.TEAM_SUBSCRIPTION.value,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def grant_count_for_cycle(cycle: str | None) -> int:
|
def grant_count_for_cycle(cycle: str | None) -> int:
|
||||||
@@ -60,87 +66,153 @@ def current_product_price(
|
|||||||
*,
|
*,
|
||||||
first_purchase: bool,
|
first_purchase: bool,
|
||||||
request_time: datetime,
|
request_time: datetime,
|
||||||
upgrade: bool = False,
|
|
||||||
) -> tuple[Decimal, Decimal | None, Decimal, str]:
|
) -> tuple[Decimal, Decimal | None, Decimal, str]:
|
||||||
if product.product_type == CreditProductType.CREDIT_ADDON.value:
|
if product.product_type == CreditProductType.CREDIT_ADDON.value:
|
||||||
price = to_credit_decimal(product.price)
|
price = to_credit_decimal(product.price)
|
||||||
return price, None, price, ProductPriceType.REGULAR.value
|
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)
|
activity = activity_price_if_valid(product, request_time)
|
||||||
if activity is not None and activity < base:
|
if first_purchase:
|
||||||
return base, activity, activity, ProductPriceType.ACTIVITY.value
|
first_price = to_credit_decimal(product.first_purchase_price)
|
||||||
return base, activity, base, (
|
return first_price, activity, first_price, ProductPriceType.FIRST_PURCHASE.value
|
||||||
ProductPriceType.UPGRADE.value
|
regular_price = to_credit_decimal(product.regular_price)
|
||||||
if upgrade
|
if activity is not None:
|
||||||
else ProductPriceType.FIRST_PURCHASE.value if first_purchase else ProductPriceType.REGULAR.value
|
return regular_price, activity, activity, ProductPriceType.ACTIVITY.value
|
||||||
)
|
return regular_price, None, regular_price, ProductPriceType.REGULAR.value
|
||||||
|
|
||||||
|
|
||||||
async def get_active_subscription(
|
def ensure_repeat_purchase_allowed(
|
||||||
db: AsyncSession,
|
product: CreditProduct,
|
||||||
user_id: str,
|
|
||||||
*,
|
*,
|
||||||
request_time: datetime | None = None,
|
first_purchase: bool,
|
||||||
for_update: bool = False,
|
) -> None:
|
||||||
) -> UserCreditSubscription | None:
|
"""校验订阅套餐续购开关。
|
||||||
checked_at = request_time or utc_now()
|
|
||||||
stmt = (
|
renewal_enabled 仅控制失去首购资格后的再次购买,不代表自动续费。
|
||||||
select(UserCreditSubscription)
|
已创建订单后续履约只读取订单快照,不再动态检查此开关。
|
||||||
.where(
|
"""
|
||||||
UserCreditSubscription.user_id == user_id,
|
if (
|
||||||
UserCreditSubscription.status == CreditSubscriptionStatus.ACTIVE.value,
|
product.product_type in SUBSCRIPTION_PRODUCT_TYPES
|
||||||
UserCreditSubscription.start_at <= checked_at,
|
and not first_purchase
|
||||||
UserCreditSubscription.expires_at > checked_at,
|
and not bool(product.renewal_enabled)
|
||||||
)
|
):
|
||||||
.order_by(UserCreditSubscription.start_at.desc(), UserCreditSubscription.id.desc())
|
raise ValueError("该套餐当前未开启续费,已失去首购资格后不能再次购买")
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
if for_update:
|
|
||||||
stmt = stmt.with_for_update()
|
|
||||||
result = await db.execute(stmt)
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
|
|
||||||
|
|
||||||
async def get_upgrade_deduction_preview(
|
def quote_product(
|
||||||
db: AsyncSession,
|
product: CreditProduct,
|
||||||
*,
|
*,
|
||||||
subscription: UserCreditSubscription,
|
first_purchase: bool,
|
||||||
request_time: datetime,
|
request_time: datetime,
|
||||||
) -> Decimal:
|
quantity: int = 1,
|
||||||
if subscription.billing_cycle not in {
|
) -> ProductPriceQuote:
|
||||||
SubscriptionBillingCycle.QUARTERLY.value,
|
if quantity < 1:
|
||||||
SubscriptionBillingCycle.YEARLY.value,
|
raise ValueError("购买数量必须大于0")
|
||||||
}:
|
if product.product_type != CreditProductType.TEAM_SUBSCRIPTION.value and quantity != 1:
|
||||||
return Decimal("0.00")
|
raise ValueError("个人订阅和积分增值包不支持购买数量")
|
||||||
result = await db.execute(
|
_, _, unit_price, price_type = current_product_price(
|
||||||
select(UserCreditSubscriptionPeriod.allocated_paid_amount).where(
|
product, first_purchase=first_purchase, request_time=request_time
|
||||||
UserCreditSubscriptionPeriod.subscription_id == subscription.id,
|
)
|
||||||
UserCreditSubscriptionPeriod.scheduled_at > request_time,
|
quoted_amount = to_credit_decimal(unit_price * quantity)
|
||||||
UserCreditSubscriptionPeriod.status == CreditSubscriptionPeriodStatus.SCHEDULED.value,
|
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]:
|
async def list_active_products(db: AsyncSession) -> list[CreditProduct]:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(CreditProduct)
|
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())
|
.order_by(CreditProduct.product_type.asc(), CreditProduct.sort_order.asc(), CreditProduct.id.asc())
|
||||||
)
|
)
|
||||||
return list(result.scalars().all())
|
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)
|
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:
|
if for_update:
|
||||||
stmt = stmt.with_for_update()
|
stmt = stmt.with_for_update()
|
||||||
result = await db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
return result.scalar_one_or_none()
|
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(
|
async def build_product_catalog(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
*,
|
*,
|
||||||
@@ -149,65 +221,66 @@ async def build_product_catalog(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
checked_at = request_time or utc_now()
|
checked_at = request_time or utc_now()
|
||||||
products = await list_active_products(db)
|
products = await list_active_products(db)
|
||||||
current = await get_active_subscription(db, user.id, request_time=checked_at)
|
personal_first_purchase = user.first_membership_paid_at is None
|
||||||
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] = []
|
subscription_products: list[dict] = []
|
||||||
|
team_subscription_products: list[dict] = []
|
||||||
credit_addons: 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:
|
for product in products:
|
||||||
if product.product_type == CreditProductType.CREDIT_ADDON.value:
|
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))
|
credit_addons.append(
|
||||||
continue
|
product_to_dict(
|
||||||
# 首订资格已经使用后,未开启续费的套餐不返回给客户端。
|
product,
|
||||||
# 该过滤同时适用于过期后的续费和有效订阅期间的升级入口,
|
user_price=to_credit_decimal(product.price),
|
||||||
# 避免仅靠客户端隐藏后仍可被直接构造请求购买。
|
price_type=ProductPriceType.REGULAR.value,
|
||||||
if not first_purchase and not bool(product.renewal_enabled):
|
can_purchase=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
can_purchase = current is None
|
is_team = product.product_type == CreditProductType.TEAM_SUBSCRIPTION.value
|
||||||
can_upgrade = False
|
first_purchase = team_first_purchase if is_team else personal_first_purchase
|
||||||
reason = None
|
if not first_purchase and not bool(product.renewal_enabled):
|
||||||
if current is not None:
|
continue
|
||||||
can_upgrade = (
|
_, _, unit_price, price_type = current_product_price(
|
||||||
product.billing_cycle == current.billing_cycle
|
|
||||||
and int(product.tier_rank or 0) > int(current.tier_rank or 0)
|
|
||||||
)
|
|
||||||
can_purchase = can_upgrade
|
|
||||||
if not can_upgrade:
|
|
||||||
reason = "当前订阅有效,暂不能续费;仅可升级同周期更高等级套餐"
|
|
||||||
_, _, target_price, price_type = current_product_price(
|
|
||||||
product,
|
product,
|
||||||
first_purchase=first_purchase,
|
first_purchase=first_purchase,
|
||||||
request_time=checked_at,
|
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(
|
item = product_to_dict(
|
||||||
product,
|
product,
|
||||||
user_price=user_price,
|
user_price=unit_price,
|
||||||
price_type=price_type,
|
price_type=price_type,
|
||||||
can_purchase=can_purchase,
|
can_purchase=(team_purchase_available if is_team else True),
|
||||||
target_price=target_price,
|
|
||||||
deduction_amount=deduction_amount,
|
|
||||||
)
|
)
|
||||||
item["can_upgrade"] = can_upgrade
|
if is_team:
|
||||||
item["unavailable_reason"] = reason
|
item["unavailable_reason"] = team_reason
|
||||||
subscription_products.append(item)
|
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 {
|
return {
|
||||||
"subscription_products": subscription_products,
|
"subscription_products": subscription_products,
|
||||||
|
"team_subscription_products": team_subscription_products,
|
||||||
"credit_addons": credit_addons,
|
"credit_addons": credit_addons,
|
||||||
"first_purchase_available": first_purchase,
|
"personal_first_purchase_available": personal_first_purchase,
|
||||||
"current_subscription": subscription_to_dict(current) if current else None,
|
"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,
|
user_price: Decimal | None = None,
|
||||||
price_type: str | None = None,
|
price_type: str | None = None,
|
||||||
can_purchase: bool | None = None,
|
can_purchase: bool | None = None,
|
||||||
target_price: Decimal | None = None,
|
|
||||||
deduction_amount: Decimal | None = None,
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
deleted = product.deleted_at is not None
|
||||||
|
if deleted:
|
||||||
|
status_label = "已删除"
|
||||||
|
elif product.is_active:
|
||||||
|
status_label = "已上架"
|
||||||
|
else:
|
||||||
|
status_label = "已下架"
|
||||||
return {
|
return {
|
||||||
"id": product.id,
|
"id": product.id,
|
||||||
"product_code": product.product_code,
|
"product_code": product.product_code,
|
||||||
"product_type": product.product_type,
|
"product_type": product.product_type,
|
||||||
|
"product_type_label": CREDIT_PRODUCT_TYPE_LABELS.get(product.product_type, "其他套餐类型"),
|
||||||
"name": product.name,
|
"name": product.name,
|
||||||
"description": product.description,
|
"description": product.description,
|
||||||
"features": product.features_json or [],
|
"features": product.features_json or [],
|
||||||
"tier_code": product.tier_code,
|
"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,
|
"tier_rank": product.tier_rank,
|
||||||
"billing_cycle": product.billing_cycle,
|
"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),
|
"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,
|
"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),
|
"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,
|
"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),
|
"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),
|
"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": 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": product.credit_level,
|
||||||
|
"credit_level_label": CREDIT_LEVEL_LABELS.get(product.credit_level, "其他积分等级"),
|
||||||
"currency": product.currency,
|
"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,
|
"sort_order": product.sort_order,
|
||||||
"can_purchase": can_purchase,
|
"can_purchase": can_purchase,
|
||||||
}
|
"unavailable_reason": None,
|
||||||
|
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,14 +8,25 @@ from typing import Iterable
|
|||||||
from sqlalchemy import and_, func, or_, select
|
from sqlalchemy import and_, func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.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.time_policy import last_usable_at
|
||||||
from app.services.credit.utils import to_credit_decimal, to_float, utc_now
|
from app.services.credit.utils import to_credit_decimal, to_float, utc_now
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True, frozen=True)
|
@dataclass(slots=True, frozen=True)
|
||||||
class CreditBalanceSummary:
|
class CreditBalanceSummary:
|
||||||
|
personal_credits: Decimal
|
||||||
|
team_available_credits: Decimal
|
||||||
|
team_frozen_credits: Decimal
|
||||||
available_credits: Decimal
|
available_credits: Decimal
|
||||||
next_expiring_credits: Decimal
|
next_expiring_credits: Decimal
|
||||||
next_expires_at: datetime | None
|
next_expires_at: datetime | None
|
||||||
@@ -23,6 +34,9 @@ class CreditBalanceSummary:
|
|||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
return {
|
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),
|
"available_credits": to_float(self.available_credits),
|
||||||
"credits": to_float(self.available_credits),
|
"credits": to_float(self.available_credits),
|
||||||
"next_expiring_credits": to_float(self.next_expiring_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,
|
db: AsyncSession,
|
||||||
user_id: str,
|
user_ids: Iterable[str],
|
||||||
*,
|
*,
|
||||||
request_time: datetime | None = None,
|
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()
|
checked_at = request_time or utc_now()
|
||||||
result = await db.execute(
|
personal_map = {user_id: Decimal("0.00") for user_id in ids}
|
||||||
select(func.coalesce(func.sum(UserCreditBalance.unspent_amount), 0)).where(
|
team_available_map = {user_id: Decimal("0.00") for user_id in ids}
|
||||||
UserCreditBalance.user_id == user_id,
|
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.valid_from <= checked_at,
|
||||||
UserCreditBalance.expires_at > checked_at,
|
UserCreditBalance.expires_at > checked_at,
|
||||||
UserCreditBalance.unspent_amount > 0,
|
UserCreditBalance.unspent_amount > 0,
|
||||||
UserCreditBalance.revoked_at.is_(None),
|
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(
|
async def get_balance_summary(
|
||||||
@@ -56,35 +161,20 @@ async def get_balance_summary(
|
|||||||
*,
|
*,
|
||||||
request_time: datetime | None = None,
|
request_time: datetime | None = None,
|
||||||
) -> CreditBalanceSummary:
|
) -> CreditBalanceSummary:
|
||||||
checked_at = request_time or utc_now()
|
return (await get_user_credit_summary_map(db, [user_id], request_time=request_time)).get(
|
||||||
available = await get_available_credits(db, user_id, request_time=checked_at)
|
user_id, _blank_summary()
|
||||||
expiry_result = await db.execute(
|
|
||||||
select(
|
|
||||||
UserCreditBalance.expires_at,
|
|
||||||
func.sum(UserCreditBalance.unspent_amount).label("amount"),
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
UserCreditBalance.user_id == user_id,
|
|
||||||
UserCreditBalance.valid_from <= checked_at,
|
|
||||||
UserCreditBalance.expires_at > checked_at,
|
|
||||||
UserCreditBalance.unspent_amount > 0,
|
|
||||||
UserCreditBalance.revoked_at.is_(None),
|
|
||||||
)
|
|
||||||
.group_by(UserCreditBalance.expires_at)
|
|
||||||
.order_by(UserCreditBalance.expires_at.asc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
row = expiry_result.first()
|
|
||||||
expires_at = row.expires_at if row else None
|
|
||||||
expiring = to_credit_decimal(row.amount if row else 0)
|
|
||||||
return CreditBalanceSummary(
|
|
||||||
available_credits=available,
|
|
||||||
next_expiring_credits=expiring,
|
|
||||||
next_expires_at=expires_at,
|
|
||||||
next_last_usable_at=last_usable_at(expires_at) if expires_at else None,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_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(
|
async def get_user_credit_map(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
user_ids: Iterable[str],
|
user_ids: Iterable[str],
|
||||||
@@ -92,31 +182,11 @@ async def get_user_credit_map(
|
|||||||
request_time: datetime | None = None,
|
request_time: datetime | None = None,
|
||||||
) -> dict[str, float]:
|
) -> dict[str, float]:
|
||||||
ids = list(dict.fromkeys(str(item) for item in user_ids if item))
|
ids = list(dict.fromkeys(str(item) for item in user_ids if item))
|
||||||
if not ids:
|
summaries = await get_user_credit_summary_map(db, ids, request_time=request_time)
|
||||||
return {}
|
return {user_id: to_float(summaries.get(user_id, _blank_summary()).available_credits) for user_id in ids}
|
||||||
checked_at = request_time or utc_now()
|
|
||||||
result = await db.execute(
|
|
||||||
select(
|
|
||||||
UserCreditBalance.user_id,
|
|
||||||
func.coalesce(func.sum(UserCreditBalance.unspent_amount), 0).label("credits"),
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
UserCreditBalance.user_id.in_(ids),
|
|
||||||
UserCreditBalance.valid_from <= checked_at,
|
|
||||||
UserCreditBalance.expires_at > checked_at,
|
|
||||||
UserCreditBalance.unspent_amount > 0,
|
|
||||||
UserCreditBalance.revoked_at.is_(None),
|
|
||||||
)
|
|
||||||
.group_by(UserCreditBalance.user_id)
|
|
||||||
)
|
|
||||||
output = {user_id: 0.0 for user_id in ids}
|
|
||||||
for row in result:
|
|
||||||
output[str(row.user_id)] = to_float(row.credits)
|
|
||||||
return output
|
|
||||||
|
|
||||||
|
|
||||||
def attach_credit_snapshot(user: object, credits: Decimal | float | int) -> object:
|
def attach_credit_snapshot(user: object, credits: Decimal | float | int) -> object:
|
||||||
# SQLAlchemy Declarative 对象允许附加非映射运行时属性;不会写回 users 表。
|
|
||||||
setattr(user, "credits", to_float(to_credit_decimal(credits)))
|
setattr(user, "credits", to_float(to_credit_decimal(credits)))
|
||||||
return user
|
return user
|
||||||
|
|
||||||
@@ -138,13 +208,8 @@ def apply_balance_status_filter(stmt, status: str | None, *, request_time: datet
|
|||||||
if not status:
|
if not status:
|
||||||
return stmt
|
return stmt
|
||||||
if status == CreditBalanceStatus.REVOKED.value:
|
if status == CreditBalanceStatus.REVOKED.value:
|
||||||
return stmt.where(
|
return stmt.where(or_(UserCreditBalance.revoked_at.is_not(None), UserCreditBalance.revoked_amount > 0))
|
||||||
or_(UserCreditBalance.revoked_at.is_not(None), UserCreditBalance.revoked_amount > 0)
|
base_not_revoked = and_(UserCreditBalance.revoked_at.is_(None), UserCreditBalance.revoked_amount <= 0)
|
||||||
)
|
|
||||||
base_not_revoked = and_(
|
|
||||||
UserCreditBalance.revoked_at.is_(None),
|
|
||||||
UserCreditBalance.revoked_amount <= 0,
|
|
||||||
)
|
|
||||||
if status == CreditBalanceStatus.EXPIRED.value:
|
if status == CreditBalanceStatus.EXPIRED.value:
|
||||||
return stmt.where(base_not_revoked, UserCreditBalance.expires_at <= request_time)
|
return stmt.where(base_not_revoked, UserCreditBalance.expires_at <= request_time)
|
||||||
if status == CreditBalanceStatus.SCHEDULED.value:
|
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)
|
|
||||||
@@ -187,6 +187,7 @@ async def deduct_credits_result(
|
|||||||
allow_negative: bool = False,
|
allow_negative: bool = False,
|
||||||
create_zero_record: bool = False,
|
create_zero_record: bool = False,
|
||||||
request_time: datetime | None = None,
|
request_time: datetime | None = None,
|
||||||
|
allowed_scopes: set[str] | None = None,
|
||||||
) -> CreditMutationResult:
|
) -> CreditMutationResult:
|
||||||
"""旧调用兼容门面;新账本始终足额同步扣除,allow_negative 不再生效。"""
|
"""旧调用兼容门面;新账本始终足额同步扣除,allow_negative 不再生效。"""
|
||||||
return await deduct_dynamic_credits(
|
return await deduct_dynamic_credits(
|
||||||
@@ -201,6 +202,7 @@ async def deduct_credits_result(
|
|||||||
record_type=record_type,
|
record_type=record_type,
|
||||||
create_zero_record=create_zero_record,
|
create_zero_record=create_zero_record,
|
||||||
request_time=request_time,
|
request_time=request_time,
|
||||||
|
allowed_scopes=allowed_scopes,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -218,6 +220,7 @@ async def deduct_credits(
|
|||||||
allow_negative: bool = False,
|
allow_negative: bool = False,
|
||||||
create_zero_record: bool = False,
|
create_zero_record: bool = False,
|
||||||
request_time: datetime | None = None,
|
request_time: datetime | None = None,
|
||||||
|
allowed_scopes: set[str] | None = None,
|
||||||
) -> User:
|
) -> User:
|
||||||
return (
|
return (
|
||||||
await deduct_credits_result(
|
await deduct_credits_result(
|
||||||
@@ -233,6 +236,7 @@ async def deduct_credits(
|
|||||||
allow_negative=allow_negative,
|
allow_negative=allow_negative,
|
||||||
create_zero_record=create_zero_record,
|
create_zero_record=create_zero_record,
|
||||||
request_time=request_time,
|
request_time=request_time,
|
||||||
|
allowed_scopes=allowed_scopes,
|
||||||
)
|
)
|
||||||
).user
|
).user
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,11 @@ async def create_invoice(
|
|||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=f"订单 {order.order_no} 未支付,无法开票",
|
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. 检查订单唯一性
|
# 2. 检查订单唯一性
|
||||||
occupied = await check_orders_available(db, data.order_ids)
|
occupied = await check_orders_available(db, data.order_ids)
|
||||||
|
|||||||
@@ -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.credit_record_meta_service import CreditRecordMeta, build_recharge_meta
|
||||||
from app.services.credits import add_credits, deduct_credits
|
from app.services.credits import add_credits, deduct_credits
|
||||||
from app.services.credit.product_service import product_to_dict
|
from app.enums.common import PaymentOrderSourceEnum
|
||||||
from app.services.credit.subscription_service import fulfill_payment_product, revoke_payment_order_credits
|
from app.enums.credit_product import CreditProductType
|
||||||
from app.services.credit.upgrade_service import quote_and_reserve_product_purchase, release_upgrade_reservation
|
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.services.credit.utils import to_credit_decimal, utc_now
|
||||||
from app.utils.id_gen import generate_id, generate_order_no
|
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
|
return False
|
||||||
|
|
||||||
order.status = "cancelled"
|
order.status = "cancelled"
|
||||||
if order.upgrade_period_ids_json:
|
|
||||||
await release_upgrade_reservation(db, order=order, released_at=utc_now())
|
|
||||||
await db.flush()
|
await db.flush()
|
||||||
logger.info(
|
logger.info(
|
||||||
f"ORDER_EXPIRED order_no={order.order_no} user={order.user_id} "
|
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_user_id = str(order.user_id)
|
||||||
log_amount = order.amount
|
log_amount = order.amount
|
||||||
order.status = "cancelled"
|
order.status = "cancelled"
|
||||||
if order.upgrade_period_ids_json:
|
|
||||||
await release_upgrade_reservation(
|
|
||||||
db, order=order, released_at=utc_now()
|
|
||||||
)
|
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
expired_count += 1
|
expired_count += 1
|
||||||
@@ -420,9 +417,14 @@ async def create_recharge_order(
|
|||||||
method: str = "wechat",
|
method: str = "wechat",
|
||||||
*,
|
*,
|
||||||
product_id: str | None = None,
|
product_id: str | None = None,
|
||||||
|
quantity: int = 1,
|
||||||
request_time: datetime | None = None,
|
request_time: datetime | None = None,
|
||||||
) -> PaymentOrder:
|
) -> PaymentOrder:
|
||||||
"""创建支付订单;支付渠道流程保持原样,仅增加积分商品快照和订阅升级预留。"""
|
"""创建线上支付订单。
|
||||||
|
|
||||||
|
商品订单在创建时冻结商品、定价和数量快照;之后商品改价、下架或软删除均不改变订单合同。
|
||||||
|
个人/团队订阅存在未完成订单时全局阻止再次创建订阅订单。
|
||||||
|
"""
|
||||||
db_configs = await _get_payment_configs(db)
|
db_configs = await _get_payment_configs(db)
|
||||||
mock_mode = _is_mock_mode(db_configs)
|
mock_mode = _is_mock_mode(db_configs)
|
||||||
if not mock_mode:
|
if not mock_mode:
|
||||||
@@ -434,120 +436,184 @@ async def create_recharge_order(
|
|||||||
raise ValueError("支付宝支付未完成配置,请联系管理员")
|
raise ValueError("支付宝支付未完成配置,请联系管理员")
|
||||||
elif method == "wechat":
|
elif method == "wechat":
|
||||||
required_configs = [
|
required_configs = [
|
||||||
"payment_wechat_appid",
|
"payment_wechat_appid", "payment_wechat_mch_id", "payment_wechat_private_key",
|
||||||
"payment_wechat_mch_id",
|
"payment_wechat_cert_serial_no", "payment_wechat_api_v3_key",
|
||||||
"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)]
|
missing_configs = [key for key in required_configs if not db_configs.get(key)]
|
||||||
if missing_configs:
|
if missing_configs:
|
||||||
raise ValueError(f"微信支付未完成配置,缺少: {', '.join(missing_configs)},请联系管理员")
|
raise ValueError(f"微信支付未完成配置,缺少: {', '.join(missing_configs)},请联系管理员")
|
||||||
|
else:
|
||||||
|
raise ValueError("不支持的线上支付方式")
|
||||||
|
|
||||||
checked_at = request_time or utc_now()
|
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_id = generate_id()
|
||||||
order_no = generate_order_no()
|
order_no = generate_order_no()
|
||||||
product: CreditProduct | None = None
|
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(
|
order = PaymentOrder(
|
||||||
id=order_id,
|
id=order_id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
order_no=order_no,
|
order_no=order_no,
|
||||||
amount=price,
|
amount=final_price,
|
||||||
credits=round(float(credits or 0) + float(bonus_credits or 0), 2),
|
credits=total_credits,
|
||||||
payment_method=method,
|
payment_method=method,
|
||||||
|
order_source=PaymentOrderSourceEnum.ONLINE_PAYMENT.value,
|
||||||
status="pending",
|
status="pending",
|
||||||
purchase_scene="legacy_recharge",
|
product_id=product.id if product else None,
|
||||||
price_type="regular",
|
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,
|
product_name_snapshot=label,
|
||||||
target_price_snapshot=price,
|
product_snapshot_json=snapshot,
|
||||||
deduction_amount_snapshot=0,
|
quantity=int(quantity),
|
||||||
payable_amount_snapshot=price,
|
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)
|
db.add(order)
|
||||||
await db.flush()
|
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(
|
logger.info(
|
||||||
f"ORDER_CREATED order_no={order.order_no} user={user_id} amount={price} "
|
f"ORDER_CREATED order_no={order.order_no} user={user_id} amount={order.amount} "
|
||||||
f"credits={total_credits} method={method} product_id={product_id} mock={mock_mode}"
|
f"credits={order.credits} method={method} product_id={product_id} quantity={quantity} mock={mock_mode}"
|
||||||
)
|
)
|
||||||
|
|
||||||
if mock_mode:
|
if mock_mode:
|
||||||
order.status = "paid"
|
order.status = "paid"
|
||||||
order.paid_at = checked_at
|
order.paid_at = checked_at
|
||||||
desc = f"充值{label}({total_credits}积分)"
|
desc = f"充值{label}({order.credits}积分)"
|
||||||
if bonus_credits > 0:
|
await _fulfill_paid_order(db, order=order, fulfilled_at=checked_at, legacy_description=desc)
|
||||||
desc += f"(含赠送{bonus_credits}积分)"
|
|
||||||
await _fulfill_paid_order(
|
|
||||||
db,
|
|
||||||
order=order,
|
|
||||||
fulfilled_at=checked_at,
|
|
||||||
legacy_description=desc,
|
|
||||||
)
|
|
||||||
await db.flush()
|
await db.flush()
|
||||||
else:
|
elif method == "wechat":
|
||||||
if method == "wechat":
|
qr_code_content = _create_wechat_order(order, db_configs)
|
||||||
qr_code_content = _create_wechat_order(order, db_configs)
|
if not qr_code_content:
|
||||||
if qr_code_content:
|
raise ValueError("微信支付预下单失败,请检查配置或稍后重试")
|
||||||
order.qr_url = qr_code_content # type: ignore[attr-defined]
|
order.qr_url = qr_code_content # type: ignore[attr-defined]
|
||||||
else:
|
elif method == "alipay":
|
||||||
if quote and quote.upgrade_period_ids:
|
qr_url = _create_alipay_order(order, db_configs)
|
||||||
await release_upgrade_reservation(db, order=order, released_at=checked_at)
|
if not qr_url:
|
||||||
raise ValueError("微信支付预下单失败,请检查配置或稍后重试")
|
raise ValueError("支付宝预下单失败,请检查配置或稍后重试")
|
||||||
elif method == "alipay":
|
order.qr_url = qr_url # type: ignore[attr-defined]
|
||||||
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("支付宝预下单失败,请检查配置或稍后重试")
|
|
||||||
return order
|
return order
|
||||||
|
|
||||||
|
|
||||||
@@ -1238,10 +1304,6 @@ async def sync_pending_orders(db: AsyncSession) -> int:
|
|||||||
updated_count += 1
|
updated_count += 1
|
||||||
elif trade_status in ("TRADE_CLOSED", "TRADE_CANCELLED"):
|
elif trade_status in ("TRADE_CLOSED", "TRADE_CANCELLED"):
|
||||||
order.status = "cancelled"
|
order.status = "cancelled"
|
||||||
if order.upgrade_period_ids_json:
|
|
||||||
await release_upgrade_reservation(
|
|
||||||
db, order=order, released_at=utc_now()
|
|
||||||
)
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
updated_count += 1
|
updated_count += 1
|
||||||
|
|
||||||
@@ -1259,10 +1321,6 @@ async def sync_pending_orders(db: AsyncSession) -> int:
|
|||||||
updated_count += 1
|
updated_count += 1
|
||||||
elif trade_state in ("CLOSED", "REVOKED"):
|
elif trade_state in ("CLOSED", "REVOKED"):
|
||||||
order.status = "cancelled"
|
order.status = "cancelled"
|
||||||
if order.upgrade_period_ids_json:
|
|
||||||
await release_upgrade_reservation(
|
|
||||||
db, order=order, released_at=utc_now()
|
|
||||||
)
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
updated_count += 1
|
updated_count += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1596,86 +1654,18 @@ async def process_refund(
|
|||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
order_no: str,
|
order_no: str,
|
||||||
refund_amount: float | None = None,
|
refund_amount: float | None = None,
|
||||||
refund_reason: str = "管理员退款"
|
refund_reason: str = "管理员退款",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Process a refund for a paid order.
|
"""本版本保留退款 Service 入口,但主动订单退款统一关闭。"""
|
||||||
|
result = await db.execute(select(PaymentOrder).where(PaymentOrder.order_no == order_no).limit(1))
|
||||||
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)
|
|
||||||
)
|
|
||||||
order = result.scalar_one_or_none()
|
order = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not order:
|
if not order:
|
||||||
return {"success": False, "message": "订单不存在"}
|
return {"success": False, "message": "订单不存在"}
|
||||||
|
logger.warning(
|
||||||
if order.status != "paid":
|
"REFUND_BLOCKED order_no=%s user=%s requested_amount=%s reason=%s",
|
||||||
return {"success": False, "message": f"订单状态为{order.status},无法退款"}
|
order_no, order.user_id, refund_amount, refund_reason,
|
||||||
|
|
||||||
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}"
|
|
||||||
)
|
)
|
||||||
return {"success": True, "message": "退款成功"}
|
return {"success": False, "message": "当前版本暂未开放订单退款"}
|
||||||
|
|
||||||
|
|
||||||
async def _refund_alipay_order(
|
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_invitation import TeamInvitation
|
||||||
from app.models.team_join_request import TeamJoinRequest
|
from app.models.team_join_request import TeamJoinRequest
|
||||||
from app.models.user import User
|
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
|
from app.utils.id_gen import generate_id
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
@@ -44,7 +46,9 @@ async def create_invitation(
|
|||||||
expires_at: datetime | None = None,
|
expires_at: datetime | None = None,
|
||||||
) -> TeamInvitation:
|
) -> TeamInvitation:
|
||||||
"""创建邀请码(仅团队管理人)。"""
|
"""创建邀请码(仅团队管理人)。"""
|
||||||
|
await acquire_team_business_lock(db, team_id)
|
||||||
await _assert_is_manager(db, created_by, 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()
|
code = _generate_invite_code()
|
||||||
if expires_at is None:
|
if expires_at is None:
|
||||||
expires_at = datetime.now(timezone.utc) + timedelta(days=1)
|
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()
|
invitation = result.scalar_one_or_none()
|
||||||
if not invitation:
|
if not invitation:
|
||||||
raise HTTPException(status_code=404, detail="邀请码不存在")
|
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_is_manager(db, revoked_by, invitation.team_id)
|
||||||
|
await assert_team_active(db, invitation.team_id, for_update=True)
|
||||||
invitation.status = "revoked"
|
invitation.status = "revoked"
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
@@ -106,6 +112,8 @@ async def create_join_request(
|
|||||||
invitation = await get_invitation_by_code(db, invitation_code)
|
invitation = await get_invitation_by_code(db, invitation_code)
|
||||||
if not invitation:
|
if not invitation:
|
||||||
raise HTTPException(status_code=400, detail="邀请码无效或已过期/已用完")
|
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(
|
user_result = await db.execute(
|
||||||
@@ -166,8 +174,20 @@ async def handle_join_request(
|
|||||||
note: str | None = None,
|
note: str | None = 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(
|
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()
|
request = result.scalar_one_or_none()
|
||||||
if not request:
|
if not request:
|
||||||
@@ -176,9 +196,9 @@ async def handle_join_request(
|
|||||||
raise HTTPException(status_code=400, detail="该申请已处理")
|
raise HTTPException(status_code=400, detail="该申请已处理")
|
||||||
|
|
||||||
await _assert_is_manager(db, manager_id, request.team_id)
|
await _assert_is_manager(db, manager_id, request.team_id)
|
||||||
|
await assert_team_active(db, request.team_id, for_update=True)
|
||||||
|
|
||||||
if action == "approve":
|
if action == "approve":
|
||||||
# 检查用户是否已在其他团队
|
|
||||||
user_result = await db.execute(
|
user_result = await db.execute(
|
||||||
select(User).where(User.id == request.user_id, User.is_active == True).limit(1)
|
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="用户不存在")
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
if user.team_id and user.team_id != request.team_id:
|
if user.team_id and user.team_id != request.team_id:
|
||||||
raise HTTPException(status_code=400, detail="用户已在其他团队中,无法加入")
|
raise HTTPException(status_code=400, detail="用户已在其他团队中,无法加入")
|
||||||
|
await set_frontend_user_team(db, user_id=user.id, team_id=request.team_id)
|
||||||
user.team_id = request.team_id
|
|
||||||
request.status = "approved"
|
request.status = "approved"
|
||||||
elif action == "reject":
|
elif action == "reject":
|
||||||
request.status = "rejected"
|
request.status = "rejected"
|
||||||
|
|||||||
@@ -3,61 +3,139 @@ from __future__ import annotations
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import select
|
from sqlalchemy import and_, func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.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 import Team
|
||||||
|
from app.models.team_manager_history import TeamManagerHistory
|
||||||
from app.models.user import User
|
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:
|
async def _assert_transfer_allowed(db: AsyncSession, team: Team) -> None:
|
||||||
"""设置团队管理人。user_id 为 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(
|
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()
|
team = result.scalar_one_or_none()
|
||||||
if not team:
|
if not team:
|
||||||
raise HTTPException(status_code=404, detail="团队不存在")
|
raise HTTPException(status_code=404, detail="团队不存在")
|
||||||
|
if team.manager_id == user_id:
|
||||||
if user_id is None:
|
|
||||||
team.manager_id = None
|
|
||||||
await db.flush()
|
|
||||||
await db.refresh(team)
|
|
||||||
return team
|
return team
|
||||||
|
await _assert_transfer_allowed(db, team)
|
||||||
|
|
||||||
# 验证用户存在、是前台用户、属于该团队
|
|
||||||
user_result = await db.execute(
|
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()
|
user = user_result.scalar_one_or_none()
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="用户不存在")
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
if user.user_type != UserType.FRONTEND.value:
|
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:
|
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
|
team.manager_id = user_id
|
||||||
await db.flush()
|
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
|
return team
|
||||||
|
|
||||||
|
|
||||||
async def is_team_manager(db: AsyncSession, user_id: str, team_id: str | None) -> bool:
|
async def is_team_manager(db: AsyncSession, user_id: str, team_id: str | None) -> bool:
|
||||||
"""判断用户是否是指定团队的管理人。"""
|
|
||||||
if not team_id:
|
if not team_id:
|
||||||
return False
|
return False
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Team.manager_id).where(Team.id == team_id, Team.deleted_at.is_(None)).limit(1)
|
select(Team.manager_id).where(Team.id == team_id, Team.deleted_at.is_(None)).limit(1)
|
||||||
)
|
)
|
||||||
manager_id = result.scalar_one_or_none()
|
return result.scalar_one_or_none() == user_id
|
||||||
return manager_id == user_id
|
|
||||||
|
|
||||||
|
|
||||||
async def get_managed_team(db: AsyncSession, user_id: str) -> Team | None:
|
async def get_managed_team(db: AsyncSession, user_id: str) -> Team | None:
|
||||||
"""获取用户管理的团队。"""
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Team).where(Team.manager_id == user_id, Team.deleted_at.is_(None)).limit(1)
|
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: int = 1,
|
||||||
page_size: int = 20,
|
page_size: int = 20,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""列出团队成员(仅前台用户)。"""
|
|
||||||
page = max(int(page or 1), 1)
|
page = max(int(page or 1), 1)
|
||||||
page_size = min(max(int(page_size or 20), 1), 100)
|
page_size = min(max(int(page_size or 20), 1), 100)
|
||||||
|
|
||||||
# 验证团队存在
|
|
||||||
team_result = await db.execute(
|
team_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)).limit(1)
|
||||||
)
|
)
|
||||||
if not team_result.scalar_one_or_none():
|
if not team_result.scalar_one_or_none():
|
||||||
raise HTTPException(status_code=404, detail="团队不存在")
|
raise HTTPException(status_code=404, detail="团队不存在")
|
||||||
|
|
||||||
# 总数
|
|
||||||
from sqlalchemy import func
|
|
||||||
total = (await db.execute(
|
total = (await db.execute(
|
||||||
select(func.count(User.id)).where(
|
select(func.count(User.id)).where(
|
||||||
User.user_type == UserType.FRONTEND.value,
|
User.user_type == UserType.FRONTEND.value,
|
||||||
@@ -91,10 +164,9 @@ async def get_team_members(
|
|||||||
User.is_active.is_(True),
|
User.is_active.is_(True),
|
||||||
)
|
)
|
||||||
)).scalar() or 0
|
)).scalar() or 0
|
||||||
|
|
||||||
# 列表
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(User).where(
|
select(User)
|
||||||
|
.where(
|
||||||
User.user_type == UserType.FRONTEND.value,
|
User.user_type == UserType.FRONTEND.value,
|
||||||
User.team_id == team_id,
|
User.team_id == team_id,
|
||||||
User.is_active.is_(True),
|
User.is_active.is_(True),
|
||||||
@@ -104,32 +176,100 @@ async def get_team_members(
|
|||||||
.limit(page_size)
|
.limit(page_size)
|
||||||
)
|
)
|
||||||
members = list(result.scalars().all())
|
members = list(result.scalars().all())
|
||||||
credit_map = await get_user_credit_map(db, [item.id for item in members])
|
summary_map = await get_user_credit_summary_map(db, [item.id for item in members])
|
||||||
for item in members:
|
items = []
|
||||||
attach_credit_snapshot(item, credit_map.get(item.id, 0.0))
|
for member in members:
|
||||||
|
summary = summary_map.get(member.id)
|
||||||
return {
|
credits = float(summary.available_credits if summary else 0)
|
||||||
"items": [
|
attach_credit_snapshot(member, credits)
|
||||||
|
items.append(
|
||||||
{
|
{
|
||||||
"id": m.id,
|
"id": member.id,
|
||||||
"username": m.username,
|
"username": member.username,
|
||||||
"phone": m.phone,
|
"phone": member.phone,
|
||||||
"credits": m.credits,
|
"credits": credits,
|
||||||
"is_active": m.is_active,
|
"personal_credits": float(summary.personal_credits if summary else 0),
|
||||||
"joined_at": m.created_at,
|
"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
|
)
|
||||||
],
|
return {"items": items, "total": int(total)}
|
||||||
"total": total,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def transfer_credits_to_member(
|
async def get_manager_history(db: AsyncSession, team_id: str) -> list[dict[str, Any]]:
|
||||||
db: AsyncSession,
|
result = await db.execute(
|
||||||
manager_id: str,
|
select(TeamManagerHistory, User.username)
|
||||||
target_member_id: str,
|
.join(User, User.id == TeamManagerHistory.manager_user_id)
|
||||||
amount: float,
|
.where(TeamManagerHistory.team_id == team_id)
|
||||||
direction: str = "increase", # "increase" 管理人→成员; "decrease" 成员扣减
|
.order_by(TeamManagerHistory.started_at.desc(), TeamManagerHistory.id.desc())
|
||||||
description: str | None = None,
|
)
|
||||||
) -> None:
|
return [
|
||||||
raise HTTPException(status_code=409, detail="当前版本积分暂未开放团队转账功能")
|
{
|
||||||
|
"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
|
||||||
|
|||||||
@@ -1,20 +1,32 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import HTTPException
|
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 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.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 import Team
|
||||||
|
from app.models.team_manager_history import TeamManagerHistory
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.team import TeamCreate, TeamUpdate
|
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
|
from app.utils.id_gen import generate_id
|
||||||
|
|
||||||
|
|
||||||
|
INCOMPLETE_ORDER_STATUSES = ("pending", "paid")
|
||||||
|
|
||||||
|
|
||||||
def _clean_text(value: str | None) -> str | None:
|
def _clean_text(value: str | None) -> str | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
@@ -25,36 +37,64 @@ def _clean_text(value: str | None) -> str | None:
|
|||||||
def _team_snapshot(team: Team | None) -> dict[str, Any]:
|
def _team_snapshot(team: Team | None) -> dict[str, Any]:
|
||||||
if not team:
|
if not team:
|
||||||
return {"team_id": None, "team_name": None}
|
return {"team_id": None, "team_name": None}
|
||||||
return {
|
return {"team_id": team.id, "team_name": getattr(team, "name", None)}
|
||||||
"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 {
|
return {
|
||||||
"id": team.id,
|
"id": team.id,
|
||||||
"name": team.name,
|
"name": team.name,
|
||||||
"code": getattr(team, "code", None),
|
"code": getattr(team, "code", None),
|
||||||
"description": getattr(team, "description", 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,
|
"sort_order": getattr(team, "sort_order", 0) or 0,
|
||||||
"member_count": int(member_count or 0),
|
"member_count": int(member_count or 0),
|
||||||
"created_at": team.created_at,
|
"created_at": team.created_at,
|
||||||
"updated_at": team.updated_at,
|
"updated_at": team.updated_at,
|
||||||
"manager_id": getattr(team, "manager_id", None),
|
"manager_id": getattr(team, "manager_id", None),
|
||||||
"manager_name": manager_name,
|
"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)
|
query = select(Team).where(Team.id == team_id).limit(1)
|
||||||
if not include_deleted:
|
if not include_deleted:
|
||||||
query = query.where(Team.deleted_at.is_(None))
|
query = query.where(Team.deleted_at.is_(None))
|
||||||
|
if for_update:
|
||||||
|
query = query.with_for_update()
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
return result.scalar_one_or_none()
|
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:
|
async def _get_team_name(db: AsyncSession, team_id: str | None) -> str | None:
|
||||||
if not team_id:
|
if not team_id:
|
||||||
return None
|
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()
|
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)]
|
conditions = [Team.deleted_at.is_(None)]
|
||||||
duplicate_filters = [Team.name == name]
|
duplicate_filters = [Team.name == name]
|
||||||
if code:
|
if code:
|
||||||
@@ -75,6 +121,22 @@ async def _assert_unique_team(db: AsyncSession, *, name: str, code: str | None,
|
|||||||
raise HTTPException(status_code=400, detail="团队名称或编码已存在")
|
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(
|
async def list_teams(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
*,
|
*,
|
||||||
@@ -85,7 +147,6 @@ async def list_teams(
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
page = max(int(page or 1), 1)
|
page = max(int(page or 1), 1)
|
||||||
page_size = min(max(int(page_size or 20), 1), 500)
|
page_size = min(max(int(page_size or 20), 1), 500)
|
||||||
|
|
||||||
filters: list[Any] = [Team.deleted_at.is_(None)]
|
filters: list[Any] = [Team.deleted_at.is_(None)]
|
||||||
kw = _clean_text(keyword)
|
kw = _clean_text(keyword)
|
||||||
if kw:
|
if kw:
|
||||||
@@ -105,7 +166,7 @@ async def list_teams(
|
|||||||
)
|
)
|
||||||
teams = list(result.scalars().all())
|
teams = list(result.scalars().all())
|
||||||
if not teams:
|
if not teams:
|
||||||
return {"items": [], "total": total}
|
return {"items": [], "total": int(total)}
|
||||||
|
|
||||||
team_ids = [team.id for team in teams]
|
team_ids = [team.id for team in teams]
|
||||||
member_result = await db.execute(
|
member_result = await db.execute(
|
||||||
@@ -114,26 +175,18 @@ async def list_teams(
|
|||||||
.group_by(User.team_id)
|
.group_by(User.team_id)
|
||||||
)
|
)
|
||||||
member_map = {row[0]: int(row[1] or 0) for row in member_result.all()}
|
member_map = {row[0]: int(row[1] or 0) for row in member_result.all()}
|
||||||
|
manager_ids = [team.manager_id for team in teams if team.manager_id]
|
||||||
# 批量获取管理人用户名
|
|
||||||
manager_ids = [getattr(t, "manager_id", None) for t in teams if getattr(t, "manager_id", None)]
|
|
||||||
manager_name_map: dict[str, str] = {}
|
manager_name_map: dict[str, str] = {}
|
||||||
if manager_ids:
|
if manager_ids:
|
||||||
mgr_result = await db.execute(
|
mgr_result = await db.execute(select(User.id, User.username).where(User.id.in_(manager_ids)))
|
||||||
select(User.id, User.username).where(User.id.in_(manager_ids))
|
|
||||||
)
|
|
||||||
manager_name_map = {row[0]: row[1] for row in mgr_result.all()}
|
manager_name_map = {row[0]: row[1] for row in mgr_result.all()}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"items": [
|
"items": [
|
||||||
_team_out_payload(
|
_team_out_payload(team, member_map.get(team.id, 0), manager_name_map.get(team.manager_id))
|
||||||
team,
|
|
||||||
member_map.get(team.id, 0),
|
|
||||||
manager_name_map.get(getattr(team, "manager_id", None)),
|
|
||||||
)
|
|
||||||
for team in teams
|
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,
|
"id": team.id,
|
||||||
"name": team.name,
|
"name": team.name,
|
||||||
"code": getattr(team, "code", None),
|
"code": team.code,
|
||||||
"status": getattr(team, "status", TeamStatus.ACTIVE.value),
|
"status": team.status,
|
||||||
|
"status_label": TEAM_STATUS_LABELS.get(team.status, "其他状态"),
|
||||||
}
|
}
|
||||||
for team in result.scalars().all()
|
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]:
|
async def batch_get_team_name_map(
|
||||||
"""Batch load team names for list pages. Avoid joining teams in high-frequency user queries."""
|
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]
|
ids = [team_id for team_id in dict.fromkeys(team_ids or []) if team_id]
|
||||||
if not ids:
|
if not ids:
|
||||||
return {}
|
return {}
|
||||||
result = await db.execute(
|
result = await db.execute(select(Team.id, Team.name).where(Team.id.in_(ids)))
|
||||||
select(Team.id, Team.name)
|
|
||||||
.where(Team.id.in_(ids), Team.deleted_at.is_(None))
|
|
||||||
)
|
|
||||||
return {row[0]: row[1] for row in result.all()}
|
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)
|
db.add(team)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await db.refresh(team)
|
|
||||||
return team
|
return team
|
||||||
|
|
||||||
|
|
||||||
async def update_team(db: AsyncSession, team_id: str, req: TeamUpdate) -> tuple[Team, dict[str, Any], dict[str, Any]]:
|
async def _next_auto_team_name(db: AsyncSession) -> str:
|
||||||
team = await _get_team(db, team_id)
|
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:
|
if not team:
|
||||||
raise HTTPException(status_code=404, detail="团队不存在")
|
raise HTTPException(status_code=404, detail="团队不存在")
|
||||||
|
|
||||||
before = {
|
before = {
|
||||||
"id": team.id,
|
"id": team.id,
|
||||||
"name": team.name,
|
"name": team.name,
|
||||||
"code": getattr(team, "code", None),
|
"code": team.code,
|
||||||
"description": getattr(team, "description", None),
|
"description": team.description,
|
||||||
"status": getattr(team, "status", TeamStatus.ACTIVE.value),
|
"status": team.status,
|
||||||
"sort_order": getattr(team, "sort_order", 0) or 0,
|
"sort_order": team.sort_order or 0,
|
||||||
}
|
}
|
||||||
name = req.name.strip()
|
name = req.name.strip()
|
||||||
code = _clean_text(req.code)
|
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.name = name
|
||||||
team.code = code
|
team.code = code
|
||||||
team.description = _clean_text(req.description)
|
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
|
team.sort_order = req.sort_order or 0
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await db.refresh(team)
|
|
||||||
|
|
||||||
after = {
|
after = {
|
||||||
"id": team.id,
|
"id": team.id,
|
||||||
"name": team.name,
|
"name": team.name,
|
||||||
"code": getattr(team, "code", None),
|
"code": team.code,
|
||||||
"description": getattr(team, "description", None),
|
"description": team.description,
|
||||||
"status": getattr(team, "status", TeamStatus.ACTIVE.value),
|
"status": team.status,
|
||||||
"sort_order": getattr(team, "sort_order", 0) or 0,
|
"sort_order": team.sort_order or 0,
|
||||||
}
|
}
|
||||||
return team, before, after
|
return team, before, after
|
||||||
|
|
||||||
|
|
||||||
async def soft_delete_team(db: AsyncSession, team_id: str) -> tuple[Team, dict[str, Any]]:
|
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:
|
if not team:
|
||||||
raise HTTPException(status_code=404, detail="团队不存在")
|
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
|
)).scalar() or 0
|
||||||
if member_count > 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 = {
|
before = {
|
||||||
"id": team.id,
|
"id": team.id,
|
||||||
"name": team.name,
|
"name": team.name,
|
||||||
"code": getattr(team, "code", None),
|
"code": team.code,
|
||||||
"status": getattr(team, "status", TeamStatus.ACTIVE.value),
|
"status": team.status,
|
||||||
"member_count": int(member_count or 0),
|
"member_count": int(member_count or 0),
|
||||||
}
|
}
|
||||||
team.deleted_at = datetime.now(timezone.utc)
|
team.deleted_at = checked_at
|
||||||
await db.flush()
|
await db.flush()
|
||||||
return team, before
|
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(
|
async def set_frontend_user_team(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
*,
|
*,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
team_id: str | None,
|
team_id: str | None,
|
||||||
) -> tuple[User, dict[str, Any], dict[str, Any]]:
|
) -> 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()
|
user = result.scalar_one_or_none()
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="用户不存在")
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
if user.user_type != UserType.FRONTEND.value:
|
if user.user_type != UserType.FRONTEND.value:
|
||||||
raise HTTPException(status_code=400, detail="仅前台用户支持设置团队")
|
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, for_update=bool(old_team_id)) if old_team_id else None
|
||||||
old_team = await _get_team(db, old_team_id, include_deleted=True) if old_team_id else None
|
|
||||||
before = _team_snapshot(old_team)
|
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
|
new_team: Team | None = None
|
||||||
if team_id:
|
if team_id:
|
||||||
new_team = await _get_team(db, team_id)
|
if not old_team_id and await _has_pending_team_purchase_without_team(db, user.id):
|
||||||
if not new_team:
|
raise HTTPException(status_code=409, detail="当前存在待处理的团队订阅订单,请先完成或取消订单")
|
||||||
raise HTTPException(status_code=404, detail="团队不存在")
|
new_team = await assert_team_active(db, team_id, for_update=True)
|
||||||
if getattr(new_team, "status", TeamStatus.ACTIVE.value) != TeamStatus.ACTIVE.value:
|
|
||||||
raise HTTPException(status_code=400, detail="禁用团队不能设置给用户")
|
|
||||||
|
|
||||||
|
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
|
user.team_id = team_id or None
|
||||||
await db.flush()
|
await db.flush()
|
||||||
after = _team_snapshot(new_team)
|
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
|
return user, before, after
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ async def _run_credit_maintenance_once(batch_size: int = 500) -> dict[str, Any]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
granted = 0
|
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:
|
async with async_session() as db:
|
||||||
try:
|
try:
|
||||||
changed = await grant_due_subscription_period_by_id(
|
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,
|
period_id=period_id,
|
||||||
subscription_id=subscription_id,
|
subscription_id=subscription_id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
|
team_id=team_id,
|
||||||
request_time=checked_at,
|
request_time=checked_at,
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|||||||
+613
File diff suppressed because one or more lines are too long
-613
File diff suppressed because one or more lines are too long
Vendored
+35
-35
@@ -1,36 +1,36 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" id="favicon" />
|
<link rel="icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" id="favicon" />
|
||||||
<script>
|
<script>
|
||||||
// 立即从 localStorage 设置 favicon,避免闪烁
|
// 立即从 localStorage 设置 favicon,避免闪烁
|
||||||
(function() {
|
(function() {
|
||||||
try {
|
try {
|
||||||
var cached = localStorage.getItem('siteInfo');
|
var cached = localStorage.getItem('siteInfo');
|
||||||
if (cached) {
|
if (cached) {
|
||||||
var info = JSON.parse(cached);
|
var info = JSON.parse(cached);
|
||||||
if (info.siteLogo) {
|
if (info.siteLogo) {
|
||||||
var link = document.getElementById('favicon');
|
var link = document.getElementById('favicon');
|
||||||
link.href = info.siteLogo;
|
link.href = info.siteLogo;
|
||||||
link.type = 'image/png';
|
link.type = 'image/png';
|
||||||
}
|
}
|
||||||
if (info.siteName) {
|
if (info.siteName) {
|
||||||
document.title = info.siteName;
|
document.title = info.siteName;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<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" />
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
<title>民众智创</title>
|
<title>民众智创</title>
|
||||||
<script type="module" crossorigin src="/assets/index-dJGCDdnM.js"></script>
|
<script type="module" crossorigin src="/assets/index-BMPNcnJK.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DSYnuUvx.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-DSYnuUvx.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -280,33 +280,22 @@ export async function retryGeneration(recordId: string): Promise<GenerationRecor
|
|||||||
return api.post<GenerationRecord>(`/generation-records/${recordId}/retry`);
|
return api.post<GenerationRecord>(`/generation-records/${recordId}/retry`);
|
||||||
}
|
}
|
||||||
// ── Credits ───────────────────────────────────────────────
|
// ── 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) {
|
if (USE_MOCK) {
|
||||||
const data = await mock.mockGetCredits();
|
const data = await mock.mockGetCredits();
|
||||||
const records = data.records || [];
|
const records = data.records || [];
|
||||||
const totalGranted = records
|
const totalGranted = records.filter((record) => record.type === 'recharge' || record.type === 'refund').reduce((sum, record) => sum + Math.max(0, Number(record.amount || 0)), 0);
|
||||||
.filter((record) => record.type === 'recharge' || record.type === 'refund')
|
const totalConsumed = records.filter((record) => record.type === 'consume').reduce((sum, record) => sum + Math.abs(Number(record.amount || 0)), 0);
|
||||||
.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 {
|
return {
|
||||||
credits: data.credits,
|
credits: data.credits, availableCredits: data.credits, personalCredits: data.credits, teamAvailableCredits: 0, teamFrozenCredits: 0,
|
||||||
availableCredits: data.credits,
|
nextExpiringCredits: 0, nextExpiresAt: null, nextLastUsableAt: null, totalGranted, totalConsumed, totalRefunded: 0, totalExpired: 0, records, total: data.total,
|
||||||
nextExpiringCredits: 0,
|
|
||||||
nextExpiresAt: null,
|
|
||||||
nextLastUsableAt: null,
|
|
||||||
totalGranted,
|
|
||||||
totalConsumed,
|
|
||||||
totalRefunded: 0,
|
|
||||||
totalExpired: 0,
|
|
||||||
records,
|
|
||||||
total: data.total,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
|
||||||
params.set('page', String(page));
|
|
||||||
params.set('page_size', String(pageSize));
|
|
||||||
return api.get(`/credits?${params.toString()}`);
|
return api.get(`/credits?${params.toString()}`);
|
||||||
}
|
}
|
||||||
// ── Captcha ───────────────────────────────────────────────
|
// ── Captcha ───────────────────────────────────────────────
|
||||||
@@ -460,10 +449,11 @@ export async function getPaymentMethods(): Promise<{ alipay: boolean; wechat: bo
|
|||||||
return api.get('/payments/methods');
|
return api.get('/payments/methods');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createRechargeOrder(planId: string, method: string = 'wechat'): Promise<any> {
|
export async function createRechargeOrder(planId: string, method: string = 'wechat', quantity = 1): Promise<any> {
|
||||||
return api.post('/payments/recharge', { plan: planId, method });
|
return api.post('/payments/recharge', { plan: planId, method, quantity });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function getPaymentOrders(
|
export async function getPaymentOrders(
|
||||||
page = 1,
|
page = 1,
|
||||||
pageSize = 20,
|
pageSize = 20,
|
||||||
@@ -1337,87 +1327,50 @@ export async function getPrivatePortraitVirtualSelectableAssets(params: { projec
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Team Management APIs ──────────────────────────────
|
// ── Team Management APIs ──────────────────────────────
|
||||||
export async function getManagedTeam(): Promise<any> {
|
export async function getManagedTeam(): Promise<any> { return api.get('/team/managed'); }
|
||||||
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> {
|
export async function getTeamMembers(page = 1, pageSize = 20): Promise<any> {
|
||||||
const params = new URLSearchParams();
|
return api.get(`/team/members?page=${page}&page_size=${pageSize}`);
|
||||||
params.set('page', String(page));
|
|
||||||
params.set('page_size', String(pageSize));
|
|
||||||
return api.get(`/team/members?${params.toString()}`);
|
|
||||||
}
|
}
|
||||||
|
export async function transferTeamManager(userId: string): Promise<void> { await api.put('/team/manager', { user_id: userId }); }
|
||||||
export async function transferCredits(memberId: string, amount: number, direction: string = "increase", description?: string): Promise<void> {
|
export async function getTeamSubscriptions(): Promise<any[]> { return api.get('/team/subscriptions'); }
|
||||||
await api.post(`/team/members/${memberId}/credits`, { target_user_id: memberId, amount, direction, description: description || null });
|
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 updateTeamSeat(seatId: string, monthlyAllocatedCredits: number): Promise<any> {
|
||||||
export async function getTeamInvitations(): Promise<any[]> {
|
return api.put(`/team/seats/${seatId}`, { monthly_allocated_credits: monthlyAllocatedCredits });
|
||||||
return api.get('/team/invitations');
|
|
||||||
}
|
}
|
||||||
|
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> {
|
export async function createTeamInvitation(maxUses?: number, expiresAt?: string): Promise<any> {
|
||||||
return api.post('/team/invitations', { max_uses: maxUses || null, expires_at: expiresAt || null });
|
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[]> {
|
export async function getPendingJoinRequests(status?: string): Promise<any[]> {
|
||||||
const url = status ? `/team/join-requests?status=${status}` : '/team/join-requests';
|
return api.get(status ? `/team/join-requests?status=${encodeURIComponent(status)}` : '/team/join-requests');
|
||||||
return api.get(url);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleJoinRequest(requestId: string, action: 'approve' | 'reject', note?: string): Promise<void> {
|
export async function handleJoinRequest(requestId: string, action: 'approve' | 'reject', note?: string): Promise<void> {
|
||||||
await api.post(`/team/join-requests/${requestId}`, { action, note: note || null });
|
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 getJoinTeamInfo(code: string): Promise<any> {
|
export async function getJoinTeamInfoPublic(code: string): Promise<any> { return api.get(`/team/join-info/public?code=${encodeURIComponent(code)}`, false); }
|
||||||
return api.get(`/team/join-info?code=${encodeURIComponent(code)}`, { auth: true, skipAuthRedirect: true });
|
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> {
|
||||||
|
|
||||||
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> {
|
|
||||||
const p = new URLSearchParams();
|
const p = new URLSearchParams();
|
||||||
if (params.page) p.set('page', String(params.page));
|
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.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.userId) p.set('user_id', params.userId);
|
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.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);
|
|
||||||
return api.get(`/team/credit-records?${p.toString()}`);
|
return api.get(`/team/credit-records?${p.toString()}`);
|
||||||
}
|
}
|
||||||
|
export function getTeamCreditExportUrl(params: { teamId?: string; phone?: string; subscriptionId?: string; recordType?: string; startDate?: string; endDate?: string; }): string {
|
||||||
export function getTeamCreditExportUrl(params: {
|
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);
|
||||||
phone?: string;
|
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);
|
||||||
recordType?: string;
|
const base = (import.meta as any).env?.VITE_API_BASE || 'http://localhost:8000'; return `${base}/api/team/credit-records/export?${p.toString()}`;
|
||||||
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()}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Dynamic credit products ───────────────────────────────
|
// ── Dynamic credit products ───────────────────────────────
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
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 { QRCodeSVG } from 'qrcode.react';
|
||||||
import {
|
import {
|
||||||
PlayCircleOutlined,
|
PlayCircleOutlined,
|
||||||
@@ -48,7 +48,6 @@ import {
|
|||||||
CrownFilled,
|
CrownFilled,
|
||||||
FireOutlined,
|
FireOutlined,
|
||||||
BankFilled,
|
BankFilled,
|
||||||
BankOutlined,
|
|
||||||
CheckCircleFilled,
|
CheckCircleFilled,
|
||||||
WechatOutlined,
|
WechatOutlined,
|
||||||
AlipayCircleOutlined,
|
AlipayCircleOutlined,
|
||||||
@@ -444,21 +443,34 @@ const AppLayout: React.FC = () => {
|
|||||||
const [menuItems, setMenuItems] = useState<MenuConfig[]>([]);
|
const [menuItems, setMenuItems] = useState<MenuConfig[]>([]);
|
||||||
const [rechargeOptions, setRechargeOptions] = useState<CreditProduct[]>([]);
|
const [rechargeOptions, setRechargeOptions] = useState<CreditProduct[]>([]);
|
||||||
const [subscriptionProducts, setSubscriptionProducts] = 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 () => {
|
const loadCreditCatalog = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await getCreditProductCatalog();
|
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));
|
setRechargeOptions((data.creditAddons || []).filter((product) => product.isActive));
|
||||||
setCurrentSubscription(data.currentSubscription || null);
|
setActivePersonalSubscriptions(data.activePersonalSubscriptions || []);
|
||||||
|
if (teamProducts.length === 0) {
|
||||||
|
setSelectedSubscriptionType('personal');
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setSubscriptionProducts([]);
|
setSubscriptionProducts([]);
|
||||||
|
setTeamSubscriptionProducts([]);
|
||||||
setRechargeOptions([]);
|
setRechargeOptions([]);
|
||||||
setCurrentSubscription(null);
|
setActivePersonalSubscriptions([]);
|
||||||
|
setSelectedSubscriptionType('personal');
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
const [creditsModalOpen, setCreditsModalOpen] = useState(false);
|
const [creditsModalOpen, setCreditsModalOpen] = useState(false);
|
||||||
const [selectedPeriod, setSelectedPeriod] = useState<'monthly' | 'quarterly' | 'yearly'>('monthly');
|
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 [qrCodeModalOpen, setQrCodeModalOpen] = useState(false);
|
||||||
const [paymentMethod, setPaymentMethod] = useState<string>('alipay');
|
const [paymentMethod, setPaymentMethod] = useState<string>('alipay');
|
||||||
const [paying, setPaying] = useState(false);
|
const [paying, setPaying] = useState(false);
|
||||||
@@ -480,17 +492,9 @@ const AppLayout: React.FC = () => {
|
|||||||
isSubscription?: boolean;
|
isSubscription?: boolean;
|
||||||
} | null>(null);
|
} | 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 [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 PENDING_ORDER_KEY = 'pending_payment_order';
|
||||||
const [unreadCount, setUnreadCount] = useState(0);
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
@@ -873,15 +877,15 @@ const AppLayout: React.FC = () => {
|
|||||||
|
|
||||||
const handleDirectPurchase = useCallback(async (
|
const handleDirectPurchase = useCallback(async (
|
||||||
productId: string,
|
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) {
|
if (!enabledMethods.alipay && !enabledMethods.wechat) {
|
||||||
message.error('暂无可用支付方式');
|
message.error('暂无可用支付方式');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPendingProduct({ id: productId, product });
|
setPendingProduct({ id: productId, product });
|
||||||
|
setPurchaseQuantity(product.productType === 'team_subscription' ? 2 : 1);
|
||||||
setQrRevealed(false);
|
setQrRevealed(false);
|
||||||
setPaymentTab('alipay');
|
|
||||||
setCurrentPaymentInfo(null);
|
setCurrentPaymentInfo(null);
|
||||||
setCreditsModalOpen(false);
|
setCreditsModalOpen(false);
|
||||||
setQrCodeModalOpen(true);
|
setQrCodeModalOpen(true);
|
||||||
@@ -892,11 +896,11 @@ const AppLayout: React.FC = () => {
|
|||||||
setPaying(true);
|
setPaying(true);
|
||||||
try {
|
try {
|
||||||
const { id: productId, product } = pendingProduct;
|
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 qrCode = order.qrUrl || order.codeUrl || order.qr_code || order.code_url;
|
||||||
const finalPrice = Number(order.amount ?? product.currentPrice ?? product.price ?? 0);
|
const finalPrice = Number(order.amount ?? product.currentPrice ?? product.price ?? 0);
|
||||||
const credits = Number(product.grantCredits || product.monthlyGrantCredits || 0);
|
const credits = Number(product.grantCredits || product.monthlyGrantCredits || 0) * (product.productType === 'team_subscription' ? purchaseQuantity : 1);
|
||||||
const originalPrice = product.regularPrice ?? product.originalPrice;
|
const originalPrice = product.regularPrice;
|
||||||
const cycleMap: Record<string, string> = { monthly: '月套餐', quarterly: '季套餐', yearly: '年套餐' };
|
const cycleMap: Record<string, string> = { monthly: '月套餐', quarterly: '季套餐', yearly: '年套餐' };
|
||||||
const isSubscription = !!product.billingCycle;
|
const isSubscription = !!product.billingCycle;
|
||||||
const periodLabel = product.billingCycle ? cycleMap[product.billingCycle] : '';
|
const periodLabel = product.billingCycle ? cycleMap[product.billingCycle] : '';
|
||||||
@@ -938,51 +942,7 @@ const AppLayout: React.FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setPaying(false);
|
setPaying(false);
|
||||||
}
|
}
|
||||||
}, [pendingProduct, paymentMethod]);
|
}, [pendingProduct, paymentMethod, purchaseQuantity]);
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
const startPolling = useCallback((orderNo: string, timeoutSeconds: number = 180) => {
|
const startPolling = useCallback((orderNo: string, timeoutSeconds: number = 180) => {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
@@ -1665,45 +1625,37 @@ const AppLayout: React.FC = () => {
|
|||||||
<div style={{ marginBottom: 24 }}>
|
<div style={{ marginBottom: 24 }}>
|
||||||
<div style={{ textAlign: 'center', marginBottom: 20 }}>
|
<div style={{ textAlign: 'center', marginBottom: 20 }}>
|
||||||
<Typography.Title level={3} style={{ marginBottom: 8 }}>订阅套餐</Typography.Title>
|
<Typography.Title level={3} style={{ marginBottom: 8 }}>订阅套餐</Typography.Title>
|
||||||
<Typography.Text type="secondary">订阅积分按自然月逐月发放;有效订阅只能升级同周期更高等级套餐,不能提前续费。</Typography.Text>
|
<Typography.Text type="secondary">个人和团队订阅均可重复购买、多实例同时有效;季卡和年卡仅在订阅内部按月发放积分,不属于自动续费。</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{currentSubscription && (
|
{selectedSubscriptionType === 'personal' && activePersonalSubscriptions.length > 0 && (
|
||||||
<div style={{ marginBottom: 16, padding: '14px 16px', borderRadius: 12, background: '#fff', border: '1px solid #e8eaed', display: 'flex', alignItems: 'center', gap: 16 }}>
|
<div style={{ marginBottom: 16, padding: '12px 16px', borderRadius: 12, background: '#fff', border: '1px solid #e8eaed' }}>
|
||||||
<div style={{
|
<Typography.Text strong>当前有效个人订阅:{activePersonalSubscriptions.length} 张</Typography.Text>
|
||||||
width: 40, height: 40, borderRadius: 10, flexShrink: 0,
|
<div style={{ marginTop: 8, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
|
{activePersonalSubscriptions.map((item) => (
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
<Tag key={item.id} color="purple">{item.productName} · {item.billingCycleLabel || '其他周期'} · 到期 {new Date(item.expiresAt).toLocaleString('zh-CN', { hour12: false })}</Tag>
|
||||||
color: '#fff', fontSize: 18,
|
))}
|
||||||
}}>
|
|
||||||
<CrownOutlined />
|
|
||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<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 }}>
|
<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.Group value={selectedPeriod} onChange={(e) => { setSelectedPeriod(e.target.value); setSelectedPlan(null); }} buttonStyle="solid">
|
||||||
<Radio.Button value="monthly">月套餐</Radio.Button>
|
<Radio.Button value="monthly">月套餐</Radio.Button>
|
||||||
@@ -1712,11 +1664,15 @@ const AppLayout: React.FC = () => {
|
|||||||
</Radio.Group>
|
</Radio.Group>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{subscriptionProducts.filter((item) => item.billingCycle === selectedPeriod).length === 0 ? (
|
{visibleSubscriptionProducts.length === 0 ? (
|
||||||
<div style={{ padding: 48, textAlign: 'center', color: '#94a3b8' }}>暂无可订阅套餐,请联系管理员配置并上架套餐。</div>
|
<div style={{ padding: 48, textAlign: 'center', color: '#94a3b8' }}>
|
||||||
|
{selectedSubscriptionType === 'team'
|
||||||
|
? '暂无可购买的团队订阅套餐,请联系管理员配置并上架套餐。'
|
||||||
|
: '暂无可订阅套餐,请联系管理员配置并上架套餐。'}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 16, flexWrap: 'wrap' }}>
|
<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 gradient = MEMBERSHIP_GRADIENTS[product.tierCode || ''] || MEMBERSHIP_GRADIENTS.default;
|
||||||
const originalPrice = product.regularPrice || product.price;
|
const originalPrice = product.regularPrice || product.price;
|
||||||
const currentPrice = product.currentPrice ?? 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 style={{ width: 32, height: 32, borderRadius: 8, background: gradient.gradient, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: 14 }}>{gradient.icon}</div>
|
||||||
<div>
|
<div>
|
||||||
<Typography.Text strong style={{ fontSize: 14 }}>{product.name}</Typography.Text>
|
<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>
|
</div>
|
||||||
<div style={{ marginBottom: 8 }}>
|
<div style={{ marginBottom: 8 }}>
|
||||||
@@ -1753,15 +1710,7 @@ const AppLayout: React.FC = () => {
|
|||||||
{hasDiscount && (
|
{hasDiscount && (
|
||||||
<span style={{ fontSize: 13, color: '#94a3b8', textDecoration: 'line-through', marginLeft: 6 }}>¥{originalPrice}</span>
|
<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>
|
</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' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', fontSize: 12, color: '#64748b' }}>
|
||||||
<span>{creditsLabel}</span>
|
<span>{creditsLabel}</span>
|
||||||
<span>共 {product.grantCount} 次</span>
|
<span>共 {product.grantCount} 次</span>
|
||||||
@@ -1972,8 +1921,6 @@ const AppLayout: React.FC = () => {
|
|||||||
setCurrentPaymentInfo(null);
|
setCurrentPaymentInfo(null);
|
||||||
setPendingProduct(null);
|
setPendingProduct(null);
|
||||||
setQrRevealed(false);
|
setQrRevealed(false);
|
||||||
setPaymentTab('alipay');
|
|
||||||
corporateTransferForm.resetFields();
|
|
||||||
}}
|
}}
|
||||||
footer={null}
|
footer={null}
|
||||||
width={760}
|
width={760}
|
||||||
@@ -2005,7 +1952,7 @@ const AppLayout: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 22, fontWeight: 700, color: '#6366f1', lineHeight: 1.4 }}>
|
<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)) && (
|
{((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 }}>
|
<span style={{ fontSize: 13, color: '#94a3b8', textDecoration: 'line-through', marginLeft: 8, fontWeight: 400 }}>
|
||||||
¥{currentPaymentInfo?.originalPrice || pendingProduct?.product?.regularPrice}
|
¥{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()} 积分` : '-'}
|
{((currentPaymentInfo?.credits != null) ? currentPaymentInfo.credits : (pendingProduct?.product?.grantCredits || pendingProduct?.product?.monthlyGrantCredits)) ? `${Number(currentPaymentInfo?.credits || pendingProduct?.product?.grantCredits || pendingProduct?.product?.monthlyGrantCredits || 0).toLocaleString()} 积分` : '-'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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 }}>
|
<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' }}>
|
<span style={{ color: '#1a1a2e' }}>
|
||||||
{(currentPaymentInfo?.isSubscription ?? !!pendingProduct?.product?.billingCycle) ? '次年按原价 可随时取消' : '一次性购买'}
|
{(currentPaymentInfo?.isSubscription ?? !!pendingProduct?.product?.billingCycle) ? '独立订阅实例,不自动续费' : '一次性购买'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ height: 1, background: '#e2e8f0', margin: '8px 0' }} />
|
<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' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '4px 0' }}>
|
||||||
<span style={{ fontSize: 14, fontWeight: 500, color: '#1a1a2e' }}>应付金额</span>
|
<span style={{ fontSize: 14, fontWeight: 500, color: '#1a1a2e' }}>应付金额</span>
|
||||||
<span style={{ fontSize: 18, fontWeight: 700, color: '#6366f1' }}>
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 右侧:支付方式 */}
|
{/* 右侧:在线支付方式。后台线下成交不在客户端创建。 */}
|
||||||
<div style={{ width: 300, padding: 20, background: '#fff', display: 'flex', flexDirection: 'column' }}>
|
<div style={{ width: 300, padding: 20, background: '#fff', display: 'flex', flexDirection: 'column' }}>
|
||||||
{/* Tab 切换 */}
|
<Typography.Text strong style={{ marginBottom: 12 }}>选择支付方式</Typography.Text>
|
||||||
<div style={{ display: 'flex', background: '#f3f4f6', borderRadius: 10, padding: 4, marginBottom: 20 }}>
|
<div style={{ display: 'flex', gap: 8, marginBottom: 18 }}>
|
||||||
<div
|
{enabledMethods.alipay && <Button type={paymentMethod === 'alipay' ? 'primary' : 'default'} onClick={() => { if (!qrRevealed) setPaymentMethod('alipay'); }} icon={<AlipayCircleOutlined />}>支付宝</Button>}
|
||||||
onClick={() => setPaymentTab('alipay')}
|
{enabledMethods.wechat && <Button type={paymentMethod === 'wechat' ? 'primary' : 'default'} onClick={() => { if (!qrRevealed) setPaymentMethod('wechat'); }} icon={<WechatOutlined />}>微信支付</Button>}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
{/* 支付宝 Tab */}
|
<div style={{ marginBottom: 12, color: '#64748b' }}>{paymentMethod === 'wechat' ? '请用微信扫码支付' : '请用支付宝扫码支付'}</div>
|
||||||
{paymentTab === 'alipay' && (
|
<div style={{ position: 'relative', width: 180, height: 180, borderRadius: 12, border: '1px solid #f0f0f0', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#fff' }}>
|
||||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
|
{qrRevealed && currentPaymentInfo?.qrCode ? <QRCodeSVG value={currentPaymentInfo.qrCode} size={150} level="M" /> : (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 12 }}>
|
<div style={{ textAlign: 'center' }}>
|
||||||
<AlipayCircleOutlined style={{ fontSize: 18, color: '#1677ff' }} />
|
<div style={{ fontSize: 13, color: '#64748b', marginBottom: 12 }}>确认商品和金额后生成二维码</div>
|
||||||
<span style={{ fontSize: 14, color: '#64748b' }}>请用支付宝扫码支付</span>
|
<Button type="primary" loading={paying} onClick={confirmPayment}>确认支付</Button>
|
||||||
</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>
|
</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>
|
</div>
|
||||||
)}
|
{qrRevealed && <div style={{ marginTop: 12, fontSize: 12, color: '#94a3b8' }}>{countdown} 秒后二维码失效</div>}
|
||||||
|
{qrRevealed && <Button danger style={{ marginTop: 14 }} onClick={async () => {
|
||||||
{/* 对公转账 Tab */}
|
stopPolling();
|
||||||
{paymentTab === 'corporate' && (
|
if (currentOrderNoRef.current) { try { await cancelPaymentOrder(currentOrderNoRef.current); } catch { } currentOrderNoRef.current = null; }
|
||||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflowY: 'auto' }}>
|
localStorage.removeItem(PENDING_ORDER_KEY); setQrCodeModalOpen(false); setCurrentPaymentInfo(null); setPendingProduct(null); setQrRevealed(false); message.info('已取消支付');
|
||||||
{/* 汇款信息 */}
|
}}>取消支付</Button>}
|
||||||
<div style={{ marginBottom: 16 }}>
|
</div>
|
||||||
<Typography.Text style={{ fontSize: 12, color: '#64748b', marginBottom: 8, display: 'block', fontWeight: 600 }}>收款账户信息</Typography.Text>
|
<div style={{ marginTop: 8, fontSize: 11, color: '#94a3b8', textAlign: 'center' }}>线上订单仅支持已启用的支付宝/微信支付;后台线下成交请联系客服处理。</div>
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -1,186 +1,50 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Table, Tag, Empty, Spin, Typography, Row, Col, Pagination } from 'antd';
|
import { Col, Empty, Pagination, Row, Spin, Table, Tag, Typography } from 'antd';
|
||||||
import { WalletOutlined, PlusCircleOutlined, ThunderboltOutlined } from '@ant-design/icons';
|
import { LockOutlined, TeamOutlined, ThunderboltOutlined, WalletOutlined } from '@ant-design/icons';
|
||||||
import { getCredits } from '../api';
|
import { getCredits } from '../api';
|
||||||
|
|
||||||
const CreditRecordsPage: React.FC = () => {
|
const CreditRecordsPage: React.FC = () => {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [records, setRecords] = useState<any[]>([]);
|
const [data, setData] = useState<any>({ records: [], total: 0, credits: 0, personalCredits: 0, teamAvailableCredits: 0, teamFrozenCredits: 0, totalGranted: 0, totalConsumed: 0 });
|
||||||
const [credits, setCredits] = useState(0);
|
|
||||||
const [total, setTotal] = useState(0);
|
|
||||||
const [totalGranted, setTotalGranted] = useState(0);
|
|
||||||
const [totalConsumed, setTotalConsumed] = useState(0);
|
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize] = useState(10);
|
const [pageSize, setPageSize] = useState(10);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
const load = async () => {
|
||||||
}, [page]);
|
setLoading(true);
|
||||||
|
try { setData(await getCredits(page, pageSize)); } finally { setLoading(false); }
|
||||||
const loadData = async () => {
|
};
|
||||||
setLoading(true);
|
void load();
|
||||||
try {
|
}, [page, pageSize]);
|
||||||
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 columns = [
|
const columns = [
|
||||||
{
|
{ 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: '变动类型',
|
{ title: '说明', dataIndex: 'description', ellipsis: true },
|
||||||
dataIndex: 'type',
|
{ 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> },
|
||||||
key: 'type',
|
{ title: '个人积分', dataIndex: 'personalAmount', width: 110, align: 'right' as const, render: (v: number) => Number(v || 0) === 0 ? '-' : Number(v || 0) },
|
||||||
width: 120,
|
{ title: '团队积分', dataIndex: 'teamAmount', width: 110, align: 'right' as const, render: (v: number) => Number(v || 0) === 0 ? '-' : Number(v || 0) },
|
||||||
render: (text: string) => {
|
{ title: '业务场景', dataIndex: 'billingSceneLabel', width: 140, render: (v: string) => v || '-' },
|
||||||
const typeConfig: Record<string, { color: string; label: string; bg: string }> = {
|
{ title: 'Token', dataIndex: 'totalTokens', width: 100, align: 'right' as const, render: (v: number) => v == null ? '-' : Number(v).toLocaleString() },
|
||||||
recharge: { color: '#10b981', label: '充值', bg: 'rgba(16,185,129,0.1)' },
|
{ title: '时间', dataIndex: 'createdAt', width: 180, render: (v: string) => v ? new Date(v).toLocaleString('zh-CN', { hour12: false }) : '-' },
|
||||||
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)' },
|
const cards = [
|
||||||
team_internal: { color: '#0958d9', label: '历史团队流转', bg: 'rgba(9,88,217,0.1)' },
|
['总可消费积分', Number(data.credits || 0), <WalletOutlined />],
|
||||||
expire: { color: '#64748b', label: '积分过期', bg: 'rgba(100,116,139,0.1)' },
|
['个人可用积分', Number(data.personalCredits || 0), <WalletOutlined />],
|
||||||
revoke: { color: '#dc2626', label: '积分撤销', bg: 'rgba(220,38,38,0.1)' },
|
['团队可用积分', Number(data.teamAvailableCredits || 0), <TeamOutlined />],
|
||||||
};
|
['团队冻结积分', Number(data.teamFrozenCredits || 0), <LockOutlined />],
|
||||||
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') : '-';
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<Spin spinning={loading}>
|
||||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
<Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
|
||||||
<Col xs={24} sm={8}>
|
{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>)}
|
||||||
<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>
|
|
||||||
</Row>
|
</Row>
|
||||||
|
<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>
|
||||||
<div>
|
{(data.records || []).length === 0 ? <Empty description="暂无积分变动记录" /> : <Table rowKey="id" columns={columns} dataSource={data.records || []} pagination={false} scroll={{ x: 1050 }} />}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
<div style={{ marginTop: 16, textAlign: 'right' }}><Pagination current={page} pageSize={pageSize} total={Number(data.total || 0)} onChange={(p, ps) => { setPage(p); setPageSize(ps); }} /></div>
|
||||||
<ThunderboltOutlined style={{ color: '#f59e0b', fontSize: 16 }} />
|
</Spin>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default CreditRecordsPage;
|
export default CreditRecordsPage;
|
||||||
|
|||||||
@@ -1,175 +1,78 @@
|
|||||||
import React, { useEffect, useState, useRef } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import {
|
import { Col, Empty, Pagination, Row, Spin, Table, Tag, Typography } from 'antd';
|
||||||
Col, Row, Space, Table, Tag, Typography,
|
import { LockOutlined, TeamOutlined, WalletOutlined } from '@ant-design/icons';
|
||||||
} from 'antd';
|
import { getCreditBalances, getCredits } from '../api';
|
||||||
import {
|
import { formatDatePrecise } from '../utils/formatDate';
|
||||||
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()}</>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const CreditsPage: React.FC = () => {
|
const CreditsPage: React.FC = () => {
|
||||||
const { user } = useAuthStore();
|
|
||||||
const [records, setRecords] = useState<CreditRecord[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
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 [page, setPage] = useState(1);
|
||||||
const [pageSize, setPageSize] = useState(10);
|
const [pageSize, setPageSize] = useState(20);
|
||||||
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);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetch = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await getCredits(page, pageSize);
|
try {
|
||||||
setRecords(data.records);
|
const [creditData, balanceData] = await Promise.all([getCredits(1, 1), getCreditBalances(page, pageSize)]);
|
||||||
setTotal(data.total);
|
setSummary({
|
||||||
setAvailableCredits(data.availableCredits ?? data.credits ?? 0);
|
personalCredits: Number(creditData.personalCredits || 0),
|
||||||
setNextExpiringCredits(data.nextExpiringCredits ?? 0);
|
teamAvailableCredits: Number(creditData.teamAvailableCredits || 0),
|
||||||
setNextLastUsableAt(data.nextLastUsableAt ?? null);
|
teamFrozenCredits: Number(creditData.teamFrozenCredits || 0),
|
||||||
setTotalConsumed(data.totalConsumed ?? 0);
|
credits: Number(creditData.credits || creditData.availableCredits || 0),
|
||||||
setLoading(false);
|
nextExpiringCredits: Number(creditData.nextExpiringCredits || 0),
|
||||||
|
nextLastUsableAt: creditData.nextLastUsableAt || null,
|
||||||
|
});
|
||||||
|
setBalances(balanceData || []);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
fetch();
|
void load();
|
||||||
}, [page, pageSize]);
|
}, [page, pageSize]);
|
||||||
|
|
||||||
const handlePageChange = (p: number, ps: number) => {
|
const cards = [
|
||||||
setPage(p);
|
{ title: '个人可用积分', value: summary.personalCredits, icon: <WalletOutlined />, note: '个人积分可正常用于业务消费' },
|
||||||
setPageSize(ps);
|
{ title: '团队可用积分', value: summary.teamAvailableCredits, icon: <TeamOutlined />, note: '仅统计当前有效席位可消费额度' },
|
||||||
};
|
{ title: '团队冻结积分', value: summary.teamFrozenCredits, icon: <LockOutlined />, note: '团队禁用期间继续发放和过期,但不可消费' },
|
||||||
|
{ title: '总可消费积分', value: summary.credits, icon: <WalletOutlined />, note: '个人积分 + 当前可用团队积分' },
|
||||||
|
];
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ title: '类型', dataIndex: 'type', key: 'type', width: 100,
|
{ title: '资金域', dataIndex: 'creditScopeLabel', width: 110, render: (v: string, r: any) => <Tag color={r.creditScope === 'team' ? 'blue' : 'green'}>{v || (r.creditScope === 'team' ? '团队积分' : '个人积分')}</Tag> },
|
||||||
render: (type: string) => {
|
{ title: '积分等级', dataIndex: 'creditLevelLabel', width: 120, render: (v: string) => v || '-' },
|
||||||
const labels: Record<string, { label: string; color: string; positive: boolean }> = {
|
{ title: '来源', dataIndex: 'sourceTypeLabel', width: 150, render: (v: string) => v || '-' },
|
||||||
recharge: { label: '发放/充值', color: 'green', positive: true },
|
{ title: '发放积分', dataIndex: 'grantAmount', width: 110, align: 'right' as const },
|
||||||
consume: { label: '消费', color: 'orange', positive: false },
|
{ title: '剩余积分', dataIndex: 'unspentAmount', width: 110, align: 'right' as const, render: (v: number) => <Typography.Text strong>{Number(v || 0).toLocaleString()}</Typography.Text> },
|
||||||
refund: { label: '业务退款', color: 'purple', positive: true },
|
{ title: '状态', dataIndex: 'statusLabel', width: 100, render: (v: string) => <Tag>{v || '其他状态'}</Tag> },
|
||||||
expire: { label: '积分过期', color: 'default', positive: false },
|
{ title: '最后可用时间', dataIndex: 'lastUsableAt', width: 190, render: (v: string) => v ? formatDatePrecise(v) : '-' },
|
||||||
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>,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<Spin spinning={loading}>
|
||||||
{/* Balance cards */}
|
<Row gutter={[16, 16]} style={{ marginBottom: 18 }}>
|
||||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
{cards.map((card) => (
|
||||||
<Col xs={24} sm={8}>
|
<Col xs={24} sm={12} lg={6} key={card.title}>
|
||||||
<div className="animate-fadeInUp" style={{
|
<div style={{ background: '#fff', border: '1px solid #f0f0f5', borderRadius: 16, padding: 20, minHeight: 132 }}>
|
||||||
borderRadius: 16, padding: 24,
|
<div style={{ display: 'flex', gap: 8, color: '#64748b', alignItems: 'center' }}>{card.icon}<span>{card.title}</span></div>
|
||||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
<div style={{ fontSize: 30, fontWeight: 800, color: '#1e293b', margin: '10px 0 6px' }}>{card.value.toLocaleString()}</div>
|
||||||
position: 'relative', overflow: 'hidden',
|
<div style={{ fontSize: 12, color: '#94a3b8' }}>{card.note}</div>
|
||||||
}}>
|
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Col>
|
||||||
</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>
|
|
||||||
</Row>
|
</Row>
|
||||||
|
{summary.nextExpiringCredits > 0 && (
|
||||||
{/* Records */}
|
<div style={{ marginBottom: 16, padding: '10px 14px', borderRadius: 10, background: '#fffbe6', color: '#8c6d1f' }}>
|
||||||
<div className="animate-fadeInUp" style={{ animationDelay: '0.15s' }}>
|
最近将有 {summary.nextExpiringCredits.toLocaleString()} 积分到期,最后可用时间:{summary.nextLastUsableAt ? formatDatePrecise(summary.nextLastUsableAt) : '-'}。
|
||||||
<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 }} />
|
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</Spin>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,215 +1,49 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { Table, Tag, Empty, Spin, Typography, Pagination, Select, DatePicker, Space } from 'antd';
|
import { DatePicker, Empty, Pagination, Select, Spin, Table, Tag, Typography } from 'antd';
|
||||||
import { FileTextOutlined, AlipayCircleOutlined, WechatOutlined } from '@ant-design/icons';
|
import { FileTextOutlined } from '@ant-design/icons';
|
||||||
import { getPaymentOrders } from '../api';
|
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 OrderRecordsPage: React.FC = () => {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [orders, setOrders] = useState<any[]>([]);
|
const [orders, setOrders] = useState<any[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize] = useState(10);
|
const [pageSize, setPageSize] = useState(10);
|
||||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
const [statusFilter, setStatusFilter] = useState<string>();
|
||||||
const [dateRange, setDateRange] = useState<any>([null, null]);
|
const [dateRange, setDateRange] = useState<any>([null, null]);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await getPaymentOrders(page, pageSize, {
|
const data = await getPaymentOrders(page, pageSize, { statusFilter, startDate: dateRange[0]?.format?.('YYYY-MM-DD'), endDate: dateRange[1]?.format?.('YYYY-MM-DD') });
|
||||||
statusFilter: statusFilter || undefined,
|
setOrders(data.items || []); setTotal(Number(data.total || 0));
|
||||||
startDate: dateRange[0]?.format?.('YYYY-MM-DD'),
|
} finally { setLoading(false); }
|
||||||
endDate: dateRange[1]?.format?.('YYYY-MM-DD'),
|
}, [page, pageSize, statusFilter, dateRange]);
|
||||||
});
|
useEffect(() => { void load(); }, [load]);
|
||||||
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 columns = [
|
const columns = [
|
||||||
{
|
{ title: '订单号', dataIndex: 'orderNo', width: 210, render: (v: string) => <Typography.Text copyable code>{v}</Typography.Text> },
|
||||||
title: '订单号',
|
{ title: '订单来源', dataIndex: 'orderSource', width: 120, render: (v: string, r: any) => <Tag color={v === 'admin_offline' ? 'purple' : 'blue'}>{r.orderSourceLabel || SOURCE_LABELS[v] || '其他来源'}</Tag> },
|
||||||
key: 'orderNo',
|
{ title: '商品', dataIndex: 'productNameSnapshot', width: 180, ellipsis: true, render: (v: string, r: any) => <span>{v || '-'}{Number(r.quantity || 1) > 1 ? ` × ${r.quantity}` : ''}</span> },
|
||||||
width: 200,
|
{ title: '支付方式', dataIndex: 'paymentMethod', width: 125, render: (v: string, r: any) => r.paymentMethodLabel || (v === 'other' && r.offlinePaymentDetail ? r.offlinePaymentDetail : PAYMENT_METHOD_LABELS[v]) || '其他支付方式' },
|
||||||
render: (_, record: any) => {
|
{ title: '实际金额', dataIndex: 'amount', width: 110, align: 'right' as const, render: (v: number) => `¥${Number(v || 0).toFixed(2)}` },
|
||||||
const orderNo = record.order_no || record.orderNo || record.id;
|
{ title: '状态', dataIndex: 'status', width: 100, render: (v: string, r: any) => <Tag>{r.statusLabel || STATUS_LABELS[v] || '其他状态'}</Tag> },
|
||||||
return (
|
{ title: '创建时间', dataIndex: 'createdAt', width: 180, render: (v: string) => v ? new Date(v).toLocaleString('zh-CN', { hour12: false }) : '-' },
|
||||||
<span style={{ fontFamily: 'monospace', fontSize: 13, color: '#64748b' }}>
|
{ title: '支付时间', dataIndex: 'paidAt', width: 180, render: (v: string) => v ? new Date(v).toLocaleString('zh-CN', { hour12: false }) : '-' },
|
||||||
{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') : '-';
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return <div>
|
||||||
<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>
|
<div style={{ display: 'flex', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
<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 }))} />
|
||||||
<FileTextOutlined style={{ color: '#0ea5e9', fontSize: 16 }} />
|
<DatePicker.RangePicker value={dateRange} onChange={(v) => { setDateRange(v || [null, null]); setPage(1); }} placeholder={['开始日期','结束日期']} />
|
||||||
<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>
|
|
||||||
</div>
|
</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;
|
|
||||||
|
|||||||
@@ -1,676 +1,239 @@
|
|||||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import {
|
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';
|
} from 'antd';
|
||||||
import { DatePicker } from 'antd';
|
import {
|
||||||
|
DownloadOutlined, LockOutlined, PlusOutlined, ReloadOutlined,
|
||||||
|
TeamOutlined, UserSwitchOutlined, WalletOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import {
|
import {
|
||||||
CopyOutlined, DownloadOutlined, PlusOutlined, ReloadOutlined, UserOutlined, HistoryOutlined, WalletOutlined, BellOutlined, ClockCircleOutlined, CheckOutlined, CloseOutlined,
|
cancelTeamSeat, createTeamInvitation, createTeamSeat, getManagerAccessTeams, getManagedTeam,
|
||||||
} from '@ant-design/icons';
|
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;
|
const { RangePicker } = DatePicker;
|
||||||
import {
|
const fmt = (value?: string | null) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '-';
|
||||||
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';
|
|
||||||
|
|
||||||
/* ── 工具函数 ────────────────────────────────────────── */
|
|
||||||
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 TeamManagementPage: React.FC = () => {
|
||||||
const [team, setTeam] = useState<ManagedTeam | null>(null);
|
const [team, setTeam] = useState<ManagedTeam | null>(null);
|
||||||
const [teamLoading, setTeamLoading] = useState(false);
|
const [accessTeams, setAccessTeams] = useState<any[]>([]);
|
||||||
|
const [selectedFlowTeamId, setSelectedFlowTeamId] = useState<string>();
|
||||||
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 [members, setMembers] = useState<TeamMember[]>([]);
|
const [members, setMembers] = useState<TeamMember[]>([]);
|
||||||
const [membersTotal, setMembersTotal] = useState(0);
|
const [memberTotal, setMemberTotal] = useState(0);
|
||||||
const [membersLoading, setMembersLoading] = useState(false);
|
const [memberPage, setMemberPage] = useState(1);
|
||||||
const [membersPage, setMembersPage] = useState(1);
|
const [subscriptions, setSubscriptions] = useState<TeamSubscriptionManage[]>([]);
|
||||||
|
const [usage, setUsage] = useState<TeamMemberUsage[]>([]);
|
||||||
const loadMembers = useCallback(async () => {
|
const [history, setHistory] = useState<any[]>([]);
|
||||||
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 [invitations, setInvitations] = useState<TeamInvitation[]>([]);
|
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 [requests, setRequests] = useState<TeamJoinRequest[]>([]);
|
||||||
const [reqLoading, setReqLoading] = useState(false);
|
const [flow, setFlow] = useState<any>({ items: [], total: 0 });
|
||||||
const [reqStatusFilter, setReqStatusFilter] = useState<string>('pending');
|
const [flowPage, setFlowPage] = useState(1);
|
||||||
const [activeTab, setActiveTab] = useState('members');
|
const [flowType, setFlowType] = useState<string>();
|
||||||
const initialNoticeShownRef = useRef(false);
|
const [flowPhone, setFlowPhone] = useState('');
|
||||||
const lastRequestCountRef = useRef(0);
|
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) => {
|
const readOnly = !!team?.isReadOnly;
|
||||||
setReqLoading(true);
|
const currentManager = !!team;
|
||||||
|
|
||||||
|
const loadBase = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await getPendingJoinRequests(status);
|
const access = await getManagerAccessTeams().catch(() => []);
|
||||||
const currentCount = data?.length || 0;
|
setAccessTeams(access || []);
|
||||||
setRequests(data || []);
|
const current = await getManagedTeam().catch(() => null);
|
||||||
|
setTeam(current);
|
||||||
// 只有待处理状态才显示弹窗通知
|
const defaultTeam = current?.id || access?.[0]?.id;
|
||||||
if (status === 'pending' && currentCount > 0) {
|
setSelectedFlowTeamId((old) => old || defaultTeam);
|
||||||
if (isInitial && !initialNoticeShownRef.current) {
|
if (current) {
|
||||||
initialNoticeShownRef.current = true;
|
const [memberData, subData, usageData, historyData, invData, requestData] = await Promise.all([
|
||||||
Modal.confirm({
|
getTeamMembers(memberPage, 20), getTeamSubscriptions(), getTeamMemberUsage(), getTeamManagerHistory(), getTeamInvitations(), getPendingJoinRequests(),
|
||||||
title: (
|
]);
|
||||||
<Space>
|
setMembers(memberData?.items || []); setMemberTotal(Number(memberData?.total || 0));
|
||||||
<BellOutlined style={{ color: '#f59e0b' }} />
|
setSubscriptions(subData || []); setUsage(usageData || []); setHistory(historyData || []);
|
||||||
待处理的加入申请
|
setInvitations(invData || []); setRequests(requestData || []);
|
||||||
</Space>
|
} else {
|
||||||
),
|
setMembers([]); setSubscriptions([]); setUsage([]); setHistory([]); setInvitations([]); setRequests([]);
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} finally { setLoading(false); }
|
||||||
|
}, [memberPage]);
|
||||||
|
|
||||||
if (status === 'pending') {
|
const loadFlow = useCallback(async () => {
|
||||||
lastRequestCountRef.current = currentCount;
|
if (!selectedFlowTeamId) { setFlow({ items: [], total: 0 }); return; }
|
||||||
}
|
|
||||||
} 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) => {
|
|
||||||
try {
|
try {
|
||||||
await handleJoinRequest(requestId, action, note);
|
const data = await getTeamCreditRecords({
|
||||||
message.success(action === 'approve' ? '已通过' : '已拒绝');
|
teamId: selectedFlowTeamId, page: flowPage, pageSize: 20, phone: flowPhone || undefined,
|
||||||
loadRequests(reqStatusFilter);
|
subscriptionId: flowSubscriptionId, recordType: flowType,
|
||||||
loadMembers();
|
startDate: flowDates[0]?.format?.('YYYY-MM-DD'), endDate: flowDates[1]?.format?.('YYYY-MM-DD'),
|
||||||
} 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,
|
|
||||||
});
|
});
|
||||||
setCreditRecords(res.items || []);
|
setFlow(data || { items: [], total: 0 });
|
||||||
setCreditTotal(res.total || 0);
|
} catch (e: any) { message.error(e?.message || '团队流水加载失败'); }
|
||||||
setCreditSummary(res.summary || null);
|
}, [selectedFlowTeamId, flowPage, flowPhone, flowSubscriptionId, flowType, flowDates]);
|
||||||
} catch (e: any) {
|
|
||||||
message.error(e?.message || '加载积分记录失败');
|
|
||||||
} finally {
|
|
||||||
setCreditLoading(false);
|
|
||||||
}
|
|
||||||
}, [creditPage, creditFilterType, creditFilterPhone, creditDateRange]);
|
|
||||||
|
|
||||||
useEffect(() => { loadCreditRecords(); }, [loadCreditRecords]);
|
useEffect(() => { void loadBase(); }, [loadBase]);
|
||||||
|
useEffect(() => { void loadFlow(); }, [loadFlow]);
|
||||||
|
|
||||||
const resetCreditFilters = () => {
|
const openSeatCreate = (subscriptionId: string) => {
|
||||||
setCreditFilterType('');
|
seatForm.resetFields();
|
||||||
setCreditFilterPhone('');
|
const target = subscriptions.find((item) => item.subscription?.id === subscriptionId);
|
||||||
const today = dayjs().format('YYYY-MM-DD');
|
const activeSeats = Math.max(1, Number(target?.seatLimit || 1) - Number(target?.activeSeatCount || 0));
|
||||||
setCreditDateRange([today, today]);
|
const defaultAmount = Number(target?.periodUnallocatedCredits || 0) > 0 ? Math.floor((Number(target?.periodUnallocatedCredits || 0) / activeSeats) * 100) / 100 : undefined;
|
||||||
setCreditPage(1);
|
seatForm.setFieldsValue({ monthlyAllocatedCredits: defaultAmount });
|
||||||
|
setSeatModal({ open: true, subscriptionId });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExportCredits = () => {
|
const openSeatEdit = (seat: TeamSeat) => {
|
||||||
const url = getTeamCreditExportUrl({
|
seatForm.setFieldsValue({ userId: seat.userId, monthlyAllocatedCredits: seat.monthlyAllocatedCredits });
|
||||||
phone: creditFilterPhone || undefined,
|
setSeatModal({ open: true, subscriptionId: seat.subscriptionId, seat });
|
||||||
recordType: creditFilterType || undefined,
|
};
|
||||||
startDate: creditDateRange?.[0] || undefined,
|
|
||||||
endDate: creditDateRange?.[1] || undefined,
|
const saveSeat = async () => {
|
||||||
});
|
try {
|
||||||
const token = localStorage.getItem('auth_token');
|
const values = await seatForm.validateFields();
|
||||||
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {};
|
if (seatModal.seat) await updateTeamSeat(seatModal.seat.id, Number(values.monthlyAllocatedCredits));
|
||||||
fetch(url, { headers })
|
else await createTeamSeat(String(seatModal.subscriptionId), String(values.userId), Number(values.monthlyAllocatedCredits));
|
||||||
.then((res) => res.blob())
|
message.success(seatModal.seat ? '席位额度已更新' : '席位已创建');
|
||||||
.then((blob) => {
|
setSeatModal({ open: false }); await loadBase();
|
||||||
const a = document.createElement('a');
|
} catch (e: any) { if (!e?.errorFields) message.error(e?.message || '席位保存失败'); }
|
||||||
a.href = URL.createObjectURL(blob);
|
};
|
||||||
a.download = `团队积分_${dayjs().format('YYYYMMDD_HHmmss')}.csv`;
|
|
||||||
a.click();
|
const removeSeat = async (seatId: string) => {
|
||||||
URL.revokeObjectURL(a.href);
|
try { await cancelTeamSeat(seatId); message.success('席位已取消,未使用额度已释放'); await loadBase(); }
|
||||||
})
|
catch (e: any) { message.error(e?.message || '席位取消失败'); }
|
||||||
.catch(() => message.error('导出失败'));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ── 表格列定义 ──────────────────────────────────────── */
|
|
||||||
const memberColumns = [
|
const memberColumns = [
|
||||||
{ title: '用户名', dataIndex: 'username', width: 140, render: (v: string) => <Typography.Text strong>{v}</Typography.Text> },
|
{ title: '成员', dataIndex: 'username', render: (v: string, r: TeamMember) => <Space><span>{v}</span>{r.id === team?.managerId && <Tag color="purple">队长</Tag>}</Space> },
|
||||||
{ title: '手机号', dataIndex: 'phone', width: 130, render: (v: string) => v || '-' },
|
{ title: '手机号', dataIndex: 'phone', 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: 'personalCredits', align: 'right' as const, render: (v: number) => Number(v || 0).toLocaleString() },
|
||||||
{ title: '状态', dataIndex: 'isActive', width: 80, render: (v: boolean) => <Tag color={v ? 'green' : 'red'}>{v ? '启用' : '禁用'}</Tag> },
|
{ title: '团队可用', dataIndex: 'teamAvailableCredits', align: 'right' as const, render: (v: number) => Number(v || 0).toLocaleString() },
|
||||||
{ title: '加入时间', dataIndex: 'joinedAt', width: 170, render: (v: string) => formatDateTime(v) },
|
{ 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> : '-' },
|
||||||
title: '操作', key: 'action', width: 100,
|
|
||||||
render: () => (
|
|
||||||
<Tooltip title="当前版本积分暂未开放团队转账功能">
|
|
||||||
<Button size="small" type="link" style={{ padding: 0, color: '#999' }} disabled>积分转账未开放</Button>
|
|
||||||
</Tooltip>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const buildInviteLink = (code: string) => {
|
const seatColumns = (subscription: TeamSubscriptionManage) => [
|
||||||
const base = window.location.origin;
|
{ title: '成员', dataIndex: 'username', render: (v: string) => v || '-' },
|
||||||
return `${base}/join-team?code=${code}`;
|
{ title: '月分配额度', dataIndex: 'monthlyAllocatedCredits', align: 'right' as const },
|
||||||
};
|
{ title: '本期净消耗', dataIndex: 'currentPeriodUsedCredits', align: 'right' as const },
|
||||||
|
{ title: '本期剩余额度', dataIndex: 'currentPeriodRemainingCredits', align: 'right' as const },
|
||||||
const invColumns = [
|
{ title: '状态', dataIndex: 'statusLabel', render: (v: string) => <Tag>{v || '其他状态'}</Tag> },
|
||||||
{ title: '邀请码', dataIndex: 'code', width: 200, render: (v: string) => <Typography.Text copyable style={{ fontFamily: 'monospace' }}>{v}</Typography.Text> },
|
{ 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> : '-' },
|
||||||
{
|
|
||||||
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 renderStatusTag = (status: string) => {
|
const subscriptionView = subscriptions.length === 0 ? <Empty description="暂无团队订阅" /> : <Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||||
const map: Record<string, { color: string; text: string }> = {
|
{subscriptions.map((item) => {
|
||||||
pending: { color: 'orange', text: '待处理' },
|
const sub = item.subscription || {};
|
||||||
approved: { color: 'green', text: '已通过' },
|
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>}>
|
||||||
rejected: { color: 'red', text: '已拒绝' },
|
<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>
|
||||||
const info = map[status] || { color: 'default', text: status };
|
<Col span={6}>本期剩余资金:<b>{Number(item.periodUnspentCredits || 0).toLocaleString()}</b></Col><Col span={6}>未分配积分:<b>{Number(item.periodUnallocatedCredits || 0).toLocaleString()}</b></Col>
|
||||||
return <Tag color={info.color}>{info.text}</Tag>;
|
<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 = [
|
const invitationColumns = [
|
||||||
{ title: '申请人', dataIndex: 'username', width: 140, render: (v: string) => <Typography.Text strong>{v}</Typography.Text> },
|
{ title: '邀请码', dataIndex: 'code', render: (v: string) => <Typography.Text copyable>{v}</Typography.Text> },
|
||||||
{ title: '手机号', dataIndex: 'phone', width: 130, render: (v: string) => v || '-' },
|
{ title: '状态', dataIndex: 'status', render: (v: string) => <Tag>{v === 'active' ? '有效' : '已撤销'}</Tag> },
|
||||||
{ title: '状态', dataIndex: 'status', width: 100, render: (v: string) => renderStatusTag(v) },
|
{ title: '使用次数', render: (_: any, r: TeamInvitation) => `${r.useCount || 0}${r.maxUses ? ` / ${r.maxUses}` : ''}` },
|
||||||
{ title: '申请时间', dataIndex: 'createdAt', width: 170, render: (v: string) => formatDateTime(v) },
|
{ title: '过期时间', dataIndex: 'expiresAt', render: (v: string) => v ? fmt(v) : '长期有效' },
|
||||||
{ title: '处理时间', dataIndex: 'handledAt', width: 170, render: (v: string) => v ? formatDateTime(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> : '-' },
|
||||||
{ 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 creditColumns = [
|
const requestColumns = [
|
||||||
{ title: '用户名', dataIndex: 'username', width: 120, render: (v: string) => <Typography.Text strong>{v || '-'}</Typography.Text> },
|
{ title: '申请人', dataIndex: 'username' }, { title: '手机号', dataIndex: 'phone', render: (v: string) => v || '-' },
|
||||||
{ title: '手机号', dataIndex: 'phone', width: 120, 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: '类型', dataIndex: 'type', width: 100,
|
{ 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> : '-' },
|
||||||
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) },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/* ── Tab 配置 ────────────────────────────────────────── */
|
const flowColumns = [
|
||||||
const tableWrapper: React.CSSProperties = { borderRadius: 16, background: '#fff', border: '1px solid #f0f0f5', overflow: 'hidden' };
|
{ title: '成员', dataIndex: 'username', width: 120 }, { title: '类型', dataIndex: 'recordTypeLabel', width: 110, render: (v: string) => <Tag>{v || '其他'}</Tag> },
|
||||||
const paginationStyle: React.CSSProperties = { padding: '16px', textAlign: 'right' };
|
{ 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> },
|
||||||
const tabItems = [
|
{ 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) },
|
||||||
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 currentOnlyTabs = currentManager ? [
|
||||||
return (
|
{ key: 'subscriptions', label: '订阅与席位', children: subscriptionView },
|
||||||
<div style={{ padding: 24 }}>
|
{ 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
|
||||||
<div style={{ marginBottom: 24 }}>
|
rowKey={(r) => `${r.userId}-${r.subscriptionPeriodId}`}
|
||||||
{teamLoading ? (
|
pagination={false}
|
||||||
<Typography.Text type="secondary">加载中...</Typography.Text>
|
dataSource={usage}
|
||||||
) : team ? (
|
scroll={{ x: 1400 }}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12 }}>
|
columns={[
|
||||||
<div>
|
{ title: '成员', dataIndex: 'username', width: 120 },
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>{team.name}</Typography.Title>
|
{ title: '订阅实例', dataIndex: 'subscriptionNo', width: 190, render: (v: string) => <Typography.Text copyable={v && v !== '历史订阅' ? { text: v } : false}>{v || '历史订阅'}</Typography.Text> },
|
||||||
<Typography.Text type="secondary">代码: {team.code || '-'} 成员: {team.memberCount} 人</Typography.Text>
|
{ title: '套餐名称', dataIndex: 'subscriptionName', width: 180, render: (v: string) => <Typography.Text strong>{v || '历史团队订阅'}</Typography.Text> },
|
||||||
</div>
|
{
|
||||||
<Button icon={<ReloadOutlined />} onClick={() => { loadTeam(); loadMembers(); loadInvitations(); loadRequests(reqStatusFilter); loadCreditRecords(); }}>刷新</Button>
|
title: '等级编码',
|
||||||
</div>
|
width: 190,
|
||||||
) : (
|
render: (_: any, r: TeamMemberUsage) => <span>
|
||||||
<Typography.Text type="secondary">无法获取团队信息</Typography.Text>
|
{r.tierLabel || '未知等级'}{r.tierCode ? `(${r.tierCode})` : ''}{r.tierRank ? ` / ${r.tierRank}` : ''}
|
||||||
)}
|
</span>,
|
||||||
</div>
|
},
|
||||||
|
{ 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="暂无任期记录" /> },
|
||||||
|
] : [];
|
||||||
|
|
||||||
{/* 标签页 */}
|
const flowTab = { key: 'flow', label: '团队积分流水', children: <>
|
||||||
<Tabs items={tabItems} activeKey={activeTab} onChange={setActiveTab} size="large" />
|
<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="您当前不是团队队长,也没有可查看的历史队长任期" />;
|
||||||
|
|
||||||
{/* 生成邀请码弹窗 */}
|
return <div>
|
||||||
<Modal
|
{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>}
|
||||||
title={<Space><CopyOutlined />生成邀请码</Space>}
|
{readOnly && <Alert type="warning" showIcon message="团队业务已冻结" description="团队订阅仍会正常发放积分、自然滚期和过期;当前团队积分显示为冻结且不会参与结算。团队重新启用后,仅恢复届时仍有效的团队积分。" style={{ marginBottom: 16 }} />}
|
||||||
open={invModal}
|
{!currentManager && <Alert type="info" showIcon message="历史队长只读权限" description="您可以查看自己担任队长任期内的整个团队积分流水,但没有当前团队管理权限。" style={{ marginBottom: 16 }} />}
|
||||||
confirmLoading={invSaving}
|
<Tabs items={[...currentOnlyTabs, flowTab]} />
|
||||||
onOk={handleCreateInvitation}
|
|
||||||
onCancel={() => { setInvModal(false); invForm.resetFields(); }}
|
<Modal open={seatModal.open} title={seatModal.seat ? '修改席位月额度' : '分配团队订阅席位'} onCancel={() => setSeatModal({ open: false })} onOk={saveSeat} okText="确认保存" cancelText="取消">
|
||||||
okText="生成"
|
<Form form={seatForm} layout="vertical">
|
||||||
width={480}
|
{!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 form={invForm} layout="vertical" style={{ marginTop: 16 }}>
|
</Form>
|
||||||
<Form.Item name="maxUses" label="最大使用次数">
|
</Modal>
|
||||||
<InputNumber style={{ width: '100%' }} min={1} placeholder="留空表示不限" size="large" />
|
|
||||||
</Form.Item>
|
<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="取消">
|
||||||
<div style={{
|
<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>
|
||||||
padding: '12px 16px',
|
</Modal>
|
||||||
background: '#eef2ff',
|
</div>;
|
||||||
borderRadius: 8,
|
|
||||||
fontSize: 13,
|
|
||||||
color: '#4f46e5',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 8,
|
|
||||||
}}>
|
|
||||||
<ClockCircleOutlined />
|
|
||||||
<span>邀请码自生成起 <strong>24 小时</strong> 内有效,过期自动失效。</span>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TeamManagementPage;
|
export default TeamManagementPage;
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ export interface TeamMember {
|
|||||||
username: string;
|
username: string;
|
||||||
phone?: string | null;
|
phone?: string | null;
|
||||||
credits: number;
|
credits: number;
|
||||||
|
personalCredits?: number;
|
||||||
|
teamAvailableCredits?: number;
|
||||||
|
teamFrozenCredits?: number;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
joinedAt: string;
|
joinedAt: string;
|
||||||
}
|
}
|
||||||
@@ -45,7 +48,7 @@ export interface TeamInvitation {
|
|||||||
maxUses?: number | null;
|
maxUses?: number | null;
|
||||||
useCount: number;
|
useCount: number;
|
||||||
expiresAt?: string | null;
|
expiresAt?: string | null;
|
||||||
inviteLink: string;
|
inviteLink?: string;
|
||||||
createdAt?: string | null;
|
createdAt?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,15 +70,27 @@ export interface ManagedTeam {
|
|||||||
name: string;
|
name: string;
|
||||||
code?: string | null;
|
code?: string | null;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
status: string;
|
status: 'active' | 'disabled' | string;
|
||||||
|
statusLabel?: string;
|
||||||
|
isReadOnly?: boolean;
|
||||||
|
teamCreditFrozen?: boolean;
|
||||||
memberCount: number;
|
memberCount: number;
|
||||||
managerId?: string | null;
|
managerId?: string | null;
|
||||||
managerName?: 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 {
|
export interface JoinTeamInfo {
|
||||||
teamName: string;
|
|
||||||
teamId: string;
|
teamId: string;
|
||||||
|
teamName: string;
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
alreadyInTeam: boolean;
|
alreadyInTeam: boolean;
|
||||||
hasPendingRequest: boolean;
|
hasPendingRequest: boolean;
|
||||||
@@ -84,12 +99,16 @@ export interface JoinTeamInfo {
|
|||||||
export interface CreditRecord {
|
export interface CreditRecord {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'consume' | 'recharge' | 'refund' | 'expire' | 'revoke' | 'team_internal';
|
type: 'consume' | 'recharge' | 'refund' | 'expire' | 'revoke' | 'team_internal';
|
||||||
|
typeLabel?: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
|
personalAmount?: number;
|
||||||
|
teamAmount?: number;
|
||||||
balanceDelta?: number;
|
balanceDelta?: number;
|
||||||
expiredAmount?: number;
|
expiredAmount?: number;
|
||||||
balanceAfter?: number;
|
balanceAfter?: number;
|
||||||
description: string;
|
description: string;
|
||||||
billingScene?: string | null;
|
billingScene?: string | null;
|
||||||
|
billingSceneLabel?: string | null;
|
||||||
sceneNameSnapshot?: string | null;
|
sceneNameSnapshot?: string | null;
|
||||||
inputTokens?: number | null;
|
inputTokens?: number | null;
|
||||||
outputTokens?: number | null;
|
outputTokens?: number | null;
|
||||||
@@ -527,22 +546,28 @@ export interface UploadResourceHistoryDayItems {
|
|||||||
// ── Dynamic credits and products ──────────────────────────
|
// ── Dynamic credits and products ──────────────────────────
|
||||||
export interface CreditBalanceSummary {
|
export interface CreditBalanceSummary {
|
||||||
credits: number;
|
credits: number;
|
||||||
available_credits: number;
|
availableCredits: number;
|
||||||
next_expiring_credits: number;
|
personalCredits: number;
|
||||||
next_expires_at?: string | null;
|
teamAvailableCredits: number;
|
||||||
next_last_usable_at?: string | null;
|
teamFrozenCredits: number;
|
||||||
|
nextExpiringCredits: number;
|
||||||
|
nextExpiresAt?: string | null;
|
||||||
|
nextLastUsableAt?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreditProduct {
|
export interface CreditProduct {
|
||||||
id: string;
|
id: string;
|
||||||
productCode: string;
|
productCode: string;
|
||||||
productType: 'subscription' | 'credit_addon';
|
productType: 'subscription' | 'team_subscription' | 'credit_addon';
|
||||||
|
productTypeLabel?: string;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
features: string[];
|
features: string[];
|
||||||
tierCode?: string | null;
|
tierCode?: string | null;
|
||||||
|
tierLabel?: string | null;
|
||||||
tierRank?: number | null;
|
tierRank?: number | null;
|
||||||
billingCycle?: 'monthly' | 'quarterly' | 'yearly' | null;
|
billingCycle?: 'monthly' | 'quarterly' | 'yearly' | null;
|
||||||
|
billingCycleLabel?: string | null;
|
||||||
monthlyGrantCredits: number;
|
monthlyGrantCredits: number;
|
||||||
grantCount: number;
|
grantCount: number;
|
||||||
firstPurchasePrice: number;
|
firstPurchasePrice: number;
|
||||||
@@ -555,37 +580,105 @@ export interface CreditProduct {
|
|||||||
validityMonths?: number | null;
|
validityMonths?: number | null;
|
||||||
price: number;
|
price: number;
|
||||||
currentPrice: number;
|
currentPrice: number;
|
||||||
targetPrice?: number | null;
|
|
||||||
deductionAmount?: number;
|
|
||||||
priceType?: string | null;
|
priceType?: string | null;
|
||||||
|
priceTypeLabel?: string | null;
|
||||||
creditLevel: string;
|
creditLevel: string;
|
||||||
|
creditLevelLabel?: string;
|
||||||
currency: string;
|
currency: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
isDeleted?: boolean;
|
||||||
|
deletedAt?: string | null;
|
||||||
|
statusLabel?: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
canPurchase?: boolean | null;
|
canPurchase?: boolean | null;
|
||||||
canUpgrade: boolean;
|
|
||||||
unavailableReason?: string | null;
|
unavailableReason?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CurrentCreditSubscription {
|
export interface ActiveCreditSubscription {
|
||||||
id: string;
|
id: string;
|
||||||
productId: string;
|
subscriptionNo: string;
|
||||||
|
productId?: string | null;
|
||||||
|
productName: string;
|
||||||
|
productType: 'subscription' | 'team_subscription';
|
||||||
status: string;
|
status: string;
|
||||||
purchaseScene: string;
|
statusLabel?: string;
|
||||||
|
billingCycle: 'monthly' | 'quarterly' | 'yearly';
|
||||||
|
billingCycleLabel?: string;
|
||||||
tierCode: string;
|
tierCode: string;
|
||||||
tierRank: number;
|
tierRank: number;
|
||||||
billingCycle: 'monthly' | 'quarterly' | 'yearly';
|
|
||||||
anchorAt: string;
|
|
||||||
startAt: string;
|
startAt: string;
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
monthlyGrantCredits: number;
|
monthlyGrantCredits: number;
|
||||||
grantCount: number;
|
paidAmount: number;
|
||||||
grantedCount: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreditProductCatalog {
|
export interface CreditProductCatalog {
|
||||||
subscriptionProducts: CreditProduct[];
|
subscriptionProducts: CreditProduct[];
|
||||||
|
teamSubscriptionProducts: CreditProduct[];
|
||||||
creditAddons: CreditProduct[];
|
creditAddons: CreditProduct[];
|
||||||
firstPurchaseAvailable: boolean;
|
personalFirstPurchaseAvailable: boolean;
|
||||||
currentSubscription?: CurrentCreditSubscription | null;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user