feat(pool-account): 池账户充值弹窗新增中心化充值 tab

与做市商管理一致,充值弹窗包含"中心化充值"和"区块链充值"两个 tab。
中心化充值直接调整池账户余额(ADJUSTMENT 类型分类账),无需区块链交易。

变更:
- wallet-service: pool-account.service 新增 centralizedDeposit 方法
- wallet-service: pool-account.controller 新增 POST /centralized-deposit
- admin-service: pool-account.controller 新增 POST /:walletName/centralized-deposit 代理
- frontend: configs.api + use-configs hook + configs page 充值弹窗 Tabs UI

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-03 02:26:31 -08:00
parent c51539e494
commit a5f6b23a95
6 changed files with 265 additions and 67 deletions

View File

@ -1,4 +1,4 @@
import { Controller, Get, Post, Body, Param, Logger, BadRequestException, HttpException, HttpStatus } from '@nestjs/common';
import { Controller, Get, Post, Body, Param, Req, Logger, BadRequestException, HttpException, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam } from '@nestjs/swagger';
import { ConfigService } from '@nestjs/config';
@ -200,4 +200,57 @@ export class PoolAccountController {
blockNumber: blockchainData.blockNumber,
};
}
@Post(':walletName/centralized-deposit')
@ApiOperation({ summary: '池账户中心化充值(管理员手动调整余额)' })
@ApiParam({ name: 'walletName', description: '池钱包名称MPC用户名' })
async centralizedDeposit(
@Param('walletName') walletName: string,
@Body() body: { amount: string },
@Req() req: any,
) {
const poolInfo = this.walletNameMap[walletName];
if (!poolInfo) {
throw new BadRequestException(`Unknown wallet name: ${walletName}`);
}
if (!body.amount || parseFloat(body.amount) <= 0) {
throw new BadRequestException('amount must be greater than 0');
}
const adminId = req.admin?.id || 'admin';
this.logger.log(`[centralized-deposit] ${poolInfo.name}: ${body.amount} by admin ${adminId}`);
try {
const walletResponse = await fetch(
`${this.walletServiceUrl}/api/v2/pool-accounts/centralized-deposit`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
poolType: poolInfo.walletPoolType,
amount: body.amount,
adminId: String(adminId),
}),
},
);
if (!walletResponse.ok) {
const errResult = await walletResponse.json().catch(() => ({}));
throw new HttpException(
errResult.message || '中心化充值失败',
walletResponse.status || HttpStatus.INTERNAL_SERVER_ERROR,
);
}
return { success: true, message: `${poolInfo.name}充值成功` };
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(`[centralized-deposit] Failed: ${error}`);
throw new HttpException(
`中心化充值失败: ${error instanceof Error ? error.message : error}`,
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
}

View File

@ -148,4 +148,17 @@ export class PoolAccountController {
);
return { success: true };
}
@Post('centralized-deposit')
@Public()
@ApiOperation({ summary: '中心化充值(仅限内网调用)' })
@ApiResponse({ status: 200, description: '充值成功' })
async centralizedDeposit(@Body() dto: { poolType: PoolAccountType; amount: string; adminId?: string }) {
await this.poolAccountService.centralizedDeposit(
dto.poolType,
new Decimal(dto.amount),
dto.adminId,
);
return { success: true };
}
}

View File

@ -575,4 +575,38 @@ export class PoolAccountService {
`Blockchain withdraw: ${amount.toFixed(8)} fUSDT from ${poolType} (tx: ${metadata.txHash.slice(0, 10)}...)`,
);
}
/**
*
*/
async centralizedDeposit(
poolType: PoolAccountType,
amount: Decimal,
adminId?: string,
): Promise<void> {
if (amount.isZero() || amount.isNegative()) {
throw new DomainException('充值金额必须大于0', 'INVALID_AMOUNT');
}
const poolName = poolType === 'SHARE_POOL_A' ? '100亿销毁池' : '200万挖矿池';
const memo = `中心化充值, ${poolName}, 金额${amount.toFixed(8)}, 操作员${adminId || 'admin'}`;
await this.poolAccountRepo.updateBalanceWithTransaction(
poolType,
amount,
{
transactionType: 'ADJUSTMENT',
counterpartyType: 'SYSTEM_ACCOUNT',
referenceType: 'ADMIN_DEPOSIT',
memo,
metadata: {
type: 'centralized_deposit',
amount: amount.toString(),
adminId: adminId || 'admin',
},
},
);
this.logger.log(`Centralized deposit: ${amount.toFixed(8)} to ${poolType} by ${adminId || 'admin'}`);
}
}

View File

@ -3,9 +3,10 @@
import { useState, useEffect } from 'react';
import { QRCodeSVG } from 'qrcode.react';
import { PageHeader } from '@/components/layout/page-header';
import { useConfigs, useUpdateConfig, useTransferEnabled, useSetTransferEnabled, useMiningStatus, useActivateMining, useDeactivateMining, useP2pTransferFee, useSetP2pTransferFee, usePoolAccountBalance, usePoolAccountBlockchainWithdraw } from '@/features/configs/hooks/use-configs';
import { useConfigs, useUpdateConfig, useTransferEnabled, useSetTransferEnabled, useMiningStatus, useActivateMining, useDeactivateMining, useP2pTransferFee, useSetP2pTransferFee, usePoolAccountBalance, usePoolAccountBlockchainWithdraw, usePoolAccountCentralizedDeposit } from '@/features/configs/hooks/use-configs';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
@ -50,17 +51,20 @@ export default function ConfigsPage() {
const { data: burnPoolBalance, isLoading: burnPoolLoading } = usePoolAccountBalance(BURN_POOL_WALLET_NAME);
const { data: miningPoolBalance, isLoading: miningPoolLoading } = usePoolAccountBalance(MINING_POOL_WALLET_NAME);
const blockchainWithdrawMutation = usePoolAccountBlockchainWithdraw();
const centralizedDepositMutation = usePoolAccountCentralizedDeposit();
const [editingConfig, setEditingConfig] = useState<SystemConfig | null>(null);
const [editValue, setEditValue] = useState('');
const [feeValue, setFeeValue] = useState('');
const [minAmountValue, setMinAmountValue] = useState('');
// 池账户区块链提现状态
// 池账户状态
const [burnWithdrawAddress, setBurnWithdrawAddress] = useState('');
const [burnWithdrawAmount, setBurnWithdrawAmount] = useState('');
const [miningWithdrawAddress, setMiningWithdrawAddress] = useState('');
const [miningWithdrawAmount, setMiningWithdrawAmount] = useState('');
const [burnDepositAmount, setBurnDepositAmount] = useState('');
const [miningDepositAmount, setMiningDepositAmount] = useState('');
const [copiedAddress, setCopiedAddress] = useState<string | null>(null);
useEffect(() => {
@ -271,7 +275,7 @@ export default function ConfigsPage() {
)}
<div className="flex gap-2 pt-4 border-t">
{/* 区块链充值 */}
{/* 充值(中心化 + 区块链) */}
<Dialog>
<DialogTrigger asChild>
<Button size="sm" variant="outline">
@ -281,40 +285,75 @@ export default function ConfigsPage() {
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle> 100亿</DialogTitle>
<DialogDescription> fUSDT</DialogDescription>
<DialogTitle> 100亿</DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
{burnPoolBalance?.walletAddress ? (
<div className="flex flex-col items-center space-y-4">
<div className="p-4 bg-white rounded-lg">
<QRCodeSVG value={burnPoolBalance.walletAddress} size={180} />
<Tabs defaultValue="centralized" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="centralized"></TabsTrigger>
<TabsTrigger value="blockchain"></TabsTrigger>
</TabsList>
<TabsContent value="centralized" className="space-y-4 pt-4">
<div>
<Label></Label>
<Input
type="number"
value={burnDepositAmount}
onChange={(e) => setBurnDepositAmount(e.target.value)}
placeholder="请输入金额"
/>
</div>
<div className="w-full">
<Label className="text-xs text-muted-foreground"> (Kava EVM)</Label>
<div className="flex items-center gap-2 mt-1">
<code className="flex-1 text-xs bg-muted p-2 rounded break-all">
{burnPoolBalance.walletAddress}
</code>
<Button
size="sm"
variant="outline"
onClick={() => handleCopyAddress(burnPoolBalance.walletAddress)}
>
{copiedAddress === burnPoolBalance.walletAddress ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
<Button
className="w-full"
onClick={() => {
centralizedDepositMutation.mutate({
walletName: BURN_POOL_WALLET_NAME,
amount: burnDepositAmount,
});
setBurnDepositAmount('');
}}
disabled={centralizedDepositMutation.isPending || !burnDepositAmount}
>
{centralizedDepositMutation.isPending ? '处理中...' : '确认充值'}
</Button>
</TabsContent>
<TabsContent value="blockchain" className="space-y-4 pt-4">
<div className="text-sm text-muted-foreground text-center">
<strong>fUSDT</strong>
</div>
{burnPoolBalance?.walletAddress ? (
<div className="flex flex-col items-center space-y-4">
<div className="p-4 bg-white rounded-lg">
<QRCodeSVG value={burnPoolBalance.walletAddress} size={180} />
</div>
<div className="w-full">
<Label className="text-xs text-muted-foreground"> (Kava EVM)</Label>
<div className="flex items-center gap-2 mt-1">
<code className="flex-1 text-xs bg-muted p-2 rounded break-all">
{burnPoolBalance.walletAddress}
</code>
<Button
size="sm"
variant="outline"
onClick={() => handleCopyAddress(burnPoolBalance.walletAddress)}
>
{copiedAddress === burnPoolBalance.walletAddress ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
<div className="text-xs text-yellow-600 bg-yellow-50 p-2 rounded w-full">
<AlertCircle className="h-3 w-3 inline mr-1" />
12
</div>
</div>
</div>
<div className="text-xs text-yellow-600 bg-yellow-50 p-2 rounded w-full">
<AlertCircle className="h-3 w-3 inline mr-1" />
12
</div>
</div>
) : (
<div className="text-center text-muted-foreground py-4">
<AlertCircle className="h-6 w-6 mx-auto mb-2 text-yellow-500" />
<p className="text-sm"> .env BURN_POOL_WALLET_ADDRESS</p>
</div>
)}
) : (
<div className="text-center text-muted-foreground py-4">
<AlertCircle className="h-6 w-6 mx-auto mb-2 text-yellow-500" />
<p className="text-sm"></p>
</div>
)}
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
@ -419,7 +458,7 @@ export default function ConfigsPage() {
)}
<div className="flex gap-2 pt-4 border-t">
{/* 区块链充值 */}
{/* 充值(中心化 + 区块链) */}
<Dialog>
<DialogTrigger asChild>
<Button size="sm" variant="outline">
@ -429,40 +468,75 @@ export default function ConfigsPage() {
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle> 200</DialogTitle>
<DialogDescription> fUSDT</DialogDescription>
<DialogTitle> 200</DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
{miningPoolBalance?.walletAddress ? (
<div className="flex flex-col items-center space-y-4">
<div className="p-4 bg-white rounded-lg">
<QRCodeSVG value={miningPoolBalance.walletAddress} size={180} />
<Tabs defaultValue="centralized" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="centralized"></TabsTrigger>
<TabsTrigger value="blockchain"></TabsTrigger>
</TabsList>
<TabsContent value="centralized" className="space-y-4 pt-4">
<div>
<Label></Label>
<Input
type="number"
value={miningDepositAmount}
onChange={(e) => setMiningDepositAmount(e.target.value)}
placeholder="请输入金额"
/>
</div>
<div className="w-full">
<Label className="text-xs text-muted-foreground"> (Kava EVM)</Label>
<div className="flex items-center gap-2 mt-1">
<code className="flex-1 text-xs bg-muted p-2 rounded break-all">
{miningPoolBalance.walletAddress}
</code>
<Button
size="sm"
variant="outline"
onClick={() => handleCopyAddress(miningPoolBalance.walletAddress)}
>
{copiedAddress === miningPoolBalance.walletAddress ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
<Button
className="w-full"
onClick={() => {
centralizedDepositMutation.mutate({
walletName: MINING_POOL_WALLET_NAME,
amount: miningDepositAmount,
});
setMiningDepositAmount('');
}}
disabled={centralizedDepositMutation.isPending || !miningDepositAmount}
>
{centralizedDepositMutation.isPending ? '处理中...' : '确认充值'}
</Button>
</TabsContent>
<TabsContent value="blockchain" className="space-y-4 pt-4">
<div className="text-sm text-muted-foreground text-center">
<strong>fUSDT</strong>
</div>
{miningPoolBalance?.walletAddress ? (
<div className="flex flex-col items-center space-y-4">
<div className="p-4 bg-white rounded-lg">
<QRCodeSVG value={miningPoolBalance.walletAddress} size={180} />
</div>
<div className="w-full">
<Label className="text-xs text-muted-foreground"> (Kava EVM)</Label>
<div className="flex items-center gap-2 mt-1">
<code className="flex-1 text-xs bg-muted p-2 rounded break-all">
{miningPoolBalance.walletAddress}
</code>
<Button
size="sm"
variant="outline"
onClick={() => handleCopyAddress(miningPoolBalance.walletAddress)}
>
{copiedAddress === miningPoolBalance.walletAddress ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
<div className="text-xs text-yellow-600 bg-yellow-50 p-2 rounded w-full">
<AlertCircle className="h-3 w-3 inline mr-1" />
12
</div>
</div>
</div>
<div className="text-xs text-yellow-600 bg-yellow-50 p-2 rounded w-full">
<AlertCircle className="h-3 w-3 inline mr-1" />
12
</div>
</div>
) : (
<div className="text-center text-muted-foreground py-4">
<AlertCircle className="h-6 w-6 mx-auto mb-2 text-yellow-500" />
<p className="text-sm"> .env MINING_POOL_WALLET_ADDRESS</p>
</div>
)}
) : (
<div className="text-center text-muted-foreground py-4">
<AlertCircle className="h-6 w-6 mx-auto mb-2 text-yellow-500" />
<p className="text-sm"></p>
</div>
)}
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>

View File

@ -92,4 +92,13 @@ export const configsApi = {
const response = await apiClient.post(`/admin/pool-accounts/${walletName}/blockchain-withdraw`, { toAddress, amount });
return response.data.data;
},
// 中心化充值(管理员手动调整余额)
poolAccountCentralizedDeposit: async (walletName: string, amount: string): Promise<{
success: boolean;
message: string;
}> => {
const response = await apiClient.post(`/admin/pool-accounts/${walletName}/centralized-deposit`, { amount });
return response.data.data;
},
};

View File

@ -151,3 +151,18 @@ export function usePoolAccountBlockchainWithdraw() {
},
});
}
export function usePoolAccountCentralizedDeposit() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ walletName, amount }: { walletName: string; amount: string }) =>
configsApi.poolAccountCentralizedDeposit(walletName, amount),
onSuccess: (data) => {
toast({ title: data.message || '充值成功' });
queryClient.invalidateQueries({ queryKey: ['configs', 'pool-account'] });
},
onError: (error: any) => {
toast({ title: '充值失败', description: error.response?.data?.message || '中心化充值失败', variant: 'destructive' });
},
});
}