diff --git a/backend/services/mining-service/src/api/controllers/mining.controller.ts b/backend/services/mining-service/src/api/controllers/mining.controller.ts index 8a559b9b..c81ef2e4 100644 --- a/backend/services/mining-service/src/api/controllers/mining.controller.ts +++ b/backend/services/mining-service/src/api/controllers/mining.controller.ts @@ -1,9 +1,20 @@ -import { Controller, Get, Param, Query, NotFoundException } from '@nestjs/common'; +import { Controller, Get, Post, Param, Query, Body, NotFoundException, BadRequestException } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger'; +import { IsString, IsOptional } from 'class-validator'; import { GetMiningAccountQuery } from '../../application/queries/get-mining-account.query'; import { GetMiningStatsQuery } from '../../application/queries/get-mining-stats.query'; import { Public } from '../../shared/guards/jwt-auth.guard'; import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service'; +import { Decimal } from '@prisma/client/runtime/library'; + +class TransferDto { + @IsString() + amount: string; + + @IsString() + @IsOptional() + transferNo?: string; +} @ApiTags('Mining') @Controller('mining') @@ -145,4 +156,129 @@ export class MiningController { pageSize ?? 50, ); } + + @Post('accounts/:accountSequence/transfer-out') + @ApiOperation({ summary: '从挖矿账户划出积分股(到交易账户)' }) + @ApiParam({ name: 'accountSequence', description: '账户序号' }) + @ApiResponse({ status: 200, description: '划出成功' }) + @ApiResponse({ status: 400, description: '余额不足或参数错误' }) + async transferOut( + @Param('accountSequence') accountSequence: string, + @Body() dto: TransferDto, + ) { + const amount = new Decimal(dto.amount); + if (amount.lessThanOrEqualTo(0)) { + throw new BadRequestException('Amount must be positive'); + } + + // 查找账户 + const account = await this.prisma.miningAccount.findUnique({ + where: { accountSequence }, + }); + + if (!account) { + throw new NotFoundException(`Account ${accountSequence} not found`); + } + + // 检查余额 + if (new Decimal(account.availableBalance).lessThan(amount)) { + throw new BadRequestException('Insufficient balance'); + } + + // 执行划转(使用事务) + const txId = `TX${Date.now().toString(36)}${Math.random().toString(36).substring(2, 8)}`.toUpperCase(); + + await this.prisma.$transaction(async (tx) => { + // 扣减余额 + const newBalance = new Decimal(account.availableBalance).minus(amount); + await tx.miningAccount.update({ + where: { accountSequence }, + data: { availableBalance: newBalance }, + }); + + // 创建交易记录 + await tx.miningTransaction.create({ + data: { + accountSequence, + type: 'TRANSFER_OUT', + amount: amount.negated(), + balanceBefore: account.availableBalance, + balanceAfter: newBalance, + referenceId: dto.transferNo || txId, + referenceType: 'TRADING_TRANSFER', + memo: `划转到交易账户,金额: ${amount.toString()}`, + }, + }); + }); + + return { + success: true, + txId, + message: 'Transfer out successful', + }; + } + + @Post('accounts/:accountSequence/transfer-in') + @ApiOperation({ summary: '划入积分股到挖矿账户(从交易账户)' }) + @ApiParam({ name: 'accountSequence', description: '账户序号' }) + @ApiResponse({ status: 200, description: '划入成功' }) + @ApiResponse({ status: 400, description: '参数错误' }) + async transferIn( + @Param('accountSequence') accountSequence: string, + @Body() dto: TransferDto, + ) { + const amount = new Decimal(dto.amount); + if (amount.lessThanOrEqualTo(0)) { + throw new BadRequestException('Amount must be positive'); + } + + const txId = `TX${Date.now().toString(36)}${Math.random().toString(36).substring(2, 8)}`.toUpperCase(); + + await this.prisma.$transaction(async (tx) => { + // 查找或创建账户 + let account = await tx.miningAccount.findUnique({ + where: { accountSequence }, + }); + + if (!account) { + // 创建账户 + account = await tx.miningAccount.create({ + data: { + accountSequence, + totalMined: new Decimal(0), + availableBalance: new Decimal(0), + frozenBalance: new Decimal(0), + totalContribution: new Decimal(0), + }, + }); + } + + // 增加余额 + const newBalance = new Decimal(account.availableBalance).plus(amount); + await tx.miningAccount.update({ + where: { accountSequence }, + data: { availableBalance: newBalance }, + }); + + // 创建交易记录 + await tx.miningTransaction.create({ + data: { + accountSequence, + type: 'TRANSFER_IN', + amount, + balanceBefore: account.availableBalance, + balanceAfter: newBalance, + referenceId: dto.transferNo || txId, + referenceType: 'TRADING_TRANSFER', + memo: `从交易账户划入,金额: ${amount.toString()}`, + }, + }); + }); + + return { + success: true, + txId, + message: 'Transfer in successful', + }; + } } diff --git a/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_chart_widget.dart b/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_chart_widget.dart index 12124d7f..796b594a 100644 --- a/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_chart_widget.dart +++ b/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_chart_widget.dart @@ -703,11 +703,16 @@ class _KlineChartWidgetState extends State { } final range = maxPrice - minPrice; - final padding = range * 0.1; + // 防止 range 为 0 导致 NaN + final safeRange = range > 0 ? range : (maxPrice > 0 ? maxPrice * 0.1 : 1.0); + final padding = safeRange * 0.1; minPrice -= padding; maxPrice += padding; - final y = 10 + ((maxPrice - price) / (maxPrice - minPrice)) * (chartHeight - 20); + final adjustedRange = maxPrice - minPrice; + if (adjustedRange <= 0) return chartHeight / 2; + + final y = 10 + ((maxPrice - price) / adjustedRange) * (chartHeight - 20); return y.clamp(0.0, chartHeight - 20); } } diff --git a/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_painter.dart b/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_painter.dart index b20ea133..d65e92b5 100644 --- a/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_painter.dart +++ b/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_painter.dart @@ -100,13 +100,16 @@ class KlinePainter extends CustomPainter { // 添加上下留白 final priceRange = maxPrice - minPrice; - final padding = priceRange * 0.1; + // 避免 priceRange 为 0 导致 NaN + final safePriceRange = priceRange > 0 ? priceRange : (maxPrice > 0 ? maxPrice * 0.1 : 1.0); + final padding = safePriceRange * 0.1; minPrice -= padding; maxPrice += padding; final adjustedRange = maxPrice - minPrice; // Y坐标转换函数 double priceToY(double price) { + if (adjustedRange <= 0) return topPadding + chartHeight / 2; return topPadding + ((maxPrice - price) / adjustedRange) * chartHeight; }