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; verifyLoginCode( phoneNumber: NormalizedPhoneNumber, verifyCode: string, ): Promise; }; type DypnsClientInstance = InstanceType; 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(); 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); }