diff --git a/backend/services/identity-service/prisma/migrations/20241224100000_add_kyc_levels/migration.sql b/backend/services/identity-service/prisma/migrations/20241224100000_add_kyc_levels/migration.sql new file mode 100644 index 00000000..6ddb7cd8 --- /dev/null +++ b/backend/services/identity-service/prisma/migrations/20241224100000_add_kyc_levels/migration.sql @@ -0,0 +1,47 @@ +-- ============================================================================= +-- 添加三层认证状态字段 +-- 用于支持三层 KYC 认证流程: +-- 层级1: 实名认证 (二要素: 姓名+身份证号) +-- 层级2: 实人认证 (人脸活体检测) +-- 层级3: KYC (证件照上传验证) +-- ============================================================================= + +-- 层级1: 实名认证 (二要素: 姓名+身份证号) +-- 注意: real_name, id_card_number 字段已在 init migration 中创建 +ALTER TABLE "user_accounts" ADD COLUMN IF NOT EXISTS "real_name_verified" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "user_accounts" ADD COLUMN IF NOT EXISTS "real_name_verified_at" TIMESTAMP(3); + +-- 层级2: 实人认证 (人脸活体检测) +ALTER TABLE "user_accounts" ADD COLUMN IF NOT EXISTS "face_verified" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "user_accounts" ADD COLUMN IF NOT EXISTS "face_verified_at" TIMESTAMP(3); +ALTER TABLE "user_accounts" ADD COLUMN IF NOT EXISTS "face_certify_id" VARCHAR(100); + +-- 层级3: KYC (证件照上传验证) +-- 注意: id_card_front_url, id_card_back_url, kyc_verified_at 字段已在 init migration 中创建 +ALTER TABLE "user_accounts" ADD COLUMN IF NOT EXISTS "kyc_verified" BOOLEAN NOT NULL DEFAULT false; + +-- ============================================================================= +-- 创建 KYC 配置表 +-- 用于管理后台控制各认证步骤的开关 +-- ============================================================================= +CREATE TABLE IF NOT EXISTS "kyc_configs" ( + "id" SERIAL NOT NULL, + "config_key" VARCHAR(50) NOT NULL, + "config_value" VARCHAR(100) NOT NULL, + "description" VARCHAR(500), + "updated_by" VARCHAR(50), + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "kyc_configs_pkey" PRIMARY KEY ("id") +); + +-- 添加唯一索引 +CREATE UNIQUE INDEX IF NOT EXISTS "kyc_configs_config_key_key" ON "kyc_configs"("config_key"); + +-- 插入默认配置 (三层认证默认都开启) +INSERT INTO "kyc_configs" ("config_key", "config_value", "description", "updated_by") +VALUES + ('kyc.level1.enabled', 'true', '层级1: 实名认证(二要素)开关', 'system'), + ('kyc.level2.enabled', 'true', '层级2: 实人认证(人脸活体)开关', 'system'), + ('kyc.level3.enabled', 'true', '层级3: KYC(证件照)开关', 'system') +ON CONFLICT ("config_key") DO NOTHING; diff --git a/backend/services/identity-service/prisma/schema.prisma b/backend/services/identity-service/prisma/schema.prisma index cce000bb..eadf9495 100644 --- a/backend/services/identity-service/prisma/schema.prisma +++ b/backend/services/identity-service/prisma/schema.prisma @@ -24,18 +24,30 @@ model UserAccount { inviterSequence String? @map("inviter_sequence") @db.VarChar(12) // 推荐人序列号 referralCode String @unique @map("referral_code") @db.VarChar(10) - // 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) // 拒绝原因 + // ========== 三层认证状态 ========== + // 层级1: 实名认证 (二要素: 姓名+身份证号) + realNameVerified Boolean @default(false) @map("real_name_verified") + realNameVerifiedAt DateTime? @map("real_name_verified_at") + realName String? @map("real_name") @db.VarChar(100) // 真实姓名(加密存储) + idCardNumber String? @map("id_card_number") @db.VarChar(50) // 身份证号(加密存储) + + // 层级2: 实人认证 (人脸活体检测) + faceVerified Boolean @default(false) @map("face_verified") + faceVerifiedAt DateTime? @map("face_verified_at") + faceCertifyId String? @map("face_certify_id") @db.VarChar(100) // 阿里云认证ID + + // 层级3: KYC (证件照上传验证) + kycVerified Boolean @default(false) @map("kyc_verified") + kycVerifiedAt DateTime? @map("kyc_verified_at") + idCardFrontUrl String? @map("id_card_front_url") @db.VarChar(500) + idCardBackUrl String? @map("id_card_back_url") @db.VarChar(500) + + // 综合 KYC 状态 (兼容旧逻辑) + // NOT_STARTED, REAL_NAME_VERIFIED, FACE_VERIFIED, KYC_VERIFIED, COMPLETED, REJECTED + kycStatus String @default("NOT_STARTED") @map("kyc_status") @db.VarChar(20) + kycProvider String? @map("kyc_provider") @db.VarChar(50) // 服务提供商: ALIYUN + 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) @@ -399,7 +411,7 @@ model KycVerificationAttempt { id BigInt @id @default(autoincrement()) userId BigInt @map("user_id") - // 验证类型: PHONE (手机验证), ID_CARD (身份证验证) + // 验证类型: PHONE (手机验证), ID_CARD (身份证二要素), ID_PHOTO (证件照OCR), FACE (人脸活体) verificationType String @map("verification_type") @db.VarChar(20) // 第三方服务信息 @@ -423,3 +435,18 @@ model KycVerificationAttempt { @@index([createdAt], name: "idx_kyc_attempt_created") @@map("kyc_verification_attempts") } + +// ============================================ +// KYC 配置表 +// 用于管理后台控制各认证步骤的开关 +// ============================================ +model KycConfig { + id Int @id @default(autoincrement()) + configKey String @unique @map("config_key") @db.VarChar(50) + configValue String @map("config_value") @db.VarChar(100) + description String? @db.VarChar(500) + updatedBy String? @map("updated_by") @db.VarChar(50) + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("kyc_configs") +} diff --git a/backend/services/identity-service/src/api/controllers/kyc.controller.ts b/backend/services/identity-service/src/api/controllers/kyc.controller.ts index 64773538..378dbe09 100644 --- a/backend/services/identity-service/src/api/controllers/kyc.controller.ts +++ b/backend/services/identity-service/src/api/controllers/kyc.controller.ts @@ -2,14 +2,22 @@ import { Controller, Post, Get, + Put, Body, + Param, + Query, UseGuards, + UseInterceptors, + UploadedFile, } from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, + ApiConsumes, + ApiBody, } from '@nestjs/swagger'; import { KycApplicationService } from '@/application/services/kyc-application.service'; import { @@ -17,73 +25,238 @@ import { CurrentUser, CurrentUserData, } from '@/shared/guards/jwt-auth.guard'; -import { - GetKycStatusDto, - VerifyPhoneForKycDto, - SubmitIdVerificationDto, - KycStatusResponseDto, - IdVerificationResponseDto, -} from '@/api/dto/kyc'; -@ApiTags('KYC') +// ========== DTO 定义 ========== + +class SubmitRealNameDto { + realName: string; + idCardNumber: string; +} + +class InitFaceVerifyDto { + metaInfo?: string; // 客户端设备信息(阿里云 SDK 需要) +} + +class QueryFaceVerifyDto { + certifyId: string; +} + +class UpdateKycConfigDto { + level1Enabled?: boolean; // 实名认证开关 + level2Enabled?: boolean; // 实人认证开关 + level3Enabled?: boolean; // KYC证件照开关 +} + +// ========== 用户端 KYC 控制器 ========== + +@ApiTags('KYC - 用户认证') @Controller('user/kyc') @UseGuards(JwtAuthGuard) +@ApiBearerAuth() export class KycController { constructor(private readonly kycService: KycApplicationService) {} + /** + * 获取 KYC 状态(包含三层认证详情) + */ @Get('status') - @ApiBearerAuth() @ApiOperation({ summary: '获取 KYC 状态', - description: '查询当前用户的 KYC 认证状态和信息', + description: '查询当前用户的三层认证状态和配置信息', }) - @ApiResponse({ status: 200, type: KycStatusResponseDto }) async getKycStatus(@CurrentUser() user: CurrentUserData) { - return this.kycService.getKycStatus(user.userId); + const result = await this.kycService.getKycStatus(user.userId); + return { + code: 'OK', + message: 'success', + data: result, + }; } - @Post('verify-phone') - @ApiBearerAuth() + /** + * 获取 KYC 配置(哪些层级开启) + */ + @Get('config') @ApiOperation({ - summary: '完成手机号验证', - description: '用于注册时跳过验证的用户完成手机号验证', + summary: '获取 KYC 配置', + 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: '手机号验证成功' }; + async getKycConfig() { + const result = await this.kycService.getKycConfig(); + return { + code: 'OK', + message: 'success', + data: result, + }; } - @Post('submit-id') - @ApiBearerAuth() + // ========== 层级1: 实名认证 ========== + + @Post('level1/submit') @ApiOperation({ - summary: '提交身份证验证', - description: '提交真实姓名和身份证号进行实名认证(二要素验证)', + summary: '层级1: 提交实名认证', + description: '提交姓名和身份证号进行二要素验证', }) - @ApiResponse({ status: 200, type: IdVerificationResponseDto }) - async submitIdVerification( + async submitRealName( @CurrentUser() user: CurrentUserData, - @Body() dto: SubmitIdVerificationDto, + @Body() dto: SubmitRealNameDto, ) { - return this.kycService.submitIdVerification( + const result = await this.kycService.submitRealNameVerification( user.userId, dto.realName, dto.idCardNumber, ); + return { + code: result.success ? 'OK' : 'FAILED', + message: result.success ? '实名认证成功' : result.errorMessage, + data: result, + }; } - @Post('send-verify-sms') - @ApiBearerAuth() + // ========== 层级2: 实人认证 ========== + + @Post('level2/init') @ApiOperation({ - summary: '发送 KYC 手机验证码', - description: '向用户绑定的手机号发送验证码,用于 KYC 手机验证', + summary: '层级2: 初始化实人认证', + description: '初始化人脸活体检测,返回 certifyId 供客户端 SDK 使用', }) - @ApiResponse({ status: 200, description: '验证码已发送' }) - async sendKycVerifySms(@CurrentUser() user: CurrentUserData) { - await this.kycService.sendKycVerifySms(user.userId); - return { success: true, message: '验证码已发送' }; + async initFaceVerify( + @CurrentUser() user: CurrentUserData, + @Body() dto: InitFaceVerifyDto, + ) { + const result = await this.kycService.initFaceVerification( + user.userId, + dto.metaInfo, + ); + return { + code: 'OK', + message: 'success', + data: result, + }; + } + + @Get('level2/query') + @ApiOperation({ + summary: '层级2: 查询实人认证结果', + description: '查询人脸活体检测结果', + }) + async queryFaceVerify( + @CurrentUser() user: CurrentUserData, + @Query('certifyId') certifyId: string, + ) { + const result = await this.kycService.queryFaceVerification( + user.userId, + certifyId, + ); + return { + code: result.passed ? 'OK' : 'PENDING', + message: result.passed ? '实人认证通过' : '认证结果待确认', + data: result, + }; + } + + // ========== 层级3: KYC 证件照 ========== + + @Post('level3/upload/:side') + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ + summary: '层级3: 上传证件照', + description: '上传身份证正面(front)或背面(back)照片', + }) + @ApiBody({ + schema: { + type: 'object', + properties: { + file: { + type: 'string', + format: 'binary', + }, + }, + }, + }) + async uploadIdCard( + @CurrentUser() user: CurrentUserData, + @Param('side') side: 'front' | 'back', + @UploadedFile() file: Express.Multer.File, + ) { + if (!['front', 'back'].includes(side)) { + return { + code: 'INVALID_PARAM', + message: 'side 参数必须是 front 或 back', + }; + } + + const result = await this.kycService.uploadIdCardPhoto( + user.userId, + side, + file, + ); + return { + code: 'OK', + message: '上传成功', + data: result, + }; + } + + @Post('level3/confirm') + @ApiOperation({ + summary: '层级3: 确认提交 KYC', + description: '确认完成证件照上传,提交 KYC 认证', + }) + async confirmKycSubmission(@CurrentUser() user: CurrentUserData) { + const result = await this.kycService.confirmKycSubmission(user.userId); + return { + code: 'OK', + message: 'KYC认证完成', + data: result, + }; + } +} + +// ========== 管理端 KYC 配置控制器 ========== + +@ApiTags('KYC - 管理配置') +@Controller('admin/kyc') +export class AdminKycController { + constructor(private readonly kycService: KycApplicationService) {} + + /** + * 获取 KYC 配置 + */ + @Get('config') + @ApiOperation({ + summary: '获取 KYC 配置', + description: '获取三层认证的开关状态', + }) + async getKycConfig() { + const result = await this.kycService.getKycConfig(); + return { + code: 'OK', + message: 'success', + data: result, + }; + } + + /** + * 更新 KYC 配置 + */ + @Put('config') + @ApiOperation({ + summary: '更新 KYC 配置', + description: '更新三层认证的开关状态', + }) + async updateKycConfig(@Body() dto: UpdateKycConfigDto) { + const result = await this.kycService.updateKycConfig( + dto.level1Enabled, + dto.level2Enabled, + dto.level3Enabled, + 'admin', + ); + return { + code: 'OK', + message: '配置已更新', + data: result, + }; } } diff --git a/backend/services/identity-service/src/app.module.ts b/backend/services/identity-service/src/app.module.ts index 420c297b..2195742e 100644 --- a/backend/services/identity-service/src/app.module.ts +++ b/backend/services/identity-service/src/app.module.ts @@ -23,7 +23,7 @@ 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'; +import { KycController, AdminKycController } from '@/api/controllers/kyc.controller'; // Application Services import { UserApplicationService } from '@/application/services/user-application.service'; @@ -155,6 +155,7 @@ export class ApplicationModule {} TotpController, InternalController, KycController, + AdminKycController, ], providers: [UserAccountRepositoryImpl], }) 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 index b18766f1..c1aa2847 100644 --- a/backend/services/identity-service/src/application/services/kyc-application.service.ts +++ b/backend/services/identity-service/src/application/services/kyc-application.service.ts @@ -6,20 +6,27 @@ import { import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service'; import { RedisService } from '@/infrastructure/redis/redis.service'; import { SmsService } from '@/infrastructure/external/sms/sms.service'; +import { StorageService } from '@/infrastructure/external/storage/storage.service'; import { AliyunKycProvider } from '@/infrastructure/external/kyc/aliyun-kyc.provider'; import { ApplicationError } from '@/shared/exceptions/domain.exception'; -import { UserId } from '@/domain/value-objects'; -// KYC 状态枚举 +// KYC 综合状态枚举 export enum KycStatus { - NOT_STARTED = 'NOT_STARTED', - PHONE_VERIFIED = 'PHONE_VERIFIED', - ID_PENDING = 'ID_PENDING', - ID_VERIFIED = 'ID_VERIFIED', - COMPLETED = 'COMPLETED', - REJECTED = 'REJECTED', + NOT_STARTED = 'NOT_STARTED', // 未开始 + REAL_NAME_VERIFIED = 'REAL_NAME_VERIFIED', // 层级1完成: 实名认证通过 + FACE_VERIFIED = 'FACE_VERIFIED', // 层级2完成: 实人认证通过 + KYC_VERIFIED = 'KYC_VERIFIED', // 层级3完成: KYC认证通过 + COMPLETED = 'COMPLETED', // 所有层级完成 + REJECTED = 'REJECTED', // 被拒绝 } +// KYC 配置键 +export const KYC_CONFIG_KEYS = { + LEVEL1_ENABLED: 'kyc.level1.enabled', // 实名认证开关 + LEVEL2_ENABLED: 'kyc.level2.enabled', // 实人认证开关 + LEVEL3_ENABLED: 'kyc.level3.enabled', // KYC证件照开关 +}; + @Injectable() export class KycApplicationService { private readonly logger = new Logger(KycApplicationService.name); @@ -30,149 +37,199 @@ export class KycApplicationService { private readonly prisma: PrismaService, private readonly redisService: RedisService, private readonly smsService: SmsService, + private readonly storageService: StorageService, private readonly aliyunKycProvider: AliyunKycProvider, ) {} /** - * 获取用户的 KYC 状态 + * 获取 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, + async getKycConfig() { + const configs = await this.prisma.kycConfig.findMany({ + where: { + configKey: { + in: Object.values(KYC_CONFIG_KEYS), + }, }, }); - if (!user) { - throw new ApplicationError('用户不存在'); - } + const configMap = new Map(configs.map((c) => [c.configKey, c.configValue])); 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, + level1Enabled: configMap.get(KYC_CONFIG_KEYS.LEVEL1_ENABLED) !== 'false', + level2Enabled: configMap.get(KYC_CONFIG_KEYS.LEVEL2_ENABLED) !== 'false', + level3Enabled: configMap.get(KYC_CONFIG_KEYS.LEVEL3_ENABLED) !== 'false', }; } /** - * 发送 KYC 手机验证码 + * 更新 KYC 配置(管理后台使用) */ - async sendKycVerifySms(userId: string) { - this.logger.log(`[KYC] Sending verify SMS for user: ${userId}`); + async updateKycConfig( + level1Enabled?: boolean, + level2Enabled?: boolean, + level3Enabled?: boolean, + updatedBy?: string, + ) { + const updates: { key: string; value: string; description: string }[] = []; - const user = await this.prisma.userAccount.findUnique({ - where: { userId: BigInt(userId) }, - select: { - phoneNumber: true, - phoneVerified: true, - }, - }); + if (level1Enabled !== undefined) { + updates.push({ + key: KYC_CONFIG_KEYS.LEVEL1_ENABLED, + value: String(level1Enabled), + description: '层级1: 实名认证(二要素)开关', + }); + } + if (level2Enabled !== undefined) { + updates.push({ + key: KYC_CONFIG_KEYS.LEVEL2_ENABLED, + value: String(level2Enabled), + description: '层级2: 实人认证(人脸活体)开关', + }); + } + if (level3Enabled !== undefined) { + updates.push({ + key: KYC_CONFIG_KEYS.LEVEL3_ENABLED, + value: String(level3Enabled), + description: '层级3: KYC(证件照)开关', + }); + } + + for (const update of updates) { + await this.prisma.kycConfig.upsert({ + where: { configKey: update.key }, + create: { + configKey: update.key, + configValue: update.value, + description: update.description, + updatedBy, + }, + update: { + configValue: update.value, + updatedBy, + }, + }); + } + + return this.getKycConfig(); + } + + /** + * 获取用户的 KYC 状态(包含三层认证详情) + */ + async getKycStatus(userId: string) { + this.logger.log(`[KYC] Getting KYC status for user: ${userId}`); + + const [user, config] = await Promise.all([ + this.prisma.userAccount.findUnique({ + where: { userId: BigInt(userId) }, + select: { + phoneNumber: true, + phoneVerified: true, + realNameVerified: true, + realNameVerifiedAt: true, + realName: true, + idCardNumber: true, + faceVerified: true, + faceVerifiedAt: true, + faceCertifyId: true, + kycVerified: true, + kycVerifiedAt: true, + idCardFrontUrl: true, + idCardBackUrl: true, + kycStatus: true, + kycRejectedReason: true, + }, + }), + this.getKycConfig(), + ]); if (!user) { throw new ApplicationError('用户不存在'); } - if (!user.phoneNumber) { - throw new ApplicationError('用户未绑定手机号'); - } + // 计算需要完成的步骤 + const requiredSteps: string[] = []; + if (config.level1Enabled) requiredSteps.push('REAL_NAME'); + if (config.level2Enabled) requiredSteps.push('FACE'); + if (config.level3Enabled) requiredSteps.push('KYC'); - if (user.phoneVerified) { - throw new ApplicationError('手机号已验证'); - } + // 计算已完成的步骤 + const completedSteps: string[] = []; + if (user.realNameVerified) completedSteps.push('REAL_NAME'); + if (user.faceVerified) completedSteps.push('FACE'); + if (user.kycVerified) completedSteps.push('KYC'); - // 发送验证码 - const code = this.generateSmsCode(); - await this.redisService.set( - `sms:kyc:${user.phoneNumber}`, - code, - 5 * 60, // 5分钟有效期 - ); + // 判断是否全部完成 + const isCompleted = requiredSteps.every((s) => completedSteps.includes(s)); - await this.smsService.sendVerificationCode(user.phoneNumber, code); - this.logger.log(`[KYC] Verify SMS sent to ${this.maskPhoneNumber(user.phoneNumber)}`); + return { + // 配置信息 + config, + requiredSteps, + + // 层级1: 实名认证 + level1: { + enabled: config.level1Enabled, + verified: user.realNameVerified, + verifiedAt: user.realNameVerifiedAt, + realName: user.realName ? this.maskName(user.realName) : undefined, + idCardNumber: user.idCardNumber ? this.maskIdCard(user.idCardNumber) : undefined, + }, + + // 层级2: 实人认证 + level2: { + enabled: config.level2Enabled, + verified: user.faceVerified, + verifiedAt: user.faceVerifiedAt, + // 只有层级1完成才能进行层级2 + canStart: config.level2Enabled && (!config.level1Enabled || user.realNameVerified), + }, + + // 层级3: KYC + level3: { + enabled: config.level3Enabled, + verified: user.kycVerified, + verifiedAt: user.kycVerifiedAt, + hasIdCardFront: !!user.idCardFrontUrl, + hasIdCardBack: !!user.idCardBackUrl, + // 只有层级2完成(或层级2未开启且层级1完成)才能进行层级3 + canStart: config.level3Enabled && ( + (!config.level2Enabled && (!config.level1Enabled || user.realNameVerified)) || + (config.level2Enabled && user.faceVerified) + ), + }, + + // 综合状态 + kycStatus: user.kycStatus, + isCompleted, + rejectedReason: user.kycRejectedReason, + phoneNumber: user.phoneNumber ? this.maskPhoneNumber(user.phoneNumber) : undefined, + phoneVerified: user.phoneVerified, + }; } /** - * 完成手机号验证(KYC 流程) + * ======================================== + * 层级1: 实名认证 - 提交二要素验证 + * ======================================== */ - 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( + async submitRealNameVerification( userId: string, realName: string, idCardNumber: string, ) { - this.logger.log(`[KYC] Submitting ID verification for user: ${userId}`); + this.logger.log(`[KYC] [Level1] Submitting real name verification for user: ${userId}`); + + const config = await this.getKycConfig(); + if (!config.level1Enabled) { + throw new ApplicationError('实名认证功能暂未开启'); + } const user = await this.prisma.userAccount.findUnique({ where: { userId: BigInt(userId) }, select: { - phoneVerified: true, + realNameVerified: true, kycStatus: true, }, }); @@ -181,16 +238,12 @@ export class KycApplicationService { throw new ApplicationError('用户不存在'); } - // 检查是否已完成身份验证 - if ( - user.kycStatus === KycStatus.ID_VERIFIED || - user.kycStatus === KycStatus.COMPLETED - ) { - throw new ApplicationError('身份验证已完成,无需重复提交'); + if (user.realNameVerified) { + throw new ApplicationError('实名认证已完成,无需重复提交'); } // 生成请求 ID - const requestId = `KYC_${userId}_${Date.now()}`; + const requestId = `REAL_NAME_${userId}_${Date.now()}`; // 记录验证尝试 const attempt = await this.prisma.kycVerificationAttempt.create({ @@ -218,7 +271,6 @@ export class KycApplicationService { if (result.success) { // 验证成功 await this.prisma.$transaction([ - // 更新验证尝试记录 this.prisma.kycVerificationAttempt.update({ where: { id: attempt.id }, data: { @@ -227,59 +279,49 @@ export class KycApplicationService { completedAt: new Date(), }, }), - // 更新用户信息 this.prisma.userAccount.update({ where: { userId: BigInt(userId) }, data: { realName, - idCardNumber, // 注意:生产环境应加密存储 - kycStatus: user.phoneVerified ? KycStatus.COMPLETED : KycStatus.ID_VERIFIED, + idCardNumber, + realNameVerified: true, + realNameVerifiedAt: new Date(), + kycStatus: KycStatus.REAL_NAME_VERIFIED, kycProvider: 'ALIYUN', kycRequestId: requestId, - kycVerifiedAt: new Date(), }, }), ]); - this.logger.log(`[KYC] ID verification SUCCESS for user: ${userId}`); + this.logger.log(`[KYC] [Level1] Verification SUCCESS for user: ${userId}`); return { - requestId, - status: 'SUCCESS', - kycStatus: user.phoneVerified ? KycStatus.COMPLETED : KycStatus.ID_VERIFIED, + success: true, + level: 1, + status: KycStatus.REAL_NAME_VERIFIED, + message: '实名认证成功', }; } 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, - }, - }), - ]); + await this.prisma.kycVerificationAttempt.update({ + where: { id: attempt.id }, + data: { + status: 'FAILED', + failureReason: result.errorMessage, + responseData: result.rawResponse as object ?? null, + completedAt: new Date(), + }, + }); - this.logger.warn(`[KYC] ID verification FAILED for user: ${userId}, reason: ${result.errorMessage}`); + this.logger.warn(`[KYC] [Level1] Verification FAILED for user: ${userId}, reason: ${result.errorMessage}`); return { - requestId, - status: 'FAILED', - failureReason: result.errorMessage, - kycStatus: KycStatus.REJECTED, + success: false, + level: 1, + errorMessage: result.errorMessage, }; } } catch (error) { - // 系统错误 await this.prisma.kycVerificationAttempt.update({ where: { id: attempt.id }, data: { @@ -289,11 +331,317 @@ export class KycApplicationService { }, }); - this.logger.error(`[KYC] ID verification ERROR for user: ${userId}`, error); - throw new ApplicationError('身份验证服务暂时不可用,请稍后重试'); + this.logger.error(`[KYC] [Level1] Verification ERROR for user: ${userId}`, error); + throw new ApplicationError('实名认证服务暂时不可用,请稍后重试'); } } + /** + * ======================================== + * 层级2: 实人认证 - 初始化人脸活体检测 + * ======================================== + */ + async initFaceVerification(userId: string, metaInfo?: string) { + this.logger.log(`[KYC] [Level2] Initializing face verification for user: ${userId}`); + + const config = await this.getKycConfig(); + if (!config.level2Enabled) { + throw new ApplicationError('实人认证功能暂未开启'); + } + + const user = await this.prisma.userAccount.findUnique({ + where: { userId: BigInt(userId) }, + select: { + realNameVerified: true, + realName: true, + idCardNumber: true, + faceVerified: true, + }, + }); + + if (!user) { + throw new ApplicationError('用户不存在'); + } + + // 层级2需要先完成层级1 + if (config.level1Enabled && !user.realNameVerified) { + throw new ApplicationError('请先完成实名认证'); + } + + if (user.faceVerified) { + throw new ApplicationError('实人认证已完成,无需重复提交'); + } + + if (!user.realName || !user.idCardNumber) { + throw new ApplicationError('缺少实名信息,请先完成实名认证'); + } + + // 调用阿里云初始化人脸认证 + const result = await this.aliyunKycProvider.initFaceVerify( + userId, + user.realName, + user.idCardNumber, + ); + + if (result.success && result.certifyId) { + // 记录验证尝试 + await this.prisma.kycVerificationAttempt.create({ + data: { + userId: BigInt(userId), + verificationType: 'FACE', + provider: 'ALIYUN', + requestId: result.certifyId, + status: 'PENDING', + }, + }); + + // 保存 certifyId 到用户记录 + await this.prisma.userAccount.update({ + where: { userId: BigInt(userId) }, + data: { + faceCertifyId: result.certifyId, + }, + }); + + return { + success: true, + certifyId: result.certifyId, + certifyUrl: result.certifyUrl, + }; + } else { + throw new ApplicationError(result.errorMessage || '初始化实人认证失败'); + } + } + + /** + * ======================================== + * 层级2: 实人认证 - 查询认证结果 + * ======================================== + */ + async queryFaceVerification(userId: string, certifyId: string) { + this.logger.log(`[KYC] [Level2] Querying face verification for user: ${userId}, certifyId: ${certifyId}`); + + const user = await this.prisma.userAccount.findUnique({ + where: { userId: BigInt(userId) }, + select: { + faceCertifyId: true, + faceVerified: true, + }, + }); + + if (!user) { + throw new ApplicationError('用户不存在'); + } + + if (user.faceVerified) { + return { + success: true, + passed: true, + status: 'PASSED', + message: '实人认证已通过', + }; + } + + if (user.faceCertifyId !== certifyId) { + throw new ApplicationError('认证ID不匹配'); + } + + // 查询阿里云认证结果 + const result = await this.aliyunKycProvider.queryFaceVerify(certifyId); + + if (result.passed) { + // 更新用户状态 + await this.prisma.$transaction([ + this.prisma.kycVerificationAttempt.updateMany({ + where: { requestId: certifyId }, + data: { + status: 'SUCCESS', + responseData: result.rawResponse as object ?? null, + completedAt: new Date(), + }, + }), + this.prisma.userAccount.update({ + where: { userId: BigInt(userId) }, + data: { + faceVerified: true, + faceVerifiedAt: new Date(), + kycStatus: KycStatus.FACE_VERIFIED, + }, + }), + ]); + + this.logger.log(`[KYC] [Level2] Face verification PASSED for user: ${userId}`); + } + + return { + success: result.success, + passed: result.passed, + status: result.status, + errorMessage: result.errorMessage, + }; + } + + /** + * ======================================== + * 层级3: KYC - 上传证件照 + * ======================================== + */ + async uploadIdCardPhoto( + userId: string, + side: 'front' | 'back', + file: Express.Multer.File, + ) { + this.logger.log(`[KYC] [Level3] Uploading ID card photo for user: ${userId}, side: ${side}`); + + const config = await this.getKycConfig(); + if (!config.level3Enabled) { + throw new ApplicationError('KYC证件上传功能暂未开启'); + } + + const user = await this.prisma.userAccount.findUnique({ + where: { userId: BigInt(userId) }, + select: { + realNameVerified: true, + faceVerified: true, + kycVerified: true, + }, + }); + + if (!user) { + throw new ApplicationError('用户不存在'); + } + + // 检查前置条件 + if (config.level1Enabled && !user.realNameVerified) { + throw new ApplicationError('请先完成实名认证'); + } + if (config.level2Enabled && !user.faceVerified) { + throw new ApplicationError('请先完成实人认证'); + } + if (user.kycVerified) { + throw new ApplicationError('KYC认证已完成,无需重复上传'); + } + + // 上传到 MinIO + const uploadResult = await this.storageService.uploadImage( + userId.toString(), + `kyc/id_card_${side}`, + file.buffer, + file.mimetype, + ); + const imageUrl = uploadResult.url; + + // 更新用户记录 + const updateData = side === 'front' + ? { idCardFrontUrl: imageUrl } + : { idCardBackUrl: imageUrl }; + + await this.prisma.userAccount.update({ + where: { userId: BigInt(userId) }, + data: updateData, + }); + + // OCR 识别(可选) + let ocrResult = null; + try { + ocrResult = await this.aliyunKycProvider.ocrIdCard(imageUrl, side); + } catch (error) { + this.logger.warn(`[KYC] [Level3] OCR failed: ${error.message}`); + } + + return { + success: true, + side, + imageUrl, + ocrResult: ocrResult?.success ? { + name: ocrResult.name, + idNumber: ocrResult.idNumber, + address: ocrResult.address, + issueAuthority: ocrResult.issueAuthority, + validPeriod: ocrResult.validPeriod, + } : null, + }; + } + + /** + * ======================================== + * 层级3: KYC - 确认提交 + * ======================================== + */ + async confirmKycSubmission(userId: string) { + this.logger.log(`[KYC] [Level3] Confirming KYC submission for user: ${userId}`); + + const user = await this.prisma.userAccount.findUnique({ + where: { userId: BigInt(userId) }, + select: { + realNameVerified: true, + faceVerified: true, + kycVerified: true, + idCardFrontUrl: true, + idCardBackUrl: true, + realName: true, + idCardNumber: true, + }, + }); + + if (!user) { + throw new ApplicationError('用户不存在'); + } + + const config = await this.getKycConfig(); + + // 检查前置条件 + if (config.level1Enabled && !user.realNameVerified) { + throw new ApplicationError('请先完成实名认证'); + } + if (config.level2Enabled && !user.faceVerified) { + throw new ApplicationError('请先完成实人认证'); + } + if (!user.idCardFrontUrl || !user.idCardBackUrl) { + throw new ApplicationError('请上传身份证正反面照片'); + } + if (user.kycVerified) { + throw new ApplicationError('KYC认证已完成'); + } + + // 记录验证尝试 + const requestId = `KYC_${userId}_${Date.now()}`; + await this.prisma.kycVerificationAttempt.create({ + data: { + userId: BigInt(userId), + verificationType: 'ID_PHOTO', + provider: 'ALIYUN', + requestId, + inputData: { + frontUrl: user.idCardFrontUrl, + backUrl: user.idCardBackUrl, + }, + status: 'SUCCESS', // 证件照上传后直接成功(OCR已在上传时完成) + completedAt: new Date(), + }, + }); + + // 更新用户状态 + await this.prisma.userAccount.update({ + where: { userId: BigInt(userId) }, + data: { + kycVerified: true, + kycVerifiedAt: new Date(), + kycStatus: KycStatus.COMPLETED, + kycRequestId: requestId, + }, + }); + + this.logger.log(`[KYC] [Level3] KYC verification COMPLETED for user: ${userId}`); + + return { + success: true, + level: 3, + status: KycStatus.COMPLETED, + message: 'KYC认证完成', + }; + } + // ============ Helper Methods ============ private generateSmsCode(): string { 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 index aeea6015..843f0175 100644 --- 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 @@ -1,6 +1,10 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import * as crypto from 'crypto'; +/** + * 二要素验证结果 + */ export interface IdCardVerificationResult { success: boolean; errorMessage?: string; @@ -8,10 +12,52 @@ export interface IdCardVerificationResult { } /** - * 阿里云实人认证服务 - 二要素验证(姓名+身份证号) + * 人脸活体认证初始化结果 + */ +export interface FaceVerifyInitResult { + success: boolean; + certifyId?: string; // 认证ID,用于客户端SDK + certifyUrl?: string; // H5认证链接(可选) + errorMessage?: string; + rawResponse?: Record; +} + +/** + * 人脸活体认证查询结果 + */ +export interface FaceVerifyQueryResult { + success: boolean; + passed: boolean; // 是否通过认证 + status: 'PENDING' | 'PASSED' | 'FAILED' | 'EXPIRED'; + errorMessage?: string; + rawResponse?: Record; +} + +/** + * OCR识别结果 + */ +export interface IdCardOcrResult { + success: boolean; + name?: string; + idNumber?: string; + address?: string; + ethnicity?: string; + birthDate?: string; + sex?: string; + issueAuthority?: string; // 签发机关 + validPeriod?: string; // 有效期限 + errorMessage?: string; + rawResponse?: Record; +} + +/** + * 阿里云实人认证服务 - 支持三层认证 * - * 使用阿里云身份二要素核验 API - * 文档: https://help.aliyun.com/document_detail/155148.html + * 层级1: 实名认证 - 二要素验证(姓名+身份证号) + * 层级2: 实人认证 - 人脸活体检测 + * 层级3: KYC - 证件照OCR识别验证 + * + * 文档: https://help.aliyun.com/product/60032.html */ @Injectable() export class AliyunKycProvider { @@ -20,11 +66,15 @@ export class AliyunKycProvider { private readonly accessKeyId: string; private readonly accessKeySecret: string; private readonly enabled: boolean; + private readonly endpoint: string; + private readonly sceneId: string; 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); + this.endpoint = this.configService.get('ALIYUN_KYC_ENDPOINT', 'cloudauth.aliyuncs.com'); + this.sceneId = this.configService.get('ALIYUN_KYC_SCENE_ID', ''); if (this.enabled && (!this.accessKeyId || !this.accessKeySecret)) { this.logger.warn('[AliyunKYC] KYC is enabled but credentials are not configured'); @@ -32,62 +82,336 @@ export class AliyunKycProvider { } /** - * 验证身份证信息(二要素验证) - * - * @param realName 真实姓名 - * @param idCardNumber 身份证号 - * @param requestId 请求ID(用于日志追踪) + * ======================================== + * 层级1: 实名认证 - 二要素验证 + * ======================================== + * 验证姓名和身份证号是否匹配 */ async verifyIdCard( realName: string, idCardNumber: string, requestId: string, ): Promise { - this.logger.log(`[AliyunKYC] Starting ID card verification, requestId: ${requestId}`); + this.logger.log(`[AliyunKYC] [Level1] Starting ID card verification, requestId: ${requestId}`); // 开发/测试环境:模拟验证 if (!this.enabled) { this.logger.warn('[AliyunKYC] KYC is disabled, using mock verification'); - return this.mockVerification(realName, idCardNumber); + return this.mockIdCardVerification(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); + // 调用阿里云身份二要素核验 API + const params = { + Action: 'VerifyMaterial', + Version: '2019-03-07', + Format: 'JSON', + BizType: 'ID_CARD_TWO', + Name: realName, + IdCardNumber: idCardNumber, + }; - this.logger.log('[AliyunKYC] Calling Aliyun API...'); + const response = await this.callAliyunApi(params); - // 模拟 API 调用延迟 - await this.delay(500); - - // 暂时使用模拟验证 - return this.mockVerification(realName, idCardNumber); + if (response.Code === 'OK' || response.Code === '200') { + this.logger.log(`[AliyunKYC] [Level1] Verification SUCCESS for requestId: ${requestId}`); + return { + success: true, + rawResponse: response, + }; + } else { + this.logger.warn(`[AliyunKYC] [Level1] Verification FAILED: ${response.Message}`); + return { + success: false, + errorMessage: this.mapErrorMessage(response.Code, response.Message), + rawResponse: response, + }; + } } catch (error) { - this.logger.error(`[AliyunKYC] API call failed: ${error.message}`, error.stack); + this.logger.error(`[AliyunKYC] [Level1] API call failed: ${error.message}`, error.stack); return { success: false, - errorMessage: '身份验证服务暂时不可用', + errorMessage: '实名认证服务暂时不可用', rawResponse: { error: error.message }, }; } } /** - * 模拟验证(开发/测试环境使用) + * ======================================== + * 层级2: 实人认证 - 初始化人脸活体检测 + * ======================================== + * 返回认证ID供客户端SDK使用 */ - private mockVerification( + async initFaceVerify( + userId: string, realName: string, idCardNumber: string, - ): IdCardVerificationResult { - this.logger.log('[AliyunKYC] Using mock verification'); + returnUrl?: string, + ): Promise { + this.logger.log(`[AliyunKYC] [Level2] Initializing face verify for user: ${userId}`); + + if (!this.enabled) { + this.logger.warn('[AliyunKYC] KYC is disabled, using mock face verify init'); + return this.mockFaceVerifyInit(userId); + } + + try { + const outerOrderNo = `FACE_${userId}_${Date.now()}`; + + // 调用阿里云金融级实人认证初始化 API + const params = { + Action: 'InitFaceVerify', + Version: '2019-03-07', + Format: 'JSON', + SceneId: this.sceneId, + OuterOrderNo: outerOrderNo, + ProductCode: 'ID_PLUS', // 金融级实人认证 + CertType: 'IDENTITY_CARD', + CertName: realName, + CertNo: idCardNumber, + ReturnUrl: returnUrl || '', + MetaInfo: JSON.stringify({ + zimVer: '3.0.0', + appVersion: '1.0.0', + bioMetaInfo: 'mock', + }), + }; + + const response = await this.callAliyunApi(params); + + if (response.Code === 'OK' || response.Code === '200') { + const resultObject = response.ResultObject || {}; + this.logger.log(`[AliyunKYC] [Level2] Init SUCCESS, certifyId: ${resultObject.CertifyId}`); + return { + success: true, + certifyId: resultObject.CertifyId, + certifyUrl: resultObject.CertifyUrl, + rawResponse: response, + }; + } else { + this.logger.warn(`[AliyunKYC] [Level2] Init FAILED: ${response.Message}`); + return { + success: false, + errorMessage: this.mapErrorMessage(response.Code, response.Message), + rawResponse: response, + }; + } + } catch (error) { + this.logger.error(`[AliyunKYC] [Level2] Init failed: ${error.message}`, error.stack); + return { + success: false, + errorMessage: '实人认证服务暂时不可用', + rawResponse: { error: error.message }, + }; + } + } + + /** + * ======================================== + * 层级2: 实人认证 - 查询认证结果 + * ======================================== + */ + async queryFaceVerify(certifyId: string): Promise { + this.logger.log(`[AliyunKYC] [Level2] Querying face verify result, certifyId: ${certifyId}`); + + if (!this.enabled) { + this.logger.warn('[AliyunKYC] KYC is disabled, using mock face verify query'); + return this.mockFaceVerifyQuery(certifyId); + } + + try { + const params = { + Action: 'DescribeFaceVerify', + Version: '2019-03-07', + Format: 'JSON', + SceneId: this.sceneId, + CertifyId: certifyId, + }; + + const response = await this.callAliyunApi(params); + + if (response.Code === 'OK' || response.Code === '200') { + const resultObject = response.ResultObject || {}; + const passed = resultObject.Passed === 'T' || resultObject.Passed === true; + + this.logger.log(`[AliyunKYC] [Level2] Query result: passed=${passed}`); + return { + success: true, + passed, + status: passed ? 'PASSED' : 'FAILED', + rawResponse: response, + }; + } else { + return { + success: false, + passed: false, + status: 'FAILED', + errorMessage: this.mapErrorMessage(response.Code, response.Message), + rawResponse: response, + }; + } + } catch (error) { + this.logger.error(`[AliyunKYC] [Level2] Query failed: ${error.message}`, error.stack); + return { + success: false, + passed: false, + status: 'FAILED', + errorMessage: '查询认证结果失败', + rawResponse: { error: error.message }, + }; + } + } + + /** + * ======================================== + * 层级3: KYC - 证件照OCR识别 + * ======================================== + */ + async ocrIdCard( + imageUrl: string, + side: 'front' | 'back', + ): Promise { + this.logger.log(`[AliyunKYC] [Level3] Starting ID card OCR, side: ${side}`); + + if (!this.enabled) { + this.logger.warn('[AliyunKYC] KYC is disabled, using mock OCR'); + return this.mockIdCardOcr(side); + } + + try { + const params = { + Action: 'RecognizeIdCard', + Version: '2019-03-07', + Format: 'JSON', + Side: side === 'front' ? 'face' : 'back', + ImageUrl: imageUrl, + }; + + const response = await this.callAliyunApi(params); + + if (response.Code === 'OK' || response.Code === '200') { + const data = response.Data || {}; + this.logger.log(`[AliyunKYC] [Level3] OCR SUCCESS`); + + if (side === 'front') { + return { + success: true, + name: data.Name, + idNumber: data.IdNumber, + address: data.Address, + ethnicity: data.Ethnicity, + birthDate: data.BirthDate, + sex: data.Sex, + rawResponse: response, + }; + } else { + return { + success: true, + issueAuthority: data.Issue, + validPeriod: data.ValidPeriod || `${data.StartDate}-${data.EndDate}`, + rawResponse: response, + }; + } + } else { + return { + success: false, + errorMessage: this.mapErrorMessage(response.Code, response.Message), + rawResponse: response, + }; + } + } catch (error) { + this.logger.error(`[AliyunKYC] [Level3] OCR failed: ${error.message}`, error.stack); + return { + success: false, + errorMessage: '证件识别服务暂时不可用', + rawResponse: { error: error.message }, + }; + } + } + + // ============ 私有方法 ============ + + /** + * 调用阿里云 API (签名方式) + */ + private async callAliyunApi(params: Record): Promise { + const timestamp = new Date().toISOString().replace(/\.\d{3}/, ''); + const nonce = crypto.randomUUID(); + + const commonParams: Record = { + AccessKeyId: this.accessKeyId, + Timestamp: timestamp, + SignatureMethod: 'HMAC-SHA1', + SignatureVersion: '1.0', + SignatureNonce: nonce, + ...params, + }; + + // 计算签名 + const signature = this.calculateSignature(commonParams); + commonParams['Signature'] = signature; + + // 发起请求 + const queryString = Object.entries(commonParams) + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) + .join('&'); + + const url = `https://${this.endpoint}/?${queryString}`; + + const response = await fetch(url, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + return response.json(); + } + + /** + * 计算阿里云 API 签名 + */ + private calculateSignature(params: Record): string { + const sortedKeys = Object.keys(params).sort(); + const canonicalizedQueryString = sortedKeys + .map((k) => `${this.percentEncode(k)}=${this.percentEncode(params[k])}`) + .join('&'); + + const stringToSign = `GET&${this.percentEncode('/')}&${this.percentEncode(canonicalizedQueryString)}`; + + const hmac = crypto.createHmac('sha1', `${this.accessKeySecret}&`); + hmac.update(stringToSign); + return hmac.digest('base64'); + } + + private percentEncode(str: string): string { + return encodeURIComponent(str) + .replace(/\+/g, '%20') + .replace(/\*/g, '%2A') + .replace(/~/g, '%7E'); + } + + /** + * 映射错误消息 + */ + private mapErrorMessage(code: string, message: string): string { + const errorMap: Record = { + 'InvalidParameter': '参数格式错误', + 'IdCardNotMatch': '姓名与身份证号不匹配', + 'IdCardNotExist': '身份证号不存在', + 'IdCardExpired': '身份证已过期', + 'FaceNotMatch': '人脸比对不通过', + 'LivenessCheckFail': '活体检测失败', + 'SystemError': '系统错误,请稍后重试', + }; + return errorMap[code] || message || '认证失败'; + } + + // ============ Mock 方法 (开发/测试环境) ============ + + private mockIdCardVerification(realName: string, idCardNumber: string): IdCardVerificationResult { + this.logger.log('[AliyunKYC] Using mock ID card verification'); // 基本格式验证 if (!realName || realName.length < 2) { @@ -117,20 +441,58 @@ export class AliyunKycProvider { }; } - // 模拟成功 - this.logger.log('[AliyunKYC] Mock verification SUCCESS'); return { success: true, - rawResponse: { - mock: true, - verifyTime: new Date().toISOString(), - }, + rawResponse: { mock: true, verifyTime: new Date().toISOString() }, }; } - /** - * 验证身份证校验码 - */ + private mockFaceVerifyInit(userId: string): FaceVerifyInitResult { + this.logger.log('[AliyunKYC] Using mock face verify init'); + const mockCertifyId = `MOCK_CERTIFY_${userId}_${Date.now()}`; + return { + success: true, + certifyId: mockCertifyId, + certifyUrl: `https://mock.aliyun.com/face-verify?certifyId=${mockCertifyId}`, + rawResponse: { mock: true }, + }; + } + + private mockFaceVerifyQuery(certifyId: string): FaceVerifyQueryResult { + this.logger.log('[AliyunKYC] Using mock face verify query'); + // 模拟环境中,假设所有以 MOCK_ 开头的认证都通过 + const passed = certifyId.startsWith('MOCK_'); + return { + success: true, + passed, + status: passed ? 'PASSED' : 'PENDING', + rawResponse: { mock: true }, + }; + } + + private mockIdCardOcr(side: 'front' | 'back'): IdCardOcrResult { + this.logger.log('[AliyunKYC] Using mock ID card OCR'); + if (side === 'front') { + return { + success: true, + name: '测试用户', + idNumber: '110101199001011234', + address: '北京市东城区测试街道1号', + ethnicity: '汉', + birthDate: '1990-01-01', + sex: '男', + rawResponse: { mock: true }, + }; + } else { + return { + success: true, + issueAuthority: '北京市公安局东城分局', + validPeriod: '2020.01.01-2040.01.01', + rawResponse: { mock: true }, + }; + } + } + private validateIdCardChecksum(idCard: string): boolean { if (idCard.length !== 18) return false; @@ -147,8 +509,4 @@ export class AliyunKycProvider { return expectedChecksum === actualChecksum; } - - private delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); - } } diff --git a/frontend/mobile-app/lib/core/network/api_client.dart b/frontend/mobile-app/lib/core/network/api_client.dart index 5eaef2e8..a481d027 100644 --- a/frontend/mobile-app/lib/core/network/api_client.dart +++ b/frontend/mobile-app/lib/core/network/api_client.dart @@ -254,6 +254,36 @@ class ApiClient { } } + /// 上传文件 + Future> uploadFile( + String path, + List fileBytes, + String fileName, { + String fieldName = 'file', + Map? extraFields, + Options? options, + }) async { + try { + final formData = FormData.fromMap({ + fieldName: MultipartFile.fromBytes( + fileBytes, + filename: fileName, + ), + ...?extraFields, + }); + + return await _dio.post( + path, + data: formData, + options: options ?? Options( + contentType: 'multipart/form-data', + ), + ); + } on DioException catch (e) { + throw _handleDioError(e); + } + } + /// 处理 Dio 错误 Exception _handleDioError(DioException error) { switch (error.type) { diff --git a/frontend/mobile-app/lib/features/kyc/data/kyc_service.dart b/frontend/mobile-app/lib/features/kyc/data/kyc_service.dart index 67696630..f9e732f9 100644 --- a/frontend/mobile-app/lib/features/kyc/data/kyc_service.dart +++ b/frontend/mobile-app/lib/features/kyc/data/kyc_service.dart @@ -2,60 +2,189 @@ import 'package:flutter/foundation.dart'; import '../../../core/network/api_client.dart'; import '../../../core/errors/exceptions.dart'; -/// KYC 状态枚举 +/// KYC 状态枚举 (综合状态) enum KycStatusType { - notStarted, // 未开始 - phoneVerified, // 手机已验证 - idPending, // 身份验证中 - idVerified, // 身份已验证 - completed, // 已完成 - rejected, // 已拒绝 + notStarted, // 未开始 + realNameVerified, // 层级1完成: 实名认证通过 + faceVerified, // 层级2完成: 实人认证通过 + kycVerified, // 层级3完成: KYC认证通过 + completed, // 所有层级完成 + rejected, // 被拒绝 } -/// KYC 状态响应 -class KycStatusResponse { - final bool phoneVerified; - final String kycStatus; +/// KYC 配置响应 +class KycConfigResponse { + final bool level1Enabled; // 实名认证开关 + final bool level2Enabled; // 实人认证开关 + final bool level3Enabled; // KYC证件照开关 + + KycConfigResponse({ + required this.level1Enabled, + required this.level2Enabled, + required this.level3Enabled, + }); + + factory KycConfigResponse.fromJson(Map json) { + return KycConfigResponse( + level1Enabled: json['level1Enabled'] as bool? ?? true, + level2Enabled: json['level2Enabled'] as bool? ?? true, + level3Enabled: json['level3Enabled'] as bool? ?? true, + ); + } +} + +/// 层级1状态 +class KycLevel1Status { + final bool enabled; + final bool verified; + final DateTime? verifiedAt; final String? realName; final String? idCardNumber; - final DateTime? kycVerifiedAt; - final String? rejectedReason; - final String? phoneNumber; - KycStatusResponse({ - required this.phoneVerified, - required this.kycStatus, + KycLevel1Status({ + required this.enabled, + required this.verified, + this.verifiedAt, this.realName, this.idCardNumber, - this.kycVerifiedAt, + }); + + factory KycLevel1Status.fromJson(Map json) { + return KycLevel1Status( + enabled: json['enabled'] as bool? ?? true, + verified: json['verified'] as bool? ?? false, + verifiedAt: json['verifiedAt'] != null + ? DateTime.parse(json['verifiedAt'] as String) + : null, + realName: json['realName'] as String?, + idCardNumber: json['idCardNumber'] as String?, + ); + } +} + +/// 层级2状态 +class KycLevel2Status { + final bool enabled; + final bool verified; + final DateTime? verifiedAt; + final bool canStart; + + KycLevel2Status({ + required this.enabled, + required this.verified, + this.verifiedAt, + required this.canStart, + }); + + factory KycLevel2Status.fromJson(Map json) { + return KycLevel2Status( + enabled: json['enabled'] as bool? ?? true, + verified: json['verified'] as bool? ?? false, + verifiedAt: json['verifiedAt'] != null + ? DateTime.parse(json['verifiedAt'] as String) + : null, + canStart: json['canStart'] as bool? ?? false, + ); + } +} + +/// 层级3状态 +class KycLevel3Status { + final bool enabled; + final bool verified; + final DateTime? verifiedAt; + final bool hasIdCardFront; + final bool hasIdCardBack; + final bool canStart; + + KycLevel3Status({ + required this.enabled, + required this.verified, + this.verifiedAt, + required this.hasIdCardFront, + required this.hasIdCardBack, + required this.canStart, + }); + + factory KycLevel3Status.fromJson(Map json) { + return KycLevel3Status( + enabled: json['enabled'] as bool? ?? true, + verified: json['verified'] as bool? ?? false, + verifiedAt: json['verifiedAt'] != null + ? DateTime.parse(json['verifiedAt'] as String) + : null, + hasIdCardFront: json['hasIdCardFront'] as bool? ?? false, + hasIdCardBack: json['hasIdCardBack'] as bool? ?? false, + canStart: json['canStart'] as bool? ?? false, + ); + } +} + +/// KYC 完整状态响应 (支持三层认证) +class KycStatusResponse { + final KycConfigResponse config; + final List requiredSteps; + + // 层级1: 实名认证 + final KycLevel1Status level1; + + // 层级2: 实人认证 + final KycLevel2Status level2; + + // 层级3: KYC + final KycLevel3Status level3; + + // 综合状态 + final String kycStatus; + final bool isCompleted; + final String? rejectedReason; + final String? phoneNumber; + final bool phoneVerified; + + KycStatusResponse({ + required this.config, + required this.requiredSteps, + required this.level1, + required this.level2, + required this.level3, + required this.kycStatus, + required this.isCompleted, this.rejectedReason, this.phoneNumber, + required this.phoneVerified, }); factory KycStatusResponse.fromJson(Map json) { return KycStatusResponse( - phoneVerified: json['phoneVerified'] as bool? ?? false, + config: KycConfigResponse.fromJson( + json['config'] as Map? ?? {}, + ), + requiredSteps: (json['requiredSteps'] as List?)?.cast() ?? [], + level1: KycLevel1Status.fromJson( + json['level1'] as Map? ?? {}, + ), + level2: KycLevel2Status.fromJson( + json['level2'] as Map? ?? {}, + ), + level3: KycLevel3Status.fromJson( + json['level3'] as Map? ?? {}, + ), 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, + isCompleted: json['isCompleted'] as bool? ?? false, rejectedReason: json['rejectedReason'] as String?, phoneNumber: json['phoneNumber'] as String?, + phoneVerified: json['phoneVerified'] as bool? ?? false, ); } 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 'REAL_NAME_VERIFIED': + return KycStatusType.realNameVerified; + case 'FACE_VERIFIED': + return KycStatusType.faceVerified; + case 'KYC_VERIFIED': + return KycStatusType.kycVerified; case 'COMPLETED': return KycStatusType.completed; case 'REJECTED': @@ -65,250 +194,159 @@ class KycStatusResponse { } } - bool get isCompleted => statusType == KycStatusType.completed; - bool get needsPhoneVerification => !phoneVerified; - bool get needsIdVerification => - statusType == KycStatusType.notStarted || - statusType == KycStatusType.phoneVerified || - statusType == KycStatusType.rejected; + /// 兼容旧代码 + String? get realName => level1.realName; + String? get idCardNumber => level1.idCardNumber; + DateTime? get kycVerifiedAt => level3.verifiedAt ?? level2.verifiedAt ?? level1.verifiedAt; + bool get needsIdVerification => !level1.verified && level1.enabled; } -/// 身份验证响应 -class IdVerificationResponse { - final String requestId; - final String status; - final String? failureReason; - final String? kycStatus; +/// 实名认证响应 +class RealNameVerifyResponse { + final bool success; + final int level; + final String? status; + final String? message; + final String? errorMessage; - IdVerificationResponse({ - required this.requestId, - required this.status, - this.failureReason, - this.kycStatus, + RealNameVerifyResponse({ + required this.success, + required this.level, + this.status, + this.message, + this.errorMessage, }); - 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?, + factory RealNameVerifyResponse.fromJson(Map json) { + return RealNameVerifyResponse( + success: json['success'] as bool? ?? false, + level: json['level'] as int? ?? 1, + status: json['status'] as String?, + message: json['message'] as String?, + errorMessage: json['errorMessage'] as String?, ); } - - bool get isSuccess => status == 'SUCCESS'; - bool get isFailed => status == 'FAILED'; } -/// KYC 服务 -class KycService { - static const String _tag = '[KycService]'; - final ApiClient _apiClient; +/// 人脸认证初始化响应 +class FaceVerifyInitResponse { + final bool success; + final String? certifyId; + final String? certifyUrl; - KycService(this._apiClient); + FaceVerifyInitResponse({ + required this.success, + this.certifyId, + this.certifyUrl, + }); - /// 获取 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'); - } + factory FaceVerifyInitResponse.fromJson(Map json) { + return FaceVerifyInitResponse( + success: json['success'] as bool? ?? false, + certifyId: json['certifyId'] as String?, + certifyUrl: json['certifyUrl'] as String?, + ); } +} - /// 发送 KYC 手机验证码 - Future sendKycVerifySms() async { - debugPrint('$_tag sendKycVerifySms() - 发送验证码'); +/// 人脸认证查询响应 +class FaceVerifyQueryResponse { + final bool success; + final bool passed; + final String status; + final String? errorMessage; - 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'); - } + FaceVerifyQueryResponse({ + required this.success, + required this.passed, + required this.status, + this.errorMessage, + }); + + factory FaceVerifyQueryResponse.fromJson(Map json) { + return FaceVerifyQueryResponse( + success: json['success'] as bool? ?? false, + passed: json['passed'] as bool? ?? false, + status: json['status'] as String? ?? 'PENDING', + errorMessage: json['errorMessage'] as String?, + ); } +} - /// 验证手机号 (KYC 流程) - Future verifyPhoneForKyc(String smsCode) async { - debugPrint('$_tag verifyPhoneForKyc() - 验证手机号'); +/// 证件照上传响应 +class IdCardUploadResponse { + final bool success; + final String side; + final String? imageUrl; + final IdCardOcrResult? ocrResult; - 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'); - } + IdCardUploadResponse({ + required this.success, + required this.side, + this.imageUrl, + this.ocrResult, + }); + + factory IdCardUploadResponse.fromJson(Map json) { + return IdCardUploadResponse( + success: json['success'] as bool? ?? false, + side: json['side'] as String? ?? '', + imageUrl: json['imageUrl'] as String?, + ocrResult: json['ocrResult'] != null + ? IdCardOcrResult.fromJson(json['ocrResult'] as Map) + : null, + ); } +} - /// 提交身份证验证 - Future submitIdVerification({ - required String realName, - required String idCardNumber, - }) async { - debugPrint('$_tag submitIdVerification() - 提交身份验证'); +/// OCR 识别结果 +class IdCardOcrResult { + final String? name; + final String? idNumber; + final String? address; + final String? issueAuthority; + final String? validPeriod; - try { - final response = await _apiClient.post( - '/user/kyc/submit-id', - data: { - 'realName': realName, - 'idCardNumber': idCardNumber, - }, - ); - debugPrint('$_tag submitIdVerification() - 响应: ${response.statusCode}'); + IdCardOcrResult({ + this.name, + this.idNumber, + this.address, + this.issueAuthority, + this.validPeriod, + }); - 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'); - } + factory IdCardOcrResult.fromJson(Map json) { + return IdCardOcrResult( + name: json['name'] as String?, + idNumber: json['idNumber'] as String?, + address: json['address'] as String?, + issueAuthority: json['issueAuthority'] as String?, + validPeriod: json['validPeriod'] as String?, + ); } +} - // ============ 更换手机号相关 ============ +/// KYC 确认响应 +class KycConfirmResponse { + final bool success; + final int level; + final String status; + final String message; - /// 获取手机号状态 - Future getPhoneStatus() async { - debugPrint('$_tag getPhoneStatus() - 获取手机号状态'); + KycConfirmResponse({ + required this.success, + required this.level, + required this.status, + required this.message, + }); - 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'); - } + factory KycConfirmResponse.fromJson(Map json) { + return KycConfirmResponse( + success: json['success'] as bool? ?? false, + level: json['level'] as int? ?? 3, + status: json['status'] as String? ?? '', + message: json['message'] as String? ?? '', + ); } } @@ -337,3 +375,360 @@ class PhoneStatusResponse { ); } } + +/// KYC 服务 - 支持三层认证 +/// 层级1: 实名认证 (二要素: 姓名+身份证号) +/// 层级2: 实人认证 (人脸活体检测) +/// 层级3: KYC (证件照上传验证) +class KycService { + static const String _tag = '[KycService]'; + final ApiClient _apiClient; + + KycService(this._apiClient); + + // ============ 获取状态和配置 ============ + + /// 获取 KYC 配置(三层认证开关) + Future getKycConfig() async { + debugPrint('$_tag getKycConfig() - 获取 KYC 配置'); + + try { + final response = await _apiClient.get('/user/kyc/config'); + debugPrint('$_tag getKycConfig() - 响应: ${response.statusCode}'); + + if (response.data == null) { + throw const ApiException('获取 KYC 配置失败: 空响应'); + } + + final responseData = response.data as Map; + final data = responseData['data'] as Map; + return KycConfigResponse.fromJson(data); + } on ApiException { + rethrow; + } catch (e) { + debugPrint('$_tag getKycConfig() - 异常: $e'); + throw ApiException('获取 KYC 配置失败: $e'); + } + } + + /// 获取 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'); + } + } + + // ============ 层级1: 实名认证 ============ + + /// 提交实名认证(二要素验证) + Future submitRealNameVerification({ + required String realName, + required String idCardNumber, + }) async { + debugPrint('$_tag submitRealNameVerification() - 提交实名认证'); + + try { + final response = await _apiClient.post( + '/user/kyc/level1/submit', + data: { + 'realName': realName, + 'idCardNumber': idCardNumber, + }, + ); + debugPrint('$_tag submitRealNameVerification() - 响应: ${response.statusCode}'); + + if (response.data == null) { + throw const ApiException('提交失败: 空响应'); + } + + final responseData = response.data as Map; + final data = responseData['data'] as Map; + return RealNameVerifyResponse.fromJson(data); + } on ApiException { + rethrow; + } catch (e) { + debugPrint('$_tag submitRealNameVerification() - 异常: $e'); + throw ApiException('实名认证失败: $e'); + } + } + + // ============ 层级2: 实人认证 ============ + + /// 初始化人脸活体检测 + Future initFaceVerification({String? metaInfo}) async { + debugPrint('$_tag initFaceVerification() - 初始化人脸认证'); + + try { + final response = await _apiClient.post( + '/user/kyc/level2/init', + data: { + if (metaInfo != null) 'metaInfo': metaInfo, + }, + ); + debugPrint('$_tag initFaceVerification() - 响应: ${response.statusCode}'); + + if (response.data == null) { + throw const ApiException('初始化失败: 空响应'); + } + + final responseData = response.data as Map; + final data = responseData['data'] as Map; + return FaceVerifyInitResponse.fromJson(data); + } on ApiException { + rethrow; + } catch (e) { + debugPrint('$_tag initFaceVerification() - 异常: $e'); + throw ApiException('初始化人脸认证失败: $e'); + } + } + + /// 查询人脸认证结果 + Future queryFaceVerification(String certifyId) async { + debugPrint('$_tag queryFaceVerification() - 查询人脸认证结果'); + + try { + final response = await _apiClient.get( + '/user/kyc/level2/query', + queryParameters: {'certifyId': certifyId}, + ); + debugPrint('$_tag queryFaceVerification() - 响应: ${response.statusCode}'); + + if (response.data == null) { + throw const ApiException('查询失败: 空响应'); + } + + final responseData = response.data as Map; + final data = responseData['data'] as Map; + return FaceVerifyQueryResponse.fromJson(data); + } on ApiException { + rethrow; + } catch (e) { + debugPrint('$_tag queryFaceVerification() - 异常: $e'); + throw ApiException('查询人脸认证结果失败: $e'); + } + } + + // ============ 层级3: KYC 证件照 ============ + + /// 上传身份证照片 + Future uploadIdCardPhoto({ + required String side, // 'front' 或 'back' + required List imageBytes, + required String fileName, + }) async { + debugPrint('$_tag uploadIdCardPhoto() - 上传证件照 side=$side'); + + try { + final response = await _apiClient.uploadFile( + '/user/kyc/level3/upload/$side', + imageBytes, + fileName, + fieldName: 'file', + ); + debugPrint('$_tag uploadIdCardPhoto() - 响应: ${response.statusCode}'); + + if (response.data == null) { + throw const ApiException('上传失败: 空响应'); + } + + final responseData = response.data as Map; + final data = responseData['data'] as Map; + return IdCardUploadResponse.fromJson(data); + } on ApiException { + rethrow; + } catch (e) { + debugPrint('$_tag uploadIdCardPhoto() - 异常: $e'); + throw ApiException('上传证件照失败: $e'); + } + } + + /// 确认提交 KYC + Future confirmKycSubmission() async { + debugPrint('$_tag confirmKycSubmission() - 确认提交 KYC'); + + try { + final response = await _apiClient.post('/user/kyc/level3/confirm'); + debugPrint('$_tag confirmKycSubmission() - 响应: ${response.statusCode}'); + + if (response.data == null) { + throw const ApiException('提交失败: 空响应'); + } + + final responseData = response.data as Map; + final data = responseData['data'] as Map; + return KycConfirmResponse.fromJson(data); + } on ApiException { + rethrow; + } catch (e) { + debugPrint('$_tag confirmKycSubmission() - 异常: $e'); + throw ApiException('提交 KYC 失败: $e'); + } + } + + // ============ 手机号验证相关 (用于跳过验证的用户补充验证) ============ + + /// 发送手机号验证短信 + Future sendKycVerifySms() async { + debugPrint('$_tag sendKycVerifySms() - 发送 KYC 手机验证短信'); + + try { + final response = await _apiClient.post('/user/kyc/send-phone-sms'); + debugPrint('$_tag sendKycVerifySms() - 响应: ${response.statusCode}'); + } on ApiException { + rethrow; + } catch (e) { + debugPrint('$_tag sendKycVerifySms() - 异常: $e'); + throw ApiException('发送验证码失败: $e'); + } + } + + /// 验证手机号验证码 + 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 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'); + } + } +} 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 index 2b250d64..9e7f33c5 100644 --- 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 @@ -601,7 +601,7 @@ class _ChangePhonePageState extends ConsumerState { } }, child: Text( - _countdown > 0 ? '${_countdown}秒后重新发送' : '发送验证码', + _countdown > 0 ? '$_countdown秒后重新发送' : '发送验证码', style: TextStyle( fontSize: 14.sp, fontWeight: FontWeight.w500, 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 index 9f2afe73..116ac339 100644 --- 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 @@ -18,7 +18,10 @@ final kycStatusProvider = FutureProvider.autoDispose((ref) as return kycService.getKycStatus(); }); -/// KYC 入口页面 +/// KYC 入口页面 - 支持三层认证 +/// 层级1: 实名认证 (二要素: 姓名+身份证号) +/// 层级2: 实人认证 (人脸活体检测) +/// 层级3: KYC (证件照上传验证) class KycEntryPage extends ConsumerWidget { const KycEntryPage({super.key}); @@ -77,7 +80,7 @@ class KycEntryPage extends ConsumerWidget { _buildStatusCard(status), SizedBox(height: 24.h), - // 步骤列表 + // 认证步骤 Text( '认证步骤', style: TextStyle( @@ -88,45 +91,25 @@ class KycEntryPage extends ConsumerWidget { ), 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), + // 层级1: 实名认证 (仅当开启时显示) + if (status.config.level1Enabled) + _buildLevel1Card(context, ref, status), - // 步骤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); - } - }, - ), + // 层级2: 实人认证 (仅当开启时显示) + if (status.config.level2Enabled) ...[ + SizedBox(height: 12.h), + _buildLevel2Card(context, ref, status), + ], + + // 层级3: KYC证件照 (仅当开启时显示) + if (status.config.level3Enabled) ...[ + SizedBox(height: 12.h), + _buildLevel3Card(context, ref, status), + ], SizedBox(height: 24.h), - // 更换手机号入口 + // 其他操作 Text( '其他操作', style: TextStyle( @@ -147,69 +130,38 @@ class KycEntryPage extends ConsumerWidget { 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, - ), - ), - ], - ), - ), + _buildInfoCard(), ], ), ); } + /// 构建状态卡片 Widget _buildStatusCard(KycStatusResponse status) { Color backgroundColor; Color textColor; IconData icon; String statusText; + String? subText; if (status.isCompleted) { backgroundColor = const Color(0xFFE8F5E9); textColor = const Color(0xFF2E7D32); icon = Icons.check_circle; statusText = '认证完成'; + subText = _getCompletedLevelsText(status); } else if (status.statusType == KycStatusType.rejected) { backgroundColor = const Color(0xFFFFEBEE); textColor = const Color(0xFFC62828); icon = Icons.cancel; statusText = '认证被拒绝'; + subText = status.rejectedReason; } else { backgroundColor = const Color(0xFFFFF3E0); textColor = const Color(0xFFE65100); icon = Icons.pending; statusText = '待完成认证'; + subText = _getPendingStepsText(status); } return Container( @@ -234,14 +186,16 @@ class KycEntryPage extends ConsumerWidget { color: textColor, ), ), - if (status.kycVerifiedAt != null) + if (subText != null) ...[ + SizedBox(height: 4.h), Text( - '完成时间: ${_formatDate(status.kycVerifiedAt!)}', + subText, style: TextStyle( fontSize: 12.sp, color: textColor.withValues(alpha: 0.8), ), ), + ], ], ), ), @@ -250,14 +204,132 @@ class KycEntryPage extends ConsumerWidget { ); } + String _getCompletedLevelsText(KycStatusResponse status) { + final List completed = []; + if (status.level1.verified) completed.add('实名认证'); + if (status.level2.verified) completed.add('实人认证'); + if (status.level3.verified) completed.add('KYC认证'); + return '已完成: ${completed.join('、')}'; + } + + String _getPendingStepsText(KycStatusResponse status) { + final List pending = []; + if (status.config.level1Enabled && !status.level1.verified) pending.add('实名认证'); + if (status.config.level2Enabled && !status.level2.verified) pending.add('实人认证'); + if (status.config.level3Enabled && !status.level3.verified) pending.add('KYC认证'); + return '待完成: ${pending.join('、')}'; + } + + /// 层级1: 实名认证卡片 + Widget _buildLevel1Card(BuildContext context, WidgetRef ref, KycStatusResponse status) { + final level1 = status.level1; + final isCompleted = level1.verified; + final isEnabled = !isCompleted; + + return _buildStepCard( + context: context, + ref: ref, + stepNumber: 1, + title: '实名认证', + subtitle: '二要素验证', + description: isCompleted + ? '${level1.realName ?? ''} (${_maskIdCard(level1.idCardNumber)})' + : '验证姓名和身份证号', + isCompleted: isCompleted, + isEnabled: isEnabled, + onTap: () { + if (isEnabled) { + context.push(RoutePaths.kycId); + } + }, + ); + } + + /// 层级2: 实人认证卡片 + Widget _buildLevel2Card(BuildContext context, WidgetRef ref, KycStatusResponse status) { + final level2 = status.level2; + final isCompleted = level2.verified; + // 必须先完成层级1才能进行层级2 + final canStart = level2.canStart && status.level1.verified; + final isEnabled = !isCompleted && canStart; + + String description; + if (isCompleted) { + description = '人脸验证已通过'; + } else if (!status.level1.verified) { + description = '请先完成实名认证'; + } else { + description = '进行人脸活体检测'; + } + + return _buildStepCard( + context: context, + ref: ref, + stepNumber: 2, + title: '实人认证', + subtitle: '人脸活体检测', + description: description, + isCompleted: isCompleted, + isEnabled: isEnabled, + isLocked: !status.level1.verified, + onTap: () { + if (isEnabled) { + context.push(RoutePaths.kycFace); + } + }, + ); + } + + /// 层级3: KYC认证卡片 + Widget _buildLevel3Card(BuildContext context, WidgetRef ref, KycStatusResponse status) { + final level3 = status.level3; + final isCompleted = level3.verified; + // 必须先完成层级1和层级2(如果开启)才能进行层级3 + final needLevel2 = status.config.level2Enabled; + final level2Done = !needLevel2 || status.level2.verified; + final canStart = level3.canStart && status.level1.verified && level2Done; + final isEnabled = !isCompleted && canStart; + + String description; + if (isCompleted) { + description = '证件照验证已通过'; + } else if (!status.level1.verified) { + description = '请先完成实名认证'; + } else if (needLevel2 && !status.level2.verified) { + description = '请先完成实人认证'; + } else { + description = '上传身份证正反面照片'; + } + + return _buildStepCard( + context: context, + ref: ref, + stepNumber: 3, + title: 'KYC认证', + subtitle: '证件照验证', + description: description, + isCompleted: isCompleted, + isEnabled: isEnabled, + isLocked: !status.level1.verified || (needLevel2 && !status.level2.verified), + onTap: () { + if (isEnabled) { + context.push(RoutePaths.kycIdCard); + } + }, + ); + } + + /// 构建步骤卡片 Widget _buildStepCard({ required BuildContext context, required WidgetRef ref, required int stepNumber, required String title, + required String subtitle, required String description, required bool isCompleted, required bool isEnabled, + bool isLocked = false, bool isRejected = false, String? rejectedReason, required VoidCallback onTap, @@ -276,34 +348,45 @@ class KycEntryPage extends ConsumerWidget { ? Colors.red : const Color(0xFFE0E0E0), ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], ), child: Row( children: [ // 步骤圆圈 Container( - width: 32.w, - height: 32.w, + width: 40.w, + height: 40.w, decoration: BoxDecoration( shape: BoxShape.circle, color: isCompleted ? const Color(0xFF2E7D32) : isRejected ? Colors.red - : const Color(0xFFE0E0E0), + : isLocked + ? const Color(0xFFBDBDBD) + : const Color(0xFFE0E0E0), ), child: Center( child: isCompleted - ? Icon(Icons.check, size: 18.sp, color: Colors.white) + ? Icon(Icons.check, size: 20.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, - ), - ), + ? Icon(Icons.close, size: 20.sp, color: Colors.white) + : isLocked + ? Icon(Icons.lock, size: 18.sp, color: Colors.white) + : Text( + '$stepNumber', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), ), ), SizedBox(width: 12.w), @@ -313,34 +396,67 @@ class KycEntryPage extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - title, - style: TextStyle( - fontSize: 15.sp, - fontWeight: FontWeight.w500, - color: const Color(0xFF333333), - ), + Row( + children: [ + Text( + title, + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w600, + color: isLocked + ? const Color(0xFF999999) + : const Color(0xFF333333), + ), + ), + SizedBox(width: 8.w), + Container( + padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 2.h), + decoration: BoxDecoration( + color: isCompleted + ? const Color(0xFFE8F5E9) + : const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(4.r), + ), + child: Text( + subtitle, + style: TextStyle( + fontSize: 10.sp, + color: isCompleted + ? const Color(0xFF2E7D32) + : const Color(0xFF666666), + ), + ), + ), + ], ), - SizedBox(height: 2.h), + SizedBox(height: 4.h), Text( - isRejected && rejectedReason != null - ? rejectedReason - : description, + isRejected && rejectedReason != null ? rejectedReason : description, style: TextStyle( fontSize: 12.sp, - color: isRejected ? Colors.red : const Color(0xFF999999), + color: isRejected + ? Colors.red + : isLocked + ? const Color(0xFFBDBDBD) + : const Color(0xFF999999), ), ), ], ), ), - // 箭头 + // 箭头或状态图标 if (isEnabled) Icon( Icons.chevron_right, size: 24.sp, color: const Color(0xFF999999), + ) + else if (isCompleted) + Icon( + Icons.verified, + size: 24.sp, + color: const Color(0xFF2E7D32), ), ], ), @@ -348,6 +464,7 @@ class KycEntryPage extends ConsumerWidget { ); } + /// 构建操作卡片 Widget _buildActionCard({ required BuildContext context, required IconData icon, @@ -410,7 +527,53 @@ class KycEntryPage extends ConsumerWidget { ); } - String _formatDate(DateTime date) { - return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + /// 构建说明卡片 + Widget _buildInfoCard() { + return 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' + '• 实人认证: 通过人脸活体检测确认本人\n' + '• KYC认证: 上传身份证正反面照片\n' + '• 您的信息将被加密存储,不会泄露给第三方', + style: TextStyle( + fontSize: 12.sp, + color: const Color(0xFF666666), + height: 1.5, + ), + ), + ], + ), + ); + } + + /// 脱敏身份证号 + String _maskIdCard(String? idCard) { + if (idCard == null || idCard.isEmpty) return ''; + if (idCard.length < 8) return '****'; + return '${idCard.substring(0, 4)}****${idCard.substring(idCard.length - 4)}'; } } diff --git a/frontend/mobile-app/lib/features/kyc/presentation/pages/kyc_face_page.dart b/frontend/mobile-app/lib/features/kyc/presentation/pages/kyc_face_page.dart new file mode 100644 index 00000000..000eb92d --- /dev/null +++ b/frontend/mobile-app/lib/features/kyc/presentation/pages/kyc_face_page.dart @@ -0,0 +1,534 @@ +import 'dart:async'; +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 'package:url_launcher/url_launcher.dart'; +import 'kyc_entry_page.dart'; + +/// KYC 层级2: 实人认证页面 (人脸活体检测) +class KycFacePage extends ConsumerStatefulWidget { + const KycFacePage({super.key}); + + @override + ConsumerState createState() => _KycFacePageState(); +} + +class _KycFacePageState extends ConsumerState { + bool _isInitializing = false; + bool _isQuerying = false; + String? _errorMessage; + String? _certifyId; + String? _certifyUrl; + + Timer? _queryTimer; + int _queryCount = 0; + static const int _maxQueryCount = 30; // 最多查询30次 (1分钟) + + @override + void dispose() { + _queryTimer?.cancel(); + super.dispose(); + } + + /// 初始化人脸认证 + Future _initFaceVerification() async { + setState(() { + _isInitializing = true; + _errorMessage = null; + }); + + try { + final kycService = ref.read(kycServiceProvider); + final result = await kycService.initFaceVerification(); + + if (!mounted) return; + + if (result.success && result.certifyUrl != null) { + setState(() { + _certifyId = result.certifyId; + _certifyUrl = result.certifyUrl; + }); + + // 打开认证URL + await _openCertifyUrl(result.certifyUrl!); + + // 开始轮询查询结果 + _startQueryingResult(); + } else { + setState(() { + _errorMessage = '初始化人脸认证失败,请稍后重试'; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _errorMessage = e.toString().replaceAll('Exception: ', ''); + }); + } + } finally { + if (mounted) { + setState(() { + _isInitializing = false; + }); + } + } + } + + /// 打开认证URL + Future _openCertifyUrl(String url) async { + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } else { + setState(() { + _errorMessage = '无法打开认证页面'; + }); + } + } + + /// 开始轮询查询结果 + void _startQueryingResult() { + _queryCount = 0; + _queryTimer?.cancel(); + _queryTimer = Timer.periodic(const Duration(seconds: 2), (timer) { + _queryResult(); + }); + } + + /// 查询认证结果 + Future _queryResult() async { + if (_certifyId == null || _isQuerying) return; + + _queryCount++; + if (_queryCount > _maxQueryCount) { + _queryTimer?.cancel(); + setState(() { + _errorMessage = '认证超时,请重新开始'; + _certifyId = null; + _certifyUrl = null; + }); + return; + } + + setState(() { + _isQuerying = true; + }); + + try { + final kycService = ref.read(kycServiceProvider); + final result = await kycService.queryFaceVerification(_certifyId!); + + if (!mounted) return; + + if (result.passed) { + _queryTimer?.cancel(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('实人认证成功', style: TextStyle(fontSize: 14.sp)), + backgroundColor: const Color(0xFF2E7D32), + behavior: SnackBarBehavior.floating, + ), + ); + ref.invalidate(kycStatusProvider); + context.pop(true); + } else if (result.status == 'FAILED') { + _queryTimer?.cancel(); + setState(() { + _errorMessage = result.errorMessage ?? '认证失败,请重新尝试'; + _certifyId = null; + _certifyUrl = null; + }); + } + // PENDING 状态继续轮询 + } catch (e) { + // 查询失败继续轮询,不中断 + } finally { + if (mounted) { + setState(() { + _isQuerying = false; + }); + } + } + } + + /// 手动查询结果 + Future _manualQueryResult() async { + if (_certifyId == null) return; + + setState(() { + _isQuerying = true; + _errorMessage = null; + }); + + try { + final kycService = ref.read(kycServiceProvider); + final result = await kycService.queryFaceVerification(_certifyId!); + + if (!mounted) return; + + if (result.passed) { + _queryTimer?.cancel(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('实人认证成功', style: TextStyle(fontSize: 14.sp)), + backgroundColor: const Color(0xFF2E7D32), + behavior: SnackBarBehavior.floating, + ), + ); + ref.invalidate(kycStatusProvider); + context.pop(true); + } else if (result.status == 'FAILED') { + setState(() { + _errorMessage = result.errorMessage ?? '认证失败,请重新尝试'; + _certifyId = null; + _certifyUrl = null; + }); + } else { + setState(() { + _errorMessage = '认证尚未完成,请完成人脸识别后再试'; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _errorMessage = e.toString().replaceAll('Exception: ', ''); + }); + } + } finally { + if (mounted) { + setState(() { + _isQuerying = 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: () { + _queryTimer?.cancel(); + context.pop(); + }, + ), + title: Text( + '实人认证', + style: TextStyle( + fontSize: 18.sp, + fontWeight: FontWeight.w600, + color: const Color(0xFF333333), + ), + ), + centerTitle: true, + ), + body: SafeArea( + child: SingleChildScrollView( + padding: EdgeInsets.symmetric(horizontal: 24.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox(height: 24.h), + + // 步骤指示 + _buildStepIndicator(), + SizedBox(height: 40.h), + + // 人脸图标 + Container( + width: 120.w, + height: 120.w, + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + shape: BoxShape.circle, + ), + child: Icon( + Icons.face, + size: 60.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( + '请确保光线充足,面部无遮挡', + style: TextStyle( + fontSize: 14.sp, + color: const Color(0xFF999999), + ), + ), + SizedBox(height: 32.h), + + // 说明步骤 + _buildInstructionCard(), + SizedBox(height: 24.h), + + // 错误信息 + if (_errorMessage != null) ...[ + 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: 24.h), + ], + + // 等待验证状态 + if (_certifyId != null && _certifyUrl != null) ...[ + Container( + padding: EdgeInsets.all(16.w), + decoration: BoxDecoration( + color: const Color(0xFFE3F2FD), + borderRadius: BorderRadius.circular(8.r), + ), + child: Column( + children: [ + if (_isQuerying) + SizedBox( + width: 24.sp, + height: 24.sp, + child: const CircularProgressIndicator(strokeWidth: 2), + ) + else + Icon(Icons.hourglass_empty, size: 24.sp, color: const Color(0xFF1976D2)), + SizedBox(height: 8.h), + Text( + '等待人脸验证结果...', + style: TextStyle( + fontSize: 14.sp, + color: const Color(0xFF1976D2), + ), + ), + SizedBox(height: 4.h), + Text( + '请在浏览器中完成人脸识别', + style: TextStyle( + fontSize: 12.sp, + color: const Color(0xFF666666), + ), + ), + ], + ), + ), + SizedBox(height: 16.h), + + // 重新打开链接按钮 + TextButton( + onPressed: () => _openCertifyUrl(_certifyUrl!), + child: Text( + '重新打开认证页面', + style: TextStyle( + fontSize: 14.sp, + color: const Color(0xFF2E7D32), + ), + ), + ), + SizedBox(height: 8.h), + + // 手动查询按钮 + GestureDetector( + onTap: _isQuerying ? null : _manualQueryResult, + child: Container( + width: double.infinity, + padding: EdgeInsets.symmetric(vertical: 16.h), + decoration: BoxDecoration( + color: _isQuerying + ? const Color(0xFFCCCCCC) + : const Color(0xFF2E7D32), + borderRadius: BorderRadius.circular(12.r), + ), + child: Text( + '我已完成认证', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + textAlign: TextAlign.center, + ), + ), + ), + ] else ...[ + // 开始验证按钮 + GestureDetector( + onTap: _isInitializing ? null : _initFaceVerification, + child: Container( + width: double.infinity, + padding: EdgeInsets.symmetric(vertical: 16.h), + decoration: BoxDecoration( + color: _isInitializing + ? const Color(0xFFCCCCCC) + : const Color(0xFF2E7D32), + borderRadius: BorderRadius.circular(12.r), + ), + child: _isInitializing + ? 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 _buildStepIndicator() { + return Row( + children: [ + _buildStepDot(1, false, true), + Expanded(child: _buildStepLine(true)), + _buildStepDot(2, true, false), + Expanded(child: _buildStepLine(false)), + _buildStepDot(3, false, false), + ], + ); + } + + Widget _buildStepDot(int step, bool isActive, bool isCompleted) { + return Container( + width: 28.w, + height: 28.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isCompleted + ? const Color(0xFF2E7D32) + : isActive + ? const Color(0xFF2E7D32) + : const Color(0xFFE0E0E0), + ), + child: Center( + child: isCompleted + ? Icon(Icons.check, size: 16.sp, color: Colors.white) + : Text( + '$step', + style: TextStyle( + fontSize: 12.sp, + fontWeight: FontWeight.w600, + color: isActive ? Colors.white : const Color(0xFF999999), + ), + ), + ), + ); + } + + Widget _buildStepLine(bool isCompleted) { + return Container( + height: 2.h, + color: isCompleted ? const Color(0xFF2E7D32) : const Color(0xFFE0E0E0), + ); + } + + Widget _buildInstructionCard() { + return Container( + padding: EdgeInsets.all(16.w), + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(12.r), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '认证步骤', + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w600, + color: const Color(0xFF333333), + ), + ), + SizedBox(height: 12.h), + _buildInstructionItem('1', '点击"开始人脸认证"按钮'), + SizedBox(height: 8.h), + _buildInstructionItem('2', '在弹出的页面中完成人脸识别'), + SizedBox(height: 8.h), + _buildInstructionItem('3', '返回APP等待验证结果'), + ], + ), + ); + } + + Widget _buildInstructionItem(String number, String text) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 20.w, + height: 20.w, + decoration: BoxDecoration( + color: const Color(0xFF2E7D32), + shape: BoxShape.circle, + ), + child: Center( + child: Text( + number, + style: TextStyle( + fontSize: 11.sp, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ), + SizedBox(width: 8.w), + Expanded( + child: Text( + text, + style: TextStyle( + fontSize: 13.sp, + color: const Color(0xFF666666), + ), + ), + ), + ], + ); + } +} diff --git a/frontend/mobile-app/lib/features/kyc/presentation/pages/kyc_id_card_page.dart b/frontend/mobile-app/lib/features/kyc/presentation/pages/kyc_id_card_page.dart new file mode 100644 index 00000000..ca8ae94c --- /dev/null +++ b/frontend/mobile-app/lib/features/kyc/presentation/pages/kyc_id_card_page.dart @@ -0,0 +1,763 @@ +import 'dart:typed_data'; +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 'package:image_picker/image_picker.dart'; +import 'kyc_entry_page.dart'; +import '../../data/kyc_service.dart'; + +/// KYC 层级3: 证件照上传页面 +class KycIdCardPage extends ConsumerStatefulWidget { + const KycIdCardPage({super.key}); + + @override + ConsumerState createState() => _KycIdCardPageState(); +} + +class _KycIdCardPageState extends ConsumerState { + final ImagePicker _picker = ImagePicker(); + + Uint8List? _frontImage; + Uint8List? _backImage; + + bool _isUploadingFront = false; + bool _isUploadingBack = false; + bool _isSubmitting = false; + String? _errorMessage; + + // OCR 识别结果 + IdCardOcrResult? _ocrResult; + + /// 选择并上传图片 + Future _pickAndUploadImage(String side) async { + try { + final XFile? image = await _picker.pickImage( + source: ImageSource.camera, + imageQuality: 80, + maxWidth: 1920, + maxHeight: 1080, + ); + + if (image == null) return; + + final bytes = await image.readAsBytes(); + final fileName = image.name; + + if (side == 'front') { + setState(() { + _frontImage = bytes; + _isUploadingFront = true; + _errorMessage = null; + }); + } else { + setState(() { + _backImage = bytes; + _isUploadingBack = true; + _errorMessage = null; + }); + } + + // 上传图片 + final kycService = ref.read(kycServiceProvider); + final result = await kycService.uploadIdCardPhoto( + side: side, + imageBytes: bytes, + fileName: fileName, + ); + + if (!mounted) return; + + if (result.success) { + if (side == 'front' && result.ocrResult != null) { + setState(() { + _ocrResult = result.ocrResult; + }); + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '${side == 'front' ? '正面' : '反面'}照片上传成功', + style: TextStyle(fontSize: 14.sp), + ), + backgroundColor: const Color(0xFF2E7D32), + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 1), + ), + ); + } else { + setState(() { + _errorMessage = '照片上传失败,请重试'; + if (side == 'front') { + _frontImage = null; + } else { + _backImage = null; + } + }); + } + } catch (e) { + if (mounted) { + setState(() { + _errorMessage = e.toString().replaceAll('Exception: ', ''); + }); + } + } finally { + if (mounted) { + setState(() { + if (side == 'front') { + _isUploadingFront = false; + } else { + _isUploadingBack = false; + } + }); + } + } + } + + /// 从相册选择图片 + Future _pickFromGallery(String side) async { + try { + final XFile? image = await _picker.pickImage( + source: ImageSource.gallery, + imageQuality: 80, + maxWidth: 1920, + maxHeight: 1080, + ); + + if (image == null) return; + + final bytes = await image.readAsBytes(); + final fileName = image.name; + + if (side == 'front') { + setState(() { + _frontImage = bytes; + _isUploadingFront = true; + _errorMessage = null; + }); + } else { + setState(() { + _backImage = bytes; + _isUploadingBack = true; + _errorMessage = null; + }); + } + + // 上传图片 + final kycService = ref.read(kycServiceProvider); + final result = await kycService.uploadIdCardPhoto( + side: side, + imageBytes: bytes, + fileName: fileName, + ); + + if (!mounted) return; + + if (result.success) { + if (side == 'front' && result.ocrResult != null) { + setState(() { + _ocrResult = result.ocrResult; + }); + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '${side == 'front' ? '正面' : '反面'}照片上传成功', + style: TextStyle(fontSize: 14.sp), + ), + backgroundColor: const Color(0xFF2E7D32), + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 1), + ), + ); + } else { + setState(() { + _errorMessage = '照片上传失败,请重试'; + if (side == 'front') { + _frontImage = null; + } else { + _backImage = null; + } + }); + } + } catch (e) { + if (mounted) { + setState(() { + _errorMessage = e.toString().replaceAll('Exception: ', ''); + }); + } + } finally { + if (mounted) { + setState(() { + if (side == 'front') { + _isUploadingFront = false; + } else { + _isUploadingBack = false; + } + }); + } + } + } + + /// 显示图片来源选择 + void _showImageSourceDialog(String side) { + showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16.r)), + ), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox(height: 8.h), + Container( + width: 40.w, + height: 4.h, + decoration: BoxDecoration( + color: const Color(0xFFE0E0E0), + borderRadius: BorderRadius.circular(2.r), + ), + ), + SizedBox(height: 16.h), + Text( + '选择图片来源', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + color: const Color(0xFF333333), + ), + ), + SizedBox(height: 16.h), + ListTile( + leading: const Icon(Icons.camera_alt, color: Color(0xFF2E7D32)), + title: Text('拍照', style: TextStyle(fontSize: 16.sp)), + onTap: () { + Navigator.pop(context); + _pickAndUploadImage(side); + }, + ), + ListTile( + leading: const Icon(Icons.photo_library, color: Color(0xFF2E7D32)), + title: Text('从相册选择', style: TextStyle(fontSize: 16.sp)), + onTap: () { + Navigator.pop(context); + _pickFromGallery(side); + }, + ), + SizedBox(height: 16.h), + ], + ), + ), + ); + } + + /// 提交 KYC + Future _submit() async { + if (_frontImage == null || _backImage == null) { + setState(() { + _errorMessage = '请上传身份证正反面照片'; + }); + return; + } + + setState(() { + _isSubmitting = true; + _errorMessage = null; + }); + + try { + final kycService = ref.read(kycServiceProvider); + final result = await kycService.confirmKycSubmission(); + + if (!mounted) return; + + if (result.success) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('KYC认证提交成功', style: TextStyle(fontSize: 14.sp)), + backgroundColor: const Color(0xFF2E7D32), + behavior: SnackBarBehavior.floating, + ), + ); + ref.invalidate(kycStatusProvider); + context.pop(true); + } else { + setState(() { + _errorMessage = result.message; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _errorMessage = e.toString().replaceAll('Exception: ', ''); + }); + } + } finally { + if (mounted) { + setState(() { + _isSubmitting = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final canSubmit = _frontImage != null && + _backImage != null && + !_isUploadingFront && + !_isUploadingBack; + + 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( + 'KYC认证', + style: TextStyle( + fontSize: 18.sp, + fontWeight: FontWeight.w600, + color: const Color(0xFF333333), + ), + ), + centerTitle: true, + ), + body: SafeArea( + child: SingleChildScrollView( + padding: EdgeInsets.symmetric(horizontal: 24.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: 24.h), + + // 步骤指示 + _buildStepIndicator(), + 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: 24.h), + + // 身份证正面 + Text( + '身份证正面(人像面)', + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w500, + color: const Color(0xFF333333), + ), + ), + SizedBox(height: 8.h), + _buildImageUploadCard( + side: 'front', + image: _frontImage, + isUploading: _isUploadingFront, + placeholder: '点击拍摄或选择身份证正面', + icon: Icons.credit_card, + ), + SizedBox(height: 16.h), + + // 身份证反面 + Text( + '身份证反面(国徽面)', + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w500, + color: const Color(0xFF333333), + ), + ), + SizedBox(height: 8.h), + _buildImageUploadCard( + side: 'back', + image: _backImage, + isUploading: _isUploadingBack, + placeholder: '点击拍摄或选择身份证反面', + icon: Icons.credit_card, + ), + + // OCR 识别结果 + if (_ocrResult != null) ...[ + SizedBox(height: 24.h), + _buildOcrResultCard(), + ], + + // 错误信息 + 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: 24.h), + + // 提示信息 + Container( + padding: EdgeInsets.all(12.w), + decoration: BoxDecoration( + color: const Color(0xFFFFF3E0), + borderRadius: BorderRadius.circular(8.r), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.info_outline, + size: 18.sp, + color: const Color(0xFFE65100), + ), + SizedBox(width: 8.w), + Text( + '拍摄提示', + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w500, + color: const Color(0xFFE65100), + ), + ), + ], + ), + SizedBox(height: 8.h), + Text( + '• 请确保证件在有效期内\n' + '• 照片清晰完整,四角完整可见\n' + '• 避免反光、阴影和遮挡\n' + '• 照片仅用于身份验证,我们会严格保护您的隐私', + style: TextStyle( + fontSize: 12.sp, + color: const Color(0xFFE65100), + height: 1.5, + ), + ), + ], + ), + ), + + SizedBox(height: 32.h), + + // 提交按钮 + GestureDetector( + onTap: (canSubmit && !_isSubmitting) ? _submit : null, + child: Container( + width: double.infinity, + padding: EdgeInsets.symmetric(vertical: 16.h), + decoration: BoxDecoration( + color: (canSubmit && !_isSubmitting) + ? const Color(0xFF2E7D32) + : const Color(0xFFCCCCCC), + 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 _buildStepIndicator() { + return Row( + children: [ + _buildStepDot(1, false, true), + Expanded(child: _buildStepLine(true)), + _buildStepDot(2, false, true), + Expanded(child: _buildStepLine(true)), + _buildStepDot(3, true, false), + ], + ); + } + + Widget _buildStepDot(int step, bool isActive, bool isCompleted) { + return Container( + width: 28.w, + height: 28.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isCompleted + ? const Color(0xFF2E7D32) + : isActive + ? const Color(0xFF2E7D32) + : const Color(0xFFE0E0E0), + ), + child: Center( + child: isCompleted + ? Icon(Icons.check, size: 16.sp, color: Colors.white) + : Text( + '$step', + style: TextStyle( + fontSize: 12.sp, + fontWeight: FontWeight.w600, + color: isActive ? Colors.white : const Color(0xFF999999), + ), + ), + ), + ); + } + + Widget _buildStepLine(bool isCompleted) { + return Container( + height: 2.h, + color: isCompleted ? const Color(0xFF2E7D32) : const Color(0xFFE0E0E0), + ); + } + + Widget _buildImageUploadCard({ + required String side, + required Uint8List? image, + required bool isUploading, + required String placeholder, + required IconData icon, + }) { + return GestureDetector( + onTap: isUploading ? null : () => _showImageSourceDialog(side), + child: Container( + width: double.infinity, + height: 160.h, + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(12.r), + border: Border.all( + color: image != null ? const Color(0xFF2E7D32) : const Color(0xFFE0E0E0), + width: image != null ? 2 : 1, + ), + ), + child: isUploading + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 32.sp, + height: 32.sp, + child: const CircularProgressIndicator(strokeWidth: 3), + ), + SizedBox(height: 12.h), + Text( + '上传中...', + style: TextStyle( + fontSize: 14.sp, + color: const Color(0xFF666666), + ), + ), + ], + ), + ) + : image != null + ? Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(11.r), + child: Image.memory( + image, + width: double.infinity, + height: double.infinity, + fit: BoxFit.cover, + ), + ), + Positioned( + top: 8.h, + right: 8.w, + child: Container( + padding: EdgeInsets.all(4.w), + decoration: const BoxDecoration( + color: Color(0xFF2E7D32), + shape: BoxShape.circle, + ), + child: Icon( + Icons.check, + size: 16.sp, + color: Colors.white, + ), + ), + ), + Positioned( + bottom: 8.h, + right: 8.w, + child: GestureDetector( + onTap: () => _showImageSourceDialog(side), + child: Container( + padding: EdgeInsets.symmetric( + horizontal: 12.w, + vertical: 6.h, + ), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(16.r), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.refresh, + size: 14.sp, + color: Colors.white, + ), + SizedBox(width: 4.w), + Text( + '重拍', + style: TextStyle( + fontSize: 12.sp, + color: Colors.white, + ), + ), + ], + ), + ), + ), + ), + ], + ) + : Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 48.sp, + color: const Color(0xFFBDBDBD), + ), + SizedBox(height: 12.h), + Text( + placeholder, + style: TextStyle( + fontSize: 14.sp, + color: const Color(0xFF999999), + ), + ), + ], + ), + ), + ); + } + + Widget _buildOcrResultCard() { + return Container( + padding: EdgeInsets.all(16.w), + decoration: BoxDecoration( + color: const Color(0xFFE8F5E9), + borderRadius: BorderRadius.circular(12.r), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.verified, size: 18.sp, color: const Color(0xFF2E7D32)), + SizedBox(width: 8.w), + Text( + 'OCR识别结果', + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w600, + color: const Color(0xFF2E7D32), + ), + ), + ], + ), + SizedBox(height: 12.h), + if (_ocrResult!.name != null) + _buildOcrItem('姓名', _ocrResult!.name!), + if (_ocrResult!.idNumber != null) + _buildOcrItem('身份证号', _maskIdNumber(_ocrResult!.idNumber!)), + if (_ocrResult!.address != null) + _buildOcrItem('地址', _ocrResult!.address!), + ], + ), + ); + } + + Widget _buildOcrItem(String label, String value) { + return Padding( + padding: EdgeInsets.only(bottom: 4.h), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 60.w, + child: Text( + label, + style: TextStyle( + fontSize: 12.sp, + color: const Color(0xFF666666), + ), + ), + ), + Expanded( + child: Text( + value, + style: TextStyle( + fontSize: 12.sp, + color: const Color(0xFF333333), + ), + ), + ), + ], + ), + ); + } + + String _maskIdNumber(String idNumber) { + if (idNumber.length < 8) return '****'; + return '${idNumber.substring(0, 4)}****${idNumber.substring(idNumber.length - 4)}'; + } +} 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 index 5b3f23a7..1be32d4f 100644 --- 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 @@ -4,7 +4,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:go_router/go_router.dart'; import 'kyc_entry_page.dart'; -/// KYC 身份证验证页面 +/// KYC 层级1: 实名认证页面 (二要素验证) class KycIdPage extends ConsumerStatefulWidget { const KycIdPage({super.key}); @@ -67,26 +67,27 @@ class _KycIdPageState extends ConsumerState { try { final kycService = ref.read(kycServiceProvider); - final result = await kycService.submitIdVerification( + final result = await kycService.submitRealNameVerification( realName: _nameController.text.trim(), idCardNumber: _idCardController.text.trim().toUpperCase(), ); if (!mounted) return; - if (result.isSuccess) { + if (result.success) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('身份验证成功', style: TextStyle(fontSize: 14.sp)), + content: Text('实名认证成功', style: TextStyle(fontSize: 14.sp)), backgroundColor: const Color(0xFF2E7D32), behavior: SnackBarBehavior.floating, ), ); - // 返回并刷新 + // 刷新状态并返回 + ref.invalidate(kycStatusProvider); context.pop(true); } else { setState(() { - _errorMessage = result.failureReason ?? '验证失败,请检查信息是否正确'; + _errorMessage = result.errorMessage ?? '验证失败,请检查信息是否正确'; }); } } catch (e) { @@ -116,7 +117,7 @@ class _KycIdPageState extends ConsumerState { onPressed: () => context.pop(), ), title: Text( - '身份证验证', + '实名认证', style: TextStyle( fontSize: 18.sp, fontWeight: FontWeight.w600, @@ -135,9 +136,14 @@ class _KycIdPageState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SizedBox(height: 32.h), + SizedBox(height: 24.h), + + // 步骤指示 + _buildStepIndicator(), + SizedBox(height: 24.h), + Text( - '实名认证', + '二要素验证', style: TextStyle( fontSize: 24.sp, fontWeight: FontWeight.w700, @@ -218,7 +224,7 @@ class _KycIdPageState extends ConsumerState { SizedBox(width: 8.w), Expanded( child: Text( - '您的身份信息将被加密存储,仅用于实名认证和合同签署,不会泄露给任何第三方。', + '您的身份信息将通过权威数据源进行验证,信息将被加密存储,不会泄露给任何第三方。', style: TextStyle( fontSize: 12.sp, color: const Color(0xFFE65100), @@ -276,6 +282,52 @@ class _KycIdPageState extends ConsumerState { ); } + Widget _buildStepIndicator() { + return Row( + children: [ + _buildStepDot(1, true, false), + Expanded(child: _buildStepLine(false)), + _buildStepDot(2, false, false), + Expanded(child: _buildStepLine(false)), + _buildStepDot(3, false, false), + ], + ); + } + + Widget _buildStepDot(int step, bool isActive, bool isCompleted) { + return Container( + width: 28.w, + height: 28.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isCompleted + ? const Color(0xFF2E7D32) + : isActive + ? const Color(0xFF2E7D32) + : const Color(0xFFE0E0E0), + ), + child: Center( + child: isCompleted + ? Icon(Icons.check, size: 16.sp, color: Colors.white) + : Text( + '$step', + style: TextStyle( + fontSize: 12.sp, + fontWeight: FontWeight.w600, + color: isActive ? Colors.white : const Color(0xFF999999), + ), + ), + ), + ); + } + + Widget _buildStepLine(bool isCompleted) { + return Container( + height: 2.h, + color: isCompleted ? const Color(0xFF2E7D32) : const Color(0xFFE0E0E0), + ); + } + Widget _buildInputField({ required String label, required String hint, 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 index 92eff4b0..4d2cae35 100644 --- 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 @@ -326,7 +326,7 @@ class _KycPhonePageState extends ConsumerState { child: GestureDetector( onTap: canResend ? _sendCode : null, child: Text( - canResend ? '重新发送验证码' : '${_countdown}秒后重新发送', + canResend ? '重新发送验证码' : '$_countdown秒后重新发送', style: TextStyle( fontSize: 14.sp, fontWeight: FontWeight.w500, diff --git a/frontend/mobile-app/lib/routes/app_router.dart b/frontend/mobile-app/lib/routes/app_router.dart index 702f0717..d49044a9 100644 --- a/frontend/mobile-app/lib/routes/app_router.dart +++ b/frontend/mobile-app/lib/routes/app_router.dart @@ -36,6 +36,8 @@ 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/kyc_face_page.dart'; +import '../features/kyc/presentation/pages/kyc_id_card_page.dart'; import '../features/kyc/presentation/pages/change_phone_page.dart'; import 'route_paths.dart'; import 'route_names.dart'; @@ -374,13 +376,27 @@ final appRouterProvider = Provider((ref) { builder: (context, state) => const KycPhonePage(), ), - // KYC ID Verification Page (身份证验证) + // KYC ID Verification Page (层级1: 实名认证 - 二要素) GoRoute( path: RoutePaths.kycId, name: RouteNames.kycId, builder: (context, state) => const KycIdPage(), ), + // KYC Face Verification Page (层级2: 实人认证 - 人脸活体) + GoRoute( + path: RoutePaths.kycFace, + name: RouteNames.kycFace, + builder: (context, state) => const KycFacePage(), + ), + + // KYC ID Card Upload Page (层级3: KYC - 证件照上传) + GoRoute( + path: RoutePaths.kycIdCard, + name: RouteNames.kycIdCard, + builder: (context, state) => const KycIdCardPage(), + ), + // Change Phone Page (更换手机号) GoRoute( path: RoutePaths.changePhone, diff --git a/frontend/mobile-app/lib/routes/route_names.dart b/frontend/mobile-app/lib/routes/route_names.dart index 12040da5..78e36aa0 100644 --- a/frontend/mobile-app/lib/routes/route_names.dart +++ b/frontend/mobile-app/lib/routes/route_names.dart @@ -45,9 +45,11 @@ class RouteNames { // Share static const share = 'share'; - // KYC (实名认证) + // KYC (实名认证) - 三层认证 static const kycEntry = 'kyc-entry'; static const kycPhone = 'kyc-phone'; - static const kycId = 'kyc-id'; + static const kycId = 'kyc-id'; // 层级1: 实名认证 (二要素) + static const kycFace = 'kyc-face'; // 层级2: 实人认证 (人脸活体) + static const kycIdCard = 'kyc-id-card'; // 层级3: KYC (证件照) 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 633eecd0..cbab2e8e 100644 --- a/frontend/mobile-app/lib/routes/route_paths.dart +++ b/frontend/mobile-app/lib/routes/route_paths.dart @@ -45,9 +45,11 @@ class RoutePaths { // Share static const share = '/share'; - // KYC (实名认证) + // KYC (实名认证) - 三层认证 static const kycEntry = '/kyc'; static const kycPhone = '/kyc/phone'; - static const kycId = '/kyc/id'; + static const kycId = '/kyc/id'; // 层级1: 实名认证 (二要素) + static const kycFace = '/kyc/face'; // 层级2: 实人认证 (人脸活体) + static const kycIdCard = '/kyc/id-card'; // 层级3: KYC (证件照) static const changePhone = '/kyc/change-phone'; }