diff --git a/backend/services/trading-service/src/api/api.module.ts b/backend/services/trading-service/src/api/api.module.ts index 58e3b8ec..1a3238ce 100644 --- a/backend/services/trading-service/src/api/api.module.ts +++ b/backend/services/trading-service/src/api/api.module.ts @@ -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], diff --git a/backend/services/trading-service/src/api/controllers/c2c-bot.controller.ts b/backend/services/trading-service/src/api/controllers/c2c-bot.controller.ts new file mode 100644 index 00000000..80efd56e --- /dev/null +++ b/backend/services/trading-service/src/api/controllers/c2c-bot.controller.ts @@ -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, + }; + } +} diff --git a/backend/services/trading-service/src/application/schedulers/c2c-bot.scheduler.ts b/backend/services/trading-service/src/application/schedulers/c2c-bot.scheduler.ts index 3d913e2b..1c8fb4e3 100644 --- a/backend/services/trading-service/src/application/schedulers/c2c-bot.scheduler.ts +++ b/backend/services/trading-service/src/application/schedulers/c2c-bot.scheduler.ts @@ -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('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('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 { + const value = await this.redis.get(C2cBotScheduler.ENABLED_KEY); + if (value === null) { + return this.configService.get('C2C_BOT_ENABLED', 'false') === 'true'; + } + return value === 'true'; + } + /** * 每10秒扫描待处理的卖单 */ @Cron('*/10 * * * * *') async processPendingSellOrders(): Promise { - if (!this.enabled) { + if (!(await this.isEnabled())) { return; } @@ -95,7 +112,7 @@ export class C2cBotScheduler implements OnModuleInit { */ @Cron('0 * * * * *') async checkHotWalletBalance(): Promise { - if (!this.enabled) { + if (!(await this.isEnabled())) { return; } diff --git a/backend/services/trading-service/src/infrastructure/persistence/repositories/c2c-order.repository.ts b/backend/services/trading-service/src/infrastructure/persistence/repositories/c2c-order.repository.ts index 5fa45100..37e0b60c 100644 --- a/backend/services/trading-service/src/infrastructure/persistence/repositories/c2c-order.repository.ts +++ b/backend/services/trading-service/src/infrastructure/persistence/repositories/c2c-order.repository.ts @@ -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记录转为实体 */ diff --git a/frontend/mining-admin-web/src/app/(dashboard)/c2c-bot/page.tsx b/frontend/mining-admin-web/src/app/(dashboard)/c2c-bot/page.tsx new file mode 100644 index 00000000..34c1d7b6 --- /dev/null +++ b/frontend/mining-admin-web/src/app/(dashboard)/c2c-bot/page.tsx @@ -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 ( +
+ +
+ + + +
+
+ ); + } + + return ( +
+
+ + +
+ + {/* 状态卡片 */} +
+ {/* Bot 开关 */} + + + + + Bot 状态 + + 自动购买用户卖单 + + +
+ 运行状态 +
+ {status?.enabled ? ( + 运行中 + ) : ( + 已停止 + )} + +
+
+
+ 区块链服务 + {status?.blockchainAvailable ? ( +
+ + 可用 +
+ ) : ( +
+ + 不可用 +
+ )} +
+
+ Bot 每 10 秒扫描一次待处理卖单,自动转账 dUSDT 完成购买 +
+
+
+ + {/* 热钱包 */} + + + + + 热钱包 + + Bot 使用的 dUSDT 钱包 + + +
+

dUSDT 余额

+

+ {formatNumber(status?.hotWallet?.balance, 4)} +

+
+ {status?.hotWallet?.address ? ( + <> +
+

钱包地址 (Kava EVM)

+
+ + {status.hotWallet.address} + + +
+
+
+
+ +
+
+
+ + 向此地址转入 dUSDT (Kava链) 即可为 Bot 充值 +
+ + ) : ( +
热钱包未配置
+ )} +
+
+ + {/* 统计 */} + + + + + 统计 + + Bot 自动购买统计 + + +
+
+

总成交订单

+

+ {status?.stats?.totalBotOrders ?? 0} +

+
+
+

总成交金额

+

+ {formatNumber(status?.stats?.totalBotAmount, 4)} +

+
+
+
+
+
+ + {/* 订单历史 */} + + + + Bot 订单历史 + {ordersData && ( + + 共 {ordersData.total} 条 + + )} + + + + {ordersLoading ? ( +
+ + + +
+ ) : ordersData && ordersData.data.length > 0 ? ( + + + + 订单号 + 卖家 + 金额 + 数量 + Kava 地址 + TxHash + 完成时间 + + + + {ordersData.data.map((order) => ( + + + {order.orderNo} + + +
+ {order.makerNickname || order.makerPhone || order.makerAccountSequence} +
+
+ + {formatNumber(order.totalAmount, 4)} + + + {formatNumber(order.quantity, 4)} + + + {truncateAddress(order.sellerKavaAddress, 6)} + + + {order.paymentTxHash ? ( + + {truncateAddress(order.paymentTxHash, 6)} + + ) : ( + '-' + )} + + + {order.completedAt + ? format(new Date(order.completedAt), 'yyyy-MM-dd HH:mm:ss', { locale: zhCN }) + : '-'} + +
+ ))} +
+
+ ) : ( +
+ 暂无 Bot 订单记录 +
+ )} + + {/* 分页 */} + {totalPages > 1 && ( +
+ + + 第 {orderPage} / {totalPages} 页 + + +
+ )} +
+
+
+ ); +} diff --git a/frontend/mining-admin-web/src/components/layout/sidebar.tsx b/frontend/mining-admin-web/src/components/layout/sidebar.tsx index 3126d5f9..008007ba 100644 --- a/frontend/mining-admin-web/src/components/layout/sidebar.tsx +++ b/frontend/mining-admin-web/src/components/layout/sidebar.tsx @@ -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 }, diff --git a/frontend/mining-admin-web/src/features/c2c-bot/api/c2c-bot.api.ts b/frontend/mining-admin-web/src/features/c2c-bot/api/c2c-bot.api.ts new file mode 100644 index 00000000..fa7a0220 --- /dev/null +++ b/frontend/mining-admin-web/src/features/c2c-bot/api/c2c-bot.api.ts @@ -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 => { + 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 => { + const response = await tradingClient.get('/admin/c2c-bot/orders', { + params: { page, pageSize }, + }); + return response.data; + }, +}; diff --git a/frontend/mining-admin-web/src/features/c2c-bot/hooks/use-c2c-bot.ts b/frontend/mining-admin-web/src/features/c2c-bot/hooks/use-c2c-bot.ts new file mode 100644 index 00000000..530d9625 --- /dev/null +++ b/frontend/mining-admin-web/src/features/c2c-bot/hooks/use-c2c-bot.ts @@ -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, + }); +}