feat(auth): 完整实现 SMS 手机注册/登录/验证系统
参考 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 <noreply@anthropic.com>
This commit is contained in:
parent
2ff8b48e50
commit
e89ec82406
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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<RegisterResult> {
|
||||
// Validate at least one identifier
|
||||
if (!dto.phone && !dto.email) {
|
||||
throw new ConflictException('Phone or email is required');
|
||||
/* ── Register ── */
|
||||
|
||||
async register(dto: RegisterDto): Promise<AuthResult> {
|
||||
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<AuthResult> {
|
||||
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<AuthResult> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<AuthTokens> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
await this.publishToOutbox('genex.user.locked', 'User', event.userId, 'user.account_locked', event);
|
||||
}
|
||||
|
||||
async publishPhoneChanged(event: PhoneChangedEvent): Promise<void> {
|
||||
await this.publishToOutbox('genex.user.phone-changed', 'User', event.userId, 'user.phone_changed', event);
|
||||
}
|
||||
|
||||
async publishPasswordReset(event: PasswordResetEvent): Promise<void> {
|
||||
await this.publishToOutbox('genex.user.password-reset', 'User', event.userId, 'user.password_reset', event);
|
||||
}
|
||||
|
||||
private async publishToOutbox(
|
||||
topic: string,
|
||||
aggregateType: string,
|
||||
|
|
|
|||
|
|
@ -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<boolean> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SmsLog>;
|
||||
|
||||
/** 按手机号查询日志 */
|
||||
findByPhone(
|
||||
phone: string,
|
||||
options?: { limit?: number; offset?: number },
|
||||
): Promise<SmsLog[]>;
|
||||
|
||||
/** 更新发送状态 */
|
||||
updateStatus(
|
||||
id: string,
|
||||
status: SmsDeliveryStatus,
|
||||
providerId?: string,
|
||||
errorMsg?: string,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
export const SMS_LOG_REPOSITORY = Symbol('ISmsLogRepository');
|
||||
|
|
@ -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<SmsVerification>;
|
||||
|
||||
/** 查找指定手机号和类型的最新有效验证码 (未过期 + 未验证) */
|
||||
findLatestValid(
|
||||
phone: string,
|
||||
type: SmsVerificationType,
|
||||
): Promise<SmsVerification | null>;
|
||||
|
||||
/** 保存 (更新 attempts / verifiedAt) */
|
||||
save(verification: SmsVerification): Promise<SmsVerification>;
|
||||
|
||||
/** 获取今日发送次数 */
|
||||
getDailySendCount(phone: string): Promise<number>;
|
||||
|
||||
/** 清理过期记录,返回删除数量 */
|
||||
deleteExpired(): Promise<number>;
|
||||
}
|
||||
|
||||
export const SMS_VERIFICATION_REPOSITORY = Symbol(
|
||||
'ISmsVerificationRepository',
|
||||
);
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<SmsLog>,
|
||||
) {}
|
||||
|
||||
async create(data: {
|
||||
phone: string;
|
||||
type: SmsVerificationType;
|
||||
status: SmsDeliveryStatus;
|
||||
provider?: string;
|
||||
providerId?: string;
|
||||
errorMsg?: string;
|
||||
userId?: string;
|
||||
}): Promise<SmsLog> {
|
||||
const entity = this.repo.create(data);
|
||||
return this.repo.save(entity);
|
||||
}
|
||||
|
||||
async findByPhone(
|
||||
phone: string,
|
||||
options?: { limit?: number; offset?: number },
|
||||
): Promise<SmsLog[]> {
|
||||
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<void> {
|
||||
await this.repo.update(id, {
|
||||
status,
|
||||
...(providerId && { providerId }),
|
||||
...(errorMsg && { errorMsg }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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<SmsVerification>,
|
||||
) {}
|
||||
|
||||
async create(data: {
|
||||
phone: string;
|
||||
code: string;
|
||||
type: SmsVerificationType;
|
||||
expiresAt: Date;
|
||||
}): Promise<SmsVerification> {
|
||||
const entity = this.repo.create(data);
|
||||
return this.repo.save(entity);
|
||||
}
|
||||
|
||||
async findLatestValid(
|
||||
phone: string,
|
||||
type: SmsVerificationType,
|
||||
): Promise<SmsVerification | null> {
|
||||
return this.repo.findOne({
|
||||
where: {
|
||||
phone,
|
||||
type,
|
||||
expiresAt: MoreThan(new Date()),
|
||||
verifiedAt: IsNull(),
|
||||
},
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async save(verification: SmsVerification): Promise<SmsVerification> {
|
||||
return this.repo.save(verification);
|
||||
}
|
||||
|
||||
async getDailySendCount(phone: string): Promise<number> {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
return this.repo.count({
|
||||
where: {
|
||||
phone,
|
||||
createdAt: MoreThan(today),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteExpired(): Promise<number> {
|
||||
const result = await this.repo
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
.where('expires_at < :now', { now: new Date() })
|
||||
.andWhere('verified_at IS NOT NULL OR expires_at < :cutoff', {
|
||||
cutoff: new Date(Date.now() - 24 * 60 * 60 * 1000), // 24h grace
|
||||
})
|
||||
.execute();
|
||||
|
||||
return result.affected || 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SmsDeliveryResult> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<SmsDeliveryResult> {
|
||||
this.logger.warn(
|
||||
`[DEV SMS] phone=${phone} code=${code} type=${type}`,
|
||||
);
|
||||
return { success: true, providerId: `console-${Date.now()}` };
|
||||
}
|
||||
}
|
||||
|
|
@ -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<SmsDeliveryResult>;
|
||||
}
|
||||
|
||||
export const SMS_PROVIDER = Symbol('ISmsProvider');
|
||||
|
|
@ -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: '密码修改成功',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue