短信验证调试成功
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { loadAppConfig } from '@dukang/shared-types';
|
||||
import { SmsCodeStore } from './sms/sms-code.store';
|
||||
import { SmsMockProvider } from './sms/sms.mock.provider';
|
||||
import { SmsAliyunProvider } from './sms/sms.aliyun.provider';
|
||||
import { PayMockProvider } from './pay/pay.mock.provider';
|
||||
import { PayWechatProvider } from './pay/pay.wechat.provider';
|
||||
import { DeliveryMockProvider } from './delivery/delivery.mock.provider';
|
||||
@@ -21,11 +23,26 @@ import { DELIVERY_QUEUE } from '../jobs/jobs.constants';
|
||||
import type { IWechatProvider } from './wechat/wechat.interface';
|
||||
import type { IPayProvider } from './pay/pay.interface';
|
||||
import type { IOssProvider } from './oss/oss.interface';
|
||||
import type { ISmsProvider } from './sms/sms.interface';
|
||||
|
||||
@Module({
|
||||
imports: [BullModule.registerQueue({ name: DELIVERY_QUEUE }), CourierModule],
|
||||
providers: [
|
||||
{ provide: SMS_PROVIDER, useClass: SmsMockProvider },
|
||||
SmsCodeStore,
|
||||
SmsMockProvider,
|
||||
SmsAliyunProvider,
|
||||
{
|
||||
provide: SMS_PROVIDER,
|
||||
useFactory: (mock: SmsMockProvider, aliyun: SmsAliyunProvider): ISmsProvider => {
|
||||
const cfg = loadAppConfig();
|
||||
if (cfg.mockSms) return mock;
|
||||
if (!aliyun.isEnabled()) {
|
||||
throw new Error('MOCK_SMS=false but Aliyun SMS credentials are missing');
|
||||
}
|
||||
return aliyun;
|
||||
},
|
||||
inject: [SmsMockProvider, SmsAliyunProvider],
|
||||
},
|
||||
WechatApiProvider,
|
||||
WechatDisabledProvider,
|
||||
{
|
||||
@@ -60,9 +77,8 @@ import type { IOssProvider } from './oss/oss.interface';
|
||||
},
|
||||
inject: [OssMockProvider, OssAliyunProvider],
|
||||
},
|
||||
SmsMockProvider,
|
||||
DeliveryMockProvider,
|
||||
],
|
||||
exports: [SMS_PROVIDER, PAY_PROVIDER, DELIVERY_PROVIDER, WECHAT_PROVIDER, OSS_PROVIDER, CourierModule],
|
||||
exports: [SMS_PROVIDER, SmsCodeStore, PAY_PROVIDER, DELIVERY_PROVIDER, WECHAT_PROVIDER, OSS_PROVIDER, CourierModule],
|
||||
})
|
||||
export class IntegrationsModule {}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { loadAppConfig } from '@dukang/shared-types';
|
||||
import { RedisService } from '../../common/redis/redis.service';
|
||||
|
||||
const CODE_TTL_SECONDS = 300;
|
||||
const RATE_TTL_SECONDS = 60;
|
||||
|
||||
function codeKey(phone: string, scene: string) {
|
||||
return `dukang:sms:${scene}:${phone}`;
|
||||
}
|
||||
|
||||
function rateKey(phone: string) {
|
||||
return `dukang:sms:rate:${phone}`;
|
||||
}
|
||||
|
||||
function randomSixDigitCode() {
|
||||
return String(Math.floor(100000 + Math.random() * 900000));
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SmsCodeStore {
|
||||
private readonly config = loadAppConfig();
|
||||
|
||||
constructor(private readonly redis: RedisService) {}
|
||||
|
||||
async assertSendCooldown(phone: string) {
|
||||
const ttl = await this.redis.ttl(rateKey(phone));
|
||||
if (ttl > 0) {
|
||||
throw new BadRequestException('发送过于频繁,请稍后再试');
|
||||
}
|
||||
}
|
||||
|
||||
async setSendCooldown(phone: string) {
|
||||
await this.redis.client.set(rateKey(phone), '1', 'EX', RATE_TTL_SECONDS);
|
||||
}
|
||||
|
||||
async generateAndStore(phone: string, scene: string): Promise<string> {
|
||||
const code = this.config.mockSms ? this.config.mockSmsCode : randomSixDigitCode();
|
||||
await this.redis.client.set(codeKey(phone, scene), code, 'EX', CODE_TTL_SECONDS);
|
||||
return code;
|
||||
}
|
||||
|
||||
async verifyAndConsume(phone: string, scene: string, code: string) {
|
||||
const key = codeKey(phone, scene);
|
||||
const stored = await this.redis.client.get(key);
|
||||
if (!stored || stored !== code.trim()) {
|
||||
throw new BadRequestException('验证码错误或已过期');
|
||||
}
|
||||
await this.redis.del(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import Dysmsapi20170525, { SendSmsRequest } from '@alicloud/dysmsapi20170525';
|
||||
import * as OpenApi from '@alicloud/openapi-client';
|
||||
import { loadAppConfig } from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { ISmsProvider } from './sms.interface';
|
||||
import { SmsCodeStore } from './sms-code.store';
|
||||
|
||||
function serializeSmsResponseBody(body: unknown): Record<string, string> | undefined {
|
||||
if (!body || typeof body !== 'object') return undefined;
|
||||
const src = body as Record<string, unknown>;
|
||||
const out: Record<string, string> = {};
|
||||
for (const key of ['code', 'message', 'requestId', 'bizId'] as const) {
|
||||
const value = src[key];
|
||||
if (value != null && typeof value !== 'function') {
|
||||
out[key] = String(value);
|
||||
}
|
||||
}
|
||||
return Object.keys(out).length ? out : undefined;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SmsAliyunProvider implements ISmsProvider {
|
||||
private readonly logger = new Logger(SmsAliyunProvider.name);
|
||||
private readonly config = loadAppConfig();
|
||||
private client: Dysmsapi20170525 | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly smsCodeStore: SmsCodeStore,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
isEnabled() {
|
||||
return !!(
|
||||
this.config.aliyunSmsAccessKeyId &&
|
||||
this.config.aliyunSmsAccessKeySecret &&
|
||||
this.config.aliyunSmsSignName &&
|
||||
this.config.aliyunSmsTemplateCode
|
||||
);
|
||||
}
|
||||
|
||||
private getClient() {
|
||||
if (!this.isEnabled()) {
|
||||
throw new Error('Aliyun SMS is not configured');
|
||||
}
|
||||
if (!this.client) {
|
||||
const openApiConfig = new OpenApi.Config({
|
||||
accessKeyId: this.config.aliyunSmsAccessKeyId,
|
||||
accessKeySecret: this.config.aliyunSmsAccessKeySecret,
|
||||
endpoint: 'dysmsapi.aliyuncs.com',
|
||||
});
|
||||
this.client = new Dysmsapi20170525(openApiConfig);
|
||||
}
|
||||
return this.client;
|
||||
}
|
||||
|
||||
async send(phone: string, scene: string): Promise<void> {
|
||||
const code = await this.smsCodeStore.generateAndStore(phone, scene);
|
||||
const request = new SendSmsRequest({
|
||||
phoneNumbers: phone,
|
||||
signName: this.config.aliyunSmsSignName,
|
||||
templateCode: this.config.aliyunSmsTemplateCode,
|
||||
templateParam: JSON.stringify({ code }),
|
||||
});
|
||||
|
||||
let logged = false;
|
||||
try {
|
||||
const response = await this.getClient().sendSms(request);
|
||||
const bizId = response.body?.bizId ?? undefined;
|
||||
const ok = response.body?.code === 'OK';
|
||||
const responseBody = serializeSmsResponseBody(response.body);
|
||||
await this.prisma.logThirdParty.create({
|
||||
data: {
|
||||
provider: 'SMS',
|
||||
scene,
|
||||
requestBody: { phone, templateCode: this.config.aliyunSmsTemplateCode, signName: this.config.aliyunSmsSignName },
|
||||
responseBody,
|
||||
externalNo: bizId,
|
||||
status: ok ? 'SUCCESS' : 'FAILED',
|
||||
errorMessage: ok ? undefined : response.body?.message ?? 'SMS send failed',
|
||||
},
|
||||
});
|
||||
logged = true;
|
||||
if (!ok) {
|
||||
throw new Error(response.body?.message ?? '短信发送失败');
|
||||
}
|
||||
this.logger.log(`Aliyun SMS sent to ${phone.slice(0, 3)}****${phone.slice(-4)} scene=${scene} bizId=${bizId ?? '-'}`);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`Aliyun SMS send failed: ${message}`);
|
||||
if (!logged) {
|
||||
await this.prisma.logThirdParty.create({
|
||||
data: {
|
||||
provider: 'SMS',
|
||||
scene,
|
||||
requestBody: { phone, templateCode: this.config.aliyunSmsTemplateCode, signName: this.config.aliyunSmsSignName },
|
||||
status: 'FAILED',
|
||||
errorMessage: message.slice(0, 512),
|
||||
},
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async verify(phone: string, code: string, scene: string): Promise<void> {
|
||||
await this.smsCodeStore.verifyAndConsume(phone, scene, code);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,34 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { loadAppConfig } from '@dukang/shared-types';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { ISmsProvider } from './sms.interface';
|
||||
import { SmsCodeStore } from './sms-code.store';
|
||||
|
||||
@Injectable()
|
||||
export class SmsMockProvider implements ISmsProvider {
|
||||
private readonly config = loadAppConfig();
|
||||
private readonly logger = new Logger(SmsMockProvider.name);
|
||||
|
||||
async send(_phone: string, _scene: string): Promise<void> {
|
||||
if (!this.config.mockSms) {
|
||||
throw new Error('Real SMS not implemented in preV1');
|
||||
}
|
||||
constructor(
|
||||
private readonly smsCodeStore: SmsCodeStore,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async send(phone: string, scene: string): Promise<void> {
|
||||
const code = await this.smsCodeStore.generateAndStore(phone, scene);
|
||||
const masked = `${phone.slice(0, 3)}****${phone.slice(-4)}`;
|
||||
this.logger.log(`Mock SMS → ${masked} scene=${scene} code=${code}`);
|
||||
|
||||
await this.prisma.logThirdParty.create({
|
||||
data: {
|
||||
provider: 'SMS',
|
||||
scene,
|
||||
requestBody: { phone: masked, mode: 'MOCK', scene },
|
||||
responseBody: { mock: true, hint: 'use MOCK_SMS_CODE or check server log' },
|
||||
status: 'SUCCESS',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async verify(phone: string, code: string, _scene: string): Promise<void> {
|
||||
if (this.config.mockSms && code === this.config.mockSmsCode) {
|
||||
return;
|
||||
}
|
||||
throw new Error(`Invalid verification code for ${phone}`);
|
||||
async verify(phone: string, code: string, scene: string): Promise<void> {
|
||||
await this.smsCodeStore.verifyAndConsume(phone, scene, code);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user