From e89ec82406f8f140a0bde16ff0ea2cf3444e4427 Mon Sep 17 00:00:00 2001 From: hailin Date: Mon, 23 Feb 2026 19:12:57 -0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E5=AE=8C=E6=95=B4=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=20SMS=20=E6=89=8B=E6=9C=BA=E6=B3=A8=E5=86=8C/?= =?UTF-8?q?=E7=99=BB=E5=BD=95/=E9=AA=8C=E8=AF=81=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 参考 rwadurian 项目的成熟实现,在 Genex auth-service 上全面增强短信验证体系。 ## 新增功能 ### Domain 层 - Phone Value Object: E.164 标准化、中国大陆格式自动补+86、掩码显示(138****8000) - SmsCode Value Object: crypto 安全随机6位生成、格式验证 - SmsVerification Entity: 验证码记录持久化,支持4种类型(REGISTER/LOGIN/RESET_PASSWORD/CHANGE_PHONE) - SmsLog Entity: SMS发送日志审计追踪(provider/status/error) - User Entity 增强: loginFailCount + lockedUntil 字段,指数退避锁定策略(1→2→4→8...→1440分钟) - 5个新 Domain Events: SmsCodeSent, SmsCodeVerified, AccountLocked, PhoneChanged, PasswordReset ### Infrastructure 层 - 3个 SQL 迁移: users表锁定字段(041), sms_verifications表(042), sms_logs表(043) - SmsVerification/SmsLog TypeORM Repository 实现 - SMS Provider 抽象层: ISmsProvider 接口 + ConsoleSmsProvider(开发) + AliyunSmsProvider(生产) - Redis SmsCodeService 增强: 类型前缀 auth:sms:{type}:{phone},保留向后兼容 ### Application 层 - 独立 SmsService: 发送验证码(日限额10条+业务规则校验) + 验证验证码(尝试限制5次) - AuthService 重构: 注册需SMS验证、密码登录带锁定检查、+resetPassword/changePhone ### Interface 层 - 新端点: POST /auth/sms/send, POST /auth/reset-password, POST /auth/change-phone - DTO 更新: RegisterDto 增加 smsCode 必填, SendSmsCodeDto 增加 type 枚举 - 全部端点 Swagger 文档 ### 配置 - .env.example: SMS_ENABLED, ALIYUN_SMS_*, SMS_DAILY_LIMIT, LOGIN_MAX_FAIL_ATTEMPTS - auth.module: SMS_PROVIDER 按 SMS_ENABLED 环境变量自动切换 ## API 端点一览 - POST /api/v1/auth/sms/send — 发送验证码(4种类型) - POST /api/v1/auth/register — 手机注册(phone+smsCode+password) - POST /api/v1/auth/login — 密码登录(带锁定检查) - POST /api/v1/auth/login-phone — 短信验证码登录 - POST /api/v1/auth/reset-password — 重置密码 - POST /api/v1/auth/change-phone — 换绑手机(需登录) Co-Authored-By: Claude Opus 4.6 --- backend/.env.example | 18 + .../041_add_user_lockout_fields.sql | 4 + .../042_create_sms_verifications.sql | 14 + backend/migrations/043_create_sms_logs.sql | 15 + .../src/application/services/auth.service.ts | 364 ++++++++++-------- .../services/event-publisher.service.ts | 15 + .../src/application/services/sms.service.ts | 171 ++++++++ .../services/auth-service/src/auth.module.ts | 28 +- .../src/domain/entities/sms-log.entity.ts | 47 +++ .../entities/sms-verification.entity.ts | 69 ++++ .../src/domain/entities/user.entity.ts | 59 +++ .../src/domain/events/auth.events.ts | 34 ++ .../sms-log.repository.interface.ts | 32 ++ .../sms-verification.repository.interface.ts | 30 ++ .../src/domain/value-objects/phone.vo.ts | 72 ++++ .../src/domain/value-objects/sms-code.vo.ts | 41 ++ .../persistence/sms-log.repository.ts | 52 +++ .../sms-verification.repository.ts | 67 ++++ .../infrastructure/redis/sms-code.service.ts | 61 ++- .../infrastructure/sms/aliyun-sms.provider.ts | 88 +++++ .../sms/console-sms.provider.ts | 22 ++ .../sms/sms-provider.interface.ts | 23 ++ .../http/controllers/auth.controller.ts | 148 ++++--- .../interface/http/dto/change-phone.dto.ts | 14 + .../src/interface/http/dto/login-phone.dto.ts | 13 +- .../src/interface/http/dto/register.dto.ts | 21 +- .../interface/http/dto/reset-password.dto.ts | 20 + .../interface/http/dto/send-sms-code.dto.ts | 13 +- 28 files changed, 1324 insertions(+), 231 deletions(-) create mode 100644 backend/migrations/041_add_user_lockout_fields.sql create mode 100644 backend/migrations/042_create_sms_verifications.sql create mode 100644 backend/migrations/043_create_sms_logs.sql create mode 100644 backend/services/auth-service/src/application/services/sms.service.ts create mode 100644 backend/services/auth-service/src/domain/entities/sms-log.entity.ts create mode 100644 backend/services/auth-service/src/domain/entities/sms-verification.entity.ts create mode 100644 backend/services/auth-service/src/domain/repositories/sms-log.repository.interface.ts create mode 100644 backend/services/auth-service/src/domain/repositories/sms-verification.repository.interface.ts create mode 100644 backend/services/auth-service/src/domain/value-objects/phone.vo.ts create mode 100644 backend/services/auth-service/src/domain/value-objects/sms-code.vo.ts create mode 100644 backend/services/auth-service/src/infrastructure/persistence/sms-log.repository.ts create mode 100644 backend/services/auth-service/src/infrastructure/persistence/sms-verification.repository.ts create mode 100644 backend/services/auth-service/src/infrastructure/sms/aliyun-sms.provider.ts create mode 100644 backend/services/auth-service/src/infrastructure/sms/console-sms.provider.ts create mode 100644 backend/services/auth-service/src/infrastructure/sms/sms-provider.interface.ts create mode 100644 backend/services/auth-service/src/interface/http/dto/change-phone.dto.ts create mode 100644 backend/services/auth-service/src/interface/http/dto/reset-password.dto.ts diff --git a/backend/.env.example b/backend/.env.example index be0e427..dae86fe 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -50,6 +50,24 @@ MINIO_ACCESS_KEY=genex-admin MINIO_SECRET_KEY=genex-minio-secret MINIO_USE_SSL=false +# --- SMS Service --- +SMS_ENABLED=false +SMS_CODE_EXPIRE_SECONDS=300 +SMS_CODE_LENGTH=6 +SMS_DAILY_LIMIT=10 +SMS_MAX_VERIFY_ATTEMPTS=5 + +# --- Aliyun SMS (when SMS_ENABLED=true) --- +ALIYUN_ACCESS_KEY_ID= +ALIYUN_ACCESS_KEY_SECRET= +ALIYUN_SMS_SIGN_NAME=券金融 +ALIYUN_SMS_TEMPLATE_CODE= +ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com + +# --- Account Lockout --- +LOGIN_MAX_FAIL_ATTEMPTS=6 +LOGIN_MAX_LOCK_MINUTES=1440 + # --- External Services (all mocked in MVP) --- CHAIN_RPC_URL=http://localhost:26657 SENDGRID_API_KEY=mock-key diff --git a/backend/migrations/041_add_user_lockout_fields.sql b/backend/migrations/041_add_user_lockout_fields.sql new file mode 100644 index 0000000..9282747 --- /dev/null +++ b/backend/migrations/041_add_user_lockout_fields.sql @@ -0,0 +1,4 @@ +-- 041: Add login lockout fields to users table +ALTER TABLE users + ADD COLUMN IF NOT EXISTS login_fail_count INT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS locked_until TIMESTAMPTZ; diff --git a/backend/migrations/042_create_sms_verifications.sql b/backend/migrations/042_create_sms_verifications.sql new file mode 100644 index 0000000..4405b08 --- /dev/null +++ b/backend/migrations/042_create_sms_verifications.sql @@ -0,0 +1,14 @@ +-- 042: SMS verification codes table +CREATE TABLE IF NOT EXISTS sms_verifications ( + id BIGSERIAL PRIMARY KEY, + phone VARCHAR(20) NOT NULL, + code VARCHAR(255) NOT NULL, + type VARCHAR(20) NOT NULL CHECK (type IN ('REGISTER', 'LOGIN', 'RESET_PASSWORD', 'CHANGE_PHONE')), + expires_at TIMESTAMPTZ NOT NULL, + verified_at TIMESTAMPTZ, + attempts INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_sms_verif_phone_type ON sms_verifications(phone, type); +CREATE INDEX idx_sms_verif_expires ON sms_verifications(expires_at); diff --git a/backend/migrations/043_create_sms_logs.sql b/backend/migrations/043_create_sms_logs.sql new file mode 100644 index 0000000..0804b5f --- /dev/null +++ b/backend/migrations/043_create_sms_logs.sql @@ -0,0 +1,15 @@ +-- 043: SMS delivery logs table (audit trail) +CREATE TABLE IF NOT EXISTS sms_logs ( + id BIGSERIAL PRIMARY KEY, + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + phone VARCHAR(20) NOT NULL, + type VARCHAR(20) NOT NULL CHECK (type IN ('REGISTER', 'LOGIN', 'RESET_PASSWORD', 'CHANGE_PHONE')), + status VARCHAR(20) NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING', 'SENT', 'DELIVERED', 'FAILED')), + provider VARCHAR(50), + provider_id VARCHAR(100), + error_msg TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_sms_logs_phone ON sms_logs(phone); +CREATE INDEX idx_sms_logs_created ON sms_logs(created_at); 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 3b5cc35..a1b0cfb 100644 --- a/backend/services/auth-service/src/application/services/auth.service.ts +++ b/backend/services/auth-service/src/application/services/auth.service.ts @@ -1,16 +1,25 @@ -import { Injectable, Logger, UnauthorizedException, ConflictException, ForbiddenException, BadRequestException } from '@nestjs/common'; -import { Inject } from '@nestjs/common'; +import { + Injectable, + Logger, + UnauthorizedException, + ConflictException, + ForbiddenException, + BadRequestException, + Inject, +} from '@nestjs/common'; import { USER_REPOSITORY, IUserRepository } from '../../domain/repositories/user.repository.interface'; import { REFRESH_TOKEN_REPOSITORY, IRefreshTokenRepository } from '../../domain/repositories/refresh-token.repository.interface'; import { TokenService } from './token.service'; +import { SmsService } from './sms.service'; import { Password } from '../../domain/value-objects/password.vo'; +import { Phone } from '../../domain/value-objects/phone.vo'; import { UserRole, UserStatus } from '../../domain/entities/user.entity'; +import { SmsVerificationType } from '../../domain/entities/sms-verification.entity'; import { EventPublisherService } from './event-publisher.service'; -import { SmsCodeService } from '../../infrastructure/redis/sms-code.service'; export interface RegisterDto { - phone?: string; - email?: string; + phone: string; + smsCode: string; password: string; nickname?: string; } @@ -28,13 +37,16 @@ export interface AuthTokens { expiresIn: number; } -export interface RegisterResult { +export interface AuthResult { user: { id: string; phone: string | null; email: string | null; + nickname: string | null; + avatarUrl: string | null; role: string; kycLevel: number; + walletMode: string; }; tokens: AuthTokens; } @@ -47,33 +59,31 @@ export class AuthService { @Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository, @Inject(REFRESH_TOKEN_REPOSITORY) private readonly refreshTokenRepo: IRefreshTokenRepository, private readonly tokenService: TokenService, + private readonly smsService: SmsService, private readonly eventPublisher: EventPublisherService, - private readonly smsCodeService: SmsCodeService, ) {} - async register(dto: RegisterDto): Promise { - // Validate at least one identifier - if (!dto.phone && !dto.email) { - throw new ConflictException('Phone or email is required'); + /* ── Register ── */ + + async register(dto: RegisterDto): Promise { + const phone = Phone.create(dto.phone); + + // Check duplicate + const existing = await this.userRepo.findByPhone(phone.value); + if (existing) { + throw new ConflictException('该手机号已注册'); } - // Check duplicates - if (dto.phone) { - const existing = await this.userRepo.findByPhone(dto.phone); - if (existing) throw new ConflictException('Phone number already registered'); - } - if (dto.email) { - const existing = await this.userRepo.findByEmail(dto.email); - if (existing) throw new ConflictException('Email already registered'); - } + // Verify SMS code + await this.smsService.verifyCode(dto.phone, dto.smsCode, SmsVerificationType.REGISTER); // Hash password const password = await Password.create(dto.password); // Create user const user = await this.userRepo.create({ - phone: dto.phone || null, - email: dto.email || null, + phone: phone.value, + email: null, passwordHash: password.value, nickname: dto.nickname || null, role: UserRole.USER, @@ -84,8 +94,6 @@ export class AuthService { // Generate tokens const tokens = await this.tokenService.generateTokenPair(user.id, user.role, user.kycLevel); - - // Store refresh token await this.tokenService.storeRefreshToken(user.id, tokens.refreshToken); // Publish event @@ -97,52 +105,51 @@ export class AuthService { timestamp: new Date().toISOString(), }); - this.logger.log(`User registered: ${user.id}`); - - return { - user: { - id: user.id, - phone: user.phone, - email: user.email, - role: user.role, - kycLevel: user.kycLevel, - }, - tokens, - }; + this.logger.log(`User registered: ${user.id} phone=${phone.masked}`); + return { user: this.toUserDto(user), tokens }; } - async login(dto: LoginDto): Promise<{ user: any; tokens: AuthTokens }> { - // Find user by phone or email + /* ── Password Login ── */ + + async login(dto: LoginDto): Promise { const user = await this.userRepo.findByPhoneOrEmail(dto.identifier); if (!user) { - throw new UnauthorizedException('Invalid credentials'); + throw new UnauthorizedException('账号或密码错误'); } - // Check status - if (user.status === UserStatus.FROZEN) { - throw new ForbiddenException('Account is frozen'); - } - if (user.status === UserStatus.DELETED) { - throw new UnauthorizedException('Account not found'); - } + this.checkUserStatus(user); + this.checkAccountLock(user); // Verify password const password = Password.fromHash(user.passwordHash); const valid = await password.verify(dto.password); if (!valid) { - throw new UnauthorizedException('Invalid credentials'); + const lockInfo = user.recordLoginFailure(); + await this.userRepo.save(user); + + if (lockInfo.lockMinutes) { + await this.eventPublisher.publishAccountLocked({ + userId: user.id, + lockedUntil: user.lockedUntil!.toISOString(), + failCount: user.loginFailCount, + timestamp: new Date().toISOString(), + }); + throw new ForbiddenException( + `登录失败次数过多,账号已锁定 ${lockInfo.lockMinutes} 分钟`, + ); + } + throw new UnauthorizedException( + `账号或密码错误,还剩 ${lockInfo.remainingAttempts} 次尝试机会`, + ); } - // Update last login - await this.userRepo.updateLastLogin(user.id); + // Success + user.recordLoginSuccess(dto.ipAddress); + await this.userRepo.save(user); - // Generate tokens const tokens = await this.tokenService.generateTokenPair(user.id, user.role, user.kycLevel); - - // Store refresh token await this.tokenService.storeRefreshToken(user.id, tokens.refreshToken, dto.deviceInfo, dto.ipAddress); - // Publish event await this.eventPublisher.publishUserLoggedIn({ userId: user.id, ipAddress: dto.ipAddress || null, @@ -150,47 +157,143 @@ export class AuthService { timestamp: new Date().toISOString(), }); - return { - user: { - id: user.id, - phone: user.phone, - email: user.email, - nickname: user.nickname, - avatarUrl: user.avatarUrl, - role: user.role, - kycLevel: user.kycLevel, - walletMode: user.walletMode, - }, - tokens, - }; + return { user: this.toUserDto(user), tokens }; } + /* ── SMS Login ── */ + + async loginWithPhone( + rawPhone: string, + smsCode: string, + deviceInfo?: string, + ipAddress?: string, + ): Promise { + const phone = Phone.create(rawPhone); + + // Verify SMS code + await this.smsService.verifyCode(rawPhone, smsCode, SmsVerificationType.LOGIN); + + const user = await this.userRepo.findByPhone(phone.value); + if (!user) { + throw new UnauthorizedException('该手机号未注册'); + } + + this.checkUserStatus(user); + + // SMS login bypasses lockout (code-verified) + 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 ── */ + + async resetPassword( + rawPhone: string, + smsCode: string, + newPassword: string, + ): Promise { + const phone = Phone.create(rawPhone); + + // Verify SMS code + await this.smsService.verifyCode(rawPhone, smsCode, SmsVerificationType.RESET_PASSWORD); + + const user = await this.userRepo.findByPhone(phone.value); + if (!user) { + throw new BadRequestException('该手机号未注册'); + } + + // Set new password + const passwordVo = await Password.create(newPassword); + user.passwordHash = passwordVo.value; + + // Clear lockout + user.loginFailCount = 0; + user.lockedUntil = null; + await this.userRepo.save(user); + + // Revoke all refresh tokens (force re-login) + await this.refreshTokenRepo.revokeByUserId(user.id); + + await this.eventPublisher.publishPasswordReset({ + userId: user.id, + phone: phone.value, + timestamp: new Date().toISOString(), + }); + + this.logger.log(`Password reset: userId=${user.id} phone=${phone.masked}`); + } + + /* ── Change Phone ── */ + + async changePhone( + userId: string, + newRawPhone: string, + newSmsCode: string, + ): Promise { + const newPhone = Phone.create(newRawPhone); + + // Verify new phone SMS code + await this.smsService.verifyCode(newRawPhone, newSmsCode, SmsVerificationType.CHANGE_PHONE); + + const user = await this.userRepo.findById(userId); + if (!user) { + throw new UnauthorizedException('用户不存在'); + } + + // Check new phone not already used + const existing = await this.userRepo.findByPhone(newPhone.value); + if (existing) { + throw new ConflictException('该手机号已被其他账户使用'); + } + + const oldPhone = user.phone; + user.phone = newPhone.value; + await this.userRepo.save(user); + + await this.eventPublisher.publishPhoneChanged({ + userId: user.id, + oldPhone: oldPhone || '', + newPhone: newPhone.value, + timestamp: new Date().toISOString(), + }); + + this.logger.log(`Phone changed: userId=${user.id} new=${newPhone.masked}`); + } + + /* ── Token Refresh ── */ + async refreshToken(refreshToken: string): Promise { const payload = await this.tokenService.verifyRefreshToken(refreshToken); - // Fetch user to get current role/kycLevel const user = await this.userRepo.findById(payload.sub); if (!user || user.status !== UserStatus.ACTIVE) { - throw new UnauthorizedException('User not found or inactive'); + throw new UnauthorizedException('用户不存在或已停用'); } - // Revoke old refresh token await this.tokenService.revokeRefreshToken(refreshToken); - // Generate new token pair const tokens = await this.tokenService.generateTokenPair(user.id, user.role, user.kycLevel); - - // Store new refresh token await this.tokenService.storeRefreshToken(user.id, tokens.refreshToken); - return tokens; } + /* ── Logout ── */ + async logout(userId: string): Promise { - // Revoke all refresh tokens for this user await this.refreshTokenRepo.revokeByUserId(userId); - // Publish event await this.eventPublisher.publishUserLoggedOut({ userId, timestamp: new Date().toISOString(), @@ -199,115 +302,64 @@ export class AuthService { this.logger.log(`User logged out: ${userId}`); } + /* ── Change Password ── */ + async changePassword(userId: string, oldPassword: string, newPassword: string): Promise { const user = await this.userRepo.findById(userId); - if (!user) throw new UnauthorizedException('User not found'); + if (!user) throw new UnauthorizedException('用户不存在'); const currentPassword = Password.fromHash(user.passwordHash); const valid = await currentPassword.verify(oldPassword); - if (!valid) throw new UnauthorizedException('Current password is incorrect'); + if (!valid) throw new UnauthorizedException('当前密码错误'); const newHash = await Password.create(newPassword); user.passwordHash = newHash.value; await this.userRepo.save(user); - // Revoke all refresh tokens (force re-login) await this.refreshTokenRepo.revokeByUserId(userId); - // Publish event await this.eventPublisher.publishPasswordChanged({ userId, timestamp: new Date().toISOString(), }); } - /** - * Send a 6-digit SMS verification code to the given phone number. - * In dev mode, the code is logged to console. - */ - async sendSmsCode(phone: string): Promise { - if (!phone) { - throw new BadRequestException('Phone number is required'); - } - await this.smsCodeService.generateCode(phone); - this.logger.log(`SMS code sent to ${phone}`); + /* ── Send SMS Code (delegates to SmsService) ── */ + + async sendSmsCode(rawPhone: string, type: SmsVerificationType): Promise<{ expiresIn: number }> { + return this.smsService.sendCode(rawPhone, type); } - /** - * Login with phone number and SMS verification code. - * If the user does not exist, a new account is created automatically. - */ - async loginWithPhone(phone: string, smsCode: string, ipAddress?: string): Promise<{ user: any; tokens: AuthTokens }> { - // Verify the SMS code - const valid = await this.smsCodeService.verifyCode(phone, smsCode); - if (!valid) { - throw new UnauthorizedException('Invalid or expired SMS code'); - } + /* ── Private Helpers ── */ - // Find or create user by phone - let user = await this.userRepo.findByPhone(phone); - if (!user) { - // Auto-register: create a new user with a random password hash - const randomPassword = await Password.create(`auto-${Date.now()}-${Math.random()}`); - user = await this.userRepo.create({ - phone, - email: null, - passwordHash: randomPassword.value, - nickname: null, - role: UserRole.USER, - status: UserStatus.ACTIVE, - kycLevel: 0, - walletMode: 'standard', - }); - - await this.eventPublisher.publishUserRegistered({ - userId: user.id, - phone: user.phone, - email: user.email, - role: user.role, - timestamp: new Date().toISOString(), - }); - - this.logger.log(`New user auto-registered via phone login: ${user.id}`); - } - - // Check status + private checkUserStatus(user: any): void { if (user.status === UserStatus.FROZEN) { - throw new ForbiddenException('Account is frozen'); + throw new ForbiddenException('账号已被冻结'); } if (user.status === UserStatus.DELETED) { - throw new UnauthorizedException('Account not found'); + throw new UnauthorizedException('账号不存在'); } + } - // Update last login - await this.userRepo.updateLastLogin(user.id); - - // Generate tokens - const tokens = await this.tokenService.generateTokenPair(user.id, user.role, user.kycLevel); - - // Store refresh token - await this.tokenService.storeRefreshToken(user.id, tokens.refreshToken, undefined, ipAddress); - - // Publish login event - await this.eventPublisher.publishUserLoggedIn({ - userId: user.id, - ipAddress: ipAddress || null, - deviceInfo: null, - timestamp: new Date().toISOString(), - }); + private checkAccountLock(user: any): void { + if (user.isLocked) { + const mins = Math.ceil(user.lockRemainingSeconds / 60); + throw new ForbiddenException( + `账号已锁定,请 ${mins} 分钟后再试`, + ); + } + } + private toUserDto(user: any) { return { - user: { - id: user.id, - phone: user.phone, - email: user.email, - nickname: user.nickname, - avatarUrl: user.avatarUrl, - role: user.role, - kycLevel: user.kycLevel, - walletMode: user.walletMode, - }, - tokens, + id: user.id, + phone: user.phone, + email: user.email, + nickname: user.nickname, + avatarUrl: user.avatarUrl, + role: user.role, + kycLevel: user.kycLevel, + walletMode: user.walletMode, }; } } diff --git a/backend/services/auth-service/src/application/services/event-publisher.service.ts b/backend/services/auth-service/src/application/services/event-publisher.service.ts index 4600ee1..069bdcf 100644 --- a/backend/services/auth-service/src/application/services/event-publisher.service.ts +++ b/backend/services/auth-service/src/application/services/event-publisher.service.ts @@ -4,6 +4,9 @@ import { UserLoggedInEvent, UserLoggedOutEvent, PasswordChangedEvent, + AccountLockedEvent, + PhoneChangedEvent, + PasswordResetEvent, } from '../../domain/events/auth.events'; /** @@ -32,6 +35,18 @@ export class EventPublisherService { await this.publishToOutbox('genex.user.password-changed', 'User', event.userId, 'user.password_changed', event); } + async publishAccountLocked(event: AccountLockedEvent): Promise { + await this.publishToOutbox('genex.user.locked', 'User', event.userId, 'user.account_locked', event); + } + + async publishPhoneChanged(event: PhoneChangedEvent): Promise { + await this.publishToOutbox('genex.user.phone-changed', 'User', event.userId, 'user.phone_changed', event); + } + + async publishPasswordReset(event: PasswordResetEvent): Promise { + await this.publishToOutbox('genex.user.password-reset', 'User', event.userId, 'user.password_reset', event); + } + private async publishToOutbox( topic: string, aggregateType: string, diff --git a/backend/services/auth-service/src/application/services/sms.service.ts b/backend/services/auth-service/src/application/services/sms.service.ts new file mode 100644 index 0000000..e1106e7 --- /dev/null +++ b/backend/services/auth-service/src/application/services/sms.service.ts @@ -0,0 +1,171 @@ +import { + Injectable, + Logger, + BadRequestException, + Inject, +} from '@nestjs/common'; +import { SmsVerificationType } from '../../domain/entities/sms-verification.entity'; +import { SmsDeliveryStatus } from '../../domain/entities/sms-log.entity'; +import { + ISmsVerificationRepository, + SMS_VERIFICATION_REPOSITORY, +} from '../../domain/repositories/sms-verification.repository.interface'; +import { + ISmsLogRepository, + SMS_LOG_REPOSITORY, +} from '../../domain/repositories/sms-log.repository.interface'; +import { IUserRepository, USER_REPOSITORY } from '../../domain/repositories/user.repository.interface'; +import { ISmsProvider, SMS_PROVIDER } from '../../infrastructure/sms/sms-provider.interface'; +import { SmsCodeService } from '../../infrastructure/redis/sms-code.service'; +import { SmsCode } from '../../domain/value-objects/sms-code.vo'; +import { Phone } from '../../domain/value-objects/phone.vo'; + +@Injectable() +export class SmsService { + private readonly logger = new Logger('SmsService'); + private readonly codeExpireSeconds: number; + private readonly dailyLimit: number; + private readonly maxAttempts: number; + + constructor( + @Inject(SMS_VERIFICATION_REPOSITORY) + private readonly smsVerifRepo: ISmsVerificationRepository, + @Inject(SMS_LOG_REPOSITORY) + private readonly smsLogRepo: ISmsLogRepository, + @Inject(USER_REPOSITORY) + private readonly userRepo: IUserRepository, + @Inject(SMS_PROVIDER) + private readonly smsProvider: ISmsProvider, + private readonly smsCodeCache: SmsCodeService, + ) { + this.codeExpireSeconds = parseInt(process.env.SMS_CODE_EXPIRE_SECONDS || '300', 10); + this.dailyLimit = parseInt(process.env.SMS_DAILY_LIMIT || '10', 10); + this.maxAttempts = parseInt(process.env.SMS_MAX_VERIFY_ATTEMPTS || '5', 10); + } + + /** + * 发送短信验证码 + */ + async sendCode( + rawPhone: string, + type: SmsVerificationType, + ): Promise<{ expiresIn: number }> { + const phone = Phone.create(rawPhone); + + // 1. 检查日发送限额 + const dailyCount = await this.smsVerifRepo.getDailySendCount(phone.value); + if (dailyCount >= this.dailyLimit) { + throw new BadRequestException('今日发送次数已达上限,请明天再试'); + } + + // 2. 按类型验证业务规则 + await this.validateSendRequest(phone, type); + + // 3. 生成验证码 + const code = SmsCode.generate(); + const expiresAt = new Date(Date.now() + this.codeExpireSeconds * 1000); + + // 4. 持久化到 DB + await this.smsVerifRepo.create({ + phone: phone.value, + code: code.value, + type, + expiresAt, + }); + + // 5. 缓存到 Redis (快速查找) + await this.smsCodeCache.setCode(phone.value, code.value, type, this.codeExpireSeconds); + + // 6. 通过 Provider 发送 + const result = await this.smsProvider.send(phone.value, code.value, type); + + // 7. 记录发送日志 + const user = await this.userRepo.findByPhone(phone.value); + await this.smsLogRepo.create({ + phone: phone.value, + type, + status: result.success ? SmsDeliveryStatus.SENT : SmsDeliveryStatus.FAILED, + provider: result.providerId ? 'aliyun' : 'console', + providerId: result.providerId, + errorMsg: result.errorMsg, + userId: user?.id, + }); + + this.logger.log(`SMS code sent: phone=${phone.masked} type=${type}`); + return { expiresIn: this.codeExpireSeconds }; + } + + /** + * 验证短信验证码 + */ + async verifyCode( + rawPhone: string, + code: string, + type: SmsVerificationType, + ): Promise { + const phone = Phone.create(rawPhone); + + // 先尝试 Redis 快速路径 + const redisMatch = await this.smsCodeCache.verifyAndDelete(phone.value, code, type); + if (redisMatch) { + // Redis 匹配成功,同步标记 DB 记录 + const dbRecord = await this.smsVerifRepo.findLatestValid(phone.value, type); + if (dbRecord) { + dbRecord.markVerified(); + await this.smsVerifRepo.save(dbRecord); + } + return true; + } + + // Redis miss → 回退到 DB 验证 + const verification = await this.smsVerifRepo.findLatestValid(phone.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.smsVerifRepo.save(verification); + throw new BadRequestException('验证码错误'); + } + + verification.markVerified(); + await this.smsVerifRepo.save(verification); + return true; + } + + /** + * 按类型验证发送条件 + */ + private async validateSendRequest( + phone: Phone, + type: SmsVerificationType, + ): Promise { + const existingUser = await this.userRepo.findByPhone(phone.value); + + switch (type) { + case SmsVerificationType.REGISTER: + if (existingUser) { + throw new BadRequestException('该手机号已注册'); + } + break; + + case SmsVerificationType.LOGIN: + case SmsVerificationType.RESET_PASSWORD: + if (!existingUser) { + throw new BadRequestException('该手机号未注册'); + } + break; + + case SmsVerificationType.CHANGE_PHONE: + 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 0293760..d08ffc7 100644 --- a/backend/services/auth-service/src/auth.module.ts +++ b/backend/services/auth-service/src/auth.module.ts @@ -6,21 +6,33 @@ import { PassportModule } from '@nestjs/passport'; // Domain entities 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'; // 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'; // 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 { JwtStrategy } from './infrastructure/strategies/jwt.strategy'; import { TokenBlacklistService } from './infrastructure/redis/token-blacklist.service'; import { SmsCodeService } from './infrastructure/redis/sms-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'; + // Application services import { AuthService } from './application/services/auth.service'; import { TokenService } from './application/services/token.service'; +import { SmsService } from './application/services/sms.service'; import { EventPublisherService } from './application/services/event-publisher.service'; // Interface controllers @@ -28,7 +40,7 @@ import { AuthController } from './interface/http/controllers/auth.controller'; @Module({ imports: [ - TypeOrmModule.forFeature([User, RefreshToken]), + TypeOrmModule.forFeature([User, RefreshToken, SmsVerification, SmsLog]), PassportModule.register({ defaultStrategy: 'jwt' }), JwtModule.register({ secret: process.env.JWT_ACCESS_SECRET || 'dev-access-secret', @@ -40,6 +52,17 @@ import { AuthController } from './interface/http/controllers/auth.controller'; // Infrastructure -> Domain port binding { provide: USER_REPOSITORY, useClass: UserRepository }, { provide: REFRESH_TOKEN_REPOSITORY, useClass: RefreshTokenRepository }, + { provide: SMS_VERIFICATION_REPOSITORY, useClass: SmsVerificationRepository }, + { provide: SMS_LOG_REPOSITORY, useClass: SmsLogRepository }, + + // SMS Provider: toggle by SMS_ENABLED env var + { + provide: SMS_PROVIDER, + useClass: + process.env.SMS_ENABLED === 'true' + ? AliyunSmsProvider + : ConsoleSmsProvider, + }, // Infrastructure JwtStrategy, @@ -49,8 +72,9 @@ import { AuthController } from './interface/http/controllers/auth.controller'; // Application services AuthService, TokenService, + SmsService, EventPublisherService, ], - exports: [AuthService, TokenService], + exports: [AuthService, TokenService, SmsService], }) export class AuthModule {} diff --git a/backend/services/auth-service/src/domain/entities/sms-log.entity.ts b/backend/services/auth-service/src/domain/entities/sms-log.entity.ts new file mode 100644 index 0000000..ead3501 --- /dev/null +++ b/backend/services/auth-service/src/domain/entities/sms-log.entity.ts @@ -0,0 +1,47 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + Index, +} from 'typeorm'; +import { SmsVerificationType } from './sms-verification.entity'; + +export enum SmsDeliveryStatus { + PENDING = 'PENDING', + SENT = 'SENT', + DELIVERED = 'DELIVERED', + FAILED = 'FAILED', +} + +@Entity('sms_logs') +@Index('idx_sms_logs_phone', ['phone']) +@Index('idx_sms_logs_created', ['createdAt']) +export class SmsLog { + @PrimaryGeneratedColumn('increment', { type: 'bigint' }) + id: string; + + @Column({ name: 'user_id', type: 'uuid', nullable: true }) + userId: string | null; + + @Column({ type: 'varchar', length: 20 }) + phone: string; + + @Column({ type: 'varchar', length: 20 }) + type: SmsVerificationType; + + @Column({ type: 'varchar', length: 20, default: SmsDeliveryStatus.PENDING }) + status: SmsDeliveryStatus; + + @Column({ type: 'varchar', length: 50, nullable: true }) + provider: string | null; + + @Column({ name: 'provider_id', type: 'varchar', length: 100, 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/sms-verification.entity.ts b/backend/services/auth-service/src/domain/entities/sms-verification.entity.ts new file mode 100644 index 0000000..90ba04d --- /dev/null +++ b/backend/services/auth-service/src/domain/entities/sms-verification.entity.ts @@ -0,0 +1,69 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + Index, +} from 'typeorm'; + +export enum SmsVerificationType { + REGISTER = 'REGISTER', + LOGIN = 'LOGIN', + RESET_PASSWORD = 'RESET_PASSWORD', + CHANGE_PHONE = 'CHANGE_PHONE', +} + +@Entity('sms_verifications') +@Index('idx_sms_verif_phone_type', ['phone', 'type']) +@Index('idx_sms_verif_expires', ['expiresAt']) +export class SmsVerification { + @PrimaryGeneratedColumn('increment', { type: 'bigint' }) + id: string; // bigint as string in TypeORM + + @Column({ type: 'varchar', length: 20 }) + phone: string; + + @Column({ type: 'varchar', length: 255 }) + code: string; + + @Column({ type: 'varchar', length: 20 }) + type: SmsVerificationType; + + @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/entities/user.entity.ts b/backend/services/auth-service/src/domain/entities/user.entity.ts index b4cb673..7f93203 100644 --- a/backend/services/auth-service/src/domain/entities/user.entity.ts +++ b/backend/services/auth-service/src/domain/entities/user.entity.ts @@ -76,6 +76,12 @@ export class User { @Column({ type: 'varchar', length: 5, nullable: true }) nationality: string | null; + @Column({ name: 'login_fail_count', type: 'int', default: 0 }) + loginFailCount: number; + + @Column({ name: 'locked_until', type: 'timestamptz', nullable: true }) + lockedUntil: Date | null; + @Column({ name: 'last_login_at', type: 'timestamptz', nullable: true }) lastLoginAt: Date | null; @@ -87,4 +93,57 @@ export class User { @VersionColumn({ default: 1 }) version: number; + + /* ── Domain Methods: Account Lockout ── */ + + /** 账号是否处于锁定状态 */ + get isLocked(): boolean { + return this.lockedUntil !== null && new Date() < this.lockedUntil; + } + + /** 是否可以登录 (状态正常且未锁定) */ + get canLogin(): boolean { + return this.status === UserStatus.ACTIVE && !this.isLocked; + } + + /** 剩余锁定秒数 (0 = 未锁定) */ + get lockRemainingSeconds(): number { + if (!this.lockedUntil) return 0; + const diff = this.lockedUntil.getTime() - Date.now(); + return diff > 0 ? Math.ceil(diff / 1000) : 0; + } + + /** + * 记录登录成功 — 清除失败计数 + */ + recordLoginSuccess(ip?: string): void { + this.loginFailCount = 0; + this.lockedUntil = null; + this.lastLoginAt = new Date(); + } + + /** + * 记录登录失败 — 指数退避锁定 + * @param maxAttempts 触发锁定的失败次数阈值 (默认 6) + * @returns 剩余尝试次数 & 锁定信息 + */ + recordLoginFailure(maxAttempts = 6): { + remainingAttempts: number; + lockMinutes?: number; + } { + this.loginFailCount += 1; + + if (this.loginFailCount < maxAttempts) { + return { remainingAttempts: maxAttempts - this.loginFailCount }; + } + + // 指数退避: 2^(failCount - maxAttempts) 分钟,最大 1440 分钟 (24h) + const lockMinutes = Math.min( + Math.pow(2, this.loginFailCount - maxAttempts), + 1440, + ); + this.lockedUntil = new Date(Date.now() + lockMinutes * 60 * 1000); + + return { remainingAttempts: 0, lockMinutes }; + } } diff --git a/backend/services/auth-service/src/domain/events/auth.events.ts b/backend/services/auth-service/src/domain/events/auth.events.ts index e9b5c07..467cd86 100644 --- a/backend/services/auth-service/src/domain/events/auth.events.ts +++ b/backend/services/auth-service/src/domain/events/auth.events.ts @@ -29,3 +29,37 @@ export interface PasswordChangedEvent { userId: string; timestamp: string; } + +/* ── SMS & Account Lock Events ── */ + +export interface SmsCodeSentEvent { + phone: string; + type: string; // SmsVerificationType + timestamp: string; +} + +export interface SmsCodeVerifiedEvent { + phone: string; + type: string; + timestamp: string; +} + +export interface AccountLockedEvent { + userId: string; + lockedUntil: string; + failCount: number; + timestamp: string; +} + +export interface PhoneChangedEvent { + userId: string; + oldPhone: string; + newPhone: string; + timestamp: string; +} + +export interface PasswordResetEvent { + userId: string; + phone: string; + timestamp: string; +} diff --git a/backend/services/auth-service/src/domain/repositories/sms-log.repository.interface.ts b/backend/services/auth-service/src/domain/repositories/sms-log.repository.interface.ts new file mode 100644 index 0000000..4910dbc --- /dev/null +++ b/backend/services/auth-service/src/domain/repositories/sms-log.repository.interface.ts @@ -0,0 +1,32 @@ +import { SmsLog } from '../entities/sms-log.entity'; +import { SmsVerificationType } from '../entities/sms-verification.entity'; +import { SmsDeliveryStatus } from '../entities/sms-log.entity'; + +export interface ISmsLogRepository { + /** 记录发送日志 */ + create(data: { + phone: string; + type: SmsVerificationType; + status: SmsDeliveryStatus; + provider?: string; + providerId?: string; + errorMsg?: string; + userId?: string; + }): Promise; + + /** 按手机号查询日志 */ + findByPhone( + phone: string, + options?: { limit?: number; offset?: number }, + ): Promise; + + /** 更新发送状态 */ + updateStatus( + id: string, + status: SmsDeliveryStatus, + providerId?: string, + errorMsg?: string, + ): Promise; +} + +export const SMS_LOG_REPOSITORY = Symbol('ISmsLogRepository'); diff --git a/backend/services/auth-service/src/domain/repositories/sms-verification.repository.interface.ts b/backend/services/auth-service/src/domain/repositories/sms-verification.repository.interface.ts new file mode 100644 index 0000000..a1057af --- /dev/null +++ b/backend/services/auth-service/src/domain/repositories/sms-verification.repository.interface.ts @@ -0,0 +1,30 @@ +import { SmsVerification, SmsVerificationType } from '../entities/sms-verification.entity'; + +export interface ISmsVerificationRepository { + /** 创建验证记录 */ + create(data: { + phone: string; + code: string; + type: SmsVerificationType; + expiresAt: Date; + }): Promise; + + /** 查找指定手机号和类型的最新有效验证码 (未过期 + 未验证) */ + findLatestValid( + phone: string, + type: SmsVerificationType, + ): Promise; + + /** 保存 (更新 attempts / verifiedAt) */ + save(verification: SmsVerification): Promise; + + /** 获取今日发送次数 */ + getDailySendCount(phone: string): Promise; + + /** 清理过期记录,返回删除数量 */ + deleteExpired(): Promise; +} + +export const SMS_VERIFICATION_REPOSITORY = Symbol( + 'ISmsVerificationRepository', +); diff --git a/backend/services/auth-service/src/domain/value-objects/phone.vo.ts b/backend/services/auth-service/src/domain/value-objects/phone.vo.ts new file mode 100644 index 0000000..71c7038 --- /dev/null +++ b/backend/services/auth-service/src/domain/value-objects/phone.vo.ts @@ -0,0 +1,72 @@ +/** + * Phone Value Object + * 手机号值对象 — 格式验证 + E.164 标准化 + 掩码显示 + */ +export class Phone { + /** 中国大陆手机号 (不带区号) */ + private static readonly CN_PATTERN = /^1[3-9]\d{9}$/; + /** E.164 国际格式 */ + private static readonly E164_PATTERN = /^\+[1-9]\d{6,14}$/; + + private constructor(public readonly value: string) {} + + /** + * 从原始输入创建 Phone,自动标准化为 E.164 格式 + * - `13800138000` → `+8613800138000` + * - `+8613800138000` → `+8613800138000` + */ + static create(raw: string): Phone { + const trimmed = raw.trim(); + + // 已经是 E.164 格式 + if (Phone.E164_PATTERN.test(trimmed)) { + return new Phone(trimmed); + } + + // 中国大陆格式,自动补 +86 + if (Phone.CN_PATTERN.test(trimmed)) { + return new Phone(`+86${trimmed}`); + } + + throw new Error(`手机号格式无效: ${trimmed}`); + } + + /** + * 从已存储的 E.164 格式重建(跳过验证) + */ + static fromStored(value: string): Phone { + return new Phone(value); + } + + /** + * 掩码显示: +86138****8000 + */ + get masked(): string { + if (this.value.startsWith('+86') && this.value.length === 14) { + const local = this.value.slice(3); // 13800138000 + return `${local.slice(0, 3)}****${local.slice(7)}`; + } + // 通用掩码:保留前4后4 + const len = this.value.length; + if (len <= 8) return this.value; + return `${this.value.slice(0, 4)}${'*'.repeat(len - 8)}${this.value.slice(-4)}`; + } + + /** + * 本地号码 (去除国际区号) + */ + get local(): string { + if (this.value.startsWith('+86')) { + return this.value.slice(3); + } + return this.value; + } + + equals(other: Phone): boolean { + return this.value === other.value; + } + + toString(): string { + return this.value; + } +} diff --git a/backend/services/auth-service/src/domain/value-objects/sms-code.vo.ts b/backend/services/auth-service/src/domain/value-objects/sms-code.vo.ts new file mode 100644 index 0000000..32aaed4 --- /dev/null +++ b/backend/services/auth-service/src/domain/value-objects/sms-code.vo.ts @@ -0,0 +1,41 @@ +import { randomInt } from 'crypto'; + +/** + * SmsCode Value Object + * 短信验证码值对象 — 安全生成 + 比对验证 + */ +export class SmsCode { + static readonly LENGTH = 6; + private static readonly PATTERN = /^\d{6}$/; + + private constructor(public readonly value: string) {} + + /** + * 使用 crypto 安全随机数生成 6 位验证码 + */ + static generate(): SmsCode { + const code = randomInt(0, 1_000_000).toString().padStart(SmsCode.LENGTH, '0'); + return new SmsCode(code); + } + + /** + * 从已有字符串重建 + */ + static from(raw: string): SmsCode { + if (!SmsCode.PATTERN.test(raw)) { + throw new Error(`验证码格式无效: 必须为${SmsCode.LENGTH}位数字`); + } + return new SmsCode(raw); + } + + /** + * 验证输入是否匹配 + */ + matches(input: string): boolean { + return this.value === input; + } + + toString(): string { + return this.value; + } +} diff --git a/backend/services/auth-service/src/infrastructure/persistence/sms-log.repository.ts b/backend/services/auth-service/src/infrastructure/persistence/sms-log.repository.ts new file mode 100644 index 0000000..11fad3b --- /dev/null +++ b/backend/services/auth-service/src/infrastructure/persistence/sms-log.repository.ts @@ -0,0 +1,52 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { SmsLog, SmsDeliveryStatus } from '../../domain/entities/sms-log.entity'; +import { SmsVerificationType } from '../../domain/entities/sms-verification.entity'; +import { ISmsLogRepository } from '../../domain/repositories/sms-log.repository.interface'; + +@Injectable() +export class SmsLogRepository implements ISmsLogRepository { + constructor( + @InjectRepository(SmsLog) + private readonly repo: Repository, + ) {} + + async create(data: { + phone: string; + type: SmsVerificationType; + status: SmsDeliveryStatus; + provider?: string; + providerId?: string; + errorMsg?: string; + userId?: string; + }): Promise { + const entity = this.repo.create(data); + return this.repo.save(entity); + } + + async findByPhone( + phone: string, + options?: { limit?: number; offset?: number }, + ): Promise { + return this.repo.find({ + where: { phone }, + order: { createdAt: 'DESC' }, + take: options?.limit || 20, + skip: options?.offset || 0, + }); + } + + async updateStatus( + id: string, + status: SmsDeliveryStatus, + providerId?: string, + errorMsg?: string, + ): Promise { + await this.repo.update(id, { + status, + ...(providerId && { providerId }), + ...(errorMsg && { errorMsg }), + }); + } +} diff --git a/backend/services/auth-service/src/infrastructure/persistence/sms-verification.repository.ts b/backend/services/auth-service/src/infrastructure/persistence/sms-verification.repository.ts new file mode 100644 index 0000000..7c5b01d --- /dev/null +++ b/backend/services/auth-service/src/infrastructure/persistence/sms-verification.repository.ts @@ -0,0 +1,67 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, MoreThan, IsNull } from 'typeorm'; +import { SmsVerification, SmsVerificationType } from '../../domain/entities/sms-verification.entity'; +import { ISmsVerificationRepository } from '../../domain/repositories/sms-verification.repository.interface'; + +@Injectable() +export class SmsVerificationRepository implements ISmsVerificationRepository { + constructor( + @InjectRepository(SmsVerification) + private readonly repo: Repository, + ) {} + + async create(data: { + phone: string; + code: string; + type: SmsVerificationType; + expiresAt: Date; + }): Promise { + const entity = this.repo.create(data); + return this.repo.save(entity); + } + + async findLatestValid( + phone: string, + type: SmsVerificationType, + ): Promise { + return this.repo.findOne({ + where: { + phone, + type, + expiresAt: MoreThan(new Date()), + verifiedAt: IsNull(), + }, + order: { createdAt: 'DESC' }, + }); + } + + async save(verification: SmsVerification): Promise { + return this.repo.save(verification); + } + + async getDailySendCount(phone: string): Promise { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + return this.repo.count({ + where: { + phone, + 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), // 24h grace + }) + .execute(); + + return result.affected || 0; + } +} diff --git a/backend/services/auth-service/src/infrastructure/redis/sms-code.service.ts b/backend/services/auth-service/src/infrastructure/redis/sms-code.service.ts index a1e6b28..0cb1bb4 100644 --- a/backend/services/auth-service/src/infrastructure/redis/sms-code.service.ts +++ b/backend/services/auth-service/src/infrastructure/redis/sms-code.service.ts @@ -1,10 +1,11 @@ import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; import Redis from 'ioredis'; +import { SmsVerificationType } from '../../domain/entities/sms-verification.entity'; /** - * SMS code storage service using Redis. - * Stores 6-digit verification codes with a 5-minute TTL. - * In dev mode, codes are logged to console instead of sent via SMS. + * SMS code Redis cache service. + * 作为 DB 持久化的快速缓存层,用于验证码的快速查找。 + * Key pattern: auth:sms:{type}:{phone} */ @Injectable() export class SmsCodeService implements OnModuleInit, OnModuleDestroy { @@ -35,28 +36,64 @@ export class SmsCodeService implements OnModuleInit, OnModuleDestroy { } /** - * Generate and store a 6-digit code for the given phone number. - * TTL is 5 minutes (300 seconds). + * 缓存验证码到 Redis + * @param phone E.164 格式手机号 + * @param code 6位验证码 + * @param type 验证码类型 + * @param ttlSeconds 过期秒数 (默认 300) */ + async setCode( + phone: string, + code: string, + type: SmsVerificationType, + ttlSeconds = 300, + ): Promise { + const key = `${type}:${phone}`; + await this.redis.set(key, code, 'EX', ttlSeconds); + } + + /** + * 从 Redis 验证验证码 + * @returns true=匹配并删除, false=不匹配或不存在 + */ + async verifyAndDelete( + phone: string, + code: string, + type: SmsVerificationType, + ): Promise { + const key = `${type}:${phone}`; + const stored = await this.redis.get(key); + if (!stored || stored !== code) { + return false; + } + await this.redis.del(key); + return true; + } + + /** + * 删除缓存的验证码 + */ + async deleteCode(phone: string, type: SmsVerificationType): Promise { + const key = `${type}:${phone}`; + await this.redis.del(key); + } + + /* ── Legacy compatibility (used by existing auth.service) ── */ + + /** @deprecated Use setCode() with type parameter */ async generateCode(phone: string): Promise { const code = String(Math.floor(100000 + Math.random() * 900000)); await this.redis.set(phone, code, 'EX', 300); - // In dev mode, log the code instead of sending a real SMS this.logger.log(`[DEV] SMS code for ${phone}: ${code}`); return code; } - /** - * Verify the code for the given phone number. - * Returns true if valid, false otherwise. - * On successful verification, the code is deleted to prevent reuse. - */ + /** @deprecated Use verifyAndDelete() with type parameter */ async verifyCode(phone: string, code: string): Promise { const stored = await this.redis.get(phone); if (!stored || stored !== code) { return false; } - // Delete the code after successful verification await this.redis.del(phone); return true; } diff --git a/backend/services/auth-service/src/infrastructure/sms/aliyun-sms.provider.ts b/backend/services/auth-service/src/infrastructure/sms/aliyun-sms.provider.ts new file mode 100644 index 0000000..4298b2a --- /dev/null +++ b/backend/services/auth-service/src/infrastructure/sms/aliyun-sms.provider.ts @@ -0,0 +1,88 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { SmsVerificationType } from '../../domain/entities/sms-verification.entity'; +import { ISmsProvider, SmsDeliveryResult } from './sms-provider.interface'; + +/** + * 阿里云 SMS Provider + * + * 使用阿里云短信服务 (dysmsapi) 发送验证码。 + * 需要安装: npm install @alicloud/dysmsapi20170525 @alicloud/openapi-client + * + * 环境变量: + * - ALIYUN_ACCESS_KEY_ID + * - ALIYUN_ACCESS_KEY_SECRET + * - ALIYUN_SMS_SIGN_NAME (签名名称) + * - ALIYUN_SMS_TEMPLATE_CODE (模板代码) + * - ALIYUN_SMS_ENDPOINT (默认 dysmsapi.aliyuncs.com) + */ +@Injectable() +export class AliyunSmsProvider implements ISmsProvider { + private readonly logger = new Logger('AliyunSmsProvider'); + private client: any; // Dysmsapi20170525 client (lazy init) + + async send( + phone: string, + code: string, + type: SmsVerificationType, + ): Promise { + try { + const client = await this.getClient(); + + // 去掉 +86 前缀 (阿里云要求纯数字) + const phoneNumber = phone.startsWith('+86') ? phone.slice(3) : phone; + + const templateParam = JSON.stringify({ code }); + const signName = process.env.ALIYUN_SMS_SIGN_NAME || '券金融'; + const templateCode = this.getTemplateCode(type); + + const result = await client.sendSms({ + phoneNumbers: phoneNumber, + signName, + templateCode, + templateParam, + }); + + if (result.body?.code === 'OK') { + return { + success: true, + providerId: result.body.bizId, + }; + } + + this.logger.error( + `Aliyun SMS failed: ${result.body?.code} - ${result.body?.message}`, + ); + return { + success: false, + errorMsg: `${result.body?.code}: ${result.body?.message}`, + }; + } catch (error: any) { + this.logger.error(`Aliyun SMS error: ${error.message}`); + return { success: false, errorMsg: error.message }; + } + } + + private getTemplateCode(type: SmsVerificationType): string { + // 可为不同类型配置不同模板,默认使用通用模板 + return process.env.ALIYUN_SMS_TEMPLATE_CODE || ''; + } + + private async getClient() { + if (this.client) return this.client; + + // 动态导入 (仅生产环境需要) + const { default: Dysmsapi20170525 } = await import( + '@alicloud/dysmsapi20170525' + ); + const { Config } = await import('@alicloud/openapi-client'); + + const config = new Config({ + accessKeyId: process.env.ALIYUN_ACCESS_KEY_ID, + accessKeySecret: process.env.ALIYUN_ACCESS_KEY_SECRET, + endpoint: process.env.ALIYUN_SMS_ENDPOINT || 'dysmsapi.aliyuncs.com', + }); + + this.client = new Dysmsapi20170525(config); + return this.client; + } +} diff --git a/backend/services/auth-service/src/infrastructure/sms/console-sms.provider.ts b/backend/services/auth-service/src/infrastructure/sms/console-sms.provider.ts new file mode 100644 index 0000000..fd506b4 --- /dev/null +++ b/backend/services/auth-service/src/infrastructure/sms/console-sms.provider.ts @@ -0,0 +1,22 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { SmsVerificationType } from '../../domain/entities/sms-verification.entity'; +import { ISmsProvider, SmsDeliveryResult } from './sms-provider.interface'; + +/** + * 开发模式 SMS Provider — 将验证码打印到控制台 + */ +@Injectable() +export class ConsoleSmsProvider implements ISmsProvider { + private readonly logger = new Logger('ConsoleSmsProvider'); + + async send( + phone: string, + code: string, + type: SmsVerificationType, + ): Promise { + this.logger.warn( + `[DEV SMS] phone=${phone} code=${code} type=${type}`, + ); + return { success: true, providerId: `console-${Date.now()}` }; + } +} diff --git a/backend/services/auth-service/src/infrastructure/sms/sms-provider.interface.ts b/backend/services/auth-service/src/infrastructure/sms/sms-provider.interface.ts new file mode 100644 index 0000000..9a52fcb --- /dev/null +++ b/backend/services/auth-service/src/infrastructure/sms/sms-provider.interface.ts @@ -0,0 +1,23 @@ +import { SmsVerificationType } from '../../domain/entities/sms-verification.entity'; + +export interface SmsDeliveryResult { + success: boolean; + providerId?: string; + errorMsg?: string; +} + +export interface ISmsProvider { + /** + * 发送短信验证码 + * @param phone E.164 格式手机号 + * @param code 6位验证码 + * @param type 验证码类型 + */ + send( + phone: string, + code: string, + type: SmsVerificationType, + ): Promise; +} + +export const SMS_PROVIDER = Symbol('ISmsProvider'); 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 fe05ca3..11b1f5c 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 @@ -10,6 +10,7 @@ import { } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; import { AuthGuard } from '@nestjs/passport'; +import { ThrottlerGuard } from '@nestjs/throttler'; import { AuthService } from '../../../application/services/auth.service'; import { RegisterDto } from '../dto/register.dto'; import { LoginDto } from '../dto/login.dto'; @@ -17,30 +18,55 @@ import { RefreshTokenDto } from '../dto/refresh-token.dto'; import { ChangePasswordDto } from '../dto/change-password.dto'; 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'; @ApiTags('Auth') @Controller('auth') export class AuthController { constructor(private readonly authService: AuthService) {} + /* ── SMS 验证码 ── */ + + @Post('sms/send') + @HttpCode(HttpStatus.OK) + @UseGuards(ThrottlerGuard) + @ApiOperation({ summary: '发送短信验证码' }) + @ApiResponse({ status: 200, description: '验证码发送成功' }) + @ApiResponse({ status: 400, description: '手机号无效 / 日发送限额已满' }) + async sendSmsCode(@Body() dto: SendSmsCodeDto) { + const result = await this.authService.sendSmsCode(dto.phone, dto.type); + return { + code: 0, + data: result, + message: '验证码发送成功', + }; + } + + /* ── 注册 ── */ + @Post('register') - @ApiOperation({ summary: 'Register a new user' }) - @ApiResponse({ status: 201, description: 'User registered successfully' }) - @ApiResponse({ status: 409, description: 'Phone/email already exists' }) + @ApiOperation({ summary: '手机号注册 (需先获取验证码)' }) + @ApiResponse({ status: 201, description: '注册成功' }) + @ApiResponse({ status: 400, description: '验证码错误' }) + @ApiResponse({ status: 409, description: '手机号已注册' }) async register(@Body() dto: RegisterDto) { const result = await this.authService.register(dto); return { code: 0, data: result, - message: 'Registration successful', + message: '注册成功', }; } + /* ── 密码登录 ── */ + @Post('login') @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Login with phone/email and password' }) - @ApiResponse({ status: 200, description: 'Login successful' }) - @ApiResponse({ status: 401, description: 'Invalid credentials' }) + @ApiOperation({ summary: '密码登录 (手机号/邮箱 + 密码)' }) + @ApiResponse({ status: 200, description: '登录成功' }) + @ApiResponse({ status: 401, description: '账号或密码错误' }) + @ApiResponse({ status: 403, description: '账号已锁定/冻结' }) async login(@Body() dto: LoginDto, @Ip() ip: string) { const result = await this.authService.login({ ...dto, @@ -49,77 +75,111 @@ export class AuthController { return { code: 0, data: result, - message: 'Login successful', + message: '登录成功', }; } + /* ── 短信验证码登录 ── */ + + @Post('login-phone') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '短信验证码登录' }) + @ApiResponse({ status: 200, description: '登录成功' }) + @ApiResponse({ status: 401, description: '验证码错误或手机号未注册' }) + async loginWithPhone(@Body() dto: LoginPhoneDto, @Ip() ip: string) { + const result = await this.authService.loginWithPhone( + dto.phone, + dto.smsCode, + dto.deviceInfo, + ip, + ); + return { + code: 0, + data: result, + message: '登录成功', + }; + } + + /* ── 重置密码 ── */ + + @Post('reset-password') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '重置密码 (手机号 + 验证码 + 新密码)' }) + @ApiResponse({ status: 200, description: '密码重置成功' }) + @ApiResponse({ status: 400, description: '验证码错误或手机号未注册' }) + async resetPassword(@Body() dto: ResetPasswordDto) { + await this.authService.resetPassword(dto.phone, dto.smsCode, dto.newPassword); + return { + code: 0, + data: null, + message: '密码重置成功,请重新登录', + }; + } + + /* ── 更换手机号 ── */ + + @Post('change-phone') + @HttpCode(HttpStatus.OK) + @UseGuards(AuthGuard('jwt')) + @ApiBearerAuth() + @ApiOperation({ summary: '更换绑定手机号 (需登录)' }) + @ApiResponse({ status: 200, description: '手机号更换成功' }) + @ApiResponse({ status: 400, description: '验证码错误' }) + @ApiResponse({ status: 409, description: '新手机号已被使用' }) + async changePhone(@Req() req: any, @Body() dto: ChangePhoneDto) { + await this.authService.changePhone(req.user.id, dto.newPhone, dto.newSmsCode); + return { + code: 0, + data: null, + message: '手机号更换成功', + }; + } + + /* ── Token 刷新 ── */ + @Post('refresh') @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Refresh access token using refresh token' }) - @ApiResponse({ status: 200, description: 'Token refreshed' }) - @ApiResponse({ status: 401, description: 'Invalid refresh token' }) + @ApiOperation({ summary: '刷新 access token' }) + @ApiResponse({ status: 200, description: 'Token 刷新成功' }) + @ApiResponse({ status: 401, description: 'Refresh token 无效' }) async refresh(@Body() dto: RefreshTokenDto) { const tokens = await this.authService.refreshToken(dto.refreshToken); return { code: 0, data: tokens, - message: 'Token refreshed', + message: 'Token 刷新成功', }; } + /* ── 登出 ── */ + @Post('logout') @HttpCode(HttpStatus.OK) @UseGuards(AuthGuard('jwt')) @ApiBearerAuth() - @ApiOperation({ summary: 'Logout - revoke all refresh tokens' }) + @ApiOperation({ summary: '登出 (撤销所有 refresh token)' }) async logout(@Req() req: any) { await this.authService.logout(req.user.id); return { code: 0, data: null, - message: 'Logged out successfully', + message: '已登出', }; } + /* ── 修改密码 ── */ + @Post('change-password') @HttpCode(HttpStatus.OK) @UseGuards(AuthGuard('jwt')) @ApiBearerAuth() - @ApiOperation({ summary: 'Change password' }) + @ApiOperation({ summary: '修改密码 (需登录)' }) async changePassword(@Req() req: any, @Body() dto: ChangePasswordDto) { await this.authService.changePassword(req.user.id, dto.oldPassword, dto.newPassword); return { code: 0, data: null, - message: 'Password changed successfully', - }; - } - - @Post('send-sms-code') - @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Send SMS verification code to phone number' }) - @ApiResponse({ status: 200, description: 'SMS code sent successfully' }) - @ApiResponse({ status: 400, description: 'Invalid phone number' }) - async sendSmsCode(@Body() dto: SendSmsCodeDto) { - await this.authService.sendSmsCode(dto.phone); - return { - code: 0, - data: { success: true }, - message: 'SMS code sent successfully', - }; - } - - @Post('login-phone') - @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Login with phone number and SMS verification code' }) - @ApiResponse({ status: 200, description: 'Login successful' }) - @ApiResponse({ status: 401, description: 'Invalid or expired SMS code' }) - async loginWithPhone(@Body() dto: LoginPhoneDto, @Ip() ip: string) { - const result = await this.authService.loginWithPhone(dto.phone, dto.smsCode, ip); - return { - code: 0, - data: result, - message: 'Login successful', + message: '密码修改成功', }; } } diff --git a/backend/services/auth-service/src/interface/http/dto/change-phone.dto.ts b/backend/services/auth-service/src/interface/http/dto/change-phone.dto.ts new file mode 100644 index 0000000..21ce008 --- /dev/null +++ b/backend/services/auth-service/src/interface/http/dto/change-phone.dto.ts @@ -0,0 +1,14 @@ +import { IsString, IsNotEmpty, Length } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class ChangePhoneDto { + @ApiProperty({ description: '新手机号', example: '+8613900139000' }) + @IsString() + @IsNotEmpty() + newPhone: string; + + @ApiProperty({ description: '新手机号的6位短信验证码', example: '123456' }) + @IsString() + @Length(6, 6) + newSmsCode: string; +} diff --git a/backend/services/auth-service/src/interface/http/dto/login-phone.dto.ts b/backend/services/auth-service/src/interface/http/dto/login-phone.dto.ts index 40d85fa..777a735 100644 --- a/backend/services/auth-service/src/interface/http/dto/login-phone.dto.ts +++ b/backend/services/auth-service/src/interface/http/dto/login-phone.dto.ts @@ -1,14 +1,19 @@ -import { IsString, IsNotEmpty, Length } from 'class-validator'; -import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsNotEmpty, IsOptional, Length } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class LoginPhoneDto { - @ApiProperty({ description: 'Phone number', example: '+8613800138000' }) + @ApiProperty({ description: '手机号', example: '+8613800138000' }) @IsString() @IsNotEmpty() phone: string; - @ApiProperty({ description: '6-digit SMS verification code', example: '123456' }) + @ApiProperty({ description: '6位短信验证码', example: '123456' }) @IsString() @Length(6, 6) smsCode: string; + + @ApiPropertyOptional({ description: '设备信息' }) + @IsOptional() + @IsString() + deviceInfo?: string; } diff --git a/backend/services/auth-service/src/interface/http/dto/register.dto.ts b/backend/services/auth-service/src/interface/http/dto/register.dto.ts index e0addca..2bb9c7e 100644 --- a/backend/services/auth-service/src/interface/http/dto/register.dto.ts +++ b/backend/services/auth-service/src/interface/http/dto/register.dto.ts @@ -1,25 +1,24 @@ -import { IsString, IsOptional, IsEmail, MinLength, MaxLength, Matches } from 'class-validator'; +import { IsString, IsOptional, MinLength, MaxLength, Matches, Length } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class RegisterDto { - @ApiPropertyOptional({ example: '+8613800138000' }) - @IsOptional() + @ApiProperty({ description: '手机号 (E.164 或大陆格式)', example: '+8613800138000' }) @IsString() - @Matches(/^\+?[1-9]\d{6,14}$/, { message: 'Invalid phone number format' }) - phone?: string; + @Matches(/^\+?[1-9]\d{6,14}$/, { message: '手机号格式无效' }) + phone: string; - @ApiPropertyOptional({ example: 'user@example.com' }) - @IsOptional() - @IsEmail() - email?: string; + @ApiProperty({ description: '6位短信验证码', example: '123456' }) + @IsString() + @Length(6, 6, { message: '验证码必须为6位数字' }) + smsCode: string; - @ApiProperty({ example: 'Password123!', minLength: 8 }) + @ApiProperty({ description: '登录密码 (8-128位)', example: 'Password123!' }) @IsString() @MinLength(8) @MaxLength(128) password: string; - @ApiPropertyOptional({ example: 'John' }) + @ApiPropertyOptional({ description: '昵称', example: 'John' }) @IsOptional() @IsString() @MaxLength(50) diff --git a/backend/services/auth-service/src/interface/http/dto/reset-password.dto.ts b/backend/services/auth-service/src/interface/http/dto/reset-password.dto.ts new file mode 100644 index 0000000..f12260f --- /dev/null +++ b/backend/services/auth-service/src/interface/http/dto/reset-password.dto.ts @@ -0,0 +1,20 @@ +import { IsString, IsNotEmpty, MinLength, MaxLength, Length } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class ResetPasswordDto { + @ApiProperty({ description: '手机号', example: '+8613800138000' }) + @IsString() + @IsNotEmpty() + phone: string; + + @ApiProperty({ description: '6位短信验证码', example: '123456' }) + @IsString() + @Length(6, 6) + smsCode: string; + + @ApiProperty({ description: '新密码 (8-128位)', example: 'NewPassword456!' }) + @IsString() + @MinLength(8) + @MaxLength(128) + newPassword: string; +} diff --git a/backend/services/auth-service/src/interface/http/dto/send-sms-code.dto.ts b/backend/services/auth-service/src/interface/http/dto/send-sms-code.dto.ts index a2538e6..f59a5c3 100644 --- a/backend/services/auth-service/src/interface/http/dto/send-sms-code.dto.ts +++ b/backend/services/auth-service/src/interface/http/dto/send-sms-code.dto.ts @@ -1,9 +1,18 @@ -import { IsString, IsNotEmpty } from 'class-validator'; +import { IsString, IsNotEmpty, IsEnum } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; +import { SmsVerificationType } from '../../../domain/entities/sms-verification.entity'; export class SendSmsCodeDto { - @ApiProperty({ description: 'Phone number to send SMS code to', example: '+8613800138000' }) + @ApiProperty({ description: '手机号', example: '+8613800138000' }) @IsString() @IsNotEmpty() phone: string; + + @ApiProperty({ + description: '验证码类型', + enum: SmsVerificationType, + example: SmsVerificationType.LOGIN, + }) + @IsEnum(SmsVerificationType, { message: '无效的验证码类型' }) + type: SmsVerificationType; }