资讯动态

Node.js短信验证码开发:异步发送与回调处理完整指南

发布时间:2026/10/18 1:35:58 来源:尧图企业网站定制
接短信验证码这个需求很多团队都做过但大多数项目第一次上线时都会在“发送”这一步踩坑。大多数讨论只停留在“调一下服务商接口拿到响应就结束”但现实根本没有这么简单请求可能超时、服务商可能返回已受理但短信实际没发出去、回调可能会重复、验签失败还会被平台一直重推。这几件事叠加起来Node.js 短信验证码接口开发就不是一个“调用 API”的 Demo而是一套需要把异步发送、回调处理、状态机、防刷和幂等想清楚的完整链路。我整理了一份可直接复用的开发流程重点放在两个最容易被低估的环节异步发送与回调处理。如果你正准备在项目里接短信验证码或者已经被“隔三差五收不到短信”“回调调不明白”折磨过这篇内容应该能帮你把整体思路理顺。1. 需求拆解验证码链路由哪几段组成做验证码接口之前先别急着想用哪个服务商也别急着写代码。动笔之前建议先画一条链路把所有参与角色列出来。我的经验是把验证码做成一个带完整生命周期的模块后面维护成本会低很多。1.1 一条验证码从生成到销毁的完整生命周期验证码的核心链路是前端触发发送请求 - 后端校验频率 - 生成随机码 - 存入缓存 - 调用短信服务商 - 服务商受理 - 异步发送到用户手机 - 服务商回调通知结果 - 后端更新发送状态。之后还有另一半链路用户在登录/注册/验证页面输入验证码 - 后端校验 - 校验通过立即作废 - 标记已使用。很多半路项目会砍掉“回调更新状态”和“验证码作废”只保留“发送校验”短期能用一旦短信服务商运行异常你连对账的凭据都没有。回调不是一个可选优化而是验证码接口能否稳定维护的分水岭。我习惯把这条链路分成两条子链路发送链路和消费链路。发送链路的核心产物是一条带唯一 requestId 的发送流水消费链路的核心产物是“验证码是否被正确校验并销毁”。两条链路共享同一个 Redis Key 或数据库记录但关注点不同。发送链路要保证请求最终被送达消费链路要保证验证码不可重放、不可爆破解密。1.2 为什么发送必须异步回调才是最终答案短信服务商普遍是耗时操作虽然绝大多数 HTTP 接口能做到 200 毫秒左右返回但高风险时段可能拖到 3 到 5 秒更夸张的情况是网关超时。如果你用同步方式直接在请求线程里调用服务商Node.js 虽然是非阻塞 I/O但你的接口响应时间会被下游拖死前端会一直转圈用户会觉得App卡了。更重要的一点是服务商接口返回“受理成功”不代表用户一定收到了短信。短信发送是异步投递最终是否成功取决于运营商通道、用户手机状态、信号覆盖、是否欠费等一堆因素。服务商唯一能给你的准确结果就是异步回调。所以开发验证码接口的第一原则应该是发送请求只代表“交给了服务商”最终状态必须靠回调来确认。把发送链路和回调链路一起设计才能避免两种典型事故服务商受理成功实际发送失败但你的数据库里还标记成“成功”服务商回调延迟了几分钟回调到达时用户已经重新请求了验证码导致新老验证码状态错乱。我在项目里通常把发送动作放进可重试的任务队列而不是直接 await。主接口请求进入后立即返回“已受理”后台消费者再调用服务商后续由回调把状态补全。这样既保证了接口响应快又给重试留下了空间。2. 技术选型先确认你的Node.js版本与存储方案技术选型这块不用纠结验证码模块并不需要复杂框架。但有些基础决定会影响后续扩展选错了后面改起来很痛苦。2.1 用原生HTTP封装还是用服务商SDK很多短信服务商提供官方 SDK安装即用。SDK 的好处是签名、请求封装都做好了缺点是失去对底层请求和返回体的直接控制无法插桩做链路追踪SDK 版本升级频繁出问题时要等平台修复回调验签部分通常不包含在 SDK 里。我个人偏向直接用 Node.js 内置 fetch 或 axios 写一个轻量 HTTP 封装。短信接口无非是 HTTPS 签名 JSON/表单自己封装不到 100 行代码。搞一套对接层把服务商接口的发送、查询、回调验签封装成 SDK后面换服务商只要改适配层业务代码不用动。实测下来这套抽象在项目迁移时能省下很多排查时间。2.2 验证码为什么必须放在Redis里验证码这类时效性数据最合适的就是Redis。优先使用 Redis 而不是内存变量原因有三个进程重启不丢Node.js 进程一重启内存 Map 全没了所有已发送验证码瞬间失效多实例共享部署多个 Node.js 进程时内存存储互相不可见用户可能在 A 实例发送、B 实例校验导致验证码永远校验失败原生过期机制Redis 的 EXPIRE / SETEX 能直接替代你自己手写的定时清除逻辑。如果项目里已经有 Redis不要单独再引一个内存缓存库。验证码、发送频率计数器、异步任务队列全都可以用 Redis 承载。生产环境建议至少用 Redis 6.xLua 脚本支持更完整对后面的原子操作很有帮助。2.3 项目目录结构参考我常用的目录结构是这样src/ ├─ app.js # 服务入口挂载路由 ├─ config/ │ └─ index.js # 环境变量与配置集中管理 ├─ routes/ │ └─ sms.js # 路由定义 ├─ controllers/ │ └─ smsController.js # 参数校验与响应处理 ├─ services/ │ ├─ smsService.js # 业务编排验证码生成、存储、校验 │ ├─ smsProvider.js # 服务商适配层发送、查询、回调验签 │ └─ smsQueue.js # 异步任务队列 ├─ utils/ │ ├─ captcha.js # 验证码生成 │ ├─ logger.js # 结构化日志 │ └─ response.js # 统一响应体这个拆分不是为了炫技而是让发送、回调、校验三个场景各司其职。改服务商只动 smsProvider.js改频率限制只动 smsService.js新增登录/注册场景只动路由和校验参数互不干扰。3. 核心代码实现验证码生成、限流与异步发送这一节给出可以直接抄的落地代码。我用的是 Node.js 18、Express、Redis 客户端 ioredis短信服务商适配层按通用 REST 接口编写。你的环境如果略有不同只需要替换对应实现。3.1 验证码生成模块用随机整数而不是 Math.random生成验证码看起来最简单也是最容易写出毛病的地方。有些项目直接用Math.random().toString().slice(-6)这在大部分场景能工作但在安全要求高的模块里我不会这么做。Math.random()不是密码学安全随机数理论上有被预测的风险而且字符串切割还可能得出少于 6 位数字的情况需要额外补位。用法上的最佳实践是使用 Node.js 内置的crypto.randomInt// utils/captcha.js const crypto require(node:crypto); function generateCode(length 6) { if (length 4 || length 10) { throw new Error(验证码长度建议控制在4到10位之间); } // 生成 [100000, 1000000) 范围的整数刚好是6位 const min 10 ** (length - 1); const max 10 ** length; return String(crypto.randomInt(min, max)); } module.exports { generateCode };这里还应该有一个逻辑同一手机号同一场景的验证码不要无限覆盖。每发送一次新验证码旧验证码就会被覆盖这本身没有问题但如果你连续的循环重置用户会认为接口坏掉了。我在生成验证码之前会做一个“发送中”标记检查用户当前还在有效期内的验证码才允许重发否则提示等待过期。// 发送前的简易检查 const pendingKey captcha:${scene}:${mobile}; const exists await redis.exists(pendingKey); if (exists !forceRefresh) { return { code: 429, msg: 验证码仍在有效期内请稍后再试 }; }3.2 接口入口先把频率限制做好发送接口每分钟只能被同个手机号请求一次这个限制必须放在所有业务逻辑之前不能放在调用短信服务商之后否则每次恶意请求都会真的触发一次短信费用。频率限制我用 Redis 计数器实现// 限制同一手机号60秒内只能发1条 async function checkSendFrequency(mobile, ip) { const mobileKey rate:send:mobile:${mobile}; const ipKey rate:send:ip:${ip}; // 手机号维度每分钟1次 const mobileCount await redis.incr(mobileKey); if (mobileCount 1) { await redis.expire(mobileKey, 60); } if (mobileCount 1) { return { allowed: false, msg: 发送过于频繁请1分钟后再试 }; } // IP维度每小时20次简单防批量攻击 const ipCount await redis.incr(ipKey); if (ipCount 1) { await redis.expire(ipKey, 3600); } if (ipCount 20) { return { allowed: false, msg: 当前网络环境发送次数过多 }; } return { allowed: true }; }注意 Redisincr配合expire的写法在并发极低时没问题但如果担心并发竞态更稳妥的方式是直接用 Lua 脚本保证原子性。下面这段脚本可以在 Redis 里一口气完成“自增设置过期时间判断是否超限”避免两次命令之间出现系统崩溃导致 Key 没有过期时间local key KEYS[1] local limit tonumber(ARGV[1]) local seconds tonumber(ARGV[2]) local current redis.call(INCR, key) if current 1 then redis.call(EXPIRE, key, seconds) end if current limit then return 0 end return 13.3 异步发送把任务丢给队列而不是阻塞HTTP响应正式环境我推荐使用 BullMQ 或类似的 Redis 队列。核心思路是客户端请求进来先做频率检查生成验证码把验证码存进 Redis把发送任务写进队列接口立即返回“发送任务已受理”后台 worker 从队列取出任务调用短信服务商服务商受理后记录 requestId等待回调。实现一个极简版队列处理器// services/smsQueue.js const { Queue, Worker } require(bullmq); const redisConnection { host: 127.0.0.1, port: 6379 }; const smsQueue new Queue(sms-send, { connection: redisConnection }); const smsWorker new Worker(sms-send, async (job) { const { requestId, mobile, scene, code, templateParams } job.data; console.log([worker] 开始发送短信: ${requestId}); // 调用服务商适配层 const result await require(./smsProvider).sendSms({ mobile, templateId: templateParams.templateId, params: templateParams.params, }); // 记录发送结果更新流水状态 await updateSmsRecord(requestId, { providerRequestId: result.requestId, status: result.success ? SENT : FAILED, }); // 如果发送失败且未超过重试次数重新入队 if (!result.success job.attemptsMade 2) { throw new Error(发送失败: ${result.msg}); } }, { connection: redisConnection, attempts: 3, backoff: { type: exponential, delay: 5000 }, });这里老读者可能会问为什么不直接 await? 如果你对接口响应时间不敏感且服务商接口稳定在几百毫秒直接 await 也能用。但线上真实情况是高峰期服务商会排队接口响应可能到几秒。用队列之后发送端接口的平均响应时间可以稳定在 50ms 以内ESS 服务的可用性也不依赖下游。验证码本身已经提前写入 Redis用户提交校验时并不需要等短信发送完成。就算短信延迟几秒用户收到后照样可以校验因为验证码在发送之前已经落库了。这是“异步发送”和“异步生成”的区别千万别搞反验证码生成必须同步短信发送可以异步。3.4 服务商适配层的封装要点服务商适配层我通常这样写// services/smsProvider.js const crypto require(node:crypto); class SmsProvider { constructor({ appId, appSecret, endpoint }) { this.appId appId; this.appSecret appSecret; this.endpoint endpoint; } generateSignature(data, timestamp, nonce) { const payload ${this.appId}${timestamp}${nonce}${JSON.stringify(data)}; const hmac crypto.createHmac(sha256, this.appSecret); hmac.update(payload, utf8); return hmac.digest(hex).toUpperCase(); } async sendSms({ mobile, templateId, params }) { const timestamp Date.now(); const nonce crypto.randomBytes(16).toString(hex); const data { mobile, templateId, params, }; const res await fetch(${this.endpoint}/sms/send, { method: POST, headers: { Content-Type: application/json, X-App-Id: this.appId, X-Timestamp: String(timestamp), X-Nonce: nonce, X-Sign: this.generateSignature(data, timestamp, nonce), }, body: JSON.stringify(data), signal: AbortSignal.timeout(5000), }); const body await res.json(); return { success: body.code 0, requestId: body.requestId, msg: body.msg, }; } }这个封装的核心是把请求超时、签名、返回信息标准化。服务商返回的 requestId 是后续回调关联的关键字段必须记录到自己的流水里。很多项目后面排查问题困难就是因为没有把平台 requestId 和自己系统的 requestId 做映射。提示超时时间建议设置在 5 秒左右。太短容易误判太长会拖垮队列消费速度。另外fetch 的 AbortSignal.timeout 在 Node.js 18 直接可用不需要额外依赖。4. 回调接口怎么写验签、幂等与状态流转异步发送的闭环靠回调。回调接口写得好不好决定验证码模块能不能在线上平安运行。这一块我会重点展开。4.1 回调通知的两种常见形态不同服务商回调差异很大但大致两种JSON POST 回调平台把发送结果以 JSON 格式 POST 到你配置的 URL内容一般包含 requestId、mobile、status、errCode、timestamp 和签名。表单 POST 回调平台以application/x-www-form-urlencoded格式推送字段类似。无论哪种你都要注意读取原始请求体做验签而不是用框架解析后的对象再重新序列化。因为服务商用原始字节计算签名你在服务端把对象 JSON.stringify 一遍字段顺序或空格都可能不一致导致验签始终失败。我在 Express 里会这样写// routes/sms.js const express require(express); const router express.Router(); const crypto require(node:crypto); // 回调通知需要原始body做验签 router.post(/callback, express.text({ type: */* }), async (req, res) { const rawBody req.body; const signature req.headers[x-sign]; const timestamp req.headers[x-timestamp]; if (!verifyCallback(rawBody, timestamp, signature)) { return res.status(403).json({ code: SIGN_INVALID, msg: 验签失败 }); } // 解析回调内容 const payload JSON.parse(rawBody); await handleCallback(payload); // 无论处理结果如何只要收到并校验通过都返回成功 res.json({ code: 0, msg: ok }); });注意 Express 默认的express.json()会把请求体解析成对象不方便拿原始字符串做验签。所以我用express.text()保留原始 body。具体用哪种中间件取决于服务商发送的 Content-Type。4.2 验签必须用 timingSafeEqual验签一旦没做好攻击者可以伪造回调把你的验证码状态改成已发送或失败干扰正常业务。验签函数我强烈建议用crypto.timingSafeEqual就是为了避免常规字符串比较的时间侧信道问题function verifyCallback(rawBody, timestamp, signature) { if (!timestamp || !signature) return false; // 校验时间戳回调时间与当前时间差超过5分钟直接丢弃 if (Math.abs(Date.now() - Number(timestamp)) 5 * 60 * 1000) { return false; } const payload ${timestamp}\n${rawBody}; const hmac crypto.createHmac(sha256, appSecret); hmac.update(payload, utf8); const expected hmac.digest(hex).toUpperCase(); if (expected.length ! signature.length) { return false; } return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature)); }注意timingSafeEqual要求两个 Buffer 长度相同所以要先比较长度再进入比较函数否则会抛异常。4.3 回调状态幂等不能让重复回调搞乱状态服务商在没收到你的 200 响应时会按策略重复推送回调可能推送 3 到 10 次不等。所以你的回调接口必须是幂等的。同一个 requestId 的回调重复到达第二次不能把状态从“已成功”改成“已失败”也不能重复触发重发短信。我常用 Redis Hash Lua 脚本实现原子状态流转-- KEYS[1] sms:record:{requestId} -- ARGV[1] newStatus -- ARGV[2] providerCode local current redis.call(HGET, KEYS[1], status) if not current then return -1 end -- 已终态的不再更新DELIVERED / FAILED 都算终态 if current DELIVERED or current FAILED or current EXPIRED then return 0 end -- 更新状态 redis.call(HSET, KEYS[1], status, ARGV[1], providerCode, ARGV[2], updatedAt, ARGV[3]) return 1执行成功返回 1说明这是第一次到达回调和状态推进返回 0 说明重复回调直接忽略返回 -1 说明流水不存在可能需要告警。把这段逻辑封装成 Node.js 函数调用async function transitionSmsRecord(requestId, nextStatus, providerCode) { const key sms:record:${requestId}; const result await redis.eval( local current redis.call(HGET, KEYS[1], status) if not current then return -1 end if current DELIVERED or current FAILED or current EXPIRED then return 0 end redis.call(HSET, KEYS[1], status, ARGV[1], providerCode, ARGV[2], updatedAt, ARGV[3]) return 1 , 1, key, nextStatus, providerCode, Date.now()); return Number(result); }状态机的设计逻辑当前状态允许流转到含义PENDINGSENT / FAILED队列已受理已调用服务商受理或失败SENTDELIVERED / FAILED服务商已受理最终送达或发送失败SENTSENT重复回调忽略DELIVERED无终态绝不回退FAILEDPENDING可重试时重新入队由队列重发4.4 重试机制要区分“可重试”和“不可重试”回调返回 FAILED 不能盲目重试。要区分失败原因手机号格式错误、模板不存在、签名未审核这类是确定性失败重试多少次都不会成功直接标记失败并告警服务商内部超时、通道拥堵、网络波动这类是可重试的。所以回调里带上 errCode 判断后再决定是否重新入队。我通常维护一张可重试错误码表命中才重试否则直接置为终态。这能避免因为号码问题反复扣短信费用。5. 防刷与安全验证码最容易翻车的地方验证码一上生产马上就会遇到黑产脚本。这类脚本不会像人一样慢慢等而是高频请求、批量换号、绕过前端限制。防刷不到位短信费用会一夜之间被刷爆。5.1 发送接口的立体限流单一维度限流不够。手机号维度挡不住换号攻击IP 维度挡不住代理池。我常用三层组合手机号维度同一手机号 60 秒内 1 条、单日 10 条IP 维度同一 IP 每 5 分钟 5 条、每小时 20 条设备指纹维度同一设备标识每 15 分钟 1 条。设备指纹可以由前端生成一个匿名 ID随请求带上。黑产可以模拟但至少提高了攻击成本。如果项目已有用户体系还可以加账号维度限制一个未登录账号最多绑定 2 个手机号已绑定手机号不能再用作其他账号的验证码。// 综合判断示例 async function validateSendLimit({ mobile, ip, deviceId }) { const checks [ { key: limit:m:${mobile}, window: 60, limit: 1 }, { key: limit:ip:${ip}, window: 300, limit: 5 }, { key: limit:d:${deviceId}, window: 900, limit: 1 }, ]; for (const item of checks) { const allowed await incrWithExpire(item.key, item.window, item.limit); if (!allowed) { return { allowed: false, msg: 请求过于频繁请稍后再试 }; } } return { allowed: true }; }发送前还有一个常见手段要求用户先通过图形验证码或滑块验证拿到一个 short-lived token然后再调用短信接口。这个 token 有效期 5 分钟、一次性使用。这能挡住大部分机器脚本因为正常用户只需要输一次图形验证码黑产却需要先打码平台或逆向滑块成本高很多。5.2 校验接口的防爆破与一次性校验验证码的接口同样需要防护。攻击者可以在不知道验证码的情况下通过暴力尝试 000000 到 999999 来撞出正确验证码。验证码只有 6 位数字100 万种组合如果接口不限速几小时就能穷举完。我常用的校验限制策略每个验证码最多尝试 5 次超过后立即作废并删除 Redis Key每次失败后记录失败次数达到 3 次后锁定该手机号校验 10 分钟校验成功或验证码过期都要立即删除 Key不允许“校验成功后还能继续使用”根域名角度加统一接口限流每个 IP 每分钟最多 30 次校验请求。async function verifyCode({ mobile, scene, inputCode }) { const key captcha:${scene}:${mobile}; const failKey captcha:fail:${scene}:${mobile}; const stored await redis.get(key); if (!stored) { return { success: false, msg: 验证码已过期请重新获取 }; } // 5次失败直接作废 const failCount Number(await redis.get(failKey) || 0); if (failCount 5) { await redis.del(key); return { success: false, msg: 尝试次数过多验证码已失效 }; } // 使用 timingSafeEqual 比较避免时序差异 const expected Buffer.from(stored); const actual Buffer.from(inputCode); if (expected.length ! actual.length || !crypto.timingSafeEqual(expected, actual)) { await redis.incr(failKey); await redis.expire(failKey, 600); return { success: false, msg: 验证码错误 }; } // 校验成功原子删除验证码与失败计数器 const script redis.call(DEL, KEYS[1]) redis.call(DEL, KEYS[2]) return 1; await redis.eval(script, 2, key, failKey); return { success: true }; }这里的关键是校验成功后删除验证码。如果不删用户在同一会话里可以反复用同一个验证码这虽然不是大问题但不符合安全习惯而且如果接口被前端误调用两次业务会重复放行。5.3 不要把验证码暴露在任何日志里验证码属于敏感数据。日志里记录完整验证码一旦日志系统被拖库等于把用户短信里的安全介质一起泄露了。我在日志里只记录当前验证码的哈希摘要比如前 6 位取 SHA-256 后截取 8 位以及手机号脱敏前 3 后 4。同时响应给前端的数据也绝不能包含验证码。有些项目测试时为了快速调试把验证码直接返回在 JSON 里开发环境用没什么一旦忘了删后果很严重。可以做一个仅限 debug 环境开启的开关生产环境强制关闭。6. 日志监控与对账回调丢了怎么办回调虽然重要但它不是 100% 可靠。服务商网络抖动、你的回调接口短暂不可用、或者平台 bug 导致回调丢失这些都会发生。所以验证码模块必须加一道对账兜底不能把所有信任都押在回调上。6.1 全链路日志到底要记哪些字段我习惯每条发送流水记录这些字段形成一张“短信流水表”或 Redis Hash字段示例说明requestIduuid系统内部请求 IDproviderRequestIdplatform_xxx服务商侧请求 IDmobile138****8000脱敏存储scenelogin / register场景区分statusPENDING / SENT / DELIVERED / FAILED状态机状态errCode0 / 1001服务商错误码createdAt时间戳入队时间updatedAt时间戳最近一次状态更新时间providerCallbackAt时间戳回调到达时间日志和流水的关系是流水用于业务对账日志用于排查链路。业务上以流水为准日志辅助还原请求顺序。使用结构化日志库每条日志带traceId整个请求链路上所有打印都带上这个 IDlogger.info({ traceId: ctx.state.traceId, event: sms.send.accepted, requestId, mobile: maskMobile(mobile), scene, costMs, });6.2 定时对账任务主动查询服务商每 5 分钟跑一个定时任务扫描所有停留在 PENDING 或 SENT 超过 10 分钟的流水调用服务商的“短信状态查询接口”确认实际状态。这里要小心查询接口本身也有频率限制所以一次批量查几十条时建议做随机抖动不要整点触发避免和所有任务撞在一起。const UNFINISHED_KEYS_LIMIT 100; async function reconcileTask() { const cursor 0; do { const [nextCursor, keys] await redis.scan( cursor, MATCH, sms:record:*, COUNT, 20 ); for (const key of keys) { const record await redis.hgetall(key); const needCheck [PENDING, SENT].includes(record.status) Date.now() - Number(record.createdAt) 10 * 60 * 1000; if (needCheck) { const result await smsProvider.querySmsStatus(record.providerRequestId); if (result.status DELIVERED || result.status FAILED) { await transitionSmsRecord(record.requestId, result.status, result.code); } } } cursor nextCursor; } while (cursor ! 0); }对账任务还有一种更简单的方式在服务商后台导出报表做 T1 对账但这属于事后补救不如实时查询来得准。实时对账能保证用户侧反馈足够及时。注意生产环境不要用KEYS sms:record:*数据量大时会阻塞 Redis。要用SCAN游标遍历一次取少量循环处理。6.3 回调地址要配成公网可达 HTTPS回调接口必须公网可达而且建议配 HTTPS。服务商一般也要求 HTTPS 证书是可信机构签发的自签名证书大概率会被拒绝。开发环境要调试回调可以用内网穿透工具把本地服务临时暴露出来但生产环境一定要走正式域名和证书。我在测试环境遇到过一个典型问题服务商后台配置的回调地址写成了http://localhost:3000/callback结果平台当然回调不到本地。排了半天才发现是地址配错了。这个点很基础但很容易被忽略。7. 常见问题与排查实录最后把我在真实项目里踩过的坑按“问题-原因-解法”整理成速查表。下面这些场景不是假设都是线上真实出现过的。7.1 验证码一直收不到可能原因判断方法解决方式服务商账户欠费登录服务商后台看余额充值签名或模板未审核服务商后台显示审核中/未通过提前提交审核一般1-2小时验证码已发出但被手机拦截查服务商后台发送状态为DELIVERED让用户检查垃圾短信手机号前缺国家码日志里手机号只有11位统一加国际区号如 86模板参数不正确服务商返回模板解析失败检查模板变量格式不要传多余变量异步队列任务堆积查看队列消费延迟扩容worker检查服务商API响应7.2 回调一直不来回调不来先看两个地方第一服务商后台配置的回调 URL 是否正确且公网可达第二你的回调接口是不是一直返回 4xx 或 5xx导致服务商反复重试后放弃。从代码层面回调接口本身也要支持幂等只要收到重复推送就返回 200不要因为验签失败返回 401。验签失败要让服务商能知道一般服务商在回调连续失败一定次数后会停推。所以一旦发现回调收不到优先看日志里是否有一堆验签失败记录。7.3 重复收到验证码短信重复短信的根因绝大多数出在你自己的队列消费端而不是服务商。比如消费任务超时队列框架把它标记为失败并重新投递消费端代码里手动重试但没有幂等判断服务商超时但是实际发送成功你的代码捕获超时后又重发了一次。防重复发送的关键地方是调用服务商之前先检查当前流水状态。如果已经处于 SENT 或 DELIVERED直接丢弃本次消费任务。更稳妥的做法是给每个手机号加一个“发送中”锁锁存在期间不允许新的发送任务执行。7.4 验证码过期的锅到底是谁的验证码过期有两个层次Redis Key 过期业务设置的验证码有效期到了进程内缓存过期如果你用了内存存储进程重启后全部丢失。排查方式很简单Redis 里查看 Key 的剩余 TTLTTL captcha:login:138****8000。如果 TTL 为 -2说明 Key 不存在为 -1说明 Key 存在但没有过期时间这是不正常的说明写入时忘了设置过期。出现后者说明修改代码时把setex改成了set。7.5 回调状态乱序服务商可能先通知“成功”后通知“失败”或者反之这取决于是不是有重试补发。状态机的价值在这里体现已经到终态的记录不允许被后来的回调改回。所以回调处理函数要在状态流转阶段做判断而不是无条件更新。7.6 服务商回调Content-Type不同导致解析失败不同服务商回调格式不同有的 POST JSON有的 POST 表单还有的 GET 方式回调。全局用express.json()只处理 JSON 会导致表单请求解析异常。我建议把回调路由单独出来按服务商要求的 Content-Type 配置中间件或者统一用express.text()保留 raw body拿到之后自己解析。7.7 排查用到的几条实用命令开发阶段我常用的排障命令# 查看Redis中某个手机号的验证码剩余有效时间 redis-cli TTL captcha:login:138****8000 # 查看某个验证码是否存在 redis-cli GET captcha:login:138****8000 # 查看发送频率限制 redis-cli GET rate:send:mobile:138****8000 # 查看某个发送流水全部字段 redis-cli HGETALL sms:record:uuid # 用SCAN找到所有超时未完成的流水 redis-cli --scan --pattern sms:record:* | xargs -I {} redis-cli HGET {} status注意xargs批量执行在生产环境要非常小心最好还是写脚本带着 SCAN 游标跑避免一次性加载大量 Key。8. 最后留个经验抽象服务商接口先做模拟测试整套流程跑下来个人最深的体会是短信验证码接口的技术难点从来不在“调通服务商”而在“链路稳定性”。只要把异步发送、回调、状态机、对账、幂等这几件事做扎实生产事故会少一大半。写代码之前建议先把服务商接口抽象成一个SmsProvider适配层同时在开发环境里实现一个MockSmsProvider。这个 Mock 不真正发短信而是模拟三种情况正常受理、接口超时、回调乱序。把回调模拟器做到测试环境里能模拟重复回调、延迟回调、失败回调这样你可以在没有真实短信服务商的情况下把状态机和幂等逻辑测透。还有一个细节想特别强调验证码校验成功的删除动作一定要放在业务处理成功之后。如果先删验证码、再执行业务逻辑万一业务处理失败用户需要重新获取验证码体验很糟。如果先执行业务、再删验证码一旦删除失败用户手里的验证码还能重复使用。稳妥做法是业务处理和验证码删除放在同一个事务里如果架构上不支持事务就采用“业务幂等 验证码删除补偿”的方案具体由业务场景决定。短信验证码接口是那种“看起来一天能写完上线后要盯一个月”的模块。把它当成一个完整的状态机系统来做比当成一个简单 HTTP 调用要稳得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑