feat(email): 实现邮箱绑定/解绑功能

后端:
- 新增 EmailService 邮件发送服务,支持 Gmail SMTP
- 新增 EmailCode 数据模型用于存储邮箱验证码
- UserAccount 添加 email 字段
- 新增 API 接口:
  - GET /user/email-status 获取邮箱绑定状态
  - POST /user/send-email-code 发送邮箱验证码
  - POST /user/bind-email 绑定邮箱
  - POST /user/unbind-email 解绑邮箱
- 新增 DTOs: SendEmailCodeDto, BindEmailDto, UnbindEmailDto
- 新增 Commands: SendEmailCodeCommand, BindEmailCommand, UnbindEmailCommand

前端:
- account_service 新增邮箱相关方法和 EmailStatus 类
- bind_email_page 更新为使用真实 API:
  - 绑定/更换邮箱功能
  - 独立的解绑验证码输入和倒计时
  - 解绑确认对话框

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2025-12-23 20:53:20 -08:00
parent 336306d6c0
commit a38aac5581
14 changed files with 952 additions and 60 deletions

View File

@ -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")

View File

@ -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: '查询我的资料' })

View File

@ -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;
}

View File

@ -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';

View File

@ -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';
}

View File

@ -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;
}

View File

@ -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 ============
// 钱包状态

View File

@ -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<void> {
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<void> {
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<void> {
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,
};
}
}

View File

@ -0,0 +1,9 @@
import { Module, Global } from '@nestjs/common';
import { EmailService } from './email.service';
@Global()
@Module({
providers: [EmailService],
exports: [EmailService],
})
export class EmailModule {}

View File

@ -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<void> {
if (!this.enabled) {
this.logger.warn('邮件服务未启用,将使用模拟模式');
return;
}
const host = this.configService.get<string>('EMAIL_SMTP_HOST');
const port = this.configService.get<number>('EMAIL_SMTP_PORT', 465);
const secure = this.configService.get('EMAIL_SMTP_SECURE', 'true') === 'true';
const user = this.configService.get<string>('EMAIL_SMTP_USER');
const pass = this.configService.get<string>('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<EmailSendResult> {
const subject = '验证码 - 榴莲皇后';
const html = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #D4AF37; text-align: center;"></h2>
<div style="background: linear-gradient(to bottom, #FFF5E6, #FFE4B5); padding: 30px; border-radius: 10px; text-align: center;">
<p style="color: #5D4037; font-size: 16px; margin-bottom: 20px;"></p>
<div style="background: #fff; padding: 15px 30px; border-radius: 8px; display: inline-block; border: 2px solid #D4AF37;">
<span style="font-size: 32px; font-weight: bold; color: #D4AF37; letter-spacing: 8px;">${code}</span>
</div>
<p style="color: #745D43; font-size: 14px; margin-top: 20px;"> 5 </p>
</div>
<p style="color: #999; font-size: 12px; text-align: center; margin-top: 20px;">
</p>
</div>
`;
return this.sendEmail(email, subject, html);
}
/**
*
*/
async sendEmail(
to: string,
subject: string,
html: string,
): Promise<EmailSendResult> {
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}`;
}
}

View File

@ -0,0 +1,2 @@
export * from './email.service';
export * from './email.module';

View File

@ -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,

View File

@ -1973,6 +1973,129 @@ class AccountService {
throw ApiException('修改密码失败: $e');
}
}
// ============ ============
///
///
/// :
/// - isBound:
/// - email: ab***c@gmail.com
Future<EmailStatus> 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<String, dynamic>? ?? response.data as Map<String, dynamic>;
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<void> 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<void> 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<void> 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,
});
}
///

View File

@ -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<BindEmailPage> {
///
final TextEditingController _emailController = TextEditingController();
///
///
final TextEditingController _codeController = TextEditingController();
///
final TextEditingController _unbindCodeController = TextEditingController();
///
bool _isLoading = true;
@ -28,15 +32,24 @@ class _BindEmailPageState extends ConsumerState<BindEmailPage> {
///
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<BindEmailPage> {
void dispose() {
_emailController.dispose();
_codeController.dispose();
_unbindCodeController.dispose();
_countdownTimer?.cancel();
_unbindCountdownTimer?.cancel();
super.dispose();
}
@ -61,17 +76,13 @@ class _BindEmailPageState extends ConsumerState<BindEmailPage> {
});
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<BindEmailPage> {
setState(() {
_isLoading = false;
});
_showErrorSnackBar('获取邮箱状态失败: ${e.toString().replaceAll('Exception: ', '')}');
}
}
}
@ -113,12 +125,11 @@ class _BindEmailPageState extends ConsumerState<BindEmailPage> {
});
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<BindEmailPage> {
_isSendingCode = false;
});
_showErrorSnackBar('发送失败: ${e.toString()}');
_showErrorSnackBar('发送失败: ${e.toString().replaceAll('Exception: ', '')}');
}
}
}
@ -191,12 +202,8 @@ class _BindEmailPageState extends ConsumerState<BindEmailPage> {
});
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<BindEmailPage> {
_isSubmitting = false;
});
_showErrorSnackBar('操作失败: ${e.toString()}');
_showErrorSnackBar('操作失败: ${e.toString().replaceAll('Exception: ', '')}');
}
}
}
///
Future<void> _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<void> _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<bool>(
context: context,
@ -287,12 +358,8 @@ class _BindEmailPageState extends ConsumerState<BindEmailPage> {
});
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<BindEmailPage> {
//
_emailController.clear();
_codeController.clear();
_unbindCodeController.clear();
}
} catch (e) {
if (mounted) {
@ -318,7 +386,7 @@ class _BindEmailPageState extends ConsumerState<BindEmailPage> {
_isSubmitting = false;
});
_showErrorSnackBar('解绑失败: ${e.toString()}');
_showErrorSnackBar('解绑失败: ${e.toString().replaceAll('Exception: ', '')}');
}
}
}
@ -387,10 +455,10 @@ class _BindEmailPageState extends ConsumerState<BindEmailPage> {
//
_buildSubmitButton(),
//
//
if (_isBound) ...[
const SizedBox(height: 16),
_buildUnbindButton(),
const SizedBox(height: 24),
_buildUnbindSection(),
],
],
),
@ -754,34 +822,164 @@ class _BindEmailPageState extends ConsumerState<BindEmailPage> {
);
}
///
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<Color>(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<Color>(Colors.red),
),
)
: const Text(
'确认解绑',
style: TextStyle(
fontSize: 16,
fontFamily: 'Inter',
fontWeight: FontWeight.w600,
height: 1.5,
color: Colors.red,
),
),
),
),
),
),
],
);
}
}