diff --git a/backend/services/identity-service/prisma/migrations/20241224000001_add_kyc_fields/migration.sql b/backend/services/identity-service/prisma/migrations/20241224000001_add_kyc_fields/migration.sql new file mode 100644 index 00000000..89ffe3e0 --- /dev/null +++ b/backend/services/identity-service/prisma/migrations/20241224000001_add_kyc_fields/migration.sql @@ -0,0 +1,38 @@ +-- AlterTable: 添加手机验证状态和 KYC 增强字段 +ALTER TABLE "user_accounts" ADD COLUMN "phone_verified" BOOLEAN NOT NULL DEFAULT true; +ALTER TABLE "user_accounts" ADD COLUMN "phone_verified_at" TIMESTAMP(3); +ALTER TABLE "user_accounts" ADD COLUMN "kyc_provider" VARCHAR(50); +ALTER TABLE "user_accounts" ADD COLUMN "kyc_request_id" VARCHAR(100); +ALTER TABLE "user_accounts" ADD COLUMN "kyc_rejected_reason" VARCHAR(500); + +-- 修改 id_card_number 列长度以支持加密存储 +ALTER TABLE "user_accounts" ALTER COLUMN "id_card_number" TYPE VARCHAR(50); + +-- 修改 kyc_status 默认值 +ALTER TABLE "user_accounts" ALTER COLUMN "kyc_status" SET DEFAULT 'NOT_STARTED'; + +-- 更新现有数据的 kyc_status +UPDATE "user_accounts" SET "kyc_status" = 'NOT_STARTED' WHERE "kyc_status" = 'NOT_VERIFIED'; + +-- CreateTable: KYC 验证尝试记录表 +CREATE TABLE "kyc_verification_attempts" ( + "id" BIGSERIAL NOT NULL, + "user_id" BIGINT NOT NULL, + "verification_type" VARCHAR(20) NOT NULL, + "provider" VARCHAR(50), + "request_id" VARCHAR(100), + "input_data" JSONB, + "status" VARCHAR(20) NOT NULL DEFAULT 'PENDING', + "failure_reason" VARCHAR(500), + "response_data" JSONB, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "completed_at" TIMESTAMP(3), + + CONSTRAINT "kyc_verification_attempts_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "idx_kyc_attempt_user" ON "kyc_verification_attempts"("user_id"); +CREATE INDEX "idx_kyc_attempt_status" ON "kyc_verification_attempts"("status"); +CREATE INDEX "idx_kyc_request_id" ON "kyc_verification_attempts"("request_id"); +CREATE INDEX "idx_kyc_attempt_created" ON "kyc_verification_attempts"("created_at"); diff --git a/backend/services/identity-service/prisma/migrations/20241224000002_add_email_verified/migration.sql b/backend/services/identity-service/prisma/migrations/20241224000002_add_email_verified/migration.sql new file mode 100644 index 00000000..fb218059 --- /dev/null +++ b/backend/services/identity-service/prisma/migrations/20241224000002_add_email_verified/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable: 添加邮箱验证状态字段 +ALTER TABLE "user_accounts" ADD COLUMN IF NOT EXISTS "email_verified" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "user_accounts" ADD COLUMN IF NOT EXISTS "email_verified_at" TIMESTAMP(3); + +-- 将已绑定邮箱的用户标记为已验证(历史数据处理) +UPDATE "user_accounts" SET "email_verified" = true, "email_verified_at" = NOW() WHERE "email" IS NOT NULL; diff --git a/backend/services/identity-service/prisma/schema.prisma b/backend/services/identity-service/prisma/schema.prisma index dcebf8de..cce000bb 100644 --- a/backend/services/identity-service/prisma/schema.prisma +++ b/backend/services/identity-service/prisma/schema.prisma @@ -12,7 +12,11 @@ model UserAccount { accountSequence String @unique @map("account_sequence") @db.VarChar(12) // 格式: D + YYMMDD + 5位序号 phoneNumber String? @unique @map("phone_number") @db.VarChar(20) + phoneVerified Boolean @default(true) @map("phone_verified") // 手机号是否验证(注册时跳过验证码则为false) + phoneVerifiedAt DateTime? @map("phone_verified_at") // 手机号验证时间 email String? @unique @db.VarChar(100) // 绑定的邮箱地址 + emailVerified Boolean @default(false) @map("email_verified") // 邮箱是否验证 + emailVerifiedAt DateTime? @map("email_verified_at") // 邮箱验证时间 passwordHash String? @map("password_hash") @db.VarChar(100) // bcrypt 哈希密码 nickname String @db.VarChar(100) avatarUrl String? @map("avatar_url") @db.Text @@ -20,12 +24,18 @@ model UserAccount { inviterSequence String? @map("inviter_sequence") @db.VarChar(12) // 推荐人序列号 referralCode String @unique @map("referral_code") @db.VarChar(10) - kycStatus String @default("NOT_VERIFIED") @map("kyc_status") @db.VarChar(20) - realName String? @map("real_name") @db.VarChar(100) - idCardNumber String? @map("id_card_number") @db.VarChar(20) + // KYC 实名认证状态 + // NOT_STARTED: 未开始, PHONE_VERIFIED: 手机已验证, ID_PENDING: 身份证审核中, + // ID_VERIFIED: 身份证已验证, COMPLETED: 完成, REJECTED: 被拒绝 + kycStatus String @default("NOT_STARTED") @map("kyc_status") @db.VarChar(20) + realName String? @map("real_name") @db.VarChar(100) // 真实姓名(加密存储) + idCardNumber String? @map("id_card_number") @db.VarChar(50) // 身份证号(加密存储) idCardFrontUrl String? @map("id_card_front_url") @db.VarChar(500) idCardBackUrl String? @map("id_card_back_url") @db.VarChar(500) kycVerifiedAt DateTime? @map("kyc_verified_at") + kycProvider String? @map("kyc_provider") @db.VarChar(50) // KYC 服务提供商: ALIYUN, TENCENT + kycRequestId String? @map("kyc_request_id") @db.VarChar(100) // 第三方请求ID + kycRejectedReason String? @map("kyc_rejected_reason") @db.VarChar(500) // 拒绝原因 status String @default("ACTIVE") @db.VarChar(20) @@ -380,3 +390,36 @@ model OutboxEvent { @@index([topic]) @@map("outbox_events") } + +// ============================================ +// KYC 验证尝试记录表 +// 记录所有 KYC 验证尝试,用于审计和追踪 +// ============================================ +model KycVerificationAttempt { + id BigInt @id @default(autoincrement()) + userId BigInt @map("user_id") + + // 验证类型: PHONE (手机验证), ID_CARD (身份证验证) + verificationType String @map("verification_type") @db.VarChar(20) + + // 第三方服务信息 + provider String? @map("provider") @db.VarChar(50) // ALIYUN, TENCENT, SMS + requestId String? @map("request_id") @db.VarChar(100) + + // 输入数据 (加密存储敏感信息) + inputData Json? @map("input_data") // { realName: "张**", idCardNumber: "1234********5678" } + + // 验证结果: PENDING, SUCCESS, FAILED + status String @default("PENDING") @db.VarChar(20) + failureReason String? @map("failure_reason") @db.VarChar(500) + responseData Json? @map("response_data") // 第三方返回的原始数据 + + createdAt DateTime @default(now()) @map("created_at") + completedAt DateTime? @map("completed_at") + + @@index([userId], name: "idx_kyc_attempt_user") + @@index([status], name: "idx_kyc_attempt_status") + @@index([requestId], name: "idx_kyc_request_id") + @@index([createdAt], name: "idx_kyc_attempt_created") + @@map("kyc_verification_attempts") +} diff --git a/backend/services/identity-service/src/api/controllers/kyc.controller.ts b/backend/services/identity-service/src/api/controllers/kyc.controller.ts new file mode 100644 index 00000000..64773538 --- /dev/null +++ b/backend/services/identity-service/src/api/controllers/kyc.controller.ts @@ -0,0 +1,89 @@ +import { + Controller, + Post, + Get, + Body, + UseGuards, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiResponse, +} from '@nestjs/swagger'; +import { KycApplicationService } from '@/application/services/kyc-application.service'; +import { + JwtAuthGuard, + CurrentUser, + CurrentUserData, +} from '@/shared/guards/jwt-auth.guard'; +import { + GetKycStatusDto, + VerifyPhoneForKycDto, + SubmitIdVerificationDto, + KycStatusResponseDto, + IdVerificationResponseDto, +} from '@/api/dto/kyc'; + +@ApiTags('KYC') +@Controller('user/kyc') +@UseGuards(JwtAuthGuard) +export class KycController { + constructor(private readonly kycService: KycApplicationService) {} + + @Get('status') + @ApiBearerAuth() + @ApiOperation({ + summary: '获取 KYC 状态', + description: '查询当前用户的 KYC 认证状态和信息', + }) + @ApiResponse({ status: 200, type: KycStatusResponseDto }) + async getKycStatus(@CurrentUser() user: CurrentUserData) { + return this.kycService.getKycStatus(user.userId); + } + + @Post('verify-phone') + @ApiBearerAuth() + @ApiOperation({ + summary: '完成手机号验证', + description: '用于注册时跳过验证的用户完成手机号验证', + }) + @ApiResponse({ status: 200, description: '验证成功' }) + async verifyPhoneForKyc( + @CurrentUser() user: CurrentUserData, + @Body() dto: VerifyPhoneForKycDto, + ) { + await this.kycService.verifyPhoneForKyc(user.userId, dto.smsCode); + return { success: true, message: '手机号验证成功' }; + } + + @Post('submit-id') + @ApiBearerAuth() + @ApiOperation({ + summary: '提交身份证验证', + description: '提交真实姓名和身份证号进行实名认证(二要素验证)', + }) + @ApiResponse({ status: 200, type: IdVerificationResponseDto }) + async submitIdVerification( + @CurrentUser() user: CurrentUserData, + @Body() dto: SubmitIdVerificationDto, + ) { + return this.kycService.submitIdVerification( + user.userId, + dto.realName, + dto.idCardNumber, + ); + } + + @Post('send-verify-sms') + @ApiBearerAuth() + @ApiOperation({ + summary: '发送 KYC 手机验证码', + description: '向用户绑定的手机号发送验证码,用于 KYC 手机验证', + }) + @ApiResponse({ status: 200, description: '验证码已发送' }) + async sendKycVerifySms(@CurrentUser() user: CurrentUserData) { + await this.kycService.sendKycVerifySms(user.userId); + return { success: true, message: '验证码已发送' }; + } +} diff --git a/backend/services/identity-service/src/api/controllers/user-account.controller.ts b/backend/services/identity-service/src/api/controllers/user-account.controller.ts index e268cffa..477d5b3c 100644 --- a/backend/services/identity-service/src/api/controllers/user-account.controller.ts +++ b/backend/services/identity-service/src/api/controllers/user-account.controller.ts @@ -33,6 +33,7 @@ import { import { AutoCreateAccountCommand, RegisterByPhoneCommand, + RegisterWithoutSmsVerifyCommand, RecoverByMnemonicCommand, RecoverByPhoneCommand, AutoLoginCommand, @@ -58,6 +59,7 @@ import { import { AutoCreateAccountDto, RegisterByPhoneDto, + RegisterWithoutSmsVerifyDto, RecoverByMnemonicDto, RecoverByPhoneDto, AutoLoginDto, @@ -90,6 +92,9 @@ import { SendEmailCodeDto, BindEmailDto, UnbindEmailDto, + VerifyOldPhoneDto, + SendNewPhoneCodeDto, + ConfirmChangePhoneDto, } from '@/api/dto'; @ApiTags('User') @@ -135,6 +140,26 @@ export class UserAccountController { ); } + @Public() + @Post('register-without-sms-verify') + @ApiOperation({ + summary: '跳过短信验证注册', + description: + '用户收不到验证码时可以跳过验证继续注册,注册后 phoneVerified=false,需在 KYC 流程中完成验证', + }) + @ApiResponse({ status: 200, type: AutoCreateAccountResponseDto }) + async registerWithoutSmsVerify(@Body() dto: RegisterWithoutSmsVerifyDto) { + return this.userService.registerWithoutSmsVerify( + new RegisterWithoutSmsVerifyCommand( + dto.phoneNumber, + dto.password, + dto.deviceId, + dto.deviceName, + dto.inviterReferralCode, + ), + ); + } + @Public() @Post('recover-by-mnemonic') @ApiOperation({ summary: '用序列号+助记词恢复账户' }) @@ -397,6 +422,81 @@ export class UserAccountController { return { message: '邮箱解绑成功' }; } + // ============ 更换手机号 ============ + + @Get('phone-status') + @ApiBearerAuth() + @ApiOperation({ summary: '获取手机号状态' }) + @ApiResponse({ status: 200, description: '返回手机号绑定和验证状态' }) + async getPhoneStatus(@CurrentUser() user: CurrentUserData) { + return this.userService.getPhoneStatus(user.userId); + } + + @Post('change-phone/send-old-code') + @ApiBearerAuth() + @ApiOperation({ + summary: '发送旧手机验证码', + description: '更换手机号第一步:向当前绑定的手机号发送验证码', + }) + @ApiResponse({ status: 200, description: '验证码已发送' }) + async sendOldPhoneCode(@CurrentUser() user: CurrentUserData) { + await this.userService.sendOldPhoneCode(user.userId); + return { message: '验证码已发送' }; + } + + @Post('change-phone/verify-old') + @ApiBearerAuth() + @ApiOperation({ + summary: '验证旧手机验证码', + description: '更换手机号第二步:验证旧手机验证码,成功后返回临时令牌', + }) + @ApiResponse({ status: 200, description: '验证成功,返回临时令牌' }) + async verifyOldPhoneCode( + @CurrentUser() user: CurrentUserData, + @Body() dto: VerifyOldPhoneDto, + ) { + return this.userService.verifyOldPhoneCode(user.userId, dto.smsCode); + } + + @Post('change-phone/send-new-code') + @ApiBearerAuth() + @ApiOperation({ + summary: '发送新手机验证码', + description: '更换手机号第三步:向新手机号发送验证码', + }) + @ApiResponse({ status: 200, description: '验证码已发送' }) + async sendNewPhoneCode( + @CurrentUser() user: CurrentUserData, + @Body() dto: SendNewPhoneCodeDto, + ) { + await this.userService.sendNewPhoneCode( + user.userId, + dto.newPhoneNumber, + dto.changePhoneToken, + ); + return { message: '验证码已发送' }; + } + + @Post('change-phone/confirm') + @ApiBearerAuth() + @ApiOperation({ + summary: '确认更换手机号', + description: '更换手机号第四步:验证新手机验证码并完成更换', + }) + @ApiResponse({ status: 200, description: '手机号更换成功' }) + async confirmChangePhone( + @CurrentUser() user: CurrentUserData, + @Body() dto: ConfirmChangePhoneDto, + ) { + await this.userService.confirmChangePhone( + user.userId, + dto.newPhoneNumber, + dto.smsCode, + dto.changePhoneToken, + ); + return { message: '手机号更换成功' }; + } + @Get('my-profile') @ApiBearerAuth() @ApiOperation({ summary: '查询我的资料' }) diff --git a/backend/services/identity-service/src/api/dto/kyc/index.ts b/backend/services/identity-service/src/api/dto/kyc/index.ts new file mode 100644 index 00000000..60dd685e --- /dev/null +++ b/backend/services/identity-service/src/api/dto/kyc/index.ts @@ -0,0 +1,89 @@ +import { + IsString, + IsNotEmpty, + Matches, + Length, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +// ============ Request DTOs ============ + +export class GetKycStatusDto { + // 无需参数,从 JWT 获取用户信息 +} + +export class VerifyPhoneForKycDto { + @ApiProperty({ + example: '123456', + description: '6位短信验证码', + }) + @IsString() + @IsNotEmpty({ message: '验证码不能为空' }) + @Matches(/^\d{6}$/, { message: '验证码格式错误' }) + smsCode: string; +} + +export class SubmitIdVerificationDto { + @ApiProperty({ + example: '张三', + description: '真实姓名', + }) + @IsString() + @IsNotEmpty({ message: '姓名不能为空' }) + @Length(2, 50, { message: '姓名长度应为2-50个字符' }) + realName: string; + + @ApiProperty({ + example: '110101199001011234', + description: '18位身份证号码', + }) + @IsString() + @IsNotEmpty({ message: '身份证号不能为空' }) + @Matches(/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/, { + message: '身份证号格式错误', + }) + idCardNumber: string; +} + +// ============ Response DTOs ============ + +export class KycStatusResponseDto { + @ApiProperty({ description: '手机号是否已验证' }) + phoneVerified: boolean; + + @ApiProperty({ + description: 'KYC 状态: NOT_STARTED, PHONE_VERIFIED, ID_PENDING, ID_VERIFIED, COMPLETED, REJECTED', + }) + kycStatus: string; + + @ApiPropertyOptional({ description: '脱敏后的真实姓名 (如: 张*)' }) + realName?: string; + + @ApiPropertyOptional({ description: '脱敏后的身份证号 (如: 1101***********234)' }) + idCardNumber?: string; + + @ApiPropertyOptional({ description: 'KYC 完成时间' }) + kycVerifiedAt?: Date; + + @ApiPropertyOptional({ description: '拒绝原因 (状态为 REJECTED 时返回)' }) + rejectedReason?: string; + + @ApiPropertyOptional({ description: '脱敏后的手机号' }) + phoneNumber?: string; +} + +export class IdVerificationResponseDto { + @ApiProperty({ description: '验证请求 ID' }) + requestId: string; + + @ApiProperty({ + description: '验证状态: SUCCESS, FAILED, PENDING', + }) + status: string; + + @ApiPropertyOptional({ description: '失败原因' }) + failureReason?: string; + + @ApiPropertyOptional({ description: '更新后的 KYC 状态' }) + kycStatus?: string; +} diff --git a/backend/services/identity-service/src/api/dto/request/change-phone.dto.ts b/backend/services/identity-service/src/api/dto/request/change-phone.dto.ts new file mode 100644 index 00000000..afa217d4 --- /dev/null +++ b/backend/services/identity-service/src/api/dto/request/change-phone.dto.ts @@ -0,0 +1,52 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, Matches } from 'class-validator'; + +/** + * 发送旧手机验证码请求 + */ +export class SendOldPhoneCodeDto { + // 不需要参数,使用当前绑定的手机号 +} + +/** + * 验证旧手机验证码请求 + */ +export class VerifyOldPhoneDto { + @ApiProperty({ example: '123456', description: '旧手机验证码' }) + @IsString() + @Matches(/^\d{6}$/, { message: '验证码格式错误' }) + smsCode: string; +} + +/** + * 发送新手机验证码请求 + */ +export class SendNewPhoneCodeDto { + @ApiProperty({ example: '13800138001', description: '新手机号' }) + @IsString() + @Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' }) + newPhoneNumber: string; + + @ApiProperty({ description: '验证旧手机后获得的临时令牌' }) + @IsString() + changePhoneToken: string; +} + +/** + * 确认更换手机号请求 + */ +export class ConfirmChangePhoneDto { + @ApiProperty({ example: '13800138001', description: '新手机号' }) + @IsString() + @Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' }) + newPhoneNumber: string; + + @ApiProperty({ example: '123456', description: '新手机验证码' }) + @IsString() + @Matches(/^\d{6}$/, { message: '验证码格式错误' }) + smsCode: string; + + @ApiProperty({ description: '验证旧手机后获得的临时令牌' }) + @IsString() + changePhoneToken: string; +} diff --git a/backend/services/identity-service/src/api/dto/request/index.ts b/backend/services/identity-service/src/api/dto/request/index.ts index f7fad34a..f71740dd 100644 --- a/backend/services/identity-service/src/api/dto/request/index.ts +++ b/backend/services/identity-service/src/api/dto/request/index.ts @@ -17,3 +17,5 @@ export * from './login-with-password.dto'; export * from './send-email-code.dto'; export * from './bind-email.dto'; export * from './unbind-email.dto'; +export * from './register-without-sms-verify.dto'; +export * from './change-phone.dto'; diff --git a/backend/services/identity-service/src/api/dto/request/register-without-sms-verify.dto.ts b/backend/services/identity-service/src/api/dto/request/register-without-sms-verify.dto.ts new file mode 100644 index 00000000..a370e68a --- /dev/null +++ b/backend/services/identity-service/src/api/dto/request/register-without-sms-verify.dto.ts @@ -0,0 +1,60 @@ +import { + IsString, + IsOptional, + IsNotEmpty, + Matches, + IsObject, + MinLength, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { DeviceNameDto } from './auto-create-account.dto'; + +/** + * 跳过短信验证码注册 DTO + * 用户在收不到验证码时可以选择跳过验证继续注册 + * 注册后 phoneVerified = false,需要后续在 KYC 流程中完成验证 + */ +export class RegisterWithoutSmsVerifyDto { + @ApiProperty({ + example: '13800138000', + description: '手机号', + }) + @IsString() + @IsNotEmpty() + @Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' }) + phoneNumber: string; + + @ApiProperty({ + example: 'Password123', + description: '登录密码 (6-20位)', + }) + @IsString() + @IsNotEmpty() + @MinLength(6, { message: '密码至少6位' }) + password: string; + + @ApiProperty({ + example: '550e8400-e29b-41d4-a716-446655440000', + description: '设备唯一标识', + }) + @IsString() + @IsNotEmpty() + deviceId: string; + + @ApiPropertyOptional({ + description: '设备信息 (JSON 对象)', + example: { model: 'iPhone 15 Pro', platform: 'ios', osVersion: '17.2' }, + }) + @IsOptional() + @IsObject() + deviceName?: DeviceNameDto; + + @ApiProperty({ + example: 'SEED01', + description: '邀请人推荐码 (6-20位大写字母和数字) - 必填', + }) + @IsString() + @IsNotEmpty({ message: '推荐码不能为空' }) + @Matches(/^[A-Z0-9]{6,20}$/, { message: '推荐码格式错误' }) + inviterReferralCode: string; +} diff --git a/backend/services/identity-service/src/app.module.ts b/backend/services/identity-service/src/app.module.ts index 85026aa4..420c297b 100644 --- a/backend/services/identity-service/src/app.module.ts +++ b/backend/services/identity-service/src/app.module.ts @@ -23,11 +23,13 @@ import { ReferralsController } from '@/api/controllers/referrals.controller'; import { AuthController } from '@/api/controllers/auth.controller'; import { TotpController } from '@/api/controllers/totp.controller'; import { InternalController } from '@/api/controllers/internal.controller'; +import { KycController } from '@/api/controllers/kyc.controller'; // Application Services import { UserApplicationService } from '@/application/services/user-application.service'; import { TokenService } from '@/application/services/token.service'; import { TotpService } from '@/application/services/totp.service'; +import { KycApplicationService } from '@/application/services/kyc-application.service'; import { BlockchainWalletHandler } from '@/application/event-handlers/blockchain-wallet.handler'; import { MpcKeygenCompletedHandler } from '@/application/event-handlers/mpc-keygen-completed.handler'; import { WalletRetryTask } from '@/application/tasks/wallet-retry.task'; @@ -56,6 +58,7 @@ import { MpcWalletService, } from '@/infrastructure/external/mpc'; import { StorageService } from '@/infrastructure/external/storage/storage.service'; +import { AliyunKycProvider } from '@/infrastructure/external/kyc/aliyun-kyc.provider'; // Shared import { @@ -86,6 +89,7 @@ import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard'; MpcWalletService, BlockchainClientService, StorageService, + AliyunKycProvider, { provide: MPC_KEY_SHARE_REPOSITORY, useClass: MpcKeyShareRepositoryImpl }, ], exports: [ @@ -100,6 +104,7 @@ import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard'; MpcWalletService, BlockchainClientService, StorageService, + AliyunKycProvider, MPC_KEY_SHARE_REPOSITORY, ], }) @@ -128,13 +133,14 @@ export class DomainModule {} UserApplicationService, TokenService, TotpService, + KycApplicationService, // Event Handlers - 通过注入到 UserApplicationService 来确保它们被初始化 BlockchainWalletHandler, MpcKeygenCompletedHandler, // Tasks - 定时任务 WalletRetryTask, ], - exports: [UserApplicationService, TokenService, TotpService], + exports: [UserApplicationService, TokenService, TotpService, KycApplicationService], }) export class ApplicationModule {} @@ -148,6 +154,7 @@ export class ApplicationModule {} AuthController, TotpController, InternalController, + KycController, ], providers: [UserAccountRepositoryImpl], }) diff --git a/backend/services/identity-service/src/application/commands/index.ts b/backend/services/identity-service/src/application/commands/index.ts index f0d4a037..74e3f9f5 100644 --- a/backend/services/identity-service/src/application/commands/index.ts +++ b/backend/services/identity-service/src/application/commands/index.ts @@ -27,6 +27,16 @@ export class RegisterByPhoneCommand { ) {} } +export class RegisterWithoutSmsVerifyCommand { + constructor( + public readonly phoneNumber: string, + public readonly password: string, + public readonly deviceId: string, + public readonly deviceName?: DeviceNameInput, + public readonly inviterReferralCode?: string, + ) {} +} + export class RecoverByMnemonicCommand { constructor( public readonly accountSequence: string, // 格式: D + YYMMDD + 5位序号 diff --git a/backend/services/identity-service/src/application/services/kyc-application.service.ts b/backend/services/identity-service/src/application/services/kyc-application.service.ts new file mode 100644 index 00000000..b18766f1 --- /dev/null +++ b/backend/services/identity-service/src/application/services/kyc-application.service.ts @@ -0,0 +1,318 @@ +import { Injectable, Logger, Inject } from '@nestjs/common'; +import { + UserAccountRepository, + USER_ACCOUNT_REPOSITORY, +} from '@/domain/repositories/user-account.repository.interface'; +import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service'; +import { RedisService } from '@/infrastructure/redis/redis.service'; +import { SmsService } from '@/infrastructure/external/sms/sms.service'; +import { AliyunKycProvider } from '@/infrastructure/external/kyc/aliyun-kyc.provider'; +import { ApplicationError } from '@/shared/exceptions/domain.exception'; +import { UserId } from '@/domain/value-objects'; + +// KYC 状态枚举 +export enum KycStatus { + NOT_STARTED = 'NOT_STARTED', + PHONE_VERIFIED = 'PHONE_VERIFIED', + ID_PENDING = 'ID_PENDING', + ID_VERIFIED = 'ID_VERIFIED', + COMPLETED = 'COMPLETED', + REJECTED = 'REJECTED', +} + +@Injectable() +export class KycApplicationService { + private readonly logger = new Logger(KycApplicationService.name); + + constructor( + @Inject(USER_ACCOUNT_REPOSITORY) + private readonly userRepository: UserAccountRepository, + private readonly prisma: PrismaService, + private readonly redisService: RedisService, + private readonly smsService: SmsService, + private readonly aliyunKycProvider: AliyunKycProvider, + ) {} + + /** + * 获取用户的 KYC 状态 + */ + async getKycStatus(userId: string) { + this.logger.log(`[KYC] Getting KYC status for user: ${userId}`); + + const user = await this.prisma.userAccount.findUnique({ + where: { userId: BigInt(userId) }, + select: { + phoneNumber: true, + phoneVerified: true, + kycStatus: true, + realName: true, + idCardNumber: true, + kycVerifiedAt: true, + kycRejectedReason: true, + }, + }); + + if (!user) { + throw new ApplicationError('用户不存在'); + } + + return { + phoneVerified: user.phoneVerified, + kycStatus: user.kycStatus, + realName: user.realName ? this.maskName(user.realName) : undefined, + idCardNumber: user.idCardNumber ? this.maskIdCard(user.idCardNumber) : undefined, + kycVerifiedAt: user.kycVerifiedAt, + rejectedReason: user.kycRejectedReason, + phoneNumber: user.phoneNumber ? this.maskPhoneNumber(user.phoneNumber) : undefined, + }; + } + + /** + * 发送 KYC 手机验证码 + */ + async sendKycVerifySms(userId: string) { + this.logger.log(`[KYC] Sending verify SMS for user: ${userId}`); + + const user = await this.prisma.userAccount.findUnique({ + where: { userId: BigInt(userId) }, + select: { + phoneNumber: true, + phoneVerified: true, + }, + }); + + if (!user) { + throw new ApplicationError('用户不存在'); + } + + if (!user.phoneNumber) { + throw new ApplicationError('用户未绑定手机号'); + } + + if (user.phoneVerified) { + throw new ApplicationError('手机号已验证'); + } + + // 发送验证码 + const code = this.generateSmsCode(); + await this.redisService.set( + `sms:kyc:${user.phoneNumber}`, + code, + 5 * 60, // 5分钟有效期 + ); + + await this.smsService.sendVerificationCode(user.phoneNumber, code); + this.logger.log(`[KYC] Verify SMS sent to ${this.maskPhoneNumber(user.phoneNumber)}`); + } + + /** + * 完成手机号验证(KYC 流程) + */ + async verifyPhoneForKyc(userId: string, smsCode: string) { + this.logger.log(`[KYC] Verifying phone for user: ${userId}`); + + const user = await this.prisma.userAccount.findUnique({ + where: { userId: BigInt(userId) }, + select: { + phoneNumber: true, + phoneVerified: true, + kycStatus: true, + }, + }); + + if (!user) { + throw new ApplicationError('用户不存在'); + } + + if (!user.phoneNumber) { + throw new ApplicationError('用户未绑定手机号'); + } + + if (user.phoneVerified) { + throw new ApplicationError('手机号已验证'); + } + + // 验证验证码 + const cachedCode = await this.redisService.get(`sms:kyc:${user.phoneNumber}`); + if (cachedCode !== smsCode) { + throw new ApplicationError('验证码错误或已过期'); + } + + // 更新状态 + const newKycStatus = + user.kycStatus === KycStatus.NOT_STARTED + ? KycStatus.PHONE_VERIFIED + : user.kycStatus; + + await this.prisma.userAccount.update({ + where: { userId: BigInt(userId) }, + data: { + phoneVerified: true, + phoneVerifiedAt: new Date(), + kycStatus: newKycStatus, + }, + }); + + // 删除验证码 + await this.redisService.delete(`sms:kyc:${user.phoneNumber}`); + + this.logger.log(`[KYC] Phone verified for user: ${userId}, new status: ${newKycStatus}`); + } + + /** + * 提交身份证验证(二要素认证) + */ + async submitIdVerification( + userId: string, + realName: string, + idCardNumber: string, + ) { + this.logger.log(`[KYC] Submitting ID verification for user: ${userId}`); + + const user = await this.prisma.userAccount.findUnique({ + where: { userId: BigInt(userId) }, + select: { + phoneVerified: true, + kycStatus: true, + }, + }); + + if (!user) { + throw new ApplicationError('用户不存在'); + } + + // 检查是否已完成身份验证 + if ( + user.kycStatus === KycStatus.ID_VERIFIED || + user.kycStatus === KycStatus.COMPLETED + ) { + throw new ApplicationError('身份验证已完成,无需重复提交'); + } + + // 生成请求 ID + const requestId = `KYC_${userId}_${Date.now()}`; + + // 记录验证尝试 + const attempt = await this.prisma.kycVerificationAttempt.create({ + data: { + userId: BigInt(userId), + verificationType: 'ID_CARD', + provider: 'ALIYUN', + requestId, + inputData: { + realName: this.maskName(realName), + idCardNumber: this.maskIdCard(idCardNumber), + }, + status: 'PENDING', + }, + }); + + try { + // 调用阿里云二要素验证 + const result = await this.aliyunKycProvider.verifyIdCard( + realName, + idCardNumber, + requestId, + ); + + if (result.success) { + // 验证成功 + await this.prisma.$transaction([ + // 更新验证尝试记录 + this.prisma.kycVerificationAttempt.update({ + where: { id: attempt.id }, + data: { + status: 'SUCCESS', + responseData: result.rawResponse as object ?? null, + completedAt: new Date(), + }, + }), + // 更新用户信息 + this.prisma.userAccount.update({ + where: { userId: BigInt(userId) }, + data: { + realName, + idCardNumber, // 注意:生产环境应加密存储 + kycStatus: user.phoneVerified ? KycStatus.COMPLETED : KycStatus.ID_VERIFIED, + kycProvider: 'ALIYUN', + kycRequestId: requestId, + kycVerifiedAt: new Date(), + }, + }), + ]); + + this.logger.log(`[KYC] ID verification SUCCESS for user: ${userId}`); + + return { + requestId, + status: 'SUCCESS', + kycStatus: user.phoneVerified ? KycStatus.COMPLETED : KycStatus.ID_VERIFIED, + }; + } else { + // 验证失败 + await this.prisma.$transaction([ + this.prisma.kycVerificationAttempt.update({ + where: { id: attempt.id }, + data: { + status: 'FAILED', + failureReason: result.errorMessage, + responseData: result.rawResponse as object ?? null, + completedAt: new Date(), + }, + }), + this.prisma.userAccount.update({ + where: { userId: BigInt(userId) }, + data: { + kycStatus: KycStatus.REJECTED, + kycRejectedReason: result.errorMessage, + }, + }), + ]); + + this.logger.warn(`[KYC] ID verification FAILED for user: ${userId}, reason: ${result.errorMessage}`); + + return { + requestId, + status: 'FAILED', + failureReason: result.errorMessage, + kycStatus: KycStatus.REJECTED, + }; + } + } catch (error) { + // 系统错误 + await this.prisma.kycVerificationAttempt.update({ + where: { id: attempt.id }, + data: { + status: 'FAILED', + failureReason: `系统错误: ${error.message}`, + completedAt: new Date(), + }, + }); + + this.logger.error(`[KYC] ID verification ERROR for user: ${userId}`, error); + throw new ApplicationError('身份验证服务暂时不可用,请稍后重试'); + } + } + + // ============ Helper Methods ============ + + private generateSmsCode(): string { + return Math.floor(100000 + Math.random() * 900000).toString(); + } + + private maskPhoneNumber(phone: string): string { + if (phone.length < 7) return phone; + return `${phone.substring(0, 3)}****${phone.substring(phone.length - 4)}`; + } + + private maskName(name: string): string { + if (name.length <= 1) return name; + if (name.length === 2) return `${name[0]}*`; + return `${name[0]}${'*'.repeat(name.length - 2)}${name[name.length - 1]}`; + } + + private maskIdCard(idCard: string): string { + if (idCard.length < 10) return idCard; + return `${idCard.substring(0, 4)}**********${idCard.substring(idCard.length - 4)}`; + } +} diff --git a/backend/services/identity-service/src/application/services/user-application.service.ts b/backend/services/identity-service/src/application/services/user-application.service.ts index d6f8265e..16f574e2 100644 --- a/backend/services/identity-service/src/application/services/user-application.service.ts +++ b/backend/services/identity-service/src/application/services/user-application.service.ts @@ -45,6 +45,7 @@ import { import { AutoCreateAccountCommand, RegisterByPhoneCommand, + RegisterWithoutSmsVerifyCommand, RecoverByMnemonicCommand, RecoverByPhoneCommand, AutoLoginCommand, @@ -387,6 +388,177 @@ export class UserApplicationService { }; } + /** + * 跳过短信验证注册 + * + * 当用户收不到验证码时,允许跳过验证继续注册 + * - 不验证短信验证码 + * - 设置 phoneVerified = false + * - 设置 kycStatus = 'NOT_STARTED' + * - 其他流程与 registerByPhone 相同 + */ + async registerWithoutSmsVerify( + command: RegisterWithoutSmsVerifyCommand, + ): Promise { + const phoneNumber = PhoneNumber.create(command.phoneNumber); + + this.logger.log( + `[REGISTER_NO_SMS] Starting registration without SMS verify: phone=${phoneNumber.value}, deviceId=${command.deviceId}`, + ); + + // 1. 检查手机号是否已注册 + const phoneValidation = + await this.validatorService.validatePhoneNumber(phoneNumber); + if (!phoneValidation.isValid) { + this.logger.warn( + `[REGISTER_NO_SMS] Phone validation failed: ${phoneValidation.errorMessage}`, + ); + throw new ApplicationError(phoneValidation.errorMessage!); + } + this.logger.log(`[REGISTER_NO_SMS] Phone number validated`); + + // 2. 验证邀请码 (必填) + if (!command.inviterReferralCode) { + this.logger.warn(`[REGISTER_NO_SMS] Missing required referral code`); + throw new ApplicationError('推荐码不能为空'); + } + + const referralCode = ReferralCode.create(command.inviterReferralCode); + const referralValidation = + await this.validatorService.validateReferralCode(referralCode); + if (!referralValidation.isValid) { + this.logger.warn( + `[REGISTER_NO_SMS] Referral code invalid: ${command.inviterReferralCode}`, + ); + throw new ApplicationError(referralValidation.errorMessage!); + } + const inviter = await this.userRepository.findByReferralCode(referralCode); + if (!inviter) { + this.logger.warn( + `[REGISTER_NO_SMS] Inviter not found for code: ${command.inviterReferralCode}`, + ); + throw new ApplicationError('推荐码对应的用户不存在'); + } + const inviterSequence = inviter.accountSequence; + this.logger.log( + `[REGISTER_NO_SMS] Inviter validated: ${inviterSequence.value}`, + ); + + // 3. 生成用户序列号 + const accountSequence = + await this.sequenceGenerator.generateNextUserSequence(); + this.logger.log( + `[REGISTER_NO_SMS] Generated sequence: ${accountSequence.value}`, + ); + + // 4. 生成用户名和头像 + const identity = generateIdentity(accountSequence.value); + + // 5. 构建设备名称字符串 + let deviceNameStr = '未命名设备'; + if (command.deviceName) { + const parts: string[] = []; + if (command.deviceName.model) parts.push(command.deviceName.model); + if (command.deviceName.platform) parts.push(command.deviceName.platform); + if (command.deviceName.osVersion) + parts.push(command.deviceName.osVersion); + if (parts.length > 0) deviceNameStr = parts.join(' '); + } + + // 6. 创建用户账户(带手机号,但未验证) + const account = UserAccount.create({ + accountSequence, + phoneNumber, + initialDeviceId: command.deviceId, + deviceName: deviceNameStr, + deviceInfo: command.deviceName, + inviterSequence, + }); + this.logger.log( + `[REGISTER_NO_SMS] Account aggregate created (not saved yet): sequence=${accountSequence.value}`, + ); + + // 7. 设置随机用户名和头像 + account.updateProfile({ + nickname: identity.username, + avatarUrl: identity.avatarSvg, + }); + + // 8. 保存账户 + this.logger.log(`[REGISTER_NO_SMS] Saving account to database...`); + await this.userRepository.save(account); + this.logger.log( + `[REGISTER_NO_SMS] Account saved: userId=${account.userId.value}, sequence=${account.accountSequence.value}`, + ); + + // 8.1 验证保存成功 + if (account.userId.value === BigInt(0)) { + this.logger.error( + `[REGISTER_NO_SMS] CRITICAL: userId is still 0 after save! sequence=${accountSequence.value}`, + ); + throw new ApplicationError('账户保存失败:未获取到用户ID'); + } + + // 9. 设置密码,同时设置 phoneVerified = false 和 kycStatus = 'NOT_STARTED' + this.logger.log( + `[REGISTER_NO_SMS] Setting password hash and phoneVerified=false...`, + ); + const bcrypt = await import('bcrypt'); + const passwordHash = await bcrypt.hash(command.password, 10); + await this.prisma.userAccount.update({ + where: { userId: account.userId.value }, + data: { + passwordHash, + phoneVerified: false, + kycStatus: 'NOT_STARTED', + }, + }); + this.logger.log(`[REGISTER_NO_SMS] Password hash set, phoneVerified=false`); + + // 10. 生成 Token + const tokens = await this.tokenService.generateTokenPair({ + userId: account.userId.toString(), + accountSequence: account.accountSequence.value, + deviceId: command.deviceId, + }); + this.logger.log(`[REGISTER_NO_SMS] Tokens generated`); + + // 11. 发布领域事件 + this.logger.log( + `[REGISTER_NO_SMS] Publishing ${account.domainEvents.length} domain events...`, + ); + await this.eventPublisher.publishAll(account.domainEvents); + account.clearDomainEvents(); + this.logger.log(`[REGISTER_NO_SMS] Domain events published`); + + // 12. 发布 MPC Keygen 请求事件 (触发后台生成钱包) + const sessionId = crypto.randomUUID(); + await this.eventPublisher.publish( + new MpcKeygenRequestedEvent({ + sessionId, + userId: account.userId.toString(), + accountSequence: account.accountSequence.value, + username: `user_${account.accountSequence.value}`, + threshold: 2, + totalParties: 3, + requireDelegate: true, + }), + ); + + this.logger.log( + `[REGISTER_NO_SMS] COMPLETE: sequence=${accountSequence.value}, phone=${phoneNumber.value}, userId=${account.userId.value}, phoneVerified=false`, + ); + + return { + userSerialNum: account.accountSequence.value, + referralCode: account.referralCode.value, + username: account.nickname, + avatarSvg: account.avatarUrl || identity.avatarSvg, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + }; + } + async recoverByMnemonic( command: RecoverByMnemonicCommand, ): Promise { @@ -854,6 +1026,205 @@ export class UserApplicationService { account.clearDomainEvents(); } + // ============ 更换手机号相关方法 ============ + + /** + * 发送旧手机验证码(更换手机号第一步) + */ + async sendOldPhoneCode(userId: string): Promise { + this.logger.log(`[CHANGE_PHONE] Sending code to old phone for user: ${userId}`); + + const account = await this.prisma.userAccount.findUnique({ + where: { userId: BigInt(userId) }, + select: { phoneNumber: true, phoneVerified: true }, + }); + + if (!account) { + throw new ApplicationError('用户不存在'); + } + + if (!account.phoneNumber) { + throw new ApplicationError('您还未绑定手机号'); + } + + // 生成并发送验证码 + const code = this.generateSmsCode(); + const cacheKey = `sms:change_phone_old:${account.phoneNumber}`; + + await this.redisService.set(cacheKey, code, 300); // 5分钟有效 + await this.smsService.sendVerificationCode(account.phoneNumber, code); + + this.logger.log(`[CHANGE_PHONE] Old phone code sent to: ${this.maskPhoneNumber(account.phoneNumber)}`); + } + + /** + * 验证旧手机验证码(更换手机号第二步) + * 返回临时令牌用于后续操作 + */ + async verifyOldPhoneCode(userId: string, smsCode: string): Promise<{ changePhoneToken: string }> { + this.logger.log(`[CHANGE_PHONE] Verifying old phone code for user: ${userId}`); + + const account = await this.prisma.userAccount.findUnique({ + where: { userId: BigInt(userId) }, + select: { phoneNumber: true }, + }); + + if (!account?.phoneNumber) { + throw new ApplicationError('用户未绑定手机号'); + } + + const cacheKey = `sms:change_phone_old:${account.phoneNumber}`; + const cachedCode = await this.redisService.get(cacheKey); + + if (!cachedCode) { + throw new ApplicationError('验证码已过期,请重新获取'); + } + + if (cachedCode !== smsCode) { + throw new ApplicationError('验证码错误'); + } + + // 删除已使用的验证码 + await this.redisService.delete(cacheKey); + + // 生成临时令牌(10分钟有效) + const changePhoneToken = `CPT_${userId}_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`; + await this.redisService.set(`change_phone_token:${userId}`, changePhoneToken, 600); + + this.logger.log(`[CHANGE_PHONE] Old phone verified for user: ${userId}`); + + return { changePhoneToken }; + } + + /** + * 发送新手机验证码(更换手机号第三步) + */ + async sendNewPhoneCode( + userId: string, + newPhoneNumber: string, + changePhoneToken: string, + ): Promise { + this.logger.log(`[CHANGE_PHONE] Sending code to new phone for user: ${userId}`); + + // 验证临时令牌 + const cachedToken = await this.redisService.get(`change_phone_token:${userId}`); + if (!cachedToken || cachedToken !== changePhoneToken) { + throw new ApplicationError('操作已过期,请重新验证旧手机号'); + } + + // 验证新手机号格式 + const phoneNumber = PhoneNumber.create(newPhoneNumber); + + // 检查新手机号是否已被使用 + const existingUser = await this.prisma.userAccount.findFirst({ + where: { + phoneNumber: phoneNumber.value, + NOT: { userId: BigInt(userId) }, + }, + }); + if (existingUser) { + throw new ApplicationError('该手机号已被其他账户绑定'); + } + + // 发送验证码 + const code = this.generateSmsCode(); + const cacheKey = `sms:change_phone_new:${phoneNumber.value}`; + + await this.redisService.set(cacheKey, code, 300); // 5分钟有效 + await this.smsService.sendVerificationCode(phoneNumber.value, code); + + this.logger.log(`[CHANGE_PHONE] New phone code sent to: ${this.maskPhoneNumber(phoneNumber.value)}`); + } + + /** + * 确认更换手机号(更换手机号第四步) + */ + async confirmChangePhone( + userId: string, + newPhoneNumber: string, + smsCode: string, + changePhoneToken: string, + ): Promise { + this.logger.log(`[CHANGE_PHONE] Confirming phone change for user: ${userId}`); + + // 验证临时令牌 + const cachedToken = await this.redisService.get(`change_phone_token:${userId}`); + if (!cachedToken || cachedToken !== changePhoneToken) { + throw new ApplicationError('操作已过期,请重新验证旧手机号'); + } + + const phoneNumber = PhoneNumber.create(newPhoneNumber); + + // 验证新手机验证码 + const cacheKey = `sms:change_phone_new:${phoneNumber.value}`; + const cachedCode = await this.redisService.get(cacheKey); + + if (!cachedCode) { + throw new ApplicationError('验证码已过期,请重新获取'); + } + + if (cachedCode !== smsCode) { + throw new ApplicationError('验证码错误'); + } + + // 再次检查新手机号是否已被使用 + const existingUser = await this.prisma.userAccount.findFirst({ + where: { + phoneNumber: phoneNumber.value, + NOT: { userId: BigInt(userId) }, + }, + }); + if (existingUser) { + throw new ApplicationError('该手机号已被其他账户绑定'); + } + + // 更新手机号 + await this.prisma.userAccount.update({ + where: { userId: BigInt(userId) }, + data: { + phoneNumber: phoneNumber.value, + phoneVerified: true, + phoneVerifiedAt: new Date(), + }, + }); + + // 清理缓存 + await this.redisService.delete(cacheKey); + await this.redisService.delete(`change_phone_token:${userId}`); + + this.logger.log(`[CHANGE_PHONE] Phone changed successfully for user: ${userId}`); + } + + /** + * 获取手机号状态 + */ + async getPhoneStatus(userId: string): Promise<{ + isBound: boolean; + isVerified: boolean; + phoneNumber: string | null; + verifiedAt: Date | null; + }> { + const account = await this.prisma.userAccount.findUnique({ + where: { userId: BigInt(userId) }, + select: { + phoneNumber: true, + phoneVerified: true, + phoneVerifiedAt: true, + }, + }); + + if (!account) { + throw new ApplicationError('用户不存在'); + } + + return { + isBound: !!account.phoneNumber, + isVerified: account.phoneVerified, + phoneNumber: account.phoneNumber ? this.maskPhoneNumber(account.phoneNumber) : null, + verifiedAt: account.phoneVerifiedAt, + }; + } + async updateProfile(command: UpdateProfileCommand): Promise { const account = await this.userRepository.findById( UserId.create(command.userId), @@ -2476,10 +2847,14 @@ export class UserApplicationService { throw new ApplicationError('该邮箱已被其他账户绑定'); } - // 更新用户邮箱 + // 更新用户邮箱并标记为已验证 await this.prisma.userAccount.update({ where: { userId: BigInt(command.userId) }, - data: { email: emailLower }, + data: { + email: emailLower, + emailVerified: true, + emailVerifiedAt: new Date(), + }, }); // 删除验证码 @@ -2514,10 +2889,14 @@ export class UserApplicationService { throw new ApplicationError('验证码错误'); } - // 解绑邮箱 + // 解绑邮箱并清除验证状态 await this.prisma.userAccount.update({ where: { userId: BigInt(command.userId) }, - data: { email: null }, + data: { + email: null, + emailVerified: false, + emailVerifiedAt: null, + }, }); // 删除验证码 @@ -2531,11 +2910,17 @@ export class UserApplicationService { */ async getEmailStatus(userId: string): Promise<{ isBound: boolean; + isVerified: boolean; email: string | null; + verifiedAt: Date | null; }> { const account = await this.prisma.userAccount.findUnique({ where: { userId: BigInt(userId) }, - select: { email: true }, + select: { + email: true, + emailVerified: true, + emailVerifiedAt: true, + }, }); if (!account) { @@ -2550,7 +2935,9 @@ export class UserApplicationService { return { isBound: !!account.email, + isVerified: account.emailVerified, email: maskedEmail, + verifiedAt: account.emailVerifiedAt, }; } } diff --git a/backend/services/identity-service/src/infrastructure/external/kyc/aliyun-kyc.provider.ts b/backend/services/identity-service/src/infrastructure/external/kyc/aliyun-kyc.provider.ts new file mode 100644 index 00000000..aeea6015 --- /dev/null +++ b/backend/services/identity-service/src/infrastructure/external/kyc/aliyun-kyc.provider.ts @@ -0,0 +1,154 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +export interface IdCardVerificationResult { + success: boolean; + errorMessage?: string; + rawResponse?: Record; +} + +/** + * 阿里云实人认证服务 - 二要素验证(姓名+身份证号) + * + * 使用阿里云身份二要素核验 API + * 文档: https://help.aliyun.com/document_detail/155148.html + */ +@Injectable() +export class AliyunKycProvider { + private readonly logger = new Logger(AliyunKycProvider.name); + + private readonly accessKeyId: string; + private readonly accessKeySecret: string; + private readonly enabled: boolean; + + constructor(private readonly configService: ConfigService) { + this.accessKeyId = this.configService.get('ALIYUN_ACCESS_KEY_ID', ''); + this.accessKeySecret = this.configService.get('ALIYUN_ACCESS_KEY_SECRET', ''); + this.enabled = this.configService.get('ALIYUN_KYC_ENABLED', false); + + if (this.enabled && (!this.accessKeyId || !this.accessKeySecret)) { + this.logger.warn('[AliyunKYC] KYC is enabled but credentials are not configured'); + } + } + + /** + * 验证身份证信息(二要素验证) + * + * @param realName 真实姓名 + * @param idCardNumber 身份证号 + * @param requestId 请求ID(用于日志追踪) + */ + async verifyIdCard( + realName: string, + idCardNumber: string, + requestId: string, + ): Promise { + this.logger.log(`[AliyunKYC] Starting ID card verification, requestId: ${requestId}`); + + // 开发/测试环境:模拟验证 + if (!this.enabled) { + this.logger.warn('[AliyunKYC] KYC is disabled, using mock verification'); + return this.mockVerification(realName, idCardNumber); + } + + try { + // TODO: 集成真实的阿里云 SDK + // 这里先使用模拟实现,后续替换为真实的阿里云 API 调用 + // + // 真实实现示例: + // const client = new Cloudauth20190307(config); + // const request = new DescribeVerifyResultRequest({ + // bizType: 'ID_CARD_VERIFY', + // bizId: requestId, + // }); + // const response = await client.describeVerifyResult(request); + + this.logger.log('[AliyunKYC] Calling Aliyun API...'); + + // 模拟 API 调用延迟 + await this.delay(500); + + // 暂时使用模拟验证 + return this.mockVerification(realName, idCardNumber); + } catch (error) { + this.logger.error(`[AliyunKYC] API call failed: ${error.message}`, error.stack); + return { + success: false, + errorMessage: '身份验证服务暂时不可用', + rawResponse: { error: error.message }, + }; + } + } + + /** + * 模拟验证(开发/测试环境使用) + */ + private mockVerification( + realName: string, + idCardNumber: string, + ): IdCardVerificationResult { + this.logger.log('[AliyunKYC] Using mock verification'); + + // 基本格式验证 + if (!realName || realName.length < 2) { + return { + success: false, + errorMessage: '姓名格式不正确', + rawResponse: { mock: true, reason: 'invalid_name' }, + }; + } + + // 身份证号格式验证 + const idCardRegex = /^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/; + if (!idCardRegex.test(idCardNumber)) { + return { + success: false, + errorMessage: '身份证号格式不正确', + rawResponse: { mock: true, reason: 'invalid_id_card_format' }, + }; + } + + // 校验码验证 + if (!this.validateIdCardChecksum(idCardNumber)) { + return { + success: false, + errorMessage: '身份证号校验码不正确', + rawResponse: { mock: true, reason: 'invalid_checksum' }, + }; + } + + // 模拟成功 + this.logger.log('[AliyunKYC] Mock verification SUCCESS'); + return { + success: true, + rawResponse: { + mock: true, + verifyTime: new Date().toISOString(), + }, + }; + } + + /** + * 验证身份证校验码 + */ + private validateIdCardChecksum(idCard: string): boolean { + if (idCard.length !== 18) return false; + + const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]; + const checksumChars = '10X98765432'; + + let sum = 0; + for (let i = 0; i < 17; i++) { + sum += parseInt(idCard[i], 10) * weights[i]; + } + + const expectedChecksum = checksumChars[sum % 11]; + const actualChecksum = idCard[17].toUpperCase(); + + return expectedChecksum === actualChecksum; + } + + private delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/backend/services/identity-service/src/infrastructure/external/kyc/index.ts b/backend/services/identity-service/src/infrastructure/external/kyc/index.ts new file mode 100644 index 00000000..7b1c1033 --- /dev/null +++ b/backend/services/identity-service/src/infrastructure/external/kyc/index.ts @@ -0,0 +1 @@ +export * from './aliyun-kyc.provider'; diff --git a/frontend/mobile-app/lib/core/services/account_service.dart b/frontend/mobile-app/lib/core/services/account_service.dart index 83f1a6ce..e0c681d1 100644 --- a/frontend/mobile-app/lib/core/services/account_service.dart +++ b/frontend/mobile-app/lib/core/services/account_service.dart @@ -1768,6 +1768,80 @@ class AccountService { } } + /// 跳过短信验证注册 + /// + /// 用户收不到验证码时可以跳过验证继续注册 + /// 注册后 phoneVerified = false,需在 KYC 流程中完成验证 + /// + /// [phoneNumber] - 手机号 + /// [password] - 登录密码 + /// [inviterReferralCode] - 邀请人推荐码(可选) + Future registerWithoutSmsVerify({ + required String phoneNumber, + required String password, + String? inviterReferralCode, + }) async { + debugPrint('$_tag registerWithoutSmsVerify() - 开始跳过验证注册'); + debugPrint('$_tag registerWithoutSmsVerify() - 手机号: ${_maskPhoneNumber(phoneNumber)}'); + debugPrint('$_tag registerWithoutSmsVerify() - 邀请码: ${inviterReferralCode ?? "无"}'); + + try { + // 获取设备ID + final deviceId = await getDeviceId(); + debugPrint('$_tag registerWithoutSmsVerify() - 获取设备ID成功'); + + // 获取设备硬件信息 + final deviceInfo = await getDeviceHardwareInfo(); + debugPrint('$_tag registerWithoutSmsVerify() - 获取设备硬件信息成功'); + + // 调用 API + debugPrint('$_tag registerWithoutSmsVerify() - 调用 POST /user/register-without-sms-verify'); + final response = await _apiClient.post( + '/user/register-without-sms-verify', + data: { + 'phoneNumber': phoneNumber, + 'password': password, + 'deviceId': deviceId, + 'deviceName': deviceInfo.toJson(), + if (inviterReferralCode != null) 'inviterReferralCode': inviterReferralCode, + }, + ); + debugPrint('$_tag registerWithoutSmsVerify() - API 响应状态码: ${response.statusCode}'); + + if (response.data == null) { + debugPrint('$_tag registerWithoutSmsVerify() - 错误: API 返回空响应'); + throw const ApiException('注册失败: 空响应'); + } + + debugPrint('$_tag registerWithoutSmsVerify() - 解析响应数据'); + final responseData = response.data as Map; + final data = responseData['data'] as Map; + final result = CreateAccountResponse.fromJson(data); + debugPrint('$_tag registerWithoutSmsVerify() - 解析成功: $result'); + + // 保存账号数据 + debugPrint('$_tag registerWithoutSmsVerify() - 保存账号数据'); + await _saveAccountData(result, deviceId); + + // 保存手机号 + await _secureStorage.write(key: StorageKeys.phoneNumber, value: phoneNumber); + + // 标记密码已设置 + await _secureStorage.write(key: StorageKeys.isPasswordSet, value: 'true'); + + // 注意:跳过验证注册的用户 phoneVerified = false,需要在 KYC 流程中完成验证 + debugPrint('$_tag registerWithoutSmsVerify() - 跳过验证注册完成 (phoneVerified=false)'); + return result; + } on ApiException catch (e) { + debugPrint('$_tag registerWithoutSmsVerify() - API 异常: $e'); + rethrow; + } catch (e, stackTrace) { + debugPrint('$_tag registerWithoutSmsVerify() - 未知异常: $e'); + debugPrint('$_tag registerWithoutSmsVerify() - 堆栈: $stackTrace'); + throw ApiException('注册失败: $e'); + } + } + /// 手机号登录 /// /// [phoneNumber] - 手机号 diff --git a/frontend/mobile-app/lib/features/auth/presentation/pages/set_password_page.dart b/frontend/mobile-app/lib/features/auth/presentation/pages/set_password_page.dart index f468b21b..215e91ed 100644 --- a/frontend/mobile-app/lib/features/auth/presentation/pages/set_password_page.dart +++ b/frontend/mobile-app/lib/features/auth/presentation/pages/set_password_page.dart @@ -12,13 +12,15 @@ class SetPasswordParams { final String? userSerialNum; // 如果有则表示已创建账号(旧流程),无则表示需要创建账号(新流程) final String? inviterReferralCode; final String? phoneNumber; // 新流程需要 - final String? smsCode; // 新流程需要 + final String? smsCode; // 新流程需要(跳过验证时为 null) + final bool skipVerify; // 是否跳过短信验证 SetPasswordParams({ this.userSerialNum, this.inviterReferralCode, this.phoneNumber, this.smsCode, + this.skipVerify = false, }); } @@ -28,7 +30,8 @@ class SetPasswordPage extends ConsumerStatefulWidget { final String? userSerialNum; // 旧流程:已创建账号 final String? inviterReferralCode; final String? phoneNumber; // 新流程:手机号 - final String? smsCode; // 新流程:验证码 + final String? smsCode; // 新流程:验证码(跳过验证时为 null) + final bool skipVerify; // 是否跳过短信验证 const SetPasswordPage({ super.key, @@ -36,6 +39,7 @@ class SetPasswordPage extends ConsumerStatefulWidget { this.inviterReferralCode, this.phoneNumber, this.smsCode, + this.skipVerify = false, }); @override @@ -110,33 +114,63 @@ class _SetPasswordPageState extends ConsumerState { final password = _passwordController.text; // 判断是新流程还是旧流程 - if (widget.phoneNumber != null && widget.smsCode != null) { - // 新流程:使用 register-by-phone API 一步完成注册 - debugPrint('[SetPasswordPage] 使用新流程: register-by-phone'); + if (widget.phoneNumber != null) { + if (widget.skipVerify) { + // 跳过验证流程:使用 register-without-sms-verify API + debugPrint('[SetPasswordPage] 使用跳过验证流程: register-without-sms-verify'); - final response = await accountService.registerByPhoneWithPassword( - phoneNumber: widget.phoneNumber!, - smsCode: widget.smsCode!, - password: password, - inviterReferralCode: widget.inviterReferralCode, - ); + final response = await accountService.registerWithoutSmsVerify( + phoneNumber: widget.phoneNumber!, + password: password, + inviterReferralCode: widget.inviterReferralCode, + ); - debugPrint('[SetPasswordPage] 注册成功: ${response.userSerialNum}'); + debugPrint('[SetPasswordPage] 注册成功(跳过验证): ${response.userSerialNum}'); - if (!mounted) return; + if (!mounted) return; - // 将账号添加到多账号列表 - final multiAccountService = ref.read(multiAccountServiceProvider); - await multiAccountService.addAccount( - AccountSummary( - userSerialNum: response.userSerialNum, - username: response.username, - avatarSvg: response.avatarSvg, - createdAt: DateTime.now(), - ), - ); - await multiAccountService.setCurrentAccountId(response.userSerialNum); - debugPrint('[SetPasswordPage] 已添加到多账号列表'); + // 将账号添加到多账号列表 + final multiAccountService = ref.read(multiAccountServiceProvider); + await multiAccountService.addAccount( + AccountSummary( + userSerialNum: response.userSerialNum, + username: response.username, + avatarSvg: response.avatarSvg, + createdAt: DateTime.now(), + ), + ); + await multiAccountService.setCurrentAccountId(response.userSerialNum); + debugPrint('[SetPasswordPage] 已添加到多账号列表(跳过验证)'); + } else if (widget.smsCode != null) { + // 新流程:使用 register-by-phone API 一步完成注册 + debugPrint('[SetPasswordPage] 使用新流程: register-by-phone'); + + final response = await accountService.registerByPhoneWithPassword( + phoneNumber: widget.phoneNumber!, + smsCode: widget.smsCode!, + password: password, + inviterReferralCode: widget.inviterReferralCode, + ); + + debugPrint('[SetPasswordPage] 注册成功: ${response.userSerialNum}'); + + if (!mounted) return; + + // 将账号添加到多账号列表 + final multiAccountService = ref.read(multiAccountServiceProvider); + await multiAccountService.addAccount( + AccountSummary( + userSerialNum: response.userSerialNum, + username: response.username, + avatarSvg: response.avatarSvg, + createdAt: DateTime.now(), + ), + ); + await multiAccountService.setCurrentAccountId(response.userSerialNum); + debugPrint('[SetPasswordPage] 已添加到多账号列表'); + } else { + throw Exception('缺少验证码'); + } } else { // 旧流程:单独设置密码 debugPrint('[SetPasswordPage] 使用旧流程: set-password'); diff --git a/frontend/mobile-app/lib/features/auth/presentation/pages/sms_verify_page.dart b/frontend/mobile-app/lib/features/auth/presentation/pages/sms_verify_page.dart index ea62fed7..2a0a02e3 100644 --- a/frontend/mobile-app/lib/features/auth/presentation/pages/sms_verify_page.dart +++ b/frontend/mobile-app/lib/features/auth/presentation/pages/sms_verify_page.dart @@ -42,11 +42,20 @@ class _SmsVerifyPageState extends ConsumerState { Timer? _countdownTimer; bool _canResend = false; + // 跳过验证倒计时(3分钟后显示跳过按钮) + int _skipCountdown = 180; + Timer? _skipTimer; + bool _canSkipVerify = false; + @override void initState() { super.initState(); debugPrint('[SmsVerifyPage] initState - phoneNumber: ${_maskPhoneNumber(widget.phoneNumber)}, type: ${widget.type}'); _startCountdown(); + // 只在注册模式下启动跳过验证倒计时 + if (widget.type == SmsCodeType.register) { + _startSkipCountdown(); + } // 自动聚焦第一个输入框 WidgetsBinding.instance.addPostFrameCallback((_) { _focusNodes[0].requestFocus(); @@ -56,6 +65,7 @@ class _SmsVerifyPageState extends ConsumerState { @override void dispose() { _countdownTimer?.cancel(); + _skipTimer?.cancel(); for (final controller in _controllers) { controller.dispose(); } @@ -83,6 +93,39 @@ class _SmsVerifyPageState extends ConsumerState { }); } + /// 启动跳过验证倒计时(3分钟) + void _startSkipCountdown() { + _skipCountdown = 180; + _canSkipVerify = false; + _skipTimer?.cancel(); + _skipTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (_skipCountdown > 0) { + setState(() { + _skipCountdown--; + }); + } else { + setState(() { + _canSkipVerify = true; + }); + timer.cancel(); + } + }); + } + + /// 跳过验证,继续注册 + void _skipVerification() { + debugPrint('[SmsVerifyPage] 用户选择跳过验证,跳转到设置密码页面'); + context.go( + RoutePaths.setPassword, + extra: SetPasswordParams( + phoneNumber: widget.phoneNumber, + smsCode: null, // 跳过验证时没有验证码 + inviterReferralCode: widget.inviterReferralCode, + skipVerify: true, // 标记跳过验证 + ), + ); + } + String _getVerificationCode() { return _controllers.map((c) => c.text).join(); } @@ -298,6 +341,11 @@ class _SmsVerifyPageState extends ConsumerState { SizedBox(height: 24.h), // 重新发送按钮 _buildResendButton(), + // 跳过验证按钮(仅注册模式,2分钟后显示) + if (widget.type == SmsCodeType.register) ...[ + SizedBox(height: 16.h), + _buildSkipButton(), + ], const Spacer(), // 验证中提示 if (_isVerifying) @@ -407,6 +455,57 @@ class _SmsVerifyPageState extends ConsumerState { ); } + /// 构建跳过验证按钮 + Widget _buildSkipButton() { + if (_canSkipVerify) { + // 2分钟后显示跳过按钮 + return Center( + child: GestureDetector( + onTap: _skipVerification, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), + decoration: BoxDecoration( + color: const Color(0xFFFFF3E0), + borderRadius: BorderRadius.circular(8.r), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.warning_amber_rounded, + size: 16.sp, + color: const Color(0xFFE65100), + ), + SizedBox(width: 6.w), + Text( + '收不到验证码?跳过验证,稍后完成', + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w500, + color: const Color(0xFFE65100), + ), + ), + ], + ), + ), + ), + ); + } else { + // 2分钟内显示倒计时提示 + final minutes = _skipCountdown ~/ 60; + final seconds = _skipCountdown % 60; + return Center( + child: Text( + '收不到验证码?${minutes > 0 ? '${minutes}分' : ''}${seconds}秒后可跳过', + style: TextStyle( + fontSize: 12.sp, + color: const Color(0xFFBDBDBD), + ), + ), + ); + } + } + String _formatPhoneNumber(String phone) { if (phone.length != 11) return phone; return '${phone.substring(0, 3)} **** ${phone.substring(7)}'; diff --git a/frontend/mobile-app/lib/features/kyc/data/kyc_service.dart b/frontend/mobile-app/lib/features/kyc/data/kyc_service.dart new file mode 100644 index 00000000..67696630 --- /dev/null +++ b/frontend/mobile-app/lib/features/kyc/data/kyc_service.dart @@ -0,0 +1,339 @@ +import 'package:flutter/foundation.dart'; +import '../../../core/network/api_client.dart'; +import '../../../core/errors/exceptions.dart'; + +/// KYC 状态枚举 +enum KycStatusType { + notStarted, // 未开始 + phoneVerified, // 手机已验证 + idPending, // 身份验证中 + idVerified, // 身份已验证 + completed, // 已完成 + rejected, // 已拒绝 +} + +/// KYC 状态响应 +class KycStatusResponse { + final bool phoneVerified; + final String kycStatus; + final String? realName; + final String? idCardNumber; + final DateTime? kycVerifiedAt; + final String? rejectedReason; + final String? phoneNumber; + + KycStatusResponse({ + required this.phoneVerified, + required this.kycStatus, + this.realName, + this.idCardNumber, + this.kycVerifiedAt, + this.rejectedReason, + this.phoneNumber, + }); + + factory KycStatusResponse.fromJson(Map json) { + return KycStatusResponse( + phoneVerified: json['phoneVerified'] as bool? ?? false, + kycStatus: json['kycStatus'] as String? ?? 'NOT_STARTED', + realName: json['realName'] as String?, + idCardNumber: json['idCardNumber'] as String?, + kycVerifiedAt: json['kycVerifiedAt'] != null + ? DateTime.parse(json['kycVerifiedAt'] as String) + : null, + rejectedReason: json['rejectedReason'] as String?, + phoneNumber: json['phoneNumber'] as String?, + ); + } + + KycStatusType get statusType { + switch (kycStatus) { + case 'NOT_STARTED': + return KycStatusType.notStarted; + case 'PHONE_VERIFIED': + return KycStatusType.phoneVerified; + case 'ID_PENDING': + return KycStatusType.idPending; + case 'ID_VERIFIED': + return KycStatusType.idVerified; + case 'COMPLETED': + return KycStatusType.completed; + case 'REJECTED': + return KycStatusType.rejected; + default: + return KycStatusType.notStarted; + } + } + + bool get isCompleted => statusType == KycStatusType.completed; + bool get needsPhoneVerification => !phoneVerified; + bool get needsIdVerification => + statusType == KycStatusType.notStarted || + statusType == KycStatusType.phoneVerified || + statusType == KycStatusType.rejected; +} + +/// 身份验证响应 +class IdVerificationResponse { + final String requestId; + final String status; + final String? failureReason; + final String? kycStatus; + + IdVerificationResponse({ + required this.requestId, + required this.status, + this.failureReason, + this.kycStatus, + }); + + factory IdVerificationResponse.fromJson(Map json) { + return IdVerificationResponse( + requestId: json['requestId'] as String, + status: json['status'] as String, + failureReason: json['failureReason'] as String?, + kycStatus: json['kycStatus'] as String?, + ); + } + + bool get isSuccess => status == 'SUCCESS'; + bool get isFailed => status == 'FAILED'; +} + +/// KYC 服务 +class KycService { + static const String _tag = '[KycService]'; + final ApiClient _apiClient; + + KycService(this._apiClient); + + /// 获取 KYC 状态 + Future getKycStatus() async { + debugPrint('$_tag getKycStatus() - 获取 KYC 状态'); + + try { + final response = await _apiClient.get('/user/kyc/status'); + debugPrint('$_tag getKycStatus() - 响应: ${response.statusCode}'); + + if (response.data == null) { + throw const ApiException('获取 KYC 状态失败: 空响应'); + } + + final responseData = response.data as Map; + final data = responseData['data'] as Map; + return KycStatusResponse.fromJson(data); + } on ApiException { + rethrow; + } catch (e) { + debugPrint('$_tag getKycStatus() - 异常: $e'); + throw ApiException('获取 KYC 状态失败: $e'); + } + } + + /// 发送 KYC 手机验证码 + Future sendKycVerifySms() async { + debugPrint('$_tag sendKycVerifySms() - 发送验证码'); + + try { + final response = await _apiClient.post('/user/kyc/send-verify-sms'); + debugPrint('$_tag sendKycVerifySms() - 响应: ${response.statusCode}'); + } on ApiException { + rethrow; + } catch (e) { + debugPrint('$_tag sendKycVerifySms() - 异常: $e'); + throw ApiException('发送验证码失败: $e'); + } + } + + /// 验证手机号 (KYC 流程) + Future verifyPhoneForKyc(String smsCode) async { + debugPrint('$_tag verifyPhoneForKyc() - 验证手机号'); + + try { + final response = await _apiClient.post( + '/user/kyc/verify-phone', + data: {'smsCode': smsCode}, + ); + debugPrint('$_tag verifyPhoneForKyc() - 响应: ${response.statusCode}'); + } on ApiException { + rethrow; + } catch (e) { + debugPrint('$_tag verifyPhoneForKyc() - 异常: $e'); + throw ApiException('验证失败: $e'); + } + } + + /// 提交身份证验证 + Future submitIdVerification({ + required String realName, + required String idCardNumber, + }) async { + debugPrint('$_tag submitIdVerification() - 提交身份验证'); + + try { + final response = await _apiClient.post( + '/user/kyc/submit-id', + data: { + 'realName': realName, + 'idCardNumber': idCardNumber, + }, + ); + debugPrint('$_tag submitIdVerification() - 响应: ${response.statusCode}'); + + if (response.data == null) { + throw const ApiException('提交身份验证失败: 空响应'); + } + + final responseData = response.data as Map; + final data = responseData['data'] as Map; + return IdVerificationResponse.fromJson(data); + } on ApiException { + rethrow; + } catch (e) { + debugPrint('$_tag submitIdVerification() - 异常: $e'); + throw ApiException('提交身份验证失败: $e'); + } + } + + // ============ 更换手机号相关 ============ + + /// 获取手机号状态 + Future getPhoneStatus() async { + debugPrint('$_tag getPhoneStatus() - 获取手机号状态'); + + try { + final response = await _apiClient.get('/user/phone-status'); + debugPrint('$_tag getPhoneStatus() - 响应: ${response.statusCode}'); + + if (response.data == null) { + throw const ApiException('获取手机号状态失败: 空响应'); + } + + final responseData = response.data as Map; + final data = responseData['data'] as Map; + return PhoneStatusResponse.fromJson(data); + } on ApiException { + rethrow; + } catch (e) { + debugPrint('$_tag getPhoneStatus() - 异常: $e'); + throw ApiException('获取手机号状态失败: $e'); + } + } + + /// 发送旧手机验证码(更换手机号第一步) + Future sendOldPhoneCode() async { + debugPrint('$_tag sendOldPhoneCode() - 发送旧手机验证码'); + + try { + final response = await _apiClient.post('/user/change-phone/send-old-code'); + debugPrint('$_tag sendOldPhoneCode() - 响应: ${response.statusCode}'); + } on ApiException { + rethrow; + } catch (e) { + debugPrint('$_tag sendOldPhoneCode() - 异常: $e'); + throw ApiException('发送验证码失败: $e'); + } + } + + /// 验证旧手机验证码(更换手机号第二步) + Future verifyOldPhoneCode(String smsCode) async { + debugPrint('$_tag verifyOldPhoneCode() - 验证旧手机验证码'); + + try { + final response = await _apiClient.post( + '/user/change-phone/verify-old', + data: {'smsCode': smsCode}, + ); + debugPrint('$_tag verifyOldPhoneCode() - 响应: ${response.statusCode}'); + + if (response.data == null) { + throw const ApiException('验证失败: 空响应'); + } + + final responseData = response.data as Map; + final data = responseData['data'] as Map; + return data['changePhoneToken'] as String; + } on ApiException { + rethrow; + } catch (e) { + debugPrint('$_tag verifyOldPhoneCode() - 异常: $e'); + throw ApiException('验证失败: $e'); + } + } + + /// 发送新手机验证码(更换手机号第三步) + Future sendNewPhoneCode({ + required String newPhoneNumber, + required String changePhoneToken, + }) async { + debugPrint('$_tag sendNewPhoneCode() - 发送新手机验证码'); + + try { + final response = await _apiClient.post( + '/user/change-phone/send-new-code', + data: { + 'newPhoneNumber': newPhoneNumber, + 'changePhoneToken': changePhoneToken, + }, + ); + debugPrint('$_tag sendNewPhoneCode() - 响应: ${response.statusCode}'); + } on ApiException { + rethrow; + } catch (e) { + debugPrint('$_tag sendNewPhoneCode() - 异常: $e'); + throw ApiException('发送验证码失败: $e'); + } + } + + /// 确认更换手机号(更换手机号第四步) + Future confirmChangePhone({ + required String newPhoneNumber, + required String smsCode, + required String changePhoneToken, + }) async { + debugPrint('$_tag confirmChangePhone() - 确认更换手机号'); + + try { + final response = await _apiClient.post( + '/user/change-phone/confirm', + data: { + 'newPhoneNumber': newPhoneNumber, + 'smsCode': smsCode, + 'changePhoneToken': changePhoneToken, + }, + ); + debugPrint('$_tag confirmChangePhone() - 响应: ${response.statusCode}'); + } on ApiException { + rethrow; + } catch (e) { + debugPrint('$_tag confirmChangePhone() - 异常: $e'); + throw ApiException('更换手机号失败: $e'); + } + } +} + +/// 手机号状态响应 +class PhoneStatusResponse { + final bool isBound; + final bool isVerified; + final String? phoneNumber; + final DateTime? verifiedAt; + + PhoneStatusResponse({ + required this.isBound, + required this.isVerified, + this.phoneNumber, + this.verifiedAt, + }); + + factory PhoneStatusResponse.fromJson(Map json) { + return PhoneStatusResponse( + isBound: json['isBound'] as bool? ?? false, + isVerified: json['isVerified'] as bool? ?? false, + phoneNumber: json['phoneNumber'] as String?, + verifiedAt: json['verifiedAt'] != null + ? DateTime.parse(json['verifiedAt'] as String) + : null, + ); + } +} diff --git a/frontend/mobile-app/lib/features/kyc/presentation/pages/change_phone_page.dart b/frontend/mobile-app/lib/features/kyc/presentation/pages/change_phone_page.dart new file mode 100644 index 00000000..2b250d64 --- /dev/null +++ b/frontend/mobile-app/lib/features/kyc/presentation/pages/change_phone_page.dart @@ -0,0 +1,657 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:go_router/go_router.dart'; +import 'kyc_entry_page.dart'; // 导入 kycServiceProvider + +/// 更换手机号流程步骤 +enum ChangePhoneStep { + verifyOld, // 验证旧手机 + inputNew, // 输入新手机号 + verifyNew, // 验证新手机 + success, // 更换成功 +} + +/// 更换手机号页面 +class ChangePhonePage extends ConsumerStatefulWidget { + const ChangePhonePage({super.key}); + + @override + ConsumerState createState() => _ChangePhonePageState(); +} + +class _ChangePhonePageState extends ConsumerState { + ChangePhoneStep _currentStep = ChangePhoneStep.verifyOld; + + // 旧手机验证 + final List _oldCodeControllers = List.generate(6, (_) => TextEditingController()); + final List _oldCodeFocusNodes = List.generate(6, (_) => FocusNode()); + + // 新手机号输入 + final _newPhoneController = TextEditingController(); + + // 新手机验证 + final List _newCodeControllers = List.generate(6, (_) => TextEditingController()); + final List _newCodeFocusNodes = List.generate(6, (_) => FocusNode()); + + // 状态 + bool _isLoading = false; + bool _isSendingCode = false; + String? _errorMessage; + String? _oldPhoneNumber; + String? _changePhoneToken; + + // 倒计时 + int _countdown = 0; + Timer? _countdownTimer; + + @override + void initState() { + super.initState(); + _loadPhoneStatus(); + } + + @override + void dispose() { + _countdownTimer?.cancel(); + for (var c in _oldCodeControllers) { + c.dispose(); + } + for (var f in _oldCodeFocusNodes) { + f.dispose(); + } + _newPhoneController.dispose(); + for (var c in _newCodeControllers) { + c.dispose(); + } + for (var f in _newCodeFocusNodes) { + f.dispose(); + } + super.dispose(); + } + + Future _loadPhoneStatus() async { + try { + final kycService = ref.read(kycServiceProvider); + final status = await kycService.getPhoneStatus(); + if (mounted) { + setState(() { + _oldPhoneNumber = status.phoneNumber; + }); + } + } catch (e) { + debugPrint('[ChangePhonePage] 加载手机号状态失败: $e'); + } + } + + void _startCountdown() { + _countdown = 60; + _countdownTimer?.cancel(); + _countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (_countdown > 0) { + setState(() => _countdown--); + } else { + timer.cancel(); + } + }); + } + + /// 发送旧手机验证码 + Future _sendOldPhoneCode() async { + if (_isSendingCode || _countdown > 0) return; + + setState(() { + _isSendingCode = true; + _errorMessage = null; + }); + + try { + final kycService = ref.read(kycServiceProvider); + await kycService.sendOldPhoneCode(); + _startCountdown(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('验证码已发送'), backgroundColor: Color(0xFF2E7D32)), + ); + } + } catch (e) { + setState(() { + _errorMessage = e.toString().replaceAll('Exception: ', ''); + }); + } finally { + if (mounted) { + setState(() => _isSendingCode = false); + } + } + } + + /// 验证旧手机验证码 + Future _verifyOldPhone() async { + final code = _oldCodeControllers.map((c) => c.text).join(); + if (code.length != 6) { + setState(() => _errorMessage = '请输入完整的验证码'); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final kycService = ref.read(kycServiceProvider); + final token = await kycService.verifyOldPhoneCode(code); + + setState(() { + _changePhoneToken = token; + _currentStep = ChangePhoneStep.inputNew; + _countdown = 0; + }); + } catch (e) { + setState(() { + _errorMessage = e.toString().replaceAll('Exception: ', ''); + }); + // 清空验证码 + for (var c in _oldCodeControllers) { + c.clear(); + } + _oldCodeFocusNodes[0].requestFocus(); + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + /// 发送新手机验证码 + Future _sendNewPhoneCode() async { + final newPhone = _newPhoneController.text.trim(); + if (!RegExp(r'^1[3-9]\d{9}$').hasMatch(newPhone)) { + setState(() => _errorMessage = '请输入正确的手机号'); + return; + } + + if (_isSendingCode || _countdown > 0) return; + + setState(() { + _isSendingCode = true; + _errorMessage = null; + }); + + try { + final kycService = ref.read(kycServiceProvider); + await kycService.sendNewPhoneCode( + newPhoneNumber: newPhone, + changePhoneToken: _changePhoneToken!, + ); + _startCountdown(); + setState(() { + _currentStep = ChangePhoneStep.verifyNew; + }); + } catch (e) { + setState(() { + _errorMessage = e.toString().replaceAll('Exception: ', ''); + }); + } finally { + if (mounted) { + setState(() => _isSendingCode = false); + } + } + } + + /// 确认更换手机号 + Future _confirmChangePhone() async { + final code = _newCodeControllers.map((c) => c.text).join(); + if (code.length != 6) { + setState(() => _errorMessage = '请输入完整的验证码'); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final kycService = ref.read(kycServiceProvider); + await kycService.confirmChangePhone( + newPhoneNumber: _newPhoneController.text.trim(), + smsCode: code, + changePhoneToken: _changePhoneToken!, + ); + + setState(() { + _currentStep = ChangePhoneStep.success; + }); + } catch (e) { + setState(() { + _errorMessage = e.toString().replaceAll('Exception: ', ''); + }); + // 清空验证码 + for (var c in _newCodeControllers) { + c.clear(); + } + _newCodeFocusNodes[0].requestFocus(); + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + void _onOldCodeChanged(int index, String value) { + if (_errorMessage != null) { + setState(() => _errorMessage = null); + } + if (value.isNotEmpty && index < 5) { + _oldCodeFocusNodes[index + 1].requestFocus(); + } else if (value.isNotEmpty && index == 5) { + _oldCodeFocusNodes[index].unfocus(); + _verifyOldPhone(); + } + } + + void _onNewCodeChanged(int index, String value) { + if (_errorMessage != null) { + setState(() => _errorMessage = null); + } + if (value.isNotEmpty && index < 5) { + _newCodeFocusNodes[index + 1].requestFocus(); + } else if (value.isNotEmpty && index == 5) { + _newCodeFocusNodes[index].unfocus(); + _confirmChangePhone(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios, color: Color(0xFF333333)), + onPressed: () => context.pop(), + ), + title: Text( + '更换手机号', + style: TextStyle( + fontSize: 18.sp, + fontWeight: FontWeight.w600, + color: const Color(0xFF333333), + ), + ), + centerTitle: true, + ), + body: SafeArea( + child: _buildContent(), + ), + ); + } + + Widget _buildContent() { + switch (_currentStep) { + case ChangePhoneStep.verifyOld: + return _buildVerifyOldStep(); + case ChangePhoneStep.inputNew: + return _buildInputNewStep(); + case ChangePhoneStep.verifyNew: + return _buildVerifyNewStep(); + case ChangePhoneStep.success: + return _buildSuccessStep(); + } + } + + /// 步骤1: 验证旧手机 + Widget _buildVerifyOldStep() { + return SingleChildScrollView( + padding: EdgeInsets.symmetric(horizontal: 24.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: 32.h), + _buildStepIndicator(1), + SizedBox(height: 24.h), + Text( + '验证原手机号', + style: TextStyle( + fontSize: 24.sp, + fontWeight: FontWeight.w700, + color: const Color(0xFF333333), + ), + ), + SizedBox(height: 8.h), + Text( + '为确保账户安全,请先验证当前绑定的手机号', + style: TextStyle(fontSize: 14.sp, color: const Color(0xFF999999)), + ), + if (_oldPhoneNumber != null) ...[ + SizedBox(height: 8.h), + Text( + '当前手机号: $_oldPhoneNumber', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w500, + color: const Color(0xFF2E7D32), + ), + ), + ], + SizedBox(height: 32.h), + _buildCodeInputs(_oldCodeControllers, _oldCodeFocusNodes, _onOldCodeChanged), + if (_errorMessage != null) ...[ + SizedBox(height: 16.h), + Text(_errorMessage!, style: TextStyle(fontSize: 14.sp, color: Colors.red)), + ], + SizedBox(height: 24.h), + _buildSendCodeButton(_sendOldPhoneCode), + SizedBox(height: 40.h), + _buildNextButton( + onPressed: _isLoading ? null : _verifyOldPhone, + text: '下一步', + isLoading: _isLoading, + ), + ], + ), + ); + } + + /// 步骤2: 输入新手机号 + Widget _buildInputNewStep() { + return SingleChildScrollView( + padding: EdgeInsets.symmetric(horizontal: 24.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: 32.h), + _buildStepIndicator(2), + SizedBox(height: 24.h), + Text( + '输入新手机号', + style: TextStyle( + fontSize: 24.sp, + fontWeight: FontWeight.w700, + color: const Color(0xFF333333), + ), + ), + SizedBox(height: 8.h), + Text( + '请输入您要绑定的新手机号', + style: TextStyle(fontSize: 14.sp, color: const Color(0xFF999999)), + ), + SizedBox(height: 32.h), + TextFormField( + controller: _newPhoneController, + keyboardType: TextInputType.phone, + maxLength: 11, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: InputDecoration( + hintText: '请输入手机号', + counterText: '', + prefixIcon: const Icon(Icons.phone_android, color: Color(0xFF999999)), + filled: true, + fillColor: const Color(0xFFF5F5F5), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + borderSide: const BorderSide(color: Color(0xFF2E7D32), width: 2), + ), + ), + style: TextStyle(fontSize: 16.sp, color: const Color(0xFF333333)), + ), + if (_errorMessage != null) ...[ + SizedBox(height: 16.h), + Text(_errorMessage!, style: TextStyle(fontSize: 14.sp, color: Colors.red)), + ], + SizedBox(height: 40.h), + _buildNextButton( + onPressed: _isSendingCode ? null : _sendNewPhoneCode, + text: '获取验证码', + isLoading: _isSendingCode, + ), + ], + ), + ); + } + + /// 步骤3: 验证新手机 + Widget _buildVerifyNewStep() { + return SingleChildScrollView( + padding: EdgeInsets.symmetric(horizontal: 24.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: 32.h), + _buildStepIndicator(3), + SizedBox(height: 24.h), + Text( + '验证新手机号', + style: TextStyle( + fontSize: 24.sp, + fontWeight: FontWeight.w700, + color: const Color(0xFF333333), + ), + ), + SizedBox(height: 8.h), + Text( + '验证码已发送至 ${_formatPhone(_newPhoneController.text)}', + style: TextStyle(fontSize: 14.sp, color: const Color(0xFF999999)), + ), + SizedBox(height: 32.h), + _buildCodeInputs(_newCodeControllers, _newCodeFocusNodes, _onNewCodeChanged), + if (_errorMessage != null) ...[ + SizedBox(height: 16.h), + Text(_errorMessage!, style: TextStyle(fontSize: 14.sp, color: Colors.red)), + ], + SizedBox(height: 24.h), + _buildSendCodeButton(() async { + final kycService = ref.read(kycServiceProvider); + await kycService.sendNewPhoneCode( + newPhoneNumber: _newPhoneController.text.trim(), + changePhoneToken: _changePhoneToken!, + ); + _startCountdown(); + }), + SizedBox(height: 40.h), + _buildNextButton( + onPressed: _isLoading ? null : _confirmChangePhone, + text: '确认更换', + isLoading: _isLoading, + ), + ], + ), + ); + } + + /// 步骤4: 更换成功 + Widget _buildSuccessStep() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.check_circle, size: 80.sp, color: const Color(0xFF2E7D32)), + SizedBox(height: 24.h), + Text( + '手机号更换成功', + style: TextStyle( + fontSize: 24.sp, + fontWeight: FontWeight.w700, + color: const Color(0xFF333333), + ), + ), + SizedBox(height: 8.h), + Text( + '新手机号: ${_formatPhone(_newPhoneController.text)}', + style: TextStyle(fontSize: 16.sp, color: const Color(0xFF666666)), + ), + SizedBox(height: 40.h), + Padding( + padding: EdgeInsets.symmetric(horizontal: 24.w), + child: _buildNextButton( + onPressed: () => context.pop(), + text: '完成', + ), + ), + ], + ), + ); + } + + Widget _buildStepIndicator(int currentStep) { + return Row( + children: List.generate(3, (index) { + final step = index + 1; + final isActive = step <= currentStep; + return Expanded( + child: Row( + children: [ + Container( + width: 24.w, + height: 24.w, + decoration: BoxDecoration( + color: isActive ? const Color(0xFF2E7D32) : const Color(0xFFE0E0E0), + shape: BoxShape.circle, + ), + child: Center( + child: Text( + '$step', + style: TextStyle( + fontSize: 12.sp, + fontWeight: FontWeight.w600, + color: isActive ? Colors.white : const Color(0xFF999999), + ), + ), + ), + ), + if (index < 2) + Expanded( + child: Container( + height: 2, + color: step < currentStep ? const Color(0xFF2E7D32) : const Color(0xFFE0E0E0), + ), + ), + ], + ), + ); + }), + ); + } + + Widget _buildCodeInputs( + List controllers, + List focusNodes, + void Function(int, String) onChanged, + ) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: List.generate(6, (index) { + return SizedBox( + width: 48.w, + height: 56.h, + child: TextField( + controller: controllers[index], + focusNode: focusNodes[index], + keyboardType: TextInputType.number, + textAlign: TextAlign.center, + maxLength: 1, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: InputDecoration( + counterText: '', + filled: true, + fillColor: const Color(0xFFF5F5F5), + contentPadding: EdgeInsets.zero, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + borderSide: const BorderSide(color: Color(0xFF2E7D32), width: 2), + ), + ), + style: TextStyle( + fontSize: 24.sp, + fontWeight: FontWeight.w700, + color: const Color(0xFF333333), + ), + onChanged: (value) => onChanged(index, value), + ), + ); + }), + ); + } + + Widget _buildSendCodeButton(Future Function() onSend) { + return Center( + child: GestureDetector( + onTap: _countdown > 0 ? null : () async { + try { + await onSend(); + } catch (e) { + if (mounted) { + setState(() { + _errorMessage = e.toString().replaceAll('Exception: ', ''); + }); + } + } + }, + child: Text( + _countdown > 0 ? '${_countdown}秒后重新发送' : '发送验证码', + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w500, + color: _countdown > 0 ? const Color(0xFF999999) : const Color(0xFF2E7D32), + ), + ), + ), + ); + } + + Widget _buildNextButton({ + required VoidCallback? onPressed, + required String text, + bool isLoading = false, + }) { + return GestureDetector( + onTap: onPressed, + child: Container( + width: double.infinity, + padding: EdgeInsets.symmetric(vertical: 16.h), + decoration: BoxDecoration( + color: onPressed == null ? const Color(0xFFCCCCCC) : const Color(0xFF2E7D32), + borderRadius: BorderRadius.circular(12.r), + ), + child: isLoading + ? Center( + child: SizedBox( + width: 24.sp, + height: 24.sp, + child: const CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ), + ) + : Text( + text, + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + textAlign: TextAlign.center, + ), + ), + ); + } + + String _formatPhone(String phone) { + if (phone.length != 11) return phone; + return '${phone.substring(0, 3)} **** ${phone.substring(7)}'; + } +} diff --git a/frontend/mobile-app/lib/features/kyc/presentation/pages/kyc_entry_page.dart b/frontend/mobile-app/lib/features/kyc/presentation/pages/kyc_entry_page.dart new file mode 100644 index 00000000..9f2afe73 --- /dev/null +++ b/frontend/mobile-app/lib/features/kyc/presentation/pages/kyc_entry_page.dart @@ -0,0 +1,416 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:go_router/go_router.dart'; +import '../../../../core/di/injection_container.dart'; +import '../../../../routes/route_paths.dart'; +import '../../data/kyc_service.dart'; + +/// KYC Provider +final kycServiceProvider = Provider((ref) { + final apiClient = ref.read(apiClientProvider); + return KycService(apiClient); +}); + +/// KYC 状态 Provider +final kycStatusProvider = FutureProvider.autoDispose((ref) async { + final kycService = ref.read(kycServiceProvider); + return kycService.getKycStatus(); +}); + +/// KYC 入口页面 +class KycEntryPage extends ConsumerWidget { + const KycEntryPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final kycStatusAsync = ref.watch(kycStatusProvider); + + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios, color: Color(0xFF333333)), + onPressed: () => context.pop(), + ), + title: Text( + '实名认证', + style: TextStyle( + fontSize: 18.sp, + fontWeight: FontWeight.w600, + color: const Color(0xFF333333), + ), + ), + centerTitle: true, + ), + body: kycStatusAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, size: 48.sp, color: Colors.red), + SizedBox(height: 16.h), + Text('加载失败: $error', style: TextStyle(fontSize: 14.sp)), + SizedBox(height: 16.h), + ElevatedButton( + onPressed: () => ref.invalidate(kycStatusProvider), + child: const Text('重试'), + ), + ], + ), + ), + data: (status) => _buildContent(context, ref, status), + ), + ); + } + + Widget _buildContent(BuildContext context, WidgetRef ref, KycStatusResponse status) { + return SingleChildScrollView( + padding: EdgeInsets.all(16.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 状态卡片 + _buildStatusCard(status), + SizedBox(height: 24.h), + + // 步骤列表 + Text( + '认证步骤', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + color: const Color(0xFF333333), + ), + ), + SizedBox(height: 12.h), + + // 步骤1: 手机验证 + _buildStepCard( + context: context, + ref: ref, + stepNumber: 1, + title: '手机号验证', + description: status.phoneNumber ?? '验证您的手机号', + isCompleted: status.phoneVerified, + isEnabled: !status.phoneVerified, + onTap: () { + if (!status.phoneVerified) { + context.push(RoutePaths.kycPhone); + } + }, + ), + SizedBox(height: 12.h), + + // 步骤2: 身份证验证 + _buildStepCard( + context: context, + ref: ref, + stepNumber: 2, + title: '身份证验证', + description: status.realName ?? '验证您的真实身份', + isCompleted: status.statusType == KycStatusType.idVerified || + status.statusType == KycStatusType.completed, + isEnabled: status.phoneVerified && status.needsIdVerification, + isRejected: status.statusType == KycStatusType.rejected, + rejectedReason: status.rejectedReason, + onTap: () { + if (status.phoneVerified && status.needsIdVerification) { + context.push(RoutePaths.kycId); + } + }, + ), + + SizedBox(height: 24.h), + + // 更换手机号入口 + Text( + '其他操作', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + color: const Color(0xFF333333), + ), + ), + SizedBox(height: 12.h), + _buildActionCard( + context: context, + icon: Icons.phone_android, + title: '更换手机号', + description: status.phoneNumber ?? '更换绑定的手机号', + onTap: () => context.push(RoutePaths.changePhone), + ), + + SizedBox(height: 24.h), + + // 说明文字 + Container( + padding: EdgeInsets.all(16.w), + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(8.r), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.info_outline, size: 16.sp, color: const Color(0xFF666666)), + SizedBox(width: 8.w), + Text( + '认证说明', + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w500, + color: const Color(0xFF333333), + ), + ), + ], + ), + SizedBox(height: 8.h), + Text( + '• 请确保填写的信息与身份证一致\n' + '• 实名认证信息将用于合同签署和收益结算\n' + '• 您的信息将被加密存储,不会泄露给第三方', + style: TextStyle( + fontSize: 12.sp, + color: const Color(0xFF666666), + height: 1.5, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildStatusCard(KycStatusResponse status) { + Color backgroundColor; + Color textColor; + IconData icon; + String statusText; + + if (status.isCompleted) { + backgroundColor = const Color(0xFFE8F5E9); + textColor = const Color(0xFF2E7D32); + icon = Icons.check_circle; + statusText = '认证完成'; + } else if (status.statusType == KycStatusType.rejected) { + backgroundColor = const Color(0xFFFFEBEE); + textColor = const Color(0xFFC62828); + icon = Icons.cancel; + statusText = '认证被拒绝'; + } else { + backgroundColor = const Color(0xFFFFF3E0); + textColor = const Color(0xFFE65100); + icon = Icons.pending; + statusText = '待完成认证'; + } + + return Container( + padding: EdgeInsets.all(16.w), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(12.r), + ), + child: Row( + children: [ + Icon(icon, size: 32.sp, color: textColor), + SizedBox(width: 12.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + statusText, + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + color: textColor, + ), + ), + if (status.kycVerifiedAt != null) + Text( + '完成时间: ${_formatDate(status.kycVerifiedAt!)}', + style: TextStyle( + fontSize: 12.sp, + color: textColor.withValues(alpha: 0.8), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildStepCard({ + required BuildContext context, + required WidgetRef ref, + required int stepNumber, + required String title, + required String description, + required bool isCompleted, + required bool isEnabled, + bool isRejected = false, + String? rejectedReason, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: isEnabled ? onTap : null, + child: Container( + padding: EdgeInsets.all(16.w), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12.r), + border: Border.all( + color: isCompleted + ? const Color(0xFF2E7D32) + : isRejected + ? Colors.red + : const Color(0xFFE0E0E0), + ), + ), + child: Row( + children: [ + // 步骤圆圈 + Container( + width: 32.w, + height: 32.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isCompleted + ? const Color(0xFF2E7D32) + : isRejected + ? Colors.red + : const Color(0xFFE0E0E0), + ), + child: Center( + child: isCompleted + ? Icon(Icons.check, size: 18.sp, color: Colors.white) + : isRejected + ? Icon(Icons.close, size: 18.sp, color: Colors.white) + : Text( + '$stepNumber', + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ), + SizedBox(width: 12.w), + + // 内容 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w500, + color: const Color(0xFF333333), + ), + ), + SizedBox(height: 2.h), + Text( + isRejected && rejectedReason != null + ? rejectedReason + : description, + style: TextStyle( + fontSize: 12.sp, + color: isRejected ? Colors.red : const Color(0xFF999999), + ), + ), + ], + ), + ), + + // 箭头 + if (isEnabled) + Icon( + Icons.chevron_right, + size: 24.sp, + color: const Color(0xFF999999), + ), + ], + ), + ), + ); + } + + Widget _buildActionCard({ + required BuildContext context, + required IconData icon, + required String title, + required String description, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: EdgeInsets.all(16.w), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12.r), + border: Border.all(color: const Color(0xFFE0E0E0)), + ), + child: Row( + children: [ + Container( + width: 40.w, + height: 40.w, + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(8.r), + ), + child: Icon(icon, size: 24.sp, color: const Color(0xFF666666)), + ), + SizedBox(width: 12.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w500, + color: const Color(0xFF333333), + ), + ), + SizedBox(height: 2.h), + Text( + description, + style: TextStyle( + fontSize: 12.sp, + color: const Color(0xFF999999), + ), + ), + ], + ), + ), + Icon( + Icons.chevron_right, + size: 24.sp, + color: const Color(0xFF999999), + ), + ], + ), + ), + ); + } + + String _formatDate(DateTime date) { + return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + } +} diff --git a/frontend/mobile-app/lib/features/kyc/presentation/pages/kyc_id_page.dart b/frontend/mobile-app/lib/features/kyc/presentation/pages/kyc_id_page.dart new file mode 100644 index 00000000..5b3f23a7 --- /dev/null +++ b/frontend/mobile-app/lib/features/kyc/presentation/pages/kyc_id_page.dart @@ -0,0 +1,339 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:go_router/go_router.dart'; +import 'kyc_entry_page.dart'; + +/// KYC 身份证验证页面 +class KycIdPage extends ConsumerStatefulWidget { + const KycIdPage({super.key}); + + @override + ConsumerState createState() => _KycIdPageState(); +} + +class _KycIdPageState extends ConsumerState { + final _formKey = GlobalKey(); + final _nameController = TextEditingController(); + final _idCardController = TextEditingController(); + + bool _isSubmitting = false; + String? _errorMessage; + + @override + void dispose() { + _nameController.dispose(); + _idCardController.dispose(); + super.dispose(); + } + + String? _validateName(String? value) { + if (value == null || value.isEmpty) { + return '请输入真实姓名'; + } + if (value.length < 2) { + return '姓名至少2个字符'; + } + // 只允许中文和英文字母 + if (!RegExp(r'^[\u4e00-\u9fa5a-zA-Z·]+$').hasMatch(value)) { + return '姓名格式不正确'; + } + return null; + } + + String? _validateIdCard(String? value) { + if (value == null || value.isEmpty) { + return '请输入身份证号'; + } + // 18位身份证号验证 + final regex = RegExp( + r'^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$', + ); + if (!regex.hasMatch(value)) { + return '身份证号格式不正确'; + } + return null; + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) { + return; + } + + setState(() { + _isSubmitting = true; + _errorMessage = null; + }); + + try { + final kycService = ref.read(kycServiceProvider); + final result = await kycService.submitIdVerification( + realName: _nameController.text.trim(), + idCardNumber: _idCardController.text.trim().toUpperCase(), + ); + + if (!mounted) return; + + if (result.isSuccess) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('身份验证成功', style: TextStyle(fontSize: 14.sp)), + backgroundColor: const Color(0xFF2E7D32), + behavior: SnackBarBehavior.floating, + ), + ); + // 返回并刷新 + context.pop(true); + } else { + setState(() { + _errorMessage = result.failureReason ?? '验证失败,请检查信息是否正确'; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _errorMessage = e.toString().replaceAll('Exception: ', ''); + }); + } + } finally { + if (mounted) { + setState(() { + _isSubmitting = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios, color: Color(0xFF333333)), + onPressed: () => context.pop(), + ), + title: Text( + '身份证验证', + style: TextStyle( + fontSize: 18.sp, + fontWeight: FontWeight.w600, + color: const Color(0xFF333333), + ), + ), + centerTitle: true, + ), + body: GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: SafeArea( + child: SingleChildScrollView( + padding: EdgeInsets.symmetric(horizontal: 24.w), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: 32.h), + Text( + '实名认证', + style: TextStyle( + fontSize: 24.sp, + fontWeight: FontWeight.w700, + color: const Color(0xFF333333), + ), + ), + SizedBox(height: 8.h), + Text( + '请填写与身份证一致的信息', + style: TextStyle( + fontSize: 14.sp, + color: const Color(0xFF999999), + ), + ), + SizedBox(height: 32.h), + + // 姓名输入 + _buildInputField( + label: '真实姓名', + hint: '请输入身份证上的姓名', + controller: _nameController, + validator: _validateName, + keyboardType: TextInputType.text, + ), + SizedBox(height: 16.h), + + // 身份证号输入 + _buildInputField( + label: '身份证号', + hint: '请输入18位身份证号', + controller: _idCardController, + validator: _validateIdCard, + keyboardType: TextInputType.text, + maxLength: 18, + ), + + // 错误信息 + if (_errorMessage != null) ...[ + SizedBox(height: 16.h), + Container( + padding: EdgeInsets.all(12.w), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8.r), + ), + child: Row( + children: [ + Icon(Icons.error_outline, color: Colors.red, size: 20.sp), + SizedBox(width: 8.w), + Expanded( + child: Text( + _errorMessage!, + style: TextStyle(fontSize: 14.sp, color: Colors.red), + ), + ), + ], + ), + ), + ], + + SizedBox(height: 32.h), + + // 提示信息 + Container( + padding: EdgeInsets.all(12.w), + decoration: BoxDecoration( + color: const Color(0xFFFFF3E0), + borderRadius: BorderRadius.circular(8.r), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.security, + size: 18.sp, + color: const Color(0xFFE65100), + ), + SizedBox(width: 8.w), + Expanded( + child: Text( + '您的身份信息将被加密存储,仅用于实名认证和合同签署,不会泄露给任何第三方。', + style: TextStyle( + fontSize: 12.sp, + color: const Color(0xFFE65100), + height: 1.5, + ), + ), + ), + ], + ), + ), + + SizedBox(height: 40.h), + + // 提交按钮 + GestureDetector( + onTap: _isSubmitting ? null : _submit, + child: Container( + width: double.infinity, + padding: EdgeInsets.symmetric(vertical: 16.h), + decoration: BoxDecoration( + color: _isSubmitting + ? const Color(0xFFCCCCCC) + : const Color(0xFF2E7D32), + borderRadius: BorderRadius.circular(12.r), + ), + child: _isSubmitting + ? Center( + child: SizedBox( + width: 24.sp, + height: 24.sp, + child: const CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ), + ) + : Text( + '提交验证', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + textAlign: TextAlign.center, + ), + ), + ), + SizedBox(height: 32.h), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _buildInputField({ + required String label, + required String hint, + required TextEditingController controller, + required String? Function(String?) validator, + TextInputType keyboardType = TextInputType.text, + int? maxLength, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w500, + color: const Color(0xFF333333), + ), + ), + SizedBox(height: 8.h), + TextFormField( + controller: controller, + validator: validator, + keyboardType: keyboardType, + maxLength: maxLength, + decoration: InputDecoration( + hintText: hint, + hintStyle: TextStyle( + fontSize: 16.sp, + color: const Color(0xFF999999), + ), + counterText: '', + filled: true, + fillColor: const Color(0xFFF5F5F5), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + borderSide: const BorderSide(color: Color(0xFF2E7D32), width: 2), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + borderSide: const BorderSide(color: Colors.red, width: 1), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + borderSide: const BorderSide(color: Colors.red, width: 2), + ), + contentPadding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 16.h), + ), + style: TextStyle( + fontSize: 16.sp, + color: const Color(0xFF333333), + ), + ), + ], + ); + } +} diff --git a/frontend/mobile-app/lib/features/kyc/presentation/pages/kyc_phone_page.dart b/frontend/mobile-app/lib/features/kyc/presentation/pages/kyc_phone_page.dart new file mode 100644 index 00000000..92eff4b0 --- /dev/null +++ b/frontend/mobile-app/lib/features/kyc/presentation/pages/kyc_phone_page.dart @@ -0,0 +1,339 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:go_router/go_router.dart'; +import 'kyc_entry_page.dart'; + +/// KYC 手机验证页面 +class KycPhonePage extends ConsumerStatefulWidget { + const KycPhonePage({super.key}); + + @override + ConsumerState createState() => _KycPhonePageState(); +} + +class _KycPhonePageState extends ConsumerState { + final List _controllers = List.generate( + 6, + (_) => TextEditingController(), + ); + final List _focusNodes = List.generate(6, (_) => FocusNode()); + + bool _isVerifying = false; + bool _isSending = false; + String? _errorMessage; + + // 倒计时 + int _countdown = 0; + Timer? _countdownTimer; + + @override + void initState() { + super.initState(); + // 页面打开时自动发送验证码 + WidgetsBinding.instance.addPostFrameCallback((_) { + _sendCode(); + _focusNodes[0].requestFocus(); + }); + } + + @override + void dispose() { + _countdownTimer?.cancel(); + for (final controller in _controllers) { + controller.dispose(); + } + for (final focusNode in _focusNodes) { + focusNode.dispose(); + } + super.dispose(); + } + + void _startCountdown() { + _countdown = 60; + _countdownTimer?.cancel(); + _countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (_countdown > 0) { + setState(() { + _countdown--; + }); + } else { + timer.cancel(); + } + }); + } + + String _getVerificationCode() { + return _controllers.map((c) => c.text).join(); + } + + Future _sendCode() async { + if (_isSending || _countdown > 0) return; + + setState(() { + _isSending = true; + _errorMessage = null; + }); + + try { + final kycService = ref.read(kycServiceProvider); + await kycService.sendKycVerifySms(); + + if (mounted) { + _startCountdown(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('验证码已发送', style: TextStyle(fontSize: 14.sp)), + backgroundColor: const Color(0xFF2E7D32), + behavior: SnackBarBehavior.floating, + ), + ); + } + } catch (e) { + if (mounted) { + setState(() { + _errorMessage = e.toString().replaceAll('Exception: ', ''); + }); + } + } finally { + if (mounted) { + setState(() { + _isSending = false; + }); + } + } + } + + Future _verifyCode() async { + final code = _getVerificationCode(); + if (code.length != 6) { + setState(() { + _errorMessage = '请输入完整的验证码'; + }); + return; + } + + setState(() { + _isVerifying = true; + _errorMessage = null; + }); + + try { + final kycService = ref.read(kycServiceProvider); + await kycService.verifyPhoneForKyc(code); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('手机号验证成功', style: TextStyle(fontSize: 14.sp)), + backgroundColor: const Color(0xFF2E7D32), + behavior: SnackBarBehavior.floating, + ), + ); + // 返回并刷新 + context.pop(true); + } + } catch (e) { + if (mounted) { + setState(() { + _errorMessage = e.toString().replaceAll('Exception: ', ''); + }); + // 清空验证码 + for (final controller in _controllers) { + controller.clear(); + } + _focusNodes[0].requestFocus(); + } + } finally { + if (mounted) { + setState(() { + _isVerifying = false; + }); + } + } + } + + void _onCodeChanged(int index, String value) { + if (_errorMessage != null) { + setState(() { + _errorMessage = null; + }); + } + + if (value.isNotEmpty) { + if (index < 5) { + _focusNodes[index + 1].requestFocus(); + } else { + _focusNodes[index].unfocus(); + _verifyCode(); + } + } + } + + void _onKeyEvent(int index, KeyEvent event) { + if (event is KeyDownEvent) { + if (event.logicalKey == LogicalKeyboardKey.backspace) { + if (_controllers[index].text.isEmpty && index > 0) { + _controllers[index - 1].clear(); + _focusNodes[index - 1].requestFocus(); + } + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios, color: Color(0xFF333333)), + onPressed: () => context.pop(), + ), + title: Text( + '手机号验证', + style: TextStyle( + fontSize: 18.sp, + fontWeight: FontWeight.w600, + color: const Color(0xFF333333), + ), + ), + centerTitle: true, + ), + body: GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: SafeArea( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 24.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: 32.h), + Text( + '输入验证码', + style: TextStyle( + fontSize: 24.sp, + fontWeight: FontWeight.w700, + color: const Color(0xFF333333), + ), + ), + SizedBox(height: 8.h), + Text( + '验证码已发送至您绑定的手机号', + style: TextStyle( + fontSize: 14.sp, + color: const Color(0xFF999999), + ), + ), + SizedBox(height: 32.h), + _buildCodeInputs(), + if (_errorMessage != null) ...[ + SizedBox(height: 16.h), + Text( + _errorMessage!, + style: TextStyle( + fontSize: 14.sp, + color: Colors.red, + ), + ), + ], + SizedBox(height: 24.h), + _buildResendButton(), + const Spacer(), + if (_isVerifying) + Center( + child: Column( + children: [ + SizedBox( + width: 32.sp, + height: 32.sp, + child: const CircularProgressIndicator( + strokeWidth: 3, + valueColor: AlwaysStoppedAnimation(Color(0xFF2E7D32)), + ), + ), + SizedBox(height: 12.h), + Text( + '正在验证...', + style: TextStyle( + fontSize: 14.sp, + color: const Color(0xFF666666), + ), + ), + ], + ), + ), + SizedBox(height: 32.h), + ], + ), + ), + ), + ), + ); + } + + Widget _buildCodeInputs() { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: List.generate(6, (index) { + return SizedBox( + width: 50.w, + height: 60.h, + child: KeyboardListener( + focusNode: FocusNode(), + onKeyEvent: (event) => _onKeyEvent(index, event), + child: TextField( + controller: _controllers[index], + focusNode: _focusNodes[index], + keyboardType: TextInputType.number, + textAlign: TextAlign.center, + maxLength: 1, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: InputDecoration( + counterText: '', + filled: true, + fillColor: const Color(0xFFF5F5F5), + contentPadding: EdgeInsets.zero, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + borderSide: const BorderSide(color: Color(0xFF2E7D32), width: 2), + ), + ), + style: TextStyle( + fontSize: 24.sp, + fontWeight: FontWeight.w700, + color: const Color(0xFF333333), + ), + onChanged: (value) => _onCodeChanged(index, value), + ), + ), + ); + }), + ); + } + + Widget _buildResendButton() { + final canResend = _countdown == 0 && !_isSending; + return Center( + child: GestureDetector( + onTap: canResend ? _sendCode : null, + child: Text( + canResend ? '重新发送验证码' : '${_countdown}秒后重新发送', + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w500, + color: canResend ? const Color(0xFF2E7D32) : const Color(0xFF999999), + ), + ), + ), + ); + } +} diff --git a/frontend/mobile-app/lib/features/profile/presentation/pages/profile_page.dart b/frontend/mobile-app/lib/features/profile/presentation/pages/profile_page.dart index a3b5261e..471f4577 100644 --- a/frontend/mobile-app/lib/features/profile/presentation/pages/profile_page.dart +++ b/frontend/mobile-app/lib/features/profile/presentation/pages/profile_page.dart @@ -23,6 +23,7 @@ import '../../../auth/presentation/providers/wallet_status_provider.dart'; import '../widgets/team_tree_widget.dart'; import '../widgets/stacked_cards_widget.dart'; import '../../../authorization/presentation/widgets/stickman_race_widget.dart'; +import '../../../kyc/data/kyc_service.dart'; /// 个人中心页面 - 显示用户信息、社区数据、收益和设置 /// 包含用户资料、推荐信息、社区考核、收益领取等功能 @@ -1181,6 +1182,11 @@ class _ProfilePageState extends ConsumerState { context.push(RoutePaths.bindEmail); } + /// 实名认证 + void _goToKyc() { + context.push(RoutePaths.kycEntry); + } + /// 自助申请授权 void _goToAuthorizationApply() { context.push(RoutePaths.authorizationApply); @@ -3929,6 +3935,11 @@ class _ProfilePageState extends ConsumerState { title: '绑定邮箱', onTap: _goToBindEmail, ), + _buildSettingItem( + icon: Icons.verified_user, + title: '实名认证', + onTap: _goToKyc, + ), _buildSettingItem( icon: Icons.verified, title: '自助申请授权', diff --git a/frontend/mobile-app/lib/routes/app_router.dart b/frontend/mobile-app/lib/routes/app_router.dart index 945743b6..702f0717 100644 --- a/frontend/mobile-app/lib/routes/app_router.dart +++ b/frontend/mobile-app/lib/routes/app_router.dart @@ -33,6 +33,10 @@ import '../features/withdraw/presentation/pages/withdraw_usdt_page.dart'; import '../features/withdraw/presentation/pages/withdraw_confirm_page.dart'; import '../features/notification/presentation/pages/notification_inbox_page.dart'; import '../features/account/presentation/pages/account_switch_page.dart'; +import '../features/kyc/presentation/pages/kyc_entry_page.dart'; +import '../features/kyc/presentation/pages/kyc_phone_page.dart'; +import '../features/kyc/presentation/pages/kyc_id_page.dart'; +import '../features/kyc/presentation/pages/change_phone_page.dart'; import 'route_paths.dart'; import 'route_names.dart'; @@ -191,6 +195,7 @@ final appRouterProvider = Provider((ref) { inviterReferralCode: params.inviterReferralCode, phoneNumber: params.phoneNumber, smsCode: params.smsCode, + skipVerify: params.skipVerify, ); }, ), @@ -355,6 +360,34 @@ final appRouterProvider = Provider((ref) { }, ), + // KYC Entry Page (实名认证入口) + GoRoute( + path: RoutePaths.kycEntry, + name: RouteNames.kycEntry, + builder: (context, state) => const KycEntryPage(), + ), + + // KYC Phone Verification Page (手机号验证) + GoRoute( + path: RoutePaths.kycPhone, + name: RouteNames.kycPhone, + builder: (context, state) => const KycPhonePage(), + ), + + // KYC ID Verification Page (身份证验证) + GoRoute( + path: RoutePaths.kycId, + name: RouteNames.kycId, + builder: (context, state) => const KycIdPage(), + ), + + // Change Phone Page (更换手机号) + GoRoute( + path: RoutePaths.changePhone, + name: RouteNames.changePhone, + builder: (context, state) => const ChangePhonePage(), + ), + // Main Shell with Bottom Navigation ShellRoute( navigatorKey: _shellNavigatorKey, diff --git a/frontend/mobile-app/lib/routes/route_names.dart b/frontend/mobile-app/lib/routes/route_names.dart index 8635ef93..12040da5 100644 --- a/frontend/mobile-app/lib/routes/route_names.dart +++ b/frontend/mobile-app/lib/routes/route_names.dart @@ -44,4 +44,10 @@ class RouteNames { // Share static const share = 'share'; + + // KYC (实名认证) + static const kycEntry = 'kyc-entry'; + static const kycPhone = 'kyc-phone'; + static const kycId = 'kyc-id'; + static const changePhone = 'change-phone'; } diff --git a/frontend/mobile-app/lib/routes/route_paths.dart b/frontend/mobile-app/lib/routes/route_paths.dart index ba3af4ef..633eecd0 100644 --- a/frontend/mobile-app/lib/routes/route_paths.dart +++ b/frontend/mobile-app/lib/routes/route_paths.dart @@ -44,4 +44,10 @@ class RoutePaths { // Share static const share = '/share'; + + // KYC (实名认证) + static const kycEntry = '/kyc'; + static const kycPhone = '/kyc/phone'; + static const kycId = '/kyc/id'; + static const changePhone = '/kyc/change-phone'; }