feat(c2c-bot): 添加C2C Bot管理页面,支持运行时开关和热钱包查看

- 新增C2C Bot管理页面(mining-admin-web):Bot开关、热钱包余额/地址/QR码、统计、订单历史
- 新增admin API端点(trading-service):status/enable/disable/orders
- 重构Bot调度器enabled为Redis驱动,支持运行时开关(多实例安全)
- C2cOrderRepository新增findBotPurchasedOrders和getBotStats查询方法
- 侧边栏添加C2C Bot导航入口

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-01 04:47:28 -08:00
parent bf772967f5
commit 251e37f772
8 changed files with 669 additions and 8 deletions

View File

@ -11,6 +11,7 @@ import { BurnController } from './controllers/burn.controller';
import { AssetController } from './controllers/asset.controller';
import { MarketMakerController } from './controllers/market-maker.controller';
import { C2cController } from './controllers/c2c.controller';
import { C2cBotController } from './controllers/c2c-bot.controller';
import { PriceGateway } from './gateways/price.gateway';
@Module({
@ -26,6 +27,7 @@ import { PriceGateway } from './gateways/price.gateway';
AssetController,
MarketMakerController,
C2cController,
C2cBotController,
],
providers: [PriceGateway],
exports: [PriceGateway],

View File

@ -0,0 +1,115 @@
import {
Controller,
Get,
Post,
Query,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiQuery } from '@nestjs/swagger';
import { C2cBotService } from '../../application/services/c2c-bot.service';
import { C2cBotScheduler } from '../../application/schedulers/c2c-bot.scheduler';
import { C2cOrderRepository } from '../../infrastructure/persistence/repositories/c2c-order.repository';
import { BlockchainClient } from '../../infrastructure/blockchain/blockchain.client';
import { RedisService } from '../../infrastructure/redis/redis.service';
import { Public } from '../../shared/guards/jwt-auth.guard';
@ApiTags('C2C Bot Admin')
@Controller('admin/c2c-bot')
export class C2cBotController {
constructor(
private readonly c2cBotService: C2cBotService,
private readonly c2cOrderRepository: C2cOrderRepository,
private readonly blockchainClient: BlockchainClient,
private readonly redis: RedisService,
) {}
@Get('status')
@Public()
@ApiOperation({ summary: '获取 C2C Bot 状态' })
async getStatus() {
const [enabled, blockchainAvailable, balance, status, stats] = await Promise.all([
this.redis.get(C2cBotScheduler.ENABLED_KEY).then((v) => v === 'true'),
this.c2cBotService.isAvailable(),
this.c2cBotService.getHotWalletBalance(),
this.blockchainClient.getStatus(),
this.c2cOrderRepository.getBotStats(),
]);
return {
success: true,
enabled,
blockchainAvailable,
hotWallet: {
address: status?.hotWalletAddress ?? null,
balance: balance ?? null,
},
stats: {
totalBotOrders: stats.totalOrders,
totalBotAmount: stats.totalAmount,
},
};
}
@Post('enable')
@Public()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '开启 C2C Bot' })
async enable() {
await this.redis.set(C2cBotScheduler.ENABLED_KEY, 'true');
return {
success: true,
enabled: true,
message: 'C2C Bot 已开启',
};
}
@Post('disable')
@Public()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '关闭 C2C Bot' })
async disable() {
await this.redis.set(C2cBotScheduler.ENABLED_KEY, 'false');
return {
success: true,
enabled: false,
message: 'C2C Bot 已关闭',
};
}
@Get('orders')
@Public()
@ApiOperation({ summary: '获取 Bot 购买的订单历史' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'pageSize', required: false, type: Number })
async getOrders(
@Query('page') page?: number,
@Query('pageSize') pageSize?: number,
) {
const result = await this.c2cOrderRepository.findBotPurchasedOrders({
page: page ?? 1,
pageSize: pageSize ?? 20,
});
return {
success: true,
data: result.data.map((order) => ({
orderNo: order.orderNo,
makerAccountSequence: order.makerAccountSequence,
makerPhone: order.makerPhone,
makerNickname: order.makerNickname,
totalAmount: order.totalAmount,
price: order.price,
quantity: order.quantity,
sellerKavaAddress: order.sellerKavaAddress,
paymentTxHash: order.paymentTxHash,
status: order.status,
completedAt: order.completedAt,
createdAt: order.createdAt,
})),
total: result.total,
page: page ?? 1,
pageSize: pageSize ?? 20,
};
}
}

View File

@ -13,21 +13,27 @@ import { RedisService } from '../../infrastructure/redis/redis.service';
export class C2cBotScheduler implements OnModuleInit {
private readonly logger = new Logger(C2cBotScheduler.name);
private readonly LOCK_KEY = 'c2c:bot:scheduler:lock';
private readonly enabled: boolean;
static readonly ENABLED_KEY = 'c2c:bot:enabled';
constructor(
private readonly c2cOrderRepository: C2cOrderRepository,
private readonly c2cBotService: C2cBotService,
private readonly redis: RedisService,
private readonly configService: ConfigService,
) {
this.enabled = this.configService.get<boolean>('C2C_BOT_ENABLED', false);
}
) {}
async onModuleInit() {
this.logger.log(`C2C Bot Scheduler initialized, enabled: ${this.enabled}`);
// 如果 Redis 中没有设置,用环境变量初始化
const existing = await this.redis.get(C2cBotScheduler.ENABLED_KEY);
if (existing === null) {
const envEnabled = this.configService.get<string>('C2C_BOT_ENABLED', 'false');
await this.redis.set(C2cBotScheduler.ENABLED_KEY, envEnabled === 'true' ? 'true' : 'false');
}
if (this.enabled) {
const enabled = await this.isEnabled();
this.logger.log(`C2C Bot Scheduler initialized, enabled: ${enabled}`);
if (enabled) {
const isAvailable = await this.c2cBotService.isAvailable();
if (isAvailable) {
const balance = await this.c2cBotService.getHotWalletBalance();
@ -38,12 +44,23 @@ export class C2cBotScheduler implements OnModuleInit {
}
}
/**
* Bot Redis退
*/
async isEnabled(): Promise<boolean> {
const value = await this.redis.get(C2cBotScheduler.ENABLED_KEY);
if (value === null) {
return this.configService.get<string>('C2C_BOT_ENABLED', 'false') === 'true';
}
return value === 'true';
}
/**
* 10
*/
@Cron('*/10 * * * * *')
async processPendingSellOrders(): Promise<void> {
if (!this.enabled) {
if (!(await this.isEnabled())) {
return;
}
@ -95,7 +112,7 @@ export class C2cBotScheduler implements OnModuleInit {
*/
@Cron('0 * * * * *')
async checkHotWalletBalance(): Promise<void> {
if (!this.enabled) {
if (!(await this.isEnabled())) {
return;
}

View File

@ -347,6 +347,46 @@ export class C2cOrderRepository {
return this.toEntity(record);
}
/**
* Bot
*/
async findBotPurchasedOrders(options: {
page: number;
pageSize: number;
}): Promise<{ data: C2cOrderEntity[]; total: number }> {
const where = { botPurchased: true };
const [records, total] = await Promise.all([
this.prisma.c2cOrder.findMany({
where,
orderBy: { completedAt: 'desc' },
skip: (options.page - 1) * options.pageSize,
take: options.pageSize,
}),
this.prisma.c2cOrder.count({ where }),
]);
return {
data: records.map((r: any) => this.toEntity(r)),
total,
};
}
/**
* Bot
*/
async getBotStats(): Promise<{ totalOrders: number; totalAmount: string }> {
const [count, sum] = await Promise.all([
this.prisma.c2cOrder.count({ where: { botPurchased: true } }),
this.prisma.c2cOrder.aggregate({
where: { botPurchased: true },
_sum: { totalAmount: true },
}),
]);
return {
totalOrders: count,
totalAmount: sum._sum.totalAmount?.toString() || '0',
};
}
/**
* Prisma记录转为实体
*/

View File

@ -0,0 +1,339 @@
'use client';
import { useState } from 'react';
import { QRCodeSVG } from 'qrcode.react';
import { PageHeader } from '@/components/layout/page-header';
import {
useC2cBotStatus,
useEnableC2cBot,
useDisableC2cBot,
useC2cBotOrders,
} from '@/features/c2c-bot/hooks/use-c2c-bot';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import {
RefreshCw,
Wallet,
BarChart3,
Copy,
Check,
ChevronLeft,
ChevronRight,
AlertCircle,
CheckCircle2,
Zap,
} from 'lucide-react';
import { format } from 'date-fns';
import { zhCN } from 'date-fns/locale';
function formatNumber(value: string | undefined | null, decimals: number = 2) {
if (!value) return '0';
const num = parseFloat(value);
if (isNaN(num)) return '0';
return num.toLocaleString(undefined, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
}
function truncateAddress(addr: string | null | undefined, chars: number = 8) {
if (!addr) return '-';
if (addr.length <= chars * 2 + 2) return addr;
return `${addr.slice(0, chars + 2)}...${addr.slice(-chars)}`;
}
export default function C2cBotPage() {
const [orderPage, setOrderPage] = useState(1);
const [copied, setCopied] = useState(false);
const pageSize = 20;
const { data: status, isLoading: statusLoading, refetch } = useC2cBotStatus();
const { data: ordersData, isLoading: ordersLoading } = useC2cBotOrders(orderPage, pageSize);
const enableMutation = useEnableC2cBot();
const disableMutation = useDisableC2cBot();
const totalPages = ordersData ? Math.ceil(ordersData.total / pageSize) : 0;
const handleToggle = (checked: boolean) => {
if (checked) {
enableMutation.mutate();
} else {
disableMutation.mutate();
}
};
const handleCopyAddress = () => {
if (status?.hotWallet?.address) {
navigator.clipboard.writeText(status.hotWallet.address);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
if (statusLoading) {
return (
<div className="space-y-6">
<PageHeader title="C2C Bot 管理" description="管理 C2C 自动购买机器人" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Skeleton className="h-48" />
<Skeleton className="h-48" />
<Skeleton className="h-48" />
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<PageHeader title="C2C Bot 管理" description="管理 C2C 自动购买机器人" />
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
</Button>
</div>
{/* 状态卡片 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Bot 开关 */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Zap className="h-5 w-5" />
Bot
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium"></span>
<div className="flex items-center gap-3">
{status?.enabled ? (
<Badge className="bg-green-500"></Badge>
) : (
<Badge variant="secondary"></Badge>
)}
<Switch
checked={status?.enabled ?? false}
onCheckedChange={handleToggle}
disabled={enableMutation.isPending || disableMutation.isPending}
/>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground"></span>
{status?.blockchainAvailable ? (
<div className="flex items-center gap-1 text-green-600">
<CheckCircle2 className="h-4 w-4" />
<span className="text-sm"></span>
</div>
) : (
<div className="flex items-center gap-1 text-red-500">
<AlertCircle className="h-4 w-4" />
<span className="text-sm"></span>
</div>
)}
</div>
<div className="text-xs text-muted-foreground bg-muted p-2 rounded">
Bot 10 dUSDT
</div>
</CardContent>
</Card>
{/* 热钱包 */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Wallet className="h-5 w-5" />
</CardTitle>
<CardDescription>Bot 使 dUSDT </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<p className="text-sm text-muted-foreground">dUSDT </p>
<p className="text-2xl font-bold">
{formatNumber(status?.hotWallet?.balance, 4)}
</p>
</div>
{status?.hotWallet?.address ? (
<>
<div>
<p className="text-sm text-muted-foreground mb-1"> (Kava EVM)</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-xs bg-muted p-2 rounded break-all">
{status.hotWallet.address}
</code>
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={handleCopyAddress}>
{copied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
<div className="flex justify-center">
<div className="p-3 bg-white rounded-lg">
<QRCodeSVG value={status.hotWallet.address} size={120} />
</div>
</div>
<div className="text-xs text-yellow-600 bg-yellow-50 p-2 rounded">
<AlertCircle className="h-3 w-3 inline mr-1" />
dUSDT (Kava链) Bot
</div>
</>
) : (
<div className="text-sm text-muted-foreground"></div>
)}
</CardContent>
</Card>
{/* 统计 */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<BarChart3 className="h-5 w-5" />
</CardTitle>
<CardDescription>Bot </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="text-2xl font-bold">
{status?.stats?.totalBotOrders ?? 0}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="text-2xl font-bold">
{formatNumber(status?.stats?.totalBotAmount, 4)}
</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* 订单历史 */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
Bot
{ordersData && (
<Badge variant="secondary" className="text-xs">
{ordersData.total}
</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{ordersLoading ? (
<div className="p-6 space-y-3">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
) : ordersData && ordersData.data.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead>Kava </TableHead>
<TableHead>TxHash</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ordersData.data.map((order) => (
<TableRow key={order.orderNo}>
<TableCell className="font-mono text-xs">
{order.orderNo}
</TableCell>
<TableCell>
<div className="text-sm">
{order.makerNickname || order.makerPhone || order.makerAccountSequence}
</div>
</TableCell>
<TableCell className="text-right font-mono">
{formatNumber(order.totalAmount, 4)}
</TableCell>
<TableCell className="text-right font-mono">
{formatNumber(order.quantity, 4)}
</TableCell>
<TableCell className="font-mono text-xs">
{truncateAddress(order.sellerKavaAddress, 6)}
</TableCell>
<TableCell className="font-mono text-xs">
{order.paymentTxHash ? (
<span className="text-blue-600" title={order.paymentTxHash}>
{truncateAddress(order.paymentTxHash, 6)}
</span>
) : (
'-'
)}
</TableCell>
<TableCell className="text-sm">
{order.completedAt
? format(new Date(order.completedAt), 'yyyy-MM-dd HH:mm:ss', { locale: zhCN })
: '-'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="p-8 text-center text-muted-foreground">
Bot
</div>
)}
{/* 分页 */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 py-4">
<Button
variant="outline"
size="sm"
onClick={() => setOrderPage((p) => Math.max(1, p - 1))}
disabled={orderPage <= 1}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm text-muted-foreground px-4">
{orderPage} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setOrderPage((p) => Math.min(totalPages, p + 1))}
disabled={orderPage >= totalPages}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -15,6 +15,7 @@ import {
ChevronRight,
ArrowLeftRight,
Bot,
Zap,
HandCoins,
FileSpreadsheet,
SendHorizontal,
@ -27,6 +28,7 @@ const menuItems = [
{ name: '交易管理', href: '/trading', icon: ArrowLeftRight },
{ name: 'P2P划转', href: '/p2p-transfers', icon: SendHorizontal },
{ name: '做市商管理', href: '/market-maker', icon: Bot },
{ name: 'C2C Bot', href: '/c2c-bot', icon: Zap },
{ name: '手工补发', href: '/manual-mining', icon: HandCoins },
{ name: '批量补发', href: '/batch-mining', icon: FileSpreadsheet },
{ name: '配置管理', href: '/configs', icon: Settings },

View File

@ -0,0 +1,99 @@
import axios from 'axios';
const tradingClient = axios.create({
baseURL: '/api/trading',
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
});
tradingClient.interceptors.request.use(
(config) => {
const token = typeof window !== 'undefined' ? localStorage.getItem('admin_token') : null;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
tradingClient.interceptors.response.use(
(response) => {
if (response.data && response.data.data !== undefined) {
response.data = response.data.data;
}
return response;
},
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('admin_token');
if (typeof window !== 'undefined' && !window.location.pathname.includes('/login')) {
window.location.href = '/login';
}
}
return Promise.reject(error);
}
);
export interface C2cBotStatus {
success: boolean;
enabled: boolean;
blockchainAvailable: boolean;
hotWallet: {
address: string | null;
balance: string | null;
};
stats: {
totalBotOrders: number;
totalBotAmount: string;
};
}
export interface BotOrder {
orderNo: string;
makerAccountSequence: string;
makerPhone: string | null;
makerNickname: string | null;
totalAmount: string;
price: string;
quantity: string;
sellerKavaAddress: string | null;
paymentTxHash: string | null;
status: string;
completedAt: string | null;
createdAt: string;
}
export interface BotOrdersResponse {
success: boolean;
data: BotOrder[];
total: number;
page: number;
pageSize: number;
}
export const c2cBotApi = {
getStatus: async (): Promise<C2cBotStatus> => {
const response = await tradingClient.get('/admin/c2c-bot/status');
return response.data;
},
enable: async (): Promise<{ success: boolean; enabled: boolean; message: string }> => {
const response = await tradingClient.post('/admin/c2c-bot/enable');
return response.data;
},
disable: async (): Promise<{ success: boolean; enabled: boolean; message: string }> => {
const response = await tradingClient.post('/admin/c2c-bot/disable');
return response.data;
},
getOrders: async (page: number = 1, pageSize: number = 20): Promise<BotOrdersResponse> => {
const response = await tradingClient.get('/admin/c2c-bot/orders', {
params: { page, pageSize },
});
return response.data;
},
};

View File

@ -0,0 +1,47 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { c2cBotApi } from '../api/c2c-bot.api';
import { toast } from '@/lib/hooks/use-toast';
export function useC2cBotStatus() {
return useQuery({
queryKey: ['c2cBot', 'status'],
queryFn: () => c2cBotApi.getStatus(),
refetchInterval: 10000,
});
}
export function useEnableC2cBot() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => c2cBotApi.enable(),
onSuccess: (data) => {
toast({ title: '成功', description: data.message });
queryClient.invalidateQueries({ queryKey: ['c2cBot'] });
},
onError: (error: any) => {
toast({ title: '错误', description: error.response?.data?.message || '开启失败', variant: 'destructive' });
},
});
}
export function useDisableC2cBot() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => c2cBotApi.disable(),
onSuccess: (data) => {
toast({ title: '成功', description: data.message });
queryClient.invalidateQueries({ queryKey: ['c2cBot'] });
},
onError: (error: any) => {
toast({ title: '错误', description: error.response?.data?.message || '关闭失败', variant: 'destructive' });
},
});
}
export function useC2cBotOrders(page: number = 1, pageSize: number = 20) {
return useQuery({
queryKey: ['c2cBot', 'orders', page, pageSize],
queryFn: () => c2cBotApi.getOrders(page, pageSize),
refetchInterval: 10000,
});
}