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位序号
|
accountSequence String @unique @map("account_sequence") @db.VarChar(12) // 格式: D + YYMMDD + 5位序号
|
||||||
|
|
||||||
phoneNumber String? @unique @map("phone_number") @db.VarChar(20)
|
phoneNumber String? @unique @map("phone_number") @db.VarChar(20)
|
||||||
|
email String? @unique @db.VarChar(100) // 绑定的邮箱地址
|
||||||
passwordHash String? @map("password_hash") @db.VarChar(100) // bcrypt 哈希密码
|
passwordHash String? @map("password_hash") @db.VarChar(100) // bcrypt 哈希密码
|
||||||
nickname String @db.VarChar(100)
|
nickname String @db.VarChar(100)
|
||||||
avatarUrl String? @map("avatar_url") @db.Text
|
avatarUrl String? @map("avatar_url") @db.Text
|
||||||
|
|
@ -36,6 +37,7 @@ model UserAccount {
|
||||||
walletAddresses WalletAddress[]
|
walletAddresses WalletAddress[]
|
||||||
|
|
||||||
@@index([phoneNumber], name: "idx_phone")
|
@@index([phoneNumber], name: "idx_phone")
|
||||||
|
@@index([email], name: "idx_email")
|
||||||
@@index([accountSequence], name: "idx_sequence")
|
@@index([accountSequence], name: "idx_sequence")
|
||||||
@@index([referralCode], name: "idx_referral_code")
|
@@index([referralCode], name: "idx_referral_code")
|
||||||
@@index([inviterSequence], name: "idx_inviter")
|
@@index([inviterSequence], name: "idx_inviter")
|
||||||
|
|
@ -186,6 +188,22 @@ model SmsCode {
|
||||||
@@map("sms_codes")
|
@@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 密钥分片存储 - 服务端持有的分片
|
// MPC 密钥分片存储 - 服务端持有的分片
|
||||||
model MpcKeyShare {
|
model MpcKeyShare {
|
||||||
shareId BigInt @id @default(autoincrement()) @map("share_id")
|
shareId BigInt @id @default(autoincrement()) @map("share_id")
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,9 @@ import {
|
||||||
VerifySmsCodeCommand,
|
VerifySmsCodeCommand,
|
||||||
SetPasswordCommand,
|
SetPasswordCommand,
|
||||||
ChangePasswordCommand,
|
ChangePasswordCommand,
|
||||||
|
SendEmailCodeCommand,
|
||||||
|
BindEmailCommand,
|
||||||
|
UnbindEmailCommand,
|
||||||
} from '@/application/commands';
|
} from '@/application/commands';
|
||||||
import {
|
import {
|
||||||
AutoCreateAccountDto,
|
AutoCreateAccountDto,
|
||||||
|
|
@ -84,6 +87,9 @@ import {
|
||||||
ChangePasswordDto,
|
ChangePasswordDto,
|
||||||
LoginWithPasswordDto,
|
LoginWithPasswordDto,
|
||||||
ResetPasswordDto,
|
ResetPasswordDto,
|
||||||
|
SendEmailCodeDto,
|
||||||
|
BindEmailDto,
|
||||||
|
UnbindEmailDto,
|
||||||
} from '@/api/dto';
|
} from '@/api/dto';
|
||||||
|
|
||||||
@ApiTags('User')
|
@ApiTags('User')
|
||||||
|
|
@ -315,6 +321,82 @@ export class UserAccountController {
|
||||||
return { message: '密码修改成功' };
|
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')
|
@Get('my-profile')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: '查询我的资料' })
|
@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 './set-password.dto';
|
||||||
export * from './change-password.dto';
|
export * from './change-password.dto';
|
||||||
export * from './login-with-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 ============
|
// ============ Results ============
|
||||||
|
|
||||||
// 钱包状态
|
// 钱包状态
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import { TokenService } from './token.service';
|
||||||
import { RedisService } from '@/infrastructure/redis/redis.service';
|
import { RedisService } from '@/infrastructure/redis/redis.service';
|
||||||
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
|
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
|
||||||
import { SmsService } from '@/infrastructure/external/sms/sms.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 { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
|
||||||
import { BlockchainClientService } from '@/infrastructure/external/blockchain/blockchain-client.service';
|
import { BlockchainClientService } from '@/infrastructure/external/blockchain/blockchain-client.service';
|
||||||
import { MpcWalletService } from '@/infrastructure/external/mpc';
|
import { MpcWalletService } from '@/infrastructure/external/mpc';
|
||||||
|
|
@ -95,6 +96,7 @@ export class UserApplicationService {
|
||||||
private readonly redisService: RedisService,
|
private readonly redisService: RedisService,
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly smsService: SmsService,
|
private readonly smsService: SmsService,
|
||||||
|
private readonly emailService: EmailService,
|
||||||
private readonly eventPublisher: EventPublisherService,
|
private readonly eventPublisher: EventPublisherService,
|
||||||
// 注入事件处理器以确保它们被 NestJS 实例化并执行 onModuleInit
|
// 注入事件处理器以确保它们被 NestJS 实例化并执行 onModuleInit
|
||||||
private readonly blockchainWalletHandler: BlockchainWalletHandler,
|
private readonly blockchainWalletHandler: BlockchainWalletHandler,
|
||||||
|
|
@ -2341,4 +2343,208 @@ export class UserApplicationService {
|
||||||
`[RESET_PASSWORD] Password reset successfully for: ${this.maskPhoneNumber(phoneNumber)}`,
|
`[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 { MpcEventConsumerService } from './kafka/mpc-event-consumer.service';
|
||||||
import { BlockchainEventConsumerService } from './kafka/blockchain-event-consumer.service';
|
import { BlockchainEventConsumerService } from './kafka/blockchain-event-consumer.service';
|
||||||
import { SmsService } from './external/sms/sms.service';
|
import { SmsService } from './external/sms/sms.service';
|
||||||
|
import { EmailService } from './external/email/email.service';
|
||||||
import { BlockchainClientService } from './external/blockchain/blockchain-client.service';
|
import { BlockchainClientService } from './external/blockchain/blockchain-client.service';
|
||||||
import { MpcClientService, MpcWalletService } from './external/mpc';
|
import { MpcClientService, MpcWalletService } from './external/mpc';
|
||||||
import { StorageService } from './external/storage/storage.service';
|
import { StorageService } from './external/storage/storage.service';
|
||||||
|
|
@ -37,6 +38,7 @@ import { MPC_KEY_SHARE_REPOSITORY } from '@/domain/repositories/mpc-key-share.re
|
||||||
MpcEventConsumerService,
|
MpcEventConsumerService,
|
||||||
BlockchainEventConsumerService,
|
BlockchainEventConsumerService,
|
||||||
SmsService,
|
SmsService,
|
||||||
|
EmailService,
|
||||||
// BlockchainClientService 调用 blockchain-service API
|
// BlockchainClientService 调用 blockchain-service API
|
||||||
BlockchainClientService,
|
BlockchainClientService,
|
||||||
MpcClientService,
|
MpcClientService,
|
||||||
|
|
@ -56,6 +58,7 @@ import { MPC_KEY_SHARE_REPOSITORY } from '@/domain/repositories/mpc-key-share.re
|
||||||
MpcEventConsumerService,
|
MpcEventConsumerService,
|
||||||
BlockchainEventConsumerService,
|
BlockchainEventConsumerService,
|
||||||
SmsService,
|
SmsService,
|
||||||
|
EmailService,
|
||||||
BlockchainClientService,
|
BlockchainClientService,
|
||||||
MpcClientService,
|
MpcClientService,
|
||||||
MpcWalletService,
|
MpcWalletService,
|
||||||
|
|
|
||||||
|
|
@ -1973,6 +1973,129 @@ class AccountService {
|
||||||
throw ApiException('修改密码失败: $e');
|
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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.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 _emailController = TextEditingController();
|
||||||
|
|
||||||
/// 验证码控制器
|
/// 绑定验证码控制器
|
||||||
final TextEditingController _codeController = TextEditingController();
|
final TextEditingController _codeController = TextEditingController();
|
||||||
|
|
||||||
|
/// 解绑验证码控制器
|
||||||
|
final TextEditingController _unbindCodeController = TextEditingController();
|
||||||
|
|
||||||
/// 是否正在加载
|
/// 是否正在加载
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
|
|
||||||
|
|
@ -28,15 +32,24 @@ class _BindEmailPageState extends ConsumerState<BindEmailPage> {
|
||||||
/// 当前绑定的邮箱(脱敏显示)
|
/// 当前绑定的邮箱(脱敏显示)
|
||||||
String? _currentEmail;
|
String? _currentEmail;
|
||||||
|
|
||||||
/// 是否正在发送验证码
|
/// 是否正在发送绑定验证码
|
||||||
bool _isSendingCode = false;
|
bool _isSendingCode = false;
|
||||||
|
|
||||||
/// 验证码发送倒计时
|
/// 绑定验证码发送倒计时
|
||||||
int _countdown = 0;
|
int _countdown = 0;
|
||||||
|
|
||||||
/// 倒计时定时器
|
/// 绑定倒计时定时器
|
||||||
Timer? _countdownTimer;
|
Timer? _countdownTimer;
|
||||||
|
|
||||||
|
/// 是否正在发送解绑验证码
|
||||||
|
bool _isSendingUnbindCode = false;
|
||||||
|
|
||||||
|
/// 解绑验证码发送倒计时
|
||||||
|
int _unbindCountdown = 0;
|
||||||
|
|
||||||
|
/// 解绑倒计时定时器
|
||||||
|
Timer? _unbindCountdownTimer;
|
||||||
|
|
||||||
/// 是否正在提交
|
/// 是否正在提交
|
||||||
bool _isSubmitting = false;
|
bool _isSubmitting = false;
|
||||||
|
|
||||||
|
|
@ -50,7 +63,9 @@ class _BindEmailPageState extends ConsumerState<BindEmailPage> {
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_emailController.dispose();
|
_emailController.dispose();
|
||||||
_codeController.dispose();
|
_codeController.dispose();
|
||||||
|
_unbindCodeController.dispose();
|
||||||
_countdownTimer?.cancel();
|
_countdownTimer?.cancel();
|
||||||
|
_unbindCountdownTimer?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -61,17 +76,13 @@ class _BindEmailPageState extends ConsumerState<BindEmailPage> {
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// TODO: 调用API获取邮箱绑定状态
|
final accountService = ref.read(accountServiceProvider);
|
||||||
// final accountService = ref.read(accountServiceProvider);
|
final emailStatus = await accountService.getEmailStatus();
|
||||||
// final emailStatus = await accountService.getEmailStatus();
|
|
||||||
|
|
||||||
// 模拟数据
|
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isBound = false; // 模拟未绑定
|
_isBound = emailStatus.isBound;
|
||||||
_currentEmail = null;
|
_currentEmail = emailStatus.email;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -80,6 +91,7 @@ class _BindEmailPageState extends ConsumerState<BindEmailPage> {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
|
_showErrorSnackBar('获取邮箱状态失败: ${e.toString().replaceAll('Exception: ', '')}');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -113,12 +125,11 @@ class _BindEmailPageState extends ConsumerState<BindEmailPage> {
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// TODO: 调用API发送验证码
|
final accountService = ref.read(accountServiceProvider);
|
||||||
// final accountService = ref.read(accountServiceProvider);
|
await accountService.sendEmailCode(
|
||||||
// await accountService.sendEmailCode(email);
|
email: email,
|
||||||
|
purpose: 'BIND_EMAIL',
|
||||||
// 模拟请求
|
);
|
||||||
await Future.delayed(const Duration(seconds: 1));
|
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
@ -142,7 +153,7 @@ class _BindEmailPageState extends ConsumerState<BindEmailPage> {
|
||||||
_isSendingCode = false;
|
_isSendingCode = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
_showErrorSnackBar('发送失败: ${e.toString()}');
|
_showErrorSnackBar('发送失败: ${e.toString().replaceAll('Exception: ', '')}');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -191,12 +202,8 @@ class _BindEmailPageState extends ConsumerState<BindEmailPage> {
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// TODO: 调用API绑定邮箱
|
final accountService = ref.read(accountServiceProvider);
|
||||||
// final accountService = ref.read(accountServiceProvider);
|
await accountService.bindEmail(email: email, code: code);
|
||||||
// await accountService.bindEmail(email, code);
|
|
||||||
|
|
||||||
// 模拟请求
|
|
||||||
await Future.delayed(const Duration(seconds: 1));
|
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
@ -218,20 +225,84 @@ class _BindEmailPageState extends ConsumerState<BindEmailPage> {
|
||||||
_isSubmitting = false;
|
_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 {
|
Future<void> _unbindEmail() async {
|
||||||
final code = _codeController.text.trim();
|
final code = _unbindCodeController.text.trim();
|
||||||
|
|
||||||
if (code.isEmpty) {
|
if (code.isEmpty) {
|
||||||
_showErrorSnackBar('请输入验证码');
|
_showErrorSnackBar('请输入验证码');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (code.length != 6 || !RegExp(r'^\d{6}$').hasMatch(code)) {
|
||||||
|
_showErrorSnackBar('验证码格式错误,请输入6位数字');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 显示确认对话框
|
// 显示确认对话框
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
|
|
@ -287,12 +358,8 @@ class _BindEmailPageState extends ConsumerState<BindEmailPage> {
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// TODO: 调用API解绑邮箱
|
final accountService = ref.read(accountServiceProvider);
|
||||||
// final accountService = ref.read(accountServiceProvider);
|
await accountService.unbindEmail(code: code);
|
||||||
// await accountService.unbindEmail(code);
|
|
||||||
|
|
||||||
// 模拟请求
|
|
||||||
await Future.delayed(const Duration(seconds: 1));
|
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
@ -311,6 +378,7 @@ class _BindEmailPageState extends ConsumerState<BindEmailPage> {
|
||||||
// 清空输入
|
// 清空输入
|
||||||
_emailController.clear();
|
_emailController.clear();
|
||||||
_codeController.clear();
|
_codeController.clear();
|
||||||
|
_unbindCodeController.clear();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|
@ -318,7 +386,7 @@ class _BindEmailPageState extends ConsumerState<BindEmailPage> {
|
||||||
_isSubmitting = false;
|
_isSubmitting = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
_showErrorSnackBar('解绑失败: ${e.toString()}');
|
_showErrorSnackBar('解绑失败: ${e.toString().replaceAll('Exception: ', '')}');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -387,10 +455,10 @@ class _BindEmailPageState extends ConsumerState<BindEmailPage> {
|
||||||
// 提交按钮
|
// 提交按钮
|
||||||
_buildSubmitButton(),
|
_buildSubmitButton(),
|
||||||
|
|
||||||
// 解绑按钮(已绑定时显示)
|
// 解绑区域(已绑定时显示)
|
||||||
if (_isBound) ...[
|
if (_isBound) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 24),
|
||||||
_buildUnbindButton(),
|
_buildUnbindSection(),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -754,34 +822,164 @@ class _BindEmailPageState extends ConsumerState<BindEmailPage> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 构建解绑按钮
|
/// 构建解绑区域
|
||||||
Widget _buildUnbindButton() {
|
Widget _buildUnbindSection() {
|
||||||
return GestureDetector(
|
return Column(
|
||||||
onTap: _isSubmitting ? null : _unbindEmail,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
child: Container(
|
children: [
|
||||||
width: double.infinity,
|
// 分隔线
|
||||||
height: 56,
|
Container(
|
||||||
decoration: BoxDecoration(
|
width: double.infinity,
|
||||||
color: Colors.transparent,
|
height: 1,
|
||||||
borderRadius: BorderRadius.circular(12),
|
color: const Color(0x33D4AF37),
|
||||||
border: Border.all(
|
),
|
||||||
color: Colors.red.withValues(alpha: 0.5),
|
const SizedBox(height: 24),
|
||||||
width: 1,
|
|
||||||
|
// 解绑标题
|
||||||
|
const Text(
|
||||||
|
'解绑邮箱',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontFamily: 'Inter',
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Colors.red,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: const Center(
|
const SizedBox(height: 8),
|
||||||
child: Text(
|
Text(
|
||||||
'解绑邮箱',
|
'解绑验证码将发送到当前绑定的邮箱: $_currentEmail',
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 12,
|
||||||
fontFamily: 'Inter',
|
fontFamily: 'Inter',
|
||||||
fontWeight: FontWeight.w600,
|
color: Color(0xFF745D43),
|
||||||
height: 1.5,
|
),
|
||||||
color: Colors.red,
|
),
|
||||||
|
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