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;
}