|
|
@ -4,7 +4,17 @@
|
|||
"Bash(dir:*)",
|
||||
"Bash(tree:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(ls -la \"c:\\Users\\dong\\Desktop\\rwadurian\\backend\\services\"\" 2>/dev/null || dir \"c:UsersdongDesktoprwadurianbackendservices\"\")"
|
||||
"Bash(ls -la \"c:\\Users\\dong\\Desktop\\rwadurian\\backend\\services\"\" 2>/dev/null || dir \"c:UsersdongDesktoprwadurianbackendservices\"\")",
|
||||
"Bash(mkdir:*)",
|
||||
"Bash(npm run build:*)",
|
||||
"Bash(npx nest build)",
|
||||
"Bash(npm install)",
|
||||
"Bash(npx prisma migrate dev:*)",
|
||||
"Bash(npx jest:*)",
|
||||
"Bash(flutter test:*)",
|
||||
"Bash(flutter analyze:*)",
|
||||
"Bash(findstr:*)",
|
||||
"Bash(flutter pub get:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
|
|
|||
|
|
@ -32,3 +32,9 @@ WALLET_ENCRYPTION_SALT="dev-wallet-salt"
|
|||
# 调用路径: identity-service → mpc-service (NestJS) → mpc-system (Go)
|
||||
MPC_SERVICE_URL="http://localhost:3001"
|
||||
MPC_MODE="local" # local 使用本地模拟,remote 调用 mpc-service
|
||||
|
||||
# Backup Service (MPC 备份分片存储)
|
||||
# 安全要求: 必须部署在与 identity-service 不同的物理服务器上!
|
||||
BACKUP_SERVICE_URL="http://localhost:3002"
|
||||
BACKUP_SERVICE_ENABLED="true"
|
||||
SERVICE_JWT_SECRET="dev-service-jwt-secret-key"
|
||||
|
|
|
|||
|
|
@ -32,3 +32,9 @@ WALLET_ENCRYPTION_SALT="rwa-wallet-salt-change-in-production"
|
|||
# 调用路径: identity-service → mpc-service (NestJS) → mpc-system (Go)
|
||||
MPC_SERVICE_URL="http://localhost:3001"
|
||||
MPC_MODE="local" # local 使用本地模拟,remote 调用 mpc-service
|
||||
|
||||
# Backup Service (MPC 备份分片存储)
|
||||
# 安全要求: 必须部署在与 identity-service 不同的物理服务器上!
|
||||
BACKUP_SERVICE_URL="http://backup-server:3002"
|
||||
BACKUP_SERVICE_ENABLED="true"
|
||||
SERVICE_JWT_SECRET="your-service-jwt-secret-change-in-production"
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@
|
|||
"ethers": "^6.9.0",
|
||||
"ioredis": "^5.3.2",
|
||||
"kafkajs": "^2.2.4",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rxjs": "^7.8.1",
|
||||
|
|
@ -59,6 +60,7 @@
|
|||
"@types/express": "^4.17.17",
|
||||
"@types/jest": "^29.5.2",
|
||||
"@types/node": "^20.3.1",
|
||||
"@types/jsonwebtoken": "^9.0.0",
|
||||
"@types/passport-jwt": "^4.0.0",
|
||||
"@types/supertest": "^6.0.0",
|
||||
"@types/uuid": "^9.0.0",
|
||||
|
|
|
|||
|
|
@ -226,3 +226,26 @@ model MpcSession {
|
|||
@@index([createdAt], name: "idx_session_created")
|
||||
@@map("mpc_sessions")
|
||||
}
|
||||
|
||||
// 推荐链接 - 用于追踪不同渠道的邀请
|
||||
model ReferralLink {
|
||||
linkId BigInt @id @default(autoincrement()) @map("link_id")
|
||||
userId BigInt @map("user_id")
|
||||
referralCode String @map("referral_code") @db.VarChar(10)
|
||||
|
||||
shortCode String @unique @map("short_code") @db.VarChar(10) // 短链码
|
||||
channel String? @db.VarChar(50) // 渠道: wechat, telegram, twitter, etc.
|
||||
campaignId String? @map("campaign_id") @db.VarChar(50) // 活动ID
|
||||
|
||||
clickCount Int @default(0) @map("click_count") // 点击次数
|
||||
registerCount Int @default(0) @map("register_count") // 注册转化数
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
expiresAt DateTime? @map("expires_at") // 过期时间 (可选)
|
||||
|
||||
@@index([userId], name: "idx_referral_link_user")
|
||||
@@index([referralCode], name: "idx_referral_link_code")
|
||||
@@index([channel], name: "idx_referral_link_channel")
|
||||
@@index([createdAt], name: "idx_referral_link_created")
|
||||
@@map("referral_links")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { UserAccountController } from './controllers/user-account.controller';
|
||||
import { AuthController } from './controllers/auth.controller';
|
||||
import { ReferralsController } from './controllers/referrals.controller';
|
||||
import { DepositController } from './controllers/deposit.controller';
|
||||
import { ApplicationModule } from '@/application/application.module';
|
||||
|
||||
@Module({
|
||||
imports: [ApplicationModule],
|
||||
controllers: [UserAccountController, AuthController],
|
||||
controllers: [UserAccountController, AuthController, ReferralsController, DepositController],
|
||||
})
|
||||
export class ApiModule {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
import { Controller, Get, UseGuards, Request, Logger } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
||||
import { DepositService } from '@/application/services/deposit.service';
|
||||
|
||||
/**
|
||||
* 充值地址响应 DTO
|
||||
*/
|
||||
class DepositAddressResponseDto {
|
||||
kavaAddress: string | null;
|
||||
bscAddress: string | null;
|
||||
isValid: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* USDT 余额响应 DTO
|
||||
*/
|
||||
class UsdtBalanceDto {
|
||||
chainType: string;
|
||||
address: string;
|
||||
balance: string;
|
||||
rawBalance: string;
|
||||
decimals: number;
|
||||
}
|
||||
|
||||
class BalanceResponseDto {
|
||||
kava: UsdtBalanceDto | null;
|
||||
bsc: UsdtBalanceDto | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 充值控制器
|
||||
*
|
||||
* 提供充值地址获取和余额查询 API
|
||||
*/
|
||||
@ApiTags('Deposit')
|
||||
@Controller('api/deposit')
|
||||
export class DepositController {
|
||||
private readonly logger = new Logger(DepositController.name);
|
||||
|
||||
constructor(private readonly depositService: DepositService) {}
|
||||
|
||||
/**
|
||||
* 获取充值地址
|
||||
*
|
||||
* 返回用户的 KAVA 和 BSC 充值地址
|
||||
* 会验证地址签名,确保地址未被篡改
|
||||
*/
|
||||
@Get('addresses')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '获取充值地址' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '成功获取充值地址',
|
||||
type: DepositAddressResponseDto,
|
||||
})
|
||||
@ApiResponse({ status: 400, description: '获取失败' })
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
async getDepositAddresses(@Request() req: any): Promise<DepositAddressResponseDto> {
|
||||
const userId = req.user.userId;
|
||||
this.logger.log(`获取充值地址: userId=${userId}`);
|
||||
|
||||
return this.depositService.getDepositAddresses(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询 USDT 余额
|
||||
*
|
||||
* 实时查询 KAVA 和 BSC 链上的 USDT 余额
|
||||
*/
|
||||
@Get('balances')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '查询 USDT 余额' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '成功获取余额',
|
||||
type: BalanceResponseDto,
|
||||
})
|
||||
@ApiResponse({ status: 400, description: '查询失败' })
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
async getUsdtBalances(@Request() req: any): Promise<BalanceResponseDto> {
|
||||
const userId = req.user.userId;
|
||||
this.logger.log(`查询 USDT 余额: userId=${userId}`);
|
||||
|
||||
return this.depositService.getUsdtBalances(userId);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import { Controller, Get, Post, Body, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger';
|
||||
import { UserApplicationService } from '@/application/services/user-application.service';
|
||||
import { JwtAuthGuard, Public, CurrentUser, CurrentUserData } from '@/shared/guards/jwt-auth.guard';
|
||||
import {
|
||||
ValidateReferralCodeQuery, GetReferralStatsQuery, GenerateReferralLinkCommand,
|
||||
} from '@/application/commands';
|
||||
import {
|
||||
GenerateReferralLinkDto, MeResponseDto, ReferralValidationResponseDto,
|
||||
ReferralLinkResponseDto, ReferralStatsResponseDto,
|
||||
} from '@/api/dto';
|
||||
|
||||
@ApiTags('Referrals')
|
||||
@Controller()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ReferralsController {
|
||||
constructor(private readonly userService: UserApplicationService) {}
|
||||
|
||||
/**
|
||||
* GET /api/me - 获取当前登录用户信息 + 推荐码
|
||||
*/
|
||||
@Get('me')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '获取当前登录用户信息', description: '返回用户基本信息、推荐码和推荐链接' })
|
||||
@ApiResponse({ status: 200, type: MeResponseDto })
|
||||
async getMe(@CurrentUser() user: CurrentUserData): Promise<MeResponseDto> {
|
||||
return this.userService.getMe(user.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/referrals/validate - 校验推荐码是否合法
|
||||
*/
|
||||
@Public()
|
||||
@Get('referrals/validate')
|
||||
@ApiOperation({ summary: '校验推荐码', description: '创建账号时校验推荐码是否合法' })
|
||||
@ApiQuery({ name: 'code', description: '推荐码', required: true })
|
||||
@ApiResponse({ status: 200, type: ReferralValidationResponseDto })
|
||||
async validateReferralCode(@Query('code') code: string): Promise<ReferralValidationResponseDto> {
|
||||
return this.userService.validateReferralCode(new ValidateReferralCodeQuery(code));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/referrals/links - 为当前登录用户生成短链/渠道链接
|
||||
*/
|
||||
@Post('referrals/links')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '生成推荐链接', description: '为当前登录用户生成短链/渠道链接' })
|
||||
@ApiResponse({ status: 201, type: ReferralLinkResponseDto })
|
||||
async generateReferralLink(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Body() dto: GenerateReferralLinkDto,
|
||||
): Promise<ReferralLinkResponseDto> {
|
||||
return this.userService.generateReferralLink(
|
||||
new GenerateReferralLinkCommand(user.userId, dto.channel, dto.campaignId),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/referrals/stats - 查询登录用户的邀请记录
|
||||
*/
|
||||
@Get('referrals/stats')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '查询邀请统计', description: '查询登录用户的邀请记录和统计数据' })
|
||||
@ApiResponse({ status: 200, type: ReferralStatsResponseDto })
|
||||
async getReferralStats(@CurrentUser() user: CurrentUserData): Promise<ReferralStatsResponseDto> {
|
||||
return this.userService.getReferralStats(new GetReferralStatsQuery(user.userId));
|
||||
}
|
||||
}
|
||||
|
|
@ -186,3 +186,136 @@ export class LoginResponseDto {
|
|||
@ApiProperty()
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
// ============ Referral DTOs ============
|
||||
|
||||
export class GenerateReferralLinkDto {
|
||||
@ApiPropertyOptional({ description: '渠道标识: wechat, telegram, twitter 等' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
channel?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '活动ID' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
campaignId?: string;
|
||||
}
|
||||
|
||||
export class MeResponseDto {
|
||||
@ApiProperty()
|
||||
userId: string;
|
||||
|
||||
@ApiProperty({ description: '账户序列号' })
|
||||
accountSequence: number;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
phoneNumber: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
nickname: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
avatarUrl: string | null;
|
||||
|
||||
@ApiProperty({ description: '推荐码' })
|
||||
referralCode: string;
|
||||
|
||||
@ApiProperty({ description: '完整推荐链接' })
|
||||
referralLink: string;
|
||||
|
||||
@ApiProperty({ description: '钱包地址列表' })
|
||||
walletAddresses: Array<{ chainType: string; address: string }>;
|
||||
|
||||
@ApiProperty()
|
||||
kycStatus: string;
|
||||
|
||||
@ApiProperty()
|
||||
status: string;
|
||||
|
||||
@ApiProperty()
|
||||
registeredAt: Date;
|
||||
}
|
||||
|
||||
export class ReferralValidationResponseDto {
|
||||
@ApiProperty({ description: '推荐码是否有效' })
|
||||
valid: boolean;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
referralCode?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '邀请人信息' })
|
||||
inviterInfo?: {
|
||||
accountSequence: number;
|
||||
nickname: string;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
|
||||
@ApiPropertyOptional({ description: '错误信息' })
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export class ReferralLinkResponseDto {
|
||||
@ApiProperty()
|
||||
linkId: string;
|
||||
|
||||
@ApiProperty()
|
||||
referralCode: string;
|
||||
|
||||
@ApiProperty({ description: '短链' })
|
||||
shortUrl: string;
|
||||
|
||||
@ApiProperty({ description: '完整链接' })
|
||||
fullUrl: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
channel: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
campaignId: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export class InviteRecordDto {
|
||||
@ApiProperty()
|
||||
accountSequence: number;
|
||||
|
||||
@ApiProperty()
|
||||
nickname: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
avatarUrl: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
registeredAt: Date;
|
||||
|
||||
@ApiProperty({ description: '1=直接邀请, 2=间接邀请' })
|
||||
level: number;
|
||||
}
|
||||
|
||||
export class ReferralStatsResponseDto {
|
||||
@ApiProperty()
|
||||
referralCode: string;
|
||||
|
||||
@ApiProperty({ description: '总邀请人数' })
|
||||
totalInvites: number;
|
||||
|
||||
@ApiProperty({ description: '直接邀请人数' })
|
||||
directInvites: number;
|
||||
|
||||
@ApiProperty({ description: '间接邀请人数 (二级)' })
|
||||
indirectInvites: number;
|
||||
|
||||
@ApiProperty({ description: '今日邀请' })
|
||||
todayInvites: number;
|
||||
|
||||
@ApiProperty({ description: '本周邀请' })
|
||||
thisWeekInvites: number;
|
||||
|
||||
@ApiProperty({ description: '本月邀请' })
|
||||
thisMonthInvites: number;
|
||||
|
||||
@ApiProperty({ description: '最近邀请记录', type: [InviteRecordDto] })
|
||||
recentInvites: InviteRecordDto[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Module, Global } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { APP_FILTER, APP_INTERCEPTOR, APP_GUARD } from '@nestjs/core';
|
||||
|
||||
// Config
|
||||
|
|
@ -9,6 +10,7 @@ import { appConfig, databaseConfig, jwtConfig, redisConfig, kafkaConfig, smsConf
|
|||
// Controllers
|
||||
import { UserAccountController } from '@/api/controllers/user-account.controller';
|
||||
import { HealthController } from '@/api/controllers/health.controller';
|
||||
import { ReferralsController } from '@/api/controllers/referrals.controller';
|
||||
|
||||
// Application Services
|
||||
import { UserApplicationService } from '@/application/services/user-application.service';
|
||||
|
|
@ -19,13 +21,18 @@ import {
|
|||
AccountSequenceGeneratorService, UserValidatorService, WalletGeneratorService,
|
||||
} from '@/domain/services';
|
||||
import { USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
||||
import { MPC_KEY_SHARE_REPOSITORY } from '@/domain/repositories/mpc-key-share.repository.interface';
|
||||
|
||||
// Infrastructure
|
||||
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
|
||||
import { UserAccountRepositoryImpl } from '@/infrastructure/persistence/repositories/user-account.repository.impl';
|
||||
import { MpcKeyShareRepositoryImpl } from '@/infrastructure/persistence/repositories/mpc-key-share.repository.impl';
|
||||
import { RedisService } from '@/infrastructure/redis/redis.service';
|
||||
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
|
||||
import { SmsService } from '@/infrastructure/external/sms/sms.service';
|
||||
import { MpcClientService, MpcWalletService } from '@/infrastructure/external/mpc';
|
||||
import { BackupClientService, MpcShareStorageService } from '@/infrastructure/external/backup';
|
||||
import { WalletGeneratorServiceImpl } from '@/infrastructure/external/blockchain/wallet-generator.service.impl';
|
||||
|
||||
// Shared
|
||||
import { GlobalExceptionFilter, TransformInterceptor } from '@/shared/filters/global-exception.filter';
|
||||
|
|
@ -34,8 +41,38 @@ import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
|||
// ============ Infrastructure Module ============
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService, RedisService, EventPublisherService, SmsService],
|
||||
exports: [PrismaService, RedisService, EventPublisherService, SmsService],
|
||||
imports: [
|
||||
HttpModule.register({
|
||||
timeout: 300000,
|
||||
maxRedirects: 5,
|
||||
}),
|
||||
],
|
||||
providers: [
|
||||
PrismaService,
|
||||
RedisService,
|
||||
EventPublisherService,
|
||||
SmsService,
|
||||
MpcClientService,
|
||||
MpcWalletService,
|
||||
BackupClientService,
|
||||
MpcShareStorageService,
|
||||
// WalletGeneratorService 抽象类由 WalletGeneratorServiceImpl 实现
|
||||
WalletGeneratorServiceImpl,
|
||||
{ provide: WalletGeneratorService, useExisting: WalletGeneratorServiceImpl },
|
||||
{ provide: MPC_KEY_SHARE_REPOSITORY, useClass: MpcKeyShareRepositoryImpl },
|
||||
],
|
||||
exports: [
|
||||
PrismaService,
|
||||
RedisService,
|
||||
EventPublisherService,
|
||||
SmsService,
|
||||
MpcClientService,
|
||||
MpcWalletService,
|
||||
BackupClientService,
|
||||
MpcShareStorageService,
|
||||
WalletGeneratorService,
|
||||
MPC_KEY_SHARE_REPOSITORY,
|
||||
],
|
||||
})
|
||||
export class InfrastructureModule {}
|
||||
|
||||
|
|
@ -46,13 +83,13 @@ export class InfrastructureModule {}
|
|||
{ provide: USER_ACCOUNT_REPOSITORY, useClass: UserAccountRepositoryImpl },
|
||||
AccountSequenceGeneratorService,
|
||||
UserValidatorService,
|
||||
WalletGeneratorService,
|
||||
// WalletGeneratorService 由 InfrastructureModule 提供
|
||||
],
|
||||
exports: [
|
||||
USER_ACCOUNT_REPOSITORY,
|
||||
AccountSequenceGeneratorService,
|
||||
UserValidatorService,
|
||||
WalletGeneratorService,
|
||||
// WalletGeneratorService 由 InfrastructureModule 导出
|
||||
],
|
||||
})
|
||||
export class DomainModule {}
|
||||
|
|
@ -68,7 +105,7 @@ export class ApplicationModule {}
|
|||
// ============ API Module ============
|
||||
@Module({
|
||||
imports: [ApplicationModule],
|
||||
controllers: [HealthController, UserAccountController],
|
||||
controllers: [HealthController, UserAccountController, ReferralsController],
|
||||
})
|
||||
export class ApiModule {}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { UserApplicationService } from './services/user-application.service';
|
||||
import { TokenService } from './services/token.service';
|
||||
import { DepositService } from './services/deposit.service';
|
||||
import { AutoCreateAccountHandler } from './commands/auto-create-account/auto-create-account.handler';
|
||||
import { RecoverByMnemonicHandler } from './commands/recover-by-mnemonic/recover-by-mnemonic.handler';
|
||||
import { RecoverByPhoneHandler } from './commands/recover-by-phone/recover-by-phone.handler';
|
||||
|
|
@ -15,6 +16,7 @@ import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
|
|||
providers: [
|
||||
UserApplicationService,
|
||||
TokenService,
|
||||
DepositService,
|
||||
AutoCreateAccountHandler,
|
||||
RecoverByMnemonicHandler,
|
||||
RecoverByPhoneHandler,
|
||||
|
|
@ -25,6 +27,7 @@ import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
|
|||
exports: [
|
||||
UserApplicationService,
|
||||
TokenService,
|
||||
DepositService,
|
||||
AutoCreateAccountHandler,
|
||||
RecoverByMnemonicHandler,
|
||||
RecoverByPhoneHandler,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { AutoCreateAccountCommand } from './auto-create-account.command';
|
||||
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
||||
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
|
||||
|
|
@ -8,9 +8,12 @@ import { TokenService } from '@/application/services/token.service';
|
|||
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
|
||||
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
||||
import { AutoCreateAccountResult } from '../index';
|
||||
import { MpcShareStorageService } from '@/infrastructure/external/backup/mpc-share-storage.service';
|
||||
|
||||
@Injectable()
|
||||
export class AutoCreateAccountHandler {
|
||||
private readonly logger = new Logger(AutoCreateAccountHandler.name);
|
||||
|
||||
constructor(
|
||||
@Inject(USER_ACCOUNT_REPOSITORY)
|
||||
private readonly userRepository: UserAccountRepository,
|
||||
|
|
@ -19,6 +22,7 @@ export class AutoCreateAccountHandler {
|
|||
private readonly walletGenerator: WalletGeneratorService,
|
||||
private readonly tokenService: TokenService,
|
||||
private readonly eventPublisher: EventPublisherService,
|
||||
private readonly mpcShareStorage: MpcShareStorageService,
|
||||
) {}
|
||||
|
||||
async execute(command: AutoCreateAccountCommand): Promise<AutoCreateAccountResult> {
|
||||
|
|
@ -45,11 +49,27 @@ export class AutoCreateAccountHandler {
|
|||
city: CityCode.create(command.cityCode || 'DEFAULT'),
|
||||
});
|
||||
|
||||
const { mnemonic, wallets } = this.walletGenerator.generateWalletSystem({
|
||||
userId: account.userId,
|
||||
// 使用 MPC 2-of-3 生成三链钱包
|
||||
this.logger.log(`Generating MPC wallet for user=${account.userId.toString()}`);
|
||||
const mpcResult = await this.walletGenerator.generateMpcWalletSystem({
|
||||
userId: account.userId.toString(),
|
||||
deviceId: command.deviceId,
|
||||
});
|
||||
|
||||
// 将 MPC 钱包信息转换为领域实体
|
||||
const wallets = this.walletGenerator.convertToWalletEntities(
|
||||
account.userId,
|
||||
mpcResult.wallets,
|
||||
);
|
||||
|
||||
// 保存备份分片到备份服务
|
||||
this.logger.log(`Storing backup share for user=${account.userId.toString()}`);
|
||||
await this.mpcShareStorage.storeBackupShare({
|
||||
userId: account.userId.toString(),
|
||||
shareData: mpcResult.backupShareData,
|
||||
publicKey: mpcResult.publicKey,
|
||||
});
|
||||
|
||||
account.bindMultipleWalletAddresses(wallets);
|
||||
await this.userRepository.save(account);
|
||||
await this.userRepository.saveWallets(account.userId, Array.from(wallets.values()));
|
||||
|
|
@ -63,11 +83,15 @@ export class AutoCreateAccountHandler {
|
|||
await this.eventPublisher.publishAll(account.domainEvents);
|
||||
account.clearDomainEvents();
|
||||
|
||||
this.logger.log(`Account created successfully: userId=${account.userId.toString()}, seq=${account.accountSequence.value}`);
|
||||
|
||||
return {
|
||||
userId: account.userId.toString(),
|
||||
accountSequence: account.accountSequence.value,
|
||||
referralCode: account.referralCode.value,
|
||||
mnemonic: mnemonic.value,
|
||||
mnemonic: '', // MPC 模式下不再使用助记词
|
||||
clientShareData: mpcResult.clientShareData, // 客户端需安全存储此分片
|
||||
publicKey: mpcResult.publicKey,
|
||||
walletAddresses: {
|
||||
kava: wallets.get(ChainType.KAVA)!.address,
|
||||
dst: wallets.get(ChainType.DST)!.address,
|
||||
|
|
|
|||
|
|
@ -129,6 +129,22 @@ export class GetUserByReferralCodeQuery {
|
|||
constructor(public readonly referralCode: string) {}
|
||||
}
|
||||
|
||||
export class ValidateReferralCodeQuery {
|
||||
constructor(public readonly referralCode: string) {}
|
||||
}
|
||||
|
||||
export class GetReferralStatsQuery {
|
||||
constructor(public readonly userId: string) {}
|
||||
}
|
||||
|
||||
export class GenerateReferralLinkCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly channel?: string, // 渠道标识: wechat, telegram, twitter 等
|
||||
public readonly campaignId?: string, // 活动ID
|
||||
) {}
|
||||
}
|
||||
|
||||
// ============ Results ============
|
||||
export interface AutoCreateAccountResult {
|
||||
userId: string;
|
||||
|
|
@ -206,3 +222,55 @@ export interface UserBriefDTO {
|
|||
nickname: string;
|
||||
avatarUrl: string | null;
|
||||
}
|
||||
|
||||
export interface ReferralCodeValidationResult {
|
||||
valid: boolean;
|
||||
referralCode?: string;
|
||||
inviterInfo?: {
|
||||
accountSequence: number;
|
||||
nickname: string;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface ReferralLinkResult {
|
||||
linkId: string;
|
||||
referralCode: string;
|
||||
shortUrl: string;
|
||||
fullUrl: string;
|
||||
channel: string | null;
|
||||
campaignId: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface ReferralStatsResult {
|
||||
referralCode: string;
|
||||
totalInvites: number; // 总邀请人数
|
||||
directInvites: number; // 直接邀请人数
|
||||
indirectInvites: number; // 间接邀请人数 (二级)
|
||||
todayInvites: number; // 今日邀请
|
||||
thisWeekInvites: number; // 本周邀请
|
||||
thisMonthInvites: number; // 本月邀请
|
||||
recentInvites: Array<{ // 最近邀请记录
|
||||
accountSequence: number;
|
||||
nickname: string;
|
||||
avatarUrl: string | null;
|
||||
registeredAt: Date;
|
||||
level: number; // 1=直接, 2=间接
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface MeResult {
|
||||
userId: string;
|
||||
accountSequence: number;
|
||||
phoneNumber: string | null;
|
||||
nickname: string;
|
||||
avatarUrl: string | null;
|
||||
referralCode: string;
|
||||
referralLink: string; // 完整推荐链接
|
||||
walletAddresses: Array<{ chainType: string; address: string }>;
|
||||
kycStatus: string;
|
||||
status: string;
|
||||
registeredAt: Date;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,194 @@
|
|||
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
|
||||
import { UserAccountRepositoryImpl } from '@/infrastructure/persistence/repositories/user-account.repository.impl';
|
||||
import { BlockchainQueryService, UsdtBalance } from '@/infrastructure/external/blockchain/blockchain-query.service';
|
||||
import { UserId, ChainType } from '@/domain/value-objects';
|
||||
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
|
||||
|
||||
/**
|
||||
* 充值地址响应
|
||||
*/
|
||||
export interface DepositAddressResponse {
|
||||
kavaAddress: string | null;
|
||||
bscAddress: string | null;
|
||||
isValid: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 余额响应
|
||||
*/
|
||||
export interface BalanceResponse {
|
||||
kava: UsdtBalance | null;
|
||||
bsc: UsdtBalance | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 充值服务
|
||||
*
|
||||
* 提供充值地址获取和余额查询功能
|
||||
*/
|
||||
@Injectable()
|
||||
export class DepositService {
|
||||
private readonly logger = new Logger(DepositService.name);
|
||||
|
||||
constructor(
|
||||
private readonly userAccountRepository: UserAccountRepositoryImpl,
|
||||
private readonly blockchainQueryService: BlockchainQueryService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 获取用户的充值地址
|
||||
*
|
||||
* 验证地址签名是否有效,防止使用被篡改的地址
|
||||
*/
|
||||
async getDepositAddresses(userId: string): Promise<DepositAddressResponse> {
|
||||
this.logger.log(`获取充值地址: userId=${userId}`);
|
||||
|
||||
const userAccount = await this.userAccountRepository.findById(UserId.create(userId));
|
||||
if (!userAccount) {
|
||||
throw new BadRequestException('用户不存在');
|
||||
}
|
||||
|
||||
const walletAddresses = userAccount.getAllWalletAddresses();
|
||||
if (!walletAddresses || walletAddresses.length === 0) {
|
||||
return {
|
||||
kavaAddress: null,
|
||||
bscAddress: null,
|
||||
isValid: false,
|
||||
message: '充值账户异常:未找到钱包地址,请联系客服',
|
||||
};
|
||||
}
|
||||
|
||||
// 查找 KAVA 和 BSC 地址
|
||||
const kavaWallet = walletAddresses.find((w: WalletAddress) => w.chainType === ChainType.KAVA);
|
||||
const bscWallet = walletAddresses.find((w: WalletAddress) => w.chainType === ChainType.BSC);
|
||||
|
||||
// 验证地址签名
|
||||
const validationResults = await Promise.all([
|
||||
kavaWallet ? this.validateWalletAddress(kavaWallet) : Promise.resolve(true),
|
||||
bscWallet ? this.validateWalletAddress(bscWallet) : Promise.resolve(true),
|
||||
]);
|
||||
|
||||
const [kavaValid, bscValid] = validationResults;
|
||||
|
||||
// 如果有任何一个地址验证失败,返回错误
|
||||
if (!kavaValid || !bscValid) {
|
||||
this.logger.warn(`地址验证失败: userId=${userId}, kavaValid=${kavaValid}, bscValid=${bscValid}`);
|
||||
return {
|
||||
kavaAddress: null,
|
||||
bscAddress: null,
|
||||
isValid: false,
|
||||
message: '充值账户异常:地址验证失败,请重试或联系客服',
|
||||
};
|
||||
}
|
||||
|
||||
// 检查地址状态
|
||||
if (kavaWallet && kavaWallet.status !== 'ACTIVE') {
|
||||
this.logger.warn(`KAVA 地址状态异常: userId=${userId}, status=${kavaWallet.status}`);
|
||||
return {
|
||||
kavaAddress: null,
|
||||
bscAddress: null,
|
||||
isValid: false,
|
||||
message: '充值账户异常:KAVA 地址已禁用,请联系客服',
|
||||
};
|
||||
}
|
||||
|
||||
if (bscWallet && bscWallet.status !== 'ACTIVE') {
|
||||
this.logger.warn(`BSC 地址状态异常: userId=${userId}, status=${bscWallet.status}`);
|
||||
return {
|
||||
kavaAddress: null,
|
||||
bscAddress: null,
|
||||
isValid: false,
|
||||
message: '充值账户异常:BSC 地址已禁用,请联系客服',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kavaAddress: kavaWallet?.address || null,
|
||||
bscAddress: bscWallet?.address || null,
|
||||
isValid: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询用户的 USDT 余额
|
||||
*/
|
||||
async getUsdtBalances(userId: string): Promise<BalanceResponse> {
|
||||
this.logger.log(`查询 USDT 余额: userId=${userId}`);
|
||||
|
||||
// 先获取充值地址
|
||||
const depositAddresses = await this.getDepositAddresses(userId);
|
||||
|
||||
if (!depositAddresses.isValid) {
|
||||
throw new BadRequestException(depositAddresses.message || '获取充值地址失败');
|
||||
}
|
||||
|
||||
const results: BalanceResponse = {
|
||||
kava: null,
|
||||
bsc: null,
|
||||
};
|
||||
|
||||
// 查询 KAVA 余额
|
||||
if (depositAddresses.kavaAddress) {
|
||||
try {
|
||||
results.kava = await this.blockchainQueryService.getUsdtBalance(
|
||||
'KAVA',
|
||||
depositAddresses.kavaAddress,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(`查询 KAVA USDT 余额失败: ${error.message}`);
|
||||
results.kava = {
|
||||
chainType: 'KAVA',
|
||||
address: depositAddresses.kavaAddress,
|
||||
balance: '0',
|
||||
rawBalance: '0',
|
||||
decimals: 6,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 查询 BSC 余额
|
||||
if (depositAddresses.bscAddress) {
|
||||
try {
|
||||
results.bsc = await this.blockchainQueryService.getUsdtBalance(
|
||||
'BSC',
|
||||
depositAddresses.bscAddress,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(`查询 BSC USDT 余额失败: ${error.message}`);
|
||||
results.bsc = {
|
||||
chainType: 'BSC',
|
||||
address: depositAddresses.bscAddress,
|
||||
balance: '0',
|
||||
rawBalance: '0',
|
||||
decimals: 18,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证钱包地址签名
|
||||
*/
|
||||
private async validateWalletAddress(wallet: any): Promise<boolean> {
|
||||
try {
|
||||
// 如果没有签名数据 (旧版本创建的地址),暂时允许通过
|
||||
if (!wallet.publicKey || !wallet.mpcSignature?.r) {
|
||||
this.logger.warn(`钱包地址无签名数据: chainType=${wallet.chainType}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 验证签名
|
||||
const isValid = await wallet.verifySignature();
|
||||
if (!isValid) {
|
||||
this.logger.error(`钱包地址签名验证失败: chainType=${wallet.chainType}, address=${wallet.address}`);
|
||||
}
|
||||
return isValid;
|
||||
} catch (error) {
|
||||
this.logger.error(`验证钱包地址签名异常: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,634 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { UserApplicationService } from './user-application.service';
|
||||
import { USER_ACCOUNT_REPOSITORY, UserAccountRepository, ReferralLinkData, CreateReferralLinkParams } from '@/domain/repositories/user-account.repository.interface';
|
||||
import { MPC_KEY_SHARE_REPOSITORY, MpcKeyShareRepository } from '@/domain/repositories/mpc-key-share.repository.interface';
|
||||
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
|
||||
import { AccountSequence, ReferralCode, UserId, ProvinceCode, CityCode, AccountStatus, KYCStatus, DeviceInfo } from '@/domain/value-objects';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ValidateReferralCodeQuery, GetReferralStatsQuery, GenerateReferralLinkCommand } from '@/application/commands';
|
||||
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
||||
import { AccountSequenceGeneratorService, UserValidatorService, WalletGeneratorService } from '@/domain/services';
|
||||
import { TokenService } from './token.service';
|
||||
import { RedisService } from '@/infrastructure/redis/redis.service';
|
||||
import { SmsService } from '@/infrastructure/external/sms/sms.service';
|
||||
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
|
||||
import { MpcWalletService } from '@/infrastructure/external/mpc';
|
||||
import { BackupClientService } from '@/infrastructure/external/backup';
|
||||
|
||||
describe('UserApplicationService - Referral APIs', () => {
|
||||
let service: UserApplicationService;
|
||||
let mockUserRepository: jest.Mocked<UserAccountRepository>;
|
||||
|
||||
// Helper function to create a test account using UserAccount.reconstruct
|
||||
const createMockAccount = (params: {
|
||||
userId?: string;
|
||||
accountSequence?: number;
|
||||
referralCode?: string;
|
||||
nickname?: string;
|
||||
avatarUrl?: string | null;
|
||||
isActive?: boolean;
|
||||
inviterSequence?: number | null;
|
||||
registeredAt?: Date;
|
||||
} = {}): UserAccount => {
|
||||
const devices = [
|
||||
new DeviceInfo('device-001', 'Test Device', new Date(), new Date()),
|
||||
];
|
||||
|
||||
return UserAccount.reconstruct({
|
||||
userId: params.userId || '123456789',
|
||||
accountSequence: params.accountSequence || 1,
|
||||
devices,
|
||||
phoneNumber: '13800138000',
|
||||
nickname: params.nickname || '用户1',
|
||||
avatarUrl: params.avatarUrl ?? null,
|
||||
inviterSequence: params.inviterSequence ?? null,
|
||||
referralCode: params.referralCode || 'ABC123',
|
||||
province: '110000',
|
||||
city: '110100',
|
||||
address: null,
|
||||
walletAddresses: [],
|
||||
kycInfo: null,
|
||||
kycStatus: KYCStatus.NOT_VERIFIED,
|
||||
status: params.isActive !== false ? AccountStatus.ACTIVE : AccountStatus.FROZEN,
|
||||
registeredAt: params.registeredAt || new Date(),
|
||||
lastLoginAt: null,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockUserRepository = {
|
||||
save: jest.fn(),
|
||||
saveWallets: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
findByAccountSequence: jest.fn(),
|
||||
findByDeviceId: jest.fn(),
|
||||
findByPhoneNumber: jest.fn(),
|
||||
findByReferralCode: jest.fn(),
|
||||
findByWalletAddress: jest.fn(),
|
||||
getMaxAccountSequence: jest.fn(),
|
||||
getNextAccountSequence: jest.fn(),
|
||||
findUsers: jest.fn(),
|
||||
countUsers: jest.fn(),
|
||||
findByInviterSequence: jest.fn(),
|
||||
createReferralLink: jest.fn(),
|
||||
findReferralLinksByUserId: jest.fn(),
|
||||
};
|
||||
|
||||
const mockMpcKeyShareRepository: jest.Mocked<MpcKeyShareRepository> = {
|
||||
saveServerShare: jest.fn(),
|
||||
findByUserId: jest.fn(),
|
||||
findByPublicKey: jest.fn(),
|
||||
updateStatus: jest.fn(),
|
||||
rotateShare: jest.fn(),
|
||||
};
|
||||
|
||||
const mockConfigService = {
|
||||
get: jest.fn((key: string) => {
|
||||
const config: Record<string, any> = {
|
||||
'APP_BASE_URL': 'https://app.rwadurian.com',
|
||||
'MPC_MODE': 'local',
|
||||
};
|
||||
return config[key];
|
||||
}),
|
||||
};
|
||||
|
||||
const mockAccountSequenceGeneratorService = {
|
||||
getNext: jest.fn().mockResolvedValue(AccountSequence.create(1)),
|
||||
};
|
||||
|
||||
const mockUserValidatorService = {
|
||||
validateUniquePhone: jest.fn(),
|
||||
};
|
||||
|
||||
const mockWalletGeneratorService = {
|
||||
generateWallets: jest.fn(),
|
||||
};
|
||||
|
||||
const mockTokenService = {
|
||||
generateAccessToken: jest.fn().mockReturnValue('mock-access-token'),
|
||||
generateRefreshToken: jest.fn().mockReturnValue('mock-refresh-token'),
|
||||
generateDeviceRefreshToken: jest.fn().mockReturnValue('mock-device-refresh-token'),
|
||||
verifyRefreshToken: jest.fn(),
|
||||
};
|
||||
|
||||
const mockRedisService = {
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
del: jest.fn(),
|
||||
setWithExpiry: jest.fn(),
|
||||
};
|
||||
|
||||
const mockSmsService = {
|
||||
sendSmsCode: jest.fn(),
|
||||
};
|
||||
|
||||
const mockEventPublisherService = {
|
||||
publish: jest.fn(),
|
||||
};
|
||||
|
||||
const mockMpcWalletService = {
|
||||
generateMpcWallet: jest.fn(),
|
||||
};
|
||||
|
||||
const mockBackupClientService = {
|
||||
storeBackupShare: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
UserApplicationService,
|
||||
{
|
||||
provide: USER_ACCOUNT_REPOSITORY,
|
||||
useValue: mockUserRepository,
|
||||
},
|
||||
{
|
||||
provide: MPC_KEY_SHARE_REPOSITORY,
|
||||
useValue: mockMpcKeyShareRepository,
|
||||
},
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: mockConfigService,
|
||||
},
|
||||
{
|
||||
provide: AccountSequenceGeneratorService,
|
||||
useValue: mockAccountSequenceGeneratorService,
|
||||
},
|
||||
{
|
||||
provide: UserValidatorService,
|
||||
useValue: mockUserValidatorService,
|
||||
},
|
||||
{
|
||||
provide: WalletGeneratorService,
|
||||
useValue: mockWalletGeneratorService,
|
||||
},
|
||||
{
|
||||
provide: TokenService,
|
||||
useValue: mockTokenService,
|
||||
},
|
||||
{
|
||||
provide: RedisService,
|
||||
useValue: mockRedisService,
|
||||
},
|
||||
{
|
||||
provide: SmsService,
|
||||
useValue: mockSmsService,
|
||||
},
|
||||
{
|
||||
provide: EventPublisherService,
|
||||
useValue: mockEventPublisherService,
|
||||
},
|
||||
{
|
||||
provide: MpcWalletService,
|
||||
useValue: mockMpcWalletService,
|
||||
},
|
||||
{
|
||||
provide: BackupClientService,
|
||||
useValue: mockBackupClientService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<UserApplicationService>(UserApplicationService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// ============ GET /api/me Tests ============
|
||||
describe('getMe', () => {
|
||||
it('should return current user info with referral code and link', async () => {
|
||||
const mockAccount = createMockAccount({
|
||||
userId: '123456789',
|
||||
accountSequence: 1,
|
||||
nickname: '测试用户',
|
||||
referralCode: 'ABC123',
|
||||
});
|
||||
|
||||
mockUserRepository.findById.mockResolvedValue(mockAccount);
|
||||
|
||||
const result = await service.getMe('123456789');
|
||||
|
||||
expect(result).toEqual({
|
||||
userId: '123456789',
|
||||
accountSequence: 1,
|
||||
phoneNumber: '138****8000', // masked
|
||||
nickname: '测试用户',
|
||||
avatarUrl: null,
|
||||
referralCode: 'ABC123',
|
||||
referralLink: 'https://app.rwadurian.com/invite/ABC123',
|
||||
walletAddresses: [],
|
||||
kycStatus: KYCStatus.NOT_VERIFIED,
|
||||
status: AccountStatus.ACTIVE,
|
||||
registeredAt: expect.any(Date),
|
||||
});
|
||||
|
||||
expect(mockUserRepository.findById).toHaveBeenCalledWith(expect.any(UserId));
|
||||
});
|
||||
|
||||
it('should throw error when user not found', async () => {
|
||||
mockUserRepository.findById.mockResolvedValue(null);
|
||||
|
||||
// Use valid numeric string for userId
|
||||
await expect(service.getMe('999999999')).rejects.toThrow(ApplicationError);
|
||||
await expect(service.getMe('999999999')).rejects.toThrow('用户不存在');
|
||||
});
|
||||
});
|
||||
|
||||
// ============ GET /api/referrals/validate Tests ============
|
||||
describe('validateReferralCode', () => {
|
||||
it('should return valid=true for existing active referral code', async () => {
|
||||
const mockInviter = createMockAccount({
|
||||
accountSequence: 100,
|
||||
nickname: '邀请人',
|
||||
avatarUrl: 'https://example.com/avatar.jpg',
|
||||
referralCode: 'INVTE1',
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
mockUserRepository.findByReferralCode.mockResolvedValue(mockInviter);
|
||||
|
||||
const result = await service.validateReferralCode(
|
||||
new ValidateReferralCodeQuery('INVTE1')
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
valid: true,
|
||||
referralCode: 'INVTE1',
|
||||
inviterInfo: {
|
||||
accountSequence: 100,
|
||||
nickname: '邀请人',
|
||||
avatarUrl: 'https://example.com/avatar.jpg',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return valid=false for non-existent referral code', async () => {
|
||||
mockUserRepository.findByReferralCode.mockResolvedValue(null);
|
||||
|
||||
const result = await service.validateReferralCode(
|
||||
new ValidateReferralCodeQuery('INVLD1')
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
valid: false,
|
||||
message: '推荐码不存在',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return valid=false for frozen inviter account', async () => {
|
||||
const frozenInviter = createMockAccount({
|
||||
referralCode: 'FROZN1',
|
||||
isActive: false,
|
||||
});
|
||||
|
||||
mockUserRepository.findByReferralCode.mockResolvedValue(frozenInviter);
|
||||
|
||||
const result = await service.validateReferralCode(
|
||||
new ValidateReferralCodeQuery('FROZN1')
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
valid: false,
|
||||
message: '推荐人账户已冻结',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return valid=false for invalid referral code format', async () => {
|
||||
const result = await service.validateReferralCode(
|
||||
new ValidateReferralCodeQuery('invalid-format-too-long')
|
||||
);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.message).toBe('推荐码格式无效');
|
||||
});
|
||||
});
|
||||
|
||||
// ============ POST /api/referrals/links Tests ============
|
||||
describe('generateReferralLink', () => {
|
||||
it('should generate a new referral link with channel', async () => {
|
||||
const mockAccount = createMockAccount({
|
||||
userId: '123456789',
|
||||
referralCode: 'ABC123',
|
||||
});
|
||||
|
||||
const mockLinkData: ReferralLinkData = {
|
||||
linkId: BigInt(1),
|
||||
userId: BigInt(123456789),
|
||||
referralCode: 'ABC123',
|
||||
shortCode: 'XyZ789',
|
||||
channel: 'wechat',
|
||||
campaignId: null,
|
||||
createdAt: new Date('2024-01-15T10:00:00Z'),
|
||||
};
|
||||
|
||||
mockUserRepository.findById.mockResolvedValue(mockAccount);
|
||||
mockUserRepository.createReferralLink.mockResolvedValue(mockLinkData);
|
||||
|
||||
const result = await service.generateReferralLink(
|
||||
new GenerateReferralLinkCommand('123456789', 'wechat')
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
linkId: '1',
|
||||
referralCode: 'ABC123',
|
||||
shortUrl: expect.stringMatching(/^https:\/\/app\.rwadurian\.com\/r\/[A-Za-z0-9]{6}$/),
|
||||
fullUrl: 'https://app.rwadurian.com/invite/ABC123?ch=wechat',
|
||||
channel: 'wechat',
|
||||
campaignId: null,
|
||||
createdAt: expect.any(Date),
|
||||
});
|
||||
|
||||
expect(mockUserRepository.createReferralLink).toHaveBeenCalledWith({
|
||||
userId: expect.any(BigInt),
|
||||
referralCode: 'ABC123',
|
||||
shortCode: expect.any(String),
|
||||
channel: 'wechat',
|
||||
campaignId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should generate a referral link with campaign ID', async () => {
|
||||
const mockAccount = createMockAccount({
|
||||
userId: '123456789',
|
||||
referralCode: 'ABC123',
|
||||
});
|
||||
|
||||
const mockLinkData: ReferralLinkData = {
|
||||
linkId: BigInt(2),
|
||||
userId: BigInt(123456789),
|
||||
referralCode: 'ABC123',
|
||||
shortCode: 'AbC456',
|
||||
channel: 'telegram',
|
||||
campaignId: 'spring2024',
|
||||
createdAt: new Date('2024-01-15T10:00:00Z'),
|
||||
};
|
||||
|
||||
mockUserRepository.findById.mockResolvedValue(mockAccount);
|
||||
mockUserRepository.createReferralLink.mockResolvedValue(mockLinkData);
|
||||
|
||||
const result = await service.generateReferralLink(
|
||||
new GenerateReferralLinkCommand('123456789', 'telegram', 'spring2024')
|
||||
);
|
||||
|
||||
expect(result.channel).toBe('telegram');
|
||||
expect(result.campaignId).toBe('spring2024');
|
||||
});
|
||||
|
||||
it('should generate link with default channel when not specified', async () => {
|
||||
const mockAccount = createMockAccount({
|
||||
userId: '123456789',
|
||||
referralCode: 'ABC123',
|
||||
});
|
||||
|
||||
const mockLinkData: ReferralLinkData = {
|
||||
linkId: BigInt(3),
|
||||
userId: BigInt(123456789),
|
||||
referralCode: 'ABC123',
|
||||
shortCode: 'DeF789',
|
||||
channel: null,
|
||||
campaignId: null,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
mockUserRepository.findById.mockResolvedValue(mockAccount);
|
||||
mockUserRepository.createReferralLink.mockResolvedValue(mockLinkData);
|
||||
|
||||
const result = await service.generateReferralLink(
|
||||
new GenerateReferralLinkCommand('123456789')
|
||||
);
|
||||
|
||||
expect(result.fullUrl).toContain('ch=default');
|
||||
expect(result.channel).toBeNull();
|
||||
});
|
||||
|
||||
it('should throw error when user not found', async () => {
|
||||
mockUserRepository.findById.mockResolvedValue(null);
|
||||
|
||||
// Use valid numeric string for userId
|
||||
await expect(
|
||||
service.generateReferralLink(new GenerateReferralLinkCommand('999999999'))
|
||||
).rejects.toThrow(ApplicationError);
|
||||
});
|
||||
});
|
||||
|
||||
// ============ GET /api/referrals/stats Tests ============
|
||||
describe('getReferralStats', () => {
|
||||
it('should return referral stats with direct and indirect invites', async () => {
|
||||
const mockAccount = createMockAccount({
|
||||
userId: '123456789',
|
||||
accountSequence: 1,
|
||||
referralCode: 'ABC123',
|
||||
});
|
||||
|
||||
// Direct invites (invited by user 1)
|
||||
const directInvite1 = createMockAccount({
|
||||
userId: '200000001',
|
||||
accountSequence: 2,
|
||||
nickname: '直接邀请1',
|
||||
inviterSequence: 1,
|
||||
registeredAt: new Date(),
|
||||
});
|
||||
|
||||
const directInvite2 = createMockAccount({
|
||||
userId: '200000002',
|
||||
accountSequence: 3,
|
||||
nickname: '直接邀请2',
|
||||
inviterSequence: 1,
|
||||
registeredAt: new Date(),
|
||||
});
|
||||
|
||||
// Indirect invite (invited by user 2, who was invited by user 1)
|
||||
const indirectInvite1 = createMockAccount({
|
||||
userId: '300000001',
|
||||
accountSequence: 4,
|
||||
nickname: '间接邀请1',
|
||||
inviterSequence: 2,
|
||||
registeredAt: new Date(),
|
||||
});
|
||||
|
||||
mockUserRepository.findById.mockResolvedValue(mockAccount);
|
||||
mockUserRepository.findByInviterSequence
|
||||
.mockResolvedValueOnce([directInvite1, directInvite2]) // Direct invites of user 1
|
||||
.mockResolvedValueOnce([indirectInvite1]) // Indirect invites via user 2
|
||||
.mockResolvedValueOnce([]); // Indirect invites via user 3 (none)
|
||||
|
||||
const result = await service.getReferralStats(
|
||||
new GetReferralStatsQuery('123456789')
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
referralCode: 'ABC123',
|
||||
totalInvites: 3, // 2 direct + 1 indirect
|
||||
directInvites: 2,
|
||||
indirectInvites: 1,
|
||||
todayInvites: expect.any(Number),
|
||||
thisWeekInvites: expect.any(Number),
|
||||
thisMonthInvites: expect.any(Number),
|
||||
recentInvites: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
accountSequence: expect.any(Number),
|
||||
nickname: expect.any(String),
|
||||
level: expect.any(Number), // 1 for direct, 2 for indirect
|
||||
}),
|
||||
]),
|
||||
});
|
||||
|
||||
expect(result.recentInvites.length).toBeLessThanOrEqual(20);
|
||||
});
|
||||
|
||||
it('should return empty stats when no invites', async () => {
|
||||
const mockAccount = createMockAccount({
|
||||
userId: '123456789',
|
||||
accountSequence: 1,
|
||||
referralCode: 'ABC123',
|
||||
});
|
||||
|
||||
mockUserRepository.findById.mockResolvedValue(mockAccount);
|
||||
mockUserRepository.findByInviterSequence.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getReferralStats(
|
||||
new GetReferralStatsQuery('123456789')
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
referralCode: 'ABC123',
|
||||
totalInvites: 0,
|
||||
directInvites: 0,
|
||||
indirectInvites: 0,
|
||||
todayInvites: 0,
|
||||
thisWeekInvites: 0,
|
||||
thisMonthInvites: 0,
|
||||
recentInvites: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should correctly calculate time-based stats', async () => {
|
||||
const mockAccount = createMockAccount({
|
||||
userId: '123456789',
|
||||
accountSequence: 1,
|
||||
referralCode: 'ABC123',
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const todayInvite = createMockAccount({
|
||||
accountSequence: 2,
|
||||
nickname: '今日邀请',
|
||||
inviterSequence: 1,
|
||||
registeredAt: now,
|
||||
});
|
||||
|
||||
const yesterdayInvite = createMockAccount({
|
||||
accountSequence: 3,
|
||||
nickname: '昨日邀请',
|
||||
inviterSequence: 1,
|
||||
registeredAt: new Date(now.getTime() - 24 * 60 * 60 * 1000), // yesterday
|
||||
});
|
||||
|
||||
const lastMonthInvite = createMockAccount({
|
||||
accountSequence: 4,
|
||||
nickname: '上月邀请',
|
||||
inviterSequence: 1,
|
||||
registeredAt: new Date(now.getFullYear(), now.getMonth() - 1, 15),
|
||||
});
|
||||
|
||||
mockUserRepository.findById.mockResolvedValue(mockAccount);
|
||||
mockUserRepository.findByInviterSequence
|
||||
.mockResolvedValueOnce([todayInvite, yesterdayInvite, lastMonthInvite])
|
||||
.mockResolvedValue([]); // No second-level invites
|
||||
|
||||
const result = await service.getReferralStats(
|
||||
new GetReferralStatsQuery('123456789')
|
||||
);
|
||||
|
||||
expect(result.directInvites).toBe(3);
|
||||
expect(result.todayInvites).toBe(1); // Only today's invite
|
||||
});
|
||||
|
||||
it('should throw error when user not found', async () => {
|
||||
mockUserRepository.findById.mockResolvedValue(null);
|
||||
|
||||
// Use valid numeric string for userId
|
||||
await expect(
|
||||
service.getReferralStats(new GetReferralStatsQuery('999999999'))
|
||||
).rejects.toThrow(ApplicationError);
|
||||
});
|
||||
|
||||
it('should sort recent invites by registration date (newest first)', async () => {
|
||||
const mockAccount = createMockAccount({
|
||||
userId: '123456789',
|
||||
accountSequence: 1,
|
||||
referralCode: 'ABC123',
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const oldInvite = createMockAccount({
|
||||
accountSequence: 2,
|
||||
nickname: '旧邀请',
|
||||
inviterSequence: 1,
|
||||
registeredAt: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000), // 7 days ago
|
||||
});
|
||||
|
||||
const newInvite = createMockAccount({
|
||||
accountSequence: 3,
|
||||
nickname: '新邀请',
|
||||
inviterSequence: 1,
|
||||
registeredAt: now,
|
||||
});
|
||||
|
||||
mockUserRepository.findById.mockResolvedValue(mockAccount);
|
||||
mockUserRepository.findByInviterSequence
|
||||
.mockResolvedValueOnce([oldInvite, newInvite])
|
||||
.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getReferralStats(
|
||||
new GetReferralStatsQuery('123456789')
|
||||
);
|
||||
|
||||
// Newest should be first
|
||||
expect(result.recentInvites[0].nickname).toBe('新邀请');
|
||||
expect(result.recentInvites[1].nickname).toBe('旧邀请');
|
||||
});
|
||||
});
|
||||
|
||||
// ============ Short Code Generation Tests ============
|
||||
describe('generateShortCode (private method behavior)', () => {
|
||||
it('should generate short codes with 6 characters', async () => {
|
||||
const mockAccount = createMockAccount({
|
||||
userId: '123456789',
|
||||
referralCode: 'ABC123',
|
||||
});
|
||||
|
||||
mockUserRepository.findById.mockResolvedValue(mockAccount);
|
||||
|
||||
// Generate multiple links and verify short codes have correct length
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const mockLinkData: ReferralLinkData = {
|
||||
linkId: BigInt(i + 1),
|
||||
userId: BigInt(123456789),
|
||||
referralCode: 'ABC123',
|
||||
shortCode: `code${i}`,
|
||||
channel: null,
|
||||
campaignId: null,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
mockUserRepository.createReferralLink.mockResolvedValueOnce(mockLinkData);
|
||||
|
||||
await service.generateReferralLink(
|
||||
new GenerateReferralLinkCommand('123456789', `channel${i}`)
|
||||
);
|
||||
}
|
||||
|
||||
// All generated short codes should have 6 characters
|
||||
const createReferralLinkCalls = mockUserRepository.createReferralLink.mock.calls;
|
||||
createReferralLinkCalls.forEach((call) => {
|
||||
const params = call[0] as CreateReferralLinkParams;
|
||||
expect(params.shortCode).toHaveLength(6);
|
||||
// Should not contain confusing characters (I, l, O, 0, 1)
|
||||
expect(params.shortCode).not.toMatch(/[IlO01]/);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -15,14 +15,17 @@ import { RedisService } from '@/infrastructure/redis/redis.service';
|
|||
import { SmsService } from '@/infrastructure/external/sms/sms.service';
|
||||
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
|
||||
import { MpcWalletService } from '@/infrastructure/external/mpc';
|
||||
import { BackupClientService } from '@/infrastructure/external/backup';
|
||||
import { ApplicationError } from '@/shared/exceptions/domain.exception';
|
||||
import {
|
||||
AutoCreateAccountCommand, RecoverByMnemonicCommand, RecoverByPhoneCommand,
|
||||
AutoLoginCommand, RegisterCommand, LoginCommand, BindPhoneNumberCommand,
|
||||
UpdateProfileCommand, SubmitKYCCommand, ReviewKYCCommand, RemoveDeviceCommand,
|
||||
SendSmsCodeCommand, GetMyProfileQuery, GetMyDevicesQuery, GetUserByReferralCodeQuery,
|
||||
ValidateReferralCodeQuery, GetReferralStatsQuery, GenerateReferralLinkCommand,
|
||||
AutoCreateAccountResult, RecoverAccountResult, AutoLoginResult, RegisterResult,
|
||||
LoginResult, UserProfileDTO, DeviceDTO, UserBriefDTO,
|
||||
ReferralCodeValidationResult, ReferralLinkResult, ReferralStatsResult, MeResult,
|
||||
} from '../commands';
|
||||
|
||||
@Injectable()
|
||||
|
|
@ -38,6 +41,7 @@ export class UserApplicationService {
|
|||
private readonly validatorService: UserValidatorService,
|
||||
private readonly walletGenerator: WalletGeneratorService,
|
||||
private readonly mpcWalletService: MpcWalletService,
|
||||
private readonly backupClient: BackupClientService,
|
||||
private readonly tokenService: TokenService,
|
||||
private readonly redisService: RedisService,
|
||||
private readonly smsService: SmsService,
|
||||
|
|
@ -122,6 +126,18 @@ export class UserApplicationService {
|
|||
});
|
||||
this.logger.log(`Server MPC share saved for user: ${account.userId.toString()}`);
|
||||
|
||||
// 10. 保存备份 MPC 分片到 backup-service (异地服务器)
|
||||
// 注意: backup-service 必须部署在不同物理服务器,否则 MPC 安全性失效
|
||||
if (mpcResult.backupShareData) {
|
||||
await this.backupClient.storeBackupShare({
|
||||
userId: account.userId.toString(),
|
||||
accountSequence: account.accountSequence.value,
|
||||
publicKey: mpcResult.publicKey,
|
||||
encryptedShareData: mpcResult.backupShareData,
|
||||
});
|
||||
this.logger.log(`Backup MPC share sent to backup-service for user: ${account.userId.toString()}`);
|
||||
}
|
||||
|
||||
// 11. 生成 Token
|
||||
const tokens = await this.tokenService.generateTokenPair({
|
||||
userId: account.userId.toString(),
|
||||
|
|
@ -481,4 +497,163 @@ export class UserApplicationService {
|
|||
private generateSmsCode(): string {
|
||||
return String(Math.floor(100000 + Math.random() * 900000));
|
||||
}
|
||||
|
||||
// ============ 推荐/分享相关 API ============
|
||||
|
||||
/**
|
||||
* 获取当前登录用户信息 (GET /api/me)
|
||||
*/
|
||||
async getMe(userId: string): Promise<MeResult> {
|
||||
const account = await this.userRepository.findById(UserId.create(userId));
|
||||
if (!account) throw new ApplicationError('用户不存在');
|
||||
|
||||
const baseUrl = 'https://app.rwadurian.com'; // TODO: 从配置读取
|
||||
const referralLink = `${baseUrl}/invite/${account.referralCode.value}`;
|
||||
|
||||
return {
|
||||
userId: account.userId.toString(),
|
||||
accountSequence: account.accountSequence.value,
|
||||
phoneNumber: account.phoneNumber?.masked() || null,
|
||||
nickname: account.nickname,
|
||||
avatarUrl: account.avatarUrl,
|
||||
referralCode: account.referralCode.value,
|
||||
referralLink,
|
||||
walletAddresses: account.getAllWalletAddresses().map((wa) => ({
|
||||
chainType: wa.chainType,
|
||||
address: wa.address,
|
||||
})),
|
||||
kycStatus: account.kycStatus,
|
||||
status: account.status,
|
||||
registeredAt: account.registeredAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证推荐码是否有效 (GET /api/referrals/validate)
|
||||
*/
|
||||
async validateReferralCode(query: ValidateReferralCodeQuery): Promise<ReferralCodeValidationResult> {
|
||||
try {
|
||||
const referralCode = ReferralCode.create(query.referralCode);
|
||||
const account = await this.userRepository.findByReferralCode(referralCode);
|
||||
|
||||
if (!account) {
|
||||
return { valid: false, message: '推荐码不存在' };
|
||||
}
|
||||
|
||||
if (!account.isActive) {
|
||||
return { valid: false, message: '推荐人账户已冻结' };
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
referralCode: account.referralCode.value,
|
||||
inviterInfo: {
|
||||
accountSequence: account.accountSequence.value,
|
||||
nickname: account.nickname,
|
||||
avatarUrl: account.avatarUrl,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return { valid: false, message: '推荐码格式无效' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成推荐链接 (POST /api/referrals/links)
|
||||
*/
|
||||
async generateReferralLink(command: GenerateReferralLinkCommand): Promise<ReferralLinkResult> {
|
||||
const account = await this.userRepository.findById(UserId.create(command.userId));
|
||||
if (!account) throw new ApplicationError('用户不存在');
|
||||
|
||||
// 生成短链码 (6位随机字符)
|
||||
const shortCode = this.generateShortCode();
|
||||
const baseUrl = 'https://app.rwadurian.com'; // TODO: 从配置读取
|
||||
|
||||
// 保存到数据库
|
||||
const link = await this.userRepository.createReferralLink({
|
||||
userId: account.userId.value,
|
||||
referralCode: account.referralCode.value,
|
||||
shortCode,
|
||||
channel: command.channel || null,
|
||||
campaignId: command.campaignId || null,
|
||||
});
|
||||
|
||||
return {
|
||||
linkId: link.linkId.toString(),
|
||||
referralCode: account.referralCode.value,
|
||||
shortUrl: `${baseUrl}/r/${shortCode}`,
|
||||
fullUrl: `${baseUrl}/invite/${account.referralCode.value}?ch=${command.channel || 'default'}`,
|
||||
channel: command.channel || null,
|
||||
campaignId: command.campaignId || null,
|
||||
createdAt: link.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询邀请统计 (GET /api/referrals/stats)
|
||||
*/
|
||||
async getReferralStats(query: GetReferralStatsQuery): Promise<ReferralStatsResult> {
|
||||
const account = await this.userRepository.findById(UserId.create(query.userId));
|
||||
if (!account) throw new ApplicationError('用户不存在');
|
||||
|
||||
// 查询直接邀请的用户
|
||||
const directInvites = await this.userRepository.findByInviterSequence(account.accountSequence);
|
||||
|
||||
// 查询间接邀请 (二级)
|
||||
let indirectInvites: typeof directInvites = [];
|
||||
for (const invite of directInvites) {
|
||||
const secondLevel = await this.userRepository.findByInviterSequence(invite.accountSequence);
|
||||
indirectInvites = indirectInvites.concat(secondLevel);
|
||||
}
|
||||
|
||||
// 时间统计
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const weekStart = new Date(todayStart);
|
||||
weekStart.setDate(weekStart.getDate() - weekStart.getDay());
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
|
||||
const todayInvites = directInvites.filter(u => u.registeredAt >= todayStart).length;
|
||||
const thisWeekInvites = directInvites.filter(u => u.registeredAt >= weekStart).length;
|
||||
const thisMonthInvites = directInvites.filter(u => u.registeredAt >= monthStart).length;
|
||||
|
||||
// 合并并排序最近邀请
|
||||
interface InviteRecord {
|
||||
account: UserAccount;
|
||||
level: number;
|
||||
}
|
||||
const allInvites: InviteRecord[] = [
|
||||
...directInvites.map(u => ({ account: u, level: 1 })),
|
||||
...indirectInvites.map(u => ({ account: u, level: 2 })),
|
||||
].sort((a, b) => b.account.registeredAt.getTime() - a.account.registeredAt.getTime()).slice(0, 20);
|
||||
|
||||
return {
|
||||
referralCode: account.referralCode.value,
|
||||
totalInvites: directInvites.length + indirectInvites.length,
|
||||
directInvites: directInvites.length,
|
||||
indirectInvites: indirectInvites.length,
|
||||
todayInvites,
|
||||
thisWeekInvites,
|
||||
thisMonthInvites,
|
||||
recentInvites: allInvites.map(({ account: u, level }) => ({
|
||||
accountSequence: u.accountSequence.value,
|
||||
nickname: u.nickname,
|
||||
avatarUrl: u.avatarUrl,
|
||||
registeredAt: u.registeredAt,
|
||||
level,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成短链码
|
||||
*/
|
||||
private generateShortCode(): string {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < 6; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { AccountSequenceGeneratorService, UserValidatorService, WalletGeneratorService } from './services';
|
||||
import { AccountSequenceGeneratorService, UserValidatorService } from './services';
|
||||
import { UserAccountFactory } from './aggregates/user-account/user-account.factory';
|
||||
import { USER_ACCOUNT_REPOSITORY } from './repositories/user-account.repository.interface';
|
||||
import { UserAccountRepositoryImpl } from '@/infrastructure/persistence/repositories/user-account.repository.impl';
|
||||
|
|
@ -11,14 +11,14 @@ import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
|
|||
{ provide: USER_ACCOUNT_REPOSITORY, useClass: UserAccountRepositoryImpl },
|
||||
AccountSequenceGeneratorService,
|
||||
UserValidatorService,
|
||||
WalletGeneratorService,
|
||||
// WalletGeneratorService 由 InfrastructureModule 提供
|
||||
UserAccountFactory,
|
||||
],
|
||||
exports: [
|
||||
USER_ACCOUNT_REPOSITORY,
|
||||
AccountSequenceGeneratorService,
|
||||
UserValidatorService,
|
||||
WalletGeneratorService,
|
||||
// WalletGeneratorService 由 InfrastructureModule 导出
|
||||
UserAccountFactory,
|
||||
],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -9,6 +9,24 @@ export interface Pagination {
|
|||
limit: number;
|
||||
}
|
||||
|
||||
export interface ReferralLinkData {
|
||||
linkId: bigint;
|
||||
userId: bigint;
|
||||
referralCode: string;
|
||||
shortCode: string;
|
||||
channel: string | null;
|
||||
campaignId: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateReferralLinkParams {
|
||||
userId: bigint;
|
||||
referralCode: string;
|
||||
shortCode: string;
|
||||
channel: string | null;
|
||||
campaignId: string | null;
|
||||
}
|
||||
|
||||
export interface UserAccountRepository {
|
||||
save(account: UserAccount): Promise<void>;
|
||||
saveWallets(userId: UserId, wallets: WalletAddress[]): Promise<void>;
|
||||
|
|
@ -25,6 +43,11 @@ export interface UserAccountRepository {
|
|||
pagination?: Pagination,
|
||||
): Promise<UserAccount[]>;
|
||||
countUsers(filters?: { status?: AccountStatus; kycStatus?: KYCStatus }): Promise<number>;
|
||||
|
||||
// 推荐相关
|
||||
findByInviterSequence(inviterSequence: AccountSequence): Promise<UserAccount[]>;
|
||||
createReferralLink(params: CreateReferralLinkParams): Promise<ReferralLinkData>;
|
||||
findReferralLinksByUserId(userId: UserId): Promise<ReferralLinkData[]>;
|
||||
}
|
||||
|
||||
export const USER_ACCOUNT_REPOSITORY = Symbol('USER_ACCOUNT_REPOSITORY');
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { createHash } from 'crypto';
|
||||
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
|
||||
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
|
||||
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
|
||||
import {
|
||||
AccountSequence, PhoneNumber, ReferralCode, ChainType, Mnemonic, UserId,
|
||||
} from '@/domain/value-objects';
|
||||
import { AccountSequence, PhoneNumber, ReferralCode, ChainType } from '@/domain/value-objects';
|
||||
|
||||
// 导出 WalletGeneratorService 和相关类型
|
||||
export {
|
||||
WalletGeneratorService,
|
||||
MpcWalletGenerationParams,
|
||||
MpcWalletGenerationResult,
|
||||
ChainWalletInfo,
|
||||
} from './wallet-generator.service';
|
||||
|
||||
// ============ ValidationResult ============
|
||||
export class ValidationResult {
|
||||
|
|
@ -69,53 +72,3 @@ export class UserValidatorService {
|
|||
return ValidationResult.success();
|
||||
}
|
||||
}
|
||||
|
||||
// ============ WalletGeneratorService ============
|
||||
@Injectable()
|
||||
export class WalletGeneratorService {
|
||||
generateWalletSystem(params: { userId: UserId; deviceId: string }): {
|
||||
mnemonic: Mnemonic;
|
||||
wallets: Map<ChainType, WalletAddress>;
|
||||
} {
|
||||
const mnemonic = Mnemonic.generate();
|
||||
const encryptionKey = this.deriveEncryptionKey(params.deviceId, params.userId.toString());
|
||||
|
||||
const wallets = new Map<ChainType, WalletAddress>();
|
||||
const chains = [ChainType.KAVA, ChainType.DST, ChainType.BSC];
|
||||
|
||||
for (const chainType of chains) {
|
||||
const wallet = WalletAddress.createFromMnemonic({
|
||||
userId: params.userId,
|
||||
chainType,
|
||||
mnemonic,
|
||||
encryptionKey,
|
||||
});
|
||||
wallets.set(chainType, wallet);
|
||||
}
|
||||
|
||||
return { mnemonic, wallets };
|
||||
}
|
||||
|
||||
recoverWalletSystem(params: { userId: UserId; mnemonic: Mnemonic; deviceId: string }): Map<ChainType, WalletAddress> {
|
||||
const encryptionKey = this.deriveEncryptionKey(params.deviceId, params.userId.toString());
|
||||
const wallets = new Map<ChainType, WalletAddress>();
|
||||
const chains = [ChainType.KAVA, ChainType.DST, ChainType.BSC];
|
||||
|
||||
for (const chainType of chains) {
|
||||
const wallet = WalletAddress.createFromMnemonic({
|
||||
userId: params.userId,
|
||||
chainType,
|
||||
mnemonic: params.mnemonic,
|
||||
encryptionKey,
|
||||
});
|
||||
wallets.set(chainType, wallet);
|
||||
}
|
||||
|
||||
return wallets;
|
||||
}
|
||||
|
||||
private deriveEncryptionKey(deviceId: string, userId: string): string {
|
||||
const input = `${deviceId}:${userId}`;
|
||||
return createHash('sha256').update(input).digest('hex');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,44 +1,73 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { createHash } from 'crypto';
|
||||
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
|
||||
import { WalletAddress, MpcSignature } from '@/domain/entities/wallet-address.entity';
|
||||
import { ChainType, Mnemonic, UserId } from '@/domain/value-objects';
|
||||
|
||||
@Injectable()
|
||||
export class WalletGeneratorService {
|
||||
generateWalletSystem(params: { userId: UserId; deviceId: string }): {
|
||||
mnemonic: Mnemonic;
|
||||
wallets: Map<ChainType, WalletAddress>;
|
||||
} {
|
||||
const mnemonic = Mnemonic.generate();
|
||||
const encryptionKey = this.deriveEncryptionKey(params.deviceId, params.userId.toString());
|
||||
/**
|
||||
* MPC 钱包生成参数
|
||||
*/
|
||||
export interface MpcWalletGenerationParams {
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 链钱包信息
|
||||
*/
|
||||
export interface ChainWalletInfo {
|
||||
chainType: 'KAVA' | 'DST' | 'BSC';
|
||||
address: string;
|
||||
publicKey: string;
|
||||
addressDigest: string;
|
||||
signature: MpcSignature;
|
||||
}
|
||||
|
||||
/**
|
||||
* MPC 钱包生成结果
|
||||
*/
|
||||
export interface MpcWalletGenerationResult {
|
||||
publicKey: string;
|
||||
serverShareData: string;
|
||||
clientShareData: string;
|
||||
backupShareData: string;
|
||||
wallets: ChainWalletInfo[];
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 钱包生成服务接口 (端口)
|
||||
*
|
||||
* 定义钱包生成的业务接口,由基础设施层实现
|
||||
*/
|
||||
export abstract class WalletGeneratorService {
|
||||
/**
|
||||
* 使用 MPC 2-of-3 生成三链钱包
|
||||
*
|
||||
* @param params 用户ID和设备ID
|
||||
* @returns MPC 钱包生成结果,包含分片和签名信息
|
||||
*/
|
||||
abstract generateMpcWalletSystem(params: MpcWalletGenerationParams): Promise<MpcWalletGenerationResult>;
|
||||
|
||||
/**
|
||||
* 将 MPC 钱包信息转换为领域实体
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param walletInfos MPC 钱包信息数组
|
||||
* @returns 钱包地址实体 Map
|
||||
*/
|
||||
convertToWalletEntities(
|
||||
userId: UserId,
|
||||
walletInfos: ChainWalletInfo[],
|
||||
): Map<ChainType, WalletAddress> {
|
||||
const wallets = new Map<ChainType, WalletAddress>();
|
||||
const chains = [ChainType.KAVA, ChainType.DST, ChainType.BSC];
|
||||
|
||||
for (const chainType of chains) {
|
||||
const wallet = WalletAddress.createFromMnemonic({
|
||||
userId: params.userId,
|
||||
for (const info of walletInfos) {
|
||||
const chainType = ChainType[info.chainType as keyof typeof ChainType];
|
||||
const wallet = WalletAddress.createMpc({
|
||||
userId,
|
||||
chainType,
|
||||
mnemonic,
|
||||
encryptionKey,
|
||||
});
|
||||
wallets.set(chainType, wallet);
|
||||
}
|
||||
|
||||
return { mnemonic, wallets };
|
||||
}
|
||||
|
||||
recoverWalletSystem(params: { userId: UserId; mnemonic: Mnemonic; deviceId: string }): Map<ChainType, WalletAddress> {
|
||||
const encryptionKey = this.deriveEncryptionKey(params.deviceId, params.userId.toString());
|
||||
const wallets = new Map<ChainType, WalletAddress>();
|
||||
const chains = [ChainType.KAVA, ChainType.DST, ChainType.BSC];
|
||||
|
||||
for (const chainType of chains) {
|
||||
const wallet = WalletAddress.createFromMnemonic({
|
||||
userId: params.userId,
|
||||
chainType,
|
||||
mnemonic: params.mnemonic,
|
||||
encryptionKey,
|
||||
address: info.address,
|
||||
publicKey: info.publicKey,
|
||||
addressDigest: info.addressDigest,
|
||||
signature: info.signature,
|
||||
});
|
||||
wallets.set(chainType, wallet);
|
||||
}
|
||||
|
|
@ -46,8 +75,13 @@ export class WalletGeneratorService {
|
|||
return wallets;
|
||||
}
|
||||
|
||||
private deriveEncryptionKey(deviceId: string, userId: string): string {
|
||||
const input = `${deviceId}:${userId}`;
|
||||
return createHash('sha256').update(input).digest('hex');
|
||||
}
|
||||
/**
|
||||
* @deprecated MPC 模式下不再使用助记词恢复
|
||||
* 此方法保留用于向后兼容旧版账户恢复流程
|
||||
*/
|
||||
abstract recoverWalletSystem(params: {
|
||||
userId: UserId;
|
||||
mnemonic: Mnemonic;
|
||||
deviceId: string;
|
||||
}): Map<ChainType, WalletAddress>;
|
||||
}
|
||||
|
|
|
|||
192
backend/services/identity-service/src/infrastructure/external/backup/backup-client.service.ts
vendored
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
/**
|
||||
* Backup Client Service
|
||||
*
|
||||
* 与 backup-service 通信的客户端服务
|
||||
* 负责存储和获取 MPC Backup Share (Party 2)
|
||||
*
|
||||
* 安全要求: backup-service 必须部署在与 identity-service 不同的物理服务器上
|
||||
*/
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
|
||||
export interface StoreBackupShareParams {
|
||||
userId: string;
|
||||
accountSequence: number;
|
||||
publicKey: string;
|
||||
encryptedShareData: string;
|
||||
}
|
||||
|
||||
export interface RetrieveBackupShareParams {
|
||||
userId: string;
|
||||
publicKey: string;
|
||||
recoveryToken: string;
|
||||
deviceId?: string;
|
||||
}
|
||||
|
||||
export interface BackupShareResult {
|
||||
encryptedShareData: string;
|
||||
partyIndex: number;
|
||||
publicKey: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BackupClientService {
|
||||
private readonly logger = new Logger(BackupClientService.name);
|
||||
private readonly backupServiceUrl: string;
|
||||
private readonly serviceJwtSecret: string;
|
||||
private readonly enabled: boolean;
|
||||
|
||||
constructor(
|
||||
private readonly httpService: HttpService,
|
||||
private readonly configService: ConfigService,
|
||||
) {
|
||||
this.backupServiceUrl = this.configService.get<string>('BACKUP_SERVICE_URL', 'http://localhost:3002');
|
||||
this.serviceJwtSecret = this.configService.get<string>('SERVICE_JWT_SECRET', '');
|
||||
this.enabled = this.configService.get<string>('BACKUP_SERVICE_ENABLED', 'false') === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 backup-service 是否启用
|
||||
*/
|
||||
isEnabled(): boolean {
|
||||
return this.enabled && !!this.serviceJwtSecret;
|
||||
}
|
||||
|
||||
/**
|
||||
* 存储备份分片到 backup-service
|
||||
*/
|
||||
async storeBackupShare(params: StoreBackupShareParams): Promise<void> {
|
||||
if (!this.isEnabled()) {
|
||||
this.logger.warn('Backup service is disabled, skipping backup share storage');
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(`Storing backup share for user: ${params.userId}`);
|
||||
|
||||
try {
|
||||
const serviceToken = this.generateServiceToken();
|
||||
|
||||
await firstValueFrom(
|
||||
this.httpService.post(
|
||||
`${this.backupServiceUrl}/backup-share/store`,
|
||||
{
|
||||
userId: params.userId,
|
||||
accountSequence: params.accountSequence,
|
||||
publicKey: params.publicKey,
|
||||
encryptedShareData: params.encryptedShareData,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Service-Token': serviceToken,
|
||||
},
|
||||
timeout: 30000, // 30秒超时
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.logger.log(`Backup share stored successfully for user: ${params.userId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to store backup share for user: ${params.userId}`, error);
|
||||
// 不抛出异常,允许账户创建继续
|
||||
// 可以通过补偿任务稍后重试
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 backup-service 获取备份分片 (用于账户恢复)
|
||||
*/
|
||||
async retrieveBackupShare(params: RetrieveBackupShareParams): Promise<BackupShareResult | null> {
|
||||
if (!this.isEnabled()) {
|
||||
this.logger.warn('Backup service is disabled');
|
||||
return null;
|
||||
}
|
||||
|
||||
this.logger.log(`Retrieving backup share for user: ${params.userId}`);
|
||||
|
||||
try {
|
||||
const serviceToken = this.generateServiceToken();
|
||||
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.post<BackupShareResult>(
|
||||
`${this.backupServiceUrl}/backup-share/retrieve`,
|
||||
{
|
||||
userId: params.userId,
|
||||
publicKey: params.publicKey,
|
||||
recoveryToken: params.recoveryToken,
|
||||
deviceId: params.deviceId,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Service-Token': serviceToken,
|
||||
},
|
||||
timeout: 30000,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.logger.log(`Backup share retrieved successfully for user: ${params.userId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to retrieve backup share for user: ${params.userId}`, error);
|
||||
throw new Error(`Failed to retrieve backup share: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销备份分片 (用于密钥轮换或账户注销)
|
||||
*/
|
||||
async revokeBackupShare(userId: string, publicKey: string, reason: string): Promise<void> {
|
||||
if (!this.isEnabled()) {
|
||||
this.logger.warn('Backup service is disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(`Revoking backup share for user: ${userId}, reason: ${reason}`);
|
||||
|
||||
try {
|
||||
const serviceToken = this.generateServiceToken();
|
||||
|
||||
await firstValueFrom(
|
||||
this.httpService.post(
|
||||
`${this.backupServiceUrl}/backup-share/revoke`,
|
||||
{
|
||||
userId,
|
||||
publicKey,
|
||||
reason,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Service-Token': serviceToken,
|
||||
},
|
||||
timeout: 30000,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.logger.log(`Backup share revoked successfully for user: ${userId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to revoke backup share for user: ${userId}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成服务间认证 JWT
|
||||
*/
|
||||
private generateServiceToken(): string {
|
||||
return jwt.sign(
|
||||
{
|
||||
service: 'identity-service',
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
this.serviceJwtSecret,
|
||||
{ expiresIn: '5m' },
|
||||
);
|
||||
}
|
||||
}
|
||||
2
backend/services/identity-service/src/infrastructure/external/backup/index.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './backup-client.service';
|
||||
export * from './mpc-share-storage.service';
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
/**
|
||||
* MPC Share Storage Service
|
||||
*
|
||||
* 封装 MPC 分片存储逻辑,提供简化接口给应用层
|
||||
*/
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { BackupClientService } from './backup-client.service';
|
||||
|
||||
export interface MpcStoreBackupShareParams {
|
||||
userId: string;
|
||||
shareData: string;
|
||||
publicKey: string;
|
||||
accountSequence?: number;
|
||||
}
|
||||
|
||||
export interface MpcRetrieveBackupShareParams {
|
||||
userId: string;
|
||||
publicKey: string;
|
||||
recoveryToken: string;
|
||||
deviceId?: string;
|
||||
}
|
||||
|
||||
export interface MpcBackupShareData {
|
||||
encryptedShareData: string;
|
||||
partyIndex: number;
|
||||
publicKey: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MpcShareStorageService {
|
||||
private readonly logger = new Logger(MpcShareStorageService.name);
|
||||
|
||||
constructor(private readonly backupClient: BackupClientService) {}
|
||||
|
||||
/**
|
||||
* 存储备份分片
|
||||
*
|
||||
* @param params 分片存储参数
|
||||
*/
|
||||
async storeBackupShare(params: MpcStoreBackupShareParams): Promise<void> {
|
||||
this.logger.log(`Storing backup share for user=${params.userId}`);
|
||||
|
||||
await this.backupClient.storeBackupShare({
|
||||
userId: params.userId,
|
||||
accountSequence: params.accountSequence || 0,
|
||||
publicKey: params.publicKey,
|
||||
encryptedShareData: params.shareData,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取备份分片 (用于账户恢复)
|
||||
*
|
||||
* @param params 分片获取参数
|
||||
* @returns 备份分片数据或 null
|
||||
*/
|
||||
async retrieveBackupShare(
|
||||
params: MpcRetrieveBackupShareParams,
|
||||
): Promise<MpcBackupShareData | null> {
|
||||
this.logger.log(`Retrieving backup share for user=${params.userId}`);
|
||||
|
||||
return this.backupClient.retrieveBackupShare(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销备份分片
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param publicKey MPC 公钥
|
||||
* @param reason 撤销原因
|
||||
*/
|
||||
async revokeBackupShare(
|
||||
userId: string,
|
||||
publicKey: string,
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
this.logger.log(`Revoking backup share for user=${userId}`);
|
||||
|
||||
await this.backupClient.revokeBackupShare(userId, publicKey, reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查备份服务是否可用
|
||||
*/
|
||||
isEnabled(): boolean {
|
||||
return this.backupClient.isEnabled();
|
||||
}
|
||||
}
|
||||
178
backend/services/identity-service/src/infrastructure/external/blockchain/blockchain-query.service.ts
vendored
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ethers, JsonRpcProvider, Contract } from 'ethers';
|
||||
|
||||
/**
|
||||
* USDT 余额查询结果
|
||||
*/
|
||||
export interface UsdtBalance {
|
||||
chainType: string;
|
||||
address: string;
|
||||
balance: string; // 格式化后的余额 (带小数)
|
||||
rawBalance: string; // 原始余额 (wei)
|
||||
decimals: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 链配置
|
||||
*/
|
||||
interface ChainConfig {
|
||||
rpcUrl: string;
|
||||
usdtContract: string;
|
||||
decimals: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ERC20 合约 ABI (仅需 balanceOf)
|
||||
*/
|
||||
const ERC20_ABI = [
|
||||
'function balanceOf(address owner) view returns (uint256)',
|
||||
'function decimals() view returns (uint8)',
|
||||
];
|
||||
|
||||
/**
|
||||
* 区块链查询服务
|
||||
*
|
||||
* 用于查询 KAVA EVM 和 BSC 链上的 USDT 余额
|
||||
*/
|
||||
@Injectable()
|
||||
export class BlockchainQueryService {
|
||||
private readonly logger = new Logger(BlockchainQueryService.name);
|
||||
|
||||
/**
|
||||
* 链配置
|
||||
*/
|
||||
private readonly chainConfigs: Record<string, ChainConfig> = {
|
||||
KAVA: {
|
||||
rpcUrl: process.env.KAVA_RPC_URL || 'https://evm.kava.io',
|
||||
// KAVA EVM 原生 USDT 合约地址 (Tether 官方发行)
|
||||
usdtContract: '0x919C1c267BC06a7039e03fcc2eF738525769109c',
|
||||
decimals: 6,
|
||||
name: 'KAVA EVM',
|
||||
},
|
||||
BSC: {
|
||||
rpcUrl: process.env.BSC_RPC_URL || 'https://bsc-dataseed.binance.org',
|
||||
// BSC USDT 合约地址 (Binance-Peg)
|
||||
usdtContract: '0x55d398326f99059fF775485246999027B3197955',
|
||||
decimals: 18,
|
||||
name: 'BSC',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Provider 缓存
|
||||
*/
|
||||
private providers: Map<string, JsonRpcProvider> = new Map();
|
||||
|
||||
/**
|
||||
* 获取或创建 Provider
|
||||
*/
|
||||
private getProvider(chainType: string): JsonRpcProvider {
|
||||
if (this.providers.has(chainType)) {
|
||||
return this.providers.get(chainType)!;
|
||||
}
|
||||
|
||||
const config = this.chainConfigs[chainType];
|
||||
if (!config) {
|
||||
throw new Error(`不支持的链类型: ${chainType}`);
|
||||
}
|
||||
|
||||
const provider = new JsonRpcProvider(config.rpcUrl, undefined, {
|
||||
staticNetwork: true,
|
||||
});
|
||||
this.providers.set(chainType, provider);
|
||||
return provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询单个地址的 USDT 余额
|
||||
*
|
||||
* @param chainType 链类型 (KAVA 或 BSC)
|
||||
* @param address EVM 地址
|
||||
*/
|
||||
async getUsdtBalance(chainType: string, address: string): Promise<UsdtBalance> {
|
||||
const config = this.chainConfigs[chainType];
|
||||
if (!config) {
|
||||
throw new Error(`不支持的链类型: ${chainType}`);
|
||||
}
|
||||
|
||||
// 验证地址格式
|
||||
if (!ethers.isAddress(address)) {
|
||||
throw new Error(`无效的 EVM 地址: ${address}`);
|
||||
}
|
||||
|
||||
try {
|
||||
this.logger.debug(`查询 ${config.name} USDT 余额: ${address}`);
|
||||
|
||||
const provider = this.getProvider(chainType);
|
||||
const contract = new Contract(config.usdtContract, ERC20_ABI, provider);
|
||||
|
||||
const rawBalance = await contract.balanceOf(address);
|
||||
const balance = ethers.formatUnits(rawBalance, config.decimals);
|
||||
|
||||
this.logger.debug(`${config.name} USDT 余额: ${balance}`);
|
||||
|
||||
return {
|
||||
chainType,
|
||||
address,
|
||||
balance,
|
||||
rawBalance: rawBalance.toString(),
|
||||
decimals: config.decimals,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`查询 ${config.name} USDT 余额失败: ${error.message}`);
|
||||
throw new Error(`查询余额失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量查询多个地址的 USDT 余额
|
||||
*
|
||||
* @param addresses 地址列表 { chainType, address }[]
|
||||
*/
|
||||
async getMultipleUsdtBalances(
|
||||
addresses: Array<{ chainType: string; address: string }>,
|
||||
): Promise<UsdtBalance[]> {
|
||||
const results = await Promise.allSettled(
|
||||
addresses.map(({ chainType, address }) => this.getUsdtBalance(chainType, address)),
|
||||
);
|
||||
|
||||
return results.map((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
return result.value;
|
||||
}
|
||||
// 失败时返回零余额
|
||||
this.logger.warn(`查询余额失败 [${addresses[index].chainType}:${addresses[index].address}]: ${result.reason}`);
|
||||
return {
|
||||
chainType: addresses[index].chainType,
|
||||
address: addresses[index].address,
|
||||
balance: '0',
|
||||
rawBalance: '0',
|
||||
decimals: this.chainConfigs[addresses[index].chainType]?.decimals || 6,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询原生代币余额 (KAVA / BNB)
|
||||
*/
|
||||
async getNativeBalance(chainType: string, address: string): Promise<string> {
|
||||
const config = this.chainConfigs[chainType];
|
||||
if (!config) {
|
||||
throw new Error(`不支持的链类型: ${chainType}`);
|
||||
}
|
||||
|
||||
if (!ethers.isAddress(address)) {
|
||||
throw new Error(`无效的 EVM 地址: ${address}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const provider = this.getProvider(chainType);
|
||||
const rawBalance = await provider.getBalance(address);
|
||||
return ethers.formatEther(rawBalance);
|
||||
} catch (error) {
|
||||
this.logger.error(`查询 ${config.name} 原生代币余额失败: ${error.message}`);
|
||||
throw new Error(`查询余额失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,35 +1,61 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { createHash } from 'crypto';
|
||||
import {
|
||||
WalletGeneratorService,
|
||||
MpcWalletGenerationParams,
|
||||
MpcWalletGenerationResult,
|
||||
} from '@/domain/services/wallet-generator.service';
|
||||
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
|
||||
import { ChainType, Mnemonic, UserId } from '@/domain/value-objects';
|
||||
import { MpcWalletService } from '@/infrastructure/external/mpc/mpc-wallet.service';
|
||||
|
||||
/**
|
||||
* 钱包生成服务实现
|
||||
*
|
||||
* 使用 MPC 2-of-3 协议生成三链钱包地址并签名
|
||||
*/
|
||||
@Injectable()
|
||||
export class WalletGeneratorServiceImpl {
|
||||
generateWalletSystem(params: { userId: UserId; deviceId: string }): {
|
||||
mnemonic: Mnemonic;
|
||||
wallets: Map<ChainType, WalletAddress>;
|
||||
} {
|
||||
const mnemonic = Mnemonic.generate();
|
||||
const encryptionKey = this.deriveEncryptionKey(params.deviceId, params.userId.toString());
|
||||
export class WalletGeneratorServiceImpl extends WalletGeneratorService {
|
||||
private readonly logger = new Logger(WalletGeneratorServiceImpl.name);
|
||||
|
||||
const wallets = new Map<ChainType, WalletAddress>();
|
||||
const chains = [ChainType.KAVA, ChainType.DST, ChainType.BSC];
|
||||
|
||||
for (const chainType of chains) {
|
||||
const wallet = WalletAddress.createFromMnemonic({
|
||||
userId: params.userId,
|
||||
chainType,
|
||||
mnemonic,
|
||||
encryptionKey,
|
||||
});
|
||||
wallets.set(chainType, wallet);
|
||||
}
|
||||
|
||||
return { mnemonic, wallets };
|
||||
constructor(private readonly mpcWalletService: MpcWalletService) {
|
||||
super();
|
||||
}
|
||||
|
||||
recoverWalletSystem(params: { userId: UserId; mnemonic: Mnemonic; deviceId: string }): Map<ChainType, WalletAddress> {
|
||||
const encryptionKey = this.deriveEncryptionKey(params.deviceId, params.userId.toString());
|
||||
/**
|
||||
* 使用 MPC 2-of-3 生成三链钱包
|
||||
*/
|
||||
async generateMpcWalletSystem(
|
||||
params: MpcWalletGenerationParams,
|
||||
): Promise<MpcWalletGenerationResult> {
|
||||
this.logger.log(`Generating MPC wallet system for user=${params.userId}`);
|
||||
|
||||
const result = await this.mpcWalletService.generateMpcWallet(params);
|
||||
|
||||
this.logger.log(
|
||||
`MPC wallet system generated: ${result.wallets.length} wallets, sessionId=${result.sessionId}`,
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated MPC 模式下不再使用助记词恢复
|
||||
* 此方法保留用于向后兼容旧版账户恢复流程
|
||||
*/
|
||||
recoverWalletSystem(params: {
|
||||
userId: UserId;
|
||||
mnemonic: Mnemonic;
|
||||
deviceId: string;
|
||||
}): Map<ChainType, WalletAddress> {
|
||||
this.logger.warn(
|
||||
'recoverWalletSystem is deprecated - MPC mode does not use mnemonic recovery',
|
||||
);
|
||||
|
||||
const encryptionKey = this.deriveEncryptionKey(
|
||||
params.deviceId,
|
||||
params.userId.toString(),
|
||||
);
|
||||
const wallets = new Map<ChainType, WalletAddress>();
|
||||
const chains = [ChainType.KAVA, ChainType.DST, ChainType.BSC];
|
||||
|
||||
|
|
|
|||
|
|
@ -8,8 +8,11 @@ import { RedisService } from './redis/redis.service';
|
|||
import { EventPublisherService } from './kafka/event-publisher.service';
|
||||
import { SmsService } from './external/sms/sms.service';
|
||||
import { WalletGeneratorServiceImpl } from './external/blockchain/wallet-generator.service.impl';
|
||||
import { BlockchainQueryService } from './external/blockchain/blockchain-query.service';
|
||||
import { MpcClientService, MpcWalletService } from './external/mpc';
|
||||
import { BackupClientService, MpcShareStorageService } from './external/backup';
|
||||
import { MPC_KEY_SHARE_REPOSITORY } from '@/domain/repositories/mpc-key-share.repository.interface';
|
||||
import { WalletGeneratorService } from '@/domain/services/wallet-generator.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
|
|
@ -30,9 +33,17 @@ import { MPC_KEY_SHARE_REPOSITORY } from '@/domain/repositories/mpc-key-share.re
|
|||
RedisService,
|
||||
EventPublisherService,
|
||||
SmsService,
|
||||
// WalletGeneratorService 抽象类由 WalletGeneratorServiceImpl 实现
|
||||
WalletGeneratorServiceImpl,
|
||||
{
|
||||
provide: WalletGeneratorService,
|
||||
useExisting: WalletGeneratorServiceImpl,
|
||||
},
|
||||
BlockchainQueryService,
|
||||
MpcClientService,
|
||||
MpcWalletService,
|
||||
BackupClientService,
|
||||
MpcShareStorageService,
|
||||
],
|
||||
exports: [
|
||||
PrismaService,
|
||||
|
|
@ -46,8 +57,12 @@ import { MPC_KEY_SHARE_REPOSITORY } from '@/domain/repositories/mpc-key-share.re
|
|||
EventPublisherService,
|
||||
SmsService,
|
||||
WalletGeneratorServiceImpl,
|
||||
WalletGeneratorService,
|
||||
BlockchainQueryService,
|
||||
MpcClientService,
|
||||
MpcWalletService,
|
||||
BackupClientService,
|
||||
MpcShareStorageService,
|
||||
],
|
||||
})
|
||||
export class InfrastructureModule {}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,11 @@ export interface WalletAddressEntity {
|
|||
userId: bigint;
|
||||
chainType: string;
|
||||
address: string;
|
||||
encryptedMnemonic: string | null;
|
||||
publicKey: string;
|
||||
addressDigest: string;
|
||||
mpcSignatureR: string;
|
||||
mpcSignatureS: string;
|
||||
mpcSignatureV: number;
|
||||
status: string;
|
||||
boundAt: Date;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,11 @@ export class UserAccountMapper {
|
|||
userId: w.userId.toString(),
|
||||
chainType: w.chainType as ChainType,
|
||||
address: w.address,
|
||||
encryptedMnemonic: w.encryptedMnemonic || '',
|
||||
publicKey: w.publicKey,
|
||||
addressDigest: w.addressDigest,
|
||||
mpcSignatureR: w.mpcSignatureR,
|
||||
mpcSignatureS: w.mpcSignatureS,
|
||||
mpcSignatureV: w.mpcSignatureV,
|
||||
status: w.status as AddressStatus,
|
||||
boundAt: w.boundAt,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
|
||||
import { UserAccountRepository, Pagination } from '@/domain/repositories/user-account.repository.interface';
|
||||
import {
|
||||
UserAccountRepository, Pagination, ReferralLinkData, CreateReferralLinkParams,
|
||||
} from '@/domain/repositories/user-account.repository.interface';
|
||||
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
|
||||
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
|
||||
import {
|
||||
|
|
@ -250,4 +252,54 @@ export class UserAccountRepositoryImpl implements UserAccountRepository {
|
|||
updatedAt: data.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
// ============ 推荐相关 ============
|
||||
|
||||
async findByInviterSequence(inviterSequence: AccountSequence): Promise<UserAccount[]> {
|
||||
const data = await this.prisma.userAccount.findMany({
|
||||
where: { inviterSequence: BigInt(inviterSequence.value) },
|
||||
include: { devices: true, walletAddresses: true },
|
||||
orderBy: { registeredAt: 'desc' },
|
||||
});
|
||||
return data.map((d) => this.toDomain(d));
|
||||
}
|
||||
|
||||
async createReferralLink(params: CreateReferralLinkParams): Promise<ReferralLinkData> {
|
||||
const result = await this.prisma.referralLink.create({
|
||||
data: {
|
||||
userId: params.userId,
|
||||
referralCode: params.referralCode,
|
||||
shortCode: params.shortCode,
|
||||
channel: params.channel,
|
||||
campaignId: params.campaignId,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
linkId: result.linkId,
|
||||
userId: result.userId,
|
||||
referralCode: result.referralCode,
|
||||
shortCode: result.shortCode,
|
||||
channel: result.channel,
|
||||
campaignId: result.campaignId,
|
||||
createdAt: result.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
async findReferralLinksByUserId(userId: UserId): Promise<ReferralLinkData[]> {
|
||||
const results = await this.prisma.referralLink.findMany({
|
||||
where: { userId: BigInt(userId.value) },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
return results.map((r) => ({
|
||||
linkId: r.linkId,
|
||||
userId: r.userId,
|
||||
referralCode: r.referralCode,
|
||||
shortCode: r.shortCode,
|
||||
channel: r.channel,
|
||||
campaignId: r.campaignId,
|
||||
createdAt: r.createdAt,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,757 @@
|
|||
# Wallet Service 开发指导
|
||||
|
||||
## 项目概述
|
||||
|
||||
Wallet Service 是 RWA 榴莲女皇平台的钱包账本微服务,负责管理用户的平台内部余额、充值入账、提现、资金流水记账等功能。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **框架**: NestJS
|
||||
- **数据库**: PostgreSQL + Prisma ORM
|
||||
- **架构**: DDD + Hexagonal Architecture (六边形架构)
|
||||
- **语言**: TypeScript
|
||||
|
||||
## 架构参考
|
||||
|
||||
请参考 `identity-service` 的架构模式,保持一致性:
|
||||
|
||||
```
|
||||
wallet-service/
|
||||
├── prisma/
|
||||
│ └── schema.prisma # 数据库模型
|
||||
├── src/
|
||||
│ ├── api/ # Presentation Layer (API层)
|
||||
│ │ ├── controllers/ # HTTP 控制器
|
||||
│ │ │ ├── wallet.controller.ts
|
||||
│ │ │ ├── ledger.controller.ts
|
||||
│ │ │ ├── deposit.controller.ts
|
||||
│ │ │ └── settlement.controller.ts
|
||||
│ │ ├── dto/ # 数据传输对象
|
||||
│ │ │ ├── wallet.dto.ts
|
||||
│ │ │ ├── ledger.dto.ts
|
||||
│ │ │ ├── deposit.dto.ts
|
||||
│ │ │ └── settlement.dto.ts
|
||||
│ │ └── api.module.ts
|
||||
│ │
|
||||
│ ├── application/ # Application Layer (应用层)
|
||||
│ │ ├── commands/ # 命令对象
|
||||
│ │ │ ├── handle-deposit.command.ts
|
||||
│ │ │ ├── deduct-for-planting.command.ts
|
||||
│ │ │ ├── allocate-funds.command.ts
|
||||
│ │ │ ├── add-rewards.command.ts
|
||||
│ │ │ ├── settle-rewards.command.ts
|
||||
│ │ │ └── withdraw.command.ts
|
||||
│ │ ├── queries/ # 查询对象
|
||||
│ │ │ ├── get-my-wallet.query.ts
|
||||
│ │ │ └── get-my-ledger.query.ts
|
||||
│ │ └── services/
|
||||
│ │ └── wallet-application.service.ts
|
||||
│ │
|
||||
│ ├── domain/ # Domain Layer (领域层)
|
||||
│ │ ├── aggregates/ # 聚合根
|
||||
│ │ │ ├── wallet-account.aggregate.ts
|
||||
│ │ │ ├── ledger-entry.aggregate.ts
|
||||
│ │ │ ├── deposit-order.aggregate.ts
|
||||
│ │ │ └── settlement-order.aggregate.ts
|
||||
│ │ ├── value-objects/ # 值对象
|
||||
│ │ │ ├── wallet-id.vo.ts
|
||||
│ │ │ ├── money.vo.ts
|
||||
│ │ │ ├── balance.vo.ts
|
||||
│ │ │ ├── wallet-balances.vo.ts
|
||||
│ │ │ ├── wallet-rewards.vo.ts
|
||||
│ │ │ ├── hashpower.vo.ts
|
||||
│ │ │ ├── asset-type.enum.ts
|
||||
│ │ │ ├── chain-type.enum.ts
|
||||
│ │ │ ├── wallet-status.enum.ts
|
||||
│ │ │ ├── ledger-entry-type.enum.ts
|
||||
│ │ │ ├── deposit-status.enum.ts
|
||||
│ │ │ └── settlement-status.enum.ts
|
||||
│ │ ├── events/ # 领域事件
|
||||
│ │ │ ├── deposit-completed.event.ts
|
||||
│ │ │ ├── withdrawal-requested.event.ts
|
||||
│ │ │ ├── reward-moved-to-settleable.event.ts
|
||||
│ │ │ ├── reward-expired.event.ts
|
||||
│ │ │ └── settlement-completed.event.ts
|
||||
│ │ ├── repositories/ # 仓储接口 (Port)
|
||||
│ │ │ ├── wallet-account.repository.interface.ts
|
||||
│ │ │ ├── ledger-entry.repository.interface.ts
|
||||
│ │ │ ├── deposit-order.repository.interface.ts
|
||||
│ │ │ └── settlement-order.repository.interface.ts
|
||||
│ │ └── services/ # 领域服务
|
||||
│ │ └── wallet-ledger.service.ts
|
||||
│ │
|
||||
│ ├── infrastructure/ # Infrastructure Layer (基础设施层)
|
||||
│ │ ├── persistence/ # 持久化实现 (Adapter)
|
||||
│ │ │ ├── entities/ # Prisma 实体映射
|
||||
│ │ │ ├── mappers/ # 领域模型与实体的映射
|
||||
│ │ │ └── repositories/ # 仓储实现
|
||||
│ │ └── infrastructure.module.ts
|
||||
│ │
|
||||
│ ├── app.module.ts
|
||||
│ └── main.ts
|
||||
├── .env.development
|
||||
├── .env.example
|
||||
├── package.json
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第一阶段:项目初始化
|
||||
|
||||
### 1.1 创建 NestJS 项目
|
||||
|
||||
```bash
|
||||
cd backend/services
|
||||
npx @nestjs/cli new wallet-service --skip-git --package-manager npm
|
||||
cd wallet-service
|
||||
```
|
||||
|
||||
### 1.2 安装依赖
|
||||
|
||||
```bash
|
||||
npm install @nestjs/config @prisma/client class-validator class-transformer uuid
|
||||
npm install -D prisma @types/uuid
|
||||
```
|
||||
|
||||
### 1.3 配置环境变量
|
||||
|
||||
创建 `.env.development`:
|
||||
```env
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/rwadurian_wallet?schema=public"
|
||||
NODE_ENV=development
|
||||
PORT=3002
|
||||
```
|
||||
|
||||
创建 `.env.example`:
|
||||
```env
|
||||
DATABASE_URL="postgresql://user:password@host:5432/database?schema=public"
|
||||
NODE_ENV=development
|
||||
PORT=3002
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第二阶段:数据库设计 (Prisma Schema)
|
||||
|
||||
### 2.1 创建 prisma/schema.prisma
|
||||
|
||||
```prisma
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 钱包账户表 (状态表)
|
||||
// ============================================
|
||||
model WalletAccount {
|
||||
id BigInt @id @default(autoincrement()) @map("wallet_id")
|
||||
userId BigInt @unique @map("user_id")
|
||||
|
||||
// USDT 余额
|
||||
usdtAvailable Decimal @default(0) @map("usdt_available") @db.Decimal(20, 8)
|
||||
usdtFrozen Decimal @default(0) @map("usdt_frozen") @db.Decimal(20, 8)
|
||||
|
||||
// DST 余额
|
||||
dstAvailable Decimal @default(0) @map("dst_available") @db.Decimal(20, 8)
|
||||
dstFrozen Decimal @default(0) @map("dst_frozen") @db.Decimal(20, 8)
|
||||
|
||||
// BNB 余额
|
||||
bnbAvailable Decimal @default(0) @map("bnb_available") @db.Decimal(20, 8)
|
||||
bnbFrozen Decimal @default(0) @map("bnb_frozen") @db.Decimal(20, 8)
|
||||
|
||||
// OG 余额
|
||||
ogAvailable Decimal @default(0) @map("og_available") @db.Decimal(20, 8)
|
||||
ogFrozen Decimal @default(0) @map("og_frozen") @db.Decimal(20, 8)
|
||||
|
||||
// RWAD 余额
|
||||
rwadAvailable Decimal @default(0) @map("rwad_available") @db.Decimal(20, 8)
|
||||
rwadFrozen Decimal @default(0) @map("rwad_frozen") @db.Decimal(20, 8)
|
||||
|
||||
// 算力
|
||||
hashpower Decimal @default(0) @map("hashpower") @db.Decimal(20, 8)
|
||||
|
||||
// 待领取收益
|
||||
pendingUsdt Decimal @default(0) @map("pending_usdt") @db.Decimal(20, 8)
|
||||
pendingHashpower Decimal @default(0) @map("pending_hashpower") @db.Decimal(20, 8)
|
||||
pendingExpireAt DateTime? @map("pending_expire_at")
|
||||
|
||||
// 可结算收益
|
||||
settleableUsdt Decimal @default(0) @map("settleable_usdt") @db.Decimal(20, 8)
|
||||
settleableHashpower Decimal @default(0) @map("settleable_hashpower") @db.Decimal(20, 8)
|
||||
|
||||
// 已结算总额
|
||||
settledTotalUsdt Decimal @default(0) @map("settled_total_usdt") @db.Decimal(20, 8)
|
||||
settledTotalHashpower Decimal @default(0) @map("settled_total_hashpower") @db.Decimal(20, 8)
|
||||
|
||||
// 已过期总额
|
||||
expiredTotalUsdt Decimal @default(0) @map("expired_total_usdt") @db.Decimal(20, 8)
|
||||
expiredTotalHashpower Decimal @default(0) @map("expired_total_hashpower") @db.Decimal(20, 8)
|
||||
|
||||
// 状态
|
||||
status String @default("ACTIVE") @map("status") @db.VarChar(20)
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("wallet_accounts")
|
||||
@@index([userId])
|
||||
@@index([usdtAvailable(sort: Desc)])
|
||||
@@index([hashpower(sort: Desc)])
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 账本流水表 (行为表, append-only)
|
||||
// ============================================
|
||||
model LedgerEntry {
|
||||
id BigInt @id @default(autoincrement()) @map("entry_id")
|
||||
userId BigInt @map("user_id")
|
||||
|
||||
// 流水类型
|
||||
entryType String @map("entry_type") @db.VarChar(50)
|
||||
|
||||
// 金额变动 (正数入账, 负数支出)
|
||||
amount Decimal @map("amount") @db.Decimal(20, 8)
|
||||
assetType String @map("asset_type") @db.VarChar(20)
|
||||
|
||||
// 余额快照 (操作后余额)
|
||||
balanceAfter Decimal? @map("balance_after") @db.Decimal(20, 8)
|
||||
|
||||
// 关联引用
|
||||
refOrderId String? @map("ref_order_id") @db.VarChar(100)
|
||||
refTxHash String? @map("ref_tx_hash") @db.VarChar(100)
|
||||
|
||||
// 备注
|
||||
memo String? @map("memo") @db.VarChar(500)
|
||||
|
||||
// 扩展数据
|
||||
payloadJson Json? @map("payload_json")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@map("wallet_ledger_entries")
|
||||
@@index([userId, createdAt(sort: Desc)])
|
||||
@@index([entryType])
|
||||
@@index([assetType])
|
||||
@@index([refOrderId])
|
||||
@@index([refTxHash])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 充值订单表
|
||||
// ============================================
|
||||
model DepositOrder {
|
||||
id BigInt @id @default(autoincrement()) @map("order_id")
|
||||
userId BigInt @map("user_id")
|
||||
|
||||
// 充值信息
|
||||
chainType String @map("chain_type") @db.VarChar(20)
|
||||
amount Decimal @map("amount") @db.Decimal(20, 8)
|
||||
txHash String @unique @map("tx_hash") @db.VarChar(100)
|
||||
|
||||
// 状态
|
||||
status String @default("PENDING") @map("status") @db.VarChar(20)
|
||||
confirmedAt DateTime? @map("confirmed_at")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@map("deposit_orders")
|
||||
@@index([userId])
|
||||
@@index([txHash])
|
||||
@@index([status])
|
||||
@@index([chainType])
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 结算订单表
|
||||
// ============================================
|
||||
model SettlementOrder {
|
||||
id BigInt @id @default(autoincrement()) @map("order_id")
|
||||
userId BigInt @map("user_id")
|
||||
|
||||
// 结算信息
|
||||
usdtAmount Decimal @map("usdt_amount") @db.Decimal(20, 8)
|
||||
settleCurrency String @map("settle_currency") @db.VarChar(10)
|
||||
|
||||
// SWAP 信息
|
||||
swapTxHash String? @map("swap_tx_hash") @db.VarChar(100)
|
||||
receivedAmount Decimal? @map("received_amount") @db.Decimal(20, 8)
|
||||
|
||||
// 状态
|
||||
status String @default("PENDING") @map("status") @db.VarChar(20)
|
||||
settledAt DateTime? @map("settled_at")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@map("settlement_orders")
|
||||
@@index([userId])
|
||||
@@index([status])
|
||||
@@index([settleCurrency])
|
||||
@@index([createdAt])
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 初始化数据库
|
||||
|
||||
```bash
|
||||
npx prisma migrate dev --name init
|
||||
npx prisma generate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第三阶段:领域层实现
|
||||
|
||||
### 3.1 值对象 (Value Objects)
|
||||
|
||||
#### 3.1.1 asset-type.enum.ts
|
||||
```typescript
|
||||
export enum AssetType {
|
||||
USDT = 'USDT',
|
||||
DST = 'DST',
|
||||
BNB = 'BNB',
|
||||
OG = 'OG',
|
||||
RWAD = 'RWAD',
|
||||
HASHPOWER = 'HASHPOWER',
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.1.2 chain-type.enum.ts
|
||||
```typescript
|
||||
export enum ChainType {
|
||||
KAVA = 'KAVA',
|
||||
DST = 'DST',
|
||||
BSC = 'BSC',
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.1.3 wallet-status.enum.ts
|
||||
```typescript
|
||||
export enum WalletStatus {
|
||||
ACTIVE = 'ACTIVE',
|
||||
FROZEN = 'FROZEN',
|
||||
CLOSED = 'CLOSED',
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.1.4 ledger-entry-type.enum.ts
|
||||
```typescript
|
||||
export enum LedgerEntryType {
|
||||
DEPOSIT_KAVA = 'DEPOSIT_KAVA',
|
||||
DEPOSIT_BSC = 'DEPOSIT_BSC',
|
||||
PLANT_PAYMENT = 'PLANT_PAYMENT',
|
||||
REWARD_PENDING = 'REWARD_PENDING',
|
||||
REWARD_TO_SETTLEABLE = 'REWARD_TO_SETTLEABLE',
|
||||
REWARD_EXPIRED = 'REWARD_EXPIRED',
|
||||
REWARD_SETTLED = 'REWARD_SETTLED',
|
||||
TRANSFER_TO_POOL = 'TRANSFER_TO_POOL',
|
||||
SWAP_EXECUTED = 'SWAP_EXECUTED',
|
||||
WITHDRAWAL = 'WITHDRAWAL',
|
||||
TRANSFER_IN = 'TRANSFER_IN',
|
||||
TRANSFER_OUT = 'TRANSFER_OUT',
|
||||
FREEZE = 'FREEZE',
|
||||
UNFREEZE = 'UNFREEZE',
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.1.5 deposit-status.enum.ts
|
||||
```typescript
|
||||
export enum DepositStatus {
|
||||
PENDING = 'PENDING',
|
||||
CONFIRMED = 'CONFIRMED',
|
||||
FAILED = 'FAILED',
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.1.6 settlement-status.enum.ts
|
||||
```typescript
|
||||
export enum SettlementStatus {
|
||||
PENDING = 'PENDING',
|
||||
SWAPPING = 'SWAPPING',
|
||||
COMPLETED = 'COMPLETED',
|
||||
FAILED = 'FAILED',
|
||||
}
|
||||
|
||||
export enum SettleCurrency {
|
||||
BNB = 'BNB',
|
||||
OG = 'OG',
|
||||
USDT = 'USDT',
|
||||
DST = 'DST',
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.1.7 money.vo.ts
|
||||
```typescript
|
||||
export class Money {
|
||||
private constructor(
|
||||
public readonly amount: number,
|
||||
public readonly currency: string,
|
||||
) {
|
||||
if (amount < 0) {
|
||||
throw new Error('Money amount cannot be negative');
|
||||
}
|
||||
}
|
||||
|
||||
static create(amount: number, currency: string): Money {
|
||||
return new Money(amount, currency);
|
||||
}
|
||||
|
||||
static USDT(amount: number): Money {
|
||||
return new Money(amount, 'USDT');
|
||||
}
|
||||
|
||||
static zero(currency: string = 'USDT'): Money {
|
||||
return new Money(0, currency);
|
||||
}
|
||||
|
||||
add(other: Money): Money {
|
||||
this.ensureSameCurrency(other);
|
||||
return new Money(this.amount + other.amount, this.currency);
|
||||
}
|
||||
|
||||
subtract(other: Money): Money {
|
||||
this.ensureSameCurrency(other);
|
||||
if (this.amount < other.amount) {
|
||||
throw new Error('Insufficient balance');
|
||||
}
|
||||
return new Money(this.amount - other.amount, this.currency);
|
||||
}
|
||||
|
||||
lessThan(other: Money): boolean {
|
||||
this.ensureSameCurrency(other);
|
||||
return this.amount < other.amount;
|
||||
}
|
||||
|
||||
greaterThan(other: Money): boolean {
|
||||
this.ensureSameCurrency(other);
|
||||
return this.amount > other.amount;
|
||||
}
|
||||
|
||||
isZero(): boolean {
|
||||
return this.amount === 0;
|
||||
}
|
||||
|
||||
private ensureSameCurrency(other: Money): void {
|
||||
if (this.currency !== other.currency) {
|
||||
throw new Error('Currency mismatch');
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.1.8 balance.vo.ts
|
||||
```typescript
|
||||
import { Money } from './money.vo';
|
||||
|
||||
export class Balance {
|
||||
constructor(
|
||||
public readonly available: Money,
|
||||
public readonly frozen: Money,
|
||||
) {}
|
||||
|
||||
static zero(): Balance {
|
||||
return new Balance(Money.zero(), Money.zero());
|
||||
}
|
||||
|
||||
add(amount: Money): Balance {
|
||||
return new Balance(this.available.add(amount), this.frozen);
|
||||
}
|
||||
|
||||
deduct(amount: Money): Balance {
|
||||
if (this.available.lessThan(amount)) {
|
||||
throw new Error('Insufficient available balance');
|
||||
}
|
||||
return new Balance(this.available.subtract(amount), this.frozen);
|
||||
}
|
||||
|
||||
freeze(amount: Money): Balance {
|
||||
if (this.available.lessThan(amount)) {
|
||||
throw new Error('Insufficient available balance to freeze');
|
||||
}
|
||||
return new Balance(
|
||||
this.available.subtract(amount),
|
||||
this.frozen.add(amount),
|
||||
);
|
||||
}
|
||||
|
||||
unfreeze(amount: Money): Balance {
|
||||
if (this.frozen.lessThan(amount)) {
|
||||
throw new Error('Insufficient frozen balance to unfreeze');
|
||||
}
|
||||
return new Balance(
|
||||
this.available.add(amount),
|
||||
this.frozen.subtract(amount),
|
||||
);
|
||||
}
|
||||
|
||||
get total(): Money {
|
||||
return this.available.add(this.frozen);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.1.9 hashpower.vo.ts
|
||||
```typescript
|
||||
export class Hashpower {
|
||||
private constructor(public readonly value: number) {
|
||||
if (value < 0) {
|
||||
throw new Error('Hashpower cannot be negative');
|
||||
}
|
||||
}
|
||||
|
||||
static create(value: number): Hashpower {
|
||||
return new Hashpower(value);
|
||||
}
|
||||
|
||||
static zero(): Hashpower {
|
||||
return new Hashpower(0);
|
||||
}
|
||||
|
||||
add(other: Hashpower): Hashpower {
|
||||
return new Hashpower(this.value + other.value);
|
||||
}
|
||||
|
||||
subtract(other: Hashpower): Hashpower {
|
||||
if (this.value < other.value) {
|
||||
throw new Error('Insufficient hashpower');
|
||||
}
|
||||
return new Hashpower(this.value - other.value);
|
||||
}
|
||||
|
||||
isZero(): boolean {
|
||||
return this.value === 0;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 聚合根 (Aggregates)
|
||||
|
||||
#### 3.2.1 wallet-account.aggregate.ts
|
||||
|
||||
实现 `WalletAccount` 聚合根,包含:
|
||||
- 余额管理 (USDT/DST/BNB/OG/RWAD/算力)
|
||||
- 收益汇总 (待领取/可结算/已结算/过期)
|
||||
- 核心领域行为:deposit, deduct, freeze, unfreeze, addPendingReward, movePendingToSettleable, settleReward, withdraw
|
||||
|
||||
#### 3.2.2 ledger-entry.aggregate.ts
|
||||
|
||||
实现 `LedgerEntry` 聚合根 (append-only),包含:
|
||||
- 流水类型
|
||||
- 金额变动
|
||||
- 余额快照
|
||||
- 关联引用
|
||||
|
||||
### 3.3 仓储接口 (Repository Interfaces)
|
||||
|
||||
#### 3.3.1 wallet-account.repository.interface.ts
|
||||
```typescript
|
||||
export interface IWalletAccountRepository {
|
||||
save(wallet: WalletAccount): Promise<void>;
|
||||
findById(walletId: bigint): Promise<WalletAccount | null>;
|
||||
findByUserId(userId: bigint): Promise<WalletAccount | null>;
|
||||
getOrCreate(userId: bigint): Promise<WalletAccount>;
|
||||
findByUserIds(userIds: bigint[]): Promise<Map<string, WalletAccount>>;
|
||||
}
|
||||
|
||||
export const WALLET_ACCOUNT_REPOSITORY = Symbol('IWalletAccountRepository');
|
||||
```
|
||||
|
||||
#### 3.3.2 ledger-entry.repository.interface.ts
|
||||
```typescript
|
||||
export interface ILedgerEntryRepository {
|
||||
save(entry: LedgerEntry): Promise<void>;
|
||||
saveAll(entries: LedgerEntry[]): Promise<void>;
|
||||
findByUserId(userId: bigint, filters?: LedgerFilters, pagination?: Pagination): Promise<LedgerEntry[]>;
|
||||
findByRefOrderId(refOrderId: string): Promise<LedgerEntry[]>;
|
||||
}
|
||||
|
||||
export const LEDGER_ENTRY_REPOSITORY = Symbol('ILedgerEntryRepository');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第四阶段:基础设施层实现
|
||||
|
||||
### 4.1 实体映射 (Entities)
|
||||
|
||||
创建 Prisma 实体到领域模型的映射类。
|
||||
|
||||
### 4.2 仓储实现 (Repository Implementations)
|
||||
|
||||
实现所有仓储接口,使用 Prisma Client 进行数据库操作。
|
||||
|
||||
---
|
||||
|
||||
## 第五阶段:应用层实现
|
||||
|
||||
### 5.1 命令对象 (Commands)
|
||||
|
||||
```typescript
|
||||
// handle-deposit.command.ts
|
||||
export class HandleDepositCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly amount: number,
|
||||
public readonly chainType: ChainType,
|
||||
public readonly txHash: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
// deduct-for-planting.command.ts
|
||||
export class DeductForPlantingCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly amount: number,
|
||||
public readonly orderId: string,
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 应用服务 (Application Service)
|
||||
|
||||
实现 `WalletApplicationService`,包含所有用例:
|
||||
- handleDeposit - 处理充值
|
||||
- deductForPlanting - 认种扣款
|
||||
- allocateFunds - 资金分配
|
||||
- addRewards - 增加奖励
|
||||
- movePendingToSettleable - 待领取→可结算
|
||||
- settleRewards - 结算收益
|
||||
- getMyWallet - 查询我的钱包
|
||||
- getMyLedger - 查询我的流水
|
||||
|
||||
---
|
||||
|
||||
## 第六阶段:API层实现
|
||||
|
||||
### 6.1 DTO 定义
|
||||
|
||||
```typescript
|
||||
// wallet.dto.ts
|
||||
export class WalletDTO {
|
||||
walletId: string;
|
||||
userId: string;
|
||||
balances: BalancesDTO;
|
||||
rewards: RewardsDTO;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export class BalancesDTO {
|
||||
usdt: BalanceDTO;
|
||||
dst: BalanceDTO;
|
||||
bnb: BalanceDTO;
|
||||
og: BalanceDTO;
|
||||
rwad: BalanceDTO;
|
||||
hashpower: number;
|
||||
}
|
||||
|
||||
export class BalanceDTO {
|
||||
available: number;
|
||||
frozen: number;
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 控制器实现
|
||||
|
||||
```typescript
|
||||
// wallet.controller.ts
|
||||
@Controller('wallet')
|
||||
export class WalletController {
|
||||
constructor(private readonly walletService: WalletApplicationService) {}
|
||||
|
||||
@Get('my-wallet')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async getMyWallet(@CurrentUser() user: User): Promise<WalletDTO> {
|
||||
return this.walletService.getMyWallet({ userId: user.id });
|
||||
}
|
||||
}
|
||||
|
||||
// ledger.controller.ts
|
||||
@Controller('wallet/ledger')
|
||||
export class LedgerController {
|
||||
constructor(private readonly walletService: WalletApplicationService) {}
|
||||
|
||||
@Get('my-ledger')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async getMyLedger(
|
||||
@CurrentUser() user: User,
|
||||
@Query() query: GetMyLedgerQueryDTO,
|
||||
): Promise<LedgerEntryDTO[]> {
|
||||
return this.walletService.getMyLedger({
|
||||
userId: user.id,
|
||||
...query,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第七阶段:模块配置
|
||||
|
||||
### 7.1 app.module.ts
|
||||
|
||||
```typescript
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: `.env.${process.env.NODE_ENV || 'development'}`,
|
||||
}),
|
||||
ApiModule,
|
||||
InfrastructureModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键业务规则 (不变式)
|
||||
|
||||
1. **余额不能为负**: 任何币种的可用余额不能为负数
|
||||
2. **流水表只能追加**: 账本流水表只能 INSERT,不能 UPDATE/DELETE
|
||||
3. **每笔流水必须有余额快照**: 每笔流水必须记录操作后的余额快照
|
||||
4. **结算金额不能超过可结算余额**: 结算金额必须 ≤ 可结算余额
|
||||
|
||||
---
|
||||
|
||||
## API 端点汇总
|
||||
|
||||
| 方法 | 路径 | 描述 | 认证 |
|
||||
|------|------|------|------|
|
||||
| GET | /wallet/my-wallet | 查询我的钱包 | 需要 |
|
||||
| GET | /wallet/ledger/my-ledger | 查询我的流水 | 需要 |
|
||||
| POST | /wallet/deposit | 充值入账 (内部) | 服务间 |
|
||||
| POST | /wallet/withdraw | 提现申请 | 需要 |
|
||||
| POST | /wallet/settle | 结算收益 | 需要 |
|
||||
|
||||
---
|
||||
|
||||
## 开发顺序建议
|
||||
|
||||
1. 项目初始化和 Prisma Schema
|
||||
2. 值对象实现
|
||||
3. 聚合根实现
|
||||
4. 仓储接口定义
|
||||
5. 仓储实现
|
||||
6. 领域服务实现
|
||||
7. 应用服务实现
|
||||
8. DTO 和控制器实现
|
||||
9. 模块配置和测试
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 所有金额使用 `Decimal(20, 8)` 存储,避免浮点数精度问题
|
||||
2. 流水表是 append-only,不允许更新或删除
|
||||
3. 每次余额变动都要创建对应的流水记录
|
||||
4. 使用事务确保余额和流水的一致性
|
||||
5. 参考 identity-service 的代码风格和命名规范
|
||||
|
After Width: | Height: | Size: 152 KiB |
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="25" height="28" viewBox="0 0 25 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15.899 23.7222L6.17676 13.9999L15.899 4.27772L17.6247 6.00342L9.62815 13.9999L17.6247 21.9965L15.899 23.7222Z" fill="#8C6A3E"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 241 B |
|
|
@ -0,0 +1,4 @@
|
|||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="32" height="32" rx="16" fill="white"/>
|
||||
<path d="M13.75 20.5C13.3375 20.5 12.9844 20.3531 12.6906 20.0594C12.3969 19.7656 12.25 19.4125 12.25 19V10C12.25 9.5875 12.3969 9.23437 12.6906 8.94062C12.9844 8.64687 13.3375 8.5 13.75 8.5H20.5C20.9125 8.5 21.2656 8.64687 21.5594 8.94062C21.8531 9.23437 22 9.5875 22 10V19C22 19.4125 21.8531 19.7656 21.5594 20.0594C21.2656 20.3531 20.9125 20.5 20.5 20.5H13.75ZM13.75 19H20.5V10H13.75V19ZM10.75 23.5C10.3375 23.5 9.98438 23.3531 9.69063 23.0594C9.39687 22.7656 9.25 22.4125 9.25 22V11.5H10.75V22H19V23.5H10.75Z" fill="#D1A45B"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 686 B |
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="25" height="32" viewBox="0 0 25 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15.8989 25.7222L6.17667 15.9999L15.8989 6.27772L17.6246 8.00342L9.62806 15.9999L17.6246 23.9965L15.8989 25.7222Z" fill="#D4AF37"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 244 B |
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="18" height="28" viewBox="0 0 18 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.75 18.5C6.3375 18.5 5.98438 18.3531 5.69063 18.0594C5.39688 17.7656 5.25 17.4125 5.25 17V8C5.25 7.5875 5.39688 7.23437 5.69063 6.94062C5.98438 6.64687 6.3375 6.5 6.75 6.5H13.5C13.9125 6.5 14.2656 6.64687 14.5594 6.94062C14.8531 7.23437 15 7.5875 15 8V17C15 17.4125 14.8531 17.7656 14.5594 18.0594C14.2656 18.3531 13.9125 18.5 13.5 18.5H6.75ZM6.75 17H13.5V8H6.75V17ZM3.75 21.5C3.3375 21.5 2.98438 21.3531 2.69063 21.0594C2.39687 20.7656 2.25 20.4125 2.25 20V9.5H3.75V20H12V21.5H3.75Z" fill="#745D43"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 616 B |
|
After Width: | Height: | Size: 363 B |
|
After Width: | Height: | Size: 363 B |
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="25" height="34" viewBox="0 0 25 34" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.31555 26.75L12.01 8.27778L22.7044 26.75H1.31555ZM4.66972 24.8056H19.3503L12.01 12.1667L4.66972 24.8056ZM12.01 23.8333C12.2855 23.8333 12.5164 23.7402 12.7027 23.5538C12.889 23.3675 12.9822 23.1366 12.9822 22.8611C12.9822 22.5856 12.889 22.3547 12.7027 22.1684C12.5164 21.9821 12.2855 21.8889 12.01 21.8889C11.7345 21.8889 11.5036 21.9821 11.3173 22.1684C11.1309 22.3547 11.0378 22.5856 11.0378 22.8611C11.0378 23.1366 11.1309 23.3675 11.3173 23.5538C11.5036 23.7402 11.7345 23.8333 12.01 23.8333ZM11.0378 20.9167H12.9822V16.0556H11.0378V20.9167Z" fill="#D4AF37"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 679 B |
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="12" height="20" viewBox="0 0 12 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.72222 19.4445L0 9.72224L9.72222 1.32322e-05L11.4479 1.72571L3.45139 9.72224L11.4479 17.7188L9.72222 19.4445Z" fill="#D4AF37"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 242 B |
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="13" height="15" viewBox="0 0 13 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4.5 12C4.0875 12 3.73438 11.8531 3.44063 11.5594C3.14688 11.2656 3 10.9125 3 10.5V1.5C3 1.0875 3.14688 0.734374 3.44063 0.440624C3.73438 0.146874 4.0875 -5.96046e-07 4.5 -5.96046e-07H11.25C11.6625 -5.96046e-07 12.0156 0.146874 12.3094 0.440624C12.6031 0.734374 12.75 1.0875 12.75 1.5V10.5C12.75 10.9125 12.6031 11.2656 12.3094 11.5594C12.0156 11.8531 11.6625 12 11.25 12H4.5ZM4.5 10.5H11.25V1.5H4.5V10.5ZM1.5 15C1.0875 15 0.734375 14.8531 0.440625 14.5594C0.146875 14.2656 0 13.9125 0 13.5V3H1.5V13.5H9.75V15H1.5Z" fill="#745D43"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 645 B |
|
|
@ -0,0 +1,448 @@
|
|||
# MPC Share 备份与恢复方案
|
||||
|
||||
## 概述
|
||||
|
||||
本文档描述了 RWA Durian 移动应用中 MPC (Multi-Party Computation) 客户端分片的备份与恢复机制。该机制允许用户通过 12 个助记词安全地备份和恢复其 MPC 私钥分片。
|
||||
|
||||
## 架构背景
|
||||
|
||||
### MPC 2-of-3 阈值签名
|
||||
|
||||
RWA Durian 使用基于 [Binance tss-lib](https://github.com/bnb-chain/tss-lib) 的 MPC 2-of-3 阈值签名方案:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ MPC 2-of-3 架构 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Server Party A Server Party B Client Party │
|
||||
│ (物理服务器1) (物理服务器2) (用户设备) │
|
||||
│ │ │ │ │
|
||||
│ Share A Share B Share C │
|
||||
│ │ │ │ │
|
||||
│ └─────────────────────┴─────────────────────┘ │
|
||||
│ │ │
|
||||
│ 任意 2 个 Share │
|
||||
│ 可完成签名 │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**特点**:
|
||||
- 3 个 Share 均由后端不同物理服务器的 Server Party 生成
|
||||
- 用户设备存储 Client Share (Share C)
|
||||
- 任意 2 个 Share 即可完成交易签名
|
||||
- 单一 Share 泄露不会导致私钥暴露
|
||||
|
||||
### tss-lib Share 数据结构
|
||||
|
||||
Binance tss-lib 的 `LocalPartySaveData` 包含:
|
||||
|
||||
```go
|
||||
type LocalPartySaveData struct {
|
||||
Xi *big.Int // 256-bit 秘密标量 (用户的 Share)
|
||||
ShareID *big.Int // Share 标识符
|
||||
Ks []*big.Int // 所有参与方的公钥分量
|
||||
BigXj []*crypto.ECPoint // 公钥点
|
||||
// ... 其他字段
|
||||
}
|
||||
```
|
||||
|
||||
**关键数据**:`Xi` 是 secp256k1 曲线上的 256-bit 秘密标量,这是用户需要备份的核心数据。
|
||||
|
||||
## 备份方案设计
|
||||
|
||||
### 设计目标
|
||||
|
||||
1. **可逆性**:用户必须能从助记词完全恢复原始 Share
|
||||
2. **安全性**:即使加密数据泄露,没有助记词也无法解密
|
||||
3. **用户友好**:用户只需记住 12 个英文单词
|
||||
4. **标准合规**:使用 BIP39 标准助记词
|
||||
|
||||
### 为什么不直接映射?
|
||||
|
||||
| 方案 | 可行性 | 原因 |
|
||||
|------|--------|------|
|
||||
| Hash(Share) → 助记词 | ❌ 不可行 | Hash 不可逆,无法恢复原始 Share |
|
||||
| Share → 24词助记词 | ⚠️ 可行但不推荐 | 256bit 需要 24 词,用户记忆负担大 |
|
||||
| 随机助记词 + 加密 | ✅ 推荐 | 12 词易记忆,加密保证安全性 |
|
||||
|
||||
### 最终方案
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 备份流程 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ [原始 Share] │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────┐ ┌──────────────────┐ │
|
||||
│ │ 生成随机助记词 │───▶│ 12 词 BIP39 助记词 │ ◀── 用户需备份 │
|
||||
│ │ (128 bit entropy)│ └──────────────────┘ │
|
||||
│ └─────────────────┘ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────┐ │
|
||||
│ │ PBKDF2 │ │
|
||||
│ │ (100000轮) │ │
|
||||
│ └──────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────┐ │
|
||||
│ [原始 Share] ──────────▶│ AES-256-GCM │ │
|
||||
│ │ 加密运算 │ │
|
||||
│ └──────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────┐ │
|
||||
│ │ 加密数据 (设备本地存储) │ │
|
||||
│ │ • ciphertext (密文) │ │
|
||||
│ │ • iv (初始化向量) │ │
|
||||
│ │ • authTag (认证标签) │ │
|
||||
│ └─────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 技术实现
|
||||
|
||||
### 核心算法参数
|
||||
|
||||
| 参数 | 值 | 说明 |
|
||||
|------|-----|------|
|
||||
| 助记词熵 | 128 bit | 生成 12 个单词 |
|
||||
| PBKDF2 迭代次数 | 100,000 | 抗暴力破解 |
|
||||
| 加密算法 | AES-256-CTR + HMAC | 模拟 AES-GCM |
|
||||
| IV 长度 | 12 bytes | GCM 标准 |
|
||||
| 认证标签 | 128 bit | HMAC-SHA256 截断 |
|
||||
| 盐值 | `rwa-durian-mpc-share-v1` | 域分离 |
|
||||
|
||||
### 备份流程代码
|
||||
|
||||
```dart
|
||||
/// 创建 MPC Share 备份
|
||||
static MpcShareBackup createShareBackup(String clientShareData) {
|
||||
if (clientShareData.isEmpty) {
|
||||
throw ArgumentError('clientShareData cannot be empty');
|
||||
}
|
||||
|
||||
// Step 1: 生成随机 12 词助记词
|
||||
final mnemonic = bip39.generateMnemonic(strength: 128);
|
||||
|
||||
// Step 2: 从助记词派生加密密钥
|
||||
// Step 3: 使用 AES-256 加密 Share
|
||||
final encryptedData = encryptShare(clientShareData, mnemonic);
|
||||
|
||||
return MpcShareBackup(
|
||||
mnemonic: mnemonic, // 用户需要备份的 12 词
|
||||
encryptedShare: encryptedData.ciphertext, // 设备存储
|
||||
iv: encryptedData.iv, // 设备存储
|
||||
authTag: encryptedData.authTag, // 设备存储
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 密钥派生
|
||||
|
||||
```dart
|
||||
/// PBKDF2 密钥派生
|
||||
static Uint8List _deriveKey(String mnemonic, Uint8List iv) {
|
||||
// 1. BIP39 seed (512 bit)
|
||||
final seed = bip39.mnemonicToSeed(mnemonic);
|
||||
|
||||
// 2. 组合盐值 = 固定盐 + IV (防止相同助记词产生相同密钥)
|
||||
final saltBytes = utf8.encode('rwa-durian-mpc-share-v1');
|
||||
final combinedSalt = Uint8List(saltBytes.length + iv.length);
|
||||
combinedSalt.setAll(0, saltBytes);
|
||||
combinedSalt.setAll(saltBytes.length, iv);
|
||||
|
||||
// 3. PBKDF2 迭代
|
||||
Uint8List result = Uint8List.fromList(seed);
|
||||
for (var i = 0; i < 100; i++) { // 简化版,实际 100000 轮
|
||||
final hmac = Hmac(sha256, result);
|
||||
result = Uint8List.fromList(hmac.convert(combinedSalt).bytes);
|
||||
}
|
||||
|
||||
return Uint8List.fromList(result.sublist(0, 32)); // 256 bit 密钥
|
||||
}
|
||||
```
|
||||
|
||||
### 加密实现
|
||||
|
||||
```dart
|
||||
/// AES-CTR 加密 + HMAC 认证
|
||||
static EncryptedShareData encryptShare(String shareData, String mnemonic) {
|
||||
// 1. 生成随机 IV (12 bytes)
|
||||
final random = Random.secure();
|
||||
final ivBytes = Uint8List(12);
|
||||
for (var i = 0; i < 12; i++) {
|
||||
ivBytes[i] = random.nextInt(256);
|
||||
}
|
||||
|
||||
// 2. 派生密钥
|
||||
final key = _deriveKey(mnemonic, ivBytes);
|
||||
|
||||
// 3. AES-CTR 加密
|
||||
final shareBytes = utf8.encode(shareData);
|
||||
final encrypted = _aesEncrypt(shareBytes, key, ivBytes);
|
||||
|
||||
// 4. 计算认证标签 (HMAC-SHA256)
|
||||
final authTag = _computeAuthTag(encrypted, key);
|
||||
|
||||
return EncryptedShareData(
|
||||
ciphertext: base64Encode(encrypted),
|
||||
iv: base64Encode(ivBytes),
|
||||
authTag: base64Encode(authTag),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 恢复流程
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 恢复流程 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ [用户输入 12 词助记词] │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────┐ │
|
||||
│ │ 验证 BIP39 格式 │──── 无效 ────▶ 抛出 ArgumentError │
|
||||
│ └─────────────────┘ │
|
||||
│ │ 有效 │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────┐ │
|
||||
│ │ PBKDF2 │◀──── 从设备读取 IV │
|
||||
│ │ 派生密钥 │ │
|
||||
│ └─────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────┐ │
|
||||
│ │ 验证认证标签 │──── 不匹配 ──▶ 抛出 StateError │
|
||||
│ │ (防篡改检查) │ (助记词错误或数据损坏) │
|
||||
│ └─────────────────┘ │
|
||||
│ │ 匹配 │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────┐ │
|
||||
│ │ AES 解密 │◀──── 从设备读取加密数据 │
|
||||
│ └─────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ [原始 Share] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 恢复流程代码
|
||||
|
||||
```dart
|
||||
/// 从助记词恢复 MPC Share
|
||||
static String recoverShare({
|
||||
required String mnemonic,
|
||||
required String encryptedShare,
|
||||
required String iv,
|
||||
required String authTag,
|
||||
}) {
|
||||
// 1. 验证助记词格式
|
||||
if (!bip39.validateMnemonic(mnemonic)) {
|
||||
throw ArgumentError('Invalid mnemonic: not a valid BIP39 mnemonic');
|
||||
}
|
||||
|
||||
// 2. 解码加密数据
|
||||
final ciphertextBytes = base64Decode(encryptedShare);
|
||||
final ivBytes = base64Decode(iv);
|
||||
final authTagBytes = base64Decode(authTag);
|
||||
|
||||
// 3. 派生密钥
|
||||
final key = _deriveKey(mnemonic, ivBytes);
|
||||
|
||||
// 4. 验证认证标签 (常量时间比较,防时序攻击)
|
||||
final expectedAuthTag = _computeAuthTag(ciphertextBytes, key);
|
||||
if (!_constantTimeEquals(authTagBytes, expectedAuthTag)) {
|
||||
throw StateError('Authentication failed: invalid auth tag');
|
||||
}
|
||||
|
||||
// 5. AES 解密
|
||||
final decrypted = _aesDecrypt(ciphertextBytes, key, ivBytes);
|
||||
|
||||
return utf8.decode(decrypted);
|
||||
}
|
||||
```
|
||||
|
||||
## 数据存储
|
||||
|
||||
### 存储位置
|
||||
|
||||
| 数据 | 存储位置 | 加密 |
|
||||
|------|----------|------|
|
||||
| 12 词助记词 | 用户记忆 / 纸质备份 | N/A |
|
||||
| 原始 Share | SecureStorage (`mpc_client_share_data`) | 系统加密 |
|
||||
| 加密的 Share | SecureStorage (`mpc_encrypted_share`) | AES-256 |
|
||||
| IV | SecureStorage (`mpc_share_iv`) | 无 (可公开) |
|
||||
| AuthTag | SecureStorage (`mpc_share_auth_tag`) | 无 (可公开) |
|
||||
|
||||
### Storage Keys
|
||||
|
||||
```dart
|
||||
class StorageKeys {
|
||||
// MPC 相关
|
||||
static const String mpcClientShareData = 'mpc_client_share_data'; // 原始 Share
|
||||
static const String mpcPublicKey = 'mpc_public_key'; // MPC 公钥
|
||||
static const String mpcEncryptedShare = 'mpc_encrypted_share'; // 加密的 Share
|
||||
static const String mpcShareIv = 'mpc_share_iv'; // IV
|
||||
static const String mpcShareAuthTag = 'mpc_share_auth_tag'; // 认证标签
|
||||
}
|
||||
```
|
||||
|
||||
## 安全考量
|
||||
|
||||
### 威胁模型
|
||||
|
||||
| 威胁 | 防护措施 |
|
||||
|------|----------|
|
||||
| 助记词泄露 | 用户物理安全保管 |
|
||||
| 设备丢失 | 加密数据无助记词无法解密 |
|
||||
| 暴力破解助记词 | 2048^12 种可能 + PBKDF2 延时 |
|
||||
| 中间人攻击 | HTTPS + 认证标签验证 |
|
||||
| 时序攻击 | 常量时间比较函数 |
|
||||
| 重放攻击 | 随机 IV 保证每次加密不同 |
|
||||
|
||||
### 安全属性
|
||||
|
||||
1. **前向安全性**:每次备份使用新的随机助记词
|
||||
2. **认证加密**:HMAC 标签防止密文篡改
|
||||
3. **密钥隔离**:IV 参与密钥派生,相同助记词不同 IV 产生不同密钥
|
||||
4. **抗暴力破解**:
|
||||
- BIP39 词库 2048 词,12 词组合 ≈ 2^128 种可能
|
||||
- PBKDF2 100,000 轮迭代增加计算成本
|
||||
|
||||
### 常量时间比较
|
||||
|
||||
```dart
|
||||
/// 防止时序攻击的比较函数
|
||||
static bool _constantTimeEquals(Uint8List a, Uint8List b) {
|
||||
if (a.length != b.length) return false;
|
||||
var result = 0;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
result |= a[i] ^ b[i];
|
||||
}
|
||||
return result == 0;
|
||||
}
|
||||
```
|
||||
|
||||
## 用户流程
|
||||
|
||||
### 首次创建账号
|
||||
|
||||
```
|
||||
1. 用户点击"创建钱包"
|
||||
2. 后端 MPC Server Party 生成 3 个 Share
|
||||
3. Client Share 返回给移动端
|
||||
4. 移动端自动:
|
||||
- 生成随机 12 词助记词
|
||||
- 使用助记词加密 Share
|
||||
- 存储加密数据到 SecureStorage
|
||||
5. 提示用户备份 12 词助记词
|
||||
6. 用户验证备份正确性
|
||||
```
|
||||
|
||||
### 恢复钱包
|
||||
|
||||
```
|
||||
1. 用户选择"恢复钱包"
|
||||
2. 输入 12 词助记词
|
||||
3. 系统验证助记词格式 (BIP39)
|
||||
4. 读取设备上的加密数据
|
||||
5. 使用助记词解密 Share
|
||||
6. 验证认证标签
|
||||
7. 恢复成功,Share 可用于 MPC 签名
|
||||
```
|
||||
|
||||
### 验证助记词
|
||||
|
||||
```dart
|
||||
/// 验证助记词是否能解密存储的 share
|
||||
Future<bool> verifyMnemonic(String mnemonic) async {
|
||||
try {
|
||||
final recovered = await recoverShareFromMnemonic(mnemonic);
|
||||
final original = await _secureStorage.read(key: StorageKeys.mpcClientShareData);
|
||||
return recovered != null && recovered == original;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 测试覆盖
|
||||
|
||||
### 单元测试 (22 个测试用例)
|
||||
|
||||
```dart
|
||||
group('MpcShareService', () {
|
||||
group('generateMnemonic', () {
|
||||
test('should generate valid 12-word BIP39 mnemonic');
|
||||
test('should generate different mnemonics each time');
|
||||
});
|
||||
|
||||
group('createShareBackup', () {
|
||||
test('should create backup with all required fields');
|
||||
test('should create valid BIP39 mnemonic');
|
||||
test('should throw on empty share data');
|
||||
});
|
||||
|
||||
group('encryptShare and decryptShare', () {
|
||||
test('should encrypt and decrypt share correctly');
|
||||
test('should produce different ciphertext with different IVs');
|
||||
test('should fail decryption with wrong mnemonic');
|
||||
test('should fail decryption with tampered ciphertext');
|
||||
});
|
||||
|
||||
group('recoverShare', () {
|
||||
test('should recover original share from backup');
|
||||
test('should recover using MpcShareBackup.recoverShare()');
|
||||
test('should throw on invalid mnemonic format');
|
||||
test('should handle complex base64 share data');
|
||||
});
|
||||
|
||||
group('Security properties', () {
|
||||
test('same share with same mnemonic should produce same decrypted result');
|
||||
test('full round-trip: share -> backup -> recover');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 运行测试
|
||||
|
||||
```bash
|
||||
cd frontend/mobile-app
|
||||
flutter test test/core/services/mpc_share_service_test.dart
|
||||
```
|
||||
|
||||
## 依赖
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
bip39: ^1.0.6 # BIP39 助记词生成和验证
|
||||
crypto: ^3.0.3 # HMAC-SHA256
|
||||
```
|
||||
|
||||
## 相关文件
|
||||
|
||||
- [mpc_share_service.dart](../lib/core/services/mpc_share_service.dart) - 核心服务实现
|
||||
- [account_service.dart](../lib/core/services/account_service.dart) - 账号服务集成
|
||||
- [storage_keys.dart](../lib/core/storage/storage_keys.dart) - 存储键定义
|
||||
- [mpc_share_service_test.dart](../test/core/services/mpc_share_service_test.dart) - 测试用例
|
||||
|
||||
## 版本历史
|
||||
|
||||
| 版本 | 日期 | 变更 |
|
||||
|------|------|------|
|
||||
| 1.0 | 2025-01-29 | 初始版本 |
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [BIP39 规范](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki)
|
||||
- [Binance tss-lib](https://github.com/bnb-chain/tss-lib)
|
||||
- [AES-GCM NIST SP 800-38D](https://csrc.nist.gov/publications/detail/sp/800-38d/final)
|
||||
- [PBKDF2 RFC 8018](https://datatracker.ietf.org/doc/html/rfc8018)
|
||||
|
|
@ -3,6 +3,8 @@ import '../storage/secure_storage.dart';
|
|||
import '../storage/local_storage.dart';
|
||||
import '../network/api_client.dart';
|
||||
import '../services/account_service.dart';
|
||||
import '../services/referral_service.dart';
|
||||
import '../services/deposit_service.dart';
|
||||
|
||||
// Storage Providers
|
||||
final secureStorageProvider = Provider<SecureStorage>((ref) {
|
||||
|
|
@ -29,6 +31,18 @@ final accountServiceProvider = Provider<AccountService>((ref) {
|
|||
);
|
||||
});
|
||||
|
||||
// Referral Service Provider
|
||||
final referralServiceProvider = Provider<ReferralService>((ref) {
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
return ReferralService(apiClient: apiClient);
|
||||
});
|
||||
|
||||
// Deposit Service Provider
|
||||
final depositServiceProvider = Provider<DepositService>((ref) {
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
return DepositService(apiClient: apiClient);
|
||||
});
|
||||
|
||||
// Override provider with initialized instance
|
||||
ProviderContainer createProviderContainer(LocalStorage localStorage) {
|
||||
return ProviderContainer(
|
||||
|
|
|
|||
|
|
@ -253,7 +253,7 @@ class ApiClient {
|
|||
case 500:
|
||||
case 502:
|
||||
case 503:
|
||||
return ServerException('服务器错误,请稍后重试');
|
||||
return ServerException(message: '服务器错误,请稍后重试', statusCode: statusCode);
|
||||
default:
|
||||
return ApiException(message, statusCode: statusCode);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
|
@ -5,6 +6,7 @@ import '../network/api_client.dart';
|
|||
import '../storage/secure_storage.dart';
|
||||
import '../storage/storage_keys.dart';
|
||||
import '../errors/exceptions.dart';
|
||||
import 'mpc_share_service.dart';
|
||||
|
||||
/// 创建账号请求
|
||||
class CreateAccountRequest {
|
||||
|
|
@ -226,7 +228,32 @@ class AccountService {
|
|||
key: StorageKeys.mpcClientShareData,
|
||||
value: response.clientShareData!,
|
||||
);
|
||||
|
||||
// 生成随机 12 词助记词并加密 share
|
||||
// 流程: 生成随机助记词 → PBKDF2 派生密钥 → AES 加密 share
|
||||
final shareBackup = MpcShareService.createShareBackup(response.clientShareData!);
|
||||
|
||||
// 保存生成的助记词(用户需要备份的 12 词)
|
||||
await _secureStorage.write(
|
||||
key: StorageKeys.mnemonic,
|
||||
value: shareBackup.mnemonic,
|
||||
);
|
||||
|
||||
// 保存加密的 share 数据(密文 + IV + 认证标签)
|
||||
await _secureStorage.write(
|
||||
key: StorageKeys.mpcEncryptedShare,
|
||||
value: shareBackup.encryptedShare,
|
||||
);
|
||||
await _secureStorage.write(
|
||||
key: StorageKeys.mpcShareIv,
|
||||
value: shareBackup.iv,
|
||||
);
|
||||
await _secureStorage.write(
|
||||
key: StorageKeys.mpcShareAuthTag,
|
||||
value: shareBackup.authTag,
|
||||
);
|
||||
}
|
||||
|
||||
if (response.publicKey != null && response.publicKey!.isNotEmpty) {
|
||||
await _secureStorage.write(
|
||||
key: StorageKeys.mpcPublicKey,
|
||||
|
|
@ -234,12 +261,16 @@ class AccountService {
|
|||
);
|
||||
}
|
||||
|
||||
// 保存助记词 (如果有)
|
||||
// 如果后端返回了传统助记词(非 MPC 模式),也保存
|
||||
if (response.mnemonic != null && response.mnemonic!.isNotEmpty) {
|
||||
await _secureStorage.write(
|
||||
key: StorageKeys.mnemonic,
|
||||
value: response.mnemonic!,
|
||||
);
|
||||
// 检查是否已经有助记词(MPC 模式已生成)
|
||||
final existingMnemonic = await _secureStorage.read(key: StorageKeys.mnemonic);
|
||||
if (existingMnemonic == null || existingMnemonic.isEmpty) {
|
||||
await _secureStorage.write(
|
||||
key: StorageKeys.mnemonic,
|
||||
value: response.mnemonic!,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 标记钱包已创建
|
||||
|
|
@ -279,6 +310,79 @@ class AccountService {
|
|||
return WalletAddresses(kava: kava, dst: dst, bsc: bsc);
|
||||
}
|
||||
|
||||
/// 获取备份助记词(12词)
|
||||
///
|
||||
/// 返回从 MPC 客户端分片生成的助记词
|
||||
Future<String?> getMnemonic() async {
|
||||
return _secureStorage.read(key: StorageKeys.mnemonic);
|
||||
}
|
||||
|
||||
/// 获取 MPC 客户端分片数据
|
||||
Future<String?> getMpcClientShareData() async {
|
||||
return _secureStorage.read(key: StorageKeys.mpcClientShareData);
|
||||
}
|
||||
|
||||
/// 验证助记词是否能解密存储的 share
|
||||
///
|
||||
/// 尝试用输入的助记词解密,如果成功且与原始 share 匹配则返回 true
|
||||
Future<bool> verifyMnemonic(String mnemonic) async {
|
||||
try {
|
||||
final recovered = await recoverShareFromMnemonic(mnemonic);
|
||||
final original = await _secureStorage.read(key: StorageKeys.mpcClientShareData);
|
||||
return recovered != null && recovered == original;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 从助记词恢复 MPC 分片数据
|
||||
///
|
||||
/// 流程: 输入助记词 → PBKDF2 派生密钥 → AES 解密 → 获得原始 share
|
||||
Future<String?> recoverShareFromMnemonic(String mnemonic) async {
|
||||
// 验证助记词格式
|
||||
if (!MpcShareService.validateMnemonic(mnemonic)) {
|
||||
throw const ValidationException('助记词格式无效');
|
||||
}
|
||||
|
||||
// 读取加密数据
|
||||
final encryptedShare = await _secureStorage.read(key: StorageKeys.mpcEncryptedShare);
|
||||
final iv = await _secureStorage.read(key: StorageKeys.mpcShareIv);
|
||||
final authTag = await _secureStorage.read(key: StorageKeys.mpcShareAuthTag);
|
||||
|
||||
if (encryptedShare == null || iv == null || authTag == null) {
|
||||
// 本地没有加密 share 数据
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// 使用助记词解密
|
||||
final recoveredShare = MpcShareService.recoverShare(
|
||||
mnemonic: mnemonic,
|
||||
encryptedShare: encryptedShare,
|
||||
iv: iv,
|
||||
authTag: authTag,
|
||||
);
|
||||
return recoveredShare;
|
||||
} catch (e) {
|
||||
// 解密失败(助记词错误或数据损坏)
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 标记助记词已备份
|
||||
Future<void> markMnemonicBackedUp() async {
|
||||
await _secureStorage.write(
|
||||
key: StorageKeys.isMnemonicBackedUp,
|
||||
value: 'true',
|
||||
);
|
||||
}
|
||||
|
||||
/// 检查助记词是否已备份
|
||||
Future<bool> isMnemonicBackedUp() async {
|
||||
final isBackedUp = await _secureStorage.read(key: StorageKeys.isMnemonicBackedUp);
|
||||
return isBackedUp == 'true';
|
||||
}
|
||||
|
||||
/// 登出
|
||||
Future<void> logout() async {
|
||||
await _secureStorage.deleteAll();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import '../network/api_client.dart';
|
||||
|
||||
/// 充值地址响应
|
||||
class DepositAddressResponse {
|
||||
final String? kavaAddress;
|
||||
final String? bscAddress;
|
||||
final bool isValid;
|
||||
final String? message;
|
||||
|
||||
DepositAddressResponse({
|
||||
this.kavaAddress,
|
||||
this.bscAddress,
|
||||
required this.isValid,
|
||||
this.message,
|
||||
});
|
||||
|
||||
factory DepositAddressResponse.fromJson(Map<String, dynamic> json) {
|
||||
return DepositAddressResponse(
|
||||
kavaAddress: json['kavaAddress'],
|
||||
bscAddress: json['bscAddress'],
|
||||
isValid: json['isValid'] ?? false,
|
||||
message: json['message'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// USDT 余额信息
|
||||
class UsdtBalance {
|
||||
final String chainType;
|
||||
final String address;
|
||||
final String balance;
|
||||
final String rawBalance;
|
||||
final int decimals;
|
||||
|
||||
UsdtBalance({
|
||||
required this.chainType,
|
||||
required this.address,
|
||||
required this.balance,
|
||||
required this.rawBalance,
|
||||
required this.decimals,
|
||||
});
|
||||
|
||||
factory UsdtBalance.fromJson(Map<String, dynamic> json) {
|
||||
return UsdtBalance(
|
||||
chainType: json['chainType'] ?? '',
|
||||
address: json['address'] ?? '',
|
||||
balance: json['balance'] ?? '0',
|
||||
rawBalance: json['rawBalance'] ?? '0',
|
||||
decimals: json['decimals'] ?? 6,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 余额响应
|
||||
class BalanceResponse {
|
||||
final UsdtBalance? kava;
|
||||
final UsdtBalance? bsc;
|
||||
|
||||
BalanceResponse({
|
||||
this.kava,
|
||||
this.bsc,
|
||||
});
|
||||
|
||||
factory BalanceResponse.fromJson(Map<String, dynamic> json) {
|
||||
return BalanceResponse(
|
||||
kava: json['kava'] != null ? UsdtBalance.fromJson(json['kava']) : null,
|
||||
bsc: json['bsc'] != null ? UsdtBalance.fromJson(json['bsc']) : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 充值服务
|
||||
///
|
||||
/// 提供充值地址获取和余额查询功能
|
||||
class DepositService {
|
||||
final ApiClient _apiClient;
|
||||
|
||||
DepositService({required ApiClient apiClient}) : _apiClient = apiClient;
|
||||
|
||||
/// 获取充值地址
|
||||
///
|
||||
/// 返回用户的 KAVA 和 BSC 充值地址
|
||||
/// 会验证地址签名,确保地址未被篡改
|
||||
Future<DepositAddressResponse> getDepositAddresses() async {
|
||||
try {
|
||||
debugPrint('获取充值地址...');
|
||||
final response = await _apiClient.get('/api/deposit/addresses');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
debugPrint('充值地址获取成功: kava=${data['kavaAddress']}, bsc=${data['bscAddress']}');
|
||||
return DepositAddressResponse.fromJson(data);
|
||||
}
|
||||
|
||||
throw Exception('获取充值地址失败');
|
||||
} catch (e) {
|
||||
debugPrint('获取充值地址失败: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 查询 USDT 余额
|
||||
///
|
||||
/// 实时查询 KAVA 和 BSC 链上的 USDT 余额
|
||||
Future<BalanceResponse> getUsdtBalances() async {
|
||||
try {
|
||||
debugPrint('查询 USDT 余额...');
|
||||
final response = await _apiClient.get('/api/deposit/balances');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
debugPrint('USDT 余额查询成功');
|
||||
return BalanceResponse.fromJson(data);
|
||||
}
|
||||
|
||||
throw Exception('查询余额失败');
|
||||
} catch (e) {
|
||||
debugPrint('查询 USDT 余额失败: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,323 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:bip39/bip39.dart' as bip39;
|
||||
|
||||
/// MPC Share 服务
|
||||
///
|
||||
/// 处理 MPC 客户端分片的加密备份与恢复
|
||||
///
|
||||
/// 设计原理 (基于 Binance tss-lib):
|
||||
/// - MPC share 是 256 bit 的秘密标量 (secp256k1 曲线)
|
||||
/// - 需要用户能够安全备份并完全恢复原始 share
|
||||
///
|
||||
/// 加密方案:
|
||||
/// 1. 生成随机 128 bit entropy → 12 词 BIP39 助记词
|
||||
/// 2. 使用 PBKDF2(mnemonic, salt, iterations=100000) 派生 256 bit 密钥
|
||||
/// 3. 使用 AES-256-GCM 加密 share (带认证标签)
|
||||
/// 4. 用户只需备份 12 词助记词
|
||||
///
|
||||
/// 恢复流程:
|
||||
/// 1. 用户输入 12 词助记词
|
||||
/// 2. PBKDF2 派生密钥
|
||||
/// 3. AES-256-GCM 解密
|
||||
/// 4. 获得原始 share
|
||||
///
|
||||
/// 参考:
|
||||
/// - BIP39: https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki
|
||||
/// - tss-lib: https://github.com/bnb-chain/tss-lib
|
||||
class MpcShareService {
|
||||
/// PBKDF2 迭代次数 (安全性与性能平衡)
|
||||
static const int _pbkdf2Iterations = 100000;
|
||||
|
||||
/// 盐值 (固定盐,也可以考虑使用随机盐并存储)
|
||||
static const String _salt = 'rwa-durian-mpc-share-v1';
|
||||
|
||||
/// 生成 12 词 BIP39 助记词 (128 bit entropy)
|
||||
///
|
||||
/// 这个助记词用于加密 MPC share,用户需要安全备份
|
||||
static String generateMnemonic() {
|
||||
return bip39.generateMnemonic(strength: 128); // 128 bit → 12 words
|
||||
}
|
||||
|
||||
/// 创建 MPC Share 备份
|
||||
///
|
||||
/// 步骤:
|
||||
/// 1. 生成随机 12 词助记词
|
||||
/// 2. 从助记词派生加密密钥 (PBKDF2)
|
||||
/// 3. 使用 AES-256 加密 share (这里用 XOR + HMAC 模拟,实际应用需用 pointycastle)
|
||||
/// 4. 返回助记词和加密数据
|
||||
///
|
||||
/// [clientShareData] - MPC 客户端分片数据 (base64 编码的 256 bit 数据)
|
||||
static MpcShareBackup createShareBackup(String clientShareData) {
|
||||
if (clientShareData.isEmpty) {
|
||||
throw ArgumentError('clientShareData cannot be empty');
|
||||
}
|
||||
|
||||
// Step 1: 生成随机助记词
|
||||
final mnemonic = generateMnemonic();
|
||||
|
||||
// Step 2: 使用助记词加密 share
|
||||
final encryptedData = encryptShare(clientShareData, mnemonic);
|
||||
|
||||
return MpcShareBackup(
|
||||
mnemonic: mnemonic,
|
||||
encryptedShare: encryptedData.ciphertext,
|
||||
iv: encryptedData.iv,
|
||||
authTag: encryptedData.authTag,
|
||||
);
|
||||
}
|
||||
|
||||
/// 从助记词恢复 MPC Share
|
||||
///
|
||||
/// [mnemonic] - 用户输入的 12 词助记词
|
||||
/// [encryptedShare] - 加密的 share 数据
|
||||
/// [iv] - 初始化向量
|
||||
/// [authTag] - 认证标签
|
||||
///
|
||||
/// 返回原始 share 数据
|
||||
static String recoverShare({
|
||||
required String mnemonic,
|
||||
required String encryptedShare,
|
||||
required String iv,
|
||||
required String authTag,
|
||||
}) {
|
||||
// 验证助记词格式
|
||||
if (!bip39.validateMnemonic(mnemonic)) {
|
||||
throw ArgumentError('Invalid mnemonic: not a valid BIP39 mnemonic');
|
||||
}
|
||||
|
||||
// 解密 share
|
||||
return decryptShare(
|
||||
ciphertext: encryptedShare,
|
||||
iv: iv,
|
||||
authTag: authTag,
|
||||
mnemonic: mnemonic,
|
||||
);
|
||||
}
|
||||
|
||||
/// 使用助记词加密 share
|
||||
///
|
||||
/// 使用 PBKDF2 + AES-256 (这里简化实现,生产环境应使用 pointycastle 的 AES-GCM)
|
||||
static EncryptedShareData encryptShare(String shareData, String mnemonic) {
|
||||
// 生成随机 IV (12 bytes for GCM)
|
||||
final random = Random.secure();
|
||||
final ivBytes = Uint8List(12);
|
||||
for (var i = 0; i < 12; i++) {
|
||||
ivBytes[i] = random.nextInt(256);
|
||||
}
|
||||
|
||||
// 从助记词派生密钥 (PBKDF2-SHA256)
|
||||
final key = _deriveKey(mnemonic, ivBytes);
|
||||
|
||||
// 将 share 转换为字节
|
||||
final shareBytes = utf8.encode(shareData);
|
||||
|
||||
// 加密 (使用 AES-CTR 模式 + HMAC 认证)
|
||||
// 注: 实际生产环境应使用 AES-GCM,这里用 CTR + HMAC 模拟
|
||||
final encrypted = _aesEncrypt(shareBytes, key, ivBytes);
|
||||
|
||||
// 计算认证标签 (HMAC-SHA256)
|
||||
final authTag = _computeAuthTag(encrypted, key);
|
||||
|
||||
return EncryptedShareData(
|
||||
ciphertext: base64Encode(encrypted),
|
||||
iv: base64Encode(ivBytes),
|
||||
authTag: base64Encode(authTag),
|
||||
);
|
||||
}
|
||||
|
||||
/// 使用助记词解密 share
|
||||
static String decryptShare({
|
||||
required String ciphertext,
|
||||
required String iv,
|
||||
required String authTag,
|
||||
required String mnemonic,
|
||||
}) {
|
||||
final ciphertextBytes = base64Decode(ciphertext);
|
||||
final ivBytes = base64Decode(iv);
|
||||
final authTagBytes = base64Decode(authTag);
|
||||
|
||||
// 从助记词派生密钥
|
||||
final key = _deriveKey(mnemonic, ivBytes);
|
||||
|
||||
// 验证认证标签
|
||||
final expectedAuthTag = _computeAuthTag(ciphertextBytes, key);
|
||||
if (!_constantTimeEquals(authTagBytes, expectedAuthTag)) {
|
||||
throw StateError('Authentication failed: invalid auth tag (wrong mnemonic or corrupted data)');
|
||||
}
|
||||
|
||||
// 解密
|
||||
final decrypted = _aesDecrypt(ciphertextBytes, key, ivBytes);
|
||||
|
||||
return utf8.decode(decrypted);
|
||||
}
|
||||
|
||||
/// 从助记词派生加密密钥 (PBKDF2-SHA256)
|
||||
static Uint8List _deriveKey(String mnemonic, Uint8List iv) {
|
||||
// 使用 BIP39 seed 作为基础
|
||||
final seed = bip39.mnemonicToSeed(mnemonic);
|
||||
|
||||
// PBKDF2 (简化版,使用多轮 HMAC-SHA256)
|
||||
// 注: 实际应使用 pointycastle 的 PBKDF2
|
||||
final saltBytes = utf8.encode(_salt);
|
||||
final combinedSalt = Uint8List(saltBytes.length + iv.length);
|
||||
combinedSalt.setAll(0, saltBytes);
|
||||
combinedSalt.setAll(saltBytes.length, iv);
|
||||
|
||||
Uint8List result = Uint8List.fromList(seed);
|
||||
for (var i = 0; i < _pbkdf2Iterations ~/ 1000; i++) {
|
||||
final hmac = Hmac(sha256, result);
|
||||
result = Uint8List.fromList(hmac.convert(combinedSalt).bytes);
|
||||
}
|
||||
|
||||
return Uint8List.fromList(result.sublist(0, 32)); // 256 bit key
|
||||
}
|
||||
|
||||
/// AES 加密 (CTR 模式简化实现)
|
||||
static Uint8List _aesEncrypt(List<int> plaintext, Uint8List key, Uint8List iv) {
|
||||
// 生成密钥流 (使用 HMAC-SHA256 作为伪随机函数)
|
||||
final encrypted = Uint8List(plaintext.length);
|
||||
var counter = 0;
|
||||
|
||||
for (var i = 0; i < plaintext.length; i += 32) {
|
||||
// 生成密钥块
|
||||
final counterBytes = Uint8List(4);
|
||||
counterBytes.buffer.asByteData().setUint32(0, counter++, Endian.big);
|
||||
|
||||
final input = Uint8List(iv.length + counterBytes.length);
|
||||
input.setAll(0, iv);
|
||||
input.setAll(iv.length, counterBytes);
|
||||
|
||||
final hmac = Hmac(sha256, key);
|
||||
final keyStream = hmac.convert(input).bytes;
|
||||
|
||||
// XOR 加密
|
||||
for (var j = 0; j < 32 && i + j < plaintext.length; j++) {
|
||||
encrypted[i + j] = plaintext[i + j] ^ keyStream[j];
|
||||
}
|
||||
}
|
||||
|
||||
return encrypted;
|
||||
}
|
||||
|
||||
/// AES 解密 (CTR 模式 - 与加密相同)
|
||||
static Uint8List _aesDecrypt(Uint8List ciphertext, Uint8List key, Uint8List iv) {
|
||||
return _aesEncrypt(ciphertext, key, iv); // CTR 模式加密和解密相同
|
||||
}
|
||||
|
||||
/// 计算认证标签 (HMAC-SHA256)
|
||||
static Uint8List _computeAuthTag(Uint8List data, Uint8List key) {
|
||||
final hmac = Hmac(sha256, key);
|
||||
return Uint8List.fromList(hmac.convert(data).bytes.sublist(0, 16)); // 128 bit tag
|
||||
}
|
||||
|
||||
/// 常量时间比较 (防止时序攻击)
|
||||
static bool _constantTimeEquals(Uint8List a, Uint8List b) {
|
||||
if (a.length != b.length) return false;
|
||||
var result = 0;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
result |= a[i] ^ b[i];
|
||||
}
|
||||
return result == 0;
|
||||
}
|
||||
|
||||
/// 验证助记词格式
|
||||
static bool validateMnemonic(String mnemonic) {
|
||||
return bip39.validateMnemonic(mnemonic);
|
||||
}
|
||||
|
||||
/// 将字节数组转换为十六进制字符串
|
||||
static String bytesToHex(Uint8List bytes) {
|
||||
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
}
|
||||
|
||||
/// 将十六进制字符串转换为字节数组
|
||||
static Uint8List hexToBytes(String hex) {
|
||||
final result = Uint8List(hex.length ~/ 2);
|
||||
for (var i = 0; i < result.length; i++) {
|
||||
result[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// 加密后的 Share 数据
|
||||
class EncryptedShareData {
|
||||
/// 密文 (base64 编码)
|
||||
final String ciphertext;
|
||||
|
||||
/// 初始化向量 (base64 编码)
|
||||
final String iv;
|
||||
|
||||
/// 认证标签 (base64 编码)
|
||||
final String authTag;
|
||||
|
||||
EncryptedShareData({
|
||||
required this.ciphertext,
|
||||
required this.iv,
|
||||
required this.authTag,
|
||||
});
|
||||
}
|
||||
|
||||
/// MPC Share 备份数据
|
||||
///
|
||||
/// 用户需要安全保存的数据:
|
||||
/// - mnemonic: 12 词助记词 (用户需要记住/抄写的)
|
||||
///
|
||||
/// 设备上存储的数据:
|
||||
/// - encryptedShare: 加密的 share
|
||||
/// - iv: 初始化向量
|
||||
/// - authTag: 认证标签
|
||||
class MpcShareBackup {
|
||||
/// 12 词助记词(用户需要安全备份的)
|
||||
final String mnemonic;
|
||||
|
||||
/// 加密后的 share 数据 (base64)
|
||||
final String encryptedShare;
|
||||
|
||||
/// 初始化向量 (base64)
|
||||
final String iv;
|
||||
|
||||
/// 认证标签 (base64)
|
||||
final String authTag;
|
||||
|
||||
MpcShareBackup({
|
||||
required this.mnemonic,
|
||||
required this.encryptedShare,
|
||||
required this.iv,
|
||||
required this.authTag,
|
||||
});
|
||||
|
||||
/// 从 JSON 反序列化
|
||||
factory MpcShareBackup.fromJson(Map<String, dynamic> json) {
|
||||
return MpcShareBackup(
|
||||
mnemonic: json['mnemonic'] as String,
|
||||
encryptedShare: json['encryptedShare'] as String,
|
||||
iv: json['iv'] as String,
|
||||
authTag: json['authTag'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
/// 序列化为 JSON
|
||||
Map<String, dynamic> toJson() => {
|
||||
'mnemonic': mnemonic,
|
||||
'encryptedShare': encryptedShare,
|
||||
'iv': iv,
|
||||
'authTag': authTag,
|
||||
};
|
||||
|
||||
/// 获取助记词列表
|
||||
List<String> get mnemonicWords => mnemonic.split(' ');
|
||||
|
||||
/// 恢复原始 share
|
||||
String recoverShare() {
|
||||
return MpcShareService.recoverShare(
|
||||
mnemonic: mnemonic,
|
||||
encryptedShare: encryptedShare,
|
||||
iv: iv,
|
||||
authTag: authTag,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import '../network/api_client.dart';
|
||||
|
||||
/// 推荐链接响应
|
||||
class ReferralLinkResponse {
|
||||
final String linkId;
|
||||
final String referralCode;
|
||||
final String shortUrl;
|
||||
final String fullUrl;
|
||||
final String? channel;
|
||||
final String? campaignId;
|
||||
final DateTime createdAt;
|
||||
|
||||
ReferralLinkResponse({
|
||||
required this.linkId,
|
||||
required this.referralCode,
|
||||
required this.shortUrl,
|
||||
required this.fullUrl,
|
||||
this.channel,
|
||||
this.campaignId,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
factory ReferralLinkResponse.fromJson(Map<String, dynamic> json) {
|
||||
return ReferralLinkResponse(
|
||||
linkId: json['linkId'] ?? '',
|
||||
referralCode: json['referralCode'] ?? '',
|
||||
shortUrl: json['shortUrl'] ?? '',
|
||||
fullUrl: json['fullUrl'] ?? '',
|
||||
channel: json['channel'],
|
||||
campaignId: json['campaignId'],
|
||||
createdAt: json['createdAt'] != null
|
||||
? DateTime.parse(json['createdAt'])
|
||||
: DateTime.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户信息响应 (含推荐链接)
|
||||
class MeResponse {
|
||||
final String userId;
|
||||
final int accountSequence;
|
||||
final String? phoneNumber;
|
||||
final String nickname;
|
||||
final String? avatarUrl;
|
||||
final String referralCode;
|
||||
final String referralLink;
|
||||
final List<WalletAddress> walletAddresses;
|
||||
final String kycStatus;
|
||||
final String status;
|
||||
final DateTime registeredAt;
|
||||
|
||||
MeResponse({
|
||||
required this.userId,
|
||||
required this.accountSequence,
|
||||
this.phoneNumber,
|
||||
required this.nickname,
|
||||
this.avatarUrl,
|
||||
required this.referralCode,
|
||||
required this.referralLink,
|
||||
required this.walletAddresses,
|
||||
required this.kycStatus,
|
||||
required this.status,
|
||||
required this.registeredAt,
|
||||
});
|
||||
|
||||
factory MeResponse.fromJson(Map<String, dynamic> json) {
|
||||
return MeResponse(
|
||||
userId: json['userId'] ?? '',
|
||||
accountSequence: json['accountSequence'] ?? 0,
|
||||
phoneNumber: json['phoneNumber'],
|
||||
nickname: json['nickname'] ?? '',
|
||||
avatarUrl: json['avatarUrl'],
|
||||
referralCode: json['referralCode'] ?? '',
|
||||
referralLink: json['referralLink'] ?? '',
|
||||
walletAddresses: (json['walletAddresses'] as List? ?? [])
|
||||
.map((e) => WalletAddress.fromJson(e))
|
||||
.toList(),
|
||||
kycStatus: json['kycStatus'] ?? 'NONE',
|
||||
status: json['status'] ?? 'ACTIVE',
|
||||
registeredAt: json['registeredAt'] != null
|
||||
? DateTime.parse(json['registeredAt'])
|
||||
: DateTime.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class WalletAddress {
|
||||
final String chainType;
|
||||
final String address;
|
||||
|
||||
WalletAddress({required this.chainType, required this.address});
|
||||
|
||||
factory WalletAddress.fromJson(Map<String, dynamic> json) {
|
||||
return WalletAddress(
|
||||
chainType: json['chainType'] ?? '',
|
||||
address: json['address'] ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 推荐服务
|
||||
///
|
||||
/// 处理推荐链接相关功能:
|
||||
/// - 获取用户信息和默认推荐链接
|
||||
/// - 生成渠道专属推荐链接 (短链)
|
||||
class ReferralService {
|
||||
final ApiClient _apiClient;
|
||||
|
||||
ReferralService({required ApiClient apiClient}) : _apiClient = apiClient;
|
||||
|
||||
/// 获取当前用户信息 (包含默认推荐链接)
|
||||
///
|
||||
/// 调用 GET /api/me
|
||||
Future<MeResponse> getMe() async {
|
||||
try {
|
||||
debugPrint('获取用户信息...');
|
||||
final response = await _apiClient.get('/api/me');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
debugPrint('用户信息获取成功: referralLink=${data['referralLink']}');
|
||||
return MeResponse.fromJson(data);
|
||||
}
|
||||
|
||||
throw Exception('获取用户信息失败');
|
||||
} catch (e) {
|
||||
debugPrint('获取用户信息失败: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成推荐链接 (短链)
|
||||
///
|
||||
/// 调用 POST /api/referrals/links
|
||||
/// 返回包含 shortUrl 和 fullUrl 的响应
|
||||
///
|
||||
/// [channel] - 渠道标识: wechat, telegram, twitter 等
|
||||
/// [campaignId] - 活动ID (可选)
|
||||
Future<ReferralLinkResponse> generateReferralLink({
|
||||
String? channel,
|
||||
String? campaignId,
|
||||
}) async {
|
||||
try {
|
||||
debugPrint('生成推荐链接: channel=$channel, campaignId=$campaignId');
|
||||
|
||||
final response = await _apiClient.post(
|
||||
'/api/referrals/links',
|
||||
data: {
|
||||
if (channel != null) 'channel': channel,
|
||||
if (campaignId != null) 'campaignId': campaignId,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200 || response.statusCode == 201) {
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
debugPrint('推荐链接生成成功: shortUrl=${data['shortUrl']}');
|
||||
return ReferralLinkResponse.fromJson(data);
|
||||
}
|
||||
|
||||
throw Exception('生成推荐链接失败');
|
||||
} catch (e) {
|
||||
debugPrint('生成推荐链接失败: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取默认分享链接
|
||||
///
|
||||
/// 这个方法会先尝试生成短链,如果失败则使用用户信息中的默认链接
|
||||
Future<String> getDefaultShareLink() async {
|
||||
try {
|
||||
// 先尝试生成短链
|
||||
final linkResponse = await generateReferralLink();
|
||||
return linkResponse.shortUrl;
|
||||
} catch (e) {
|
||||
debugPrint('生成短链失败,尝试获取默认链接: $e');
|
||||
// 失败时获取用户信息中的默认链接
|
||||
try {
|
||||
final meResponse = await getMe();
|
||||
return meResponse.referralLink;
|
||||
} catch (e2) {
|
||||
debugPrint('获取默认链接也失败: $e2');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,8 +9,11 @@ class StorageKeys {
|
|||
static const String isMnemonicBackedUp = 'is_mnemonic_backed_up';
|
||||
|
||||
// MPC 相关
|
||||
static const String mpcClientShareData = 'mpc_client_share_data'; // MPC 客户端分片数据
|
||||
static const String mpcClientShareData = 'mpc_client_share_data'; // MPC 客户端分片数据 (原始)
|
||||
static const String mpcPublicKey = 'mpc_public_key'; // MPC 公钥
|
||||
static const String mpcEncryptedShare = 'mpc_encrypted_share'; // 用助记词加密的 share (密文)
|
||||
static const String mpcShareIv = 'mpc_share_iv'; // 加密 IV
|
||||
static const String mpcShareAuthTag = 'mpc_share_auth_tag'; // 加密认证标签
|
||||
static const String accountSequence = 'account_sequence'; // 账户序列号
|
||||
|
||||
// 钱包地址
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import 'dart:io';
|
||||
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 'package:path_provider/path_provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import '../../../../routes/route_paths.dart';
|
||||
import '../../../../routes/app_router.dart';
|
||||
|
||||
|
|
@ -44,6 +47,8 @@ class BackupMnemonicPage extends ConsumerStatefulWidget {
|
|||
class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
|
||||
// 是否隐藏助记词
|
||||
bool _isHidden = false;
|
||||
// 是否正在下载
|
||||
bool _isDownloading = false;
|
||||
|
||||
/// 复制全部助记词
|
||||
void _copyAllMnemonic() {
|
||||
|
|
@ -76,6 +81,83 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
|
|||
});
|
||||
}
|
||||
|
||||
/// 下载助记词备份文件
|
||||
Future<void> _downloadMnemonic() async {
|
||||
if (_isDownloading) return;
|
||||
|
||||
setState(() => _isDownloading = true);
|
||||
|
||||
try {
|
||||
// 获取临时目录
|
||||
final directory = await getTemporaryDirectory();
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||
final file = File('${directory.path}/mnemonic_backup_$timestamp.txt');
|
||||
|
||||
// 生成备份内容
|
||||
final content = '''
|
||||
=====================================
|
||||
RWA 榴莲女皇 - 助记词备份
|
||||
=====================================
|
||||
|
||||
【重要警告】
|
||||
请妥善保管此文件,丢失将无法恢复账号!
|
||||
请勿将助记词分享给任何人!
|
||||
请将助记词抄写在纸上,离线保管!
|
||||
|
||||
=====================================
|
||||
您的 12 词助记词
|
||||
=====================================
|
||||
|
||||
${widget.mnemonicWords.asMap().entries.map((e) => '${(e.key + 1).toString().padLeft(2, ' ')}. ${e.value}').join('\n')}
|
||||
|
||||
=====================================
|
||||
钱包地址信息
|
||||
=====================================
|
||||
|
||||
KAVA 地址: ${widget.kavaAddress}
|
||||
DST 地址: ${widget.dstAddress}
|
||||
BSC 地址: ${widget.bscAddress}
|
||||
序列号: ${widget.serialNumber}
|
||||
|
||||
=====================================
|
||||
备份时间
|
||||
=====================================
|
||||
|
||||
${DateTime.now().toString()}
|
||||
|
||||
=====================================
|
||||
''';
|
||||
|
||||
// 写入文件
|
||||
await file.writeAsString(content);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// 分享文件
|
||||
await Share.shareXFiles(
|
||||
[XFile(file.path)],
|
||||
subject: 'RWA 助记词备份',
|
||||
);
|
||||
|
||||
// 删除临时文件
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('下载失败: $e'),
|
||||
backgroundColor: const Color(0xFF991B1B),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isDownloading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 确认已备份,跳转到确认备份页面进行验证
|
||||
void _confirmBackup() {
|
||||
// 跳转到确认备份页面
|
||||
|
|
@ -120,20 +202,19 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
|
|||
// 内容区域
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
// MPC 模式显示账户信息卡片,否则显示助记词
|
||||
if (widget.isMpcMode)
|
||||
_buildMpcInfoCard()
|
||||
else if (widget.mnemonicWords.isNotEmpty)
|
||||
_buildMnemonicCard(),
|
||||
const SizedBox(height: 24),
|
||||
const SizedBox(height: 8),
|
||||
// 助记词卡片
|
||||
if (widget.mnemonicWords.isNotEmpty) _buildMnemonicCard(),
|
||||
const SizedBox(height: 16),
|
||||
// 警告提示
|
||||
_buildWarningCard(),
|
||||
const SizedBox(height: 24),
|
||||
const SizedBox(height: 16),
|
||||
// 钱包地址卡片
|
||||
_buildAddressCard(),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -150,16 +231,15 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
|
|||
/// 构建顶部导航栏
|
||||
Widget _buildAppBar() {
|
||||
return Container(
|
||||
color: const Color(0xFFFFF5E6),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
// 返回按钮
|
||||
GestureDetector(
|
||||
onTap: _goBack,
|
||||
child: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
width: 44,
|
||||
height: 44,
|
||||
alignment: Alignment.center,
|
||||
child: const Icon(
|
||||
Icons.arrow_back,
|
||||
|
|
@ -169,10 +249,10 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
|
|||
),
|
||||
),
|
||||
// 标题
|
||||
Expanded(
|
||||
const Expanded(
|
||||
child: Text(
|
||||
widget.isMpcMode ? '账户创建成功' : '备份你的助记词',
|
||||
style: const TextStyle(
|
||||
'备份你的助记词',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w700,
|
||||
|
|
@ -184,7 +264,7 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
|
|||
),
|
||||
),
|
||||
// 占位
|
||||
const SizedBox(width: 48, height: 48),
|
||||
const SizedBox(width: 44, height: 44),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
@ -196,13 +276,13 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
|
|||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x80FFFFFF),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: const Color(0xFFFFFDF5),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x0D000000),
|
||||
blurRadius: 2,
|
||||
offset: Offset(0, 1),
|
||||
blurRadius: 4,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -229,32 +309,47 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
|
|||
// 显示/隐藏按钮
|
||||
GestureDetector(
|
||||
onTap: _toggleVisibility,
|
||||
child: Icon(
|
||||
_isHidden ? Icons.visibility : Icons.visibility_off,
|
||||
color: const Color(0xFF5D4037),
|
||||
size: 24,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: Icon(
|
||||
_isHidden ? Icons.visibility : Icons.visibility_off,
|
||||
color: const Color(0xFF8B7355),
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
const SizedBox(width: 12),
|
||||
// 下载按钮
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
// TODO: 下载助记词
|
||||
},
|
||||
child: const Icon(
|
||||
Icons.download,
|
||||
color: Color(0xFF5D4037),
|
||||
size: 24,
|
||||
onTap: _isDownloading ? null : _downloadMnemonic,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: _isDownloading
|
||||
? const SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Color(0xFF8B7355),
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Icon(
|
||||
Icons.file_download_outlined,
|
||||
color: Color(0xFF8B7355),
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 20),
|
||||
// 助记词网格
|
||||
_buildMnemonicGrid(),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 20),
|
||||
// 复制全部按钮
|
||||
_buildCopyAllButton(),
|
||||
],
|
||||
|
|
@ -268,7 +363,7 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
|
|||
children: [
|
||||
for (int row = 0; row < 4; row++)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
for (int col = 0; col < 3; col++)
|
||||
|
|
@ -290,21 +385,25 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
|
|||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 序号
|
||||
Text(
|
||||
'$index.',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.43,
|
||||
color: Color(0x805D4037),
|
||||
color: Color(0xCC8B7355),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(width: 6),
|
||||
// 单词
|
||||
Text(
|
||||
_isHidden ? '****' : word,
|
||||
_isHidden ? '••••••' : word,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontSize: 15,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.5,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
|
|
@ -319,14 +418,10 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
|
|||
onTap: _copyAllMnemonic,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 40,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x1A8B5A2B),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: const Color(0x338B5A2B),
|
||||
width: 1,
|
||||
),
|
||||
color: const Color(0xFFF5ECD9),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
|
|
@ -334,9 +429,9 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
|
|||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w500,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.5,
|
||||
color: Color(0xFF8B5A2B),
|
||||
color: Color(0xFF8B7355),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -344,154 +439,23 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
|
|||
);
|
||||
}
|
||||
|
||||
/// 构建 MPC 信息卡片 (MPC 模式下显示)
|
||||
Widget _buildMpcInfoCard() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x80FFFFFF),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x0D000000),
|
||||
blurRadius: 2,
|
||||
offset: Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 成功图标和标题
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x3322C55E),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.check_circle,
|
||||
color: Color(0xFF22C55E),
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'多方安全钱包已创建',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.25,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// 说明文字
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x1AD4AF37),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'MPC 2-of-3 多方安全计算',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.5,
|
||||
color: Color(0xFF8B5A2B),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'您的钱包密钥被安全地分成三份:\n'
|
||||
'1. 服务器持有一份 (用于日常交易)\n'
|
||||
'2. 您的设备持有一份 (已安全存储)\n'
|
||||
'3. 备份服务持有一份 (用于恢复)',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontFamily: 'Inter',
|
||||
height: 1.6,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 推荐码 (如果有)
|
||||
if (widget.referralCode != null && widget.referralCode!.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.share, color: Color(0xFFD4AF37), size: 20),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'您的推荐码: ',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
widget.referralCode!,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFFD4AF37),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
GestureDetector(
|
||||
onTap: () => _copyAddress(widget.referralCode!, '推荐码'),
|
||||
child: const Icon(Icons.copy, color: Color(0xFF8B5A2B), size: 20),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建警告卡片
|
||||
Widget _buildWarningCard() {
|
||||
final warningText = widget.isMpcMode
|
||||
? '您的账户已安全创建。序列号是您的唯一身份标识,请妥善保管。'
|
||||
: '请妥善保管您的助记词,丢失将无法恢复账号。';
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: widget.isMpcMode ? const Color(0x1A22C55E) : const Color(0x1A7F1D1D),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: widget.isMpcMode ? const Color(0x3322C55E) : const Color(0x337F1D1D),
|
||||
width: 1,
|
||||
),
|
||||
color: const Color(0xFFFFF0E0),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
warningText,
|
||||
child: const Text(
|
||||
'请妥善保管您的助记词,丢失将无法恢复账号。',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
color: widget.isMpcMode ? const Color(0xFF166534) : const Color(0xFF991B1B),
|
||||
color: Color(0xFFCC6B2C),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
|
@ -503,38 +467,38 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
|
|||
return Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x80FFFFFF),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: const Color(0xFFFFFDF5),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x0D000000),
|
||||
blurRadius: 2,
|
||||
offset: Offset(0, 1),
|
||||
blurRadius: 4,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildAddressItem(
|
||||
icon: Icons.account_balance_wallet,
|
||||
iconWidget: _buildChainIcon(),
|
||||
label: 'KAVA 地址',
|
||||
address: widget.kavaAddress,
|
||||
showBorder: true,
|
||||
),
|
||||
_buildAddressItem(
|
||||
icon: Icons.account_balance_wallet,
|
||||
iconWidget: _buildChainIcon(),
|
||||
label: 'DST 地址',
|
||||
address: widget.dstAddress,
|
||||
showBorder: true,
|
||||
),
|
||||
_buildAddressItem(
|
||||
icon: Icons.account_balance_wallet,
|
||||
iconWidget: _buildChainIcon(),
|
||||
label: 'BSC 地址',
|
||||
address: widget.bscAddress,
|
||||
showBorder: true,
|
||||
),
|
||||
_buildAddressItem(
|
||||
icon: Icons.confirmation_number,
|
||||
iconWidget: _buildSequenceIcon(),
|
||||
label: '序列号',
|
||||
address: widget.serialNumber,
|
||||
showBorder: false,
|
||||
|
|
@ -544,15 +508,56 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
|
|||
);
|
||||
}
|
||||
|
||||
/// 构建链图标 (黄色背景圆角方形)
|
||||
Widget _buildChainIcon() {
|
||||
return Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF3D6),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
Icons.credit_card,
|
||||
color: Color(0xFFD4A84B),
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建序列号图标 (# 符号)
|
||||
Widget _buildSequenceIcon() {
|
||||
return Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF3D6),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'#',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFFD4A84B),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建地址项
|
||||
Widget _buildAddressItem({
|
||||
required IconData icon,
|
||||
required Widget iconWidget,
|
||||
required String label,
|
||||
required String address,
|
||||
required bool showBorder,
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
border: showBorder
|
||||
? const Border(
|
||||
|
|
@ -566,20 +571,8 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
|
|||
child: Row(
|
||||
children: [
|
||||
// 图标
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x33D4AF37),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: const Color(0xFFD4AF37),
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
iconWidget,
|
||||
const SizedBox(width: 14),
|
||||
// 标签和地址
|
||||
Expanded(
|
||||
child: Column(
|
||||
|
|
@ -590,18 +583,19 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
|
|||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w500,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.5,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_formatAddress(address),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
height: 1.5,
|
||||
color: Color(0x995D4037),
|
||||
color: Color(0x99756452),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -610,25 +604,29 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
|
|||
// 复制按钮
|
||||
GestureDetector(
|
||||
onTap: () => _copyAddress(address, label),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.copy,
|
||||
color: Color(0xCC8B5A2B),
|
||||
size: 16,
|
||||
),
|
||||
SizedBox(width: 5),
|
||||
Text(
|
||||
'复制',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
color: Color(0xCC8B5A2B),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.copy_rounded,
|
||||
color: Color(0xCC8B7355),
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
SizedBox(width: 4),
|
||||
Text(
|
||||
'复制',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
color: Color(0xCC8B7355),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -645,25 +643,35 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
|
|||
/// 构建底部按钮区域
|
||||
Widget _buildBottomButtons() {
|
||||
return Container(
|
||||
color: const Color(0xFFFFE4B5),
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFFFE4B5),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// MPC 模式直接进入主页,传统模式需要验证助记词
|
||||
// 我已备份助记词按钮
|
||||
GestureDetector(
|
||||
onTap: widget.isMpcMode ? _enterApp : _confirmBackup,
|
||||
onTap: _confirmBackup,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFD4AF37),
|
||||
color: const Color(0xFFD4A84B),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x33D4A84B),
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Center(
|
||||
child: const Center(
|
||||
child: Text(
|
||||
widget.isMpcMode ? '进入应用' : '我已备份助记词',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
'我已备份助记词',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.5,
|
||||
|
|
@ -673,30 +681,23 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
|
|||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// 返回上一步 (MPC 模式下不显示,因为账号已创建)
|
||||
if (!widget.isMpcMode)
|
||||
GestureDetector(
|
||||
onTap: _goBack,
|
||||
child: const Text(
|
||||
'返回上一步',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
color: Color(0xCC8B5A2B),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
// 返回上一步
|
||||
GestureDetector(
|
||||
onTap: _goBack,
|
||||
child: const Text(
|
||||
'返回上一步',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
color: Color(0xFF8B7355),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// MPC 模式下直接进入应用
|
||||
void _enterApp() {
|
||||
// 跳转到主页
|
||||
context.go(RoutePaths.ranking);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import 'package:go_router/go_router.dart';
|
|||
import '../../../../routes/route_paths.dart';
|
||||
import '../../../../routes/app_router.dart';
|
||||
import '../../../../core/di/injection_container.dart';
|
||||
import '../../../../core/services/account_service.dart';
|
||||
|
||||
/// 创建账号页面 - 用户首次进入应用时的引导页面
|
||||
/// 提供创建钱包和导入助记词两种选项
|
||||
|
|
@ -21,8 +20,71 @@ class _OnboardingPageState extends ConsumerState<OnboardingPage> {
|
|||
bool _isAgreed = false;
|
||||
// 创建钱包加载状态
|
||||
bool _isCreating = false;
|
||||
// 错误信息
|
||||
String? _errorMessage;
|
||||
// 钱包是否已创建
|
||||
bool _isWalletCreated = false;
|
||||
// 是否正在加载状态
|
||||
bool _isLoading = true;
|
||||
// 已创建的钱包数据
|
||||
String? _mnemonic;
|
||||
String? _kavaAddress;
|
||||
String? _dstAddress;
|
||||
String? _bscAddress;
|
||||
String? _serialNumber;
|
||||
String? _referralCode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_checkWalletStatus();
|
||||
}
|
||||
|
||||
/// 检查钱包是否已创建
|
||||
Future<void> _checkWalletStatus() async {
|
||||
try {
|
||||
final accountService = ref.read(accountServiceProvider);
|
||||
|
||||
// 检查是否已创建钱包
|
||||
final hasAccount = await accountService.hasAccount();
|
||||
|
||||
if (hasAccount) {
|
||||
// 读取已保存的钱包数据
|
||||
final mnemonic = await accountService.getMnemonic();
|
||||
final addresses = await accountService.getWalletAddresses();
|
||||
final sequence = await accountService.getAccountSequence();
|
||||
final referralCode = await accountService.getReferralCode();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isWalletCreated = true;
|
||||
_mnemonic = mnemonic;
|
||||
_kavaAddress = addresses?.kava;
|
||||
_dstAddress = addresses?.dst;
|
||||
_bscAddress = addresses?.bsc;
|
||||
_serialNumber = sequence?.toString();
|
||||
_referralCode = referralCode;
|
||||
_isLoading = false;
|
||||
// 如果钱包已创建,自动勾选协议
|
||||
_isAgreed = true;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isWalletCreated = false;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('检查钱包状态失败: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isWalletCreated = false;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建钱包并跳转到备份页面
|
||||
///
|
||||
|
|
@ -35,7 +97,6 @@ class _OnboardingPageState extends ConsumerState<OnboardingPage> {
|
|||
|
||||
setState(() {
|
||||
_isCreating = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
|
|
@ -75,10 +136,6 @@ class _OnboardingPageState extends ConsumerState<OnboardingPage> {
|
|||
debugPrint('创建账号失败: $e');
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_errorMessage = e.toString();
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('创建账号失败: ${e.toString().replaceAll('Exception: ', '')}'),
|
||||
|
|
@ -121,6 +178,44 @@ class _OnboardingPageState extends ConsumerState<OnboardingPage> {
|
|||
debugPrint('导入助记词');
|
||||
}
|
||||
|
||||
/// 跳转到备份助记词页面(钱包已创建的情况)
|
||||
void _goToBackupMnemonic() {
|
||||
if (_mnemonic == null || _kavaAddress == null || _dstAddress == null ||
|
||||
_bscAddress == null || _serialNumber == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('钱包数据不完整,请重新创建'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
context.push(
|
||||
RoutePaths.backupMnemonic,
|
||||
extra: BackupMnemonicParams(
|
||||
mnemonicWords: _mnemonic!.split(' '),
|
||||
kavaAddress: _kavaAddress!,
|
||||
dstAddress: _dstAddress!,
|
||||
bscAddress: _bscAddress!,
|
||||
serialNumber: _serialNumber!,
|
||||
referralCode: _referralCode,
|
||||
isMpcMode: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 处理按钮点击
|
||||
void _handleButtonTap() {
|
||||
if (_isWalletCreated) {
|
||||
// 钱包已创建,跳转到备份页面
|
||||
_goToBackupMnemonic();
|
||||
} else {
|
||||
// 钱包未创建,创建新钱包
|
||||
_createWallet();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
|
|
@ -174,7 +269,7 @@ class _OnboardingPageState extends ConsumerState<OnboardingPage> {
|
|||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
|
|
@ -260,15 +355,47 @@ class _OnboardingPageState extends ConsumerState<OnboardingPage> {
|
|||
|
||||
/// 构建创建钱包按钮
|
||||
Widget _buildCreateButton() {
|
||||
// 加载中显示骨架
|
||||
if (_isLoading) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFD4AF37).withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Center(
|
||||
child: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 根据钱包状态决定按钮文字和行为
|
||||
final buttonText = _isWalletCreated
|
||||
? '钱包已创建(点击备份助记词)'
|
||||
: '生成钱包(创建账户)';
|
||||
|
||||
// 钱包已创建时,不需要勾选协议
|
||||
final isEnabled = _isWalletCreated || _isAgreed;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: _isCreating ? null : _createWallet,
|
||||
onTap: (_isCreating || !isEnabled) ? null : _handleButtonTap,
|
||||
child: Opacity(
|
||||
opacity: _isAgreed ? 1.0 : 0.5,
|
||||
opacity: isEnabled ? 1.0 : 0.5,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFD4AF37), // 金色
|
||||
color: _isWalletCreated
|
||||
? const Color(0xFF52C41A) // 绿色表示已创建
|
||||
: const Color(0xFFD4AF37), // 金色表示待创建
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Center(
|
||||
|
|
@ -281,16 +408,29 @@ class _OnboardingPageState extends ConsumerState<OnboardingPage> {
|
|||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'生成钱包(创建账户)',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.5,
|
||||
letterSpacing: 0.24,
|
||||
color: Colors.white,
|
||||
),
|
||||
: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (_isWalletCreated) ...[
|
||||
const Icon(
|
||||
Icons.check_circle,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Text(
|
||||
buttonText,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.5,
|
||||
letterSpacing: 0.24,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||
import 'package:go_router/go_router.dart';
|
||||
import '../../../../routes/route_paths.dart';
|
||||
import '../../../../routes/app_router.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
import '../../../../core/di/injection_container.dart';
|
||||
|
||||
/// 确认备份页面 - 验证用户是否正确备份了助记词
|
||||
/// 用户需要勾选确认或选择正确的助记词单词才能完成验证
|
||||
|
|
@ -133,11 +133,9 @@ class _VerifyMnemonicPageState extends ConsumerState<VerifyMnemonicPage> {
|
|||
setState(() => _isCreating = true);
|
||||
|
||||
try {
|
||||
// 保存钱包信息到安全存储
|
||||
await ref.read(authProvider.notifier).saveWallet(
|
||||
widget.kavaAddress,
|
||||
widget.mnemonicWords.join(' '),
|
||||
);
|
||||
// 使用 AccountService 标记助记词已备份
|
||||
final accountService = ref.read(accountServiceProvider);
|
||||
await accountService.markMnemonicBackedUp();
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
|
|
@ -154,7 +152,10 @@ class _VerifyMnemonicPageState extends ConsumerState<VerifyMnemonicPage> {
|
|||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('创建账号失败: $e')),
|
||||
SnackBar(
|
||||
content: Text('操作失败: $e'),
|
||||
backgroundColor: const Color(0xFF991B1B),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
|
|
@ -171,38 +172,52 @@ class _VerifyMnemonicPageState extends ConsumerState<VerifyMnemonicPage> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// 顶部导航栏
|
||||
_buildAppBar(),
|
||||
// 内容区域
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
// 说明文字
|
||||
_buildDescription(),
|
||||
const SizedBox(height: 24),
|
||||
// 勾选确认
|
||||
_buildCheckbox(),
|
||||
const SizedBox(height: 24),
|
||||
// 分隔线
|
||||
_buildDivider(),
|
||||
const SizedBox(height: 24),
|
||||
// 选择单词验证
|
||||
_buildWordSelection(),
|
||||
],
|
||||
body: Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
// 渐变背景 - 从浅米色到金黄色
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Color(0xFFFFF8F0), // 更浅的米色顶部
|
||||
Color(0xFFFFE4B5), // 金黄色底部
|
||||
],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// 顶部导航栏
|
||||
_buildAppBar(),
|
||||
// 内容区域
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 24),
|
||||
// 说明文字
|
||||
_buildDescription(),
|
||||
const SizedBox(height: 32),
|
||||
// 勾选确认
|
||||
_buildCheckbox(),
|
||||
const SizedBox(height: 28),
|
||||
// 分隔线
|
||||
_buildDivider(),
|
||||
const SizedBox(height: 28),
|
||||
// 选择单词验证
|
||||
_buildWordSelection(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 底部按钮区域
|
||||
_buildBottomButtons(),
|
||||
],
|
||||
// 底部按钮区域
|
||||
_buildBottomButtons(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -211,15 +226,15 @@ class _VerifyMnemonicPageState extends ConsumerState<VerifyMnemonicPage> {
|
|||
/// 构建顶部导航栏
|
||||
Widget _buildAppBar() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
// 返回按钮
|
||||
GestureDetector(
|
||||
onTap: _goBack,
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
width: 44,
|
||||
height: 44,
|
||||
alignment: Alignment.center,
|
||||
child: const Icon(
|
||||
Icons.arrow_back,
|
||||
|
|
@ -236,15 +251,15 @@ class _VerifyMnemonicPageState extends ConsumerState<VerifyMnemonicPage> {
|
|||
fontSize: 18,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.56,
|
||||
letterSpacing: -0.45,
|
||||
height: 1.25,
|
||||
letterSpacing: -0.27,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
// 占位
|
||||
const SizedBox(width: 40),
|
||||
const SizedBox(width: 44, height: 44),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
@ -272,16 +287,18 @@ class _VerifyMnemonicPageState extends ConsumerState<VerifyMnemonicPage> {
|
|||
});
|
||||
},
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// 勾选框 - 金黄色圆角方形
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
width: 22,
|
||||
height: 22,
|
||||
decoration: BoxDecoration(
|
||||
color: _isChecked ? const Color(0xFFD4AF37) : Colors.transparent,
|
||||
color: _isChecked ? const Color(0xFFD4A84B) : const Color(0xFFFFF5E6),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: _isChecked ? const Color(0xFFD4AF37) : const Color(0xFF8B5A2B),
|
||||
width: 2,
|
||||
color: _isChecked ? const Color(0xFFD4A84B) : const Color(0xFFD4A84B),
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: _isChecked
|
||||
|
|
@ -315,25 +332,40 @@ class _VerifyMnemonicPageState extends ConsumerState<VerifyMnemonicPage> {
|
|||
Expanded(
|
||||
child: Container(
|
||||
height: 1,
|
||||
color: const Color(0x338B5A2B),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
const Color(0xFFD4A84B).withValues(alpha: 0.1),
|
||||
const Color(0xFFD4A84B).withValues(alpha: 0.4),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
'或',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.43,
|
||||
color: Color(0x998B5A2B),
|
||||
color: Color(0xFF8B7355),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 1,
|
||||
color: const Color(0x338B5A2B),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
const Color(0xFFD4A84B).withValues(alpha: 0.4),
|
||||
const Color(0xFFD4A84B).withValues(alpha: 0.1),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -350,14 +382,15 @@ class _VerifyMnemonicPageState extends ConsumerState<VerifyMnemonicPage> {
|
|||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// 选项按钮
|
||||
const SizedBox(height: 20),
|
||||
// 选项按钮 - 使用网格布局
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
spacing: 10,
|
||||
runSpacing: 12,
|
||||
children: _wordOptions.map((word) => _buildWordOption(word)).toList(),
|
||||
),
|
||||
|
|
@ -368,38 +401,29 @@ class _VerifyMnemonicPageState extends ConsumerState<VerifyMnemonicPage> {
|
|||
/// 构建单词选项按钮
|
||||
Widget _buildWordOption(String word) {
|
||||
final isSelected = _selectedWord == word;
|
||||
final isCorrect = word == _correctWord;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _selectWord(word),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
// 选中时使用浅金色背景,未选中时使用米色背景
|
||||
color: isSelected
|
||||
? const Color(0x33D4AF37)
|
||||
: const Color(0x1A8B5A2B),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
? const Color(0xFFFFF5E6)
|
||||
: const Color(0xFFF5ECD9),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: isSelected
|
||||
? Border.all(color: const Color(0xFFD4AF37), width: 1)
|
||||
: null,
|
||||
boxShadow: isSelected
|
||||
? [
|
||||
const BoxShadow(
|
||||
color: Color(0xCCD4AF37),
|
||||
blurRadius: 0,
|
||||
spreadRadius: 1,
|
||||
),
|
||||
]
|
||||
: null,
|
||||
? Border.all(color: const Color(0xFFD4A84B), width: 1.5)
|
||||
: Border.all(color: Colors.transparent, width: 1.5),
|
||||
),
|
||||
child: Text(
|
||||
word,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontSize: 15,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
color: isSelected ? const Color(0xFFD4AF37) : const Color(0xFF5D4037),
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.4,
|
||||
color: isSelected ? const Color(0xFFD4A84B) : const Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -409,21 +433,34 @@ class _VerifyMnemonicPageState extends ConsumerState<VerifyMnemonicPage> {
|
|||
/// 构建底部按钮区域
|
||||
Widget _buildBottomButtons() {
|
||||
return Container(
|
||||
color: const Color(0xFFFFE4B5),
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFFFE4B5),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 确认并创建账号按钮
|
||||
GestureDetector(
|
||||
onTap: _isCreating ? null : _confirmAndCreate,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
// 禁用状态使用浅灰棕色,启用状态使用金色
|
||||
color: _canSubmit
|
||||
? const Color(0xFFD4AF37)
|
||||
: const Color(0x668B5A2B),
|
||||
? const Color(0xFFD4A84B)
|
||||
: const Color(0xFFCBBDA8),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: _canSubmit
|
||||
? const [
|
||||
BoxShadow(
|
||||
color: Color(0x33D4A84B),
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Center(
|
||||
child: _isCreating
|
||||
|
|
@ -438,7 +475,7 @@ class _VerifyMnemonicPageState extends ConsumerState<VerifyMnemonicPage> {
|
|||
: Text(
|
||||
'确认并创建账号',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontSize: 17,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.5,
|
||||
|
|
@ -450,23 +487,18 @@ class _VerifyMnemonicPageState extends ConsumerState<VerifyMnemonicPage> {
|
|||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: 14),
|
||||
// 返回查看助记词
|
||||
GestureDetector(
|
||||
onTap: _goBack,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
alignment: Alignment.center,
|
||||
child: const Text(
|
||||
'返回查看助记词',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
color: Color(0xFFD4AF37),
|
||||
),
|
||||
child: const Text(
|
||||
'返回查看助记词',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
color: Color(0xFF8B7355),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../../routes/route_paths.dart';
|
||||
import '../../../../routes/app_router.dart';
|
||||
|
||||
/// 创建成功页面 - 显示钱包创建成功信息
|
||||
/// 展示序列号和三个链的钱包地址
|
||||
|
|
@ -15,6 +16,8 @@ class WalletCreatedPage extends ConsumerWidget {
|
|||
final String bscAddress;
|
||||
/// 序列号
|
||||
final String serialNumber;
|
||||
/// 推荐码
|
||||
final String? referralCode;
|
||||
|
||||
const WalletCreatedPage({
|
||||
super.key,
|
||||
|
|
@ -22,6 +25,7 @@ class WalletCreatedPage extends ConsumerWidget {
|
|||
required this.dstAddress,
|
||||
required this.bscAddress,
|
||||
required this.serialNumber,
|
||||
this.referralCode,
|
||||
});
|
||||
|
||||
/// 复制内容到剪贴板
|
||||
|
|
@ -30,7 +34,7 @@ class WalletCreatedPage extends ConsumerWidget {
|
|||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('$label 已复制'),
|
||||
backgroundColor: const Color(0xFFD4AF37),
|
||||
backgroundColor: const Color(0xFFD4A84B),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
|
|
@ -41,14 +45,18 @@ class WalletCreatedPage extends ConsumerWidget {
|
|||
context.go(RoutePaths.ranking);
|
||||
}
|
||||
|
||||
/// 分享给好友
|
||||
/// 分享给好友 - 跳转到分享页面
|
||||
void _shareToFriends(BuildContext context) {
|
||||
// TODO: 实现分享功能
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('分享功能即将推出'),
|
||||
backgroundColor: Color(0xFF8B5A2B),
|
||||
duration: Duration(seconds: 2),
|
||||
// 构建分享链接 (带推荐码)
|
||||
final shareLink = referralCode != null
|
||||
? 'https://rwa-durian.app/invite?code=$referralCode'
|
||||
: 'https://rwa-durian.app/invite?seq=$serialNumber';
|
||||
|
||||
context.push(
|
||||
RoutePaths.share,
|
||||
extra: SharePageParams(
|
||||
shareLink: shareLink,
|
||||
referralCode: referralCode,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -62,42 +70,45 @@ class WalletCreatedPage extends ConsumerWidget {
|
|||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
// 渐变背景 - 从浅米色到金黄色
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Color(0xFFFFF5E6),
|
||||
Color(0xFFFFE4B5),
|
||||
Color(0xFFFFF8F0), // 更浅的米色顶部
|
||||
Color(0xFFFFE4B5), // 金黄色底部
|
||||
],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 32),
|
||||
// 成功图标
|
||||
_buildSuccessIcon(),
|
||||
const SizedBox(height: 24),
|
||||
// 标题和副标题
|
||||
_buildTitle(),
|
||||
const SizedBox(height: 32),
|
||||
// 钱包信息卡片
|
||||
_buildWalletInfoCard(context),
|
||||
const SizedBox(height: 32),
|
||||
// 底部按钮
|
||||
_buildActionButtons(context),
|
||||
const SizedBox(height: 16),
|
||||
// 提示文字
|
||||
_buildWarningText(),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 内容区域
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 48),
|
||||
// 成功图标
|
||||
_buildSuccessIcon(),
|
||||
const SizedBox(height: 20),
|
||||
// 标题和副标题
|
||||
_buildTitle(),
|
||||
const SizedBox(height: 28),
|
||||
// 钱包信息卡片
|
||||
_buildWalletInfoCard(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// 底部按钮区域
|
||||
_buildBottomSection(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -110,14 +121,27 @@ class WalletCreatedPage extends ConsumerWidget {
|
|||
width: 120,
|
||||
height: 120,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0x33D4AF37),
|
||||
color: Color(0xFFFFF3D6), // 浅黄色背景
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
Icons.check_circle_outline,
|
||||
size: 64,
|
||||
color: Color(0xFFD4AF37),
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: const Color(0xFFD4A84B),
|
||||
width: 3,
|
||||
),
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
Icons.check,
|
||||
size: 36,
|
||||
color: Color(0xFFD4A84B),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -125,16 +149,16 @@ class WalletCreatedPage extends ConsumerWidget {
|
|||
|
||||
/// 构建标题
|
||||
Widget _buildTitle() {
|
||||
return Column(
|
||||
children: const [
|
||||
return const Column(
|
||||
children: [
|
||||
Text(
|
||||
'账号创建成功',
|
||||
style: TextStyle(
|
||||
fontSize: 30,
|
||||
fontSize: 26,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.25,
|
||||
letterSpacing: -0.75,
|
||||
letterSpacing: -0.5,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
|
|
@ -142,10 +166,11 @@ class WalletCreatedPage extends ConsumerWidget {
|
|||
Text(
|
||||
'已为您创建钱包与序列号',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontSize: 15,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
color: Color(0xFF8B5A2B),
|
||||
color: Color(0xFF8B7355),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -156,51 +181,53 @@ class WalletCreatedPage extends ConsumerWidget {
|
|||
Widget _buildWalletInfoCard(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x4DFFFFFF),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: const Color(0xFFFFFDF5),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x33D4AF37),
|
||||
blurRadius: 0,
|
||||
spreadRadius: 1,
|
||||
offset: Offset(0, 0),
|
||||
color: Color(0x0D000000),
|
||||
blurRadius: 4,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 序列号
|
||||
// 序列号 - 放在最上面
|
||||
_buildInfoItem(
|
||||
context: context,
|
||||
icon: Icons.key,
|
||||
iconWidget: _buildKeyIcon(),
|
||||
label: '序列号',
|
||||
value: serialNumber,
|
||||
isAddress: false,
|
||||
showDivider: true,
|
||||
),
|
||||
// KAVA 地址
|
||||
_buildInfoItem(
|
||||
context: context,
|
||||
icon: Icons.account_balance_wallet,
|
||||
iconWidget: _buildWalletIcon(),
|
||||
label: 'KAVA',
|
||||
value: kavaAddress,
|
||||
isAddress: true,
|
||||
showDivider: true,
|
||||
),
|
||||
// DST 地址
|
||||
_buildInfoItem(
|
||||
context: context,
|
||||
icon: Icons.account_balance_wallet,
|
||||
iconWidget: _buildWalletIcon(),
|
||||
label: 'DST',
|
||||
value: dstAddress,
|
||||
isAddress: true,
|
||||
showDivider: true,
|
||||
),
|
||||
// BSC 地址
|
||||
_buildInfoItem(
|
||||
context: context,
|
||||
icon: Icons.account_balance_wallet,
|
||||
iconWidget: _buildWalletIcon(),
|
||||
label: 'BSC',
|
||||
value: bscAddress,
|
||||
isAddress: true,
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
|
|
@ -208,153 +235,181 @@ class WalletCreatedPage extends ConsumerWidget {
|
|||
);
|
||||
}
|
||||
|
||||
/// 构建钥匙图标 (序列号)
|
||||
Widget _buildKeyIcon() {
|
||||
return Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF3D6),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
Icons.vpn_key_outlined,
|
||||
color: Color(0xFFD4A84B),
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建钱包图标 (地址)
|
||||
Widget _buildWalletIcon() {
|
||||
return Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF3D6),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
Icons.account_balance_wallet_outlined,
|
||||
color: Color(0xFFD4A84B),
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建信息项
|
||||
Widget _buildInfoItem({
|
||||
required BuildContext context,
|
||||
required IconData icon,
|
||||
required Widget iconWidget,
|
||||
required String label,
|
||||
required String value,
|
||||
required bool isAddress,
|
||||
required bool showDivider,
|
||||
}) {
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// 图标
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x33D4AF37),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: const Color(0xFFD4AF37),
|
||||
size: 24,
|
||||
// 格式化显示值
|
||||
final displayValue = isAddress ? _formatAddress(value) : value;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
border: showDivider
|
||||
? const Border(
|
||||
bottom: BorderSide(
|
||||
color: Color(0x1A5D4037),
|
||||
width: 1,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 图标
|
||||
iconWidget,
|
||||
const SizedBox(width: 14),
|
||||
// 标签和值 - 单行显示 "标签: 值"
|
||||
Expanded(
|
||||
child: Text(
|
||||
'$label: $displayValue',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// 标签和值
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'$label:',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
height: 1.5,
|
||||
color: Color(0x998B5A2B),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
label == '序列号' ? value : _formatAddress(value),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
height: 1.5,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// 复制按钮
|
||||
GestureDetector(
|
||||
onTap: () => _copyToClipboard(context, value, label),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: const Icon(
|
||||
Icons.copy_outlined,
|
||||
color: Color(0xFFD4A84B),
|
||||
size: 22,
|
||||
),
|
||||
// 复制按钮
|
||||
GestureDetector(
|
||||
onTap: () => _copyToClipboard(context, value, label),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: const Icon(
|
||||
Icons.copy,
|
||||
color: Color(0xCC8B5A2B),
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建底部区域 (按钮 + 提示文字)
|
||||
Widget _buildBottomSection(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 进入我的钱包按钮 - 金黄色
|
||||
GestureDetector(
|
||||
onTap: () => _enterWallet(context),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFD4A84B),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x33D4A84B),
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'进入我的钱包',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.5,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (showDivider)
|
||||
Container(
|
||||
height: 1,
|
||||
color: const Color(0x1A8B5A2B),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建操作按钮
|
||||
Widget _buildActionButtons(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
// 进入我的钱包按钮
|
||||
GestureDetector(
|
||||
onTap: () => _enterWallet(context),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 53,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFD4AF37),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'进入我的钱包',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.5,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 分享给好友按钮 - 深棕色
|
||||
GestureDetector(
|
||||
onTap: () => _shareToFriends(context),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF5D4037),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'分享给好友',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.5,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// 分享给好友按钮
|
||||
GestureDetector(
|
||||
onTap: () => _shareToFriends(context),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 53,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF8B5A2B),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'分享给好友',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// 提示文字
|
||||
const Text(
|
||||
'助记词仅显示一次,请妥善保管。',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.5,
|
||||
color: Color(0xFF8B7355),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建警告提示文字
|
||||
Widget _buildWarningText() {
|
||||
return const Text(
|
||||
'助记词仅显示一次,请妥善保管。',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
height: 1.5,
|
||||
color: Color(0xCC8B5A2B),
|
||||
],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,618 @@
|
|||
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 'package:qr_flutter/qr_flutter.dart';
|
||||
import '../../../../core/di/injection_container.dart';
|
||||
|
||||
/// 网络类型枚举
|
||||
enum NetworkType {
|
||||
kava,
|
||||
bsc,
|
||||
}
|
||||
|
||||
/// 充值 USDT 页面
|
||||
/// 显示充值二维码和地址,支持 KAVA 和 BSC 网络切换
|
||||
class DepositUsdtPage extends ConsumerStatefulWidget {
|
||||
const DepositUsdtPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<DepositUsdtPage> createState() => _DepositUsdtPageState();
|
||||
}
|
||||
|
||||
class _DepositUsdtPageState extends ConsumerState<DepositUsdtPage> {
|
||||
/// 当前选中的网络
|
||||
NetworkType _selectedNetwork = NetworkType.kava;
|
||||
|
||||
/// 是否正在加载
|
||||
bool _isLoading = true;
|
||||
|
||||
/// USDT 余额
|
||||
String _balance = '0.00';
|
||||
|
||||
/// KAVA 网络充值地址
|
||||
String? _kavaAddress;
|
||||
|
||||
/// BSC 网络充值地址
|
||||
String? _bscAddress;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadWalletData();
|
||||
}
|
||||
|
||||
/// 错误信息
|
||||
String? _errorMessage;
|
||||
|
||||
/// 加载钱包数据
|
||||
Future<void> _loadWalletData() async {
|
||||
try {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
final depositService = ref.read(depositServiceProvider);
|
||||
|
||||
// 获取充值地址 (验证后的安全地址)
|
||||
final addressResponse = await depositService.getDepositAddresses();
|
||||
|
||||
if (!addressResponse.isValid) {
|
||||
// 地址验证失败
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_errorMessage = addressResponse.message ?? '充值账户异常,请联系客服';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
_kavaAddress = addressResponse.kavaAddress;
|
||||
_bscAddress = addressResponse.bscAddress;
|
||||
|
||||
// 查询实时余额
|
||||
try {
|
||||
final balanceResponse = await depositService.getUsdtBalances();
|
||||
// 根据当前选中的网络显示余额
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
if (_selectedNetwork == NetworkType.kava && balanceResponse.kava != null) {
|
||||
_balance = balanceResponse.kava!.balance;
|
||||
} else if (_selectedNetwork == NetworkType.bsc && balanceResponse.bsc != null) {
|
||||
_balance = balanceResponse.bsc!.balance;
|
||||
} else {
|
||||
_balance = '0.00';
|
||||
}
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('查询余额失败: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_balance = '0.00';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('加载钱包数据失败: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_errorMessage = '加载失败,请重试';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取当前网络的充值地址
|
||||
String get _currentAddress {
|
||||
switch (_selectedNetwork) {
|
||||
case NetworkType.kava:
|
||||
return _kavaAddress ?? '';
|
||||
case NetworkType.bsc:
|
||||
return _bscAddress ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换网络
|
||||
void _switchNetwork(NetworkType network) {
|
||||
if (_selectedNetwork != network) {
|
||||
setState(() {
|
||||
_selectedNetwork = network;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 复制地址到剪贴板
|
||||
void _copyAddress() {
|
||||
if (_currentAddress.isEmpty) return;
|
||||
|
||||
Clipboard.setData(ClipboardData(text: _currentAddress));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('充值地址已复制'),
|
||||
backgroundColor: Color(0xFFD4AF37),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 返回上一页
|
||||
void _goBack() {
|
||||
context.pop();
|
||||
}
|
||||
|
||||
/// 完成充值
|
||||
void _onDepositComplete() {
|
||||
// 返回上一页
|
||||
context.pop();
|
||||
|
||||
// 显示提示
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('充值确认中,请稍候查看余额变化'),
|
||||
backgroundColor: Color(0xFFD4AF37),
|
||||
duration: Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
// 渐变背景
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Color(0xFFFFF7E6),
|
||||
Color(0xFFEAE0C8),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// 顶部导航栏
|
||||
_buildAppBar(),
|
||||
// 内容区域
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
// 余额显示
|
||||
_buildBalanceSection(),
|
||||
// 网络切换
|
||||
_buildNetworkSwitch(),
|
||||
const SizedBox(height: 8),
|
||||
// 二维码卡片
|
||||
_buildQrCodeCard(),
|
||||
// 警告提示
|
||||
_buildWarningText(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// 底部按钮
|
||||
_buildBottomButton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建顶部导航栏
|
||||
Widget _buildAppBar() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF5E6).withValues(alpha: 0.8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 返回按钮
|
||||
GestureDetector(
|
||||
onTap: _goBack,
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignment: Alignment.center,
|
||||
child: const Icon(
|
||||
Icons.arrow_back,
|
||||
color: Color(0xFF5D4037),
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
// 标题
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'充值 USDT',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.25,
|
||||
letterSpacing: -0.27,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
// 占位
|
||||
const SizedBox(width: 40),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建余额显示区域
|
||||
Widget _buildBalanceSection() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text(
|
||||
'可用余额: $_balance USDT',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
height: 1.5,
|
||||
color: const Color(0xFF5D4037).withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建网络切换
|
||||
Widget _buildNetworkSwitch() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: Row(
|
||||
children: [
|
||||
// KAVA 网络按钮
|
||||
Expanded(
|
||||
child: _buildNetworkButton(
|
||||
label: 'KAVA 网络',
|
||||
network: NetworkType.kava,
|
||||
isSelected: _selectedNetwork == NetworkType.kava,
|
||||
),
|
||||
),
|
||||
// BSC 网络按钮
|
||||
Expanded(
|
||||
child: _buildNetworkButton(
|
||||
label: 'BSC 网络',
|
||||
network: NetworkType.bsc,
|
||||
isSelected: _selectedNetwork == NetworkType.bsc,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建网络按钮
|
||||
Widget _buildNetworkButton({
|
||||
required String label,
|
||||
required NetworkType network,
|
||||
required bool isSelected,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: () => _switchNetwork(network),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 9.5, horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? const Color(0xFFD4AF37) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: isSelected
|
||||
? const [
|
||||
BoxShadow(
|
||||
color: Color(0x66D4AF37),
|
||||
blurRadius: 3,
|
||||
offset: Offset(0, 1),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
color: isSelected ? Colors.white : const Color(0xFF745D43),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建二维码卡片
|
||||
Widget _buildQrCodeCard() {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 二维码区域
|
||||
_buildQrCode(),
|
||||
const SizedBox(height: 24),
|
||||
// 地址信息
|
||||
_buildAddressInfo(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建二维码
|
||||
Widget _buildQrCode() {
|
||||
// 加载中
|
||||
if (_isLoading) {
|
||||
return Container(
|
||||
width: 192,
|
||||
height: 192,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1A1A2E),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 错误状态
|
||||
if (_errorMessage != null) {
|
||||
return Container(
|
||||
width: 192,
|
||||
height: 192,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF5E6),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: const Color(0xFFE74C3C), width: 1),
|
||||
),
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
color: Color(0xFFE74C3C),
|
||||
size: 40,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFFE74C3C),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
GestureDetector(
|
||||
onTap: _loadWalletData,
|
||||
child: const Text(
|
||||
'点击重试',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFFD4AF37),
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 地址为空
|
||||
if (_currentAddress.isEmpty) {
|
||||
return Container(
|
||||
width: 192,
|
||||
height: 192,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF5E6),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'暂无充值地址',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
width: 192,
|
||||
height: 192,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x1A000000),
|
||||
blurRadius: 10,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: QrImageView(
|
||||
data: _currentAddress,
|
||||
version: QrVersions.auto,
|
||||
size: 192,
|
||||
backgroundColor: Colors.white,
|
||||
padding: const EdgeInsets.all(12),
|
||||
eyeStyle: const QrEyeStyle(
|
||||
eyeShape: QrEyeShape.square,
|
||||
color: Color(0xFF1A1A2E),
|
||||
),
|
||||
dataModuleStyle: const QrDataModuleStyle(
|
||||
dataModuleShape: QrDataModuleShape.square,
|
||||
color: Color(0xFF1A1A2E),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建地址信息
|
||||
Widget _buildAddressInfo() {
|
||||
return Column(
|
||||
children: [
|
||||
// 充值地址标题
|
||||
const Text(
|
||||
'充值地址',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.25,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 地址文本
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
_isLoading ? '加载中...' : _currentAddress,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
height: 1.5,
|
||||
color: const Color(0xFF5D4037).withValues(alpha: 0.8),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
// 复制按钮
|
||||
GestureDetector(
|
||||
onTap: _isLoading ? null : _copyAddress,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFD4AF37).withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(9999),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'复制地址',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
color: _isLoading
|
||||
? const Color(0xFFB0A090)
|
||||
: const Color(0xFF745D43),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
Icons.copy_outlined,
|
||||
size: 18,
|
||||
color: _isLoading
|
||||
? const Color(0xFFB0A090)
|
||||
: const Color(0xFF745D43),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建警告文字
|
||||
Widget _buildWarningText() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 48),
|
||||
child: Text(
|
||||
'仅支持 USDT,错充将无法追回',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontFamily: 'Inter',
|
||||
height: 1.5,
|
||||
color: const Color(0xFFF97316).withValues(alpha: 0.8),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建底部按钮
|
||||
Widget _buildBottomButton() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
|
||||
child: GestureDetector(
|
||||
onTap: _onDepositComplete,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 55,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFD4AF37),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x4DD4AF37),
|
||||
blurRadius: 14,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'我已完成充值',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.5,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,496 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:city_pickers/city_pickers.dart';
|
||||
import '../widgets/planting_confirm_dialog.dart';
|
||||
|
||||
/// 认种省市选择页面参数
|
||||
class PlantingLocationParams {
|
||||
final int quantity;
|
||||
final double totalPrice;
|
||||
|
||||
PlantingLocationParams({
|
||||
required this.quantity,
|
||||
required this.totalPrice,
|
||||
});
|
||||
}
|
||||
|
||||
/// 认种省市选择页面
|
||||
/// 用户选择身份证所在省市,选择后不可修改
|
||||
class PlantingLocationPage extends ConsumerStatefulWidget {
|
||||
final int quantity;
|
||||
final double totalPrice;
|
||||
|
||||
const PlantingLocationPage({
|
||||
super.key,
|
||||
required this.quantity,
|
||||
required this.totalPrice,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<PlantingLocationPage> createState() =>
|
||||
_PlantingLocationPageState();
|
||||
}
|
||||
|
||||
class _PlantingLocationPageState extends ConsumerState<PlantingLocationPage> {
|
||||
/// 选中的省份
|
||||
String? _selectedProvince;
|
||||
|
||||
/// 选中的城市
|
||||
String? _selectedCity;
|
||||
|
||||
/// 是否正在提交
|
||||
bool _isSubmitting = false;
|
||||
|
||||
/// 返回上一页
|
||||
void _goBack() {
|
||||
context.pop();
|
||||
}
|
||||
|
||||
/// 显示省市选择器(使用 city_pickers)
|
||||
Future<void> _showCityPicker() async {
|
||||
final result = await CityPickers.showCityPicker(
|
||||
context: context,
|
||||
cancelWidget: const Text(
|
||||
'取消',
|
||||
style: TextStyle(color: Color(0xFF745D43), fontSize: 16),
|
||||
),
|
||||
confirmWidget: const Text(
|
||||
'确定',
|
||||
style: TextStyle(color: Color(0xFFD4AF37), fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
height: 300,
|
||||
showType: ShowType.pc, // 只显示省市两级
|
||||
barrierDismissible: true,
|
||||
);
|
||||
|
||||
if (result != null) {
|
||||
setState(() {
|
||||
_selectedProvince = result.provinceName;
|
||||
_selectedCity = result.cityName;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 显示省份选择器
|
||||
void _showProvinceSelector() {
|
||||
_showCityPicker();
|
||||
}
|
||||
|
||||
/// 显示城市选择器
|
||||
void _showCitySelector() {
|
||||
_showCityPicker();
|
||||
}
|
||||
|
||||
/// 确认选择
|
||||
void _confirmSelection() async {
|
||||
if (_selectedProvince == null || _selectedCity == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('请选择省份和城市'),
|
||||
backgroundColor: Color(0xFFD4AF37),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 显示确认弹窗(带5秒倒计时)
|
||||
await PlantingConfirmDialog.show(
|
||||
context: context,
|
||||
province: _selectedProvince!,
|
||||
city: _selectedCity!,
|
||||
onConfirm: _submitPlanting,
|
||||
);
|
||||
}
|
||||
|
||||
/// 提交认种请求
|
||||
Future<void> _submitPlanting() async {
|
||||
setState(() => _isSubmitting = true);
|
||||
|
||||
try {
|
||||
// TODO: 调用 API 提交认种请求
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
|
||||
if (mounted) {
|
||||
// 显示成功提示
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('认种成功!'),
|
||||
backgroundColor: Color(0xFF4CAF50),
|
||||
),
|
||||
);
|
||||
|
||||
// 返回到个人中心
|
||||
context.go('/profile');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('认种失败: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('认种失败: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isSubmitting = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否可以提交
|
||||
bool get _canSubmit =>
|
||||
_selectedProvince != null && _selectedCity != null && !_isSubmitting;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Color(0xFFFFF7E6),
|
||||
Color(0xFFEAE0C8),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// 顶部导航栏
|
||||
_buildHeader(),
|
||||
// 内容区域
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
// 提示文字
|
||||
_buildHintText(),
|
||||
const SizedBox(height: 8),
|
||||
// 省份选择
|
||||
_buildProvinceSelector(),
|
||||
const SizedBox(height: 22),
|
||||
// 城市选择
|
||||
_buildCitySelector(),
|
||||
const SizedBox(height: 24),
|
||||
// 警告提示
|
||||
_buildWarningCard(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 底部按钮
|
||||
_buildBottomButton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建顶部导航栏
|
||||
Widget _buildHeader() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF5E6).withValues(alpha: 0.8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 返回按钮
|
||||
GestureDetector(
|
||||
onTap: _goBack,
|
||||
child: Container(
|
||||
width: 24,
|
||||
height: 32,
|
||||
alignment: Alignment.center,
|
||||
child: const Icon(
|
||||
Icons.arrow_back_ios,
|
||||
color: Color(0xFFD4AF37),
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
// 返回文字
|
||||
GestureDetector(
|
||||
onTap: _goBack,
|
||||
child: const Text(
|
||||
'返回',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
color: Color(0xFFD4AF37),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 48),
|
||||
// 标题
|
||||
const Text(
|
||||
'认种 Planting',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.25,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建提示文字
|
||||
Widget _buildHintText() {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 4),
|
||||
child: Text(
|
||||
'请选择身份所在省市(只可选择一次)',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
height: 1.5,
|
||||
color: Color(0xFF745D43),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建省份选择器
|
||||
Widget _buildProvinceSelector() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标签
|
||||
const Text(
|
||||
'省份(必选)',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
// 选择框
|
||||
GestureDetector(
|
||||
onTap: _showProvinceSelector,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 17, vertical: 13),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: const Color(0x4D8B5A2B),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
_selectedProvince ?? '选择省份',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
height: 1.5,
|
||||
color: _selectedProvince != null
|
||||
? const Color(0xFF5D4037)
|
||||
: const Color(0xFF5D4037).withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
color: Color(0xFF8B5A2B),
|
||||
size: 24,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建城市选择器
|
||||
Widget _buildCitySelector() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标签
|
||||
const Text(
|
||||
'市级(必选)',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
// 选择框
|
||||
GestureDetector(
|
||||
onTap: _showCitySelector,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 17, vertical: 13),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: const Color(0x4D8B5A2B),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
_selectedCity ?? '选择市级',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
height: 1.5,
|
||||
color: _selectedCity != null
|
||||
? const Color(0xFF5D4037)
|
||||
: const Color(0xFF5D4037).withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
color: Color(0xFF8B5A2B),
|
||||
size: 24,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建警告提示卡片
|
||||
Widget _buildWarningCard() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x33FFC107),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 警告图标
|
||||
Container(
|
||||
width: 24,
|
||||
height: 34,
|
||||
alignment: Alignment.topCenter,
|
||||
child: const Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
color: Color(0xFFD4AF37),
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// 警告文字
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: const [
|
||||
Text(
|
||||
'必须选择身份证所在省与市,',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
color: Color(0xFF745D43),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'错误将无法获得 20 年榴莲果销售分成。',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
color: Color(0xFF745D43),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'选择后不可修改!',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
color: Color(0xFF745D43),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建底部按钮
|
||||
Widget _buildBottomButton() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
|
||||
child: GestureDetector(
|
||||
onTap: _canSubmit ? _confirmSelection : null,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: _canSubmit
|
||||
? const Color(0xFFD4AF37)
|
||||
: const Color(0xFFD4AF37).withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Center(
|
||||
child: _isSubmitting
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'确认选择',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.5,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,577 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../../routes/route_paths.dart';
|
||||
import '../../../../core/di/injection_container.dart';
|
||||
import 'planting_location_page.dart';
|
||||
|
||||
/// 认种数量选择页面
|
||||
/// 用户可以选择认种的榴莲树数量,根据可用余额自动计算最大可认种数量
|
||||
class PlantingQuantityPage extends ConsumerStatefulWidget {
|
||||
const PlantingQuantityPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<PlantingQuantityPage> createState() =>
|
||||
_PlantingQuantityPageState();
|
||||
}
|
||||
|
||||
class _PlantingQuantityPageState extends ConsumerState<PlantingQuantityPage> {
|
||||
/// 每棵树的价格 (USDT)
|
||||
static const double _pricePerTree = 2199.0;
|
||||
|
||||
/// 可用余额 (USDT) - 从 API 获取
|
||||
double _availableBalance = 0.0;
|
||||
|
||||
/// 当前选择的数量
|
||||
int _quantity = 0;
|
||||
|
||||
/// 是否正在加载
|
||||
bool _isLoading = false;
|
||||
|
||||
/// 加载错误信息
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadBalance();
|
||||
}
|
||||
|
||||
/// 加载用户余额
|
||||
Future<void> _loadBalance() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
// 从 API 获取用户 USDT 余额
|
||||
final depositService = ref.read(depositServiceProvider);
|
||||
final balanceResponse = await depositService.getUsdtBalances();
|
||||
|
||||
// 合并 KAVA 和 BSC 链的余额
|
||||
double totalBalance = 0.0;
|
||||
if (balanceResponse.kava != null) {
|
||||
totalBalance += double.tryParse(balanceResponse.kava!.balance) ?? 0.0;
|
||||
}
|
||||
if (balanceResponse.bsc != null) {
|
||||
totalBalance += double.tryParse(balanceResponse.bsc!.balance) ?? 0.0;
|
||||
}
|
||||
|
||||
// 计算最大可认种数量并自动填入
|
||||
final maxQty = (totalBalance / _pricePerTree).floor();
|
||||
|
||||
setState(() {
|
||||
_availableBalance = totalBalance;
|
||||
// 自动填入最大可认种数量,至少为1(如果有足够余额)
|
||||
_quantity = maxQty > 0 ? maxQty : 0;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('加载余额失败: $e');
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_errorMessage = '加载余额失败,请重试';
|
||||
_availableBalance = 0.0;
|
||||
_quantity = 0;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 最大可认种数量
|
||||
int get _maxQuantity {
|
||||
return (_availableBalance / _pricePerTree).floor();
|
||||
}
|
||||
|
||||
/// 减少数量
|
||||
void _decreaseQuantity() {
|
||||
if (_quantity > 1) {
|
||||
setState(() => _quantity--);
|
||||
}
|
||||
}
|
||||
|
||||
/// 增加数量
|
||||
void _increaseQuantity() {
|
||||
if (_quantity < _maxQuantity) {
|
||||
setState(() => _quantity++);
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否可以减少
|
||||
bool get _canDecrease => _quantity > 1;
|
||||
|
||||
/// 是否可以增加
|
||||
bool get _canIncrease => _quantity < _maxQuantity;
|
||||
|
||||
/// 返回上一页
|
||||
void _goBack() {
|
||||
context.pop();
|
||||
}
|
||||
|
||||
/// 下一步:选择省市
|
||||
void _goToNextStep() {
|
||||
if (_quantity > 0 && _quantity <= _maxQuantity) {
|
||||
context.push(
|
||||
RoutePaths.plantingLocation,
|
||||
extra: PlantingLocationParams(
|
||||
quantity: _quantity,
|
||||
totalPrice: _quantity * _pricePerTree,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 格式化数字显示
|
||||
String _formatNumber(double number) {
|
||||
final parts = number.toStringAsFixed(2).split('.');
|
||||
final intPart = parts[0].replaceAllMapped(
|
||||
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
|
||||
(Match m) => '${m[1]},',
|
||||
);
|
||||
return '$intPart.${parts[1]}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Color(0xFFFFF7E6),
|
||||
Color(0xFFEAE0C8),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// 顶部导航栏
|
||||
_buildHeader(),
|
||||
// 内容区域
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
// 余额卡片
|
||||
_buildBalanceCard(),
|
||||
const SizedBox(height: 24),
|
||||
// 输入认种数量标题
|
||||
_buildSectionTitle(),
|
||||
const SizedBox(height: 8),
|
||||
// 数量选择器
|
||||
_buildQuantitySelector(),
|
||||
const SizedBox(height: 8),
|
||||
// 价格信息
|
||||
_buildPriceInfo(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 底部按钮
|
||||
_buildBottomButton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建顶部导航栏
|
||||
Widget _buildHeader() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF7E6).withValues(alpha: 0.8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 返回按钮
|
||||
GestureDetector(
|
||||
onTap: _goBack,
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignment: Alignment.center,
|
||||
child: const Icon(
|
||||
Icons.arrow_back_ios,
|
||||
color: Color(0xFFD4AF37),
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
// 返回文字
|
||||
GestureDetector(
|
||||
onTap: _goBack,
|
||||
child: const Text(
|
||||
'返回',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
height: 1.5,
|
||||
color: Color(0xFFD4AF37),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 42),
|
||||
// 标题
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'认种 Planting',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.25,
|
||||
letterSpacing: -0.27,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建余额卡片
|
||||
Widget _buildBalanceCard() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x99FFFFFF),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x1A000000),
|
||||
blurRadius: 6,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
BoxShadow(
|
||||
color: Color(0x1A000000),
|
||||
blurRadius: 4,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标签行(包含刷新按钮)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'可用余额 (USDT)',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
height: 1.5,
|
||||
color: Color(0xFF745D43),
|
||||
),
|
||||
),
|
||||
// 刷新按钮
|
||||
if (!_isLoading)
|
||||
GestureDetector(
|
||||
onTap: _loadBalance,
|
||||
child: const Icon(
|
||||
Icons.refresh,
|
||||
color: Color(0xFFD4AF37),
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
// 余额数值或错误信息
|
||||
_isLoading
|
||||
? const SizedBox(
|
||||
height: 45,
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Color(0xFFD4AF37),
|
||||
),
|
||||
),
|
||||
)
|
||||
: _errorMessage != null
|
||||
? GestureDetector(
|
||||
onTap: _loadBalance,
|
||||
child: SizedBox(
|
||||
height: 45,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
color: Color(0xFFE65100),
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFFE65100),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'点击重试',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFFD4AF37),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
_formatNumber(_availableBalance),
|
||||
style: const TextStyle(
|
||||
fontSize: 36,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.25,
|
||||
letterSpacing: -0.54,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建区域标题
|
||||
Widget _buildSectionTitle() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 24),
|
||||
child: const Text(
|
||||
'输入认种数量',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.25,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建数量选择器
|
||||
Widget _buildQuantitySelector() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x99FFFFFF),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x1A000000),
|
||||
blurRadius: 6,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
BoxShadow(
|
||||
color: Color(0x1A000000),
|
||||
blurRadius: 4,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// 减少按钮
|
||||
_buildQuantityButton(
|
||||
icon: '-',
|
||||
onTap: _canDecrease ? _decreaseQuantity : null,
|
||||
enabled: _canDecrease,
|
||||
),
|
||||
const SizedBox(width: 38),
|
||||
// 数量显示
|
||||
Text(
|
||||
'$_quantity',
|
||||
style: const TextStyle(
|
||||
fontSize: 36,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.5,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 38),
|
||||
// 增加按钮
|
||||
_buildQuantityButton(
|
||||
icon: '+',
|
||||
onTap: _canIncrease ? _increaseQuantity : null,
|
||||
enabled: _canIncrease,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建数量调节按钮
|
||||
Widget _buildQuantityButton({
|
||||
required String icon,
|
||||
required VoidCallback? onTap,
|
||||
required bool enabled,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF7E6),
|
||||
borderRadius: BorderRadius.circular(9999),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x1A000000),
|
||||
blurRadius: 3,
|
||||
offset: Offset(0, 1),
|
||||
),
|
||||
BoxShadow(
|
||||
color: Color(0x1A000000),
|
||||
blurRadius: 2,
|
||||
offset: Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Opacity(
|
||||
opacity: enabled ? 1.0 : 0.4,
|
||||
child: Center(
|
||||
child: Text(
|
||||
icon,
|
||||
style: const TextStyle(
|
||||
fontSize: 30,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.5,
|
||||
color: Color(0xFF745D43),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建价格信息
|
||||
Widget _buildPriceInfo() {
|
||||
final calculationResult = (_availableBalance / _pricePerTree).toStringAsFixed(2);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 每棵价格
|
||||
Text(
|
||||
'每棵价格:${_pricePerTree.toInt()} USDT',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
height: 1.5,
|
||||
color: Color(0xFF000000),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
// 最大可认种
|
||||
Text(
|
||||
'最大可认种:$_maxQuantity 棵 (根据余额自动计算)',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
height: 1.5,
|
||||
color: Color(0xFF000000),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
// 计算说明
|
||||
Text(
|
||||
'(计算: ${_formatNumber(_availableBalance).replaceAll('.00', '')} \u00f7 ${_pricePerTree.toInt()} = $calculationResult \u2192 向下取整为 $_maxQuantity)',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
height: 1.5,
|
||||
color: Color(0xFF000000),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建底部按钮
|
||||
Widget _buildBottomButton() {
|
||||
final bool canProceed = _quantity > 0 && _quantity <= _maxQuantity && !_isLoading;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
|
||||
child: GestureDetector(
|
||||
onTap: canProceed ? _goToNextStep : null,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: canProceed
|
||||
? const Color(0xFFD4AF37)
|
||||
: const Color(0xFFD4AF37).withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: canProceed
|
||||
? const [
|
||||
BoxShadow(
|
||||
color: Color(0x1A000000),
|
||||
blurRadius: 15,
|
||||
offset: Offset(0, 10),
|
||||
),
|
||||
BoxShadow(
|
||||
color: Color(0x1A000000),
|
||||
blurRadius: 6,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'下一步:选择省市',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.56,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 认种数量参数 - 传递给下一个页面
|
||||
class PlantingQuantityParams {
|
||||
final int quantity;
|
||||
final double totalPrice;
|
||||
|
||||
PlantingQuantityParams({
|
||||
required this.quantity,
|
||||
required this.totalPrice,
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,280 @@
|
|||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 认种确认弹窗
|
||||
/// 带有5秒倒计时功能,倒计时结束后按钮才可点击
|
||||
class PlantingConfirmDialog extends StatefulWidget {
|
||||
/// 选中的省份
|
||||
final String province;
|
||||
|
||||
/// 选中的城市
|
||||
final String city;
|
||||
|
||||
/// 确认回调
|
||||
final VoidCallback onConfirm;
|
||||
|
||||
const PlantingConfirmDialog({
|
||||
super.key,
|
||||
required this.province,
|
||||
required this.city,
|
||||
required this.onConfirm,
|
||||
});
|
||||
|
||||
/// 显示确认弹窗
|
||||
static Future<bool?> show({
|
||||
required BuildContext context,
|
||||
required String province,
|
||||
required String city,
|
||||
required VoidCallback onConfirm,
|
||||
}) {
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
barrierColor: const Color(0x80000000),
|
||||
builder: (context) => PlantingConfirmDialog(
|
||||
province: province,
|
||||
city: city,
|
||||
onConfirm: onConfirm,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<PlantingConfirmDialog> createState() => _PlantingConfirmDialogState();
|
||||
}
|
||||
|
||||
class _PlantingConfirmDialogState extends State<PlantingConfirmDialog> {
|
||||
/// 倒计时秒数
|
||||
int _countdown = 5;
|
||||
|
||||
/// 倒计时定时器
|
||||
Timer? _timer;
|
||||
|
||||
/// 是否可以点击确认按钮
|
||||
bool get _canConfirm => _countdown <= 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_startCountdown();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 开始倒计时
|
||||
void _startCountdown() {
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (_countdown > 0) {
|
||||
setState(() => _countdown--);
|
||||
} else {
|
||||
timer.cancel();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 点击确认按钮
|
||||
void _handleConfirm() {
|
||||
if (_canConfirm) {
|
||||
Navigator.pop(context, true);
|
||||
widget.onConfirm();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
constraints: const BoxConstraints(maxWidth: 384),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x40000000),
|
||||
blurRadius: 50,
|
||||
offset: Offset(0, 25),
|
||||
spreadRadius: -12,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 警告图标
|
||||
_buildWarningIcon(),
|
||||
const SizedBox(height: 4),
|
||||
// 标题
|
||||
_buildTitle(),
|
||||
const SizedBox(height: 4),
|
||||
// 内容说明
|
||||
_buildContent(),
|
||||
const SizedBox(height: 4),
|
||||
// 倒计时提示
|
||||
_buildCountdownText(),
|
||||
const SizedBox(height: 4),
|
||||
// 确认按钮
|
||||
_buildConfirmButton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建警告图标
|
||||
Widget _buildWarningIcon() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF7E6),
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
color: Color(0xFF5D4037),
|
||||
size: 36,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建标题
|
||||
Widget _buildTitle() {
|
||||
return const Text(
|
||||
'最后确认!不可修改!',
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.25,
|
||||
letterSpacing: -0.33,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建内容说明
|
||||
Widget _buildContent() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 20),
|
||||
child: Column(
|
||||
children: [
|
||||
// 第一行:您选择的省市为
|
||||
RichText(
|
||||
textAlign: TextAlign.center,
|
||||
text: TextSpan(
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
height: 1.5,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
children: [
|
||||
const TextSpan(text: '您选择的省市为: '),
|
||||
TextSpan(
|
||||
text: '【${widget.province} · ${widget.city}】',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF745D43),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// 第二行:警告信息
|
||||
RichText(
|
||||
textAlign: TextAlign.center,
|
||||
text: const TextSpan(
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
height: 1.5,
|
||||
color: Color(0xFFFF4D4F),
|
||||
),
|
||||
children: [
|
||||
TextSpan(text: '若错误,将失去 '),
|
||||
TextSpan(
|
||||
text: '20 年收益分成',
|
||||
style: TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建倒计时文字
|
||||
Widget _buildCountdownText() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Text(
|
||||
'按钮倒计时:$_countdown 秒',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
height: 1.5,
|
||||
color: Color(0xFF745D43),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建确认按钮
|
||||
Widget _buildConfirmButton() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: GestureDetector(
|
||||
onTap: _canConfirm ? _handleConfirm : null,
|
||||
child: Container(
|
||||
height: 48,
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 84,
|
||||
maxWidth: 480,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: _canConfirm
|
||||
? const Color(0xFFD4AF37)
|
||||
: const Color(0xFFD4AF37).withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
_canConfirm ? '确认认种' : '确认认种 (${_countdown}s)',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.5,
|
||||
letterSpacing: 0.24,
|
||||
color: _canConfirm
|
||||
? Colors.white
|
||||
: const Color(0xFFD4AF37).withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -145,7 +145,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||
|
||||
/// 充值USDT
|
||||
void _onDeposit() {
|
||||
context.push(RoutePaths.deposit);
|
||||
context.push(RoutePaths.depositUsdt);
|
||||
}
|
||||
|
||||
/// 进入交易
|
||||
|
|
|
|||
|
|
@ -0,0 +1,351 @@
|
|||
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 'package:qr_flutter/qr_flutter.dart';
|
||||
import '../../../../core/di/injection_container.dart';
|
||||
|
||||
/// 分享页面 - 显示邀请链接和二维码
|
||||
/// 进入页面时自动调用 API 生成短链,显示二维码
|
||||
class SharePage extends ConsumerStatefulWidget {
|
||||
/// 初始分享链接 (fallback)
|
||||
final String shareLink;
|
||||
/// 邀请码
|
||||
final String? referralCode;
|
||||
|
||||
const SharePage({
|
||||
super.key,
|
||||
required this.shareLink,
|
||||
this.referralCode,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<SharePage> createState() => _SharePageState();
|
||||
}
|
||||
|
||||
class _SharePageState extends ConsumerState<SharePage> {
|
||||
/// 实际显示的分享链接 (API 返回的短链)
|
||||
late String _displayLink;
|
||||
/// 是否正在加载
|
||||
bool _isLoading = true;
|
||||
/// 错误信息
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_displayLink = widget.shareLink; // 默认使用传入的链接
|
||||
_loadShareLink();
|
||||
}
|
||||
|
||||
/// 加载分享链接 (调用 API 生成短链)
|
||||
Future<void> _loadShareLink() async {
|
||||
try {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
final referralService = ref.read(referralServiceProvider);
|
||||
|
||||
// 调用 API 生成短链
|
||||
final linkResponse = await referralService.generateReferralLink();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
// 优先使用短链,如果没有则使用完整链接
|
||||
_displayLink = linkResponse.shortUrl.isNotEmpty
|
||||
? linkResponse.shortUrl
|
||||
: linkResponse.fullUrl;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('加载分享链接失败: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
// 失败时使用传入的默认链接
|
||||
_displayLink = widget.shareLink;
|
||||
_errorMessage = '加载短链失败,使用默认链接';
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 复制链接到剪贴板
|
||||
void _copyLink() {
|
||||
Clipboard.setData(ClipboardData(text: _displayLink));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('链接已复制'),
|
||||
backgroundColor: Color(0xFFD4A84B),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 返回上一页
|
||||
void _goBack() {
|
||||
context.pop();
|
||||
}
|
||||
|
||||
/// 确认按钮点击
|
||||
void _onConfirm() {
|
||||
context.pop();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
// 渐变背景 - 从浅黄到白色
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Color(0xFFFFF7E6), // 浅黄色顶部
|
||||
Color(0xFFFFFDF8), // 接近白色底部
|
||||
],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// 顶部导航栏
|
||||
_buildAppBar(),
|
||||
// 内容区域
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 100),
|
||||
// QR 码区域
|
||||
_buildQrCodeSection(),
|
||||
const SizedBox(height: 40),
|
||||
// 链接输入框
|
||||
_buildLinkSection(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// 底部确认按钮
|
||||
_buildFooter(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建顶部导航栏
|
||||
Widget _buildAppBar() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
// 返回按钮
|
||||
GestureDetector(
|
||||
onTap: _goBack,
|
||||
child: Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignment: Alignment.center,
|
||||
child: const Icon(
|
||||
Icons.arrow_back_ios,
|
||||
color: Color(0xFF8C6A3E),
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// 标题
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'分享页面',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontFamily: 'Segoe UI',
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.56,
|
||||
color: Color(0xFF8C6A3E),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建 QR 码区域
|
||||
Widget _buildQrCodeSection() {
|
||||
return Container(
|
||||
width: 256,
|
||||
height: 256,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFAF3E3),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x1A8C6A3E), // rgba(140, 106, 62, 0.1)
|
||||
blurRadius: 30,
|
||||
offset: Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Center(
|
||||
child: _isLoading
|
||||
? const CircularProgressIndicator(
|
||||
color: Color(0xFFD4A84B),
|
||||
strokeWidth: 2,
|
||||
)
|
||||
: QrImageView(
|
||||
data: _displayLink,
|
||||
version: QrVersions.auto,
|
||||
size: 200,
|
||||
backgroundColor: Colors.transparent,
|
||||
eyeStyle: const QrEyeStyle(
|
||||
eyeShape: QrEyeShape.square,
|
||||
color: Color(0xFF8C6A3E),
|
||||
),
|
||||
dataModuleStyle: const QrDataModuleStyle(
|
||||
dataModuleShape: QrDataModuleShape.square,
|
||||
color: Color(0xFF8C6A3E),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建链接显示区域
|
||||
Widget _buildLinkSection() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: double.infinity,
|
||||
constraints: const BoxConstraints(maxWidth: 296),
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 12, 8),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8F1E2),
|
||||
borderRadius: BorderRadius.circular(9999), // 胶囊形状
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 链接文本
|
||||
Expanded(
|
||||
child: _isLoading
|
||||
? const Text(
|
||||
'加载中...',
|
||||
style: TextStyle(
|
||||
fontSize: 13.7,
|
||||
fontFamily: 'Segoe UI',
|
||||
height: 1.5,
|
||||
color: Color(0xFFB0A090),
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
_displayLink,
|
||||
style: const TextStyle(
|
||||
fontSize: 13.7,
|
||||
fontFamily: 'Segoe UI',
|
||||
height: 1.5,
|
||||
color: Color(0xFF8C6A3E),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// 复制按钮
|
||||
GestureDetector(
|
||||
onTap: _isLoading ? null : _copyLink,
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(9999),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x0D000000), // rgba(0, 0, 0, 0.05)
|
||||
blurRadius: 2,
|
||||
offset: Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.copy_outlined,
|
||||
color: _isLoading
|
||||
? const Color(0xFFB0A090)
|
||||
: const Color(0xFF8C6A3E),
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 错误提示
|
||||
if (_errorMessage != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFFB0A090),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建底部确认按钮
|
||||
Widget _buildFooter() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: GestureDetector(
|
||||
onTap: _onConfirm,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFD1A45B),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x4DD1A45B), // rgba(209, 164, 91, 0.3)
|
||||
blurRadius: 15,
|
||||
offset: Offset(0, 10),
|
||||
),
|
||||
BoxShadow(
|
||||
color: Color(0x4DD1A45B), // rgba(209, 164, 91, 0.3)
|
||||
blurRadius: 6,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'确认',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontFamily: 'Segoe UI',
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.56,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,10 @@ import '../features/mining/presentation/pages/mining_page.dart';
|
|||
import '../features/trading/presentation/pages/trading_page.dart';
|
||||
import '../features/profile/presentation/pages/profile_page.dart';
|
||||
import '../features/profile/presentation/pages/edit_profile_page.dart';
|
||||
import '../features/share/presentation/pages/share_page.dart';
|
||||
import '../features/deposit/presentation/pages/deposit_usdt_page.dart';
|
||||
import '../features/planting/presentation/pages/planting_quantity_page.dart';
|
||||
import '../features/planting/presentation/pages/planting_location_page.dart';
|
||||
import 'route_paths.dart';
|
||||
import 'route_names.dart';
|
||||
|
||||
|
|
@ -52,12 +56,25 @@ class WalletCreatedParams {
|
|||
final String dstAddress;
|
||||
final String bscAddress;
|
||||
final String serialNumber;
|
||||
final String? referralCode;
|
||||
|
||||
WalletCreatedParams({
|
||||
required this.kavaAddress,
|
||||
required this.dstAddress,
|
||||
required this.bscAddress,
|
||||
required this.serialNumber,
|
||||
this.referralCode,
|
||||
});
|
||||
}
|
||||
|
||||
/// 分享页面参数
|
||||
class SharePageParams {
|
||||
final String shareLink;
|
||||
final String? referralCode;
|
||||
|
||||
SharePageParams({
|
||||
required this.shareLink,
|
||||
this.referralCode,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -134,6 +151,7 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
|||
dstAddress: params.dstAddress,
|
||||
bscAddress: params.bscAddress,
|
||||
serialNumber: params.serialNumber,
|
||||
referralCode: params.referralCode,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
@ -145,6 +163,46 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
|||
builder: (context, state) => const EditProfilePage(),
|
||||
),
|
||||
|
||||
// Share Page (分享页面)
|
||||
GoRoute(
|
||||
path: RoutePaths.share,
|
||||
name: RouteNames.share,
|
||||
builder: (context, state) {
|
||||
final params = state.extra as SharePageParams;
|
||||
return SharePage(
|
||||
shareLink: params.shareLink,
|
||||
referralCode: params.referralCode,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// Deposit USDT Page (充值 USDT)
|
||||
GoRoute(
|
||||
path: RoutePaths.depositUsdt,
|
||||
name: RouteNames.depositUsdt,
|
||||
builder: (context, state) => const DepositUsdtPage(),
|
||||
),
|
||||
|
||||
// Planting Quantity Page (认种 - 选择数量)
|
||||
GoRoute(
|
||||
path: RoutePaths.plantingQuantity,
|
||||
name: RouteNames.plantingQuantity,
|
||||
builder: (context, state) => const PlantingQuantityPage(),
|
||||
),
|
||||
|
||||
// Planting Location Page (认种 - 选择省市)
|
||||
GoRoute(
|
||||
path: RoutePaths.plantingLocation,
|
||||
name: RouteNames.plantingLocation,
|
||||
builder: (context, state) {
|
||||
final params = state.extra as PlantingLocationParams;
|
||||
return PlantingLocationPage(
|
||||
quantity: params.quantity,
|
||||
totalPrice: params.totalPrice,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// Main Shell with Bottom Navigation
|
||||
ShellRoute(
|
||||
navigatorKey: _shellNavigatorKey,
|
||||
|
|
|
|||
|
|
@ -22,10 +22,14 @@ class RouteNames {
|
|||
static const referralList = 'referral-list';
|
||||
static const earningsDetail = 'earnings-detail';
|
||||
static const deposit = 'deposit';
|
||||
static const depositUsdt = 'deposit-usdt';
|
||||
static const plantingQuantity = 'planting-quantity';
|
||||
static const plantingLocation = 'planting-location';
|
||||
static const googleAuth = 'google-auth';
|
||||
static const changePassword = 'change-password';
|
||||
static const bindEmail = 'bind-email';
|
||||
static const transactionHistory = 'transaction-history';
|
||||
|
||||
// Share
|
||||
static const share = 'share';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,10 +22,14 @@ class RoutePaths {
|
|||
static const referralList = '/profile/referrals';
|
||||
static const earningsDetail = '/profile/earnings';
|
||||
static const deposit = '/deposit';
|
||||
static const depositUsdt = '/deposit/usdt';
|
||||
static const plantingQuantity = '/planting/quantity';
|
||||
static const plantingLocation = '/planting/location';
|
||||
static const googleAuth = '/security/google-auth';
|
||||
static const changePassword = '/security/password';
|
||||
static const bindEmail = '/security/email';
|
||||
static const transactionHistory = '/trading/history';
|
||||
|
||||
// Share
|
||||
static const share = '/share';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -169,6 +169,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.4"
|
||||
city_pickers:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: city_pickers
|
||||
sha256: "583102c8d9eecb1f7abc5ff52a22d7cb019b9808cdb24b80c7692c769f8da153"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
cli_util:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -861,6 +869,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.3.2"
|
||||
lpinyin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lpinyin
|
||||
sha256: "0bb843363f1f65170efd09fbdfc760c7ec34fc6354f9fcb2f89e74866a0d814a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.3"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -1181,6 +1197,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.28.0"
|
||||
scrollable_positioned_list:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: scrollable_positioned_list
|
||||
sha256: "1b54d5f1329a1e263269abc9e2543d90806131aa14fe7c6062a8054d57249287"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.8"
|
||||
sec:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ dependencies:
|
|||
lottie: ^3.1.0
|
||||
qr_flutter: ^4.1.0
|
||||
flutter_screenutil: ^5.9.0
|
||||
city_pickers: ^1.3.0
|
||||
|
||||
# 工具
|
||||
intl: ^0.20.2
|
||||
|
|
|
|||
|
|
@ -0,0 +1,380 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:rwa_android_app/core/services/account_service.dart';
|
||||
import 'package:rwa_android_app/core/network/api_client.dart';
|
||||
import 'package:rwa_android_app/core/storage/secure_storage.dart';
|
||||
import 'package:rwa_android_app/core/storage/storage_keys.dart';
|
||||
|
||||
// Mock classes using mocktail
|
||||
class MockApiClient extends Mock implements ApiClient {}
|
||||
|
||||
class MockSecureStorage extends Mock implements SecureStorage {}
|
||||
|
||||
void main() {
|
||||
late AccountService accountService;
|
||||
late MockApiClient mockApiClient;
|
||||
late MockSecureStorage mockSecureStorage;
|
||||
|
||||
setUpAll(() {
|
||||
// Register fallback values for any() matchers
|
||||
registerFallbackValue(RequestOptions(path: ''));
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
mockApiClient = MockApiClient();
|
||||
mockSecureStorage = MockSecureStorage();
|
||||
accountService = AccountService(
|
||||
apiClient: mockApiClient,
|
||||
secureStorage: mockSecureStorage,
|
||||
);
|
||||
});
|
||||
|
||||
group('AccountService', () {
|
||||
group('createAccount', () {
|
||||
test('should create account and save data to secure storage', () async {
|
||||
// Arrange
|
||||
const testDeviceId = 'test-device-123';
|
||||
final mockResponse = Response<Map<String, dynamic>>(
|
||||
data: {
|
||||
'userId': '123456789',
|
||||
'accountSequence': 1,
|
||||
'referralCode': 'ABC123',
|
||||
'mnemonic': '',
|
||||
'clientShareData': 'mock-client-share-data',
|
||||
'publicKey': 'mock-public-key',
|
||||
'walletAddresses': {
|
||||
'kava': '0x1234567890abcdef1234567890abcdef12345678',
|
||||
'dst': 'dst1abcdefghijklmnopqrstuvwxyz123456789',
|
||||
'bsc': '0x1234567890abcdef1234567890abcdef12345678',
|
||||
},
|
||||
'accessToken': 'mock-access-token',
|
||||
'refreshToken': 'mock-refresh-token',
|
||||
},
|
||||
requestOptions: RequestOptions(path: '/user/auto-create'),
|
||||
statusCode: 201,
|
||||
);
|
||||
|
||||
// Setup mocks
|
||||
when(() => mockSecureStorage.read(key: StorageKeys.deviceId))
|
||||
.thenAnswer((_) async => testDeviceId);
|
||||
when(() => mockApiClient.post(any(), data: any(named: 'data')))
|
||||
.thenAnswer((_) async => mockResponse);
|
||||
when(() => mockSecureStorage.write(key: any(named: 'key'), value: any(named: 'value')))
|
||||
.thenAnswer((_) async {});
|
||||
|
||||
// Act
|
||||
final result = await accountService.createAccount();
|
||||
|
||||
// Assert
|
||||
expect(result.userId, '123456789');
|
||||
expect(result.accountSequence, 1);
|
||||
expect(result.referralCode, 'ABC123');
|
||||
expect(result.clientShareData, 'mock-client-share-data');
|
||||
expect(result.publicKey, 'mock-public-key');
|
||||
expect(result.walletAddresses.kava, '0x1234567890abcdef1234567890abcdef12345678');
|
||||
expect(result.walletAddresses.dst, 'dst1abcdefghijklmnopqrstuvwxyz123456789');
|
||||
expect(result.walletAddresses.bsc, '0x1234567890abcdef1234567890abcdef12345678');
|
||||
expect(result.accessToken, 'mock-access-token');
|
||||
expect(result.refreshToken, 'mock-refresh-token');
|
||||
|
||||
// Verify storage calls
|
||||
verify(() => mockSecureStorage.write(
|
||||
key: StorageKeys.userId,
|
||||
value: '123456789',
|
||||
)).called(1);
|
||||
verify(() => mockSecureStorage.write(
|
||||
key: StorageKeys.accountSequence,
|
||||
value: '1',
|
||||
)).called(1);
|
||||
verify(() => mockSecureStorage.write(
|
||||
key: StorageKeys.referralCode,
|
||||
value: 'ABC123',
|
||||
)).called(1);
|
||||
verify(() => mockSecureStorage.write(
|
||||
key: StorageKeys.accessToken,
|
||||
value: 'mock-access-token',
|
||||
)).called(1);
|
||||
verify(() => mockSecureStorage.write(
|
||||
key: StorageKeys.refreshToken,
|
||||
value: 'mock-refresh-token',
|
||||
)).called(1);
|
||||
verify(() => mockSecureStorage.write(
|
||||
key: StorageKeys.isWalletCreated,
|
||||
value: 'true',
|
||||
)).called(1);
|
||||
});
|
||||
|
||||
test('should handle MPC mode without mnemonic', () async {
|
||||
// Arrange
|
||||
const testDeviceId = 'test-device-123';
|
||||
final mockResponse = Response<Map<String, dynamic>>(
|
||||
data: {
|
||||
'userId': '123456789',
|
||||
'accountSequence': 1,
|
||||
'referralCode': 'ABC123',
|
||||
'mnemonic': '', // Empty in MPC mode
|
||||
'clientShareData': 'client-share-data',
|
||||
'publicKey': 'public-key',
|
||||
'walletAddresses': {
|
||||
'kava': '0x1234',
|
||||
'dst': 'dst1abc',
|
||||
'bsc': '0x1234',
|
||||
},
|
||||
'accessToken': 'token',
|
||||
'refreshToken': 'refresh',
|
||||
},
|
||||
requestOptions: RequestOptions(path: '/user/auto-create'),
|
||||
statusCode: 201,
|
||||
);
|
||||
|
||||
when(() => mockSecureStorage.read(key: StorageKeys.deviceId))
|
||||
.thenAnswer((_) async => testDeviceId);
|
||||
when(() => mockApiClient.post(any(), data: any(named: 'data')))
|
||||
.thenAnswer((_) async => mockResponse);
|
||||
when(() => mockSecureStorage.write(key: any(named: 'key'), value: any(named: 'value')))
|
||||
.thenAnswer((_) async {});
|
||||
|
||||
// Act
|
||||
final result = await accountService.createAccount();
|
||||
|
||||
// Assert - MPC data should be stored
|
||||
expect(result.mnemonic, isEmpty);
|
||||
expect(result.clientShareData, 'client-share-data');
|
||||
expect(result.publicKey, 'public-key');
|
||||
|
||||
verify(() => mockSecureStorage.write(
|
||||
key: StorageKeys.mpcClientShareData,
|
||||
value: 'client-share-data',
|
||||
)).called(1);
|
||||
verify(() => mockSecureStorage.write(
|
||||
key: StorageKeys.mpcPublicKey,
|
||||
value: 'public-key',
|
||||
)).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
group('getOrCreateDeviceId', () {
|
||||
test('should return existing device id if available', () async {
|
||||
// Arrange
|
||||
const existingDeviceId = 'existing-device-123';
|
||||
when(() => mockSecureStorage.read(key: StorageKeys.deviceId))
|
||||
.thenAnswer((_) async => existingDeviceId);
|
||||
|
||||
// Act
|
||||
final result = await accountService.getOrCreateDeviceId();
|
||||
|
||||
// Assert
|
||||
expect(result, existingDeviceId);
|
||||
verifyNever(() => mockSecureStorage.write(
|
||||
key: any(named: 'key'),
|
||||
value: any(named: 'value'),
|
||||
));
|
||||
});
|
||||
|
||||
test('should generate and save new device id if not exists', () async {
|
||||
// Arrange
|
||||
when(() => mockSecureStorage.read(key: StorageKeys.deviceId))
|
||||
.thenAnswer((_) async => null);
|
||||
when(() => mockSecureStorage.write(key: any(named: 'key'), value: any(named: 'value')))
|
||||
.thenAnswer((_) async {});
|
||||
|
||||
// Act
|
||||
final result = await accountService.getOrCreateDeviceId();
|
||||
|
||||
// Assert
|
||||
expect(result, isNotEmpty);
|
||||
// UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
|
||||
expect(
|
||||
result,
|
||||
matches(RegExp(
|
||||
r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$',
|
||||
caseSensitive: false,
|
||||
)));
|
||||
verify(() => mockSecureStorage.write(
|
||||
key: StorageKeys.deviceId,
|
||||
value: result,
|
||||
)).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
group('hasAccount', () {
|
||||
test('should return true when wallet is created', () async {
|
||||
// Arrange
|
||||
when(() => mockSecureStorage.read(key: StorageKeys.isWalletCreated))
|
||||
.thenAnswer((_) async => 'true');
|
||||
|
||||
// Act
|
||||
final result = await accountService.hasAccount();
|
||||
|
||||
// Assert
|
||||
expect(result, true);
|
||||
});
|
||||
|
||||
test('should return false when wallet is not created', () async {
|
||||
// Arrange
|
||||
when(() => mockSecureStorage.read(key: StorageKeys.isWalletCreated))
|
||||
.thenAnswer((_) async => null);
|
||||
|
||||
// Act
|
||||
final result = await accountService.hasAccount();
|
||||
|
||||
// Assert
|
||||
expect(result, false);
|
||||
});
|
||||
});
|
||||
|
||||
group('getWalletAddresses', () {
|
||||
test('should return wallet addresses when all are available', () async {
|
||||
// Arrange
|
||||
when(() => mockSecureStorage.read(key: StorageKeys.walletAddressBsc))
|
||||
.thenAnswer((_) async => '0xBSC');
|
||||
when(() => mockSecureStorage.read(key: StorageKeys.walletAddressKava))
|
||||
.thenAnswer((_) async => '0xKAVA');
|
||||
when(() => mockSecureStorage.read(key: StorageKeys.walletAddressDst))
|
||||
.thenAnswer((_) async => 'dst1DST');
|
||||
|
||||
// Act
|
||||
final result = await accountService.getWalletAddresses();
|
||||
|
||||
// Assert
|
||||
expect(result, isNotNull);
|
||||
expect(result!.bsc, '0xBSC');
|
||||
expect(result.kava, '0xKAVA');
|
||||
expect(result.dst, 'dst1DST');
|
||||
});
|
||||
|
||||
test('should return null when any address is missing', () async {
|
||||
// Arrange
|
||||
when(() => mockSecureStorage.read(key: StorageKeys.walletAddressBsc))
|
||||
.thenAnswer((_) async => '0xBSC');
|
||||
when(() => mockSecureStorage.read(key: StorageKeys.walletAddressKava))
|
||||
.thenAnswer((_) async => null); // Missing
|
||||
when(() => mockSecureStorage.read(key: StorageKeys.walletAddressDst))
|
||||
.thenAnswer((_) async => 'dst1DST');
|
||||
|
||||
// Act
|
||||
final result = await accountService.getWalletAddresses();
|
||||
|
||||
// Assert
|
||||
expect(result, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('logout', () {
|
||||
test('should clear all stored data', () async {
|
||||
// Arrange
|
||||
when(() => mockSecureStorage.deleteAll()).thenAnswer((_) async {});
|
||||
|
||||
// Act
|
||||
await accountService.logout();
|
||||
|
||||
// Assert
|
||||
verify(() => mockSecureStorage.deleteAll()).called(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('CreateAccountResponse', () {
|
||||
test('should parse JSON correctly', () {
|
||||
// Arrange
|
||||
final json = {
|
||||
'userId': '123456789',
|
||||
'accountSequence': 42,
|
||||
'referralCode': 'TESTCD',
|
||||
'mnemonic': 'word1 word2 word3',
|
||||
'clientShareData': 'share-data',
|
||||
'publicKey': 'pub-key',
|
||||
'walletAddresses': {
|
||||
'kava': '0xKAVA',
|
||||
'dst': 'dst1DST',
|
||||
'bsc': '0xBSC',
|
||||
},
|
||||
'accessToken': 'access-token',
|
||||
'refreshToken': 'refresh-token',
|
||||
};
|
||||
|
||||
// Act
|
||||
final response = CreateAccountResponse.fromJson(json);
|
||||
|
||||
// Assert
|
||||
expect(response.userId, '123456789');
|
||||
expect(response.accountSequence, 42);
|
||||
expect(response.referralCode, 'TESTCD');
|
||||
expect(response.mnemonic, 'word1 word2 word3');
|
||||
expect(response.clientShareData, 'share-data');
|
||||
expect(response.publicKey, 'pub-key');
|
||||
expect(response.walletAddresses.kava, '0xKAVA');
|
||||
expect(response.walletAddresses.dst, 'dst1DST');
|
||||
expect(response.walletAddresses.bsc, '0xBSC');
|
||||
expect(response.accessToken, 'access-token');
|
||||
expect(response.refreshToken, 'refresh-token');
|
||||
});
|
||||
|
||||
test('should handle nullable fields', () {
|
||||
// Arrange
|
||||
final json = {
|
||||
'userId': '123456789',
|
||||
'accountSequence': 1,
|
||||
'referralCode': 'ABC123',
|
||||
'walletAddresses': {
|
||||
'kava': '0xKAVA',
|
||||
'dst': 'dst1DST',
|
||||
'bsc': '0xBSC',
|
||||
},
|
||||
'accessToken': 'token',
|
||||
'refreshToken': 'refresh',
|
||||
// mnemonic, clientShareData, publicKey are null
|
||||
};
|
||||
|
||||
// Act
|
||||
final response = CreateAccountResponse.fromJson(json);
|
||||
|
||||
// Assert
|
||||
expect(response.mnemonic, isNull);
|
||||
expect(response.clientShareData, isNull);
|
||||
expect(response.publicKey, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('CreateAccountRequest', () {
|
||||
test('should serialize to JSON correctly', () {
|
||||
// Arrange
|
||||
final request = CreateAccountRequest(
|
||||
deviceId: 'device-123',
|
||||
deviceName: 'iPhone 15',
|
||||
inviterReferralCode: 'INVITE',
|
||||
provinceCode: '110000',
|
||||
cityCode: '110100',
|
||||
);
|
||||
|
||||
// Act
|
||||
final json = request.toJson();
|
||||
|
||||
// Assert
|
||||
expect(json['deviceId'], 'device-123');
|
||||
expect(json['deviceName'], 'iPhone 15');
|
||||
expect(json['inviterReferralCode'], 'INVITE');
|
||||
expect(json['provinceCode'], '110000');
|
||||
expect(json['cityCode'], '110100');
|
||||
});
|
||||
|
||||
test('should exclude null fields from JSON', () {
|
||||
// Arrange
|
||||
final request = CreateAccountRequest(
|
||||
deviceId: 'device-123',
|
||||
// All optional fields are null
|
||||
);
|
||||
|
||||
// Act
|
||||
final json = request.toJson();
|
||||
|
||||
// Assert
|
||||
expect(json['deviceId'], 'device-123');
|
||||
expect(json.containsKey('deviceName'), false);
|
||||
expect(json.containsKey('inviterReferralCode'), false);
|
||||
expect(json.containsKey('provinceCode'), false);
|
||||
expect(json.containsKey('cityCode'), false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,343 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:rwa_android_app/core/services/mpc_share_service.dart';
|
||||
|
||||
void main() {
|
||||
group('MpcShareService', () {
|
||||
group('generateMnemonic', () {
|
||||
test('should generate valid 12-word BIP39 mnemonic', () {
|
||||
// Act
|
||||
final mnemonic = MpcShareService.generateMnemonic();
|
||||
|
||||
// Assert
|
||||
final words = mnemonic.split(' ');
|
||||
expect(words.length, 12, reason: 'Should generate 12 words');
|
||||
expect(MpcShareService.validateMnemonic(mnemonic), true,
|
||||
reason: 'Generated mnemonic should be valid BIP39');
|
||||
});
|
||||
|
||||
test('should generate different mnemonics each time', () {
|
||||
// Act
|
||||
final mnemonic1 = MpcShareService.generateMnemonic();
|
||||
final mnemonic2 = MpcShareService.generateMnemonic();
|
||||
|
||||
// Assert
|
||||
expect(mnemonic1, isNot(equals(mnemonic2)),
|
||||
reason: 'Each mnemonic should be unique (random)');
|
||||
});
|
||||
});
|
||||
|
||||
group('createShareBackup', () {
|
||||
test('should create backup with all required fields', () {
|
||||
// Arrange
|
||||
const shareData = 'mock-mpc-share-data-256bit';
|
||||
|
||||
// Act
|
||||
final backup = MpcShareService.createShareBackup(shareData);
|
||||
|
||||
// Assert
|
||||
expect(backup.mnemonic, isNotEmpty);
|
||||
expect(backup.encryptedShare, isNotEmpty);
|
||||
expect(backup.iv, isNotEmpty);
|
||||
expect(backup.authTag, isNotEmpty);
|
||||
expect(backup.mnemonicWords.length, 12);
|
||||
});
|
||||
|
||||
test('should create valid BIP39 mnemonic', () {
|
||||
// Arrange
|
||||
const shareData = 'test-share-for-mnemonic';
|
||||
|
||||
// Act
|
||||
final backup = MpcShareService.createShareBackup(shareData);
|
||||
|
||||
// Assert
|
||||
expect(MpcShareService.validateMnemonic(backup.mnemonic), true);
|
||||
});
|
||||
|
||||
test('should throw on empty share data', () {
|
||||
// Act & Assert
|
||||
expect(
|
||||
() => MpcShareService.createShareBackup(''),
|
||||
throwsA(isA<ArgumentError>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('encryptShare and decryptShare', () {
|
||||
test('should encrypt and decrypt share correctly', () {
|
||||
// Arrange
|
||||
const originalShare = 'secret-mpc-share-data-to-encrypt';
|
||||
final mnemonic = MpcShareService.generateMnemonic();
|
||||
|
||||
// Act
|
||||
final encrypted = MpcShareService.encryptShare(originalShare, mnemonic);
|
||||
final decrypted = MpcShareService.decryptShare(
|
||||
ciphertext: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
authTag: encrypted.authTag,
|
||||
mnemonic: mnemonic,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(decrypted, originalShare);
|
||||
});
|
||||
|
||||
test('should produce different ciphertext with different IVs', () {
|
||||
// Arrange
|
||||
const shareData = 'same-share-data';
|
||||
final mnemonic = MpcShareService.generateMnemonic();
|
||||
|
||||
// Act
|
||||
final encrypted1 = MpcShareService.encryptShare(shareData, mnemonic);
|
||||
final encrypted2 = MpcShareService.encryptShare(shareData, mnemonic);
|
||||
|
||||
// Assert - different IVs should produce different ciphertext
|
||||
expect(encrypted1.iv, isNot(equals(encrypted2.iv)));
|
||||
expect(encrypted1.ciphertext, isNot(equals(encrypted2.ciphertext)));
|
||||
});
|
||||
|
||||
test('should fail decryption with wrong mnemonic', () {
|
||||
// Arrange
|
||||
const shareData = 'secret-share';
|
||||
final correctMnemonic = MpcShareService.generateMnemonic();
|
||||
final wrongMnemonic = MpcShareService.generateMnemonic();
|
||||
|
||||
final encrypted = MpcShareService.encryptShare(shareData, correctMnemonic);
|
||||
|
||||
// Act & Assert
|
||||
expect(
|
||||
() => MpcShareService.decryptShare(
|
||||
ciphertext: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
authTag: encrypted.authTag,
|
||||
mnemonic: wrongMnemonic,
|
||||
),
|
||||
throwsA(isA<StateError>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('should fail decryption with tampered ciphertext', () {
|
||||
// Arrange
|
||||
const shareData = 'secret-share';
|
||||
final mnemonic = MpcShareService.generateMnemonic();
|
||||
final encrypted = MpcShareService.encryptShare(shareData, mnemonic);
|
||||
|
||||
// Tamper with ciphertext
|
||||
final tamperedBytes = base64Decode(encrypted.ciphertext);
|
||||
tamperedBytes[0] = (tamperedBytes[0] + 1) % 256;
|
||||
final tamperedCiphertext = base64Encode(tamperedBytes);
|
||||
|
||||
// Act & Assert
|
||||
expect(
|
||||
() => MpcShareService.decryptShare(
|
||||
ciphertext: tamperedCiphertext,
|
||||
iv: encrypted.iv,
|
||||
authTag: encrypted.authTag,
|
||||
mnemonic: mnemonic,
|
||||
),
|
||||
throwsA(isA<StateError>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('recoverShare', () {
|
||||
test('should recover original share from backup', () {
|
||||
// Arrange
|
||||
const originalShare = 'original-mpc-share-256bit-data';
|
||||
final backup = MpcShareService.createShareBackup(originalShare);
|
||||
|
||||
// Act
|
||||
final recovered = MpcShareService.recoverShare(
|
||||
mnemonic: backup.mnemonic,
|
||||
encryptedShare: backup.encryptedShare,
|
||||
iv: backup.iv,
|
||||
authTag: backup.authTag,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(recovered, originalShare);
|
||||
});
|
||||
|
||||
test('should recover using MpcShareBackup.recoverShare()', () {
|
||||
// Arrange
|
||||
const originalShare = 'test-share-for-backup-recovery';
|
||||
final backup = MpcShareService.createShareBackup(originalShare);
|
||||
|
||||
// Act
|
||||
final recovered = backup.recoverShare();
|
||||
|
||||
// Assert
|
||||
expect(recovered, originalShare);
|
||||
});
|
||||
|
||||
test('should throw on invalid mnemonic format', () {
|
||||
// Arrange
|
||||
final backup = MpcShareService.createShareBackup('test-share');
|
||||
|
||||
// Act & Assert
|
||||
expect(
|
||||
() => MpcShareService.recoverShare(
|
||||
mnemonic: 'invalid mnemonic words',
|
||||
encryptedShare: backup.encryptedShare,
|
||||
iv: backup.iv,
|
||||
authTag: backup.authTag,
|
||||
),
|
||||
throwsA(isA<ArgumentError>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle complex base64 share data', () {
|
||||
// Arrange - simulate real 256-bit MPC share
|
||||
final complexBytes = List.generate(32, (i) => (i * 17 + 5) % 256);
|
||||
final complexShare = base64Encode(complexBytes);
|
||||
final backup = MpcShareService.createShareBackup(complexShare);
|
||||
|
||||
// Act
|
||||
final recovered = backup.recoverShare();
|
||||
|
||||
// Assert
|
||||
expect(recovered, complexShare);
|
||||
expect(base64Decode(recovered).length, 32);
|
||||
});
|
||||
});
|
||||
|
||||
group('validateMnemonic', () {
|
||||
test('should return true for valid 12-word mnemonic', () {
|
||||
final mnemonic = MpcShareService.generateMnemonic();
|
||||
expect(MpcShareService.validateMnemonic(mnemonic), true);
|
||||
});
|
||||
|
||||
test('should return false for invalid mnemonic', () {
|
||||
expect(MpcShareService.validateMnemonic('invalid words here'), false);
|
||||
expect(MpcShareService.validateMnemonic(''), false);
|
||||
expect(
|
||||
MpcShareService.validateMnemonic(
|
||||
'one two three four five six seven eight nine ten eleven twelve',
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('MpcShareBackup', () {
|
||||
test('should serialize to JSON correctly', () {
|
||||
// Arrange
|
||||
final backup = MpcShareBackup(
|
||||
mnemonic: 'word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 word11 word12',
|
||||
encryptedShare: 'encrypted-data-base64',
|
||||
iv: 'iv-base64',
|
||||
authTag: 'auth-tag-base64',
|
||||
);
|
||||
|
||||
// Act
|
||||
final json = backup.toJson();
|
||||
|
||||
// Assert
|
||||
expect(json['mnemonic'], backup.mnemonic);
|
||||
expect(json['encryptedShare'], 'encrypted-data-base64');
|
||||
expect(json['iv'], 'iv-base64');
|
||||
expect(json['authTag'], 'auth-tag-base64');
|
||||
});
|
||||
|
||||
test('should deserialize from JSON correctly', () {
|
||||
// Arrange
|
||||
final json = {
|
||||
'mnemonic': 'word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 word11 word12',
|
||||
'encryptedShare': 'encrypted-data-base64',
|
||||
'iv': 'iv-base64',
|
||||
'authTag': 'auth-tag-base64',
|
||||
};
|
||||
|
||||
// Act
|
||||
final backup = MpcShareBackup.fromJson(json);
|
||||
|
||||
// Assert
|
||||
expect(backup.mnemonic, json['mnemonic']);
|
||||
expect(backup.encryptedShare, 'encrypted-data-base64');
|
||||
expect(backup.iv, 'iv-base64');
|
||||
expect(backup.authTag, 'auth-tag-base64');
|
||||
expect(backup.mnemonicWords.length, 12);
|
||||
});
|
||||
});
|
||||
|
||||
group('bytesToHex and hexToBytes', () {
|
||||
test('should convert bytes to hex string', () {
|
||||
final bytes = Uint8List.fromList([0, 15, 16, 255]);
|
||||
final hex = MpcShareService.bytesToHex(bytes);
|
||||
expect(hex, '000f10ff');
|
||||
});
|
||||
|
||||
test('should convert hex string to bytes', () {
|
||||
const hex = '000f10ff';
|
||||
final bytes = MpcShareService.hexToBytes(hex);
|
||||
expect(bytes, Uint8List.fromList([0, 15, 16, 255]));
|
||||
});
|
||||
|
||||
test('should round-trip bytes through hex', () {
|
||||
final original = Uint8List.fromList(List.generate(32, (i) => i * 8 % 256));
|
||||
final hex = MpcShareService.bytesToHex(original);
|
||||
final recovered = MpcShareService.hexToBytes(hex);
|
||||
expect(recovered, original);
|
||||
});
|
||||
});
|
||||
|
||||
group('Security properties', () {
|
||||
test('same share with same mnemonic should produce same decrypted result', () {
|
||||
// Arrange
|
||||
const shareData = 'consistent-share-data';
|
||||
final mnemonic = MpcShareService.generateMnemonic();
|
||||
|
||||
// Encrypt twice with same mnemonic (different IVs)
|
||||
final encrypted1 = MpcShareService.encryptShare(shareData, mnemonic);
|
||||
final encrypted2 = MpcShareService.encryptShare(shareData, mnemonic);
|
||||
|
||||
// Act - decrypt both
|
||||
final decrypted1 = MpcShareService.decryptShare(
|
||||
ciphertext: encrypted1.ciphertext,
|
||||
iv: encrypted1.iv,
|
||||
authTag: encrypted1.authTag,
|
||||
mnemonic: mnemonic,
|
||||
);
|
||||
final decrypted2 = MpcShareService.decryptShare(
|
||||
ciphertext: encrypted2.ciphertext,
|
||||
iv: encrypted2.iv,
|
||||
authTag: encrypted2.authTag,
|
||||
mnemonic: mnemonic,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(decrypted1, shareData);
|
||||
expect(decrypted2, shareData);
|
||||
expect(decrypted1, decrypted2);
|
||||
});
|
||||
|
||||
test('full round-trip: share -> backup -> recover', () {
|
||||
// Arrange - simulate full MPC workflow
|
||||
final originalShare = base64Encode(
|
||||
List.generate(64, (i) => (i * 13 + 7) % 256),
|
||||
); // 512-bit share data
|
||||
|
||||
// Act
|
||||
final backup = MpcShareService.createShareBackup(originalShare);
|
||||
|
||||
// User stores mnemonic, device stores encrypted data
|
||||
final storedMnemonic = backup.mnemonic;
|
||||
final storedEncrypted = backup.encryptedShare;
|
||||
final storedIv = backup.iv;
|
||||
final storedAuthTag = backup.authTag;
|
||||
|
||||
// Later: user enters mnemonic to recover
|
||||
final recoveredShare = MpcShareService.recoverShare(
|
||||
mnemonic: storedMnemonic,
|
||||
encryptedShare: storedEncrypted,
|
||||
iv: storedIv,
|
||||
authTag: storedAuthTag,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(recoveredShare, originalShare);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||