Files
Genarrative/server-node/src/services/smsVerificationService.ts
kdletters 680b9a3e1c
Some checks failed
CI / verify (push) Has been cancelled
添加短信验证服务
2026-04-18 08:51:52 +00:00

278 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import crypto from 'node:crypto';
import DypnsApiModule, {
CheckSmsVerifyCodeRequest,
SendSmsVerifyCodeRequest,
} from '@alicloud/dypnsapi20170525';
import OpenApiClient from '@alicloud/openapi-client';
import type { Logger } from 'pino';
import type { NormalizedPhoneNumber } from '../auth/phoneNumber.js';
import type { AppConfig } from '../config.js';
import {
badRequest,
unauthorized,
upstreamError,
} from '../errors.js';
export type SendLoginCodeResult = {
cooldownSeconds: number;
expiresInSeconds: number;
providerRequestId: string | null;
};
export type SmsVerificationService = {
sendLoginCode(phoneNumber: NormalizedPhoneNumber): Promise<SendLoginCodeResult>;
verifyLoginCode(
phoneNumber: NormalizedPhoneNumber,
verifyCode: string,
): Promise<void>;
};
type DypnsClientInstance = InstanceType<typeof DypnsApiModule>;
type DypnsClientConstructor = new (
config: OpenApiClient.Config,
) => DypnsClientInstance;
function resolveDypnsClientConstructor(): DypnsClientConstructor {
const directExport = DypnsApiModule as unknown;
if (typeof directExport === 'function') {
return directExport as DypnsClientConstructor;
}
// 兼容 CommonJS SDK 在 ESM/tsx 运行时被包一层 default 的情况。
const nestedDefault = (
DypnsApiModule as unknown as { default?: unknown }
).default;
if (typeof nestedDefault === 'function') {
return nestedDefault as DypnsClientConstructor;
}
throw new Error('阿里云短信 SDK Client 导出异常');
}
const DypnsClient = resolveDypnsClientConstructor();
function isAliyunConfigMissing(config: AppConfig['smsAuth']) {
return !config.accessKeyId || !config.accessKeySecret;
}
function assertAliyunRequiredConfig(config: AppConfig['smsAuth']) {
if (!config.signName.trim()) {
throw new Error('ALIYUN_SMS_SIGN_NAME 未配置');
}
if (!config.templateCode.trim()) {
throw new Error('ALIYUN_SMS_TEMPLATE_CODE 未配置');
}
if (!config.templateParamKey.trim()) {
throw new Error('ALIYUN_SMS_TEMPLATE_PARAM_KEY 未配置');
}
}
function buildProviderErrorMessage(prefix: string, message: string) {
const normalizedMessage = message.trim();
return normalizedMessage ? `${prefix}${normalizedMessage}` : prefix;
}
class AliyunSmsVerificationService implements SmsVerificationService {
private readonly client: DypnsClient;
constructor(
private readonly config: AppConfig['smsAuth'],
private readonly logger: Logger,
) {
if (isAliyunConfigMissing(config)) {
throw new Error('ALIYUN_SMS_ACCESS_KEY_ID 或 ALIYUN_SMS_ACCESS_KEY_SECRET 未配置');
}
assertAliyunRequiredConfig(config);
const clientConfig = new OpenApiClient.Config({
accessKeyId: config.accessKeyId,
accessKeySecret: config.accessKeySecret,
endpoint: config.endpoint,
protocol: 'HTTPS',
});
this.client = new DypnsClient(clientConfig);
}
async sendLoginCode(phoneNumber: NormalizedPhoneNumber) {
const templateParam = JSON.stringify({
[this.config.templateParamKey]: '##code##',
"min": this.config.validTimeSeconds,
});
const request = new SendSmsVerifyCodeRequest({
phoneNumber: phoneNumber.nationalNumber,
countryCode: this.config.countryCode,
signName: this.config.signName,
templateCode: this.config.templateCode,
templateParam,
codeLength: this.config.codeLength,
codeType: this.config.codeType,
validTime: this.config.validTimeSeconds,
interval: this.config.intervalSeconds,
duplicatePolicy: this.config.duplicatePolicy,
returnVerifyCode: this.config.returnVerifyCode,
schemeName: this.config.schemeName || undefined,
outId: `login_${crypto.randomBytes(12).toString('hex')}`,
});
try {
const response = await this.client.sendSmsVerifyCode(request);
const body = response.body;
if (!body?.success || body.code !== 'OK') {
throw this.resolveAliyunRequestError(
'短信验证码发送失败',
body?.message ?? '',
body?.code ?? '',
);
}
return {
cooldownSeconds: this.config.intervalSeconds,
expiresInSeconds: this.config.validTimeSeconds,
providerRequestId: body.requestId ?? body.model?.requestId ?? null,
} satisfies SendLoginCodeResult;
} catch (error) {
if (error instanceof Error && error.name === 'HttpError') {
throw error;
}
this.logger.error(
{
err: error,
phone_suffix: phoneNumber.nationalNumber.slice(-4),
},
'aliyun sms send failed',
);
throw upstreamError(
buildProviderErrorMessage(
'短信验证码发送失败',
error instanceof Error ? error.message : 'unknown error',
),
);
}
}
async verifyLoginCode(
phoneNumber: NormalizedPhoneNumber,
verifyCode: string,
) {
const request = new CheckSmsVerifyCodeRequest({
phoneNumber: phoneNumber.nationalNumber,
countryCode: this.config.countryCode,
verifyCode,
caseAuthPolicy: this.config.caseAuthPolicy,
schemeName: this.config.schemeName || undefined,
});
try {
const response = await this.client.checkSmsVerifyCode(request);
const body = response.body;
if (!body?.success || body.code !== 'OK') {
throw this.resolveAliyunRequestError(
'验证码校验失败',
body?.message ?? '',
body?.code ?? '',
);
}
if (body.model?.verifyResult !== 'PASS') {
throw unauthorized('验证码错误或已失效');
}
} catch (error) {
if (error instanceof Error && error.name === 'HttpError') {
throw error;
}
this.logger.error(
{
err: error,
phone_suffix: phoneNumber.nationalNumber.slice(-4),
},
'aliyun sms verify failed',
);
throw upstreamError(
buildProviderErrorMessage(
'验证码校验失败',
error instanceof Error ? error.message : 'unknown error',
),
);
}
}
private resolveAliyunRequestError(
fallbackMessage: string,
providerMessage: string,
providerCode: string,
) {
const normalizedCode = providerCode.trim().toUpperCase();
if (
normalizedCode.includes('MOBILE') ||
normalizedCode.includes('PHONE') ||
normalizedCode.includes('TEMPLATE') ||
normalizedCode.includes('SIGN')
) {
return badRequest(
buildProviderErrorMessage(fallbackMessage, providerMessage),
{
providerCode,
},
);
}
return upstreamError(
buildProviderErrorMessage(fallbackMessage, providerMessage),
{
providerCode,
},
);
}
}
class MockSmsVerificationService implements SmsVerificationService {
private readonly sentCodes = new Map<string, string>();
constructor(private readonly config: AppConfig['smsAuth']) {}
async sendLoginCode(phoneNumber: NormalizedPhoneNumber) {
this.sentCodes.set(phoneNumber.e164, this.config.mockVerifyCode);
return {
cooldownSeconds: this.config.intervalSeconds,
expiresInSeconds: this.config.validTimeSeconds,
providerRequestId: 'mock-request-id',
} satisfies SendLoginCodeResult;
}
async verifyLoginCode(
phoneNumber: NormalizedPhoneNumber,
verifyCode: string,
) {
const expectedCode = this.sentCodes.get(phoneNumber.e164);
if (!expectedCode || expectedCode !== verifyCode) {
throw unauthorized('验证码错误或已失效');
}
}
}
export function createSmsVerificationService(
config: AppConfig,
logger: Logger,
): SmsVerificationService {
if (!config.smsAuth.enabled) {
return {
async sendLoginCode() {
throw badRequest('短信验证码登录未启用');
},
async verifyLoginCode() {
throw badRequest('短信验证码登录未启用');
},
};
}
if (config.smsAuth.provider === 'mock') {
return new MockSmsVerificationService(config.smsAuth);
}
return new AliyunSmsVerificationService(config.smsAuth, logger);
}