在Node.js后端开发中,短信通知功能是用户注册验证、订单状态提醒等场景的刚需。但很多开发者初次对接短信API时,往往会遇到以下几个典型问题:
回调地狱陷阱:使用传统回调函数处理异步请求时,代码会形成金字塔式的嵌套结构,不仅难以维护,还容易遗漏错误处理。我曾见过一个生产环境的bug,就是因为回调函数中漏掉了错误处理,导致短信发送失败时整个服务崩溃。
参数校验的坑:短信服务商对参数有严格要求,比如:
编码问题:有一次我调试了整整一天,最后发现是因为没有对中文字符进行UTF-8编码,导致短信内容变成了乱码。这种问题在测试环境可能发现不了,一到线上就出问题。
针对上述问题,我总结了一套经过实战检验的解决方案:
axios替代原生http模块:
分层设计:
mermaid复制graph TD
A[输入手机号和内容] --> B{本地校验}
B -->|通过| C[构造请求参数]
B -->|失败| D[返回错误]
C --> E[发送异步请求]
E --> F{请求成功?}
F -->|是| G[解析响应]
F -->|否| H[异常处理]
G --> I[返回标准化结果]
H --> I
首先创建一个配置文件config.js:
javascript复制// 短信接口配置
module.exports = {
// 基础配置
apiUrl: 'https://api.ihuyi.com/sms/Submit.json',
account: process.env.SMS_API_ID, // 从环境变量读取
password: process.env.SMS_API_KEY,
// 模板配置
templates: {
verification: '1', // 验证码模板
notification: '2' // 通知类模板
},
// 频率限制(次/分钟)
rateLimit: {
verification: 1, // 验证码1分钟1次
notification: 5 // 通知类5次
}
};
javascript复制const axios = require('axios');
const querystring = require('querystring');
const config = require('./config');
const redis = require('redis'); // 用于频率控制
class SmsService {
constructor() {
// 初始化Redis客户端
this.redisClient = redis.createClient({
host: process.env.REDIS_HOST || '127.0.0.1',
port: process.env.REDIS_PORT || 6379
});
// 监听Redis错误
this.redisClient.on('error', (err) => {
console.error('Redis连接错误:', err);
});
}
/**
* 手机号校验
* @param {string} mobile - 手机号码
* @returns {boolean}
*/
validateMobile(mobile) {
const reg = /^1[3-9]\d{9}$/;
if (!reg.test(mobile)) {
return false;
}
// 校验号段有效性
const validPrefix = [
'130','131','132','133','134','135','136','137','138','139',
'150','151','152','153','155','156','157','158','159',
'166','170','171','172','173','175','176','177','178',
'180','181','182','183','184','185','186','187','188','189',
'191','198','199'
];
return validPrefix.includes(mobile.substring(0,3));
}
/**
* 检查发送频率
* @param {string} mobile - 手机号
* @param {string} type - 短信类型
* @returns {Promise<boolean>}
*/
async checkRateLimit(mobile, type) {
const key = `sms:${type}:${mobile}`;
const limit = config.rateLimit[type] || 1;
return new Promise((resolve) => {
this.redisClient.get(key, (err, count) => {
if (err) {
console.error('Redis查询错误:', err);
return resolve(false);
}
if (count && parseInt(count) >= limit) {
resolve(false);
} else {
resolve(true);
}
});
});
}
/**
* 发送短信
* @param {string} mobile - 手机号
* @param {string} content - 短信内容
* @param {string} type - 短信类型
* @returns {Promise<object>}
*/
async sendSms(mobile, content, type = 'verification') {
// 参数校验
if (!this.validateMobile(mobile)) {
return {
success: false,
code: 'INVALID_MOBILE',
message: '手机号格式错误'
};
}
// 频率控制
const canSend = await this.checkRateLimit(mobile, type);
if (!canSend) {
return {
success: false,
code: 'RATE_LIMIT',
message: '发送频率过高'
};
}
// 构造请求参数
const params = {
account: config.account,
password: config.password,
mobile: mobile,
content: content,
templateid: config.templates[type] || '1'
};
try {
// 发送请求
const response = await axios({
method: 'post',
url: config.apiUrl,
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8'
},
data: querystring.stringify(params),
timeout: 8000
});
// 记录发送次数
const key = `sms:${type}:${mobile}`;
this.redisClient.multi()
.incr(key)
.expire(key, 60)
.exec();
// 解析响应
const { code, msg, smsid } = response.data;
return {
success: code === '2',
code: code,
message: msg,
smsid: smsid
};
} catch (error) {
// 错误处理
let errorCode = 'UNKNOWN_ERROR';
let errorMessage = '短信发送失败';
if (error.response) {
// 服务端返回的错误
errorCode = `API_${error.response.status}`;
errorMessage = error.response.data?.msg || error.message;
} else if (error.request) {
// 请求已发出但没有收到响应
errorCode = 'NETWORK_ERROR';
errorMessage = '网络连接异常';
} else {
// 其他错误
errorCode = 'INTERNAL_ERROR';
errorMessage = error.message;
}
return {
success: false,
code: errorCode,
message: errorMessage
};
}
}
}
module.exports = new SmsService();
javascript复制const sms = require('./smsService');
// 发送验证码
async function sendVerificationCode(mobile, code) {
const content = `您的验证码是:${code},5分钟内有效`;
const result = await sms.sendSms(mobile, content, 'verification');
if (result.success) {
console.log(`验证码发送成功,流水号:${result.smsid}`);
} else {
console.error(`发送失败:${result.message} (${result.code})`);
// 根据错误码进行特殊处理
switch(result.code) {
case 'RATE_LIMIT':
// 提示用户发送太频繁
break;
case 'INVALID_MOBILE':
// 提示手机号错误
break;
default:
// 其他错误处理
}
}
}
// 测试发送
sendVerificationCode('13800138000', '123456');
对于固定格式的短信,可以使用模板方式:
javascript复制// 在config.js中添加模板
templates: {
orderPaid: '3', // 订单支付成功模板
deliveryNotify: '4' // 发货通知模板
}
// 使用模板发送
async function sendOrderPaidSms(mobile, orderNo) {
const params = {
mobile: mobile,
templateid: config.templates.orderPaid,
vars: JSON.stringify({ // 模板变量
order_no: orderNo
})
};
// ...发送逻辑
}
对于网络不稳定的情况,可以添加自动重试:
javascript复制async function sendWithRetry(params, retries = 3) {
let lastError;
for (let i = 0; i < retries; i++) {
try {
const response = await axios({
/* 配置 */
});
return response.data;
} catch (error) {
lastError = error;
if (i < retries - 1) {
await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
}
}
}
throw lastError;
}
javascript复制const httpsAgent = new https.Agent({
keepAlive: true,
maxSockets: 50,
maxFreeSockets: 10
});
// 在axios配置中添加
axios.defaults.httpsAgent = httpsAgent;
javascript复制async function batchSend(mobiles, content) {
const chunks = [];
const size = 50; // 每批50个
for (let i = 0; i < mobiles.length; i += size) {
chunks.push(mobiles.slice(i, i + size));
}
const results = [];
for (const chunk of chunks) {
const res = await Promise.all(
chunk.map(mobile => sendSms(mobile, content))
);
results.push(...res);
}
return results;
}
安全规范:
监控指标:
灾备方案:
合规要求:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 返回405错误 | API账号密码错误 | 1. 检查账号密码 2. 联系服务商确认状态 |
| 返回406错误 | 手机号格式错误 | 1. 检查手机号格式 2. 验证号段有效性 |
| 返回4072错误 | 内容与模板不匹配 | 1. 核对模板变量 2. 检查内容长度 |
| 请求超时 | 网络问题或服务端故障 | 1. 检查网络连接 2. 添加重试机制 |
| 中文乱码 | 编码问题 | 1. 确保使用UTF-8 2. 正确设置Content-Type |
javascript复制// 测试环境mock
if (process.env.NODE_ENV === 'test') {
axios.interceptors.request.use(config => {
if (config.url.includes('sms')) {
return Promise.resolve({
data: {
code: '2',
msg: '模拟发送成功',
smsid: 'TEST123'
}
});
}
return config;
});
}
javascript复制// 添加请求日志
axios.interceptors.request.use(config => {
console.log('请求参数:', config.data);
return config;
});
axios.interceptors.response.use(response => {
console.log('响应数据:', response.data);
return response;
}, error => {
console.error('请求错误:', error);
return Promise.reject(error);
});
bash复制# 使用artillery进行压力测试
artillery quick --count 100 -n 50 http://localhost:3000/send-sms
| 服务商 | 特点 | 适合场景 |
|---|---|---|
| 互亿无线 | 接口简单,价格适中 | 中小型项目 |
| 阿里云短信 | 功能全面,稳定性高 | 企业级应用 |
| 腾讯云短信 | 与微信生态结合好 | 社交类应用 |
| 云片 | 模板审核快 | 快速上线项目 |
在实际项目中,通常会采用多通道通知策略:
javascript复制async function notifyUser(user, message) {
// 优先短信
const smsResult = await sms.sendSms(user.mobile, message);
if (!smsResult.success) {
// 短信失败转邮件
await email.send({
to: user.email,
subject: '重要通知',
text: message
});
}
}
内容优化:
时效控制:
javascript复制// Redis中存储验证码时设置过期时间
redisClient.setex(`code:${mobile}`, 300, code); // 5分钟过期
javascript复制// 验证码最多验证3次
redisClient.setex(`code:attempts:${mobile}`, 3600, 3);
对于国际项目,需要考虑:
javascript复制class InternationalSms extends SmsService {
validateMobile(mobile, countryCode) {
// 根据不同国家校验号码
const validators = {
'86': /^1[3-9]\d{9}$/, // 中国
'1': /^\d{10}$/, // 美国
'44': /^7\d{9}$/ // 英国
};
const reg = validators[countryCode];
return reg && reg.test(mobile);
}
}
javascript复制// 在发送方法中添加监控
async sendSms(mobile, content) {
const start = Date.now();
let success = false;
try {
const result = await doSend();
success = result.success;
return result;
} finally {
const duration = Date.now() - start;
statsd.timing('sms.request_time', duration);
statsd.increment(`sms.${success ? 'success' : 'fail'}`);
}
}
javascript复制// 全局共享axios实例
const http = axios.create({
httpsAgent: new https.Agent({
keepAlive: true
})
});
javascript复制const dns = require('dns');
dns.setDefaultResultOrder('ipv4first'); // 优先IPv4
javascript复制const cache = new Map();
async function getTemplate(id) {
if (cache.has(id)) {
return cache.get(id);
}
const template = await fetchTemplate(id);
cache.set(id, template);
return template;
}
javascript复制class NotificationRouter {
async send(message, user) {
// 根据用户偏好和内容重要性选择通道
if (message.priority === 'high') {
return this.sms.send(user.mobile, message);
} else {
return this.email.send(user.email, message);
}
}
}