From 2597d0ef460809a46e8f73fc78b2f58b99dd76c1 Mon Sep 17 00:00:00 2001 From: hailin Date: Wed, 28 Jan 2026 06:25:42 -0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0P2P=E8=BD=AC=E8=B4=A6?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E5=8F=8A=E5=89=8D=E7=AB=AF=E8=B5=84=E4=BA=A7?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - trading-service: 添加P2pTransfer模型和P2P转账API - auth-service: 添加用户手机号查询接口用于转账验证 - frontend: 修复资产页面冻结份额显示和转账页面余额字段 - frontend: 添加P2P转账记录页面 Co-Authored-By: Claude Opus 4.5 --- .../src/api/controllers/user.controller.ts | 19 ++ .../src/application/services/user.service.ts | 18 ++ .../0002_add_p2p_transfer/migration.sql | 34 ++ .../trading-service/prisma/schema.prisma | 24 ++ .../trading-service/src/api/api.module.ts | 2 + .../controllers/p2p-transfer.controller.ts | 76 +++++ .../src/application/application.module.ts | 4 +- .../services/p2p-transfer.service.ts | 297 +++++++++++++++++ .../lib/core/router/app_router.dart | 5 + .../mining-app/lib/core/router/routes.dart | 2 + .../presentation/pages/asset/asset_page.dart | 70 +++- .../asset/p2p_transfer_records_page.dart | 300 ++++++++++++++++++ .../pages/asset/send_shares_page.dart | 29 +- 13 files changed, 854 insertions(+), 26 deletions(-) create mode 100644 backend/services/trading-service/prisma/migrations/0002_add_p2p_transfer/migration.sql create mode 100644 backend/services/trading-service/src/api/controllers/p2p-transfer.controller.ts create mode 100644 backend/services/trading-service/src/application/services/p2p-transfer.service.ts create mode 100644 frontend/mining-app/lib/presentation/pages/asset/p2p_transfer_records_page.dart diff --git a/backend/services/auth-service/src/api/controllers/user.controller.ts b/backend/services/auth-service/src/api/controllers/user.controller.ts index cd183053..a9f75dfb 100644 --- a/backend/services/auth-service/src/api/controllers/user.controller.ts +++ b/backend/services/auth-service/src/api/controllers/user.controller.ts @@ -1,7 +1,9 @@ import { Controller, Get, + Query, UseGuards, + BadRequestException, } from '@nestjs/common'; import { UserService, UserProfileResult } from '@/application/services'; import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard'; @@ -23,4 +25,21 @@ export class UserController { const result = await this.userService.getProfile(user.accountSequence); return { success: true, data: result }; } + + /** + * 根据手机号查找用户(用于P2P转账验证) + * GET /user/lookup?phone=13800138000 + */ + @Get('lookup') + async lookupByPhone( + @Query('phone') phone: string, + @CurrentUser() currentUser: { accountSequence: string }, + ): Promise<{ success: boolean; data: { exists: boolean; nickname?: string; accountSequence?: string } }> { + if (!phone || phone.length !== 11) { + throw new BadRequestException('请输入有效的11位手机号'); + } + + const result = await this.userService.lookupByPhone(phone); + return { success: true, data: result }; + } } diff --git a/backend/services/auth-service/src/application/services/user.service.ts b/backend/services/auth-service/src/application/services/user.service.ts index 74eabdf1..2929708b 100644 --- a/backend/services/auth-service/src/application/services/user.service.ts +++ b/backend/services/auth-service/src/application/services/user.service.ts @@ -48,6 +48,24 @@ export class UserService { }; } + /** + * 根据手机号查找用户(用于P2P转账验证) + */ + async lookupByPhone(phone: string): Promise<{ exists: boolean; accountSequence?: string; nickname?: string }> { + const phoneVO = Phone.create(phone); + const user = await this.userRepository.findByPhone(phoneVO); + + if (!user || user.status !== 'ACTIVE') { + return { exists: false }; + } + + return { + exists: true, + accountSequence: user.accountSequence.value, + nickname: user.isKycVerified ? this.maskName(user.realName!) : user.phone.masked, + }; + } + /** * 更换手机号 */ diff --git a/backend/services/trading-service/prisma/migrations/0002_add_p2p_transfer/migration.sql b/backend/services/trading-service/prisma/migrations/0002_add_p2p_transfer/migration.sql new file mode 100644 index 00000000..021a318d --- /dev/null +++ b/backend/services/trading-service/prisma/migrations/0002_add_p2p_transfer/migration.sql @@ -0,0 +1,34 @@ +-- CreateTable +CREATE TABLE "p2p_transfers" ( + "id" TEXT NOT NULL, + "transfer_no" TEXT NOT NULL, + "from_account_sequence" TEXT NOT NULL, + "to_account_sequence" TEXT NOT NULL, + "to_phone" TEXT NOT NULL, + "to_nickname" TEXT, + "from_phone" TEXT, + "from_nickname" TEXT, + "amount" DECIMAL(30,8) NOT NULL, + "memo" TEXT, + "status" TEXT NOT NULL DEFAULT 'PENDING', + "error_message" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "completed_at" TIMESTAMP(3), + + CONSTRAINT "p2p_transfers_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "p2p_transfers_transfer_no_key" ON "p2p_transfers"("transfer_no"); + +-- CreateIndex +CREATE INDEX "p2p_transfers_from_account_sequence_idx" ON "p2p_transfers"("from_account_sequence"); + +-- CreateIndex +CREATE INDEX "p2p_transfers_to_account_sequence_idx" ON "p2p_transfers"("to_account_sequence"); + +-- CreateIndex +CREATE INDEX "p2p_transfers_status_idx" ON "p2p_transfers"("status"); + +-- CreateIndex +CREATE INDEX "p2p_transfers_created_at_idx" ON "p2p_transfers"("created_at" DESC); diff --git a/backend/services/trading-service/prisma/schema.prisma b/backend/services/trading-service/prisma/schema.prisma index 9f739f49..0064e356 100644 --- a/backend/services/trading-service/prisma/schema.prisma +++ b/backend/services/trading-service/prisma/schema.prisma @@ -392,6 +392,30 @@ model TransferRecord { @@map("transfer_records") } +// P2P用户间转账记录 +model P2pTransfer { + id String @id @default(uuid()) + transferNo String @unique @map("transfer_no") + fromAccountSequence String @map("from_account_sequence") + toAccountSequence String @map("to_account_sequence") + toPhone String @map("to_phone") // 收款方手机号(用于显示) + toNickname String? @map("to_nickname") // 收款方昵称 + fromPhone String? @map("from_phone") // 发送方手机号 + fromNickname String? @map("from_nickname") // 发送方昵称 + amount Decimal @db.Decimal(30, 8) + memo String? @db.Text // 备注 + status String @default("PENDING") // PENDING, COMPLETED, FAILED + errorMessage String? @map("error_message") + createdAt DateTime @default(now()) @map("created_at") + completedAt DateTime? @map("completed_at") + + @@index([fromAccountSequence]) + @@index([toAccountSequence]) + @@index([status]) + @@index([createdAt(sort: Desc)]) + @@map("p2p_transfers") +} + // ==================== Outbox ==================== enum OutboxStatus { diff --git a/backend/services/trading-service/src/api/api.module.ts b/backend/services/trading-service/src/api/api.module.ts index d50e1e82..58e3b8ec 100644 --- a/backend/services/trading-service/src/api/api.module.ts +++ b/backend/services/trading-service/src/api/api.module.ts @@ -3,6 +3,7 @@ import { ApplicationModule } from '../application/application.module'; import { InfrastructureModule } from '../infrastructure/infrastructure.module'; import { TradingController } from './controllers/trading.controller'; import { TransferController } from './controllers/transfer.controller'; +import { P2pTransferController } from './controllers/p2p-transfer.controller'; import { HealthController } from './controllers/health.controller'; import { AdminController } from './controllers/admin.controller'; import { PriceController } from './controllers/price.controller'; @@ -17,6 +18,7 @@ import { PriceGateway } from './gateways/price.gateway'; controllers: [ TradingController, TransferController, + P2pTransferController, HealthController, AdminController, PriceController, diff --git a/backend/services/trading-service/src/api/controllers/p2p-transfer.controller.ts b/backend/services/trading-service/src/api/controllers/p2p-transfer.controller.ts new file mode 100644 index 00000000..aa6eb562 --- /dev/null +++ b/backend/services/trading-service/src/api/controllers/p2p-transfer.controller.ts @@ -0,0 +1,76 @@ +import { Controller, Get, Post, Body, Query, Param, Req, Headers, BadRequestException } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery, ApiParam } from '@nestjs/swagger'; +import { IsString, IsOptional, Length, Matches } from 'class-validator'; +import { P2pTransferService } from '../../application/services/p2p-transfer.service'; + +class P2pTransferDto { + @IsString() + @Length(11, 11) + @Matches(/^\d{11}$/, { message: '请输入有效的11位手机号' }) + toPhone: string; + + @IsString() + amount: string; + + @IsOptional() + @IsString() + @Length(0, 100) + memo?: string; +} + +@ApiTags('P2P Transfer') +@ApiBearerAuth() +@Controller('p2p') +export class P2pTransferController { + constructor(private readonly p2pTransferService: P2pTransferService) {} + + @Post('transfer') + @ApiOperation({ summary: 'P2P转账(积分值)' }) + async transfer( + @Body() dto: P2pTransferDto, + @Headers('authorization') authHeader: string, + @Req() req: any, + ) { + const accountSequence = req.user?.accountSequence; + if (!accountSequence) { + throw new BadRequestException('Unauthorized'); + } + + const token = authHeader?.replace('Bearer ', '') || ''; + const result = await this.p2pTransferService.transfer( + accountSequence, + dto.toPhone, + dto.amount, + dto.memo, + token, + ); + + return { success: true, data: result }; + } + + @Get('transfers/:accountSequence') + @ApiOperation({ summary: '获取P2P转账历史' }) + @ApiParam({ name: 'accountSequence', required: true, description: '账户序列号' }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'pageSize', required: false, type: Number }) + async getHistory( + @Param('accountSequence') accountSequence: string, + @Req() req: any, + @Query('page') page?: number, + @Query('pageSize') pageSize?: number, + ) { + // 验证只能查询自己的转账历史 + const currentUser = req.user?.accountSequence; + if (!currentUser || currentUser !== accountSequence) { + throw new BadRequestException('Unauthorized'); + } + + const result = await this.p2pTransferService.getTransferHistory( + accountSequence, + page ?? 1, + pageSize ?? 20, + ); + + return { success: true, data: result.data }; + } +} diff --git a/backend/services/trading-service/src/application/application.module.ts b/backend/services/trading-service/src/application/application.module.ts index 36cd29ef..f7277053 100644 --- a/backend/services/trading-service/src/application/application.module.ts +++ b/backend/services/trading-service/src/application/application.module.ts @@ -4,6 +4,7 @@ import { InfrastructureModule } from '../infrastructure/infrastructure.module'; import { ApiModule } from '../api/api.module'; import { OrderService } from './services/order.service'; import { TransferService } from './services/transfer.service'; +import { P2pTransferService } from './services/p2p-transfer.service'; import { PriceService } from './services/price.service'; import { BurnService } from './services/burn.service'; import { AssetService } from './services/asset.service'; @@ -27,6 +28,7 @@ import { C2cExpiryScheduler } from './schedulers/c2c-expiry.scheduler'; AssetService, OrderService, TransferService, + P2pTransferService, MarketMakerService, C2cService, // Schedulers @@ -35,6 +37,6 @@ import { C2cExpiryScheduler } from './schedulers/c2c-expiry.scheduler'; PriceBroadcastScheduler, C2cExpiryScheduler, ], - exports: [OrderService, TransferService, PriceService, BurnService, AssetService, MarketMakerService, C2cService], + exports: [OrderService, TransferService, P2pTransferService, PriceService, BurnService, AssetService, MarketMakerService, C2cService], }) export class ApplicationModule {} diff --git a/backend/services/trading-service/src/application/services/p2p-transfer.service.ts b/backend/services/trading-service/src/application/services/p2p-transfer.service.ts new file mode 100644 index 00000000..4e9aa9a7 --- /dev/null +++ b/backend/services/trading-service/src/application/services/p2p-transfer.service.ts @@ -0,0 +1,297 @@ +import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { TradingAccountRepository } from '../../infrastructure/persistence/repositories/trading-account.repository'; +import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service'; +import { TradingAccountAggregate } from '../../domain/aggregates/trading-account.aggregate'; +import { Money } from '../../domain/value-objects/money.vo'; + +interface RecipientInfo { + exists: boolean; + accountSequence?: string; + nickname?: string; +} + +export interface P2pTransferResult { + transferNo: string; + amount: string; + toPhone: string; + toNickname?: string; + status: string; + createdAt: Date; +} + +export interface P2pTransferHistoryItem { + transferNo: string; + fromAccountSequence: string; + toAccountSequence: string; + toPhone: string; + amount: string; + memo?: string | null; + status: string; + createdAt: Date; +} + +@Injectable() +export class P2pTransferService { + private readonly logger = new Logger(P2pTransferService.name); + private readonly authServiceUrl: string; + private readonly minTransferAmount: number; + + constructor( + private readonly accountRepository: TradingAccountRepository, + private readonly prisma: PrismaService, + private readonly configService: ConfigService, + ) { + this.authServiceUrl = this.configService.get('AUTH_SERVICE_URL', 'http://localhost:3020'); + this.minTransferAmount = this.configService.get('MIN_P2P_TRANSFER_AMOUNT', 0.01); + } + + /** + * 查找收款方 + */ + async lookupRecipient(phone: string, token: string): Promise { + try { + const response = await fetch(`${this.authServiceUrl}/api/v2/auth/user/lookup?phone=${phone}`, { + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + return { exists: false }; + } + + const result = await response.json(); + if (!result.success || !result.data) { + return { exists: false }; + } + + return result.data; + } catch (error: any) { + this.logger.warn(`Failed to lookup recipient: ${error.message}`); + return { exists: false }; + } + } + + /** + * P2P转账(积分值) + */ + async transfer( + fromAccountSequence: string, + toPhone: string, + amount: string, + memo?: string, + token?: string, + ): Promise { + const transferAmount = new Money(amount); + + // 验证转账金额 + if (transferAmount.value.lessThan(this.minTransferAmount)) { + throw new BadRequestException(`最小转账金额为 ${this.minTransferAmount}`); + } + + // 查找收款方 + const recipient = await this.lookupRecipient(toPhone, token || ''); + if (!recipient.exists || !recipient.accountSequence) { + throw new NotFoundException('收款方账户不存在'); + } + + // 不能转给自己 + if (recipient.accountSequence === fromAccountSequence) { + throw new BadRequestException('不能转账给自己'); + } + + // 查找发送方账户 + const fromAccount = await this.accountRepository.findByAccountSequence(fromAccountSequence); + if (!fromAccount) { + throw new NotFoundException('发送方账户不存在'); + } + + // 检查余额 + if (fromAccount.availableCash.isLessThan(transferAmount)) { + throw new BadRequestException('可用积分值不足'); + } + + const transferNo = this.generateTransferNo(); + + // 使用事务执行转账 + try { + await this.prisma.$transaction(async (tx) => { + // 1. 创建转账记录 + await tx.p2pTransfer.create({ + data: { + transferNo, + fromAccountSequence, + toAccountSequence: recipient.accountSequence!, + toPhone, + toNickname: recipient.nickname, + amount: transferAmount.value, + memo, + status: 'PENDING', + }, + }); + + // 2. 扣减发送方余额 + fromAccount.withdraw(transferAmount, transferNo); + + // 保存发送方账户变动 + await tx.tradingAccount.update({ + where: { accountSequence: fromAccountSequence }, + data: { + cashBalance: fromAccount.cashBalance.value, + }, + }); + + // 记录发送方交易流水 + for (const txn of fromAccount.pendingTransactions) { + await tx.tradingTransaction.create({ + data: { + accountSequence: fromAccountSequence, + type: txn.type, + assetType: txn.assetType, + amount: txn.amount.value, + balanceBefore: txn.balanceBefore.value, + balanceAfter: txn.balanceAfter.value, + referenceId: transferNo, + referenceType: 'P2P_TRANSFER', + counterpartyType: 'USER', + counterpartyAccountSeq: recipient.accountSequence, + memo: `P2P转出给 ${toPhone}`, + }, + }); + } + fromAccount.clearPendingTransactions(); + + // 3. 增加收款方余额 + let toAccount = await this.accountRepository.findByAccountSequence(recipient.accountSequence!); + if (!toAccount) { + toAccount = TradingAccountAggregate.create(recipient.accountSequence!); + // 创建新账户 + await tx.tradingAccount.create({ + data: { + accountSequence: recipient.accountSequence!, + shareBalance: 0, + cashBalance: 0, + frozenShares: 0, + frozenCash: 0, + totalBought: 0, + totalSold: 0, + }, + }); + } + + toAccount.deposit(transferAmount, transferNo); + + // 保存收款方账户变动 + await tx.tradingAccount.update({ + where: { accountSequence: recipient.accountSequence! }, + data: { + cashBalance: toAccount.cashBalance.value, + }, + }); + + // 记录收款方交易流水 + for (const txn of toAccount.pendingTransactions) { + await tx.tradingTransaction.create({ + data: { + accountSequence: recipient.accountSequence!, + type: txn.type, + assetType: txn.assetType, + amount: txn.amount.value, + balanceBefore: txn.balanceBefore.value, + balanceAfter: txn.balanceAfter.value, + referenceId: transferNo, + referenceType: 'P2P_TRANSFER', + counterpartyType: 'USER', + counterpartyAccountSeq: fromAccountSequence, + memo: `P2P转入来自 ${fromAccountSequence}`, + }, + }); + } + + // 4. 更新转账记录为完成 + await tx.p2pTransfer.update({ + where: { transferNo }, + data: { + status: 'COMPLETED', + completedAt: new Date(), + }, + }); + }); + + this.logger.log(`P2P transfer completed: ${transferNo}, ${fromAccountSequence} -> ${toPhone}, amount=${amount}`); + + return { + transferNo, + amount, + toPhone, + toNickname: recipient.nickname, + status: 'COMPLETED', + createdAt: new Date(), + }; + } catch (error: any) { + // 更新转账记录为失败 + await this.prisma.p2pTransfer.update({ + where: { transferNo }, + data: { + status: 'FAILED', + errorMessage: error.message, + }, + }).catch(() => {}); + + this.logger.error(`P2P transfer failed: ${transferNo}, ${error.message}`); + throw error; + } + } + + /** + * 获取P2P转账历史 + */ + async getTransferHistory( + accountSequence: string, + page: number = 1, + pageSize: number = 20, + ): Promise<{ data: P2pTransferHistoryItem[]; total: number }> { + const [records, total] = await Promise.all([ + this.prisma.p2pTransfer.findMany({ + where: { + OR: [ + { fromAccountSequence: accountSequence }, + { toAccountSequence: accountSequence }, + ], + }, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.p2pTransfer.count({ + where: { + OR: [ + { fromAccountSequence: accountSequence }, + { toAccountSequence: accountSequence }, + ], + }, + }), + ]); + + const data = records.map((record) => ({ + transferNo: record.transferNo, + fromAccountSequence: record.fromAccountSequence, + toAccountSequence: record.toAccountSequence, + toPhone: record.toPhone, + amount: record.amount.toString(), + memo: record.memo, + status: record.status, + createdAt: record.createdAt, + } as P2pTransferHistoryItem)); + + return { data, total }; + } + + private generateTransferNo(): string { + const timestamp = Date.now().toString(36); + const random = Math.random().toString(36).substring(2, 8); + return `P2P${timestamp}${random}`.toUpperCase(); + } +} diff --git a/frontend/mining-app/lib/core/router/app_router.dart b/frontend/mining-app/lib/core/router/app_router.dart index 1301e783..0a5956b5 100644 --- a/frontend/mining-app/lib/core/router/app_router.dart +++ b/frontend/mining-app/lib/core/router/app_router.dart @@ -23,6 +23,7 @@ import '../../presentation/pages/c2c/c2c_order_detail_page.dart'; import '../../presentation/pages/profile/team_page.dart'; import '../../presentation/pages/profile/trading_records_page.dart'; import '../../presentation/pages/trading/transfer_records_page.dart'; +import '../../presentation/pages/asset/p2p_transfer_records_page.dart'; import '../../presentation/pages/profile/help_center_page.dart'; import '../../presentation/pages/profile/about_page.dart'; import '../../presentation/widgets/main_shell.dart'; @@ -167,6 +168,10 @@ final appRouterProvider = Provider((ref) { path: Routes.transferRecords, builder: (context, state) => const TransferRecordsPage(), ), + GoRoute( + path: Routes.p2pTransferRecords, + builder: (context, state) => const P2pTransferRecordsPage(), + ), GoRoute( path: Routes.helpCenter, builder: (context, state) => const HelpCenterPage(), diff --git a/frontend/mining-app/lib/core/router/routes.dart b/frontend/mining-app/lib/core/router/routes.dart index 00e054d7..5f691819 100644 --- a/frontend/mining-app/lib/core/router/routes.dart +++ b/frontend/mining-app/lib/core/router/routes.dart @@ -26,6 +26,8 @@ class Routes { static const String tradingRecords = '/trading-records'; // 划转记录 static const String transferRecords = '/transfer-records'; + // P2P转账记录 + static const String p2pTransferRecords = '/p2p-transfer-records'; // 其他设置 static const String helpCenter = '/help-center'; static const String about = '/about'; diff --git a/frontend/mining-app/lib/presentation/pages/asset/asset_page.dart b/frontend/mining-app/lib/presentation/pages/asset/asset_page.dart index a95c8a2c..58f509dc 100644 --- a/frontend/mining-app/lib/presentation/pages/asset/asset_page.dart +++ b/frontend/mining-app/lib/presentation/pages/asset/asset_page.dart @@ -8,9 +8,12 @@ import '../../../core/network/price_websocket_service.dart'; import '../../../core/constants/app_constants.dart'; import '../../../core/constants/app_colors.dart'; import '../../../domain/entities/asset_display.dart'; +import '../../../domain/entities/trade_order.dart'; +import '../../../data/models/trade_order_model.dart'; import '../../providers/user_providers.dart'; import '../../providers/asset_providers.dart'; import '../../providers/mining_providers.dart'; +import '../../providers/trading_providers.dart'; import '../../widgets/shimmer_loading.dart'; class AssetPage extends ConsumerStatefulWidget { @@ -160,11 +163,14 @@ class _AssetPageState extends ConsumerState { final assetAsync = ref.watch(accountAssetProvider(accountSequence)); // 从 mining-service 获取每秒收益 final shareAccountAsync = ref.watch(shareAccountProvider(accountSequence)); + // 获取订单列表,用于显示冻结状态 + final ordersAsync = ref.watch(ordersProvider); // 提取数据和加载状态 final isLoading = assetAsync.isLoading || accountSequence.isEmpty; final asset = assetAsync.valueOrNull; final shareAccount = shareAccountAsync.valueOrNull; + final orders = ordersAsync.valueOrNull?.data ?? []; // 获取每秒收益(优先使用 mining-service 的数据) final perSecondEarning = shareAccount?.perSecondEarning ?? '0'; @@ -205,6 +211,7 @@ class _AssetPageState extends ConsumerState { _lastAsset = null; ref.invalidate(accountAssetProvider(accountSequence)); ref.invalidate(shareAccountProvider(accountSequence)); + ref.invalidate(ordersProvider); }, child: SingleChildScrollView( physics: const AlwaysScrollableScrollPhysics(), @@ -227,7 +234,7 @@ class _AssetPageState extends ConsumerState { _buildQuickActions(context), const SizedBox(height: 24), // 资产列表 - 始终显示,数字部分闪烁,实时刷新 - _buildAssetList(context, asset, isLoading, _currentShareBalance, perSecondEarning), + _buildAssetList(context, asset, isLoading, _currentShareBalance, perSecondEarning, orders), const SizedBox(height: 24), // 交易统计 _buildEarningsCard(context, asset, isLoading), @@ -480,7 +487,7 @@ class _AssetPageState extends ConsumerState { ); } - Widget _buildAssetList(BuildContext context, AssetDisplay? asset, bool isLoading, double currentShareBalance, String perSecondEarning) { + Widget _buildAssetList(BuildContext context, AssetDisplay? asset, bool isLoading, double currentShareBalance, String perSecondEarning, List orders) { // 使用实时积分股余额 final shareBalance = asset != null && currentShareBalance > 0 ? currentShareBalance @@ -490,6 +497,10 @@ class _AssetPageState extends ConsumerState { final currentPrice = double.tryParse(asset?.currentPrice ?? '0') ?? 0; final isDark = AppColors.isDark(context); + // 根据订单状态动态计算冻结原因 + final frozenShares = double.tryParse(asset?.frozenShares ?? '0') ?? 0; + final frozenSharesSubtitle = _getFrozenSharesSubtitle(frozenShares, orders); + return Column( children: [ // 积分股 - 实时刷新 @@ -528,12 +539,32 @@ class _AssetPageState extends ConsumerState { title: '冻结积分股', amount: asset?.frozenShares, isLoading: isLoading, - subtitle: '交易挂单中', + subtitle: frozenSharesSubtitle, + onTap: frozenShares > 0 ? () => context.push(Routes.tradingRecords) : null, ), ], ); } + /// 根据订单状态获取冻结积分股的显示文字 + String? _getFrozenSharesSubtitle(double frozenShares, List orders) { + if (frozenShares <= 0) { + return null; + } + + // 检查是否有进行中的卖单(pending 或 partial) + final hasPendingSellOrder = orders.any( + (order) => order.isSell && (order.isPending || order.isPartial), + ); + + if (hasPendingSellOrder) { + return '交易挂单中'; + } + + // 有冻结但没有进行中的挂单,可能是订单已成交但资产还未释放 + return '处理中'; + } + Widget _buildAssetItem({ required BuildContext context, required IconData icon, @@ -542,6 +573,7 @@ class _AssetPageState extends ConsumerState { required String title, String? amount, bool isLoading = false, + VoidCallback? onTap, String? valueInCny, String? tag, String? growthText, @@ -551,20 +583,23 @@ class _AssetPageState extends ConsumerState { String? subtitle, }) { final isDark = AppColors.isDark(context); - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppColors.cardOf(context), - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: isDark ? Colors.black.withOpacity(0.2) : Colors.black.withOpacity(0.05), - blurRadius: 2, - offset: const Offset(0, 1), - ), - ], - ), - child: Row( + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.cardOf(context), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: isDark ? Colors.black.withOpacity(0.2) : Colors.black.withOpacity(0.05), + blurRadius: 2, + offset: const Offset(0, 1), + ), + ], + ), + child: Row( children: [ // 图标 Container( @@ -698,6 +733,7 @@ class _AssetPageState extends ConsumerState { Icon(Icons.chevron_right, size: 14, color: AppColors.textMutedOf(context)), ], ), + ), ); } diff --git a/frontend/mining-app/lib/presentation/pages/asset/p2p_transfer_records_page.dart b/frontend/mining-app/lib/presentation/pages/asset/p2p_transfer_records_page.dart new file mode 100644 index 00000000..b91b8480 --- /dev/null +++ b/frontend/mining-app/lib/presentation/pages/asset/p2p_transfer_records_page.dart @@ -0,0 +1,300 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import '../../../core/constants/app_colors.dart'; +import '../../../core/utils/format_utils.dart'; +import '../../../data/models/p2p_transfer_model.dart'; +import '../../providers/transfer_providers.dart'; +import '../../providers/user_providers.dart'; + +/// P2P转账记录页面 +class P2pTransferRecordsPage extends ConsumerWidget { + const P2pTransferRecordsPage({super.key}); + + static const Color _orange = Color(0xFFFF6B00); + static const Color _green = Color(0xFF10B981); + static const Color _red = Color(0xFFEF4444); + static const Color _grayText = Color(0xFF6B7280); + static const Color _darkText = Color(0xFF1F2937); + static const Color _bgGray = Color(0xFFF3F4F6); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final user = ref.watch(userNotifierProvider); + final accountSequence = user.accountSequence ?? ''; + final recordsAsync = ref.watch(p2pTransferHistoryProvider(accountSequence)); + + return Scaffold( + backgroundColor: _bgGray, + appBar: AppBar( + title: const Text( + '转账记录', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: _darkText, + ), + ), + centerTitle: true, + backgroundColor: Colors.white, + elevation: 0, + iconTheme: const IconThemeData(color: _darkText), + ), + body: RefreshIndicator( + onRefresh: () async { + ref.invalidate(p2pTransferHistoryProvider(accountSequence)); + }, + child: recordsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 48, color: _grayText), + const SizedBox(height: 16), + const Text( + '加载失败', + style: TextStyle(color: _grayText), + ), + const SizedBox(height: 8), + TextButton( + onPressed: () => ref.invalidate(p2pTransferHistoryProvider(accountSequence)), + child: const Text('点击重试'), + ), + ], + ), + ), + data: (records) { + if (records.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.receipt_long, + size: 64, + color: _grayText.withOpacity(0.5), + ), + const SizedBox(height: 16), + const Text( + '暂无转账记录', + style: TextStyle( + fontSize: 16, + color: _grayText, + ), + ), + ], + ), + ); + } + + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: records.length, + itemBuilder: (context, index) { + return _buildRecordCard(context, records[index], accountSequence); + }, + ); + }, + ), + ), + ); + } + + Widget _buildRecordCard(BuildContext context, P2pTransferModel record, String myAccountSequence) { + // 判断是转出还是转入 + final isSend = record.fromAccountSequence == myAccountSequence; + final statusColor = _getStatusColor(record.status); + final statusText = _getStatusText(record.status); + final dateFormat = DateFormat('yyyy-MM-dd HH:mm:ss'); + + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 标题行 + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: (isSend ? _orange : _green).withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + isSend ? Icons.arrow_upward : Icons.arrow_downward, + size: 18, + color: isSend ? _orange : _green, + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isSend ? '转出' : '转入', + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: _darkText, + ), + ), + const SizedBox(height: 2), + Text( + dateFormat.format(record.createdAt), + style: const TextStyle( + fontSize: 12, + color: _grayText, + ), + ), + ], + ), + ], + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: statusColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + statusText, + style: TextStyle( + fontSize: 12, + color: statusColor, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + const Divider(height: 1), + const SizedBox(height: 12), + // 金额 + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + '转账金额', + style: TextStyle(fontSize: 13, color: _grayText), + ), + Text( + '${isSend ? '-' : '+'}${formatAmount(record.amount)} 积分值', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: isSend ? _orange : _green, + ), + ), + ], + ), + const SizedBox(height: 8), + // 对方账号 + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + isSend ? '收款方' : '付款方', + style: const TextStyle(fontSize: 12, color: _grayText), + ), + Text( + _maskPhone(record.toPhone), + style: const TextStyle( + fontSize: 12, + color: _grayText, + ), + ), + ], + ), + // 备注 + if (record.memo != null && record.memo!.isNotEmpty) ...[ + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + '备注', + style: TextStyle(fontSize: 12, color: _grayText), + ), + Flexible( + child: Text( + record.memo!, + style: const TextStyle( + fontSize: 12, + color: _darkText, + ), + textAlign: TextAlign.right, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + const SizedBox(height: 8), + // 单号 + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + '转账单号', + style: TextStyle(fontSize: 12, color: _grayText), + ), + Text( + record.transferNo, + style: const TextStyle( + fontSize: 12, + color: _grayText, + fontFamily: 'monospace', + ), + ), + ], + ), + ], + ), + ); + } + + String _maskPhone(String phone) { + if (phone.length != 11) return phone; + return '${phone.substring(0, 3)}****${phone.substring(7)}'; + } + + Color _getStatusColor(String status) { + switch (status.toUpperCase()) { + case 'COMPLETED': + case 'SUCCESS': + return _green; + case 'PENDING': + return _orange; + case 'FAILED': + return _red; + default: + return _grayText; + } + } + + String _getStatusText(String status) { + switch (status.toUpperCase()) { + case 'COMPLETED': + case 'SUCCESS': + return '已完成'; + case 'PENDING': + return '处理中'; + case 'FAILED': + return '失败'; + default: + return status; + } + } +} diff --git a/frontend/mining-app/lib/presentation/pages/asset/send_shares_page.dart b/frontend/mining-app/lib/presentation/pages/asset/send_shares_page.dart index 1b5c1b7c..4f7aeadd 100644 --- a/frontend/mining-app/lib/presentation/pages/asset/send_shares_page.dart +++ b/frontend/mining-app/lib/presentation/pages/asset/send_shares_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../../../core/router/routes.dart'; import '../../../core/utils/format_utils.dart'; import '../../providers/user_providers.dart'; import '../../providers/asset_providers.dart'; @@ -44,7 +45,7 @@ class _SendSharesPageState extends ConsumerState { final assetAsync = ref.watch(accountAssetProvider(accountSequence)); final transferState = ref.watch(transferNotifierProvider); - final availableShares = assetAsync.valueOrNull?.availableShares ?? '0'; + final availableCash = assetAsync.valueOrNull?.availableCash ?? '0'; return Scaffold( backgroundColor: _bgGray, @@ -64,6 +65,18 @@ class _SendSharesPageState extends ConsumerState { ), ), centerTitle: true, + actions: [ + TextButton( + onPressed: () => context.push(Routes.p2pTransferRecords), + child: const Text( + '转账记录', + style: TextStyle( + fontSize: 14, + color: _orange, + ), + ), + ), + ], ), body: SingleChildScrollView( child: Column( @@ -76,7 +89,7 @@ class _SendSharesPageState extends ConsumerState { const SizedBox(height: 16), // 转账金额 - _buildAmountSection(availableShares), + _buildAmountSection(availableCash), const SizedBox(height: 16), @@ -86,7 +99,7 @@ class _SendSharesPageState extends ConsumerState { const SizedBox(height: 32), // 发送按钮 - _buildSendButton(transferState, availableShares), + _buildSendButton(transferState, availableCash), const SizedBox(height: 16), @@ -248,7 +261,7 @@ class _SendSharesPageState extends ConsumerState { ); } - Widget _buildAmountSection(String availableShares) { + Widget _buildAmountSection(String availableCash) { return Container( margin: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.all(20), @@ -271,7 +284,7 @@ class _SendSharesPageState extends ConsumerState { ), ), Text( - '可用: ${formatAmount(availableShares)}', + '可用: ${formatAmount(availableCash)}', style: const TextStyle( fontSize: 12, color: _grayText, @@ -301,7 +314,7 @@ class _SendSharesPageState extends ConsumerState { ), suffixIcon: TextButton( onPressed: () { - _amountController.text = availableShares; + _amountController.text = availableCash; }, child: const Text( '全部', @@ -365,9 +378,9 @@ class _SendSharesPageState extends ConsumerState { ); } - Widget _buildSendButton(TransferState transferState, String availableShares) { + Widget _buildSendButton(TransferState transferState, String availableCash) { final amount = double.tryParse(_amountController.text) ?? 0; - final available = double.tryParse(availableShares) ?? 0; + final available = double.tryParse(availableCash) ?? 0; final isValid = _isRecipientVerified && amount > 0 && amount <= available; return Padding(