From 94735125300bf0d61b1aa458acd77a446491b2b3 Mon Sep 17 00:00:00 2001 From: hailin Date: Wed, 4 Mar 2026 02:27:43 -0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E9=82=AE=E7=AE=B1=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E5=AE=8C=E6=95=B4=E5=AE=9E=E7=8E=B0=20=E2=80=94=20Gma?= =?UTF-8?q?il=20SMTP=20+=20=E9=82=AE=E4=BB=B6=E9=AA=8C=E8=AF=81=E7=A0=81?= =?UTF-8?q?=E5=85=A8=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端 (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 --- .../048_create_email_verifications.sql | 39 ++++ backend/services/auth-service/.env.example | 13 ++ backend/services/auth-service/package.json | 4 +- .../src/application/services/auth.service.ts | 123 +++++++++++++ .../src/application/services/email.service.ts | 170 ++++++++++++++++++ .../services/auth-service/src/auth.module.ts | 30 +++- .../src/domain/entities/email-log.entity.ts | 46 +++++ .../entities/email-verification.entity.ts | 69 +++++++ .../email-log.repository.interface.ts | 16 ++ ...email-verification.repository.interface.ts | 28 +++ .../src/domain/value-objects/email.vo.ts | 42 +++++ .../email/console-email.provider.ts | 24 +++ .../email/email-provider.interface.ts | 23 +++ .../infrastructure/email/gmail.provider.ts | 126 +++++++++++++ .../persistence/email-log.repository.ts | 35 ++++ .../email-verification.repository.ts | 65 +++++++ .../redis/email-code.service.ts | 73 ++++++++ .../http/controllers/auth.controller.ts | 74 ++++++++ .../src/interface/http/dto/login-email.dto.ts | 18 ++ .../interface/http/dto/register-email.dto.ts | 31 ++++ .../http/dto/reset-password-email.dto.ts | 19 ++ .../interface/http/dto/send-email-code.dto.ts | 17 ++ .../genex-mobile/lib/app/i18n/strings/en.dart | 1 + .../genex-mobile/lib/app/i18n/strings/ja.dart | 1 + .../lib/app/i18n/strings/zh_cn.dart | 1 + .../lib/app/i18n/strings/zh_tw.dart | 1 + .../lib/core/services/auth_service.dart | 85 +++++++++ .../presentation/pages/register_page.dart | 45 +++-- .../auth/presentation/pages/welcome_page.dart | 2 +- frontend/genex-mobile/lib/main.dart | 5 +- 30 files changed, 1207 insertions(+), 19 deletions(-) create mode 100644 backend/migrations/048_create_email_verifications.sql create mode 100644 backend/services/auth-service/src/application/services/email.service.ts create mode 100644 backend/services/auth-service/src/domain/entities/email-log.entity.ts create mode 100644 backend/services/auth-service/src/domain/entities/email-verification.entity.ts create mode 100644 backend/services/auth-service/src/domain/repositories/email-log.repository.interface.ts create mode 100644 backend/services/auth-service/src/domain/repositories/email-verification.repository.interface.ts create mode 100644 backend/services/auth-service/src/domain/value-objects/email.vo.ts create mode 100644 backend/services/auth-service/src/infrastructure/email/console-email.provider.ts create mode 100644 backend/services/auth-service/src/infrastructure/email/email-provider.interface.ts create mode 100644 backend/services/auth-service/src/infrastructure/email/gmail.provider.ts create mode 100644 backend/services/auth-service/src/infrastructure/persistence/email-log.repository.ts create mode 100644 backend/services/auth-service/src/infrastructure/persistence/email-verification.repository.ts create mode 100644 backend/services/auth-service/src/infrastructure/redis/email-code.service.ts create mode 100644 backend/services/auth-service/src/interface/http/dto/login-email.dto.ts create mode 100644 backend/services/auth-service/src/interface/http/dto/register-email.dto.ts create mode 100644 backend/services/auth-service/src/interface/http/dto/reset-password-email.dto.ts create mode 100644 backend/services/auth-service/src/interface/http/dto/send-email-code.dto.ts diff --git a/backend/migrations/048_create_email_verifications.sql b/backend/migrations/048_create_email_verifications.sql new file mode 100644 index 0000000..8f7b729 --- /dev/null +++ b/backend/migrations/048_create_email_verifications.sql @@ -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); diff --git a/backend/services/auth-service/.env.example b/backend/services/auth-service/.env.example index edc803b..696ca2f 100644 --- a/backend/services/auth-service/.env.example +++ b/backend/services/auth-service/.env.example @@ -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 diff --git a/backend/services/auth-service/package.json b/backend/services/auth-service/package.json index 9a826f4..796e5b2 100644 --- a/backend/services/auth-service/package.json +++ b/backend/services/auth-service/package.json @@ -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", diff --git a/backend/services/auth-service/src/application/services/auth.service.ts b/backend/services/auth-service/src/application/services/auth.service.ts index 3767934..d75e5bb 100644 --- a/backend/services/auth-service/src/application/services/auth.service.ts +++ b/backend/services/auth-service/src/application/services/auth.service.ts @@ -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 { + 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 { + 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 { + 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 { diff --git a/backend/services/auth-service/src/application/services/email.service.ts b/backend/services/auth-service/src/application/services/email.service.ts new file mode 100644 index 0000000..3ce26cb --- /dev/null +++ b/backend/services/auth-service/src/application/services/email.service.ts @@ -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 { + 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 { + 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; + } + } +} diff --git a/backend/services/auth-service/src/auth.module.ts b/backend/services/auth-service/src/auth.module.ts index 44a44b8..8600235 100644 --- a/backend/services/auth-service/src/auth.module.ts +++ b/backend/services/auth-service/src/auth.module.ts @@ -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 {} diff --git a/backend/services/auth-service/src/domain/entities/email-log.entity.ts b/backend/services/auth-service/src/domain/entities/email-log.entity.ts new file mode 100644 index 0000000..7844400 --- /dev/null +++ b/backend/services/auth-service/src/domain/entities/email-log.entity.ts @@ -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; +} diff --git a/backend/services/auth-service/src/domain/entities/email-verification.entity.ts b/backend/services/auth-service/src/domain/entities/email-verification.entity.ts new file mode 100644 index 0000000..47350e6 --- /dev/null +++ b/backend/services/auth-service/src/domain/entities/email-verification.entity.ts @@ -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(); + } +} diff --git a/backend/services/auth-service/src/domain/repositories/email-log.repository.interface.ts b/backend/services/auth-service/src/domain/repositories/email-log.repository.interface.ts new file mode 100644 index 0000000..94e690e --- /dev/null +++ b/backend/services/auth-service/src/domain/repositories/email-log.repository.interface.ts @@ -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; +} + +export const EMAIL_LOG_REPOSITORY = Symbol('IEmailLogRepository'); diff --git a/backend/services/auth-service/src/domain/repositories/email-verification.repository.interface.ts b/backend/services/auth-service/src/domain/repositories/email-verification.repository.interface.ts new file mode 100644 index 0000000..aa48bc5 --- /dev/null +++ b/backend/services/auth-service/src/domain/repositories/email-verification.repository.interface.ts @@ -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; + + /** 查找指定邮箱和类型的最新有效验证码 (未过期 + 未验证) */ + findLatestValid( + email: string, + type: EmailVerificationType, + ): Promise; + + /** 保存 (更新 attempts / verifiedAt) */ + save(verification: EmailVerification): Promise; + + /** 获取今日发送次数 */ + getDailySendCount(email: string): Promise; + + /** 清理过期记录,返回删除数量 */ + deleteExpired(): Promise; +} + +export const EMAIL_VERIFICATION_REPOSITORY = Symbol('IEmailVerificationRepository'); diff --git a/backend/services/auth-service/src/domain/value-objects/email.vo.ts b/backend/services/auth-service/src/domain/value-objects/email.vo.ts new file mode 100644 index 0000000..280c494 --- /dev/null +++ b/backend/services/auth-service/src/domain/value-objects/email.vo.ts @@ -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; + } +} diff --git a/backend/services/auth-service/src/infrastructure/email/console-email.provider.ts b/backend/services/auth-service/src/infrastructure/email/console-email.provider.ts new file mode 100644 index 0000000..b0124e8 --- /dev/null +++ b/backend/services/auth-service/src/infrastructure/email/console-email.provider.ts @@ -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 { + this.logger.warn( + `[DEV] Email code [${type}] → ${email} : ${code}`, + ); + return { success: true, providerId: `console-${Date.now()}` }; + } +} diff --git a/backend/services/auth-service/src/infrastructure/email/email-provider.interface.ts b/backend/services/auth-service/src/infrastructure/email/email-provider.interface.ts new file mode 100644 index 0000000..b19a21c --- /dev/null +++ b/backend/services/auth-service/src/infrastructure/email/email-provider.interface.ts @@ -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; +} + +export const EMAIL_PROVIDER = Symbol('IEmailProvider'); diff --git a/backend/services/auth-service/src/infrastructure/email/gmail.provider.ts b/backend/services/auth-service/src/infrastructure/email/gmail.provider.ts new file mode 100644 index 0000000..9dffbbc --- /dev/null +++ b/backend/services/auth-service/src/infrastructure/email/gmail.provider.ts @@ -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.REGISTER]: '【Genex】注册验证码', + [EmailVerificationType.LOGIN]: '【Genex】登录验证码', + [EmailVerificationType.RESET_PASSWORD]: '【Genex】重置密码验证码', + [EmailVerificationType.CHANGE_EMAIL]: '【Genex】更换邮箱验证码', + }; + + /** 验证码类型 → 操作说明文案 */ + private static readonly ACTION_MAP: Record = { + [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 { + 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 ` + + + + + + + + +
+ + + + + + + + + + + + + +
+ GENEX +

券金融平台

+
+

${action},请使用以下验证码:

+ +
+ ${code} +
+

+ 验证码有效期 5 分钟,请勿将验证码告知任何人。
+ 如非本人操作,请忽略此邮件。 +

+
+

+ 此邮件由系统自动发送,请勿直接回复。
+ © ${new Date().getFullYear()} Genex · 券金融平台 +

+
+
+ +`; + } +} diff --git a/backend/services/auth-service/src/infrastructure/persistence/email-log.repository.ts b/backend/services/auth-service/src/infrastructure/persistence/email-log.repository.ts new file mode 100644 index 0000000..71fdc3a --- /dev/null +++ b/backend/services/auth-service/src/infrastructure/persistence/email-log.repository.ts @@ -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, + ) {} + + async create(data: { + email: string; + type: EmailVerificationType; + status: EmailDeliveryStatus; + provider?: string; + providerId?: string; + errorMsg?: string; + userId?: string; + }): Promise { + 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); + } +} diff --git a/backend/services/auth-service/src/infrastructure/persistence/email-verification.repository.ts b/backend/services/auth-service/src/infrastructure/persistence/email-verification.repository.ts new file mode 100644 index 0000000..01f3095 --- /dev/null +++ b/backend/services/auth-service/src/infrastructure/persistence/email-verification.repository.ts @@ -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, + ) {} + + async create(data: { + email: string; + code: string; + type: EmailVerificationType; + expiresAt: Date; + }): Promise { + const entity = this.repo.create(data); + return this.repo.save(entity); + } + + async findLatestValid( + email: string, + type: EmailVerificationType, + ): Promise { + return this.repo.findOne({ + where: { + email, + type, + expiresAt: MoreThan(new Date()), + verifiedAt: IsNull(), + }, + order: { createdAt: 'DESC' }, + }); + } + + async save(verification: EmailVerification): Promise { + return this.repo.save(verification); + } + + async getDailySendCount(email: string): Promise { + const today = new Date(); + today.setHours(0, 0, 0, 0); + return this.repo.count({ + where: { + email, + createdAt: MoreThan(today), + }, + }); + } + + async deleteExpired(): Promise { + 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; + } +} diff --git a/backend/services/auth-service/src/infrastructure/redis/email-code.service.ts b/backend/services/auth-service/src/infrastructure/redis/email-code.service.ts new file mode 100644 index 0000000..607389e --- /dev/null +++ b/backend/services/auth-service/src/infrastructure/redis/email-code.service.ts @@ -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 { + 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 { + 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 { + const key = `${type}:${email}`; + await this.redis.del(key); + } +} diff --git a/backend/services/auth-service/src/interface/http/controllers/auth.controller.ts b/backend/services/auth-service/src/interface/http/controllers/auth.controller.ts index 11b1f5c..3a86024 100644 --- a/backend/services/auth-service/src/interface/http/controllers/auth.controller.ts +++ b/backend/services/auth-service/src/interface/http/controllers/auth.controller.ts @@ -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: '密码重置成功,请重新登录', + }; + } } diff --git a/backend/services/auth-service/src/interface/http/dto/login-email.dto.ts b/backend/services/auth-service/src/interface/http/dto/login-email.dto.ts new file mode 100644 index 0000000..f84bab0 --- /dev/null +++ b/backend/services/auth-service/src/interface/http/dto/login-email.dto.ts @@ -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; +} diff --git a/backend/services/auth-service/src/interface/http/dto/register-email.dto.ts b/backend/services/auth-service/src/interface/http/dto/register-email.dto.ts new file mode 100644 index 0000000..702ba97 --- /dev/null +++ b/backend/services/auth-service/src/interface/http/dto/register-email.dto.ts @@ -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; +} diff --git a/backend/services/auth-service/src/interface/http/dto/reset-password-email.dto.ts b/backend/services/auth-service/src/interface/http/dto/reset-password-email.dto.ts new file mode 100644 index 0000000..43b1c27 --- /dev/null +++ b/backend/services/auth-service/src/interface/http/dto/reset-password-email.dto.ts @@ -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; +} diff --git a/backend/services/auth-service/src/interface/http/dto/send-email-code.dto.ts b/backend/services/auth-service/src/interface/http/dto/send-email-code.dto.ts new file mode 100644 index 0000000..3f2b378 --- /dev/null +++ b/backend/services/auth-service/src/interface/http/dto/send-email-code.dto.ts @@ -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; +} diff --git a/frontend/genex-mobile/lib/app/i18n/strings/en.dart b/frontend/genex-mobile/lib/app/i18n/strings/en.dart index 13e46f6..78ab329 100644 --- a/frontend/genex-mobile/lib/app/i18n/strings/en.dart +++ b/frontend/genex-mobile/lib/app/i18n/strings/en.dart @@ -82,6 +82,7 @@ const Map 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', diff --git a/frontend/genex-mobile/lib/app/i18n/strings/ja.dart b/frontend/genex-mobile/lib/app/i18n/strings/ja.dart index ab65e34..30493b6 100644 --- a/frontend/genex-mobile/lib/app/i18n/strings/ja.dart +++ b/frontend/genex-mobile/lib/app/i18n/strings/ja.dart @@ -82,6 +82,7 @@ const Map ja = { 'register.hasAccount': 'アカウントをお持ちですか?', 'register.loginNow': 'ログイン', 'register.errorPhoneRequired': '電話番号を入力してください', + 'register.errorEmailRequired': 'メールアドレスを入力してください', 'register.errorCodeInvalid': '6桁の認証コードを入力してください', 'register.errorPasswordWeak': 'パスワードは8文字以上で英字と数字を含む必要があります', 'register.errorTermsRequired': '利用規約に同意してください', diff --git a/frontend/genex-mobile/lib/app/i18n/strings/zh_cn.dart b/frontend/genex-mobile/lib/app/i18n/strings/zh_cn.dart index 2499e99..023ca2d 100644 --- a/frontend/genex-mobile/lib/app/i18n/strings/zh_cn.dart +++ b/frontend/genex-mobile/lib/app/i18n/strings/zh_cn.dart @@ -82,6 +82,7 @@ const Map zhCN = { 'register.hasAccount': '已有账号?', 'register.loginNow': '立即登录', 'register.errorPhoneRequired': '请输入手机号', + 'register.errorEmailRequired': '请输入邮箱地址', 'register.errorCodeInvalid': '请输入6位验证码', 'register.errorPasswordWeak': '密码需要8位以上且包含字母和数字', 'register.errorTermsRequired': '请先阅读并同意用户协议', diff --git a/frontend/genex-mobile/lib/app/i18n/strings/zh_tw.dart b/frontend/genex-mobile/lib/app/i18n/strings/zh_tw.dart index 37791d2..b34a450 100644 --- a/frontend/genex-mobile/lib/app/i18n/strings/zh_tw.dart +++ b/frontend/genex-mobile/lib/app/i18n/strings/zh_tw.dart @@ -82,6 +82,7 @@ const Map zhTW = { 'register.hasAccount': '已有帳號?', 'register.loginNow': '立即登入', 'register.errorPhoneRequired': '請輸入手機號', + 'register.errorEmailRequired': '請輸入電子郵件地址', 'register.errorCodeInvalid': '請輸入6位驗證碼', 'register.errorPasswordWeak': '密碼需要8位以上且包含字母和數字', 'register.errorTermsRequired': '請先閱讀並同意使用者協議', diff --git a/frontend/genex-mobile/lib/core/services/auth_service.dart b/frontend/genex-mobile/lib/core/services/auth_service.dart index 5b99584..4ad5ccd 100644 --- a/frontend/genex-mobile/lib/core/services/auth_service.dart +++ b/frontend/genex-mobile/lib/core/services/auth_service.dart @@ -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 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; + return data['expiresIn'] as int; + } + + // ── 邮箱注册 ───────────────────────────────────────────────────────────── + + /// 邮箱注册(需先用 EmailCodeType.register 获取验证码) + /// + /// 注册成功后自动登录(调用 _setAuth 保存 Token + 设置请求头)。 + Future 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 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 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, + }); + } + // ── 推荐码 ───────────────────────────────────────────────────────────────── /// 验证推荐码是否有效(注册页实时校验用,不需要登录) diff --git a/frontend/genex-mobile/lib/features/auth/presentation/pages/register_page.dart b/frontend/genex-mobile/lib/features/auth/presentation/pages/register_page.dart index fffc421..f493440 100644 --- a/frontend/genex-mobile/lib/features/auth/presentation/pages/register_page.dart +++ b/frontend/genex-mobile/lib/features/auth/presentation/pages/register_page.dart @@ -72,14 +72,20 @@ class _RegisterPageState extends State { /* ── 发送注册验证码 ── */ Future _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 { /* ── 提交注册 ── */ Future _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 { 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)); diff --git a/frontend/genex-mobile/lib/features/auth/presentation/pages/welcome_page.dart b/frontend/genex-mobile/lib/features/auth/presentation/pages/welcome_page.dart index 067057f..f33c4e9 100644 --- a/frontend/genex-mobile/lib/features/auth/presentation/pages/welcome_page.dart +++ b/frontend/genex-mobile/lib/features/auth/presentation/pages/welcome_page.dart @@ -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), diff --git a/frontend/genex-mobile/lib/main.dart b/frontend/genex-mobile/lib/main.dart index e1b9fa4..2b16a42 100644 --- a/frontend/genex-mobile/lib/main.dart +++ b/frontend/genex-mobile/lib/main.dart @@ -153,7 +153,10 @@ class _GenexConsumerAppState extends State { 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':