feat: Add topup billing history with admin manual completion

Implement comprehensive topup billing system with user history viewing and admin management capabilities.

## Features Added

### Frontend
- Add topup history modal with paginated billing records
- Display order details: trade number, payment method, amount, money, status, create time
- Implement empty state with proper illustrations
- Add payment method column with localized display (Stripe, Alipay, WeChat)
- Add admin manual completion feature for pending orders
- Add Coins icon for recharge amount display
- Integrate "Bills" button in RechargeCard header
- Optimize code quality by using shared utility functions (isAdmin)
- Extract constants for status and payment method mappings
- Use React.useMemo for performance optimization

### Backend
- Create GET `/api/user/topup/self` endpoint for user topup history with pagination
- Create POST `/api/user/topup/complete` endpoint for admin manual order completion
- Add `payment_method` field to TopUp model for tracking payment types
- Implement `GetUserTopUps` method with proper pagination and ordering
- Implement `ManualCompleteTopUp` with transaction safety and row-level locking
- Add application-level mutex locks to prevent concurrent order processing
- Record payment method in Epay and Stripe payment flows
- Ensure idempotency and data consistency with proper error handling

### Internationalization
- Add i18n keys for Chinese (zh), English (en), and French (fr)
- Support for billing-related UI text and status messages

## Technical Improvements
- Use database transactions with FOR UPDATE row-level locking
- Implement sync.Map-based mutex for order-level concurrency control
- Proper error handling and user-friendly toast notifications
- Follow existing codebase patterns for empty states and modals
- Maintain code quality with extracted render functions and constants

## Files Changed
- Backend: controller/topup.go, controller/topup_stripe.go, model/topup.go, router/api-router.go
- Frontend: web/src/components/topup/modals/TopupHistoryModal.jsx (new), web/src/components/topup/RechargeCard.jsx, web/src/components/topup/index.jsx
- i18n: web/src/i18n/locales/{zh,en,fr}.json
This commit is contained in:
Apple\Apple
2025-10-07 00:22:45 +08:00
parent 5640cfa44e
commit dec3a32397
34 changed files with 2354 additions and 1571 deletions

View File

@@ -929,10 +929,10 @@ export function renderQuotaWithAmount(amount) {
export function getCurrencyConfig() {
const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
const statusStr = localStorage.getItem('status');
let symbol = '$';
let rate = 1;
if (quotaDisplayType === 'CNY') {
symbol = '¥';
try {
@@ -950,7 +950,7 @@ export function getCurrencyConfig() {
}
} catch (e) {}
}
return { symbol, rate, type: quotaDisplayType };
}
@@ -1128,7 +1128,7 @@ export function renderModelPrice(
user_group_ratio,
);
groupRatio = effectiveGroupRatio;
// 获取货币配置
const { symbol, rate } = getCurrencyConfig();
@@ -1177,13 +1177,16 @@ export function renderModelPrice(
<>
<article>
<p>
{i18next.t('输入价格:{{symbol}}{{price}} / 1M tokens{{audioPrice}}', {
symbol: symbol,
price: (inputRatioPrice * rate).toFixed(6),
audioPrice: audioInputSeperatePrice
? `,音频 ${symbol}${(audioInputPrice * rate).toFixed(6)} / 1M tokens`
: '',
})}
{i18next.t(
'输入价格:{{symbol}}{{price}} / 1M tokens{{audioPrice}}',
{
symbol: symbol,
price: (inputRatioPrice * rate).toFixed(6),
audioPrice: audioInputSeperatePrice
? `,音频 ${symbol}${(audioInputPrice * rate).toFixed(6)} / 1M tokens`
: '',
},
)}
</p>
<p>
{i18next.t(
@@ -1311,27 +1314,27 @@ export function renderModelPrice(
const extraServices = [
webSearch && webSearchCallCount > 0
? i18next.t(
' + Web搜索 {{count}}次 / 1K 次 * {{symbol}}{{price}} * {{ratioType}} {{ratio}}',
{
count: webSearchCallCount,
symbol: symbol,
price: (webSearchPrice * rate).toFixed(6),
ratio: groupRatio,
ratioType: ratioLabel,
},
)
' + Web搜索 {{count}}次 / 1K 次 * {{symbol}}{{price}} * {{ratioType}} {{ratio}}',
{
count: webSearchCallCount,
symbol: symbol,
price: (webSearchPrice * rate).toFixed(6),
ratio: groupRatio,
ratioType: ratioLabel,
},
)
: '',
fileSearch && fileSearchCallCount > 0
? i18next.t(
' + 文件搜索 {{count}}次 / 1K 次 * {{symbol}}{{price}} * {{ratioType}} {{ratio}}',
{
count: fileSearchCallCount,
symbol: symbol,
price: (fileSearchPrice * rate).toFixed(6),
ratio: groupRatio,
ratioType: ratioLabel,
},
)
' + 文件搜索 {{count}}次 / 1K 次 * {{symbol}}{{price}} * {{ratioType}} {{ratio}}',
{
count: fileSearchCallCount,
symbol: symbol,
price: (fileSearchPrice * rate).toFixed(6),
ratio: groupRatio,
ratioType: ratioLabel,
},
)
: '',
imageGenerationCall && imageGenerationCallPrice > 0
? i18next.t(
@@ -1384,7 +1387,7 @@ export function renderLogContent(
label: ratioLabel,
useUserGroupRatio: useUserGroupRatio,
} = getEffectiveRatio(groupRatio, user_group_ratio);
// 获取货币配置
const { symbol, rate } = getCurrencyConfig();
@@ -1484,10 +1487,10 @@ export function renderAudioModelPrice(
user_group_ratio,
);
groupRatio = effectiveGroupRatio;
// 获取货币配置
const { symbol, rate } = getCurrencyConfig();
// 1 ratio = $0.002 / 1K tokens
if (modelPrice !== -1) {
return i18next.t(
@@ -1522,10 +1525,10 @@ export function renderAudioModelPrice(
let audioPrice =
(audioInputTokens / 1000000) * inputRatioPrice * audioRatio * groupRatio +
(audioCompletionTokens / 1000000) *
inputRatioPrice *
audioRatio *
audioCompletionRatio *
groupRatio;
inputRatioPrice *
audioRatio *
audioCompletionRatio *
groupRatio;
let price = textPrice + audioPrice;
return (
<>
@@ -1577,7 +1580,12 @@ export function renderAudioModelPrice(
{
symbol: symbol,
price: (inputRatioPrice * rate).toFixed(6),
total: (inputRatioPrice * audioRatio * audioCompletionRatio * rate).toFixed(6),
total: (
inputRatioPrice *
audioRatio *
audioCompletionRatio *
rate
).toFixed(6),
audioRatio: audioRatio,
audioCompRatio: audioCompletionRatio,
},
@@ -1586,29 +1594,31 @@ export function renderAudioModelPrice(
<p>
{cacheTokens > 0
? i18next.t(
'文字提示 {{nonCacheInput}} tokens / 1M tokens * {{symbol}}{{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * {{symbol}}{{cachePrice}} + 文字补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} = {{symbol}}{{total}}',
{
nonCacheInput: inputTokens - cacheTokens,
cacheInput: cacheTokens,
symbol: symbol,
cachePrice: (inputRatioPrice * cacheRatio * rate).toFixed(6),
price: (inputRatioPrice * rate).toFixed(6),
completion: completionTokens,
compPrice: (completionRatioPrice * rate).toFixed(6),
total: (textPrice * rate).toFixed(6),
},
)
'文字提示 {{nonCacheInput}} tokens / 1M tokens * {{symbol}}{{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * {{symbol}}{{cachePrice}} + 文字补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} = {{symbol}}{{total}}',
{
nonCacheInput: inputTokens - cacheTokens,
cacheInput: cacheTokens,
symbol: symbol,
cachePrice: (inputRatioPrice * cacheRatio * rate).toFixed(
6,
),
price: (inputRatioPrice * rate).toFixed(6),
completion: completionTokens,
compPrice: (completionRatioPrice * rate).toFixed(6),
total: (textPrice * rate).toFixed(6),
},
)
: i18next.t(
'文字提示 {{input}} tokens / 1M tokens * {{symbol}}{{price}} + 文字补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} = {{symbol}}{{total}}',
{
input: inputTokens,
symbol: symbol,
price: (inputRatioPrice * rate).toFixed(6),
completion: completionTokens,
compPrice: (completionRatioPrice * rate).toFixed(6),
total: (textPrice * rate).toFixed(6),
},
)}
'文字提示 {{input}} tokens / 1M tokens * {{symbol}}{{price}} + 文字补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} = {{symbol}}{{total}}',
{
input: inputTokens,
symbol: symbol,
price: (inputRatioPrice * rate).toFixed(6),
completion: completionTokens,
compPrice: (completionRatioPrice * rate).toFixed(6),
total: (textPrice * rate).toFixed(6),
},
)}
</p>
<p>
{i18next.t(
@@ -1617,9 +1627,15 @@ export function renderAudioModelPrice(
input: audioInputTokens,
completion: audioCompletionTokens,
symbol: symbol,
audioInputPrice: (audioRatio * inputRatioPrice * rate).toFixed(6),
audioCompPrice:
(audioRatio * audioCompletionRatio * inputRatioPrice * rate).toFixed(6),
audioInputPrice: (audioRatio * inputRatioPrice * rate).toFixed(
6,
),
audioCompPrice: (
audioRatio *
audioCompletionRatio *
inputRatioPrice *
rate
).toFixed(6),
total: (audioPrice * rate).toFixed(6),
},
)}
@@ -1668,7 +1684,7 @@ export function renderClaudeModelPrice(
user_group_ratio,
);
groupRatio = effectiveGroupRatio;
// 获取货币配置
const { symbol, rate } = getCurrencyConfig();
@@ -1757,37 +1773,39 @@ export function renderClaudeModelPrice(
<p>
{cacheTokens > 0 || cacheCreationTokens > 0
? i18next.t(
'提示 {{nonCacheInput}} tokens / 1M tokens * {{symbol}}{{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * {{symbol}}{{cachePrice}} + 缓存创建 {{cacheCreationInput}} tokens / 1M tokens * {{symbol}}{{cacheCreationPrice}} + 补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}',
{
nonCacheInput: nonCachedTokens,
cacheInput: cacheTokens,
cacheRatio: cacheRatio,
cacheCreationInput: cacheCreationTokens,
cacheCreationRatio: cacheCreationRatio,
symbol: symbol,
cachePrice: (cacheRatioPrice * rate).toFixed(2),
cacheCreationPrice: (cacheCreationRatioPrice * rate).toFixed(6),
price: (inputRatioPrice * rate).toFixed(6),
completion: completionTokens,
compPrice: (completionRatioPrice * rate).toFixed(6),
ratio: groupRatio,
ratioType: ratioLabel,
total: (price * rate).toFixed(6),
},
)
'提示 {{nonCacheInput}} tokens / 1M tokens * {{symbol}}{{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * {{symbol}}{{cachePrice}} + 缓存创建 {{cacheCreationInput}} tokens / 1M tokens * {{symbol}}{{cacheCreationPrice}} + 补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}',
{
nonCacheInput: nonCachedTokens,
cacheInput: cacheTokens,
cacheRatio: cacheRatio,
cacheCreationInput: cacheCreationTokens,
cacheCreationRatio: cacheCreationRatio,
symbol: symbol,
cachePrice: (cacheRatioPrice * rate).toFixed(2),
cacheCreationPrice: (
cacheCreationRatioPrice * rate
).toFixed(6),
price: (inputRatioPrice * rate).toFixed(6),
completion: completionTokens,
compPrice: (completionRatioPrice * rate).toFixed(6),
ratio: groupRatio,
ratioType: ratioLabel,
total: (price * rate).toFixed(6),
},
)
: i18next.t(
'提示 {{input}} tokens / 1M tokens * {{symbol}}{{price}} + 补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}',
{
input: inputTokens,
symbol: symbol,
price: (inputRatioPrice * rate).toFixed(6),
completion: completionTokens,
compPrice: (completionRatioPrice * rate).toFixed(6),
ratio: groupRatio,
ratioType: ratioLabel,
total: (price * rate).toFixed(6),
},
)}
'提示 {{input}} tokens / 1M tokens * {{symbol}}{{price}} + 补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}',
{
input: inputTokens,
symbol: symbol,
price: (inputRatioPrice * rate).toFixed(6),
completion: completionTokens,
compPrice: (completionRatioPrice * rate).toFixed(6),
ratio: groupRatio,
ratioType: ratioLabel,
total: (price * rate).toFixed(6),
},
)}
</p>
<p>{i18next.t('仅供参考,以实际扣费为准')}</p>
</article>
@@ -1810,7 +1828,7 @@ export function renderClaudeLogContent(
user_group_ratio,
);
groupRatio = effectiveGroupRatio;
// 获取货币配置
const { symbol, rate } = getCurrencyConfig();