diff --git a/apps/backend/package.json b/apps/backend/package.json index f5223f0..39c46cf 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -22,6 +22,7 @@ "dependencies": { "@alicloud/credentials": "^2.4.3", "@alicloud/dm20151123": "1.2.6", + "@alicloud/dypnsapi20170525": "^2.0.0", "@alicloud/dysmsapi20170525": "4.1.0", "@alicloud/openapi-client": "^0.4.14", "@alicloud/tea-util": "^1.4.10", diff --git a/apps/backend/pnpm-lock.yaml b/apps/backend/pnpm-lock.yaml index 7527d50..948783b 100644 --- a/apps/backend/pnpm-lock.yaml +++ b/apps/backend/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@alicloud/dm20151123': specifier: 1.2.6 version: 1.2.6 + '@alicloud/dypnsapi20170525': + specifier: ^2.0.0 + version: 2.0.0 '@alicloud/dysmsapi20170525': specifier: 4.1.0 version: 4.1.0 @@ -174,6 +177,9 @@ packages: '@alicloud/dm20151123@1.2.6': resolution: {integrity: sha512-6pYgy0D5zmUoxfRYwj0ysX4WPw8IfGimaw3ORFj6hF6lTxWpJ3tteOD72i8rw764eZ78TRc4UyET3U9qCaBeaA==} + '@alicloud/dypnsapi20170525@2.0.0': + resolution: {integrity: sha512-eVh1dJ2HA82bBHt+YZFIBzPEYW80FK+TSpcxSR9o0W+FgfTqBaj6eeIHnN7NFhyDAD/3+HtZ146Pmvr51JEEAg==} + '@alicloud/dysmsapi20170525@4.1.0': resolution: {integrity: sha512-oUmRp6DTI6gGNbrSQK4lW7EouHIB4C0DCbSEA121NvxHC9XKe4cqiPP2VDqgDQiIK43oiFaHKY3rj+IteOWekA==} @@ -3432,6 +3438,13 @@ snapshots: transitivePeerDependencies: - 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': dependencies: '@alicloud/openapi-core': 1.0.4 diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index b8c3741..a579ad0 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -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], diff --git a/apps/backend/src/auth/dto/sms-login.dto.ts b/apps/backend/src/auth/dto/sms-login.dto.ts new file mode 100644 index 0000000..4c3bc02 --- /dev/null +++ b/apps/backend/src/auth/dto/sms-login.dto.ts @@ -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; +} \ No newline at end of file diff --git a/apps/backend/src/common/constants/error-codes.ts b/apps/backend/src/common/constants/error-codes.ts index b7affd8..9961cdb 100644 --- a/apps/backend/src/common/constants/error-codes.ts +++ b/apps/backend/src/common/constants/error-codes.ts @@ -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]; \ No newline at end of file diff --git a/apps/backend/src/sms/sms.controller.spec.ts b/apps/backend/src/sms/sms.controller.spec.ts new file mode 100644 index 0000000..ef21174 --- /dev/null +++ b/apps/backend/src/sms/sms.controller.spec.ts @@ -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); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/apps/backend/src/sms/sms.controller.ts b/apps/backend/src/sms/sms.controller.ts new file mode 100644 index 0000000..d5d688a --- /dev/null +++ b/apps/backend/src/sms/sms.controller.ts @@ -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; + } +} diff --git a/apps/backend/src/sms/sms.module.ts b/apps/backend/src/sms/sms.module.ts new file mode 100644 index 0000000..8ef76a8 --- /dev/null +++ b/apps/backend/src/sms/sms.module.ts @@ -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 { } diff --git a/apps/backend/src/sms/sms.service.spec.ts b/apps/backend/src/sms/sms.service.spec.ts new file mode 100644 index 0000000..4085265 --- /dev/null +++ b/apps/backend/src/sms/sms.service.spec.ts @@ -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); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/apps/backend/src/sms/sms.service.ts b/apps/backend/src/sms/sms.service.ts new file mode 100644 index 0000000..b198c7b --- /dev/null +++ b/apps/backend/src/sms/sms.service.ts @@ -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 + ) { + 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 { + 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类型'); + } + } +}