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

@@ -1,3 +1,21 @@
/*
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
*/
export function base64UrlToBuffer(base64url) {
if (!base64url) return new ArrayBuffer(0);
let padding = '='.repeat((4 - (base64url.length % 4)) % 4);
@@ -26,7 +44,11 @@ export function bufferToBase64Url(buffer) {
}
export function prepareCredentialCreationOptions(payload) {
const options = payload?.publicKey || payload?.PublicKey || payload?.response || payload?.Response;
const options =
payload?.publicKey ||
payload?.PublicKey ||
payload?.response ||
payload?.Response;
if (!options) {
throw new Error('无法从服务端响应中解析 Passkey 注册参数');
}
@@ -46,7 +68,10 @@ export function prepareCredentialCreationOptions(payload) {
}));
}
if (Array.isArray(options.attestationFormats) && options.attestationFormats.length === 0) {
if (
Array.isArray(options.attestationFormats) &&
options.attestationFormats.length === 0
) {
delete publicKey.attestationFormats;
}
@@ -54,7 +79,11 @@ export function prepareCredentialCreationOptions(payload) {
}
export function prepareCredentialRequestOptions(payload) {
const options = payload?.publicKey || payload?.PublicKey || payload?.response || payload?.Response;
const options =
payload?.publicKey ||
payload?.PublicKey ||
payload?.response ||
payload?.Response;
if (!options) {
throw new Error('无法从服务端响应中解析 Passkey 登录参数');
}
@@ -77,7 +106,10 @@ export function buildRegistrationResult(credential) {
if (!credential) return null;
const { response } = credential;
const transports = typeof response.getTransports === 'function' ? response.getTransports() : undefined;
const transports =
typeof response.getTransports === 'function'
? response.getTransports()
: undefined;
return {
id: credential.id,
@@ -107,7 +139,9 @@ export function buildAssertionResult(assertion) {
authenticatorData: bufferToBase64Url(response.authenticatorData),
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
signature: bufferToBase64Url(response.signature),
userHandle: response.userHandle ? bufferToBase64Url(response.userHandle) : null,
userHandle: response.userHandle
? bufferToBase64Url(response.userHandle)
: null,
},
clientExtensionResults: assertion.getClientExtensionResults?.() ?? {},
};
@@ -117,15 +151,22 @@ export async function isPasskeySupported() {
if (typeof window === 'undefined' || !window.PublicKeyCredential) {
return false;
}
if (typeof window.PublicKeyCredential.isConditionalMediationAvailable === 'function') {
if (
typeof window.PublicKeyCredential.isConditionalMediationAvailable ===
'function'
) {
try {
const available = await window.PublicKeyCredential.isConditionalMediationAvailable();
const available =
await window.PublicKeyCredential.isConditionalMediationAvailable();
if (available) return true;
} catch (error) {
// ignore
}
}
if (typeof window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable === 'function') {
if (
typeof window.PublicKeyCredential
.isUserVerifyingPlatformAuthenticatorAvailable === 'function'
) {
try {
return await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
} catch (error) {
@@ -134,4 +175,3 @@ export async function isPasskeySupported() {
}
return true;
}

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();

View File

@@ -37,7 +37,7 @@ export function isVerificationRequiredError(error) {
const verificationCodes = [
'VERIFICATION_REQUIRED',
'VERIFICATION_EXPIRED',
'VERIFICATION_INVALID'
'VERIFICATION_INVALID',
];
return verificationCodes.includes(data.code);
@@ -57,6 +57,6 @@ export function extractVerificationInfo(error) {
return {
code: data.code,
message: data.message || '需要安全验证',
required: true
required: true,
};
}
}