PHP短信验证码API实战:从生成、发送到防刷的完整实现 直接往外喷的内容别急着学先把验证码这套写完很多项目卡在这了。PHP短信验证码API说难真不难但网上抄来的示例十个里有八个是调不通的。标题里写了“最常见验证码发送写法”我按我实际在项目里反复用、基本不改直接复制的那套逻辑来讲把发送验证码从0到能跑、从能跑到稳定给你捋一遍。1. 整体设计与方案选型为什么验证码模块要单独拎出来做短信验证码这个东西功能上就是“生成一串数字、发给用户、存下来、用户填回来、比对一下”这么个闭环。但你要是把这几步散落在登录接口、注册接口、找回密码接口里各写一份后期等着哭。最常见的正确做法是把验证码相关的逻辑收敛成一个独立的服务模块所有需要验证码的业务入口统一调它。1.1 业务上先拆清楚验证码不该和业务代码混在一起我在实际项目中见到最多的烂摊子就是登录页面里直接写了发送短信的代码注册页里又复制了一份找回密码页再改改参数又整一份。一旦短信服务商要求换接口、加签名、改模板你要全项目去搜“sms”关键词搜索出来好几处改完这处漏那处测试阶段就炸。合理的拆法是三层第一层是统一的发送封装类只负责一件事把验证码发出去返回成功或失败。第二层是业务处理器负责生成随机码、把码存到缓存或数据库、调用发送封装类、记录发送状态。第三层是接口入口严格做参数校验、频率限制、IP限制再交给业务处理器。这样做的好处不只是代码干净。排查问题的时候你可以非常快地判断是“发送环节挂了”还是“业务逻辑挂了”不用在一坨代码里翻来翻去。核心流程是这样的前端或小程序端请求发送验证码接口带上手机号、场景标识如register、login、resetpwd。后端先做校验手机号格式对不对、这个号今天发了多少次、这个IP是不是在刷、上次发送距现在够不够60秒。生成6位数字验证码存入缓存设定有效时间常规5分钟。调用短信服务商的API把模板参数填好提交发送。返回给前端“已发送”的结果前端引导用户去填验证码。用户提交表单时后端比对用户输入的验证码和缓存里的验证码相等继续走业务否则提示错误。这个流程看着简单但每一步都有坑。后面我会把每一步的代码细节和为什么这么写的原因讲到位。1.2 选型对比为什么优先走API接口而不是其他发送方式短信发送的方式大方向上就这么几种服务商提供的HTTP API、服务商提供的SDK包、自建短信网关。自建网关这个基本不用考虑需要对接大量运营商通道实名制审核、通道稳定性、到达率问题能把小团队拖死。SDK包适合项目里只有一个服务商、且不想写HTTP请求细节的情况但缺点是SDK质量参差不齐有的服务商SDK还停留在老旧的PHP写法上有的甚至不兼容PHP8。我个人的习惯是统一走HTTP API不用SDK。原因很实在HTTP API是各家短信服务商最通用的能力换上任何一家你只需要改请求地址、参数名、签名算法核心代码不变。依赖的只是PHP自带的cURL扩展不用因为某个服务商的SDK去改PHP版本或扩展配置。方便做统一的日志记录、超时控制、重试机制。这里说的“最常见写法”指的就是这种cURL发起HTTPS请求、参数用JSON或标准表单方式提交、返回结果统一解析。这套写法你学会了换任何一家服务商你改的只是API地址和密钥整体骨架完全不用动。2. 核心代码解析验证码从生成到发出去的每个环节这段我直接贴能用的代码然后逐行讲我为什么这么写。主题是“最常见的写法”那我不搞花活就写老老实实的标准写法但一些容易忽略的细节我全部补上。2.1 验证码生成与缓存存储别用数据库用缓存先讲生成验证码和存验证码这一步。这是整个流程里代码量最少、但最容易出错的部分。最常见、也是我推荐的做法不存数据库直接放缓存。为什么验证码的生命周期只有几分钟生命周期极短数据库一张表反复写入删除纯属浪费。缓存里设定过期时间到期自动消失连删除逻辑都省了。如果你项目用了Redis直接Redis搞定如果没有Redis文件缓存或者一套简单的缓存封装也可以甚至PHP的APCu都行。重点在于给验证码一个不可忽略的过期时间而且比对之后要立刻删除防止暴力重放。我写过不下几十套验证码生成逻辑最后固定成下面这种写法?php /** * 生成6位数字验证码 * 之所以用random_int而不是mt_rand是为了密码学意义上的安全 * 验证码虽然不强依赖随机数强度但用更安全的生成方式没坏处 */ function generateCode(int $length 6): string { // 计算最小值 10^(length-1) 例如6位就是100000 $min pow(10, $length - 1); $max pow(10, $length) - 1; try { return (string) random_int($min, $max); } catch (Exception $e) { // random_int在随机源异常时会抛异常理论上很少发生 // 但一旦发生直接回落成时间戳加随机数方式保证接口不死 return (string) time() . mt_rand(100, 999); } }这里我必须强调为什么用random_int而不是mt_rand。验证码是个四位或六位数字看起来随机不重要但验证码是安全凭证。mt_rand的随机性在统计学上是有规律的对普通场景无所谓可因为验证码暴力破解只需要试一万次或一百万次如果你同时没有做尝试次数限制随机数算法强度不够会放大风险。我见过有人用rand(1000,9999)生成4位验证码而且没有次数限制的这种接口被爆破只是时间问题。random_int贵一点但验证码这种低频操作不在乎这点性能开销。生成了验证码就该存了。我常用的键名设计是$key sms:code: . $scene . : . $mobile;把场景标识和手机号一起拼进键名就能做到同一个手机号在注册和找回密码两个场景下各存各的验证码互不干扰。如果你不区分场景用户先注册收了一个验证码然后跑去找回密码找回密码的校验可能会拿注册的验证码去对逻辑不严谨。存缓存这里有个关键点验证码本身不能明文直接裸放至少做个简单处理。常见做法是直接存明文然后比对时用这个应付普通项目够用但更稳妥的做法是存哈希。我一般这样做// 存入缓存以Redis举例 // 存的不是验证码明文而是加了固定盐的hash $salt your_custom_static_salt; $cache-set($key, password_hash($code . $salt, PASSWORD_DEFAULT), 300); // 校验时 $savedHash $cache-get($key); if ($savedHash password_verify($inputCode . $salt, $savedHash)) { // 验证通过后立即删除缓存防止重放 $cache-delete($key); }这样做的道理万一Redis被拖库或者说日志里意外打了日志明文验证码会直接泄露。哈希后至少验证码没有直接裸奔。有人觉得验证码有效期5分钟泄露就泄露了无所谓但安全这种东西多一层是一层成本几乎为零为什么不加呢。2.2 发送短信的HTTP调用封装用cURL发HTTPS请求的标准写法接下来是重头戏真正把验证码通过短信服务商发出去。这里我直接把最常用、最标准的POST JSON格式写法给你。目前绝大多数短信服务商都支持POST一个JSON对象到指定API地址参数包括手机号、模板ID、模板参数、签名等。下面是核心代码?php /** * 短信发送封装类 * 以最常见的POST JSON格式对接 */ class SmsSender { private string $apiUrl https://api.example.com/sms/send; private string $accessKey; private string $secretKey; private string $signName; private string $templateCode; public function __construct(array $config) { $this-apiUrl $config[apiUrl] ?? $this-apiUrl; $this-accessKey $config[accessKey]; $this-secretKey $config[secretKey]; $this-signName $config[signName]; $this-templateCode $config[templateCode]; } /** * 发送短信 * param string $mobile 手机号 * param array $templateParams 模板参数如 [code 123456] * return array 统一返回数组包含success、message、requestId */ public function send(string $mobile, array $templateParams): array { // 组装请求参数 $params [ accessKey $this-accessKey, mobile $mobile, signName $this-signName, templateCode $this-templateCode, templateParams json_encode($templateParams, JSON_UNESCAPED_UNICODE), timestamp time(), ]; // 生成签名 $params[sign] $this-generateSign($params); // 发送请求 $result $this-postJson($this-apiUrl, $params); return $this-parseResult($result); } /** * 生成签名不同服务商算法不同这里以最常见的MD5签名举例 */ private function generateSign(array $params): string { ksort($params); $str ; foreach ($params as $key $value) { $str . $key . . $value . ; } $str . key . $this-secretKey; return strtoupper(md5($str)); } /** * 使用cURL发送POST JSON请求 */ private function postJson(string $url, array $params): array { $ch curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($params, JSON_UNESCAPED_UNICODE)); curl_setopt($ch, CURLOPT_HTTPHEADER, [ Content-Type: application/json; charsetutf-8, ]); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 10); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); $response curl_exec($ch); $error curl_error($ch); curl_close($ch); if ($error) { return [ success false, message 请求短信服务失败: . $error, ]; } $data json_decode($response, true); if (json_last_error() ! JSON_ERROR_NONE) { return [ success false, message 短信服务返回数据格式异常, ]; } return $data; } /** * 解析服务商返回结果统一格式 */ private function parseResult(array $data): array { // 这里根据服务商实际返回结构解析 // 大多数服务商会返回code字段如200表示成功其他表示失败 $code $data[code] ?? ($data[status] ?? ); $message $data[message] ?? ($data[msg] ?? 未知错误); $requestId $data[requestId] ?? ($data[request_id] ?? ); $success $code 200 || $code 200 || $code OK; return [ success $success, message $message, requestId $requestId, ]; } }代码不长但有几个点我单独拎出来说因为都是实际坑过人的地方第一个坑是SSL证书校验。我在本地测试时候把CURLOPT_SSL_VERIFYPEER设为false是因为有些本机PHP环境没有配置完整的CA证书链不关掉就发不出去。但是上生产环境我强烈建议你把它改成true并配置好CA证书路径。生产环境里关闭SSL校验等于把请求裸奔在网络上虽然加了签名参数但你发给服务商的请求内容手机号、验证码全是可以被中间人看到的。正确做法是下载服务商文档里推荐的CA证书bundle或者直接用系统的CA证书文件。很多资料图省事直接写false你抄下来之后要记得在生产环境改掉。第二个坑是签名算法。各家服务商的签名算法五花八门有的MD5有的HMAC-SHA256有的要特定顺序拼接。我写的这个ksort之后按keyvalue拼接再md5的算法是很多服务商通用的做法。不同服务商的差别主要在拼接的格式和是否做URL编码。你在换服务商的时候最需要小心的就是这一块鉴权不过的话服务商会直接返回签名错误这是最常见的对接失败原因。第三个坑是请求超时。CURLOPT_TIMEOUT我设置成10秒CURLOPT_CONNECTTIMEOUT设置成5秒。短信接口是业务关键链路用户在上面等结果时间太长体验差。但也要注意不能太短比如2秒因为短信服务商处理请求通常需要1到3秒你设置2秒超时可能要不就请求还没发到对方服务器就被打断要不就是对方已成功发送但你没收到响应然后你这边显示失败用户重复提交造成重复发送成本白白浪费。2.3 业务层的完整发送流程把生成、存储、发送、限制串起来底层封装好了业务层要把流程串起来。我写业务层代码的习惯是一个方法对应一个场景方法之间不互相调用控制层只负责调用这个方法逻辑都在方法里。?php /** * 验证码业务处理器 */ class SmsCodeService { private SmsSender $smsSender; private CacheInterface $cache; private array $config; public function __construct(SmsSender $smsSender, CacheInterface $cache, array $config) { $this-smsSender $smsSender; $this-cache $cache; $this-config $config; } /** * 发送验证码 * param string $mobile 手机号 * param string $scene 场景标识如register、login、resetpwd * return array */ public function sendCode(string $mobile, string $scene): array { // 1. 校验手机号格式 if (!preg_match(/^1[3-9]\d{9}$/, $mobile)) { return [success false, message 手机号格式不正确]; } // 2. 场景白名单校验防止传任意值 $allowedScenes [register, login, resetpwd]; if (!in_array($scene, $allowedScenes, true)) { return [success false, message 场景参数非法]; } // 3. 频率限制同一手机号两次发送间隔至少60秒 $lastSendKey sms:lastsend: . $scene . : . $mobile; $lastSendTime $this-cache-get($lastSendKey); if ($lastSendTime (time() - $lastSendTime) 60) { return [success false, message 发送太频繁请稍后再试]; } // 4. 每日发送次数限制防止短信轰炸 $dayKey sms:daycount: . $scene . : . $mobile . : . date(Ymd); $dayCount (int) $this-cache-get($dayKey); if ($dayCount 10) { return [success false, message 今日发送次数已达上限]; } // 5. 生成验证码并存储 $code generateCode(6); $storeKey sms:code: . $scene . : . $mobile; $salt your_custom_static_salt; $this-cache-set($storeKey, password_hash($code . $salt, PASSWORD_DEFAULT), 300); // 6. 调用短信发送 $result $this-smsSender-send($mobile, [code $code]); // 7. 发送成功后才更新频率限制和当天计数 if ($result[success]) { $this-cache-set($lastSendKey, time(), 60); $this-cache-set($dayKey, $dayCount 1, 86400); } else { // 发送失败删除已存储的验证码防止用户用这个码或留着占用缓存 $this-cache-delete($storeKey); } return $result; } /** * 校验验证码 * param string $mobile 手机号 * param string $scene 场景标识 * param string $inputCode 用户输入的验证码 * return bool */ public function verifyCode(string $mobile, string $scene, string $inputCode): bool { if (empty($inputCode)) { return false; } $storeKey sms:code: . $scene . : . $mobile; $savedHash $this-cache-get($storeKey); if (!$savedHash) { return false; } $salt your_custom_static_salt; $ok password_verify($inputCode . $salt, $savedHash); // 无论对错只要校验过一次就删掉防止暴力循环 $this-cache-delete($storeKey); return $ok; } }这段代码有几个细节我想重点讲。发送成功后才更新频率限制和当天计数这个顺序很多人写反了。有人先把次数加一再调用短信接口结果短信接口因为服务商欠费或者签名错误挂了用户的发送次数却被扣了。用户反馈说我明明没收到短信你告诉我今天不能发这个体验非常差。正确顺序就是先发短信成功了再记录限制信息。还有一个细节是发送失败要把验证码缓存删掉。很多人不管发送结果先把验证码存进去。如果服务商返回失败那用户就根本收不到短信但这个验证码已经存在缓存里了。5分钟之内有人拿这个手机号和场景尝试假设他没有收到验证码但是乱填了一个因为缓存里有一个验证码他要猜对的概率就存在。虽然设置了次数限制但没必要增加这个风险面。发送失败就删掉重来逻辑干净。3. 实操过程对接服务商并跑通整个发送场景代码写得再漂亮真正去对接的时候你才会发现文档里没写全的细节才是真正卡你一天的东西。这个部分我从零开始带你走一遍实际对接的全流程所有该准备的信息、该测的点、该注意的事项我全摊开讲。3.1 准备阶段申请短信签名和模板的具体流程不管用哪家短信服务商你都要先完成三类信息的准备AccessKey和SecretKey有的叫AppID和AppSecret、短信签名、短信模板。短信签名就是用户收到短信时看到的那一对方括号里的名称如【某公司】。签名是要审核的审核内容是签名是否和你的应用或公司主体相关。如果是个人的项目签名可以起个跟产品相关的名字比如【某测试应用】里面包含产品名字审核一般能过。如果你是一个还没上线的应用签名审核时通常需要提供应用商店的截图、软著或者公司营业执照之类的证明材料这点我最开始对接的时候完全没料想到等审核被驳回才知道要准备这些。短信模板是短信正文的格式化版本里面会用${code}这样的占位符。比如您的验证码为${code}5分钟内有效。请勿泄露给他人。模板也需要审核主要看文案是否合规。这里有个经验模板里的变量名不要起太长也不要用中文变量名不同服务商对变量名格式要求不同但通用的规则是字母开头、数字组合。另外“请勿泄露”这类的安全提示词建议加上审核通过率会高也对用户负责。审核时间一般几分钟到一个工作日不等。所以对接的第一步永远是先申请签名和模板不要等代码写完了再去申请那样你会被审核流程硬生生卡住一整天。代码写得再快审核不通过你也发不出短信。3.2 联调测试模拟发送和真实发送各自怎么测拿到密钥、签名、模板之后先别急着写完整业务代码。先用最简单的单文件脚本验证连通性。?php // 联调测试脚本 require SmsSender.php; $config [ apiUrl https://api.example.com/sms/send, accessKey 你的AccessKey, secretKey 你的SecretKey, signName 【某测试应用】, templateCode SMS_123456789, ]; $sender new SmsSender($config); $result $sender-send(13800138000, [code 123456]); var_dump($result);这个脚本里的手机号一定不要用真实的手机号要用服务商提供的测试号码。大多数服务商都有专门用于测试的号码用测试号码发送时服务商不会真实下发短信但在控制台里能看到发送状态和请求日志这样就不用浪费真实的短信条数来测试。如果测试号码返回成功你再去用自己的手机号做一次真实发送。真实验证的时候重点不是看短信到没到而是记下这些信息从发出请求到手机收到短信中间耗时大概多少秒。超过10秒说明服务商的通道今天可能有问题要留意。收到的短信正文格式是否正常有没有出现乱码或者变量没替换的情况。短信签名是否显示正常有的服务商在测试阶段签名前缀处理不一样。我遇到过一次的情况是测试号码模拟发送全部正常真实发送也提示成功但手机收到的短信里变量没有被替换手机上直接显示“您的验证码为${code}5分钟内有效”。这个就是模板变量名错误或者提交参数名称对不上导致的服务商端不会校验变量是否真的被替换了只检查格式合法就受理了。所以真实发送测试时一定要看手机收到的消息内容别只看接口返回。3.3 生产注意事项密钥管理、日志记录、错误处理联调通过之后把代码集成到业务系统里这一步有几个生产环境特有的问题要处理。密钥管理这块不能偷懒。AccessKey和SecretKey不要直接写在PHP代码文件里。最基础的做法是放到配置文件里并且配置文件的路径不能跟着代码仓库一起提交要在部署环境里单独维护。更好的做法是用环境变量或专门的密钥管理服务来存储。这里说的不是矫情。代码仓库万一泄露就等于把短信接口的钥匙交出去了别人拿到你的密钥可以用你的签名去发垃圾短信最终账单算在你头上。真要出了这种事你不仅要联系服务商紧急禁用密钥还要去解释那些垃圾短信和你没关系流程非常折腾。所以密钥管理这块从一开始就要认真对待。日志记录要区分成功和失败。成功的日志记录什么记录手机号、场景、模板、requestId、耗时。失败日志记录什么记录手机号、场景、错误码、错误信息、服务商返回原文。这个日志的价值是后续排查问题用的比如用户投诉说没收到短信你查日志一看服务商返回的是“模板不存在”那就说明环境配置搞错了不是通道问题。我自己一般会为短信服务单独建一张日志表。字段包括id、mobile、scene、template_code、content、status、response_code、response_message、request_id、created_at。这张表不参与业务逻辑就纯查记录所以你不需要担心它拖慢主流程。错误处理要设计重试机制和降级方案。短信服务商偶尔会抽风接口返回5xx或者超时。碰到这种情况直接给用户返回“发送失败”让用户再点一次用户体验很差。常规做法是对网络层面的错误做一次重试间隔个1秒再请求一次但如果服务商明确返回了“签名错误”“模板错误”“余额不足”这类业务错误一定不要重试重试多少次都是失败纯浪费。还有一个小小的建议短信发送接口做一个简单的熔断。如果连续10次请求都是网络错误那大概率是服务商通道出问题了这时候直接拒绝后续发送请求返回“短信服务暂不可用请稍后再试”给服务商留点恢复时间也避免用户反复点发送把请求堆死。4. 常见问题与排查技巧短信验证码接口的疑难杂症这部分是实战中踩坑的精华汇总。我按问题现象来分类每个问题给出排查思路和解决方案方便你在遇到问题的时候直接对照着查。4.1 发送接口报错从签名鉴权到模板匹配的排查顺序短信接口对接初期大多数报错都集中在鉴权失败和模板参数错误这两类。我把典型的报错和对应原因整理成一个速查表。报错现象常见原因解决思路签名错误 / SignatureErrorSecretKey写错、签名算法拼接顺序不对、参数漏加重新核验密钥逐字符检查对照服务商文档检查签名拼接规则缺少参数 / MissingParameter提交的参数字段名与服务商要求不一致看服务商文档里要求的字段名注意大小写和下划线模板不存在templateCode填错、模板审核未通过在服务商控制台确认模板ID和审核状态变量不匹配模板里定义了${code}但提交的是param或code参数名不同确认提交的模板参数字段名和模板占位符一一对应手机号格式错误手机号带86前缀或包含空格发送前统一格式化服务商一般只接受纯11位手机号没有签名或签名未审批签名漏传、签名未通过审核在控制台查看签名审核状态排查这类问题有个顺序。先看服务商返回的错误码和错误信息不要只看结果。很多新手对接时只看successfalse就慌了不去看具体message。服务商的返回信息已经告诉你是哪一步错了你自己就不用瞎猜了。拿到具体的英文错误码之后去服务商文档里查对应的中文解释和解决方案比自己翻代码要快得多。4.2 验证码收不到的几种常见原因短信接口返回成功但用户就是收不到短信这是最让人头疼的一类问题。我按出现频率来排一下常见原因。第一种是手机号被运营商拦截。现在各大运营商对短信的拦截策略特别多含链接的短信基本会被拦截还有很多包含“验证码”关键词的短信会被某些手机系统自动收进“骚扰拦截”分类里。用户感知就是没收到。这个你没法从代码层面解决只能引导用户去拦截记录里找。第二种是模板含敏感词被审核通过但下发时受限。有些模板文案虽然审核过了但发送的时候运营商端还会做二次内容过滤比如“验证码”加“金额”加“链接”这种组合容易触发二次审核。处理方式就是让文案尽量简洁不要塞一堆营销词汇。第三种是服务商通道选择问题。有些服务商有多个通道不同通道对不同运营商的到达率不一样。你测试时用某运营商的手机号正常但用户用别的运营商收不到那就可能是通道的覆盖问题。这时候需要换通道或者让服务商排查。第四种是接口返回成功但上游实际失败。这种情况服务商侧会有一个状态回执异步通知到你的回调地址。生产环境一定要配置短信状态回调把最终的状态同步到日志表里。否则你永远只知道服务商受理了请求而不知道短信最终是不是真的下发成功了。4.3 验证码被刷或被爆破的应急处理方案短信接口上线后第一个盯上你的往往不是正儿八经的用户而是扫描接口的脚本。验证码接口一旦没有防护就会被拿去刷短信造成两种后果一是你账户里的短信余量被刷光产生费用二是你的签名被用户投诉多了服务商可能暂停你的发送权限。我给出的基础防护措施上线前必须全部做上同一手机号60秒内只能请求一次发送。同一手机号每天发送验证码的次数上限建议5到10次。同一IP每天发送验证码的次数上限建议20到50次。如果应用已登录可以要求用户30秒内不能重复发送。配合图形验证码或滑动验证前端点击发送验证码之前先完成一个人机验证这是目前防刷最有效的方式。脚本自己过不了图形验证码你再做频率限制双保险。对校验接口也就是用户提交验证码的接口要做尝试次数限制。我给一个建议同一个手机号同一个场景5分钟内最多尝试5次超过5次直接锁定让用户重新获取验证码。这个限制直接杜绝了暴力枚举的可能。就算你的验证码是6位100万种组合在没有次数限制的情况下最多试100万次也能试出来但有了5次限制爆破基本不可能。有一个细节是验证码校验接口要上频率限制不能只在发送接口上做。很多人防刷只防发送忘记了校验接口。攻击脚本绕过发送环节直接对校验接口做暴力碰撞也能把所有可能取值的验证码试一遍。即使验证码只有6位试错100万次也就不到一天的事没有限制就很危险。4.4 调试技巧如何快速定位问题到底出在哪一环我最后分享一个我自己定位短信问题的通用套路。整个短信链路是前端 → 后端接口 → 缓存 → 短信服务商 → 运营商 → 用户手机。任何一个环节出问题都表现为“用户没收到验证码”或“接口报错”。我的排查顺序是先看后端日志确认接口有没有收到请求、参数是什么。确认服务商返回的结果是成功还是具体的错误码。如果服务商返回成功看服务商控制台的发送状态回执确认是不是最终下发成功。如果最终下发成功引导用户查手机拦截记录。按这个顺序排查可以省掉大量时间。最怕的是你上来就怀疑代码写错了一行行读代码结果实际是服务商通道抽风或者手机拦截导致的。先确认问题在哪一层再动手解决比盲目改代码靠谱得多。5. 安全加固与代码健壮性不局限于“能跑”的写法网上很多示例代码到发送成功就完事了但实际撑起一个线上项目的验证码模块要做到位的东西还不少。我在这个部分把常见的安全加固策略和代码健壮性处理一起讲了。5.1 防止接口被恶意调用从IP到账号维度的立体防护前面第4.3节讲的频率限制是基础防护但仅有基础防护还不够。一个恶意脚本一天换无数个IP来刷你的接口你单纯限IP是防不住的这时要用一些更立体的策略。首先校验手机号对应的用户账号是否存在。比如注册场景如果你已经存在该手机号的账号可以直接拒绝发送验证码找回密码场景如果手机号不在库中也拒绝发送。这样做的目的是减少攻击面让脚本无法任意指定目标手机号来骚扰。其次对短信发送接口增加人机验证环节。目前比较成熟的做法是点击发送验证码前先完成图形滑块验证拿到一个凭证然后带着这个凭证去请求发送接口。后端校验凭证校验通过才放行。这一步能把纯脚本的攻击挡在门外能把99%的刷量直接拦住。再次是短信模板与场景的强绑定。比如找回密码场景发的是“找回密码验证码”模板如果脚本一直拿找回密码模板发验证码虽然每次都成功但一天下来也会有很多用户被骚扰。对这种场景还要限制每个手机号每小时最多请求一次每天最多三次更严格。5.2 代码健壮性细节防止并发、超时、异常拖垮主流程并发问题也是短信模块容易踩的坑。最常见的是并发重复请求用户手快连点了两下发送按钮两个请求同时到达后端。两个请求都通过了频率校验都生成了验证码都发了短信最后的缓存里存的是第二个验证码。用户收到两条短信第一条作废。这种体验只能算一般但更严重的是如果两条短信都计费重复的成本是会累积的。解决方式很简单用Redis的原子操作做互斥。发送前设置一个带过期时间的键只有设置成功的那个请求才能继续其他请求直接返回“操作太频繁”。$lockKey sms:lock: . $scene . : . $mobile; $lockOk $this-cache-set($lockKey, 1, 5); // 5秒过期防止死锁 if (!$lockOk) { return [success false, message 操作太频繁请稍后再试]; }这里关键在于set操作要带NX参数Redis的SET if Not eXists保证同时只有一个请求能设置成功。如果你先get再set两个请求都读到不存在就都能设置成功锁就失效了。异常处理方面我给一个建议把短信发送统一放进try-catch里任何异常都不能让主业务流程崩掉。用户注册的时候如果短信服务商超时导致异常抛出去用户注册页直接变成500错误这是不能接受的。正确做法是捕获所有异常返回一个业务上的失败提示让用户重试不要暴露错误堆栈。5.3 测试验证码与上线切换环境隔离的写法开发环境和测试环境如果共用一套短信配置后果就是你会在凌晨收到一堆真实的短信验证码再浪费掉一堆短信费用。我建议在配置里增加一个isMock开关开发环境和测试环境设为true此时不发真实短信而是把验证码记录到日志里前端直接看日志拿到验证码进行测试。// 在发送前判断 if ($this-config[isMock] ?? false) { // 模拟模式不调用短信服务商把验证码打到日志里 error_log([MOCK SMS] . $mobile . code . $code); return [success true, message 模拟发送成功(开发模式)]; } // 正式模式才走真实发送 $result $this-smsSender-send($mobile, [code $code]);这套写法的好处是开发和测试阶段根本不需要真实短信条数也不需要等服务商审核模板就能把整个流程调通。而且上线的时候只需要把配置里的isMock改成false其他代码一行不用动。我每次做新项目都用这套省掉了大量测试短信的成本和时间。注意模拟模式下日志里会记录验证码明文。日志文件的权限要严格设置不能让其他开发人员之外的权限直接读取。最好只在开发机器上开启测试环境的日志也要定期清理。6. 几个我实际用下来的建议与体会代码部分讲完了说点没有代码、但比代码更影响你使用体验的经验。短信验证码这东西我最大的体会是代码本身不是瓶颈瓶颈全在服务商接入细节和防护策略上。你在网上随便搜的示例大概率只是演示了发送这一步但生产环境要能扛住真实流量需要把频率限制、人机验证、日志监控、熔断全部做上。很多项目前期不做防护等被人刷了一波短信账单之后再来补花的钱远比一开始就写完防护要多。关于服务商的选择我给一个建议优先看三样一是通道到达率这个可以问客服要近期统计数据二是API文档质量文档里如果连完整的请求响应示例都没有对接起来会很痛苦三是处理售后工单的速度短信这种业务出问题的时候很紧急比如用户集中反映收不到你的服务商要能快速响应排查。价格其实各家差距不大没必要为了几分钱选一个通道明显不稳的。关于调试和排查我再说一个建议坚决在前端把发送按钮做成倒计时60秒并在后端用缓存强制保证两次发送间隔不少于60秒。双重限制之下就算前端被绕过后端依然兜底。这个兜底逻辑是必须的因为接口可以被任何人直接调用前端限制只是体验优化后端限制才是安全底线。另外一个容易被忽略的点短信验证码的文案里一定要带应用名。短信是直接发到用户手机上的用户不一定记得你产品叫什么在内容里带上应用名用户看到“某应用提醒您您的验证码为123456”才会确认这是正规产品发的短信避免被当作垃圾短信举报也能降低误拦截概率。最后我个人在项目中的做法是把所有短信发送的配置项做成环境变量并且后端逻辑里不硬编码任何模板相关的文案内容。这样每次换服务商或者同一个项目对接多套短信配置时代码一行都不改只有配置文件在变。我从第一次对接短信到现在累计换过三家服务商每次都是只改配置、不动代码这就验证了这套封装结构的合理性。这篇文章就是围绕“最常见验证码发送写法”展开的。如果你正准备在项目里接入短信验证码照着这套思路去写从生成验证码、存缓存、调服务商发送到校验和防刷一层层做上去你这个模块上线后基本可以做到不用再操心。