feat(auth): 邮箱注册完整实现 — Gmail SMTP + 邮件验证码全链路
后端 (auth-service): - 新增 EmailVerification / EmailLog 实体 + TypeORM 映射 - Email 值对象:格式校验、小写归一化、脱敏展示 - Gmail SMTP Provider (nodemailer) + ConsoleEmailProvider (dev) - EmailCodeService:Redis 缓存快速路径,与 SmsCodeService 对称 - EmailService:sendCode/verifyCode + 日限额 + 业务规则校验 - 新增端点:POST /auth/email/send / register-email / login-email / reset-password-email - EMAIL_ENABLED 环境变量切换真实/控制台发送 - 数据库迁移:048_create_email_verifications.sql 前端 (genex-mobile): - AuthService 新增 sendEmailCode / registerByEmail / loginByEmail / resetPasswordByEmail - RegisterPage 根据 isEmail 参数自动切换 SMS/Email API 调用 - WelcomePage 邮箱注册按钮传递 isEmail:true 参数 - i18n 新增 register.errorEmailRequired(4语种) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a893dbdb1b
commit
9473512530
|
|
@ -0,0 +1,39 @@
|
|||
-- ============================================================
|
||||
-- Migration 048: 创建邮件验证码表
|
||||
--
|
||||
-- 存储邮箱验证码记录,支持注册/登录/重置密码/换绑邮箱等场景。
|
||||
-- 与 sms_verifications 结构对称。
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS email_verifications (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
email VARCHAR(100) NOT NULL,
|
||||
code VARCHAR(255) NOT NULL,
|
||||
type VARCHAR(20) NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
verified_at TIMESTAMPTZ,
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_email_verif_email_type ON email_verifications (email, type);
|
||||
CREATE INDEX IF NOT EXISTS idx_email_verif_expires ON email_verifications (expires_at);
|
||||
|
||||
-- ============================================================
|
||||
-- Migration 049: 创建邮件发送日志表
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS email_logs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id UUID,
|
||||
email VARCHAR(100) NOT NULL,
|
||||
type VARCHAR(20) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
|
||||
provider VARCHAR(50),
|
||||
provider_id VARCHAR(200),
|
||||
error_msg TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_email_logs_email ON email_logs (email);
|
||||
CREATE INDEX IF NOT EXISTS idx_email_logs_created ON email_logs (created_at);
|
||||
|
|
@ -34,6 +34,19 @@ SMS_MAX_VERIFY_ATTEMPTS=5
|
|||
# ALIYUN_SMS_TPL_TRANSACTION=SMS_501820752
|
||||
# ALIYUN_SMS_TPL_PAYMENT=SMS_501855782
|
||||
|
||||
# ── Email (Gmail SMTP) ──
|
||||
# EMAIL_ENABLED=true 时使用 Gmail 真实发送; false 时验证码打印到控制台
|
||||
EMAIL_ENABLED=false
|
||||
EMAIL_CODE_EXPIRE_SECONDS=300
|
||||
EMAIL_DAILY_LIMIT=10
|
||||
EMAIL_MAX_VERIFY_ATTEMPTS=5
|
||||
|
||||
# Gmail SMTP (only when EMAIL_ENABLED=true)
|
||||
# 步骤:Google 账号 → 安全性 → 开启两步验证 → 应用专用密码 → 选「邮件」→ 复制16位密码
|
||||
# GMAIL_USER=noreply@gmail.com
|
||||
# GMAIL_APP_PASSWORD=xxxxxxxxxxxxxx (16位,填写时不含空格)
|
||||
# EMAIL_FROM_NAME=Genex
|
||||
|
||||
# ── Kafka (optional, events silently skipped if unavailable) ──
|
||||
KAFKA_BROKERS=localhost:9092
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,8 @@
|
|||
"rxjs": "^7.8.1",
|
||||
"@alicloud/dysmsapi20170525": "^3.0.0",
|
||||
"@alicloud/openapi-client": "^0.4.0",
|
||||
"@alicloud/tea-util": "^1.4.0"
|
||||
"@alicloud/tea-util": "^1.4.0",
|
||||
"nodemailer": "^6.9.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.3.0",
|
||||
|
|
@ -40,6 +41,7 @@
|
|||
"@types/node": "^20.11.0",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"typescript": "^5.3.0",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.1.0",
|
||||
|
|
|
|||
|
|
@ -11,10 +11,13 @@ import { USER_REPOSITORY, IUserRepository } from '../../domain/repositories/user
|
|||
import { REFRESH_TOKEN_REPOSITORY, IRefreshTokenRepository } from '../../domain/repositories/refresh-token.repository.interface';
|
||||
import { TokenService } from './token.service';
|
||||
import { SmsService } from './sms.service';
|
||||
import { EmailService } from './email.service';
|
||||
import { Password } from '../../domain/value-objects/password.vo';
|
||||
import { Phone } from '../../domain/value-objects/phone.vo';
|
||||
import { Email } from '../../domain/value-objects/email.vo';
|
||||
import { UserRole, UserStatus } from '../../domain/entities/user.entity';
|
||||
import { SmsVerificationType } from '../../domain/entities/sms-verification.entity';
|
||||
import { EmailVerificationType } from '../../domain/entities/email-verification.entity';
|
||||
import { EventPublisherService } from './event-publisher.service';
|
||||
|
||||
export interface RegisterDto {
|
||||
|
|
@ -61,6 +64,7 @@ export class AuthService {
|
|||
@Inject(REFRESH_TOKEN_REPOSITORY) private readonly refreshTokenRepo: IRefreshTokenRepository,
|
||||
private readonly tokenService: TokenService,
|
||||
private readonly smsService: SmsService,
|
||||
private readonly emailService: EmailService,
|
||||
private readonly eventPublisher: EventPublisherService,
|
||||
) {}
|
||||
|
||||
|
|
@ -334,12 +338,131 @@ export class AuthService {
|
|||
});
|
||||
}
|
||||
|
||||
/* ── Email Registration ── */
|
||||
|
||||
async registerByEmail(dto: {
|
||||
email: string;
|
||||
emailCode: string;
|
||||
password: string;
|
||||
nickname?: string;
|
||||
referralCode?: string;
|
||||
}): Promise<AuthResult> {
|
||||
const email = Email.create(dto.email);
|
||||
|
||||
const existing = await this.userRepo.findByEmail(email.value);
|
||||
if (existing) {
|
||||
throw new ConflictException('该邮箱已注册');
|
||||
}
|
||||
|
||||
await this.emailService.verifyCode(dto.email, dto.emailCode, EmailVerificationType.REGISTER);
|
||||
|
||||
const password = await Password.create(dto.password);
|
||||
const user = await this.userRepo.create({
|
||||
phone: null,
|
||||
email: email.value,
|
||||
passwordHash: password.value,
|
||||
nickname: dto.nickname || null,
|
||||
role: UserRole.USER,
|
||||
status: UserStatus.ACTIVE,
|
||||
kycLevel: 0,
|
||||
walletMode: 'standard',
|
||||
});
|
||||
|
||||
const tokens = await this.tokenService.generateTokenPair(user.id, user.role, user.kycLevel);
|
||||
await this.tokenService.storeRefreshToken(user.id, tokens.refreshToken);
|
||||
|
||||
await this.eventPublisher.publishUserRegistered({
|
||||
userId: user.id,
|
||||
phone: user.phone,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
referralCode: dto.referralCode?.toUpperCase() ?? null,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
this.logger.log(`User registered by email: ${user.id} email=${email.masked}`);
|
||||
return { user: this.toUserDto(user), tokens };
|
||||
}
|
||||
|
||||
/* ── Email Code Login ── */
|
||||
|
||||
async loginWithEmail(
|
||||
rawEmail: string,
|
||||
emailCode: string,
|
||||
deviceInfo?: string,
|
||||
ipAddress?: string,
|
||||
): Promise<AuthResult> {
|
||||
const email = Email.create(rawEmail);
|
||||
|
||||
await this.emailService.verifyCode(rawEmail, emailCode, EmailVerificationType.LOGIN);
|
||||
|
||||
const user = await this.userRepo.findByEmail(email.value);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('该邮箱未注册');
|
||||
}
|
||||
|
||||
this.checkUserStatus(user);
|
||||
user.recordLoginSuccess(ipAddress);
|
||||
await this.userRepo.save(user);
|
||||
|
||||
const tokens = await this.tokenService.generateTokenPair(user.id, user.role, user.kycLevel);
|
||||
await this.tokenService.storeRefreshToken(user.id, tokens.refreshToken, deviceInfo, ipAddress);
|
||||
|
||||
await this.eventPublisher.publishUserLoggedIn({
|
||||
userId: user.id,
|
||||
ipAddress: ipAddress || null,
|
||||
deviceInfo: deviceInfo || null,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return { user: this.toUserDto(user), tokens };
|
||||
}
|
||||
|
||||
/* ── Reset Password via Email ── */
|
||||
|
||||
async resetPasswordByEmail(
|
||||
rawEmail: string,
|
||||
emailCode: string,
|
||||
newPassword: string,
|
||||
): Promise<void> {
|
||||
const email = Email.create(rawEmail);
|
||||
|
||||
await this.emailService.verifyCode(rawEmail, emailCode, EmailVerificationType.RESET_PASSWORD);
|
||||
|
||||
const user = await this.userRepo.findByEmail(email.value);
|
||||
if (!user) {
|
||||
throw new BadRequestException('该邮箱未注册');
|
||||
}
|
||||
|
||||
const passwordVo = await Password.create(newPassword);
|
||||
user.passwordHash = passwordVo.value;
|
||||
user.loginFailCount = 0;
|
||||
user.lockedUntil = null;
|
||||
await this.userRepo.save(user);
|
||||
|
||||
await this.refreshTokenRepo.revokeByUserId(user.id);
|
||||
|
||||
await this.eventPublisher.publishPasswordReset({
|
||||
userId: user.id,
|
||||
phone: user.phone || '',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
this.logger.log(`Password reset by email: userId=${user.id} email=${email.masked}`);
|
||||
}
|
||||
|
||||
/* ── Send SMS Code (delegates to SmsService) ── */
|
||||
|
||||
async sendSmsCode(rawPhone: string, type: SmsVerificationType): Promise<{ expiresIn: number }> {
|
||||
return this.smsService.sendCode(rawPhone, type);
|
||||
}
|
||||
|
||||
/* ── Send Email Code (delegates to EmailService) ── */
|
||||
|
||||
async sendEmailCode(rawEmail: string, type: EmailVerificationType): Promise<{ expiresIn: number }> {
|
||||
return this.emailService.sendCode(rawEmail, type);
|
||||
}
|
||||
|
||||
/* ── Private Helpers ── */
|
||||
|
||||
private checkUserStatus(user: any): void {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,170 @@
|
|||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
BadRequestException,
|
||||
Inject,
|
||||
} from '@nestjs/common';
|
||||
import { EmailVerificationType } from '../../domain/entities/email-verification.entity';
|
||||
import { EmailDeliveryStatus } from '../../domain/entities/email-log.entity';
|
||||
import {
|
||||
IEmailVerificationRepository,
|
||||
EMAIL_VERIFICATION_REPOSITORY,
|
||||
} from '../../domain/repositories/email-verification.repository.interface';
|
||||
import {
|
||||
IEmailLogRepository,
|
||||
EMAIL_LOG_REPOSITORY,
|
||||
} from '../../domain/repositories/email-log.repository.interface';
|
||||
import { IUserRepository, USER_REPOSITORY } from '../../domain/repositories/user.repository.interface';
|
||||
import { IEmailProvider, EMAIL_PROVIDER } from '../../infrastructure/email/email-provider.interface';
|
||||
import { EmailCodeService } from '../../infrastructure/redis/email-code.service';
|
||||
import { SmsCode } from '../../domain/value-objects/sms-code.vo';
|
||||
import { Email } from '../../domain/value-objects/email.vo';
|
||||
|
||||
@Injectable()
|
||||
export class EmailService {
|
||||
private readonly logger = new Logger('EmailService');
|
||||
private readonly codeExpireSeconds: number;
|
||||
private readonly dailyLimit: number;
|
||||
private readonly maxAttempts: number;
|
||||
|
||||
constructor(
|
||||
@Inject(EMAIL_VERIFICATION_REPOSITORY)
|
||||
private readonly emailVerifRepo: IEmailVerificationRepository,
|
||||
@Inject(EMAIL_LOG_REPOSITORY)
|
||||
private readonly emailLogRepo: IEmailLogRepository,
|
||||
@Inject(USER_REPOSITORY)
|
||||
private readonly userRepo: IUserRepository,
|
||||
@Inject(EMAIL_PROVIDER)
|
||||
private readonly emailProvider: IEmailProvider,
|
||||
private readonly emailCodeCache: EmailCodeService,
|
||||
) {
|
||||
this.codeExpireSeconds = parseInt(process.env.EMAIL_CODE_EXPIRE_SECONDS || '300', 10);
|
||||
this.dailyLimit = parseInt(process.env.EMAIL_DAILY_LIMIT || '10', 10);
|
||||
this.maxAttempts = parseInt(process.env.EMAIL_MAX_VERIFY_ATTEMPTS || '5', 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送邮件验证码
|
||||
*/
|
||||
async sendCode(
|
||||
rawEmail: string,
|
||||
type: EmailVerificationType,
|
||||
): Promise<{ expiresIn: number }> {
|
||||
const email = Email.create(rawEmail);
|
||||
|
||||
// 1. 检查日发送限额
|
||||
const dailyCount = await this.emailVerifRepo.getDailySendCount(email.value);
|
||||
if (dailyCount >= this.dailyLimit) {
|
||||
throw new BadRequestException('今日发送次数已达上限,请明天再试');
|
||||
}
|
||||
|
||||
// 2. 按类型验证业务规则
|
||||
await this.validateSendRequest(email, type);
|
||||
|
||||
// 3. 生成验证码(复用 SmsCode 值对象:安全随机 6 位数字)
|
||||
const code = SmsCode.generate();
|
||||
const expiresAt = new Date(Date.now() + this.codeExpireSeconds * 1000);
|
||||
|
||||
// 4. 持久化到 DB
|
||||
await this.emailVerifRepo.create({
|
||||
email: email.value,
|
||||
code: code.value,
|
||||
type,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
// 5. 缓存到 Redis (快速查找)
|
||||
await this.emailCodeCache.setCode(email.value, code.value, type, this.codeExpireSeconds);
|
||||
|
||||
// 6. 通过 Provider 发送
|
||||
const result = await this.emailProvider.send(email.value, code.value, type);
|
||||
|
||||
// 7. 记录发送日志
|
||||
const user = await this.userRepo.findByEmail(email.value);
|
||||
await this.emailLogRepo.create({
|
||||
email: email.value,
|
||||
type,
|
||||
status: result.success ? EmailDeliveryStatus.SENT : EmailDeliveryStatus.FAILED,
|
||||
provider: result.providerId ? 'gmail' : 'console',
|
||||
providerId: result.providerId,
|
||||
errorMsg: result.errorMsg,
|
||||
userId: user?.id,
|
||||
});
|
||||
|
||||
this.logger.log(`Email code sent: email=${email.masked} type=${type}`);
|
||||
return { expiresIn: this.codeExpireSeconds };
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证邮件验证码
|
||||
*/
|
||||
async verifyCode(
|
||||
rawEmail: string,
|
||||
code: string,
|
||||
type: EmailVerificationType,
|
||||
): Promise<boolean> {
|
||||
const email = Email.create(rawEmail);
|
||||
|
||||
// 先尝试 Redis 快速路径
|
||||
const redisMatch = await this.emailCodeCache.verifyAndDelete(email.value, code, type);
|
||||
if (redisMatch) {
|
||||
const dbRecord = await this.emailVerifRepo.findLatestValid(email.value, type);
|
||||
if (dbRecord) {
|
||||
dbRecord.markVerified();
|
||||
await this.emailVerifRepo.save(dbRecord);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Redis miss → 回退到 DB 验证
|
||||
const verification = await this.emailVerifRepo.findLatestValid(email.value, type);
|
||||
if (!verification) {
|
||||
throw new BadRequestException('验证码已过期或不存在');
|
||||
}
|
||||
|
||||
if (!verification.canAttempt(this.maxAttempts)) {
|
||||
throw new BadRequestException('验证码尝试次数过多,请重新获取');
|
||||
}
|
||||
|
||||
if (!SmsCode.from(verification.code).matches(code)) {
|
||||
verification.incrementAttempts();
|
||||
await this.emailVerifRepo.save(verification);
|
||||
throw new BadRequestException('验证码错误');
|
||||
}
|
||||
|
||||
verification.markVerified();
|
||||
await this.emailVerifRepo.save(verification);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按类型验证发送条件
|
||||
*/
|
||||
private async validateSendRequest(
|
||||
email: Email,
|
||||
type: EmailVerificationType,
|
||||
): Promise<void> {
|
||||
const existingUser = await this.userRepo.findByEmail(email.value);
|
||||
|
||||
switch (type) {
|
||||
case EmailVerificationType.REGISTER:
|
||||
if (existingUser) {
|
||||
throw new BadRequestException('该邮箱已注册');
|
||||
}
|
||||
break;
|
||||
|
||||
case EmailVerificationType.LOGIN:
|
||||
case EmailVerificationType.RESET_PASSWORD:
|
||||
if (!existingUser) {
|
||||
throw new BadRequestException('该邮箱未注册');
|
||||
}
|
||||
break;
|
||||
|
||||
case EmailVerificationType.CHANGE_EMAIL:
|
||||
if (existingUser) {
|
||||
throw new BadRequestException('该邮箱已被其他账户使用');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,31 +8,44 @@ import { User } from './domain/entities/user.entity';
|
|||
import { RefreshToken } from './domain/entities/refresh-token.entity';
|
||||
import { SmsVerification } from './domain/entities/sms-verification.entity';
|
||||
import { SmsLog } from './domain/entities/sms-log.entity';
|
||||
import { EmailVerification } from './domain/entities/email-verification.entity';
|
||||
import { EmailLog } from './domain/entities/email-log.entity';
|
||||
|
||||
// Domain repository interfaces
|
||||
import { USER_REPOSITORY } from './domain/repositories/user.repository.interface';
|
||||
import { REFRESH_TOKEN_REPOSITORY } from './domain/repositories/refresh-token.repository.interface';
|
||||
import { SMS_VERIFICATION_REPOSITORY } from './domain/repositories/sms-verification.repository.interface';
|
||||
import { SMS_LOG_REPOSITORY } from './domain/repositories/sms-log.repository.interface';
|
||||
import { EMAIL_VERIFICATION_REPOSITORY } from './domain/repositories/email-verification.repository.interface';
|
||||
import { EMAIL_LOG_REPOSITORY } from './domain/repositories/email-log.repository.interface';
|
||||
|
||||
// Infrastructure implementations
|
||||
import { UserRepository } from './infrastructure/persistence/user.repository';
|
||||
import { RefreshTokenRepository } from './infrastructure/persistence/refresh-token.repository';
|
||||
import { SmsVerificationRepository } from './infrastructure/persistence/sms-verification.repository';
|
||||
import { SmsLogRepository } from './infrastructure/persistence/sms-log.repository';
|
||||
import { EmailVerificationRepository } from './infrastructure/persistence/email-verification.repository';
|
||||
import { EmailLogRepository } from './infrastructure/persistence/email-log.repository';
|
||||
import { JwtStrategy } from './infrastructure/strategies/jwt.strategy';
|
||||
import { TokenBlacklistService } from './infrastructure/redis/token-blacklist.service';
|
||||
import { SmsCodeService } from './infrastructure/redis/sms-code.service';
|
||||
import { EmailCodeService } from './infrastructure/redis/email-code.service';
|
||||
|
||||
// SMS Provider
|
||||
import { SMS_PROVIDER } from './infrastructure/sms/sms-provider.interface';
|
||||
import { ConsoleSmsProvider } from './infrastructure/sms/console-sms.provider';
|
||||
import { AliyunSmsProvider } from './infrastructure/sms/aliyun-sms.provider';
|
||||
|
||||
// Email Provider
|
||||
import { EMAIL_PROVIDER } from './infrastructure/email/email-provider.interface';
|
||||
import { ConsoleEmailProvider } from './infrastructure/email/console-email.provider';
|
||||
import { GmailProvider } from './infrastructure/email/gmail.provider';
|
||||
|
||||
// Application services
|
||||
import { AuthService } from './application/services/auth.service';
|
||||
import { TokenService } from './application/services/token.service';
|
||||
import { SmsService } from './application/services/sms.service';
|
||||
import { EmailService } from './application/services/email.service';
|
||||
import { EventPublisherService } from './application/services/event-publisher.service';
|
||||
|
||||
// Interface controllers
|
||||
|
|
@ -41,7 +54,7 @@ import { AdminSmsController } from './interface/http/controllers/admin-sms.contr
|
|||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([User, RefreshToken, SmsVerification, SmsLog]),
|
||||
TypeOrmModule.forFeature([User, RefreshToken, SmsVerification, SmsLog, EmailVerification, EmailLog]),
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_ACCESS_SECRET || 'dev-access-secret',
|
||||
|
|
@ -55,6 +68,8 @@ import { AdminSmsController } from './interface/http/controllers/admin-sms.contr
|
|||
{ provide: REFRESH_TOKEN_REPOSITORY, useClass: RefreshTokenRepository },
|
||||
{ provide: SMS_VERIFICATION_REPOSITORY, useClass: SmsVerificationRepository },
|
||||
{ provide: SMS_LOG_REPOSITORY, useClass: SmsLogRepository },
|
||||
{ provide: EMAIL_VERIFICATION_REPOSITORY, useClass: EmailVerificationRepository },
|
||||
{ provide: EMAIL_LOG_REPOSITORY, useClass: EmailLogRepository },
|
||||
|
||||
// SMS Provider: toggle by SMS_ENABLED env var
|
||||
{
|
||||
|
|
@ -65,17 +80,28 @@ import { AdminSmsController } from './interface/http/controllers/admin-sms.contr
|
|||
: ConsoleSmsProvider,
|
||||
},
|
||||
|
||||
// Email Provider: toggle by EMAIL_ENABLED env var
|
||||
{
|
||||
provide: EMAIL_PROVIDER,
|
||||
useClass:
|
||||
process.env.EMAIL_ENABLED === 'true'
|
||||
? GmailProvider
|
||||
: ConsoleEmailProvider,
|
||||
},
|
||||
|
||||
// Infrastructure
|
||||
JwtStrategy,
|
||||
TokenBlacklistService,
|
||||
SmsCodeService,
|
||||
EmailCodeService,
|
||||
|
||||
// Application services
|
||||
AuthService,
|
||||
TokenService,
|
||||
SmsService,
|
||||
EmailService,
|
||||
EventPublisherService,
|
||||
],
|
||||
exports: [AuthService, TokenService, SmsService],
|
||||
exports: [AuthService, TokenService, SmsService, EmailService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { EmailVerificationType } from './email-verification.entity';
|
||||
|
||||
export enum EmailDeliveryStatus {
|
||||
PENDING = 'PENDING',
|
||||
SENT = 'SENT',
|
||||
FAILED = 'FAILED',
|
||||
}
|
||||
|
||||
@Entity('email_logs')
|
||||
@Index('idx_email_logs_email', ['email'])
|
||||
@Index('idx_email_logs_created', ['createdAt'])
|
||||
export class EmailLog {
|
||||
@PrimaryGeneratedColumn('increment', { type: 'bigint' })
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'user_id', type: 'uuid', nullable: true })
|
||||
userId: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
email: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20 })
|
||||
type: EmailVerificationType;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: EmailDeliveryStatus.PENDING })
|
||||
status: EmailDeliveryStatus;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, nullable: true })
|
||||
provider: string | null;
|
||||
|
||||
@Column({ name: 'provider_id', type: 'varchar', length: 200, nullable: true })
|
||||
providerId: string | null;
|
||||
|
||||
@Column({ name: 'error_msg', type: 'text', nullable: true })
|
||||
errorMsg: string | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
export enum EmailVerificationType {
|
||||
REGISTER = 'REGISTER',
|
||||
LOGIN = 'LOGIN',
|
||||
RESET_PASSWORD = 'RESET_PASSWORD',
|
||||
CHANGE_EMAIL = 'CHANGE_EMAIL',
|
||||
}
|
||||
|
||||
@Entity('email_verifications')
|
||||
@Index('idx_email_verif_email_type', ['email', 'type'])
|
||||
@Index('idx_email_verif_expires', ['expiresAt'])
|
||||
export class EmailVerification {
|
||||
@PrimaryGeneratedColumn('increment', { type: 'bigint' })
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
email: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
code: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20 })
|
||||
type: EmailVerificationType;
|
||||
|
||||
@Column({ name: 'expires_at', type: 'timestamptz' })
|
||||
expiresAt: Date;
|
||||
|
||||
@Column({ name: 'verified_at', type: 'timestamptz', nullable: true })
|
||||
verifiedAt: Date | null;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
attempts: number;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
/* ── Domain Methods ── */
|
||||
|
||||
get isExpired(): boolean {
|
||||
return new Date() > this.expiresAt;
|
||||
}
|
||||
|
||||
get isVerified(): boolean {
|
||||
return this.verifiedAt !== null;
|
||||
}
|
||||
|
||||
get isValid(): boolean {
|
||||
return !this.isExpired && !this.isVerified;
|
||||
}
|
||||
|
||||
canAttempt(maxAttempts: number): boolean {
|
||||
return this.attempts < maxAttempts;
|
||||
}
|
||||
|
||||
incrementAttempts(): void {
|
||||
this.attempts += 1;
|
||||
}
|
||||
|
||||
markVerified(): void {
|
||||
this.verifiedAt = new Date();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { EmailDeliveryStatus, EmailLog } from '../entities/email-log.entity';
|
||||
import { EmailVerificationType } from '../entities/email-verification.entity';
|
||||
|
||||
export interface IEmailLogRepository {
|
||||
create(data: {
|
||||
email: string;
|
||||
type: EmailVerificationType;
|
||||
status: EmailDeliveryStatus;
|
||||
provider?: string;
|
||||
providerId?: string;
|
||||
errorMsg?: string;
|
||||
userId?: string;
|
||||
}): Promise<EmailLog>;
|
||||
}
|
||||
|
||||
export const EMAIL_LOG_REPOSITORY = Symbol('IEmailLogRepository');
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { EmailVerification, EmailVerificationType } from '../entities/email-verification.entity';
|
||||
|
||||
export interface IEmailVerificationRepository {
|
||||
/** 创建验证记录 */
|
||||
create(data: {
|
||||
email: string;
|
||||
code: string;
|
||||
type: EmailVerificationType;
|
||||
expiresAt: Date;
|
||||
}): Promise<EmailVerification>;
|
||||
|
||||
/** 查找指定邮箱和类型的最新有效验证码 (未过期 + 未验证) */
|
||||
findLatestValid(
|
||||
email: string,
|
||||
type: EmailVerificationType,
|
||||
): Promise<EmailVerification | null>;
|
||||
|
||||
/** 保存 (更新 attempts / verifiedAt) */
|
||||
save(verification: EmailVerification): Promise<EmailVerification>;
|
||||
|
||||
/** 获取今日发送次数 */
|
||||
getDailySendCount(email: string): Promise<number>;
|
||||
|
||||
/** 清理过期记录,返回删除数量 */
|
||||
deleteExpired(): Promise<number>;
|
||||
}
|
||||
|
||||
export const EMAIL_VERIFICATION_REPOSITORY = Symbol('IEmailVerificationRepository');
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* Email Value Object
|
||||
* 邮箱值对象 — 格式校验 + 归一化(小写)+ 脱敏展示
|
||||
*/
|
||||
export class Email {
|
||||
private static readonly PATTERN =
|
||||
/^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/;
|
||||
|
||||
private constructor(public readonly value: string) {}
|
||||
|
||||
/**
|
||||
* 创建并校验 Email
|
||||
* @param raw 原始输入(自动 trim + lowercase)
|
||||
* @throws BadRequestException 格式无效
|
||||
*/
|
||||
static create(raw: string): Email {
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
if (!Email.PATTERN.test(normalized)) {
|
||||
throw new BadRequestException('邮箱格式无效');
|
||||
}
|
||||
return new Email(normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* 脱敏展示: user@example.com → u***@example.com
|
||||
*/
|
||||
get masked(): string {
|
||||
const atIndex = this.value.indexOf('@');
|
||||
const local = this.value.slice(0, atIndex);
|
||||
const domain = this.value.slice(atIndex);
|
||||
const maskedLocal = local.length > 1
|
||||
? local[0] + '***'
|
||||
: '***';
|
||||
return `${maskedLocal}${domain}`;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { EmailVerificationType } from '../../domain/entities/email-verification.entity';
|
||||
import { IEmailProvider, EmailDeliveryResult } from './email-provider.interface';
|
||||
|
||||
/**
|
||||
* Console Email Provider (开发/测试模式)
|
||||
*
|
||||
* EMAIL_ENABLED=false 时启用,将验证码打印到控制台而非真实发送。
|
||||
*/
|
||||
@Injectable()
|
||||
export class ConsoleEmailProvider implements IEmailProvider {
|
||||
private readonly logger = new Logger('ConsoleEmailProvider');
|
||||
|
||||
async send(
|
||||
email: string,
|
||||
code: string,
|
||||
type: EmailVerificationType,
|
||||
): Promise<EmailDeliveryResult> {
|
||||
this.logger.warn(
|
||||
`[DEV] Email code [${type}] → ${email} : ${code}`,
|
||||
);
|
||||
return { success: true, providerId: `console-${Date.now()}` };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { EmailVerificationType } from '../../domain/entities/email-verification.entity';
|
||||
|
||||
export interface EmailDeliveryResult {
|
||||
success: boolean;
|
||||
providerId?: string;
|
||||
errorMsg?: string;
|
||||
}
|
||||
|
||||
export interface IEmailProvider {
|
||||
/**
|
||||
* 发送邮件验证码
|
||||
* @param email 目标邮箱
|
||||
* @param code 6位验证码
|
||||
* @param type 验证码类型
|
||||
*/
|
||||
send(
|
||||
email: string,
|
||||
code: string,
|
||||
type: EmailVerificationType,
|
||||
): Promise<EmailDeliveryResult>;
|
||||
}
|
||||
|
||||
export const EMAIL_PROVIDER = Symbol('IEmailProvider');
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import * as nodemailer from 'nodemailer';
|
||||
import { EmailVerificationType } from '../../domain/entities/email-verification.entity';
|
||||
import { IEmailProvider, EmailDeliveryResult } from './email-provider.interface';
|
||||
|
||||
/**
|
||||
* Gmail SMTP Provider
|
||||
*
|
||||
* 使用 Gmail SMTP + App Password 发送验证码邮件。
|
||||
*
|
||||
* 环境变量:
|
||||
* GMAIL_USER — Gmail 账号(如 noreply@gmail.com)
|
||||
* GMAIL_APP_PASSWORD — Gmail 应用专用密码(Google 账号 → 安全性 → 应用专用密码)
|
||||
* EMAIL_FROM_NAME — 发件人显示名(默认 Genex)
|
||||
*
|
||||
* Gmail 应用专用密码获取步骤:
|
||||
* 1. 在 Google 账号开启两步验证
|
||||
* 2. 访问 myaccount.google.com → 安全性 → 两步验证 → 应用专用密码
|
||||
* 3. 选择「邮件」→ 复制 16 位密码(不含空格)
|
||||
*/
|
||||
@Injectable()
|
||||
export class GmailProvider implements IEmailProvider, OnModuleInit {
|
||||
private readonly logger = new Logger('GmailProvider');
|
||||
private transporter: nodemailer.Transporter;
|
||||
|
||||
/** 验证码类型 → 邮件主题 */
|
||||
private static readonly SUBJECT_MAP: Record<EmailVerificationType, string> = {
|
||||
[EmailVerificationType.REGISTER]: '【Genex】注册验证码',
|
||||
[EmailVerificationType.LOGIN]: '【Genex】登录验证码',
|
||||
[EmailVerificationType.RESET_PASSWORD]: '【Genex】重置密码验证码',
|
||||
[EmailVerificationType.CHANGE_EMAIL]: '【Genex】更换邮箱验证码',
|
||||
};
|
||||
|
||||
/** 验证码类型 → 操作说明文案 */
|
||||
private static readonly ACTION_MAP: Record<EmailVerificationType, string> = {
|
||||
[EmailVerificationType.REGISTER]: '您正在注册 Genex 账号',
|
||||
[EmailVerificationType.LOGIN]: '您正在登录 Genex 账号',
|
||||
[EmailVerificationType.RESET_PASSWORD]: '您正在重置 Genex 账号密码',
|
||||
[EmailVerificationType.CHANGE_EMAIL]: '您正在更换 Genex 账号绑定邮箱',
|
||||
};
|
||||
|
||||
async onModuleInit() {
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host: 'smtp.gmail.com',
|
||||
port: 587,
|
||||
secure: false, // STARTTLS
|
||||
auth: {
|
||||
user: process.env.GMAIL_USER,
|
||||
pass: process.env.GMAIL_APP_PASSWORD,
|
||||
},
|
||||
});
|
||||
this.logger.log(`Gmail SMTP initialized: user=${process.env.GMAIL_USER}`);
|
||||
}
|
||||
|
||||
async send(
|
||||
email: string,
|
||||
code: string,
|
||||
type: EmailVerificationType,
|
||||
): Promise<EmailDeliveryResult> {
|
||||
try {
|
||||
const fromName = process.env.EMAIL_FROM_NAME || 'Genex';
|
||||
const from = `"${fromName}" <${process.env.GMAIL_USER}>`;
|
||||
const subject = GmailProvider.SUBJECT_MAP[type];
|
||||
const html = this.buildHtml(code, type);
|
||||
|
||||
const info = await this.transporter.sendMail({ from, to: email, subject, html });
|
||||
|
||||
this.logger.log(
|
||||
`Email sent [${type}]: to=${email.replace(/(?<=.).(?=[^@]*@)/, '*')} msgId=${info.messageId}`,
|
||||
);
|
||||
return { success: true, providerId: info.messageId };
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Gmail send error: ${error.message}`);
|
||||
return { success: false, errorMsg: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
private buildHtml(code: string, type: EmailVerificationType): string {
|
||||
const action = GmailProvider.ACTION_MAP[type];
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8" /></head>
|
||||
<body style="margin:0;padding:0;background:#f5f5f7;font-family:Arial,sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td align="center" style="padding:40px 16px;">
|
||||
<table width="560" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:16px;overflow:hidden;box-shadow:0 2px 12px rgba(0,0,0,0.08);">
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style="background:linear-gradient(135deg,#6C63FF,#9B8FFF);padding:32px;text-align:center;">
|
||||
<span style="font-size:28px;font-weight:800;color:#fff;letter-spacing:-0.5px;">GENEX</span>
|
||||
<p style="margin:8px 0 0;color:rgba(255,255,255,0.85);font-size:13px;">券金融平台</p>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Body -->
|
||||
<tr>
|
||||
<td style="padding:40px 40px 32px;">
|
||||
<p style="margin:0 0 8px;color:#1a1a2e;font-size:16px;">${action},请使用以下验证码:</p>
|
||||
<!-- Code block -->
|
||||
<div style="margin:28px 0;text-align:center;">
|
||||
<span style="display:inline-block;background:#f0eeff;border-radius:12px;padding:18px 40px;font-size:42px;font-weight:800;letter-spacing:12px;color:#6C63FF;">${code}</span>
|
||||
</div>
|
||||
<p style="margin:0;color:#555;font-size:14px;line-height:1.7;">
|
||||
验证码有效期 <strong>5 分钟</strong>,请勿将验证码告知任何人。<br/>
|
||||
如非本人操作,请忽略此邮件。
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="background:#f9f9f9;padding:20px 40px;border-top:1px solid #eee;">
|
||||
<p style="margin:0;color:#999;font-size:12px;text-align:center;">
|
||||
此邮件由系统自动发送,请勿直接回复。<br/>
|
||||
© ${new Date().getFullYear()} Genex · 券金融平台
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { EmailLog, EmailDeliveryStatus } from '../../domain/entities/email-log.entity';
|
||||
import { EmailVerificationType } from '../../domain/entities/email-verification.entity';
|
||||
import { IEmailLogRepository } from '../../domain/repositories/email-log.repository.interface';
|
||||
|
||||
@Injectable()
|
||||
export class EmailLogRepository implements IEmailLogRepository {
|
||||
constructor(
|
||||
@InjectRepository(EmailLog)
|
||||
private readonly repo: Repository<EmailLog>,
|
||||
) {}
|
||||
|
||||
async create(data: {
|
||||
email: string;
|
||||
type: EmailVerificationType;
|
||||
status: EmailDeliveryStatus;
|
||||
provider?: string;
|
||||
providerId?: string;
|
||||
errorMsg?: string;
|
||||
userId?: string;
|
||||
}): Promise<EmailLog> {
|
||||
const entity = this.repo.create({
|
||||
email: data.email,
|
||||
type: data.type,
|
||||
status: data.status,
|
||||
provider: data.provider ?? null,
|
||||
providerId: data.providerId ?? null,
|
||||
errorMsg: data.errorMsg ?? null,
|
||||
userId: data.userId ?? null,
|
||||
});
|
||||
return this.repo.save(entity);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, MoreThan, IsNull } from 'typeorm';
|
||||
import { EmailVerification, EmailVerificationType } from '../../domain/entities/email-verification.entity';
|
||||
import { IEmailVerificationRepository } from '../../domain/repositories/email-verification.repository.interface';
|
||||
|
||||
@Injectable()
|
||||
export class EmailVerificationRepository implements IEmailVerificationRepository {
|
||||
constructor(
|
||||
@InjectRepository(EmailVerification)
|
||||
private readonly repo: Repository<EmailVerification>,
|
||||
) {}
|
||||
|
||||
async create(data: {
|
||||
email: string;
|
||||
code: string;
|
||||
type: EmailVerificationType;
|
||||
expiresAt: Date;
|
||||
}): Promise<EmailVerification> {
|
||||
const entity = this.repo.create(data);
|
||||
return this.repo.save(entity);
|
||||
}
|
||||
|
||||
async findLatestValid(
|
||||
email: string,
|
||||
type: EmailVerificationType,
|
||||
): Promise<EmailVerification | null> {
|
||||
return this.repo.findOne({
|
||||
where: {
|
||||
email,
|
||||
type,
|
||||
expiresAt: MoreThan(new Date()),
|
||||
verifiedAt: IsNull(),
|
||||
},
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async save(verification: EmailVerification): Promise<EmailVerification> {
|
||||
return this.repo.save(verification);
|
||||
}
|
||||
|
||||
async getDailySendCount(email: string): Promise<number> {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
return this.repo.count({
|
||||
where: {
|
||||
email,
|
||||
createdAt: MoreThan(today),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteExpired(): Promise<number> {
|
||||
const result = await this.repo
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
.where('expires_at < :now', { now: new Date() })
|
||||
.andWhere('verified_at IS NOT NULL OR expires_at < :cutoff', {
|
||||
cutoff: new Date(Date.now() - 24 * 60 * 60 * 1000),
|
||||
})
|
||||
.execute();
|
||||
return result.affected || 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
import { EmailVerificationType } from '../../domain/entities/email-verification.entity';
|
||||
|
||||
/**
|
||||
* Email code Redis cache service.
|
||||
* 作为 DB 持久化的快速缓存层,用于邮件验证码的快速查找。
|
||||
* Key pattern: auth:email:{TYPE}:{EMAIL}
|
||||
*/
|
||||
@Injectable()
|
||||
export class EmailCodeService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger('EmailCodeService');
|
||||
private redis: Redis;
|
||||
|
||||
async onModuleInit() {
|
||||
const host = process.env.REDIS_HOST || 'localhost';
|
||||
const port = parseInt(process.env.REDIS_PORT || '6379', 10);
|
||||
const password = process.env.REDIS_PASSWORD || undefined;
|
||||
|
||||
this.redis = new Redis({
|
||||
host,
|
||||
port,
|
||||
password,
|
||||
keyPrefix: 'auth:email:',
|
||||
retryStrategy: (times) => Math.min(times * 50, 2000),
|
||||
});
|
||||
|
||||
this.redis.on('connect', () => this.logger.log('Redis connected for email code service'));
|
||||
this.redis.on('error', (err) => this.logger.error(`Redis error: ${err.message}`));
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
if (this.redis) {
|
||||
await this.redis.quit();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存验证码到 Redis
|
||||
*/
|
||||
async setCode(
|
||||
email: string,
|
||||
code: string,
|
||||
type: EmailVerificationType,
|
||||
ttlSeconds = 300,
|
||||
): Promise<void> {
|
||||
const key = `${type}:${email}`;
|
||||
await this.redis.set(key, code, 'EX', ttlSeconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Redis 验证验证码
|
||||
* @returns true=匹配并删除, false=不匹配或不存在
|
||||
*/
|
||||
async verifyAndDelete(
|
||||
email: string,
|
||||
code: string,
|
||||
type: EmailVerificationType,
|
||||
): Promise<boolean> {
|
||||
const key = `${type}:${email}`;
|
||||
const stored = await this.redis.get(key);
|
||||
if (!stored || stored !== code) {
|
||||
return false;
|
||||
}
|
||||
await this.redis.del(key);
|
||||
return true;
|
||||
}
|
||||
|
||||
async deleteCode(email: string, type: EmailVerificationType): Promise<void> {
|
||||
const key = `${type}:${email}`;
|
||||
await this.redis.del(key);
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,10 @@ import { SendSmsCodeDto } from '../dto/send-sms-code.dto';
|
|||
import { LoginPhoneDto } from '../dto/login-phone.dto';
|
||||
import { ResetPasswordDto } from '../dto/reset-password.dto';
|
||||
import { ChangePhoneDto } from '../dto/change-phone.dto';
|
||||
import { SendEmailCodeDto } from '../dto/send-email-code.dto';
|
||||
import { RegisterEmailDto } from '../dto/register-email.dto';
|
||||
import { LoginEmailDto } from '../dto/login-email.dto';
|
||||
import { ResetPasswordEmailDto } from '../dto/reset-password-email.dto';
|
||||
|
||||
@ApiTags('Auth')
|
||||
@Controller('auth')
|
||||
|
|
@ -182,4 +186,74 @@ export class AuthController {
|
|||
message: '密码修改成功',
|
||||
};
|
||||
}
|
||||
|
||||
/* ── 邮件验证码 ── */
|
||||
|
||||
@Post('email/send')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(ThrottlerGuard)
|
||||
@ApiOperation({ summary: '发送邮件验证码' })
|
||||
@ApiResponse({ status: 200, description: '验证码发送成功' })
|
||||
@ApiResponse({ status: 400, description: '邮箱格式无效 / 日发送限额已满' })
|
||||
async sendEmailCode(@Body() dto: SendEmailCodeDto) {
|
||||
const result = await this.authService.sendEmailCode(dto.email, dto.type);
|
||||
return {
|
||||
code: 0,
|
||||
data: result,
|
||||
message: '验证码已发送',
|
||||
};
|
||||
}
|
||||
|
||||
/* ── 邮箱注册 ── */
|
||||
|
||||
@Post('register-email')
|
||||
@ApiOperation({ summary: '邮箱注册 (需先获取邮件验证码)' })
|
||||
@ApiResponse({ status: 201, description: '注册成功' })
|
||||
@ApiResponse({ status: 400, description: '验证码错误' })
|
||||
@ApiResponse({ status: 409, description: '邮箱已注册' })
|
||||
async registerByEmail(@Body() dto: RegisterEmailDto) {
|
||||
const result = await this.authService.registerByEmail(dto);
|
||||
return {
|
||||
code: 0,
|
||||
data: result,
|
||||
message: '注册成功',
|
||||
};
|
||||
}
|
||||
|
||||
/* ── 邮件验证码登录 ── */
|
||||
|
||||
@Post('login-email')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '邮件验证码登录 (无需密码)' })
|
||||
@ApiResponse({ status: 200, description: '登录成功' })
|
||||
@ApiResponse({ status: 401, description: '验证码错误或邮箱未注册' })
|
||||
async loginWithEmail(@Body() dto: LoginEmailDto, @Ip() ip: string) {
|
||||
const result = await this.authService.loginWithEmail(
|
||||
dto.email,
|
||||
dto.emailCode,
|
||||
dto.deviceInfo,
|
||||
ip,
|
||||
);
|
||||
return {
|
||||
code: 0,
|
||||
data: result,
|
||||
message: '登录成功',
|
||||
};
|
||||
}
|
||||
|
||||
/* ── 邮件重置密码 ── */
|
||||
|
||||
@Post('reset-password-email')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '通过邮件验证码重置密码' })
|
||||
@ApiResponse({ status: 200, description: '密码重置成功' })
|
||||
@ApiResponse({ status: 400, description: '验证码错误或邮箱未注册' })
|
||||
async resetPasswordByEmail(@Body() dto: ResetPasswordEmailDto) {
|
||||
await this.authService.resetPasswordByEmail(dto.email, dto.emailCode, dto.newPassword);
|
||||
return {
|
||||
code: 0,
|
||||
data: null,
|
||||
message: '密码重置成功,请重新登录',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
import { IsEmail, IsString, IsOptional, Length } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class LoginEmailDto {
|
||||
@ApiProperty({ description: '邮箱地址', example: 'user@example.com' })
|
||||
@IsEmail({}, { message: '邮箱格式无效' })
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ description: '6位邮箱验证码', example: '123456' })
|
||||
@IsString()
|
||||
@Length(6, 6, { message: '验证码必须为6位数字' })
|
||||
emailCode: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '设备信息', example: 'iPhone 15 iOS 17' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
deviceInfo?: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { IsEmail, IsString, IsOptional, MinLength, MaxLength, Length, Matches } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class RegisterEmailDto {
|
||||
@ApiProperty({ description: '邮箱地址', example: 'user@example.com' })
|
||||
@IsEmail({}, { message: '邮箱格式无效' })
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ description: '6位邮箱验证码', example: '123456' })
|
||||
@IsString()
|
||||
@Length(6, 6, { message: '验证码必须为6位数字' })
|
||||
emailCode: string;
|
||||
|
||||
@ApiProperty({ description: '登录密码 (8-128位)', example: 'Password123!' })
|
||||
@IsString()
|
||||
@MinLength(8, { message: '密码至少8位' })
|
||||
@MaxLength(128)
|
||||
password: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '昵称', example: 'John' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
nickname?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '推荐码(可选)', example: 'GNX1A2B3' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^[A-Z0-9]{6,20}$/i, { message: '推荐码格式无效' })
|
||||
referralCode?: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { IsEmail, IsString, MinLength, MaxLength, Length } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class ResetPasswordEmailDto {
|
||||
@ApiProperty({ description: '邮箱地址', example: 'user@example.com' })
|
||||
@IsEmail({}, { message: '邮箱格式无效' })
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ description: '6位邮箱验证码', example: '123456' })
|
||||
@IsString()
|
||||
@Length(6, 6, { message: '验证码必须为6位数字' })
|
||||
emailCode: string;
|
||||
|
||||
@ApiProperty({ description: '新密码 (8-128位)', example: 'NewPassword123!' })
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(128)
|
||||
newPassword: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { IsEmail, IsEnum } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { EmailVerificationType } from '../../../domain/entities/email-verification.entity';
|
||||
|
||||
export class SendEmailCodeDto {
|
||||
@ApiProperty({ description: '邮箱地址', example: 'user@example.com' })
|
||||
@IsEmail({}, { message: '邮箱格式无效' })
|
||||
email: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '验证码类型',
|
||||
enum: EmailVerificationType,
|
||||
example: EmailVerificationType.REGISTER,
|
||||
})
|
||||
@IsEnum(EmailVerificationType, { message: '验证码类型无效' })
|
||||
type: EmailVerificationType;
|
||||
}
|
||||
|
|
@ -82,6 +82,7 @@ const Map<String, String> en = {
|
|||
'register.hasAccount': 'Already have an account? ',
|
||||
'register.loginNow': 'Log In',
|
||||
'register.errorPhoneRequired': 'Please enter your phone number',
|
||||
'register.errorEmailRequired': 'Please enter your email address',
|
||||
'register.errorCodeInvalid': 'Please enter a 6-digit code',
|
||||
'register.errorPasswordWeak': 'Password must be 8+ characters with letters and numbers',
|
||||
'register.errorTermsRequired': 'Please agree to the Terms of Service',
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ const Map<String, String> ja = {
|
|||
'register.hasAccount': 'アカウントをお持ちですか?',
|
||||
'register.loginNow': 'ログイン',
|
||||
'register.errorPhoneRequired': '電話番号を入力してください',
|
||||
'register.errorEmailRequired': 'メールアドレスを入力してください',
|
||||
'register.errorCodeInvalid': '6桁の認証コードを入力してください',
|
||||
'register.errorPasswordWeak': 'パスワードは8文字以上で英字と数字を含む必要があります',
|
||||
'register.errorTermsRequired': '利用規約に同意してください',
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ const Map<String, String> zhCN = {
|
|||
'register.hasAccount': '已有账号?',
|
||||
'register.loginNow': '立即登录',
|
||||
'register.errorPhoneRequired': '请输入手机号',
|
||||
'register.errorEmailRequired': '请输入邮箱地址',
|
||||
'register.errorCodeInvalid': '请输入6位验证码',
|
||||
'register.errorPasswordWeak': '密码需要8位以上且包含字母和数字',
|
||||
'register.errorTermsRequired': '请先阅读并同意用户协议',
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ const Map<String, String> zhTW = {
|
|||
'register.hasAccount': '已有帳號?',
|
||||
'register.loginNow': '立即登入',
|
||||
'register.errorPhoneRequired': '請輸入手機號',
|
||||
'register.errorEmailRequired': '請輸入電子郵件地址',
|
||||
'register.errorCodeInvalid': '請輸入6位驗證碼',
|
||||
'register.errorPasswordWeak': '密碼需要8位以上且包含字母和數字',
|
||||
'register.errorTermsRequired': '請先閱讀並同意使用者協議',
|
||||
|
|
|
|||
|
|
@ -35,6 +35,17 @@ enum SmsCodeType {
|
|||
const SmsCodeType(this.value);
|
||||
}
|
||||
|
||||
/// 邮件验证码类型
|
||||
enum EmailCodeType {
|
||||
register('REGISTER'),
|
||||
login('LOGIN'),
|
||||
resetPassword('RESET_PASSWORD'),
|
||||
changeEmail('CHANGE_EMAIL');
|
||||
|
||||
final String value;
|
||||
const EmailCodeType(this.value);
|
||||
}
|
||||
|
||||
/// 认证结果(登录/注册/Token 刷新后由后端返回)
|
||||
///
|
||||
/// 后端响应结构(/api/v1/auth/login 等):
|
||||
|
|
@ -162,6 +173,80 @@ class AuthService {
|
|||
return data['expiresIn'] as int;
|
||||
}
|
||||
|
||||
// ── 邮件验证码 ────────────────────────────────────────────────────────────
|
||||
|
||||
/// 发送邮件验证码
|
||||
///
|
||||
/// [type] 决定后端使用的模板:REGISTER / LOGIN / RESET_PASSWORD / CHANGE_EMAIL
|
||||
///
|
||||
/// Returns expiresIn (秒),UI 用于倒计时展示
|
||||
Future<int> sendEmailCode(String email, EmailCodeType type) async {
|
||||
final resp = await _api.post('/api/v1/auth/email/send', data: {
|
||||
'email': email,
|
||||
'type': type.value,
|
||||
});
|
||||
final data = resp.data['data'] as Map<String, dynamic>;
|
||||
return data['expiresIn'] as int;
|
||||
}
|
||||
|
||||
// ── 邮箱注册 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// 邮箱注册(需先用 EmailCodeType.register 获取验证码)
|
||||
///
|
||||
/// 注册成功后自动登录(调用 _setAuth 保存 Token + 设置请求头)。
|
||||
Future<AuthResult> registerByEmail({
|
||||
required String email,
|
||||
required String emailCode,
|
||||
required String password,
|
||||
String? nickname,
|
||||
String? referralCode,
|
||||
}) async {
|
||||
final resp = await _api.post('/api/v1/auth/register-email', data: {
|
||||
'email': email,
|
||||
'emailCode': emailCode,
|
||||
'password': password,
|
||||
if (nickname != null) 'nickname': nickname,
|
||||
if (referralCode != null && referralCode.isNotEmpty)
|
||||
'referralCode': referralCode.toUpperCase(),
|
||||
});
|
||||
final result = AuthResult.fromJson(resp.data['data']);
|
||||
await _setAuth(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── 邮件验证码登录 ────────────────────────────────────────────────────────
|
||||
|
||||
/// 邮件验证码登录(需先用 EmailCodeType.login 获取验证码)
|
||||
Future<AuthResult> loginByEmail({
|
||||
required String email,
|
||||
required String emailCode,
|
||||
String? deviceInfo,
|
||||
}) async {
|
||||
final resp = await _api.post('/api/v1/auth/login-email', data: {
|
||||
'email': email,
|
||||
'emailCode': emailCode,
|
||||
if (deviceInfo != null) 'deviceInfo': deviceInfo,
|
||||
});
|
||||
final result = AuthResult.fromJson(resp.data['data']);
|
||||
await _setAuth(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── 邮件重置密码 ──────────────────────────────────────────────────────────
|
||||
|
||||
/// 通过邮件验证码重置密码(忘记密码场景,需先用 EmailCodeType.resetPassword 获取验证码)
|
||||
Future<void> resetPasswordByEmail({
|
||||
required String email,
|
||||
required String emailCode,
|
||||
required String newPassword,
|
||||
}) async {
|
||||
await _api.post('/api/v1/auth/reset-password-email', data: {
|
||||
'email': email,
|
||||
'emailCode': emailCode,
|
||||
'newPassword': newPassword,
|
||||
});
|
||||
}
|
||||
|
||||
// ── 推荐码 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 验证推荐码是否有效(注册页实时校验用,不需要登录)
|
||||
|
|
|
|||
|
|
@ -72,14 +72,20 @@ class _RegisterPageState extends State<RegisterPage> {
|
|||
|
||||
/* ── 发送注册验证码 ── */
|
||||
Future<void> _handleSendCode() async {
|
||||
final phone = _accountController.text.trim();
|
||||
if (phone.isEmpty) {
|
||||
setState(() => _errorMessage = context.t('register.errorPhoneRequired'));
|
||||
throw Exception('phone required');
|
||||
final account = _accountController.text.trim();
|
||||
if (account.isEmpty) {
|
||||
setState(() => _errorMessage = widget.isEmail
|
||||
? context.t('register.errorEmailRequired')
|
||||
: context.t('register.errorPhoneRequired'));
|
||||
throw Exception('account required');
|
||||
}
|
||||
setState(() => _errorMessage = null);
|
||||
try {
|
||||
await _authService.sendSmsCode(phone, SmsCodeType.register);
|
||||
if (widget.isEmail) {
|
||||
await _authService.sendEmailCode(account, EmailCodeType.register);
|
||||
} else {
|
||||
await _authService.sendSmsCode(account, SmsCodeType.register);
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
setState(() => _errorMessage = _extractError(e));
|
||||
rethrow;
|
||||
|
|
@ -88,12 +94,14 @@ class _RegisterPageState extends State<RegisterPage> {
|
|||
|
||||
/* ── 提交注册 ── */
|
||||
Future<void> _handleRegister() async {
|
||||
final phone = _accountController.text.trim();
|
||||
final account = _accountController.text.trim();
|
||||
final code = _codeController.text.trim();
|
||||
final password = _passwordController.text;
|
||||
|
||||
if (phone.isEmpty) {
|
||||
setState(() => _errorMessage = context.t('register.errorPhoneRequired'));
|
||||
if (account.isEmpty) {
|
||||
setState(() => _errorMessage = widget.isEmail
|
||||
? context.t('register.errorEmailRequired')
|
||||
: context.t('register.errorPhoneRequired'));
|
||||
return;
|
||||
}
|
||||
if (code.length != 6) {
|
||||
|
|
@ -114,12 +122,21 @@ class _RegisterPageState extends State<RegisterPage> {
|
|||
setState(() { _loading = true; _errorMessage = null; });
|
||||
try {
|
||||
final referralCode = _referralCodeController.text.trim();
|
||||
await _authService.register(
|
||||
phone: phone,
|
||||
smsCode: code,
|
||||
password: password,
|
||||
referralCode: referralCode.isNotEmpty ? referralCode : null,
|
||||
);
|
||||
if (widget.isEmail) {
|
||||
await _authService.registerByEmail(
|
||||
email: account,
|
||||
emailCode: code,
|
||||
password: password,
|
||||
referralCode: referralCode.isNotEmpty ? referralCode : null,
|
||||
);
|
||||
} else {
|
||||
await _authService.register(
|
||||
phone: account,
|
||||
smsCode: code,
|
||||
password: password,
|
||||
referralCode: referralCode.isNotEmpty ? referralCode : null,
|
||||
);
|
||||
}
|
||||
if (mounted) Navigator.pushReplacementNamed(context, '/main');
|
||||
} on DioException catch (e) {
|
||||
setState(() => _errorMessage = _extractError(e));
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ class WelcomePage extends StatelessWidget {
|
|||
icon: Icons.email_outlined,
|
||||
variant: GenexButtonVariant.outline,
|
||||
onPressed: () {
|
||||
Navigator.pushNamed(context, '/register');
|
||||
Navigator.pushNamed(context, '/register', arguments: {'isEmail': true});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
|
|
|||
|
|
@ -153,7 +153,10 @@ class _GenexConsumerAppState extends State<GenexConsumerApp> {
|
|||
case '/login':
|
||||
return MaterialPageRoute(builder: (_) => const LoginPage());
|
||||
case '/register':
|
||||
return MaterialPageRoute(builder: (_) => const RegisterPage());
|
||||
final regArgs = settings.arguments as Map?;
|
||||
return MaterialPageRoute(
|
||||
builder: (_) => RegisterPage(isEmail: regArgs?['isEmail'] == true),
|
||||
);
|
||||
case '/forgot-password':
|
||||
return MaterialPageRoute(builder: (_) => const ForgotPasswordPage());
|
||||
case '/main':
|
||||
|
|
|
|||
Loading…
Reference in New Issue