feat: 实现短信模块

This commit is contained in:
2025-12-17 23:01:13 +08:00
parent 54acad1671
commit 2ef3507cea
10 changed files with 286 additions and 13 deletions

View File

@@ -22,6 +22,7 @@
"dependencies": { "dependencies": {
"@alicloud/credentials": "^2.4.3", "@alicloud/credentials": "^2.4.3",
"@alicloud/dm20151123": "1.2.6", "@alicloud/dm20151123": "1.2.6",
"@alicloud/dypnsapi20170525": "^2.0.0",
"@alicloud/dysmsapi20170525": "4.1.0", "@alicloud/dysmsapi20170525": "4.1.0",
"@alicloud/openapi-client": "^0.4.14", "@alicloud/openapi-client": "^0.4.14",
"@alicloud/tea-util": "^1.4.10", "@alicloud/tea-util": "^1.4.10",

View File

@@ -14,6 +14,9 @@ importers:
'@alicloud/dm20151123': '@alicloud/dm20151123':
specifier: 1.2.6 specifier: 1.2.6
version: 1.2.6 version: 1.2.6
'@alicloud/dypnsapi20170525':
specifier: ^2.0.0
version: 2.0.0
'@alicloud/dysmsapi20170525': '@alicloud/dysmsapi20170525':
specifier: 4.1.0 specifier: 4.1.0
version: 4.1.0 version: 4.1.0
@@ -174,6 +177,9 @@ packages:
'@alicloud/dm20151123@1.2.6': '@alicloud/dm20151123@1.2.6':
resolution: {integrity: sha512-6pYgy0D5zmUoxfRYwj0ysX4WPw8IfGimaw3ORFj6hF6lTxWpJ3tteOD72i8rw764eZ78TRc4UyET3U9qCaBeaA==} resolution: {integrity: sha512-6pYgy0D5zmUoxfRYwj0ysX4WPw8IfGimaw3ORFj6hF6lTxWpJ3tteOD72i8rw764eZ78TRc4UyET3U9qCaBeaA==}
'@alicloud/dypnsapi20170525@2.0.0':
resolution: {integrity: sha512-eVh1dJ2HA82bBHt+YZFIBzPEYW80FK+TSpcxSR9o0W+FgfTqBaj6eeIHnN7NFhyDAD/3+HtZ146Pmvr51JEEAg==}
'@alicloud/dysmsapi20170525@4.1.0': '@alicloud/dysmsapi20170525@4.1.0':
resolution: {integrity: sha512-oUmRp6DTI6gGNbrSQK4lW7EouHIB4C0DCbSEA121NvxHC9XKe4cqiPP2VDqgDQiIK43oiFaHKY3rj+IteOWekA==} resolution: {integrity: sha512-oUmRp6DTI6gGNbrSQK4lW7EouHIB4C0DCbSEA121NvxHC9XKe4cqiPP2VDqgDQiIK43oiFaHKY3rj+IteOWekA==}
@@ -3432,6 +3438,13 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@alicloud/dypnsapi20170525@2.0.0':
dependencies:
'@alicloud/openapi-core': 1.0.4
'@darabonba/typescript': 1.0.3
transitivePeerDependencies:
- supports-color
'@alicloud/dysmsapi20170525@4.1.0': '@alicloud/dysmsapi20170525@4.1.0':
dependencies: dependencies:
'@alicloud/openapi-core': 1.0.4 '@alicloud/openapi-core': 1.0.4

View File

@@ -13,6 +13,7 @@ import { AdminModule } from './admin/admin.module';
import { OssModule } from './oss/oss.module'; import { OssModule } from './oss/oss.module';
import { ThrottlerModule } from '@nestjs/throttler'; import { ThrottlerModule } from '@nestjs/throttler';
import { CaptchaModule } from './captcha/captcha.module'; import { CaptchaModule } from './captcha/captcha.module';
import { SmsModule } from './sms/sms.module';
@Module({ @Module({
imports: [ imports: [
@@ -45,6 +46,7 @@ import { CaptchaModule } from './captcha/captcha.module';
AdminModule, AdminModule,
OssModule, OssModule,
CaptchaModule, CaptchaModule,
SmsModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService],

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

View File

@@ -17,29 +17,31 @@ export const ErrorCode = {
USER_FIND_OPTIONS_EMPTY: -1004, USER_FIND_OPTIONS_EMPTY: -1004,
USER_ACCOUNT_DEACTIVATED: -1005, USER_ACCOUNT_DEACTIVATED: -1005,
// 认证模块2000 ~ 2999 // 认证模块
AUTH_INVALID_CREDENTIALS: -2001, AUTH_INVALID_CREDENTIALS: -2001,
AUTH_SMS_CODE_EXPIRED: -2002, AUTH_PASSKEY_NOT_REGISTERED: -2002,
AUTH_SMS_CODE_INCORRECT: -2003, AUTH_SESSION_EXPIRED: -2003,
AUTH_PASSKEY_NOT_REGISTERED: -2004,
AUTH_SESSION_EXPIRED: -2005,
// 博客模块3000 ~ 3999 // 博客模块
BLOG_NOT_FOUND: -3001, BLOG_NOT_FOUND: -3001,
BLOG_PERMISSION_DENIED: -3002, BLOG_PERMISSION_DENIED: -3002,
// 验证模块4000 ~ 4999 // 验证模块
CAPTCHA_RARE_LIMIT: -4001, CAPTCHA_RARE_LIMIT: -4001,
// 通知模块5000 ~ 5999 // 通知模块
NOTIFICATION_SEND_FAILED: -5001, NOTIFICATION_SEND_FAILED: -5001,
// 资源模块6000 ~ 6999 // Sms模块
RESOURCE_UPLOAD_FAILED: -6001, SMS_CODE_INCORRECT: -6001,
RESOURCE_NOT_FOUND: -6002, SMS_CODE_EXPIRED: -6002,
// 管理员模块7000 ~ 7999 // 资源模块
ADMIN_FORBIDDEN: -7001, RESOURCE_UPLOAD_FAILED: -7001,
RESOURCE_NOT_FOUND: -7002,
// 管理员模块
ADMIN_FORBIDDEN: -8001,
} as const; } as const;
export type ErrorCodeType = typeof ErrorCode[keyof typeof ErrorCode]; export type ErrorCodeType = typeof ErrorCode[keyof typeof ErrorCode];

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

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

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

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

View 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类型');
}
}
}