sms 短信服务
统一的短信服务包,支持腾讯云、阿里云、Twilio 等多个提供商
概述
@mono/sms 提供统一的短信服务接口,支持多个短信提供商。无论使用哪个提供商,都可以通过相同的 API 发送验证码和模板消息。
核心功能
- 支持腾讯云 SMS、阿里云 SMS、Twilio
- 统一的 API 接口,轻松切换提供商
- 验证码发送和模板消息
- 从环境变量自动加载配置
- 完善的错误处理和日志记录
快速开始
安装
pnpm add @mono/sms基础用法
import { createSMSProviderFromEnv } from "@mono/sms";
// 从环境变量创建提供商
const smsProvider = createSMSProviderFromEnv();
// 发送验证码
const result = await smsProvider.sendVerificationCode(
"+8613800138000",
"123456"
);
if (result.success) {
console.log("验证码发送成功");
} else {
console.error("发送失败:", result.error);
}提供商配置
腾讯云 SMS
import { createSMSProvider } from "@mono/sms";
const provider = createSMSProvider({
provider: "tencent",
secretId: process.env.TENCENT_CLOUD_SECRET_ID!,
secretKey: process.env.TENCENT_CLOUD_SECRET_KEY!,
region: "ap-guangzhou",
sdkAppId: process.env.TENCENT_SMS_SDK_APP_ID!,
signName: "您的签名",
templateId: "您的模板ID",
});环境变量:
TENCENT_CLOUD_SECRET_ID=your-secret-id
TENCENT_CLOUD_SECRET_KEY=your-secret-key
TENCENT_SMS_REGION=ap-guangzhou
TENCENT_SMS_SDK_APP_ID=your-sdk-app-id
TENCENT_SMS_SIGN_NAME=your-sign-name
TENCENT_SMS_TEMPLATE_ID=your-template-id阿里云 SMS
const provider = createSMSProvider({
provider: "aliyun",
accessKeyId: process.env.ALIYUN_ACCESS_KEY_ID!,
accessKeySecret: process.env.ALIYUN_ACCESS_KEY_SECRET!,
signName: "您的签名",
templateCode: "您的模板代码",
});环境变量:
ALIYUN_ACCESS_KEY_ID=your-access-key-id
ALIYUN_ACCESS_KEY_SECRET=your-access-key-secret
ALIYUN_SMS_SIGN_NAME=your-sign-name
ALIYUN_SMS_TEMPLATE_CODE=your-template-codeTwilio
const provider = createSMSProvider({
provider: "twilio",
accountSid: process.env.TWILIO_ACCOUNT_SID!,
authToken: process.env.TWILIO_AUTH_TOKEN!,
fromPhoneNumber: "+1234567890",
});环境变量:
TWILIO_ACCOUNT_SID=your-account-sid
TWILIO_AUTH_TOKEN=your-auth-token
TWILIO_FROM_PHONE_NUMBER=+1234567890实战示例
示例 1:用户注册验证码
import { createSMSProviderFromEnv } from "@mono/sms";
import { normalizePhoneNumber } from "@mono/utils";
export async function sendRegistrationCode(phoneNumber: string) {
// 格式化手机号
const formattedPhone = normalizePhoneNumber(phoneNumber);
// 生成 6 位验证码
const code = Math.floor(100000 + Math.random() * 900000).toString();
// 发送短信
const smsProvider = createSMSProviderFromEnv();
const result = await smsProvider.sendVerificationCode(formattedPhone, code);
if (result.success) {
// 保存验证码到 Redis(5 分钟过期)
await redis.setex(`sms:code:${phoneNumber}`, 300, code);
return {
success: true,
requestId: result.requestId,
message: "验证码已发送",
};
} else {
throw new Error(result.error || "发送验证码失败");
}
}示例 2:Next.js API Route
// app/api/sms/send-code/route.ts
import { createSMSProviderFromEnv } from "@mono/sms";
import { NextResponse } from "next/server";
export async function POST(request: Request) {
try {
const { phoneNumber } = await request.json();
// 验证手机号格式
if (!/^1[3-9]\d{9}$/.test(phoneNumber)) {
return NextResponse.json(
{ error: "手机号格式不正确" },
{ status: 400 }
);
}
// 检查发送频率(60 秒限制)
const lastSent = await redis.get(`sms:last-sent:${phoneNumber}`);
if (lastSent && Date.now() - Number(lastSent) < 60000) {
return NextResponse.json(
{ error: "发送过于频繁,请稍后再试" },
{ status: 429 }
);
}
// 生成验证码
const code = Math.floor(100000 + Math.random() * 900000).toString();
// 发送短信
const smsProvider = createSMSProviderFromEnv();
const result = await smsProvider.sendVerificationCode(
`+86${phoneNumber}`,
code
);
if (result.success) {
// 记录发送时间
await redis.set(`sms:last-sent:${phoneNumber}`, Date.now().toString());
// 保存验证码
await redis.setex(`sms:code:${phoneNumber}`, 300, code);
return NextResponse.json({
success: true,
requestId: result.requestId,
});
} else {
return NextResponse.json(
{ error: result.error },
{ status: 500 }
);
}
} catch (error) {
console.error("发送短信失败:", error);
return NextResponse.json(
{ error: "服务器错误" },
{ status: 500 }
);
}
}示例 3:发送模板消息
import { createSMSProvider } from "@mono/sms";
const smsProvider = createSMSProvider({
provider: "tencent",
// ... 配置
});
// 发送订单通知
async function sendOrderNotification(
phoneNumber: string,
orderNo: string,
amount: string
) {
const result = await smsProvider.sendTemplateMessage({
phoneNumber,
templateId: "order-notification-template-id",
templateParams: {
orderNo,
amount,
time: new Date().toLocaleString("zh-CN"),
},
});
if (result.success) {
console.log("订单通知发送成功");
} else {
console.error("发送失败:", result.error);
}
return result;
}示例 4:多提供商降级策略
import { createSMSProvider, type ISMSProvider } from "@mono/sms";
import { logger } from "@mono/logs";
class SMSService {
private providers: ISMSProvider[];
constructor() {
// 配置多个提供商作为备份
this.providers = [
createSMSProvider({
provider: "tencent",
secretId: process.env.TENCENT_CLOUD_SECRET_ID!,
secretKey: process.env.TENCENT_CLOUD_SECRET_KEY!,
region: "ap-guangzhou",
sdkAppId: process.env.TENCENT_SMS_SDK_APP_ID!,
signName: process.env.TENCENT_SMS_SIGN_NAME!,
templateId: process.env.TENCENT_SMS_TEMPLATE_ID!,
}),
createSMSProvider({
provider: "aliyun",
accessKeyId: process.env.ALIYUN_ACCESS_KEY_ID!,
accessKeySecret: process.env.ALIYUN_ACCESS_KEY_SECRET!,
signName: process.env.ALIYUN_SMS_SIGN_NAME!,
templateCode: process.env.ALIYUN_SMS_TEMPLATE_CODE!,
}),
];
}
async sendVerificationCode(phoneNumber: string, code: string) {
for (const provider of this.providers) {
try {
const result = await provider.sendVerificationCode(phoneNumber, code);
if (result.success) {
logger.info(`成功使用 ${provider.getProviderName()} 发送短信`);
return result;
}
logger.warn(
`${provider.getProviderName()} 发送失败,尝试下一个提供商`,
{ error: result.error }
);
} catch (error) {
logger.error(`${provider.getProviderName()} 发送异常:`, error);
}
}
throw new Error("所有短信提供商均发送失败");
}
}
// 使用
const smsService = new SMSService();
await smsService.sendVerificationCode("+8613800138000", "123456");示例 5:验证码验证
import { createSMSProviderFromEnv } from "@mono/sms";
export async function verifyCode(phoneNumber: string, code: string) {
// 从 Redis 获取验证码
const savedCode = await redis.get(`sms:code:${phoneNumber}`);
if (!savedCode) {
return {
success: false,
error: "验证码已过期或不存在",
};
}
if (savedCode !== code) {
// 记录错误次数
const errorCount = await redis.incr(`sms:error:${phoneNumber}`);
await redis.expire(`sms:error:${phoneNumber}`, 300);
// 超过 5 次错误,锁定 5 分钟
if (errorCount >= 5) {
await redis.del(`sms:code:${phoneNumber}`);
return {
success: false,
error: "验证码错误次数过多,请重新获取",
};
}
return {
success: false,
error: `验证码错误,还可尝试 ${5 - errorCount} 次`,
};
}
// 验证成功,删除验证码和错误计数
await redis.del(`sms:code:${phoneNumber}`);
await redis.del(`sms:error:${phoneNumber}`);
return { success: true };
}示例 6:带重试的发送
import { createSMSProviderFromEnv } from "@mono/sms";
import { logger } from "@mono/logs";
async function sendSMSWithRetry(
phoneNumber: string,
code: string,
maxRetries = 3
) {
const smsProvider = createSMSProviderFromEnv();
for (let i = 0; i < maxRetries; i++) {
try {
const result = await smsProvider.sendVerificationCode(phoneNumber, code);
if (result.success) {
logger.info("短信发送成功", {
phoneNumber: phoneNumber.replace(/(\d{3})\d{4}(\d{4})/, "$1****$2"),
requestId: result.requestId,
attempt: i + 1,
});
return result;
}
logger.warn(`短信发送失败 (尝试 ${i + 1}/${maxRetries})`, {
error: result.error,
});
} catch (error) {
logger.error(`短信发送异常 (尝试 ${i + 1}/${maxRetries})`, error);
}
// 等待后重试(指数退避)
if (i < maxRetries - 1) {
const delay = 1000 * Math.pow(2, i); // 1s, 2s, 4s
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw new Error("短信发送失败,已达到最大重试次数");
}API 参考
ISMSProvider
所有短信提供商实现的统一接口。
interface ISMSProvider {
// 发送验证码
sendVerificationCode(phoneNumber: string, code: string): Promise<SMSResult>;
// 发送模板消息
sendTemplateMessage(params: TemplateMessageParams): Promise<SMSResult>;
// 获取提供商名称
getProviderName(): string;
}SMSResult
短信发送结果。
interface SMSResult {
success: boolean; // 是否发送成功
message?: string; // 成功消息
requestId?: string; // 请求 ID(用于追踪)
error?: string; // 错误信息
}TemplateMessageParams
模板消息参数。
interface TemplateMessageParams {
phoneNumber: string; // 手机号
templateParams: Record<string, string>; // 模板参数
templateId?: string; // 模板 ID(可选)
}提供商选择
腾讯云 SMS
优势:
- 国内覆盖好,到达率高
- 价格相对便宜(0.045 元/条起)
- 与腾讯云其他服务集成方便
适用场景:
- 主要面向中国大陆用户
- 已使用腾讯云其他服务
- 预算有限
阿里云 SMS
优势:
- 国内覆盖好,稳定性高
- 控制台功能丰富
- 详细的发送报告
适用场景:
- 主要面向中国大陆用户
- 已使用阿里云其他服务
- 需要详细的数据分析
Twilio
优势:
- 全球覆盖最好(190+ 国家)
- API 设计优秀,文档完善
- 可靠性高
适用场景:
- 面向国际用户
- 需要全球短信服务
- 预算充足
最佳实践
1. 发送频率限制
// ✅ 推荐:使用 Redis 限制发送频率
async function sendCodeWithRateLimit(phoneNumber: string) {
const key = `sms:last-sent:${phoneNumber}`;
const lastSent = await redis.get(key);
if (lastSent && Date.now() - Number(lastSent) < 60000) {
throw new Error("发送过于频繁,请 60 秒后再试");
}
// 发送短信...
await redis.set(key, Date.now().toString(), "EX", 60);
}2. 验证码安全
// ✅ 推荐:设置合理的过期时间和错误次数限制
async function saveVerificationCode(phoneNumber: string, code: string) {
// 验证码 5 分钟过期
await redis.setex(`sms:code:${phoneNumber}`, 300, code);
// 错误次数限制(5 次)
await redis.set(`sms:error:${phoneNumber}`, "0", "EX", 300);
}3. 手机号格式化
import { normalizePhoneNumber } from "@mono/utils";
// ✅ 推荐:使用 @mono/utils 格式化手机号
const formattedPhone = normalizePhoneNumber("13800138000");
// "+8613800138000"4. 日志记录
import { logger } from "@mono/logs";
// ✅ 推荐:记录关键操作日志
logger.info("发送短信", {
phoneNumber: phoneNumber.replace(/(\d{3})\d{4}(\d{4})/, "$1****$2"),
provider: smsProvider.getProviderName(),
requestId: result.requestId,
});5. 错误处理
// ✅ 推荐:妥善处理各种错误情况
try {
const result = await smsProvider.sendVerificationCode(phoneNumber, code);
if (!result.success) {
// 记录错误日志
logger.error("短信发送失败", { error: result.error });
// 返回用户友好的错误信息
throw new Error("验证码发送失败,请稍后重试");
}
} catch (error) {
logger.error("短信发送异常", error);
throw new Error("服务暂时不可用,请稍后重试");
}故障排查
问题:发送失败,提示 "Missing required environment variable"
原因: 缺少必需的环境变量。
解决方案:
- 检查
.env.local文件是否存在 - 确认所有必需的环境变量都已配置
- 重启开发服务器使环境变量生效
问题:腾讯云返回 "签名不存在"
原因: 短信签名未创建或未审核通过。
解决方案:
- 登录腾讯云控制台
- 进入短信服务 → 签名管理
- 创建签名并等待审核通过
- 确保
TENCENT_SMS_SIGN_NAME与控制台中的签名完全一致
问题:模板消息参数不匹配
原因: 模板参数顺序或数量不正确。
解决方案:
确保 templateParams 的参数顺序和数量与模板定义一致:
// 模板:您的验证码是{1},{2}分钟内有效
await smsProvider.sendTemplateMessage({
phoneNumber: "+8613800138000",
templateParams: {
"1": "123456", // 验证码
"2": "5", // 有效时间
},
});问题:国际号码发送失败
原因: 提供商不支持目标国家/地区,或手机号格式不正确。
解决方案:
- 确保手机号包含国家代码(如 +86、+1)
- 检查提供商是否支持目标国家/地区
- 考虑使用 Twilio 发送国际短信
相关资源
- 腾讯云 SMS 文档
- 阿里云 SMS 文档
- Twilio SMS 文档
- @mono/utils 文档 - 手机号格式化工具
- @mono/logs 文档 - 日志记录工具
完整的 API 文档请参考 README.md。