feat: 实现短信模块
This commit is contained in:
@@ -13,6 +13,7 @@ import { AdminModule } from './admin/admin.module';
|
||||
import { OssModule } from './oss/oss.module';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
import { CaptchaModule } from './captcha/captcha.module';
|
||||
import { SmsModule } from './sms/sms.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -45,6 +46,7 @@ import { CaptchaModule } from './captcha/captcha.module';
|
||||
AdminModule,
|
||||
OssModule,
|
||||
CaptchaModule,
|
||||
SmsModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
|
||||
13
apps/backend/src/auth/dto/sms-login.dto.ts
Normal file
13
apps/backend/src/auth/dto/sms-login.dto.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { IsPhoneNumber, Matches } from "class-validator";
|
||||
|
||||
export class SmsLoginDto {
|
||||
@IsPhoneNumber('CN', {
|
||||
message: '请输入有效的中国大陆手机号',
|
||||
})
|
||||
phone: string;
|
||||
|
||||
@Matches(/^\d{6}$/, {
|
||||
message: '验证码必须是6位数字',
|
||||
})
|
||||
code: string;
|
||||
}
|
||||
@@ -17,29 +17,31 @@ export const ErrorCode = {
|
||||
USER_FIND_OPTIONS_EMPTY: -1004,
|
||||
USER_ACCOUNT_DEACTIVATED: -1005,
|
||||
|
||||
// 认证模块(2000 ~ 2999)
|
||||
// 认证模块
|
||||
AUTH_INVALID_CREDENTIALS: -2001,
|
||||
AUTH_SMS_CODE_EXPIRED: -2002,
|
||||
AUTH_SMS_CODE_INCORRECT: -2003,
|
||||
AUTH_PASSKEY_NOT_REGISTERED: -2004,
|
||||
AUTH_SESSION_EXPIRED: -2005,
|
||||
AUTH_PASSKEY_NOT_REGISTERED: -2002,
|
||||
AUTH_SESSION_EXPIRED: -2003,
|
||||
|
||||
// 博客模块(3000 ~ 3999)
|
||||
// 博客模块
|
||||
BLOG_NOT_FOUND: -3001,
|
||||
BLOG_PERMISSION_DENIED: -3002,
|
||||
|
||||
// 验证模块(4000 ~ 4999)
|
||||
// 验证模块
|
||||
CAPTCHA_RARE_LIMIT: -4001,
|
||||
|
||||
// 通知模块(5000 ~ 5999)
|
||||
// 通知模块
|
||||
NOTIFICATION_SEND_FAILED: -5001,
|
||||
|
||||
// 资源模块(6000 ~ 6999)
|
||||
RESOURCE_UPLOAD_FAILED: -6001,
|
||||
RESOURCE_NOT_FOUND: -6002,
|
||||
// Sms模块
|
||||
SMS_CODE_INCORRECT: -6001,
|
||||
SMS_CODE_EXPIRED: -6002,
|
||||
|
||||
// 管理员模块(7000 ~ 7999)
|
||||
ADMIN_FORBIDDEN: -7001,
|
||||
// 资源模块
|
||||
RESOURCE_UPLOAD_FAILED: -7001,
|
||||
RESOURCE_NOT_FOUND: -7002,
|
||||
|
||||
// 管理员模块
|
||||
ADMIN_FORBIDDEN: -8001,
|
||||
} as const;
|
||||
|
||||
export type ErrorCodeType = typeof ErrorCode[keyof typeof ErrorCode];
|
||||
18
apps/backend/src/sms/sms.controller.spec.ts
Normal file
18
apps/backend/src/sms/sms.controller.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { SmsController } from './sms.controller';
|
||||
|
||||
describe('SmsController', () => {
|
||||
let controller: SmsController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [SmsController],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<SmsController>(SmsController);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
});
|
||||
15
apps/backend/src/sms/sms.controller.ts
Normal file
15
apps/backend/src/sms/sms.controller.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { SendLoginSmsDto } from './dto/send-login-sms.dto';
|
||||
import { SmsService } from './sms.service';
|
||||
|
||||
@Controller('sms')
|
||||
export class SmsController {
|
||||
|
||||
constructor(private readonly smsService: SmsService) { }
|
||||
|
||||
@Post('send/login')
|
||||
async sendLoginSms(@Body() dto: SendLoginSmsDto) {
|
||||
await this.smsService.sendSms(dto.phone, 'login');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
13
apps/backend/src/sms/sms.module.ts
Normal file
13
apps/backend/src/sms/sms.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SmsService } from './sms.service';
|
||||
import { SmsController } from './sms.controller';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { SmsRecord } from './entity/sms-record.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([SmsRecord])],
|
||||
providers: [SmsService],
|
||||
controllers: [SmsController],
|
||||
exports: [SmsService],
|
||||
})
|
||||
export class SmsModule { }
|
||||
18
apps/backend/src/sms/sms.service.spec.ts
Normal file
18
apps/backend/src/sms/sms.service.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { SmsService } from './sms.service';
|
||||
|
||||
describe('SmsService', () => {
|
||||
let service: SmsService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [SmsService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<SmsService>(SmsService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
178
apps/backend/src/sms/sms.service.ts
Normal file
178
apps/backend/src/sms/sms.service.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
|
||||
import Dypnsapi, * as $Dypnsapi from '@alicloud/dypnsapi20170525';
|
||||
import * as $OpenApi from '@alicloud/openapi-client';
|
||||
import { randomInt } from 'crypto';
|
||||
import { MoreThan, Repository } from 'typeorm';
|
||||
import { SmsRecord } from './entity/sms-record.entity';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { BusinessException } from 'src/common/exceptions/business.exception';
|
||||
import { ErrorCode } from 'src/common/constants/error-codes';
|
||||
|
||||
const LoginSmsExpiredMin = 5;
|
||||
const LoginSmsMaxTryCount = 5;
|
||||
|
||||
const devMode = process.env.NODE_ENV !== 'production';
|
||||
|
||||
@Injectable()
|
||||
export class SmsService {
|
||||
|
||||
private logger = new Logger(SmsService.name);
|
||||
|
||||
private client: Dypnsapi;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(SmsRecord)
|
||||
private readonly smsRecordRepository: Repository<SmsRecord>
|
||||
) {
|
||||
const config = new $OpenApi.Config({})
|
||||
config.accessKeyId = process.env.ALIYUN_ACCESS_KEY_ID;
|
||||
config.accessKeySecret = process.env.ALIYUN_ACCESS_KEY_SECRET;
|
||||
|
||||
this.client = new Dypnsapi(config as any);
|
||||
}
|
||||
|
||||
private generateSmsCode(): string {
|
||||
// 生成 0 到 999999 的随机整数,补零到 6 位
|
||||
const code = randomInt(0, 1_000_000);
|
||||
return code.toString().padStart(6, '0');
|
||||
}
|
||||
|
||||
async checkSendSmsLimit(phone: string, type: string): Promise<void> {
|
||||
const now = new Date();
|
||||
const oneMinuteAgo = new Date(now.getTime() - 60 * 1000);
|
||||
const twentyFourHoursAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
|
||||
// 1. 检查 1 分钟内是否已发送
|
||||
const recentRecord = await this.smsRecordRepository.findOne({
|
||||
where: {
|
||||
phone,
|
||||
type,
|
||||
createdAt: MoreThan(twentyFourHoursAgo),
|
||||
},
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
if (recentRecord && recentRecord.createdAt > oneMinuteAgo) {
|
||||
throw new BusinessException({ message: '操作太快了,一分钟后重试吧' }); // 距离上一条不足 1 分钟
|
||||
}
|
||||
|
||||
// 2. 检查 24 小时内是否超过 10 条
|
||||
const count = await this.smsRecordRepository.count({
|
||||
where: {
|
||||
phone,
|
||||
type,
|
||||
createdAt: MoreThan(twentyFourHoursAgo),
|
||||
},
|
||||
});
|
||||
|
||||
if (count >= 10) {
|
||||
throw new BusinessException({ message: '操作太快了,稍后再重试吧' }); // 24 小时超过 10 条
|
||||
}
|
||||
}
|
||||
|
||||
async sendSms(phone: string, type: 'login') {
|
||||
if (type === 'login') {
|
||||
// 检查限流
|
||||
await this.checkSendSmsLimit(phone, type);
|
||||
|
||||
// 生成
|
||||
const code = this.generateSmsCode();
|
||||
const smsRecord = this.smsRecordRepository.create({
|
||||
phone,
|
||||
type,
|
||||
code,
|
||||
expiredAt: new Date(Date.now() + LoginSmsExpiredMin * 60 * 1000),
|
||||
});
|
||||
|
||||
// 发送
|
||||
const request = new $Dypnsapi.SendSmsVerifyCodeRequest({});
|
||||
request.phoneNumber = phone;
|
||||
request.signName = '速通互联验证码';
|
||||
request.templateCode = '100001';
|
||||
request.templateParam = JSON.stringify({
|
||||
code,
|
||||
min: `${LoginSmsExpiredMin}`,
|
||||
})
|
||||
|
||||
|
||||
let success: boolean = false;
|
||||
|
||||
if (devMode) {
|
||||
success = true;
|
||||
this.logger.debug(`${phone}:${code}`)
|
||||
} else {
|
||||
await this.client.sendSmsVerifyCode(request).then(a => {
|
||||
success = a.body?.success || false;
|
||||
}, err => {
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
if (success) {
|
||||
this.smsRecordRepository.save(smsRecord).catch(e => {
|
||||
this.logger.warn(e, 'sendSms:saveRecord');
|
||||
})
|
||||
}
|
||||
|
||||
return success;
|
||||
} else {
|
||||
throw new InternalServerErrorException('未知的Sms类型');
|
||||
}
|
||||
}
|
||||
|
||||
async checkSms(phone: string, type: 'login', code: string) {
|
||||
if (type === 'login') {
|
||||
const now = new Date();
|
||||
|
||||
const record = await this.smsRecordRepository.findOne({
|
||||
where: {
|
||||
phone,
|
||||
type,
|
||||
expiredAt: MoreThan(now),
|
||||
usedAt: null,
|
||||
},
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
if (!record) {
|
||||
throw new BusinessException({
|
||||
code: ErrorCode.SMS_CODE_EXPIRED,
|
||||
message: '验证码已失效,请重新获取',
|
||||
})
|
||||
}
|
||||
|
||||
// 检查尝试次数
|
||||
if (record.tryCount >= LoginSmsMaxTryCount) {
|
||||
throw new BusinessException({
|
||||
code: ErrorCode.SMS_CODE_EXPIRED,
|
||||
message: '验证码已失效,请重新获取',
|
||||
})
|
||||
}
|
||||
|
||||
// 检查是否匹配
|
||||
if (record.code !== code) {
|
||||
// 增加尝试次数
|
||||
record.tryCount = (record.tryCount || 0) + 1;
|
||||
await this.smsRecordRepository.save(record);
|
||||
|
||||
if (record.tryCount >= LoginSmsMaxTryCount) {
|
||||
throw new BusinessException({
|
||||
code: ErrorCode.SMS_CODE_EXPIRED,
|
||||
message: '验证码已失效,请重新获取',
|
||||
})
|
||||
}
|
||||
|
||||
throw new BusinessException({
|
||||
code: ErrorCode.SMS_CODE_INCORRECT,
|
||||
message: '验证码不对的喔~',
|
||||
})
|
||||
}
|
||||
|
||||
record.usedAt = new Date();
|
||||
await this.smsRecordRepository.save(record);
|
||||
return true;
|
||||
} else {
|
||||
throw new InternalServerErrorException('未知的Sms类型');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user