1. 项目概述
短信验证码是现代应用中最基础的安全验证手段之一。作为后端开发者,我们经常需要对接第三方短信平台实现验证码发送功能。在Node.js环境下,由于异步I/O的特性,如何正确处理短信发送的异步流程和回调处理就显得尤为重要。
我最近刚完成了一个电商项目的短信模块开发,踩了不少坑也积累了些经验。今天就来详细聊聊Node.js中实现短信验证码接口的全流程,包括异步发送、回调处理、防刷策略等关键环节。
2. 技术选型与准备
2.1 短信平台选择
国内常见的短信平台有阿里云短信、腾讯云短信、云片等。我最终选择了阿里云短信,主要基于以下几点考虑:
- 文档完善,API设计规范
- 到达率和稳定性有保障
- 提供详细的发送记录和统计报表
- 支持签名和模板审核
提示:选择短信平台时,除了考虑价格因素,更要关注到达率和稳定性。有些小平台价格便宜但到达率可能只有70%-80%,这对验证码场景是不可接受的。
2.2 项目依赖安装
我们需要安装以下npm包:
bash复制npm install alibabacloud/dysmsapi-20170525 # 阿里云短信SDK
npm install redis # 用于存储验证码
npm install express # Web框架
npm install body-parser # 解析请求体
3. 核心实现流程
3.1 初始化短信客户端
首先创建sms.js文件初始化短信客户端:
javascript复制const Dysmsapi = require('@alicloud/dysmsapi-20170525');
const Config = require('./config');
const clientConfig = {
accessKeyId: Config.accessKeyId,
accessKeySecret: Config.accessKeySecret,
endpoint: 'dysmsapi.aliyuncs.com'
};
const client = new Dysmsapi(clientConfig);
3.2 发送验证码接口实现
创建/sendCode接口处理验证码发送:
javascript复制const express = require('express');
const router = express.Router();
const redis = require('redis');
const client = redis.createClient();
router.post('/sendCode', async (req, res) => {
const { phone } = req.body;
// 1. 生成随机验证码
const code = Math.floor(100000 + Math.random() * 900000);
// 2. 存储到Redis,设置5分钟过期
await client.setEx(`sms:${phone}`, 300, code);
// 3. 发送短信
const sendSmsRequest = new SendSmsRequest({
phoneNumbers: phone,
signName: '你的签名',
templateCode: '你的模板ID',
templateParam: `{"code":"${code}"}`
});
try {
const result = await client.sendSms(sendSmsRequest);
if (result.body.Code === 'OK') {
res.json({ success: true });
} else {
throw new Error(result.body.Message);
}
} catch (err) {
res.status(500).json({
success: false,
message: err.message
});
}
});
3.3 回调处理实现
短信平台通常会提供状态回调,我们需要实现一个接口接收发送状态:
javascript复制router.post('/callback', (req, res) => {
const { phone_number, send_status, err_code, err_msg } = req.body;
if (send_status === 'FAIL') {
console.error(`短信发送失败: ${phone_number}, ${err_code}-${err_msg}`);
// 可以在这里实现重试逻辑或告警
}
res.status(200).end();
});
4. 高级功能实现
4.1 防刷策略
为了防止恶意刷短信,我们需要实现以下防护措施:
- 频率限制:同一手机号60秒内只能发送一次
- 日限制:同一手机号每天最多发送10次
- IP限制:同一IP每小时最多发送50次
实现代码:
javascript复制// 频率限制中间件
const rateLimit = async (req, res, next) => {
const { phone } = req.body;
const ip = req.ip;
// 检查手机号频率
const lastSent = await client.get(`sms:time:${phone}`);
if (lastSent && Date.now() - lastSent < 60000) {
return res.status(429).json({
success: false,
message: '操作过于频繁,请稍后再试'
});
}
// 检查日发送量
const dayCount = await client.incr(`sms:count:${phone}:${new Date().toISOString().slice(0,10)}`);
if (dayCount > 10) {
return res.status(429).json({
success: false,
message: '今日发送次数已达上限'
});
}
// 检查IP频率
const ipCount = await client.incr(`sms:ip:${ip}:${Math.floor(Date.now()/3600000)}`);
if (ipCount > 50) {
return res.status(429).json({
success: false,
message: 'IP发送频率过高'
});
}
await client.set(`sms:time:${phone}`, Date.now());
next();
};
// 应用中间件
router.post('/sendCode', rateLimit, async (req, res) => {
// ...原有代码
});
4.2 验证码校验接口
创建/verifyCode接口用于验证用户输入的验证码:
javascript复制router.post('/verifyCode', async (req, res) => {
const { phone, code } = req.body;
const storedCode = await client.get(`sms:${phone}`);
if (!storedCode) {
return res.json({
success: false,
message: '验证码已过期或不存在'
});
}
if (storedCode !== code) {
return res.json({
success: false,
message: '验证码错误'
});
}
// 验证成功后删除验证码
await client.del(`sms:${phone}`);
res.json({
success: true
});
});
5. 性能优化与稳定性保障
5.1 异步队列处理
对于高并发场景,直接调用短信API可能会导致性能问题。我们可以使用消息队列来解耦:
javascript复制const { Queue } = require('bull');
const smsQueue = new Queue('sms', {
redis: {
port: 6379,
host: '127.0.0.1'
}
});
// 生产者
router.post('/sendCode', rateLimit, async (req, res) => {
const { phone } = req.body;
const code = Math.floor(100000 + Math.random() * 900000);
await client.setEx(`sms:${phone}`, 300, code);
// 将任务加入队列
await smsQueue.add({
phone,
code
});
res.json({ success: true });
});
// 消费者
smsQueue.process(async (job) => {
const { phone, code } = job.data;
const sendSmsRequest = new SendSmsRequest({
phoneNumbers: phone,
signName: '你的签名',
templateCode: '你的模板ID',
templateParam: `{"code":"${code}"}`
});
await client.sendSms(sendSmsRequest);
});
5.2 失败重试机制
短信发送可能会因为网络问题失败,我们需要实现自动重试:
javascript复制smsQueue.process(async (job) => {
let retries = 3;
let lastError;
while (retries--) {
try {
const sendSmsRequest = new SendSmsRequest({
phoneNumbers: job.data.phone,
signName: '你的签名',
templateCode: '你的模板ID',
templateParam: `{"code":"${job.data.code}"}`
});
await client.sendSms(sendSmsRequest);
return;
} catch (err) {
lastError = err;
if (retries > 0) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
}
throw lastError;
});
6. 监控与告警
6.1 发送成功率监控
我们可以记录每次发送的结果,计算成功率:
javascript复制// 在回调接口中添加统计
router.post('/callback', (req, res) => {
const { phone_number, send_status } = req.body;
if (send_status === 'FAIL') {
client.incr('sms:stats:fail');
} else {
client.incr('sms:stats:success');
}
res.status(200).end();
});
// 定时计算成功率
setInterval(async () => {
const success = await client.get('sms:stats:success') || 0;
const fail = await client.get('sms:stats:fail') || 0;
const rate = success / (success + fail);
if (rate < 0.9) {
// 触发告警
sendAlert(`短信发送成功率下降: ${(rate * 100).toFixed(2)}%`);
}
}, 3600000); // 每小时检查一次
6.2 异常告警
对于关键错误,我们需要实时告警:
javascript复制process.on('unhandledRejection', (reason, promise) => {
if (reason.message.includes('SendSms')) {
sendAlert(`短信发送异常: ${reason.message}`);
}
});
7. 测试策略
7.1 单元测试
使用Jest编写单元测试:
javascript复制const request = require('supertest');
const app = require('../app');
describe('短信验证码接口', () => {
it('应该成功发送验证码', async () => {
const res = await request(app)
.post('/sendCode')
.send({ phone: '13800138000' });
expect(res.statusCode).toEqual(200);
expect(res.body.success).toBeTruthy();
});
it('应该拒绝频繁请求', async () => {
await request(app).post('/sendCode').send({ phone: '13800138001' });
const res = await request(app)
.post('/sendCode')
.send({ phone: '13800138001' });
expect(res.statusCode).toEqual(429);
});
});
7.2 压力测试
使用Artillery进行压力测试:
yaml复制config:
target: "http://localhost:3000"
phases:
- duration: 60
arrivalRate: 50
scenarios:
- flow:
- post:
url: "/sendCode"
json:
phone: "138{{$randomNumber(10000000,99999999)}}"
8. 部署注意事项
8.1 环境变量管理
敏感信息如AccessKey应该通过环境变量注入:
bash复制# .env文件
ACCESS_KEY_ID=your_key_id
ACCESS_KEY_SECRET=your_key_secret
8.2 多实例部署
如果部署多个实例,需要注意:
- Redis需要使用同一个实例
- 消息队列需要统一管理
- 频率限制需要基于共享存储
9. 常见问题排查
9.1 短信发送失败
可能原因及解决方案:
| 错误码 | 原因 | 解决方案 |
|---|---|---|
| isv.BUSINESS_LIMIT_CONTROL | 业务限流 | 检查发送频率,调整限流策略 |
| isv.AMOUNT_NOT_ENOUGH | 账户余额不足 | 充值 |
| isv.MOBILE_NUMBER_ILLEGAL | 非法手机号 | 检查手机号格式 |
| isv.TEMPLATE_MISSING_PARAMETERS | 模板参数缺失 | 检查templateParam格式 |
9.2 Redis连接问题
如果遇到Redis连接超时:
- 检查Redis服务是否运行
- 检查网络连通性
- 调整连接超时时间:
javascript复制const client = redis.createClient({
socket: {
connectTimeout: 5000,
reconnectStrategy: (retries) => Math.min(retries * 100, 5000)
}
});
10. 性能优化技巧
- 连接池管理:为Redis和短信客户端配置适当的连接池大小
- 批量发送:对于通知类短信,可以实现批量发送接口
- 本地缓存:对模板等不变的数据可以加入本地缓存
- DNS缓存:配置DNS缓存减少DNS查询时间
javascript复制const dns = require('dns');
dns.setDefaultResultOrder('ipv4first');
11. 安全加固措施
- 接口鉴权:为发送和验证接口添加JWT鉴权
- 参数校验:严格校验手机号格式
- HTTPS:确保所有接口都通过HTTPS访问
- 日志脱敏:在日志中脱敏手机号等敏感信息
javascript复制// 手机号脱敏
function maskPhone(phone) {
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
}
12. 扩展功能思路
- 多通道切换:当主通道失败时自动切换到备用通道
- 语音验证码:作为短信的备用方案
- 国际短信:支持+86以外的国际号码
- 数据统计:提供发送量、成功率等统计报表
实现多通道的例子:
javascript复制async function sendSms(phone, code) {
const channels = [aliyunSms, tencentSms];
let lastError;
for (const channel of channels) {
try {
await channel.send(phone, code);
return;
} catch (err) {
lastError = err;
}
}
throw lastError;
}
在实际项目中,短信验证码模块虽然看起来简单,但要实现一个稳定、安全、高性能的解决方案需要考虑很多细节。我在最近的项目中就遇到了因为没处理好异步回调导致的验证码不一致问题,后来通过引入消息队列和加强测试才彻底解决。