150 lines
5.0 KiB
TypeScript
150 lines
5.0 KiB
TypeScript
import { createRequire } from 'node:module';
|
||
import { $OpenApiUtil } from '@alicloud/openapi-core';
|
||
import { RuntimeOptions } from '@darabonba/typescript';
|
||
import { config } from '../../utils/config.js';
|
||
import { AppError, ValidationError } from '../../utils/errors.js';
|
||
|
||
// 阿里云 SDK 是 Darabonba 生成的 CJS 模块,ESM import 在不同运行时下行为不一致
|
||
// (Bun / Bun node 兼容模式 / Node.js 对 __esModule 和 exports.default 解析不同)
|
||
// 使用 createRequire 直接走 CJS require 路径,绕开所有 ESM/CJS 互操作问题
|
||
const require = createRequire(import.meta.url);
|
||
const DypnsClient = require('@alicloud/dypnsapi20170525').default;
|
||
const { SendSmsVerifyCodeRequest, CheckSmsVerifyCodeRequest } = require('@alicloud/dypnsapi20170525/dist/models/model.js');
|
||
|
||
type SmsOperation = 'send' | 'verify';
|
||
|
||
type AliyunSdkError = {
|
||
code?: unknown;
|
||
message?: unknown;
|
||
data?: {
|
||
Code?: unknown;
|
||
Message?: unknown;
|
||
};
|
||
};
|
||
|
||
function createClient() {
|
||
const openApiConfig = new $OpenApiUtil.Config({
|
||
accessKeyId: config.ALIYUN_ACCESS_KEY_ID,
|
||
accessKeySecret: config.ALIYUN_ACCESS_KEY_SECRET,
|
||
endpoint: 'dypnsapi.aliyuncs.com',
|
||
});
|
||
return new DypnsClient(openApiConfig);
|
||
}
|
||
|
||
function assertSmsConfigured(): void {
|
||
if (
|
||
!config.ALIYUN_ACCESS_KEY_ID ||
|
||
!config.ALIYUN_ACCESS_KEY_SECRET ||
|
||
!config.ALIYUN_SMS_SIGN_NAME ||
|
||
!config.ALIYUN_SMS_TEMPLATE_CODE
|
||
) {
|
||
throw new AppError('SMS service is not configured on the server', 503, 'SERVICE_UNAVAILABLE');
|
||
}
|
||
}
|
||
|
||
function getString(value: unknown): string | undefined {
|
||
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
||
}
|
||
|
||
function invalidVerifyCodeError(): AppError {
|
||
return new AppError('验证码无效或已过期', 401, 'INVALID_VERIFY_CODE');
|
||
}
|
||
|
||
function mapAliyunError(code: string, message: string, operation: SmsOperation): AppError {
|
||
switch (code) {
|
||
case 'MOBILE_NUMBER_ILLEGAL':
|
||
return new ValidationError('Invalid phone number');
|
||
case 'isv.ValidateFail':
|
||
return operation === 'verify'
|
||
? invalidVerifyCodeError()
|
||
: new AppError('SMS service error: verification failed', 502, 'SMS_SERVICE_ERROR');
|
||
case 'BUSINESS_LIMIT_CONTROL':
|
||
case 'FREQUENCY_FAIL':
|
||
return new AppError('Too many SMS requests, please try again later', 429, 'RATE_LIMITED');
|
||
case 'isv.BUSINESS_LIMIT_CONTROL':
|
||
return new AppError('SMS daily limit reached for this number', 429, 'RATE_LIMITED');
|
||
case 'biz.FREQUENCY':
|
||
return new AppError('Too many SMS requests, please try again later', 429, 'RATE_LIMITED');
|
||
case 'InternalError':
|
||
return new AppError('SMS provider temporarily unavailable, please try again', 503, 'SMS_PROVIDER_ERROR');
|
||
case 'INVALID_PARAMETERS':
|
||
return new ValidationError('Invalid request parameters');
|
||
default:
|
||
return new AppError(
|
||
`SMS service error: ${message || code}`,
|
||
502,
|
||
'SMS_SERVICE_ERROR',
|
||
);
|
||
}
|
||
}
|
||
|
||
export function mapAliyunException(error: unknown, operation: SmsOperation): AppError {
|
||
if (error instanceof AppError) return error;
|
||
|
||
if (typeof error === 'object' && error !== null) {
|
||
const sdkError = error as AliyunSdkError;
|
||
const code = getString(sdkError.data?.Code) ?? getString(sdkError.code);
|
||
const message = getString(sdkError.data?.Message) ?? getString(sdkError.message);
|
||
|
||
if (code) {
|
||
return mapAliyunError(code, message ?? code, operation);
|
||
}
|
||
}
|
||
|
||
return new AppError('SMS service error: request failed', 502, 'SMS_SERVICE_ERROR');
|
||
}
|
||
|
||
const RUNTIME_OPTIONS = new RuntimeOptions({
|
||
readTimeout: 10000,
|
||
connectTimeout: 5000,
|
||
});
|
||
|
||
export async function sendCode(phoneNumber: string): Promise<void> {
|
||
assertSmsConfigured();
|
||
|
||
const request = new SendSmsVerifyCodeRequest({
|
||
phoneNumber,
|
||
signName: config.ALIYUN_SMS_SIGN_NAME,
|
||
templateCode: config.ALIYUN_SMS_TEMPLATE_CODE,
|
||
templateParam: config.ALIYUN_SMS_TEMPLATE_PARAM,
|
||
});
|
||
|
||
const response = await createClient().sendSmsVerifyCodeWithOptions(request, RUNTIME_OPTIONS).catch((error: unknown) => {
|
||
throw mapAliyunException(error, 'send');
|
||
});
|
||
|
||
if (!response.body || response.body.code !== 'OK') {
|
||
throw mapAliyunError(
|
||
response.body?.code ?? 'UNKNOWN',
|
||
response.body?.message ?? 'Unknown SMS error',
|
||
'send',
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function verifyCode(phoneNumber: string, code: string): Promise<void> {
|
||
assertSmsConfigured();
|
||
|
||
const request = new CheckSmsVerifyCodeRequest({
|
||
phoneNumber,
|
||
verifyCode: code,
|
||
caseAuthPolicy: 1,
|
||
});
|
||
|
||
const response = await createClient().checkSmsVerifyCodeWithOptions(request, RUNTIME_OPTIONS).catch((error: unknown) => {
|
||
throw mapAliyunException(error, 'verify');
|
||
});
|
||
|
||
if (!response.body || response.body.code !== 'OK') {
|
||
throw mapAliyunError(
|
||
response.body?.code ?? 'UNKNOWN',
|
||
response.body?.message ?? 'Unknown SMS verification error',
|
||
'verify',
|
||
);
|
||
}
|
||
|
||
if (response.body.model?.verifyResult !== 'PASS') {
|
||
throw invalidVerifyCodeError();
|
||
}
|
||
}
|