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:
t0ng7u
2026-01-30 05:31:10 +08:00
parent c6c12d340f
commit 009910b960
36 changed files with 3872 additions and 181 deletions

View File

@@ -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) => {