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:
hailin 2026-03-04 02:27:43 -08:00
parent a893dbdb1b
commit 9473512530
30 changed files with 1207 additions and 19 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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()}` };
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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: '密码重置成功,请重新登录',
};
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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': '利用規約に同意してください',

View File

@ -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': '请先阅读并同意用户协议',

View File

@ -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': '請先閱讀並同意使用者協議',

View File

@ -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,
});
}
//
///

View File

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

View File

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

View File

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