01MVP 标识01MVP
包文档基础设施服务sms 短信服务

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-code

Twilio

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"

原因: 缺少必需的环境变量。

解决方案:

  1. 检查 .env.local 文件是否存在
  2. 确认所有必需的环境变量都已配置
  3. 重启开发服务器使环境变量生效

问题:腾讯云返回 "签名不存在"

原因: 短信签名未创建或未审核通过。

解决方案:

  1. 登录腾讯云控制台
  2. 进入短信服务 → 签名管理
  3. 创建签名并等待审核通过
  4. 确保 TENCENT_SMS_SIGN_NAME 与控制台中的签名完全一致

问题:模板消息参数不匹配

原因: 模板参数顺序或数量不正确。

解决方案: 确保 templateParams 的参数顺序和数量与模板定义一致:

// 模板:您的验证码是{1},{2}分钟内有效
await smsProvider.sendTemplateMessage({
  phoneNumber: "+8613800138000",
  templateParams: {
    "1": "123456",  // 验证码
    "2": "5",       // 有效时间
  },
});

问题:国际号码发送失败

原因: 提供商不支持目标国家/地区,或手机号格式不正确。

解决方案:

  1. 确保手机号包含国家代码(如 +86、+1)
  2. 检查提供商是否支持目标国家/地区
  3. 考虑使用 Twilio 发送国际短信

相关资源

完整的 API 文档请参考 README.md