From 17b9c0938121e87a7d6a991a51bfd33c0166368a Mon Sep 17 00:00:00 2001 From: hailin Date: Sat, 3 Jan 2026 20:09:17 -0800 Subject: [PATCH] feat(ledger): add detailed ledger entry views with source tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现账本流水详情功能,支持点击查看各类型流水的详细信息。 ## reward-service 后端 ### 数据库 - 新增 `source_account_sequence` 字段到 `reward_ledger_entries` 表 - 添加索引 `idx_source_account_seq` 提升查询性能 - 字段可空,兼容历史数据 ### 领域层 - `RewardSource` 值对象新增 `sourceAccountSequence` 属性 - `RewardCalculationService` 传递 `sourceAccountSequence` ### 应用层 - 新增 `getSettlementHistory` 方法查询结算历史 - 新增 `SettlementRecordRepository` 仓储实现 ### API层 - 新增 `GET /settlements/history` 接口 - 新增 `SettlementHistoryQueryDTO` 和 `SettlementHistoryDTO` ## mobile-app 前端 ### 服务层 - `RewardService` 新增结算历史相关模型和方法: - `SettlementHistoryItem` 结算记录模型 - `SettlementRewardEntry` 关联奖励条目模型 - `getSettlementHistory()` 获取结算历史 - `WalletService` 新增: - `LedgerEntry.payloadJson` 字段及辅助方法 - `counterpartyAccountSequence` 获取转账对手方ID - `counterpartyUserId` 获取转账对手方用户ID - `transferFee` 获取转账手续费 ### 账本详情页 - 结算流水详情:显示结算金额、币种、涉及奖励明细(含来源用户) - 提现流水详情:显示提现订单信息、状态、手续费等 - 转账流水详情:显示转入来源/转出目标用户信息 ### 交互优化 - REWARD_SETTLED、WITHDRAWAL、TRANSFER_IN、TRANSFER_OUT 类型可点击 - 使用底部弹窗展示详情,支持滚动查看长列表 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../migration.sql | 10 + .../reward-service/prisma/schema.prisma | 8 +- .../api/controllers/settlement.controller.ts | 19 +- .../request/settlement-history-query.dto.ts | 20 + .../dto/response/settlement-history.dto.ts | 55 ++ .../services/reward-application.service.ts | 62 ++ .../services/reward-calculation.service.ts | 48 +- .../domain/value-objects/reward-source.vo.ts | 4 +- .../infrastructure/infrastructure.module.ts | 3 + .../mappers/reward-ledger-entry.mapper.ts | 2 + .../settlement-record.repository.ts | 81 ++ .../lib/core/services/reward_service.dart | 141 +++ .../lib/core/services/wallet_service.dart | 116 +++ .../pages/ledger_detail_page.dart | 805 +++++++++++++++++- 14 files changed, 1363 insertions(+), 11 deletions(-) create mode 100644 backend/services/reward-service/prisma/migrations/20260103000000_add_source_account_sequence/migration.sql create mode 100644 backend/services/reward-service/src/api/dto/request/settlement-history-query.dto.ts create mode 100644 backend/services/reward-service/src/api/dto/response/settlement-history.dto.ts create mode 100644 backend/services/reward-service/src/infrastructure/persistence/repositories/settlement-record.repository.ts diff --git a/backend/services/reward-service/prisma/migrations/20260103000000_add_source_account_sequence/migration.sql b/backend/services/reward-service/prisma/migrations/20260103000000_add_source_account_sequence/migration.sql new file mode 100644 index 00000000..aeb99a73 --- /dev/null +++ b/backend/services/reward-service/prisma/migrations/20260103000000_add_source_account_sequence/migration.sql @@ -0,0 +1,10 @@ +-- Add source_account_sequence column to reward_ledger_entries +-- This stores the account sequence of the user who triggered the reward (the planter) + +ALTER TABLE "reward_ledger_entries" ADD COLUMN "source_account_sequence" VARCHAR(20); + +-- Create index for source_account_sequence +CREATE INDEX "idx_source_account_seq" ON "reward_ledger_entries"("source_account_sequence"); + +-- Note: This column is nullable for backward compatibility with existing records +-- New records should always have this field populated diff --git a/backend/services/reward-service/prisma/schema.prisma b/backend/services/reward-service/prisma/schema.prisma index 198b7a4f..c765a82d 100644 --- a/backend/services/reward-service/prisma/schema.prisma +++ b/backend/services/reward-service/prisma/schema.prisma @@ -17,9 +17,10 @@ model RewardLedgerEntry { accountSequence String @map("account_sequence") @db.VarChar(20) // 账户序列号 // === 奖励来源 === - sourceOrderNo String @map("source_order_no") @db.VarChar(50) // 来源认种订单号(字符串格式如PLT1765391584505Q0Q6QD) - sourceUserId BigInt @map("source_user_id") // 触发奖励的用户ID(认种者) - rightType String @map("right_type") @db.VarChar(50) // 权益类型 + sourceOrderNo String @map("source_order_no") @db.VarChar(50) // 来源认种订单号(字符串格式如PLT1765391584505Q0Q6QD) + sourceUserId BigInt @map("source_user_id") // 触发奖励的用户ID(认种者) + sourceAccountSequence String? @map("source_account_sequence") @db.VarChar(20) // 触发奖励的用户账户序列号 + rightType String @map("right_type") @db.VarChar(50) // 权益类型 // === 奖励金额 === usdtAmount Decimal @map("usdt_amount") @db.Decimal(20, 8) @@ -45,6 +46,7 @@ model RewardLedgerEntry { @@index([accountSequence, createdAt(sort: Desc)], name: "idx_account_created") @@index([sourceOrderNo], name: "idx_source_order") @@index([sourceUserId], name: "idx_source_user") + @@index([sourceAccountSequence], name: "idx_source_account_seq") @@index([rightType], name: "idx_right_type") @@index([rewardStatus], name: "idx_status") @@index([expireAt], name: "idx_expire") diff --git a/backend/services/reward-service/src/api/controllers/settlement.controller.ts b/backend/services/reward-service/src/api/controllers/settlement.controller.ts index 0599ab50..36172c3d 100644 --- a/backend/services/reward-service/src/api/controllers/settlement.controller.ts +++ b/backend/services/reward-service/src/api/controllers/settlement.controller.ts @@ -1,9 +1,11 @@ -import { Controller, Post, Body, UseGuards, Request } from '@nestjs/common'; +import { Controller, Post, Get, Body, Query, UseGuards, Request } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; import { JwtAuthGuard } from '../../shared/guards/jwt-auth.guard'; import { RewardApplicationService } from '../../application/services/reward-application.service'; import { SettleRewardsDto } from '../dto/request/settle-rewards.dto'; +import { SettlementHistoryQueryDto } from '../dto/request/settlement-history-query.dto'; import { SettlementResultDto } from '../dto/response/settlement-result.dto'; +import { SettlementHistoryResponseDto } from '../dto/response/settlement-history.dto'; @ApiTags('Settlement') @Controller('rewards') @@ -27,4 +29,19 @@ export class SettlementController { settleCurrency: dto.settleCurrency, }); } + + @Get('settlement-history') + @ApiOperation({ summary: '获取结算历史记录' }) + @ApiResponse({ status: 200, description: '成功', type: SettlementHistoryResponseDto }) + async getSettlementHistory( + @Request() req, + @Query() query: SettlementHistoryQueryDto, + ): Promise { + const accountSequence = req.user.accountSequence; + + return this.rewardService.getSettlementHistory(accountSequence, { + page: query.page || 1, + pageSize: query.pageSize || 20, + }); + } } diff --git a/backend/services/reward-service/src/api/dto/request/settlement-history-query.dto.ts b/backend/services/reward-service/src/api/dto/request/settlement-history-query.dto.ts new file mode 100644 index 00000000..5ff4f477 --- /dev/null +++ b/backend/services/reward-service/src/api/dto/request/settlement-history-query.dto.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsOptional, IsInt, Min, Max } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class SettlementHistoryQueryDto { + @ApiProperty({ description: '页码', example: 1, required: false, default: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @ApiProperty({ description: '每页数量', example: 20, required: false, default: 20 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + pageSize?: number = 20; +} diff --git a/backend/services/reward-service/src/api/dto/response/settlement-history.dto.ts b/backend/services/reward-service/src/api/dto/response/settlement-history.dto.ts new file mode 100644 index 00000000..eb4b5c8e --- /dev/null +++ b/backend/services/reward-service/src/api/dto/response/settlement-history.dto.ts @@ -0,0 +1,55 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class SettlementHistoryItemDto { + @ApiProperty({ description: '结算记录ID', example: '123' }) + id: string; + + @ApiProperty({ description: '结算USDT金额', example: 1000 }) + usdtAmount: number; + + @ApiProperty({ description: '结算算力', example: 0 }) + hashpowerAmount: number; + + @ApiProperty({ description: '结算币种', example: 'USDT' }) + settleCurrency: string; + + @ApiProperty({ description: '实际收到金额', example: 1000 }) + receivedAmount: number; + + @ApiProperty({ description: '兑换哈希', example: '0x...', nullable: true }) + swapTxHash: string | null; + + @ApiProperty({ description: '兑换汇率', example: 1.0, nullable: true }) + swapRate: number | null; + + @ApiProperty({ description: '状态', example: 'SUCCESS' }) + status: string; + + @ApiProperty({ description: '创建时间' }) + createdAt: Date; + + @ApiProperty({ description: '完成时间', nullable: true }) + completedAt: Date | null; + + @ApiProperty({ description: '涉及的奖励条目数量', example: 5 }) + rewardEntryCount: number; +} + +export class SettlementHistoryPaginationDto { + @ApiProperty({ description: '当前页', example: 1 }) + page: number; + + @ApiProperty({ description: '每页数量', example: 20 }) + pageSize: number; + + @ApiProperty({ description: '总数', example: 100 }) + total: number; +} + +export class SettlementHistoryResponseDto { + @ApiProperty({ description: '结算记录列表', type: [SettlementHistoryItemDto] }) + data: SettlementHistoryItemDto[]; + + @ApiProperty({ description: '分页信息', type: SettlementHistoryPaginationDto }) + pagination: SettlementHistoryPaginationDto; +} diff --git a/backend/services/reward-service/src/application/services/reward-application.service.ts b/backend/services/reward-service/src/application/services/reward-application.service.ts index b7f2a15e..ea87470c 100644 --- a/backend/services/reward-service/src/application/services/reward-application.service.ts +++ b/backend/services/reward-service/src/application/services/reward-application.service.ts @@ -13,6 +13,7 @@ import { Hashpower } from '../../domain/value-objects/hashpower.vo'; import { EventPublisherService } from '../../infrastructure/kafka/event-publisher.service'; import { WalletServiceClient } from '../../infrastructure/external/wallet-service/wallet-service.client'; import { OutboxRepository, OutboxEventData } from '../../infrastructure/persistence/repositories/outbox.repository'; +import { SettlementRecordRepository, SettlementRecordDto } from '../../infrastructure/persistence/repositories/settlement-record.repository'; import { RewardSummary } from '../../domain/aggregates/reward-summary/reward-summary.aggregate'; // 总部社区账户ID @@ -32,6 +33,7 @@ export class RewardApplicationService { private readonly eventPublisher: EventPublisherService, private readonly walletService: WalletServiceClient, private readonly outboxRepository: OutboxRepository, + private readonly settlementRecordRepository: SettlementRecordRepository, ) {} /** @@ -669,6 +671,8 @@ export class RewardApplicationService { createdAt: r.createdAt, expireAt: r.expireAt, remainingTimeMs: r.getRemainingTimeMs(), + sourceOrderNo: r.rewardSource.sourceOrderNo, + sourceAccountSequence: r.rewardSource.sourceAccountSequence, memo: r.memo, })); } @@ -687,6 +691,7 @@ export class RewardApplicationService { createdAt: r.createdAt, claimedAt: r.claimedAt, sourceOrderNo: r.rewardSource.sourceOrderNo, + sourceAccountSequence: r.rewardSource.sourceAccountSequence, memo: r.memo, })); } @@ -822,4 +827,61 @@ export class RewardApplicationService { return results; } + + /** + * 获取结算历史记录(新增接口) + */ + async getSettlementHistory( + accountSequence: string, + pagination?: { page: number; pageSize: number }, + ): Promise<{ + data: Array<{ + id: string; + usdtAmount: number; + hashpowerAmount: number; + settleCurrency: string; + receivedAmount: number; + swapTxHash: string | null; + swapRate: number | null; + status: string; + createdAt: Date; + completedAt: Date | null; + rewardEntryCount: number; + }>; + pagination: { + page: number; + pageSize: number; + total: number; + }; + }> { + const page = pagination?.page || 1; + const pageSize = pagination?.pageSize || 20; + + const records = await this.settlementRecordRepository.findByAccountSequence( + accountSequence, + { page, pageSize }, + ); + const total = await this.settlementRecordRepository.countByAccountSequence(accountSequence); + + return { + data: records.map(r => ({ + id: r.id, + usdtAmount: r.usdtAmount, + hashpowerAmount: r.hashpowerAmount, + settleCurrency: r.settleCurrency, + receivedAmount: r.receivedAmount, + swapTxHash: r.swapTxHash, + swapRate: r.swapRate, + status: r.status, + createdAt: r.createdAt, + completedAt: r.completedAt, + rewardEntryCount: r.rewardEntryIds.length, + })), + pagination: { + page, + pageSize, + total, + }, + }; + } } diff --git a/backend/services/reward-service/src/domain/services/reward-calculation.service.ts b/backend/services/reward-service/src/domain/services/reward-calculation.service.ts index 64a1a613..9a743234 100644 --- a/backend/services/reward-service/src/domain/services/reward-calculation.service.ts +++ b/backend/services/reward-service/src/domain/services/reward-calculation.service.ts @@ -110,6 +110,7 @@ export class RewardCalculationService { const costFeeReward = this.calculateCostFee( params.sourceOrderNo, params.sourceUserId, + params.sourceAccountSequence, params.treeCount, ); rewards.push(costFeeReward); @@ -118,6 +119,7 @@ export class RewardCalculationService { const operationFeeReward = this.calculateOperationFee( params.sourceOrderNo, params.sourceUserId, + params.sourceAccountSequence, params.treeCount, ); rewards.push(operationFeeReward); @@ -126,6 +128,7 @@ export class RewardCalculationService { const headquartersBaseFeeReward = this.calculateHeadquartersBaseFee( params.sourceOrderNo, params.sourceUserId, + params.sourceAccountSequence, params.treeCount, ); rewards.push(headquartersBaseFeeReward); @@ -134,6 +137,7 @@ export class RewardCalculationService { const rwadPoolReward = this.calculateRwadPoolInjection( params.sourceOrderNo, params.sourceUserId, + params.sourceAccountSequence, params.treeCount, ); rewards.push(rwadPoolReward); @@ -218,6 +222,7 @@ export class RewardCalculationService { private calculateCostFee( sourceOrderNo: string, sourceUserId: bigint, + sourceAccountSequence: string | undefined, treeCount: number, ): RewardLedgerEntry { const { usdt, hashpowerPercent } = RIGHT_AMOUNTS[RightType.COST_FEE]; @@ -228,6 +233,7 @@ export class RewardCalculationService { RightType.COST_FEE, sourceOrderNo, sourceUserId, + sourceAccountSequence, ); return RewardLedgerEntry.createSettleable({ @@ -236,7 +242,7 @@ export class RewardCalculationService { rewardSource, usdtAmount, hashpowerAmount: hashpower, - memo: `成本费:来自用户${sourceUserId}的认种,${treeCount}棵树`, + memo: `成本费:来自用户${sourceAccountSequence || sourceUserId}的认种,${treeCount}棵树`, }); } @@ -247,6 +253,7 @@ export class RewardCalculationService { private calculateOperationFee( sourceOrderNo: string, sourceUserId: bigint, + sourceAccountSequence: string | undefined, treeCount: number, ): RewardLedgerEntry { const { usdt, hashpowerPercent } = RIGHT_AMOUNTS[RightType.OPERATION_FEE]; @@ -257,6 +264,7 @@ export class RewardCalculationService { RightType.OPERATION_FEE, sourceOrderNo, sourceUserId, + sourceAccountSequence, ); return RewardLedgerEntry.createSettleable({ @@ -265,7 +273,7 @@ export class RewardCalculationService { rewardSource, usdtAmount, hashpowerAmount: hashpower, - memo: `运营费:来自用户${sourceUserId}的认种,${treeCount}棵树`, + memo: `运营费:来自用户${sourceAccountSequence || sourceUserId}的认种,${treeCount}棵树`, }); } @@ -276,6 +284,7 @@ export class RewardCalculationService { private calculateHeadquartersBaseFee( sourceOrderNo: string, sourceUserId: bigint, + sourceAccountSequence: string | undefined, treeCount: number, ): RewardLedgerEntry { const { usdt, hashpowerPercent } = RIGHT_AMOUNTS[RightType.HEADQUARTERS_BASE_FEE]; @@ -286,6 +295,7 @@ export class RewardCalculationService { RightType.HEADQUARTERS_BASE_FEE, sourceOrderNo, sourceUserId, + sourceAccountSequence, ); return RewardLedgerEntry.createSettleable({ @@ -294,7 +304,7 @@ export class RewardCalculationService { rewardSource, usdtAmount, hashpowerAmount: hashpower, - memo: `总部社区基础费:来自用户${sourceUserId}的认种,${treeCount}棵树`, + memo: `总部社区基础费:来自用户${sourceAccountSequence || sourceUserId}的认种,${treeCount}棵树`, }); } @@ -305,6 +315,7 @@ export class RewardCalculationService { private calculateRwadPoolInjection( sourceOrderNo: string, sourceUserId: bigint, + sourceAccountSequence: string | undefined, treeCount: number, ): RewardLedgerEntry { const { usdt, hashpowerPercent } = RIGHT_AMOUNTS[RightType.RWAD_POOL_INJECTION]; @@ -315,6 +326,7 @@ export class RewardCalculationService { RightType.RWAD_POOL_INJECTION, sourceOrderNo, sourceUserId, + sourceAccountSequence, ); return RewardLedgerEntry.createSettleable({ @@ -323,7 +335,7 @@ export class RewardCalculationService { rewardSource, usdtAmount, hashpowerAmount: hashpower, - memo: `RWAD底池注入:来自用户${sourceUserId}的认种,${treeCount}棵树,5760U注入1号底池`, + memo: `RWAD底池注入:来自用户${sourceAccountSequence || sourceUserId}的认种,${treeCount}棵树,5760U注入1号底池`, }); } @@ -350,6 +362,7 @@ export class RewardCalculationService { RightType.SHARE_RIGHT, sourceOrderNo, sourceUserId, + sourceAccountSequence, ); // 使用 accountSequence 获取推荐链(优先),否则用 userId @@ -440,6 +453,7 @@ export class RewardCalculationService { RightType.PROVINCE_TEAM_RIGHT, sourceOrderNo, sourceUserId, + sourceAccountSequence, ); rewards.push( @@ -497,6 +511,7 @@ export class RewardCalculationService { RightType.PROVINCE_AREA_RIGHT, sourceOrderNo, sourceUserId, + sourceAccountSequence, ); rewards.push( @@ -553,6 +568,7 @@ export class RewardCalculationService { RightType.CITY_TEAM_RIGHT, sourceOrderNo, sourceUserId, + sourceAccountSequence, ); rewards.push( @@ -610,6 +626,7 @@ export class RewardCalculationService { RightType.CITY_AREA_RIGHT, sourceOrderNo, sourceUserId, + sourceAccountSequence, ); rewards.push( @@ -666,6 +683,7 @@ export class RewardCalculationService { RightType.COMMUNITY_RIGHT, sourceOrderNo, sourceUserId, + sourceAccountSequence, ); rewards.push( @@ -725,6 +743,7 @@ export class RewardCalculationService { rewards.push(this.calculateCostFee( params.sourceOrderNo, params.sourceUserId, + params.sourceAccountSequence, params.treeCount, )); @@ -732,6 +751,7 @@ export class RewardCalculationService { rewards.push(this.calculateOperationFee( params.sourceOrderNo, params.sourceUserId, + params.sourceAccountSequence, params.treeCount, )); @@ -739,6 +759,7 @@ export class RewardCalculationService { rewards.push(this.calculateHeadquartersBaseFee( params.sourceOrderNo, params.sourceUserId, + params.sourceAccountSequence, params.treeCount, )); @@ -746,6 +767,7 @@ export class RewardCalculationService { rewards.push(this.calculateRwadPoolInjection( params.sourceOrderNo, params.sourceUserId, + params.sourceAccountSequence, params.treeCount, )); @@ -757,6 +779,7 @@ export class RewardCalculationService { rewards.push(this.calculateShareRightToSystemAccount( params.sourceOrderNo, params.sourceUserId, + params.sourceAccountSequence, params.treeCount, )); @@ -764,6 +787,7 @@ export class RewardCalculationService { rewards.push(this.calculateProvinceTeamRightToSystemAccount( params.sourceOrderNo, params.sourceUserId, + params.sourceAccountSequence, params.provinceCode, params.treeCount, )); @@ -772,6 +796,7 @@ export class RewardCalculationService { rewards.push(this.calculateProvinceAreaRightToSystemAccount( params.sourceOrderNo, params.sourceUserId, + params.sourceAccountSequence, params.provinceCode, params.treeCount, )); @@ -780,6 +805,7 @@ export class RewardCalculationService { rewards.push(this.calculateCityTeamRightToSystemAccount( params.sourceOrderNo, params.sourceUserId, + params.sourceAccountSequence, params.cityCode, params.treeCount, )); @@ -788,6 +814,7 @@ export class RewardCalculationService { rewards.push(this.calculateCityAreaRightToSystemAccount( params.sourceOrderNo, params.sourceUserId, + params.sourceAccountSequence, params.cityCode, params.treeCount, )); @@ -796,6 +823,7 @@ export class RewardCalculationService { rewards.push(this.calculateCommunityRightToSystemAccount( params.sourceOrderNo, params.sourceUserId, + params.sourceAccountSequence, params.treeCount, )); @@ -816,6 +844,7 @@ export class RewardCalculationService { private calculateShareRightToSystemAccount( sourceOrderNo: string, sourceUserId: bigint, + sourceAccountSequence: string | undefined, treeCount: number, ): RewardLedgerEntry { const { usdt, hashpowerPercent } = RIGHT_AMOUNTS[RightType.SHARE_RIGHT]; @@ -826,6 +855,7 @@ export class RewardCalculationService { RightType.SHARE_RIGHT, sourceOrderNo, sourceUserId, + sourceAccountSequence, ); return RewardLedgerEntry.createSettleable({ @@ -844,6 +874,7 @@ export class RewardCalculationService { private calculateProvinceTeamRightToSystemAccount( sourceOrderNo: string, sourceUserId: bigint, + sourceAccountSequence: string | undefined, provinceCode: string, treeCount: number, ): RewardLedgerEntry { @@ -855,6 +886,7 @@ export class RewardCalculationService { RightType.PROVINCE_TEAM_RIGHT, sourceOrderNo, sourceUserId, + sourceAccountSequence, ); // 省团队默认账户: 7 + 省代码 @@ -876,6 +908,7 @@ export class RewardCalculationService { private calculateProvinceAreaRightToSystemAccount( sourceOrderNo: string, sourceUserId: bigint, + sourceAccountSequence: string | undefined, provinceCode: string, treeCount: number, ): RewardLedgerEntry { @@ -887,6 +920,7 @@ export class RewardCalculationService { RightType.PROVINCE_AREA_RIGHT, sourceOrderNo, sourceUserId, + sourceAccountSequence, ); // 省区域账户: 9 + 省代码 @@ -908,6 +942,7 @@ export class RewardCalculationService { private calculateCityTeamRightToSystemAccount( sourceOrderNo: string, sourceUserId: bigint, + sourceAccountSequence: string | undefined, cityCode: string, treeCount: number, ): RewardLedgerEntry { @@ -919,6 +954,7 @@ export class RewardCalculationService { RightType.CITY_TEAM_RIGHT, sourceOrderNo, sourceUserId, + sourceAccountSequence, ); // 市团队默认账户: 6 + 市代码 @@ -940,6 +976,7 @@ export class RewardCalculationService { private calculateCityAreaRightToSystemAccount( sourceOrderNo: string, sourceUserId: bigint, + sourceAccountSequence: string | undefined, cityCode: string, treeCount: number, ): RewardLedgerEntry { @@ -951,6 +988,7 @@ export class RewardCalculationService { RightType.CITY_AREA_RIGHT, sourceOrderNo, sourceUserId, + sourceAccountSequence, ); // 市区域账户: 8 + 市代码 @@ -972,6 +1010,7 @@ export class RewardCalculationService { private calculateCommunityRightToSystemAccount( sourceOrderNo: string, sourceUserId: bigint, + sourceAccountSequence: string | undefined, treeCount: number, ): RewardLedgerEntry { const { usdt, hashpowerPercent } = RIGHT_AMOUNTS[RightType.COMMUNITY_RIGHT]; @@ -982,6 +1021,7 @@ export class RewardCalculationService { RightType.COMMUNITY_RIGHT, sourceOrderNo, sourceUserId, + sourceAccountSequence, ); return RewardLedgerEntry.createSettleable({ diff --git a/backend/services/reward-service/src/domain/value-objects/reward-source.vo.ts b/backend/services/reward-service/src/domain/value-objects/reward-source.vo.ts index 6ded5ed3..fe5a5d70 100644 --- a/backend/services/reward-service/src/domain/value-objects/reward-source.vo.ts +++ b/backend/services/reward-service/src/domain/value-objects/reward-source.vo.ts @@ -5,14 +5,16 @@ export class RewardSource { public readonly rightType: RightType, public readonly sourceOrderNo: string, // 订单号是字符串格式如 PLT1765391584505Q0Q6QD public readonly sourceUserId: bigint, + public readonly sourceAccountSequence: string | null, // 触发奖励的用户账户序列号 ) {} static create( rightType: RightType, sourceOrderNo: string, sourceUserId: bigint, + sourceAccountSequence?: string | null, ): RewardSource { - return new RewardSource(rightType, sourceOrderNo, sourceUserId); + return new RewardSource(rightType, sourceOrderNo, sourceUserId, sourceAccountSequence ?? null); } equals(other: RewardSource): boolean { diff --git a/backend/services/reward-service/src/infrastructure/infrastructure.module.ts b/backend/services/reward-service/src/infrastructure/infrastructure.module.ts index 8619decf..f5431172 100644 --- a/backend/services/reward-service/src/infrastructure/infrastructure.module.ts +++ b/backend/services/reward-service/src/infrastructure/infrastructure.module.ts @@ -3,6 +3,7 @@ import { ConfigModule } from '@nestjs/config'; import { PrismaService } from './persistence/prisma/prisma.service'; import { RewardLedgerEntryRepositoryImpl } from './persistence/repositories/reward-ledger-entry.repository.impl'; import { RewardSummaryRepositoryImpl } from './persistence/repositories/reward-summary.repository.impl'; +import { SettlementRecordRepository } from './persistence/repositories/settlement-record.repository'; import { OutboxRepository } from './persistence/repositories/outbox.repository'; import { ReferralServiceClient } from './external/referral-service/referral-service.client'; import { AuthorizationServiceClient } from './external/authorization-service/authorization-service.client'; @@ -18,6 +19,7 @@ import { REFERRAL_SERVICE_CLIENT, AUTHORIZATION_SERVICE_CLIENT } from '../domain providers: [ PrismaService, OutboxRepository, + SettlementRecordRepository, { provide: REWARD_LEDGER_ENTRY_REPOSITORY, useClass: RewardLedgerEntryRepositoryImpl, @@ -43,6 +45,7 @@ import { REFERRAL_SERVICE_CLIENT, AUTHORIZATION_SERVICE_CLIENT } from '../domain exports: [ PrismaService, OutboxRepository, + SettlementRecordRepository, REWARD_LEDGER_ENTRY_REPOSITORY, REWARD_SUMMARY_REPOSITORY, REFERRAL_SERVICE_CLIENT, diff --git a/backend/services/reward-service/src/infrastructure/persistence/mappers/reward-ledger-entry.mapper.ts b/backend/services/reward-service/src/infrastructure/persistence/mappers/reward-ledger-entry.mapper.ts index 43ae9160..cc9aa370 100644 --- a/backend/services/reward-service/src/infrastructure/persistence/mappers/reward-ledger-entry.mapper.ts +++ b/backend/services/reward-service/src/infrastructure/persistence/mappers/reward-ledger-entry.mapper.ts @@ -14,6 +14,7 @@ export class RewardLedgerEntryMapper { raw.rightType as RightType, raw.sourceOrderNo, raw.sourceUserId, + raw.sourceAccountSequence, ), usdtAmount: Number(raw.usdtAmount), hashpowerAmount: Number(raw.hashpowerAmount), @@ -34,6 +35,7 @@ export class RewardLedgerEntryMapper { accountSequence: entry.accountSequence, sourceOrderNo: entry.rewardSource.sourceOrderNo, sourceUserId: entry.rewardSource.sourceUserId, + sourceAccountSequence: entry.rewardSource.sourceAccountSequence, rightType: entry.rewardSource.rightType, usdtAmount: new Prisma.Decimal(entry.usdtAmount.amount), hashpowerAmount: new Prisma.Decimal(entry.hashpowerAmount.value), diff --git a/backend/services/reward-service/src/infrastructure/persistence/repositories/settlement-record.repository.ts b/backend/services/reward-service/src/infrastructure/persistence/repositories/settlement-record.repository.ts new file mode 100644 index 00000000..303c62e8 --- /dev/null +++ b/backend/services/reward-service/src/infrastructure/persistence/repositories/settlement-record.repository.ts @@ -0,0 +1,81 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { SettlementRecord as PrismaSettlementRecord } from '@prisma/client'; + +export interface SettlementRecordDto { + id: string; + userId: string; + accountSequence: string; + usdtAmount: number; + hashpowerAmount: number; + settleCurrency: string; + receivedAmount: number; + swapTxHash: string | null; + swapRate: number | null; + status: string; + createdAt: Date; + completedAt: Date | null; + rewardEntryIds: string[]; +} + +@Injectable() +export class SettlementRecordRepository { + constructor(private readonly prisma: PrismaService) {} + + /** + * 查询用户的结算历史记录 + */ + async findByAccountSequence( + accountSequence: string, + pagination?: { page: number; pageSize: number }, + ): Promise { + const skip = pagination ? (pagination.page - 1) * pagination.pageSize : undefined; + const take = pagination?.pageSize; + + const rawList = await this.prisma.settlementRecord.findMany({ + where: { accountSequence }, + orderBy: { createdAt: 'desc' }, + skip, + take, + }); + + return rawList.map(this.toDto); + } + + /** + * 统计用户的结算记录数量 + */ + async countByAccountSequence(accountSequence: string): Promise { + return this.prisma.settlementRecord.count({ + where: { accountSequence }, + }); + } + + /** + * 根据ID查询结算记录 + */ + async findById(id: bigint): Promise { + const raw = await this.prisma.settlementRecord.findUnique({ + where: { id }, + }); + return raw ? this.toDto(raw) : null; + } + + private toDto(raw: PrismaSettlementRecord): SettlementRecordDto { + return { + id: raw.id.toString(), + userId: raw.userId.toString(), + accountSequence: raw.accountSequence, + usdtAmount: Number(raw.usdtAmount), + hashpowerAmount: Number(raw.hashpowerAmount), + settleCurrency: raw.settleCurrency, + receivedAmount: Number(raw.receivedAmount), + swapTxHash: raw.swapTxHash, + swapRate: raw.swapRate ? Number(raw.swapRate) : null, + status: raw.status, + createdAt: raw.createdAt, + completedAt: raw.completedAt, + rewardEntryIds: raw.rewardEntryIds.map(id => id.toString()), + }; + } +} diff --git a/frontend/mobile-app/lib/core/services/reward_service.dart b/frontend/mobile-app/lib/core/services/reward_service.dart index 9e787cc8..90871bc9 100644 --- a/frontend/mobile-app/lib/core/services/reward_service.dart +++ b/frontend/mobile-app/lib/core/services/reward_service.dart @@ -30,6 +30,8 @@ class PendingRewardItem { final DateTime createdAt; final DateTime expireAt; final int remainingTimeMs; + final String sourceOrderNo; + final String? sourceAccountSequence; // 触发奖励的用户账户序列号 final String memo; PendingRewardItem({ @@ -40,6 +42,8 @@ class PendingRewardItem { required this.createdAt, required this.expireAt, required this.remainingTimeMs, + required this.sourceOrderNo, + this.sourceAccountSequence, required this.memo, }); @@ -52,6 +56,8 @@ class PendingRewardItem { createdAt: DateTime.tryParse(json['createdAt'] ?? '') ?? DateTime.now(), expireAt: DateTime.tryParse(json['expireAt'] ?? '') ?? DateTime.now(), remainingTimeMs: (json['remainingTimeMs'] ?? 0).toInt(), + sourceOrderNo: json['sourceOrderNo'] ?? '', + sourceAccountSequence: json['sourceAccountSequence'], memo: json['memo'] ?? '', ); } @@ -187,6 +193,96 @@ class SettleToBalanceResult { } } +/// 结算历史记录条目 (从 GET /rewards/settlement-history 获取) +class SettlementHistoryItem { + final String id; + final double usdtAmount; + final double hashpowerAmount; + final String settleCurrency; + final double receivedAmount; + final String? swapTxHash; + final double? swapRate; + final String status; + final DateTime createdAt; + final DateTime? completedAt; + final int rewardEntryCount; + + SettlementHistoryItem({ + required this.id, + required this.usdtAmount, + required this.hashpowerAmount, + required this.settleCurrency, + required this.receivedAmount, + this.swapTxHash, + this.swapRate, + required this.status, + required this.createdAt, + this.completedAt, + required this.rewardEntryCount, + }); + + factory SettlementHistoryItem.fromJson(Map json) { + return SettlementHistoryItem( + id: json['id']?.toString() ?? '', + usdtAmount: (json['usdtAmount'] ?? 0).toDouble(), + hashpowerAmount: (json['hashpowerAmount'] ?? 0).toDouble(), + settleCurrency: json['settleCurrency'] ?? '', + receivedAmount: (json['receivedAmount'] ?? 0).toDouble(), + swapTxHash: json['swapTxHash'], + swapRate: json['swapRate']?.toDouble(), + status: json['status'] ?? '', + createdAt: DateTime.tryParse(json['createdAt'] ?? '') ?? DateTime.now(), + completedAt: json['completedAt'] != null ? DateTime.tryParse(json['completedAt']) : null, + rewardEntryCount: (json['rewardEntryCount'] ?? 0).toInt(), + ); + } + + /// 获取状态的中文名称 + String get statusName { + switch (status) { + case 'SUCCESS': + return '成功'; + case 'PENDING': + return '处理中'; + case 'FAILED': + return '失败'; + default: + return status; + } + } +} + +/// 结算历史分页结果 +class SettlementHistoryResult { + final List data; + final int page; + final int pageSize; + final int total; + + SettlementHistoryResult({ + required this.data, + required this.page, + required this.pageSize, + required this.total, + }); + + factory SettlementHistoryResult.fromJson(Map json) { + final dataList = json['data'] as List? ?? []; + final pagination = json['pagination'] as Map? ?? {}; + + return SettlementHistoryResult( + data: dataList + .map((item) => SettlementHistoryItem.fromJson(item as Map)) + .toList(), + page: (pagination['page'] ?? 1).toInt(), + pageSize: (pagination['pageSize'] ?? 20).toInt(), + total: (pagination['total'] ?? 0).toInt(), + ); + } + + bool get hasMore => page * pageSize < total; +} + /// 奖励汇总信息 (从 reward-service 获取) class RewardSummary { final double pendingUsdt; @@ -454,6 +550,51 @@ class RewardService { } } + /// 获取结算历史记录 + /// + /// 调用 GET /rewards/settlement-history (reward-service) + /// 返回历史结算记录列表 + Future getSettlementHistory({int page = 1, int pageSize = 20}) async { + try { + debugPrint('[RewardService] ========== 获取结算历史记录 =========='); + debugPrint('[RewardService] 请求: GET /rewards/settlement-history?page=$page&pageSize=$pageSize'); + + final response = await _apiClient.get( + '/rewards/settlement-history', + queryParameters: {'page': page, 'pageSize': pageSize}, + ); + + debugPrint('[RewardService] 响应状态码: ${response.statusCode}'); + debugPrint('[RewardService] 响应数据类型: ${response.data.runtimeType}'); + + if (response.statusCode == 200) { + final responseData = response.data as Map; + debugPrint('[RewardService] 原始响应数据: $responseData'); + + // 解包可能的 data 字段 + final data = responseData['data'] != null && responseData['pagination'] == null + ? responseData['data'] as Map + : responseData; + + final result = SettlementHistoryResult.fromJson(data); + debugPrint('[RewardService] 解析到 ${result.data.length} 条结算记录'); + debugPrint('[RewardService] 分页: page=${result.page}, pageSize=${result.pageSize}, total=${result.total}'); + debugPrint('[RewardService] ================================'); + + return result; + } + + debugPrint('[RewardService] 请求失败,状态码: ${response.statusCode}'); + debugPrint('[RewardService] 响应内容: ${response.data}'); + throw Exception('获取结算历史记录失败: ${response.statusCode}'); + } catch (e, stackTrace) { + debugPrint('[RewardService] !!!!!!!!!! 获取结算历史记录异常 !!!!!!!!!!'); + debugPrint('[RewardService] 错误: $e'); + debugPrint('[RewardService] 堆栈: $stackTrace'); + rethrow; + } + } + /// 获取已过期奖励列表 /// /// 调用 GET /wallet/expired-rewards (wallet-service) diff --git a/frontend/mobile-app/lib/core/services/wallet_service.dart b/frontend/mobile-app/lib/core/services/wallet_service.dart index 49def494..6d7e93be 100644 --- a/frontend/mobile-app/lib/core/services/wallet_service.dart +++ b/frontend/mobile-app/lib/core/services/wallet_service.dart @@ -221,6 +221,85 @@ class FeeConfig { } } +/// 提现记录项 (从 GET /wallet/withdrawals 获取) +class WithdrawalHistoryItem { + final String orderNo; + final double amount; + final double fee; + final double netAmount; + final String chainType; + final String toAddress; + final String? txHash; + final String status; + final DateTime createdAt; + + WithdrawalHistoryItem({ + required this.orderNo, + required this.amount, + required this.fee, + required this.netAmount, + required this.chainType, + required this.toAddress, + this.txHash, + required this.status, + required this.createdAt, + }); + + factory WithdrawalHistoryItem.fromJson(Map json) { + return WithdrawalHistoryItem( + orderNo: json['orderNo'] ?? '', + amount: (json['amount'] ?? 0).toDouble(), + fee: (json['fee'] ?? 0).toDouble(), + netAmount: (json['netAmount'] ?? 0).toDouble(), + chainType: json['chainType'] ?? '', + toAddress: json['toAddress'] ?? '', + txHash: json['txHash'], + status: json['status'] ?? '', + createdAt: DateTime.tryParse(json['createdAt'] ?? '') ?? DateTime.now(), + ); + } + + /// 获取状态的中文名称 + String get statusName { + switch (status) { + case 'PENDING': + return '待处理'; + case 'FROZEN': + return '已冻结'; + case 'CONFIRMED': + return '已确认'; + case 'COMPLETED': + return '已完成'; + case 'FAILED': + return '失败'; + case 'CANCELLED': + return '已取消'; + default: + return status; + } + } + + /// 获取链类型的中文名称 + String get chainTypeName { + switch (chainType) { + case 'KAVA': + return 'KAVA'; + case 'BSC': + return 'BSC'; + case 'INTERNAL': + return '内部划转'; + default: + return chainType; + } + } + + /// 获取截断的地址显示 + String get shortAddress { + if (toAddress.length <= 16) return toAddress; + return '${toAddress.substring(0, 8)}...${toAddress.substring(toAddress.length - 6)}'; + } +} + /// 钱包服务 /// /// 提供钱包余额查询、奖励领取等功能 @@ -992,6 +1071,7 @@ class LedgerEntry { final String? refTxHash; final String? memo; final String? allocationType; + final Map? payloadJson; final DateTime createdAt; LedgerEntry({ @@ -1004,6 +1084,7 @@ class LedgerEntry { this.refTxHash, this.memo, this.allocationType, + this.payloadJson, required this.createdAt, }); @@ -1018,12 +1099,47 @@ class LedgerEntry { refTxHash: json['refTxHash'], memo: json['memo'], allocationType: json['allocationType'], + payloadJson: json['payloadJson'] as Map?, createdAt: json['createdAt'] != null ? DateTime.tryParse(json['createdAt']) ?? DateTime.now() : DateTime.now(), ); } + // === 转账相关辅助方法 === + + /// 获取转账对方的账户序列号 + String? get counterpartyAccountSequence { + if (payloadJson == null) return null; + if (entryType == 'TRANSFER_IN') { + return payloadJson!['fromAccountSequence'] as String?; + } else if (entryType == 'TRANSFER_OUT') { + return payloadJson!['toAccountSequence'] as String?; + } + return null; + } + + /// 获取转账对方的用户ID + String? get counterpartyUserId { + if (payloadJson == null) return null; + if (entryType == 'TRANSFER_IN') { + return payloadJson!['fromUserId']?.toString(); + } else if (entryType == 'TRANSFER_OUT') { + return payloadJson!['toUserId']?.toString(); + } + return null; + } + + /// 获取转账手续费(仅转出时有) + double? get transferFee { + if (payloadJson == null || entryType != 'TRANSFER_OUT') return null; + final fee = payloadJson!['fee']; + if (fee == null) return null; + if (fee is num) return fee.toDouble(); + if (fee is String) return double.tryParse(fee); + return null; + } + /// 获取流水类型中文名 String get entryTypeName { const nameMap = { diff --git a/frontend/mobile-app/lib/features/trading/presentation/pages/ledger_detail_page.dart b/frontend/mobile-app/lib/features/trading/presentation/pages/ledger_detail_page.dart index 00dee96d..2ce77b54 100644 --- a/frontend/mobile-app/lib/features/trading/presentation/pages/ledger_detail_page.dart +++ b/frontend/mobile-app/lib/features/trading/presentation/pages/ledger_detail_page.dart @@ -7,6 +7,7 @@ import 'package:share_plus/share_plus.dart'; import 'package:flutter_pdfview/flutter_pdfview.dart'; import '../../../../core/di/injection_container.dart'; import '../../../../core/services/wallet_service.dart'; +import '../../../../core/services/reward_service.dart'; import '../../../../core/utils/date_utils.dart'; /// 账本明细页面 - 显示用户的流水账、统计图表和筛选功能 @@ -938,10 +939,13 @@ class _LedgerDetailPageState extends ConsumerState /// 构建流水项 Widget _buildLedgerItem(LedgerEntry entry) { final isIncome = entry.isIncome; - // 可点击查看详情的类型:认种支付、权益分配 + // 可点击查看详情的类型:认种支付、权益分配、结算、提现、转入、转出 final bool isPlantPayment = entry.entryType == 'PLANT_PAYMENT' && entry.refOrderId != null; final bool isRewardEntry = entry.allocationType != null; - final bool isClickable = isPlantPayment || isRewardEntry; + final bool isSettlementEntry = entry.entryType == 'REWARD_SETTLED'; + final bool isWithdrawalEntry = entry.entryType == 'WITHDRAWAL'; + final bool isTransferEntry = entry.entryType == 'TRANSFER_IN' || entry.entryType == 'TRANSFER_OUT'; + final bool isClickable = isPlantPayment || isRewardEntry || isSettlementEntry || isWithdrawalEntry || isTransferEntry; return GestureDetector( onTap: isClickable ? () => _showEntryDetail(entry) : null, @@ -1057,12 +1061,57 @@ class _LedgerDetailPageState extends ConsumerState if (entry.entryType == 'PLANT_PAYMENT' && entry.refOrderId != null) { // 认种支付 - 显示合同详情 _showTransactionDetail(entry); + } else if (entry.entryType == 'REWARD_SETTLED') { + // 结算 - 显示结算详情 + _showSettlementDetail(entry); + } else if (entry.entryType == 'WITHDRAWAL') { + // 提现 - 显示提现详情 + _showWithdrawalDetail(entry); + } else if (entry.entryType == 'TRANSFER_IN' || entry.entryType == 'TRANSFER_OUT') { + // 转入/转出 - 显示转账详情 + _showTransferDetail(entry); } else if (entry.allocationType != null) { // 权益分配 - 显示权益详情 _showRewardDetail(entry); } } + /// 显示转账详情弹窗(转入/转出) + void _showTransferDetail(LedgerEntry entry) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => _TransferDetailSheet(entry: entry), + ); + } + + /// 显示提现详情弹窗 + void _showWithdrawalDetail(LedgerEntry entry) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => _WithdrawalDetailSheet( + entry: entry, + walletService: ref.read(walletServiceProvider), + ), + ); + } + + /// 显示结算详情弹窗 + void _showSettlementDetail(LedgerEntry entry) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => _SettlementDetailSheet( + entry: entry, + rewardService: ref.read(rewardServiceProvider), + ), + ); + } + /// 显示交易详情弹窗(认种支付) Future _showTransactionDetail(LedgerEntry entry) async { if (entry.refOrderId == null) return; @@ -1617,3 +1666,755 @@ class _RewardDetailSheet extends StatelessWidget { return DateTimeUtils.formatDateTime(date); } } + +/// 结算详情底部弹窗 +class _SettlementDetailSheet extends StatefulWidget { + final LedgerEntry entry; + final RewardService rewardService; + + const _SettlementDetailSheet({ + required this.entry, + required this.rewardService, + }); + + @override + State<_SettlementDetailSheet> createState() => _SettlementDetailSheetState(); +} + +class _SettlementDetailSheetState extends State<_SettlementDetailSheet> { + bool _isLoading = true; + SettlementHistoryItem? _settlementDetail; + + @override + void initState() { + super.initState(); + _loadSettlementDetail(); + } + + /// 加载结算详情 + Future _loadSettlementDetail() async { + try { + // 尝试从结算历史中查找匹配的记录 + final result = await widget.rewardService.getSettlementHistory(page: 1, pageSize: 50); + + // 尝试通过金额和时间匹配 + SettlementHistoryItem? matchedItem; + for (final item in result.data) { + // 比较金额(考虑浮点数精度) + final amountMatch = (item.receivedAmount - widget.entry.amount).abs() < 0.01; + // 比较时间(允许2分钟误差) + final timeDiff = item.createdAt.difference(widget.entry.createdAt).inMinutes.abs(); + final timeMatch = timeDiff < 2; + + if (amountMatch && timeMatch) { + matchedItem = item; + break; + } + } + + if (mounted) { + setState(() { + _settlementDetail = matchedItem; + _isLoading = false; + }); + } + } catch (e) { + debugPrint('[_SettlementDetailSheet] 加载结算详情失败: $e'); + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 拖拽指示器 + Container( + margin: const EdgeInsets.only(top: 12), + width: 40, + height: 4, + decoration: BoxDecoration( + color: const Color(0xFFE0E0E0), + borderRadius: BorderRadius.circular(2), + ), + ), + // 标题区域 + Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: const Color(0x1A4CAF50), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.check_circle_outline, + size: 24, + color: Color(0xFF4CAF50), + ), + ), + const SizedBox(width: 16), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '结算详情', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: Color(0xFF5D4037), + ), + ), + SizedBox(height: 4), + Text( + '结算到钱包余额', + style: TextStyle( + fontSize: 12, + color: Color(0x995D4037), + ), + ), + ], + ), + ), + ], + ), + ), + // 分隔线 + Container( + height: 1, + color: const Color(0x1A8B5A2B), + ), + // 详情内容 + _isLoading + ? const Padding( + padding: EdgeInsets.all(40), + child: Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(Color(0xFFD4AF37)), + ), + ), + ) + : Padding( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + _buildDetailRow( + '结算金额', + '+${_formatAmount(widget.entry.amount)} 绿积分', + color: const Color(0xFF4CAF50), + ), + if (widget.entry.balanceAfter != null) + _buildDetailRow( + '结算后余额', + '${_formatAmount(widget.entry.balanceAfter!)} 绿积分', + ), + _buildDetailRow( + '结算时间', + _formatDateTime(widget.entry.createdAt), + ), + if (_settlementDetail != null) ...[ + _buildDetailRow( + '涉及奖励', + '${_settlementDetail!.rewardEntryCount} 笔', + ), + _buildDetailRow( + '结算状态', + _settlementDetail!.statusName, + color: _settlementDetail!.status == 'SUCCESS' + ? const Color(0xFF4CAF50) + : const Color(0xFFFF9800), + ), + ], + if (widget.entry.memo != null && widget.entry.memo!.isNotEmpty) + _buildDetailRow('备注', widget.entry.memo!), + ], + ), + ), + // 底部关闭按钮 + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), + child: SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () => Navigator.of(context).pop(), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFD4AF37), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text('关闭'), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildDetailRow(String label, String value, {Color? color}) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: const TextStyle( + fontSize: 14, + color: Color(0x995D4037), + ), + ), + Flexible( + child: Text( + value, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: color ?? const Color(0xFF5D4037), + ), + textAlign: TextAlign.right, + ), + ), + ], + ), + ); + } + + String _formatAmount(double amount) { + final formatter = NumberFormat('#,##0.00', 'zh_CN'); + return formatter.format(amount); + } + + String _formatDateTime(DateTime date) { + return DateTimeUtils.formatDateTime(date); + } +} + +/// 提现详情底部弹窗 +class _WithdrawalDetailSheet extends StatefulWidget { + final LedgerEntry entry; + final WalletService walletService; + + const _WithdrawalDetailSheet({ + required this.entry, + required this.walletService, + }); + + @override + State<_WithdrawalDetailSheet> createState() => _WithdrawalDetailSheetState(); +} + +class _WithdrawalDetailSheetState extends State<_WithdrawalDetailSheet> { + bool _isLoading = true; + WithdrawRecord? _withdrawalDetail; + + @override + void initState() { + super.initState(); + _loadWithdrawalDetail(); + } + + /// 加载提现详情 + Future _loadWithdrawalDetail() async { + try { + // 获取提现记录列表 + final records = await widget.walletService.getWithdrawals(); + + // 尝试通过金额和时间匹配 + WithdrawRecord? matchedRecord; + for (final record in records) { + // 比较金额(考虑浮点数精度,使用 netAmount 或 amount) + final amountMatch = (record.netAmount - widget.entry.amount.abs()).abs() < 0.01 || + (record.amount - widget.entry.amount.abs()).abs() < 0.01; + // 比较时间(允许2分钟误差) + final timeDiff = record.createdAt.difference(widget.entry.createdAt).inMinutes.abs(); + final timeMatch = timeDiff < 2; + + if (amountMatch && timeMatch) { + matchedRecord = record; + break; + } + } + + if (mounted) { + setState(() { + _withdrawalDetail = matchedRecord; + _isLoading = false; + }); + } + } catch (e) { + debugPrint('[_WithdrawalDetailSheet] 加载提现详情失败: $e'); + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 拖拽指示器 + Container( + margin: const EdgeInsets.only(top: 12), + width: 40, + height: 4, + decoration: BoxDecoration( + color: const Color(0xFFE0E0E0), + borderRadius: BorderRadius.circular(2), + ), + ), + // 标题区域 + Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: const Color(0x1AE53935), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.arrow_upward, + size: 24, + color: Color(0xFFE53935), + ), + ), + const SizedBox(width: 16), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '提现详情', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: Color(0xFF5D4037), + ), + ), + SizedBox(height: 4), + Text( + '提取到外部钱包', + style: TextStyle( + fontSize: 12, + color: Color(0x995D4037), + ), + ), + ], + ), + ), + ], + ), + ), + // 分隔线 + Container( + height: 1, + color: const Color(0x1A8B5A2B), + ), + // 详情内容 + _isLoading + ? const Padding( + padding: EdgeInsets.all(40), + child: Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(Color(0xFFD4AF37)), + ), + ), + ) + : Padding( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + _buildWithdrawalRow( + '提现金额', + '-${_formatWithdrawalAmount(widget.entry.amount.abs())} 绿积分', + color: const Color(0xFFE53935), + ), + if (widget.entry.balanceAfter != null) + _buildWithdrawalRow( + '提现后余额', + '${_formatWithdrawalAmount(widget.entry.balanceAfter!)} 绿积分', + ), + _buildWithdrawalRow( + '提现时间', + _formatWithdrawalDateTime(widget.entry.createdAt), + ), + if (_withdrawalDetail != null) ...[ + _buildWithdrawalRow( + '订单编号', + _withdrawalDetail!.orderNo, + ), + _buildWithdrawalRow( + '提现状态', + _getWithdrawalStatusName(_withdrawalDetail!.status), + color: _getWithdrawalStatusColor(_withdrawalDetail!.status), + ), + _buildWithdrawalRow( + '提现网络', + _getWithdrawalChainName(_withdrawalDetail!.chainType), + ), + if (_withdrawalDetail!.fee > 0) + _buildWithdrawalRow( + '手续费', + '${_formatWithdrawalAmount(_withdrawalDetail!.fee)} 绿积分', + ), + _buildWithdrawalRow( + '到账地址', + _getWithdrawalShortAddress(_withdrawalDetail!.toAddress), + ), + if (_withdrawalDetail!.txHash != null && + _withdrawalDetail!.txHash!.isNotEmpty) + _buildWithdrawalRow( + '交易哈希', + _getWithdrawalShortAddress(_withdrawalDetail!.txHash!), + ), + ], + if (widget.entry.memo != null && widget.entry.memo!.isNotEmpty) + _buildWithdrawalRow('备注', widget.entry.memo!), + ], + ), + ), + // 底部关闭按钮 + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), + child: SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () => Navigator.of(context).pop(), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFD4AF37), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text('关闭'), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildWithdrawalRow(String label, String value, {Color? color}) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: const TextStyle( + fontSize: 14, + color: Color(0x995D4037), + ), + ), + Flexible( + child: Text( + value, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: color ?? const Color(0xFF5D4037), + ), + textAlign: TextAlign.right, + ), + ), + ], + ), + ); + } + + String _formatWithdrawalAmount(double amount) { + final formatter = NumberFormat('#,##0.00', 'zh_CN'); + return formatter.format(amount); + } + + String _formatWithdrawalDateTime(DateTime date) { + return DateTimeUtils.formatDateTime(date); + } + + String _getWithdrawalStatusName(String status) { + switch (status) { + case 'PENDING': + return '待处理'; + case 'FROZEN': + return '已冻结'; + case 'CONFIRMED': + return '已确认'; + case 'COMPLETED': + return '已完成'; + case 'FAILED': + return '失败'; + case 'CANCELLED': + return '已取消'; + default: + return status; + } + } + + Color _getWithdrawalStatusColor(String status) { + switch (status) { + case 'COMPLETED': + return const Color(0xFF4CAF50); + case 'PENDING': + case 'FROZEN': + case 'CONFIRMED': + return const Color(0xFFFF9800); + case 'FAILED': + case 'CANCELLED': + return const Color(0xFFE53935); + default: + return const Color(0xFF5D4037); + } + } + + String _getWithdrawalChainName(String chainType) { + switch (chainType) { + case 'KAVA': + return 'KAVA'; + case 'BSC': + return 'BSC'; + case 'INTERNAL': + return '内部划转'; + default: + return chainType; + } + } + + String _getWithdrawalShortAddress(String address) { + if (address.length <= 16) return address; + return '${address.substring(0, 8)}...${address.substring(address.length - 6)}'; + } +} + +/// 转账详情底部弹窗(转入/转出) +class _TransferDetailSheet extends StatelessWidget { + final LedgerEntry entry; + + const _TransferDetailSheet({required this.entry}); + + @override + Widget build(BuildContext context) { + final isTransferIn = entry.entryType == 'TRANSFER_IN'; + final isIncome = entry.isIncome; + + return Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 拖拽指示器 + Container( + margin: const EdgeInsets.only(top: 12), + width: 40, + height: 4, + decoration: BoxDecoration( + color: const Color(0xFFE0E0E0), + borderRadius: BorderRadius.circular(2), + ), + ), + // 标题区域 + Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: isIncome + ? const Color(0x1A4CAF50) + : const Color(0x1AE53935), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + isTransferIn ? Icons.call_received : Icons.call_made, + size: 24, + color: isIncome ? const Color(0xFF4CAF50) : const Color(0xFFE53935), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isTransferIn ? '转入详情' : '转出详情', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: Color(0xFF5D4037), + ), + ), + const SizedBox(height: 4), + Text( + isTransferIn ? '来自其他用户的转账' : '转账给其他用户', + style: const TextStyle( + fontSize: 12, + color: Color(0x995D4037), + ), + ), + ], + ), + ), + ], + ), + ), + // 分隔线 + Container( + height: 1, + color: const Color(0x1A8B5A2B), + ), + // 详情内容 + Padding( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + _buildTransferRow( + isTransferIn ? '转入金额' : '转出金额', + '${isIncome ? '+' : ''}${_formatTransferAmount(entry.amount)} 绿积分', + color: isIncome ? const Color(0xFF4CAF50) : const Color(0xFFE53935), + ), + if (entry.balanceAfter != null) + _buildTransferRow( + isTransferIn ? '转入后余额' : '转出后余额', + '${_formatTransferAmount(entry.balanceAfter!)} 绿积分', + ), + _buildTransferRow( + isTransferIn ? '转入时间' : '转出时间', + _formatTransferDateTime(entry.createdAt), + ), + // 对方账户信息 + if (entry.counterpartyAccountSequence != null) + _buildTransferRow( + isTransferIn ? '来自账户' : '转至账户', + entry.counterpartyAccountSequence!, + ), + // 手续费(仅转出有) + if (entry.transferFee != null && entry.transferFee! > 0) + _buildTransferRow( + '手续费', + '${_formatTransferAmount(entry.transferFee!)} 绿积分', + ), + // 关联订单 + if (entry.refOrderId != null && entry.refOrderId!.isNotEmpty) + _buildTransferRow('订单编号', entry.refOrderId!), + // 交易哈希 + if (entry.refTxHash != null && entry.refTxHash!.isNotEmpty) + _buildTransferRow( + '交易哈希', + _getTransferShortAddress(entry.refTxHash!), + ), + // 备注 + if (entry.memo != null && entry.memo!.isNotEmpty) + _buildTransferRow('备注', entry.memo!), + ], + ), + ), + // 底部关闭按钮 + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), + child: SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () => Navigator.of(context).pop(), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFD4AF37), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text('关闭'), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildTransferRow(String label, String value, {Color? color}) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: const TextStyle( + fontSize: 14, + color: Color(0x995D4037), + ), + ), + Flexible( + child: Text( + value, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: color ?? const Color(0xFF5D4037), + ), + textAlign: TextAlign.right, + ), + ), + ], + ), + ); + } + + String _formatTransferAmount(double amount) { + final formatter = NumberFormat('#,##0.00', 'zh_CN'); + return formatter.format(amount); + } + + String _formatTransferDateTime(DateTime date) { + return DateTimeUtils.formatDateTime(date); + } + + String _getTransferShortAddress(String address) { + if (address.length <= 16) return address; + return '${address.substring(0, 8)}...${address.substring(address.length - 6)}'; + } +}