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:
parent
336306d6c0
commit
a38aac5581
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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: '查询我的资料' })
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 ============
|
||||
|
||||
// 钱包状态
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
9
backend/services/identity-service/src/infrastructure/external/email/email.module.ts
vendored
Normal file
9
backend/services/identity-service/src/infrastructure/external/email/email.module.ts
vendored
Normal 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 {}
|
||||
174
backend/services/identity-service/src/infrastructure/external/email/email.service.ts
vendored
Normal file
174
backend/services/identity-service/src/infrastructure/external/email/email.service.ts
vendored
Normal 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}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export * from './email.service';
|
||||
export * from './email.module';
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
/// 遮蔽手机号中间部分,用于日志输出
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue