mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-05-05 11:11:46 +00:00
✨ feat: add subscription billing system with admin management and user purchase flow
Implement a new subscription-based billing model alongside existing metered/per-request billing: Backend: - Add subscription plan models (SubscriptionPlan, SubscriptionPlanItem, UserSubscription, etc.) - Implement CRUD APIs for subscription plan management (admin only) - Add user subscription queries with support for multiple active/expired subscriptions - Integrate payment gateways (Stripe, Creem, Epay) for subscription purchases - Implement pre-consume and post-consume billing logic for subscription quota tracking - Add billing preference settings (subscription_first, wallet_first, etc.) - Enhance usage logs with subscription deduction details Frontend - Admin: - Add subscription management page with table view and drawer-based edit form - Match UI/UX style with existing admin pages (redemption codes, users) - Support enabling/disabling plans, configuring payment IDs, and model quotas - Add user subscription binding modal in user management Frontend - Wallet: - Add subscription plans card with current subscription status display - Show all subscriptions (active and expired) with remaining days/usage percentage - Display purchasable plans with pricing cards following SaaS best practices - Extract purchase modal to separate component matching payment confirm modal style - Add skeleton loading states with active animation - Implement billing preference selector in card header - Handle payment gateway availability based on admin configuration Frontend - Usage Logs: - Display subscription deduction details in log entries - Show step-by-step breakdown of subscription usage (pre-consumed, delta, final, remaining) - Add subscription deduction tag for subscription-covered requests
This commit is contained in:
@@ -51,6 +51,7 @@ export const DEFAULT_ADMIN_CONFIG = {
|
||||
deployment: true,
|
||||
redemption: true,
|
||||
user: true,
|
||||
subscription: true,
|
||||
setting: true,
|
||||
},
|
||||
};
|
||||
|
||||
144
web/src/hooks/subscriptions/useSubscriptionsData.jsx
Normal file
144
web/src/hooks/subscriptions/useSubscriptionsData.jsx
Normal file
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API, showError, showSuccess } from '../../helpers';
|
||||
import { useTableCompactMode } from '../common/useTableCompactMode';
|
||||
|
||||
export const useSubscriptionsData = () => {
|
||||
const { t } = useTranslation();
|
||||
const [compactMode, setCompactMode] = useTableCompactMode('subscriptions');
|
||||
|
||||
// State management
|
||||
const [plans, setPlans] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pricingModels, setPricingModels] = useState([]);
|
||||
|
||||
// Drawer states
|
||||
const [showEdit, setShowEdit] = useState(false);
|
||||
const [editingPlan, setEditingPlan] = useState(null);
|
||||
const [sheetPlacement, setSheetPlacement] = useState('left'); // 'left' | 'right'
|
||||
|
||||
// Load pricing models for dropdown
|
||||
const loadModels = async () => {
|
||||
try {
|
||||
const res = await API.get('/api/pricing');
|
||||
if (res.data?.success) {
|
||||
setPricingModels(res.data.data || []);
|
||||
}
|
||||
} catch (e) {
|
||||
setPricingModels([]);
|
||||
}
|
||||
};
|
||||
|
||||
// Load subscription plans
|
||||
const loadPlans = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get('/api/subscription/admin/plans');
|
||||
if (res.data?.success) {
|
||||
setPlans(res.data.data || []);
|
||||
} else {
|
||||
showError(res.data?.message || t('加载失败'));
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('请求失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Refresh data
|
||||
const refresh = async () => {
|
||||
await loadPlans();
|
||||
};
|
||||
|
||||
// Disable plan
|
||||
const disablePlan = async (planId) => {
|
||||
if (!planId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.delete(`/api/subscription/admin/plans/${planId}`);
|
||||
if (res.data?.success) {
|
||||
showSuccess(t('已禁用'));
|
||||
await loadPlans();
|
||||
} else {
|
||||
showError(res.data?.message || t('操作失败'));
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('请求失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Modal control functions
|
||||
const closeEdit = () => {
|
||||
setShowEdit(false);
|
||||
setEditingPlan(null);
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setSheetPlacement('left');
|
||||
setEditingPlan(null);
|
||||
setShowEdit(true);
|
||||
};
|
||||
|
||||
const openEdit = (planRecord) => {
|
||||
setSheetPlacement('right');
|
||||
setEditingPlan(planRecord);
|
||||
setShowEdit(true);
|
||||
};
|
||||
|
||||
// Initialize data on component mount
|
||||
useEffect(() => {
|
||||
loadModels();
|
||||
loadPlans();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// Data state
|
||||
plans,
|
||||
loading,
|
||||
pricingModels,
|
||||
|
||||
// Modal state
|
||||
showEdit,
|
||||
editingPlan,
|
||||
sheetPlacement,
|
||||
setShowEdit,
|
||||
setEditingPlan,
|
||||
|
||||
// UI state
|
||||
compactMode,
|
||||
setCompactMode,
|
||||
|
||||
// Actions
|
||||
loadPlans,
|
||||
disablePlan,
|
||||
refresh,
|
||||
closeEdit,
|
||||
openCreate,
|
||||
openEdit,
|
||||
|
||||
// Translation
|
||||
t,
|
||||
};
|
||||
};
|
||||
@@ -364,32 +364,32 @@ export const useLogsData = () => {
|
||||
key: t('日志详情'),
|
||||
value: other?.claude
|
||||
? renderClaudeLogContent(
|
||||
other?.model_ratio,
|
||||
other.completion_ratio,
|
||||
other.model_price,
|
||||
other.group_ratio,
|
||||
other?.user_group_ratio,
|
||||
other.cache_ratio || 1.0,
|
||||
other.cache_creation_ratio || 1.0,
|
||||
other.cache_creation_tokens_5m || 0,
|
||||
other.cache_creation_ratio_5m || other.cache_creation_ratio || 1.0,
|
||||
other.cache_creation_tokens_1h || 0,
|
||||
other.cache_creation_ratio_1h || other.cache_creation_ratio || 1.0,
|
||||
)
|
||||
other?.model_ratio,
|
||||
other.completion_ratio,
|
||||
other.model_price,
|
||||
other.group_ratio,
|
||||
other?.user_group_ratio,
|
||||
other.cache_ratio || 1.0,
|
||||
other.cache_creation_ratio || 1.0,
|
||||
other.cache_creation_tokens_5m || 0,
|
||||
other.cache_creation_ratio_5m || other.cache_creation_ratio || 1.0,
|
||||
other.cache_creation_tokens_1h || 0,
|
||||
other.cache_creation_ratio_1h || other.cache_creation_ratio || 1.0,
|
||||
)
|
||||
: renderLogContent(
|
||||
other?.model_ratio,
|
||||
other.completion_ratio,
|
||||
other.model_price,
|
||||
other.group_ratio,
|
||||
other?.user_group_ratio,
|
||||
other.cache_ratio || 1.0,
|
||||
false,
|
||||
1.0,
|
||||
other.web_search || false,
|
||||
other.web_search_call_count || 0,
|
||||
other.file_search || false,
|
||||
other.file_search_call_count || 0,
|
||||
),
|
||||
other?.model_ratio,
|
||||
other.completion_ratio,
|
||||
other.model_price,
|
||||
other.group_ratio,
|
||||
other?.user_group_ratio,
|
||||
other.cache_ratio || 1.0,
|
||||
false,
|
||||
1.0,
|
||||
other.web_search || false,
|
||||
other.web_search_call_count || 0,
|
||||
other.file_search || false,
|
||||
other.file_search_call_count || 0,
|
||||
),
|
||||
});
|
||||
if (logs[i]?.content) {
|
||||
expandDataLocal.push({
|
||||
@@ -458,12 +458,12 @@ export const useLogsData = () => {
|
||||
other.cache_creation_ratio || 1.0,
|
||||
other.cache_creation_tokens_5m || 0,
|
||||
other.cache_creation_ratio_5m ||
|
||||
other.cache_creation_ratio ||
|
||||
1.0,
|
||||
other.cache_creation_ratio ||
|
||||
1.0,
|
||||
other.cache_creation_tokens_1h || 0,
|
||||
other.cache_creation_ratio_1h ||
|
||||
other.cache_creation_ratio ||
|
||||
1.0,
|
||||
other.cache_creation_ratio ||
|
||||
1.0,
|
||||
);
|
||||
} else {
|
||||
content = renderModelPrice(
|
||||
@@ -510,6 +510,60 @@ export const useLogsData = () => {
|
||||
value: other.request_path,
|
||||
});
|
||||
}
|
||||
if (other?.billing_source === 'subscription') {
|
||||
const planId = other?.subscription_plan_id;
|
||||
const planTitle = other?.subscription_plan_title || '';
|
||||
const itemId = other?.subscription_item_id;
|
||||
const quotaType = other?.subscription_quota_type;
|
||||
const unit = quotaType === 1 ? t('次') : t('额度');
|
||||
const pre = other?.subscription_pre_consumed ?? 0;
|
||||
const postDelta = other?.subscription_post_delta ?? 0;
|
||||
const finalConsumed =
|
||||
other?.subscription_consumed ?? (quotaType === 1 ? 1 : pre + postDelta);
|
||||
const remain = other?.subscription_remain;
|
||||
const total = other?.subscription_total;
|
||||
// Use multiple Description items to avoid an overlong single line.
|
||||
if (planId) {
|
||||
expandDataLocal.push({
|
||||
key: t('订阅套餐'),
|
||||
value: `#${planId} ${planTitle}`.trim(),
|
||||
});
|
||||
}
|
||||
if (itemId) {
|
||||
expandDataLocal.push({
|
||||
key: t('订阅权益'),
|
||||
value:
|
||||
quotaType === 1
|
||||
? `${t('权益ID')} ${itemId} · ${t('按次')}(1 ${t('次')}/${t('请求')})`
|
||||
: `${t('权益ID')} ${itemId} · ${t('按量')}`,
|
||||
});
|
||||
}
|
||||
const settlementLines = [
|
||||
`${t('预扣')}:${pre} ${unit}`,
|
||||
quotaType === 0
|
||||
? `${t('结算差额')}:${postDelta > 0 ? '+' : ''}${postDelta} ${unit}`
|
||||
: null,
|
||||
`${t('最终抵扣')}:${finalConsumed} ${unit}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
expandDataLocal.push({
|
||||
key: t('订阅结算'),
|
||||
value: <div style={{ whiteSpace: 'pre-line' }}>{settlementLines}</div>,
|
||||
});
|
||||
if (remain !== undefined && total !== undefined) {
|
||||
expandDataLocal.push({
|
||||
key: t('订阅剩余'),
|
||||
value: `${remain}/${total} ${unit}`,
|
||||
});
|
||||
}
|
||||
expandDataLocal.push({
|
||||
key: t('订阅说明'),
|
||||
value: t(
|
||||
'token 会按倍率换算成“额度/次数”,请求结束后再做差额结算(补扣/返还)。',
|
||||
),
|
||||
});
|
||||
}
|
||||
if (isAdminUser) {
|
||||
expandDataLocal.push({
|
||||
key: t('请求转换'),
|
||||
@@ -524,8 +578,8 @@ export const useLogsData = () => {
|
||||
localCountMode = t('上游返回');
|
||||
}
|
||||
expandDataLocal.push({
|
||||
key: t('计费模式'),
|
||||
value: localCountMode,
|
||||
key: t('计费模式'),
|
||||
value: localCountMode,
|
||||
});
|
||||
}
|
||||
expandDatesLocal[logs[i].key] = expandDataLocal;
|
||||
@@ -584,7 +638,7 @@ export const useLogsData = () => {
|
||||
// Page handlers
|
||||
const handlePageChange = (page) => {
|
||||
setActivePage(page);
|
||||
loadLogs(page, pageSize).then((r) => {});
|
||||
loadLogs(page, pageSize).then((r) => { });
|
||||
};
|
||||
|
||||
const handlePageSizeChange = async (size) => {
|
||||
|
||||
Reference in New Issue
Block a user