diff --git a/backend/services/identity-service/prisma/schema.prisma b/backend/services/identity-service/prisma/schema.prisma index 6837936a..dcebf8de 100644 --- a/backend/services/identity-service/prisma/schema.prisma +++ b/backend/services/identity-service/prisma/schema.prisma @@ -12,6 +12,7 @@ model UserAccount { accountSequence String @unique @map("account_sequence") @db.VarChar(12) // 格式: D + YYMMDD + 5位序号 phoneNumber String? @unique @map("phone_number") @db.VarChar(20) + email String? @unique @db.VarChar(100) // 绑定的邮箱地址 passwordHash String? @map("password_hash") @db.VarChar(100) // bcrypt 哈希密码 nickname String @db.VarChar(100) avatarUrl String? @map("avatar_url") @db.Text @@ -36,6 +37,7 @@ model UserAccount { walletAddresses WalletAddress[] @@index([phoneNumber], name: "idx_phone") + @@index([email], name: "idx_email") @@index([accountSequence], name: "idx_sequence") @@index([referralCode], name: "idx_referral_code") @@index([inviterSequence], name: "idx_inviter") @@ -186,6 +188,22 @@ model SmsCode { @@map("sms_codes") } +// 邮箱验证码 +model EmailCode { + id BigInt @id @default(autoincrement()) + email String @db.VarChar(100) + code String @db.VarChar(10) + purpose String @db.VarChar(50) // BIND_EMAIL, UNBIND_EMAIL + + expiresAt DateTime @map("expires_at") + createdAt DateTime @default(now()) @map("created_at") + usedAt DateTime? @map("used_at") + + @@index([email, purpose], name: "idx_email_purpose") + @@index([expiresAt], name: "idx_email_expires") + @@map("email_codes") +} + // MPC 密钥分片存储 - 服务端持有的分片 model MpcKeyShare { shareId BigInt @id @default(autoincrement()) @map("share_id") diff --git a/backend/services/identity-service/src/api/controllers/user-account.controller.ts b/backend/services/identity-service/src/api/controllers/user-account.controller.ts index d18e01ea..e268cffa 100644 --- a/backend/services/identity-service/src/api/controllers/user-account.controller.ts +++ b/backend/services/identity-service/src/api/controllers/user-account.controller.ts @@ -51,6 +51,9 @@ import { VerifySmsCodeCommand, SetPasswordCommand, ChangePasswordCommand, + SendEmailCodeCommand, + BindEmailCommand, + UnbindEmailCommand, } from '@/application/commands'; import { AutoCreateAccountDto, @@ -84,6 +87,9 @@ import { ChangePasswordDto, LoginWithPasswordDto, ResetPasswordDto, + SendEmailCodeDto, + BindEmailDto, + UnbindEmailDto, } from '@/api/dto'; @ApiTags('User') @@ -315,6 +321,82 @@ export class UserAccountController { return { message: '密码修改成功' }; } + // ============ 邮箱绑定相关 ============ + + @Get('email-status') + @ApiBearerAuth() + @ApiOperation({ + summary: '获取邮箱绑定状态', + description: '查询当前用户的邮箱绑定状态和脱敏后的邮箱地址', + }) + @ApiResponse({ + status: 200, + description: '邮箱状态', + schema: { + properties: { + isBound: { type: 'boolean', description: '是否已绑定邮箱' }, + email: { type: 'string', nullable: true, description: '脱敏后的邮箱地址' }, + }, + }, + }) + async getEmailStatus(@CurrentUser() user: CurrentUserData) { + return this.userService.getEmailStatus(user.userId); + } + + @Post('send-email-code') + @ApiBearerAuth() + @ApiOperation({ + summary: '发送邮箱验证码', + description: '发送绑定/解绑邮箱的验证码到指定邮箱', + }) + @ApiResponse({ status: 200, description: '验证码发送成功' }) + @ApiResponse({ status: 400, description: '邮箱已被其他账户绑定' }) + async sendEmailCode( + @CurrentUser() user: CurrentUserData, + @Body() dto: SendEmailCodeDto, + ) { + await this.userService.sendEmailCode( + new SendEmailCodeCommand(user.userId, dto.email, dto.purpose), + ); + return { message: '验证码已发送' }; + } + + @Post('bind-email') + @ApiBearerAuth() + @ApiOperation({ + summary: '绑定邮箱', + description: '使用验证码绑定邮箱地址', + }) + @ApiResponse({ status: 200, description: '邮箱绑定成功' }) + @ApiResponse({ status: 400, description: '验证码错误或已过期' }) + async bindEmail( + @CurrentUser() user: CurrentUserData, + @Body() dto: BindEmailDto, + ) { + await this.userService.bindEmail( + new BindEmailCommand(user.userId, dto.email, dto.code), + ); + return { message: '邮箱绑定成功' }; + } + + @Post('unbind-email') + @ApiBearerAuth() + @ApiOperation({ + summary: '解绑邮箱', + description: '使用验证码解绑当前绑定的邮箱', + }) + @ApiResponse({ status: 200, description: '邮箱解绑成功' }) + @ApiResponse({ status: 400, description: '验证码错误或已过期' }) + async unbindEmail( + @CurrentUser() user: CurrentUserData, + @Body() dto: UnbindEmailDto, + ) { + await this.userService.unbindEmail( + new UnbindEmailCommand(user.userId, dto.code), + ); + return { message: '邮箱解绑成功' }; + } + @Get('my-profile') @ApiBearerAuth() @ApiOperation({ summary: '查询我的资料' }) diff --git a/backend/services/identity-service/src/api/dto/request/bind-email.dto.ts b/backend/services/identity-service/src/api/dto/request/bind-email.dto.ts new file mode 100644 index 00000000..954c8e8d --- /dev/null +++ b/backend/services/identity-service/src/api/dto/request/bind-email.dto.ts @@ -0,0 +1,19 @@ +import { IsEmail, IsString, Length } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class BindEmailDto { + @ApiProperty({ + example: 'user@example.com', + description: '邮箱地址', + }) + @IsEmail({}, { message: '请输入有效的邮箱地址' }) + email: string; + + @ApiProperty({ + example: '123456', + description: '6位数字验证码', + }) + @IsString() + @Length(6, 6, { message: '验证码必须是6位数字' }) + code: string; +} diff --git a/backend/services/identity-service/src/api/dto/request/index.ts b/backend/services/identity-service/src/api/dto/request/index.ts index 758a55d1..f7fad34a 100644 --- a/backend/services/identity-service/src/api/dto/request/index.ts +++ b/backend/services/identity-service/src/api/dto/request/index.ts @@ -14,3 +14,6 @@ export * from './verify-sms-code.dto'; export * from './set-password.dto'; export * from './change-password.dto'; export * from './login-with-password.dto'; +export * from './send-email-code.dto'; +export * from './bind-email.dto'; +export * from './unbind-email.dto'; diff --git a/backend/services/identity-service/src/api/dto/request/send-email-code.dto.ts b/backend/services/identity-service/src/api/dto/request/send-email-code.dto.ts new file mode 100644 index 00000000..b8de0f6b --- /dev/null +++ b/backend/services/identity-service/src/api/dto/request/send-email-code.dto.ts @@ -0,0 +1,20 @@ +import { IsEmail, IsString, IsIn } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class SendEmailCodeDto { + @ApiProperty({ + example: 'user@example.com', + description: '邮箱地址', + }) + @IsEmail({}, { message: '请输入有效的邮箱地址' }) + email: string; + + @ApiProperty({ + example: 'BIND_EMAIL', + description: '验证码用途: BIND_EMAIL(绑定邮箱), UNBIND_EMAIL(解绑邮箱)', + enum: ['BIND_EMAIL', 'UNBIND_EMAIL'], + }) + @IsString() + @IsIn(['BIND_EMAIL', 'UNBIND_EMAIL'], { message: '无效的验证码用途' }) + purpose: 'BIND_EMAIL' | 'UNBIND_EMAIL'; +} diff --git a/backend/services/identity-service/src/api/dto/request/unbind-email.dto.ts b/backend/services/identity-service/src/api/dto/request/unbind-email.dto.ts new file mode 100644 index 00000000..3fe8718f --- /dev/null +++ b/backend/services/identity-service/src/api/dto/request/unbind-email.dto.ts @@ -0,0 +1,12 @@ +import { IsString, Length } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class UnbindEmailDto { + @ApiProperty({ + example: '123456', + description: '6位数字验证码(发送到当前绑定的邮箱)', + }) + @IsString() + @Length(6, 6, { message: '验证码必须是6位数字' }) + code: string; +} diff --git a/backend/services/identity-service/src/application/commands/index.ts b/backend/services/identity-service/src/application/commands/index.ts index 3e176b36..f0d4a037 100644 --- a/backend/services/identity-service/src/application/commands/index.ts +++ b/backend/services/identity-service/src/application/commands/index.ts @@ -191,6 +191,29 @@ export class ChangePasswordCommand { ) {} } +export class SendEmailCodeCommand { + constructor( + public readonly userId: string, + public readonly email: string, + public readonly purpose: 'BIND_EMAIL' | 'UNBIND_EMAIL', + ) {} +} + +export class BindEmailCommand { + constructor( + public readonly userId: string, + public readonly email: string, + public readonly code: string, + ) {} +} + +export class UnbindEmailCommand { + constructor( + public readonly userId: string, + public readonly code: string, + ) {} +} + // ============ Results ============ // 钱包状态 diff --git a/backend/services/identity-service/src/application/services/user-application.service.ts b/backend/services/identity-service/src/application/services/user-application.service.ts index 3d7fbffd..ce9f64ab 100644 --- a/backend/services/identity-service/src/application/services/user-application.service.ts +++ b/backend/services/identity-service/src/application/services/user-application.service.ts @@ -25,6 +25,7 @@ import { TokenService } from './token.service'; import { RedisService } from '@/infrastructure/redis/redis.service'; import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service'; import { SmsService } from '@/infrastructure/external/sms/sms.service'; +import { EmailService } from '@/infrastructure/external/email/email.service'; import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service'; import { BlockchainClientService } from '@/infrastructure/external/blockchain/blockchain-client.service'; import { MpcWalletService } from '@/infrastructure/external/mpc'; @@ -95,6 +96,7 @@ export class UserApplicationService { private readonly redisService: RedisService, private readonly prisma: PrismaService, private readonly smsService: SmsService, + private readonly emailService: EmailService, private readonly eventPublisher: EventPublisherService, // 注入事件处理器以确保它们被 NestJS 实例化并执行 onModuleInit private readonly blockchainWalletHandler: BlockchainWalletHandler, @@ -2341,4 +2343,208 @@ export class UserApplicationService { `[RESET_PASSWORD] Password reset successfully for: ${this.maskPhoneNumber(phoneNumber)}`, ); } + + // ============ 邮箱绑定相关 ============ + + /** + * 生成6位数字验证码 + */ + private generateEmailCode(): string { + return Math.floor(100000 + Math.random() * 900000).toString(); + } + + /** + * 脱敏邮箱地址 + */ + private maskEmail(email: string): string { + const [local, domain] = email.split('@'); + if (!domain || local.length < 3) { + return email; + } + const maskedLocal = + local.substring(0, 2) + '***' + local.substring(local.length - 1); + return `${maskedLocal}@${domain}`; + } + + /** + * 发送邮箱验证码 + */ + async sendEmailCode(command: { + userId: string; + email: string; + purpose: 'BIND_EMAIL' | 'UNBIND_EMAIL'; + }): Promise { + this.logger.log( + `Sending email code for user: ${command.userId}, purpose: ${command.purpose}`, + ); + + const user = await this.userRepository.findById(UserId.create(command.userId)); + if (!user) { + throw new ApplicationError('用户不存在'); + } + + // 检查邮箱是否已被其他用户绑定 + if (command.purpose === 'BIND_EMAIL') { + const existingUser = await this.prisma.userAccount.findFirst({ + where: { + email: command.email.toLowerCase(), + NOT: { userId: BigInt(command.userId) }, + }, + }); + if (existingUser) { + throw new ApplicationError('该邮箱已被其他账户绑定'); + } + } + + // 解绑时验证用户确实已绑定邮箱 + if (command.purpose === 'UNBIND_EMAIL') { + const account = await this.prisma.userAccount.findUnique({ + where: { userId: BigInt(command.userId) }, + select: { email: true }, + }); + if (!account?.email) { + throw new ApplicationError('您还未绑定邮箱'); + } + // 解绑验证码发送到当前绑定的邮箱 + if (command.email.toLowerCase() !== account.email.toLowerCase()) { + throw new ApplicationError('请使用当前绑定的邮箱接收验证码'); + } + } + + const code = this.generateEmailCode(); + const cacheKey = `email:${command.purpose.toLowerCase()}:${command.email.toLowerCase()}`; + + // 发送验证码邮件 + const result = await this.emailService.sendVerificationCode( + command.email, + code, + ); + + if (!result.success) { + throw new ApplicationError(`发送验证码失败: ${result.message}`); + } + + // 缓存验证码,5分钟有效 + await this.redisService.set(cacheKey, code, 300); + + this.logger.log( + `Email code sent successfully to: ${this.maskEmail(command.email)}`, + ); + } + + /** + * 绑定邮箱 + */ + async bindEmail(command: { + userId: string; + email: string; + code: string; + }): Promise { + this.logger.log(`Binding email for user: ${command.userId}`); + + const user = await this.userRepository.findById(UserId.create(command.userId)); + if (!user) { + throw new ApplicationError('用户不存在'); + } + + const emailLower = command.email.toLowerCase(); + const cacheKey = `email:bind_email:${emailLower}`; + const cachedCode = await this.redisService.get(cacheKey); + + if (!cachedCode) { + throw new ApplicationError('验证码已过期,请重新获取'); + } + + if (cachedCode !== command.code) { + throw new ApplicationError('验证码错误'); + } + + // 再次检查邮箱是否已被绑定 + const existingUser = await this.prisma.userAccount.findFirst({ + where: { + email: emailLower, + NOT: { userId: BigInt(command.userId) }, + }, + }); + if (existingUser) { + throw new ApplicationError('该邮箱已被其他账户绑定'); + } + + // 更新用户邮箱 + await this.prisma.userAccount.update({ + where: { userId: BigInt(command.userId) }, + data: { email: emailLower }, + }); + + // 删除验证码 + await this.redisService.delete(cacheKey); + + this.logger.log(`Email bound successfully for user: ${command.userId}`); + } + + /** + * 解绑邮箱 + */ + async unbindEmail(command: { userId: string; code: string }): Promise { + this.logger.log(`Unbinding email for user: ${command.userId}`); + + const account = await this.prisma.userAccount.findUnique({ + where: { userId: BigInt(command.userId) }, + select: { email: true }, + }); + + if (!account?.email) { + throw new ApplicationError('您还未绑定邮箱'); + } + + const cacheKey = `email:unbind_email:${account.email.toLowerCase()}`; + const cachedCode = await this.redisService.get(cacheKey); + + if (!cachedCode) { + throw new ApplicationError('验证码已过期,请重新获取'); + } + + if (cachedCode !== command.code) { + throw new ApplicationError('验证码错误'); + } + + // 解绑邮箱 + await this.prisma.userAccount.update({ + where: { userId: BigInt(command.userId) }, + data: { email: null }, + }); + + // 删除验证码 + await this.redisService.delete(cacheKey); + + this.logger.log(`Email unbound successfully for user: ${command.userId}`); + } + + /** + * 获取邮箱绑定状态 + */ + async getEmailStatus(userId: string): Promise<{ + isBound: boolean; + email: string | null; + }> { + const account = await this.prisma.userAccount.findUnique({ + where: { userId: BigInt(userId) }, + select: { email: true }, + }); + + if (!account) { + throw new ApplicationError('用户不存在'); + } + + // 脱敏处理 + let maskedEmail: string | null = null; + if (account.email) { + maskedEmail = this.maskEmail(account.email); + } + + return { + isBound: !!account.email, + email: maskedEmail, + }; + } } diff --git a/backend/services/identity-service/src/infrastructure/external/email/email.module.ts b/backend/services/identity-service/src/infrastructure/external/email/email.module.ts new file mode 100644 index 00000000..f91efa6c --- /dev/null +++ b/backend/services/identity-service/src/infrastructure/external/email/email.module.ts @@ -0,0 +1,9 @@ +import { Module, Global } from '@nestjs/common'; +import { EmailService } from './email.service'; + +@Global() +@Module({ + providers: [EmailService], + exports: [EmailService], +}) +export class EmailModule {} diff --git a/backend/services/identity-service/src/infrastructure/external/email/email.service.ts b/backend/services/identity-service/src/infrastructure/external/email/email.service.ts new file mode 100644 index 00000000..ec732988 --- /dev/null +++ b/backend/services/identity-service/src/infrastructure/external/email/email.service.ts @@ -0,0 +1,174 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as nodemailer from 'nodemailer'; + +export interface EmailSendResult { + success: boolean; + messageId?: string; + code?: string; + message?: string; +} + +/** + * 邮件发送服务 + * + * 环境变量配置: + * - EMAIL_ENABLED: 是否启用邮件发送 (true/false) + * - EMAIL_SMTP_HOST: SMTP 服务器地址 + * - EMAIL_SMTP_PORT: SMTP 端口 (默认 465) + * - EMAIL_SMTP_SECURE: 是否使用 SSL (默认 true) + * - EMAIL_SMTP_USER: SMTP 用户名(发件人邮箱) + * - EMAIL_SMTP_PASS: SMTP 密码或授权码 + * - EMAIL_FROM_NAME: 发件人名称 (默认 "榴莲皇后") + * + * Gmail 配置示例: + * - EMAIL_ENABLED=true + * - EMAIL_SMTP_HOST=smtp.gmail.com + * - EMAIL_SMTP_PORT=465 + * - EMAIL_SMTP_SECURE=true + * - EMAIL_SMTP_USER=your-email@gmail.com + * - EMAIL_SMTP_PASS=xxxx xxxx xxxx xxxx (16位应用专用密码) + * - EMAIL_FROM_NAME=榴莲皇后 + * + * 注意: Gmail 需要开启两步验证并生成应用专用密码: + * 1. 登录 Google 账户 > 安全性 > 两步验证(开启) + * 2. 安全性 > 应用专用密码 > 生成新密码 + */ +@Injectable() +export class EmailService implements OnModuleInit { + private readonly logger = new Logger(EmailService.name); + private transporter: nodemailer.Transporter | null = null; + private readonly enabled: boolean; + private readonly fromEmail: string; + private readonly fromName: string; + + constructor(private readonly configService: ConfigService) { + this.enabled = this.configService.get('EMAIL_ENABLED') === 'true'; + this.fromEmail = this.configService.get('EMAIL_SMTP_USER', ''); + this.fromName = this.configService.get('EMAIL_FROM_NAME', '榴莲皇后'); + } + + async onModuleInit() { + await this.initTransporter(); + } + + private async initTransporter(): Promise { + if (!this.enabled) { + this.logger.warn('邮件服务未启用,将使用模拟模式'); + return; + } + + const host = this.configService.get('EMAIL_SMTP_HOST'); + const port = this.configService.get('EMAIL_SMTP_PORT', 465); + const secure = this.configService.get('EMAIL_SMTP_SECURE', 'true') === 'true'; + const user = this.configService.get('EMAIL_SMTP_USER'); + const pass = this.configService.get('EMAIL_SMTP_PASS'); + + if (!host || !user || !pass) { + this.logger.warn('邮件 SMTP 配置不完整,将使用模拟模式'); + return; + } + + try { + this.transporter = nodemailer.createTransport({ + host, + port, + secure, + auth: { + user, + pass, + }, + }); + + // 验证连接 + await this.transporter.verify(); + this.logger.log(`邮件服务初始化成功: ${host}:${port}`); + } catch (error: any) { + this.logger.error(`邮件服务初始化失败: ${error.message}`); + this.transporter = null; + } + } + + /** + * 发送验证码邮件 + */ + async sendVerificationCode(email: string, code: string): Promise { + const subject = '验证码 - 榴莲皇后'; + const html = ` +
+

榴莲皇后

+
+

您的验证码是:

+
+ ${code} +
+

验证码有效期为 5 分钟,请勿泄露给他人。

+
+

+ 如果您没有请求此验证码,请忽略此邮件。 +

+
+ `; + + return this.sendEmail(email, subject, html); + } + + /** + * 发送通用邮件 + */ + async sendEmail( + to: string, + subject: string, + html: string, + ): Promise { + const maskedEmail = this.maskEmail(to); + this.logger.log(`[Email] 发送邮件到 ${maskedEmail}`); + + // 模拟模式 + if (!this.enabled || !this.transporter) { + this.logger.warn(`[Email] 模拟模式: 邮件发送到 ${maskedEmail}, 主题: ${subject}`); + return { + success: true, + messageId: 'mock-message-id', + code: 'OK', + message: '模拟发送成功', + }; + } + + try { + const result = await this.transporter.sendMail({ + from: `"${this.fromName}" <${this.fromEmail}>`, + to, + subject, + html, + }); + + this.logger.log(`[Email] 发送成功: messageId=${result.messageId}`); + return { + success: true, + messageId: result.messageId, + code: 'OK', + message: '发送成功', + }; + } catch (error: any) { + this.logger.error(`[Email] 发送失败: ${error.message}`); + return { + success: false, + code: error.code || 'SEND_FAILED', + message: error.message || '邮件发送失败', + }; + } + } + + /** + * 脱敏邮箱地址(用于日志) + */ + private maskEmail(email: string): string { + const [local, domain] = email.split('@'); + if (!domain || local.length < 3) { + return email; + } + const maskedLocal = local.substring(0, 2) + '***' + local.substring(local.length - 1); + return `${maskedLocal}@${domain}`; + } +} diff --git a/backend/services/identity-service/src/infrastructure/external/email/index.ts b/backend/services/identity-service/src/infrastructure/external/email/index.ts new file mode 100644 index 00000000..47e1eacd --- /dev/null +++ b/backend/services/identity-service/src/infrastructure/external/email/index.ts @@ -0,0 +1,2 @@ +export * from './email.service'; +export * from './email.module'; diff --git a/backend/services/identity-service/src/infrastructure/infrastructure.module.ts b/backend/services/identity-service/src/infrastructure/infrastructure.module.ts index dc541e06..8b0ac893 100644 --- a/backend/services/identity-service/src/infrastructure/infrastructure.module.ts +++ b/backend/services/identity-service/src/infrastructure/infrastructure.module.ts @@ -10,6 +10,7 @@ import { EventPublisherService } from './kafka/event-publisher.service'; import { MpcEventConsumerService } from './kafka/mpc-event-consumer.service'; import { BlockchainEventConsumerService } from './kafka/blockchain-event-consumer.service'; import { SmsService } from './external/sms/sms.service'; +import { EmailService } from './external/email/email.service'; import { BlockchainClientService } from './external/blockchain/blockchain-client.service'; import { MpcClientService, MpcWalletService } from './external/mpc'; import { StorageService } from './external/storage/storage.service'; @@ -37,6 +38,7 @@ import { MPC_KEY_SHARE_REPOSITORY } from '@/domain/repositories/mpc-key-share.re MpcEventConsumerService, BlockchainEventConsumerService, SmsService, + EmailService, // BlockchainClientService 调用 blockchain-service API BlockchainClientService, MpcClientService, @@ -56,6 +58,7 @@ import { MPC_KEY_SHARE_REPOSITORY } from '@/domain/repositories/mpc-key-share.re MpcEventConsumerService, BlockchainEventConsumerService, SmsService, + EmailService, BlockchainClientService, MpcClientService, MpcWalletService, diff --git a/frontend/mobile-app/lib/core/services/account_service.dart b/frontend/mobile-app/lib/core/services/account_service.dart index 42c83f06..83f1a6ce 100644 --- a/frontend/mobile-app/lib/core/services/account_service.dart +++ b/frontend/mobile-app/lib/core/services/account_service.dart @@ -1973,6 +1973,129 @@ class AccountService { throw ApiException('修改密码失败: $e'); } } + + // ============ 邮箱绑定相关 ============ + + /// 获取邮箱绑定状态 + /// + /// 返回: + /// - isBound: 是否已绑定邮箱 + /// - email: 脱敏后的邮箱地址(如 ab***c@gmail.com) + Future getEmailStatus() async { + debugPrint('$_tag getEmailStatus() - 开始获取邮箱绑定状态'); + + try { + final response = await _apiClient.get('/user/email-status'); + debugPrint('$_tag getEmailStatus() - API 响应状态码: ${response.statusCode}'); + + final data = response.data['data'] as Map? ?? response.data as Map; + return EmailStatus( + isBound: data['isBound'] as bool? ?? false, + email: data['email'] as String?, + ); + } on ApiException catch (e) { + debugPrint('$_tag getEmailStatus() - API 异常: $e'); + rethrow; + } catch (e, stackTrace) { + debugPrint('$_tag getEmailStatus() - 未知异常: $e'); + debugPrint('$_tag getEmailStatus() - 堆栈: $stackTrace'); + throw ApiException('获取邮箱状态失败: $e'); + } + } + + /// 发送邮箱验证码 + /// + /// [email] - 目标邮箱地址 + /// [purpose] - 用途: BIND_EMAIL(绑定) 或 UNBIND_EMAIL(解绑) + Future sendEmailCode({ + required String email, + required String purpose, + }) async { + debugPrint('$_tag sendEmailCode() - 发送邮箱验证码: $purpose'); + + try { + final response = await _apiClient.post( + '/user/send-email-code', + data: { + 'email': email, + 'purpose': purpose, + }, + ); + debugPrint('$_tag sendEmailCode() - API 响应状态码: ${response.statusCode}'); + debugPrint('$_tag sendEmailCode() - 验证码发送成功'); + } on ApiException catch (e) { + debugPrint('$_tag sendEmailCode() - API 异常: $e'); + rethrow; + } catch (e, stackTrace) { + debugPrint('$_tag sendEmailCode() - 未知异常: $e'); + debugPrint('$_tag sendEmailCode() - 堆栈: $stackTrace'); + throw ApiException('发送验证码失败: $e'); + } + } + + /// 绑定邮箱 + /// + /// [email] - 邮箱地址 + /// [code] - 6位验证码 + Future bindEmail({ + required String email, + required String code, + }) async { + debugPrint('$_tag bindEmail() - 开始绑定邮箱'); + + try { + final response = await _apiClient.post( + '/user/bind-email', + data: { + 'email': email, + 'code': code, + }, + ); + debugPrint('$_tag bindEmail() - API 响应状态码: ${response.statusCode}'); + debugPrint('$_tag bindEmail() - 邮箱绑定成功'); + } on ApiException catch (e) { + debugPrint('$_tag bindEmail() - API 异常: $e'); + rethrow; + } catch (e, stackTrace) { + debugPrint('$_tag bindEmail() - 未知异常: $e'); + debugPrint('$_tag bindEmail() - 堆栈: $stackTrace'); + throw ApiException('绑定邮箱失败: $e'); + } + } + + /// 解绑邮箱 + /// + /// [code] - 6位验证码(发送到当前绑定的邮箱) + Future unbindEmail({required String code}) async { + debugPrint('$_tag unbindEmail() - 开始解绑邮箱'); + + try { + final response = await _apiClient.post( + '/user/unbind-email', + data: {'code': code}, + ); + debugPrint('$_tag unbindEmail() - API 响应状态码: ${response.statusCode}'); + debugPrint('$_tag unbindEmail() - 邮箱解绑成功'); + } on ApiException catch (e) { + debugPrint('$_tag unbindEmail() - API 异常: $e'); + rethrow; + } catch (e, stackTrace) { + debugPrint('$_tag unbindEmail() - 未知异常: $e'); + debugPrint('$_tag unbindEmail() - 堆栈: $stackTrace'); + throw ApiException('解绑邮箱失败: $e'); + } + } +} + +/// 邮箱绑定状态 +class EmailStatus { + final bool isBound; + final String? email; + + EmailStatus({ + required this.isBound, + this.email, + }); } /// 遮蔽手机号中间部分,用于日志输出 diff --git a/frontend/mobile-app/lib/features/security/presentation/pages/bind_email_page.dart b/frontend/mobile-app/lib/features/security/presentation/pages/bind_email_page.dart index 910a0053..fadb0ab7 100644 --- a/frontend/mobile-app/lib/features/security/presentation/pages/bind_email_page.dart +++ b/frontend/mobile-app/lib/features/security/presentation/pages/bind_email_page.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../../../../core/di/injection_container.dart'; /// 绑定邮箱页面 /// 支持绑定/更换邮箱,需要验证码验证 @@ -16,9 +17,12 @@ class _BindEmailPageState extends ConsumerState { /// 邮箱控制器 final TextEditingController _emailController = TextEditingController(); - /// 验证码控制器 + /// 绑定验证码控制器 final TextEditingController _codeController = TextEditingController(); + /// 解绑验证码控制器 + final TextEditingController _unbindCodeController = TextEditingController(); + /// 是否正在加载 bool _isLoading = true; @@ -28,15 +32,24 @@ class _BindEmailPageState extends ConsumerState { /// 当前绑定的邮箱(脱敏显示) String? _currentEmail; - /// 是否正在发送验证码 + /// 是否正在发送绑定验证码 bool _isSendingCode = false; - /// 验证码发送倒计时 + /// 绑定验证码发送倒计时 int _countdown = 0; - /// 倒计时定时器 + /// 绑定倒计时定时器 Timer? _countdownTimer; + /// 是否正在发送解绑验证码 + bool _isSendingUnbindCode = false; + + /// 解绑验证码发送倒计时 + int _unbindCountdown = 0; + + /// 解绑倒计时定时器 + Timer? _unbindCountdownTimer; + /// 是否正在提交 bool _isSubmitting = false; @@ -50,7 +63,9 @@ class _BindEmailPageState extends ConsumerState { void dispose() { _emailController.dispose(); _codeController.dispose(); + _unbindCodeController.dispose(); _countdownTimer?.cancel(); + _unbindCountdownTimer?.cancel(); super.dispose(); } @@ -61,17 +76,13 @@ class _BindEmailPageState extends ConsumerState { }); try { - // TODO: 调用API获取邮箱绑定状态 - // final accountService = ref.read(accountServiceProvider); - // final emailStatus = await accountService.getEmailStatus(); - - // 模拟数据 - await Future.delayed(const Duration(milliseconds: 500)); + final accountService = ref.read(accountServiceProvider); + final emailStatus = await accountService.getEmailStatus(); if (mounted) { setState(() { - _isBound = false; // 模拟未绑定 - _currentEmail = null; + _isBound = emailStatus.isBound; + _currentEmail = emailStatus.email; _isLoading = false; }); } @@ -80,6 +91,7 @@ class _BindEmailPageState extends ConsumerState { setState(() { _isLoading = false; }); + _showErrorSnackBar('获取邮箱状态失败: ${e.toString().replaceAll('Exception: ', '')}'); } } } @@ -113,12 +125,11 @@ class _BindEmailPageState extends ConsumerState { }); try { - // TODO: 调用API发送验证码 - // final accountService = ref.read(accountServiceProvider); - // await accountService.sendEmailCode(email); - - // 模拟请求 - await Future.delayed(const Duration(seconds: 1)); + final accountService = ref.read(accountServiceProvider); + await accountService.sendEmailCode( + email: email, + purpose: 'BIND_EMAIL', + ); if (mounted) { setState(() { @@ -142,7 +153,7 @@ class _BindEmailPageState extends ConsumerState { _isSendingCode = false; }); - _showErrorSnackBar('发送失败: ${e.toString()}'); + _showErrorSnackBar('发送失败: ${e.toString().replaceAll('Exception: ', '')}'); } } } @@ -191,12 +202,8 @@ class _BindEmailPageState extends ConsumerState { }); try { - // TODO: 调用API绑定邮箱 - // final accountService = ref.read(accountServiceProvider); - // await accountService.bindEmail(email, code); - - // 模拟请求 - await Future.delayed(const Duration(seconds: 1)); + final accountService = ref.read(accountServiceProvider); + await accountService.bindEmail(email: email, code: code); if (mounted) { setState(() { @@ -218,20 +225,84 @@ class _BindEmailPageState extends ConsumerState { _isSubmitting = false; }); - _showErrorSnackBar('操作失败: ${e.toString()}'); + _showErrorSnackBar('操作失败: ${e.toString().replaceAll('Exception: ', '')}'); } } } + /// 发送解绑验证码 + Future _sendUnbindCode() async { + if (_currentEmail == null || _currentEmail!.isEmpty) { + _showErrorSnackBar('当前邮箱状态异常'); + return; + } + + setState(() { + _isSendingUnbindCode = true; + }); + + try { + final accountService = ref.read(accountServiceProvider); + await accountService.sendEmailCode( + email: _currentEmail!, + purpose: 'UNBIND_EMAIL', + ); + + if (mounted) { + setState(() { + _isSendingUnbindCode = false; + _unbindCountdown = 60; + }); + + // 启动解绑倒计时 + _startUnbindCountdown(); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('验证码已发送到您的邮箱'), + backgroundColor: Color(0xFFD4AF37), + ), + ); + } + } catch (e) { + if (mounted) { + setState(() { + _isSendingUnbindCode = false; + }); + + _showErrorSnackBar('发送失败: ${e.toString().replaceAll('Exception: ', '')}'); + } + } + } + + /// 启动解绑倒计时 + void _startUnbindCountdown() { + _unbindCountdownTimer?.cancel(); + _unbindCountdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (_unbindCountdown > 0) { + setState(() { + _unbindCountdown--; + }); + } else { + timer.cancel(); + } + }); + } + /// 解绑邮箱 Future _unbindEmail() async { - final code = _codeController.text.trim(); + final code = _unbindCodeController.text.trim(); if (code.isEmpty) { _showErrorSnackBar('请输入验证码'); return; } + if (code.length != 6 || !RegExp(r'^\d{6}$').hasMatch(code)) { + _showErrorSnackBar('验证码格式错误,请输入6位数字'); + return; + } + // 显示确认对话框 final confirmed = await showDialog( context: context, @@ -287,12 +358,8 @@ class _BindEmailPageState extends ConsumerState { }); try { - // TODO: 调用API解绑邮箱 - // final accountService = ref.read(accountServiceProvider); - // await accountService.unbindEmail(code); - - // 模拟请求 - await Future.delayed(const Duration(seconds: 1)); + final accountService = ref.read(accountServiceProvider); + await accountService.unbindEmail(code: code); if (mounted) { setState(() { @@ -311,6 +378,7 @@ class _BindEmailPageState extends ConsumerState { // 清空输入 _emailController.clear(); _codeController.clear(); + _unbindCodeController.clear(); } } catch (e) { if (mounted) { @@ -318,7 +386,7 @@ class _BindEmailPageState extends ConsumerState { _isSubmitting = false; }); - _showErrorSnackBar('解绑失败: ${e.toString()}'); + _showErrorSnackBar('解绑失败: ${e.toString().replaceAll('Exception: ', '')}'); } } } @@ -387,10 +455,10 @@ class _BindEmailPageState extends ConsumerState { // 提交按钮 _buildSubmitButton(), - // 解绑按钮(已绑定时显示) + // 解绑区域(已绑定时显示) if (_isBound) ...[ - const SizedBox(height: 16), - _buildUnbindButton(), + const SizedBox(height: 24), + _buildUnbindSection(), ], ], ), @@ -754,34 +822,164 @@ class _BindEmailPageState extends ConsumerState { ); } - /// 构建解绑按钮 - Widget _buildUnbindButton() { - return GestureDetector( - onTap: _isSubmitting ? null : _unbindEmail, - child: Container( - width: double.infinity, - height: 56, - decoration: BoxDecoration( - color: Colors.transparent, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: Colors.red.withValues(alpha: 0.5), - width: 1, + /// 构建解绑区域 + Widget _buildUnbindSection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 分隔线 + Container( + width: double.infinity, + height: 1, + color: const Color(0x33D4AF37), + ), + const SizedBox(height: 24), + + // 解绑标题 + const Text( + '解绑邮箱', + style: TextStyle( + fontSize: 16, + fontFamily: 'Inter', + fontWeight: FontWeight.w500, + color: Colors.red, ), ), - child: const Center( - child: Text( - '解绑邮箱', - style: TextStyle( - fontSize: 16, - fontFamily: 'Inter', - fontWeight: FontWeight.w600, - height: 1.5, - color: Colors.red, + const SizedBox(height: 8), + Text( + '解绑验证码将发送到当前绑定的邮箱: $_currentEmail', + style: const TextStyle( + fontSize: 12, + fontFamily: 'Inter', + color: Color(0xFF745D43), + ), + ), + const SizedBox(height: 16), + + // 解绑验证码输入 + Row( + children: [ + // 验证码输入框 + Expanded( + child: Container( + height: 54, + decoration: BoxDecoration( + color: const Color(0x80FFFFFF), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0x80FFFFFF), + width: 1, + ), + boxShadow: const [ + BoxShadow( + color: Color(0x0D000000), + blurRadius: 2, + offset: Offset(0, 1), + ), + ], + ), + child: TextField( + controller: _unbindCodeController, + keyboardType: TextInputType.number, + maxLength: 6, + style: const TextStyle( + fontSize: 16, + fontFamily: 'Inter', + height: 1.19, + color: Color(0xFF5D4037), + ), + decoration: const InputDecoration( + counterText: '', + contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 15), + border: InputBorder.none, + hintText: '请输入解绑验证码', + hintStyle: TextStyle( + fontSize: 16, + fontFamily: 'Inter', + height: 1.19, + color: Color(0x995D4037), + ), + ), + ), + ), + ), + const SizedBox(width: 12), + // 发送解绑验证码按钮 + GestureDetector( + onTap: (_unbindCountdown > 0 || _isSendingUnbindCode) ? null : _sendUnbindCode, + child: Container( + height: 54, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: (_unbindCountdown > 0 || _isSendingUnbindCode) + ? Colors.red.withValues(alpha: 0.3) + : Colors.red.withValues(alpha: 0.8), + borderRadius: BorderRadius.circular(12), + ), + child: Center( + child: _isSendingUnbindCode + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : Text( + _unbindCountdown > 0 ? '${_unbindCountdown}s' : '发送验证码', + style: const TextStyle( + fontSize: 14, + fontFamily: 'Inter', + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ), + ), + ], + ), + const SizedBox(height: 16), + + // 解绑按钮 + GestureDetector( + onTap: _isSubmitting ? null : _unbindEmail, + child: Container( + width: double.infinity, + height: 56, + decoration: BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.red.withValues(alpha: 0.5), + width: 1, + ), + ), + child: Center( + child: _isSubmitting + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.red), + ), + ) + : const Text( + '确认解绑', + style: TextStyle( + fontSize: 16, + fontFamily: 'Inter', + fontWeight: FontWeight.w600, + height: 1.5, + color: Colors.red, + ), + ), ), ), ), - ), + ], ); } }