✨ 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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user