资讯动态

ThinkPHP阿里云短信验证码:缓存存储与校验全流程指南

发布时间:2026/9/16 13:30:53 来源:尧图企业网站定制
简介一份基于ThinkPHP框架集成阿里云短信服务的完整示例工程面向需要在网站或App中快速实现手机号验证码登录、注册、找回密码等场景的PHP开发者。资源将验证码生成、阿里云SDK调用、验证码缓存存储与校验封装为可直接改配置复用的代码开发者只需替换AccessKey、短信签名及模板CODE即可接入生产环境。压缩包共120个文件以101个php源码文件为主辅以md说明文档、composer.json依赖配置、代码风格与Git忽略文件等整体仅127KB结构轻量、便于二次开发与阅读。已有338人学习使用。通过这份资源读者可获得一套清晰的ThinkPHP短信验证码实现思路包括SDK安装与调用方式、缓存有效期与单次有效校验的写法以及接口安全防护的注意事项尤其适合希望用最小成本完成短信验证功能的初中级开发者。1. 真正能在 thinkphp 里跑通的阿里云短信验证码发完不是终点短信验证码最容易翻车的不是阿里云接口调不通而是用户提交时读不到刚存进去的缓存。标题里这句“存储到缓存中完成手机号验证”正是正确写法的关键阿里云负责发码ThinkPHP 缓存负责暂存提交时从缓存比对。不建短信记录表不维护发送状态机改配置参数就能换签名和环境。这套方案适合注册、登录验证、找回密码、换绑手机号等所有“证明手机号是你自己的”场景。对 TP3.2 老项目同样友好阿里云短信 SDK 不依赖 ThinkPHP验证码缓存也只是S()和cache()的写法差异。即使项目跑在兼容 PHP8 的 TP3.2 分支短信服务类也可以独立工作。下文按配置参数、短信发送、缓存校验、上线排错四步拆开。前两步解决“发得出去”后两步解决“验得过”。2. 先把阿里云短信配置参数和 ThinkPHP 缓存策略分开设计配置参数是这条链路的起点也是标题里“非常简单”的底气。阿里云短信和 ThinkPHP 缓存可以完全不耦合签名、模板、密钥收进配置文件服务类只按数组读取验证码写缓存时键名、有效期、场景前缀也先约定好。否则等代码写完再找参数就会变成满仓库替换签名和模板编号。2.1 阿里云短信签名、模板和 AccessKey 是三个前置配置参数开通阿里云短信服务并完成实名认证后需要准备三样东西AccessKey、短信签名、短信模板。签名和模板都要在阿里云控制台申请并等待审核模板里带一个变量比如您的验证码为${code}5分钟内有效模板变量名要和代码里 JSON 的键保持一致。参数说明示例值access_key_idRAM 用户的 AccessKey IDLTAI5tXXXXXXXXXXXXXXXXXaccess_key_secret与 ID 配对的 Secret创建时只显示一次sign_name已审核的短信签名智简科技template_code已审核的短信模板编号SMS_123456789endpoint短信服务 API 网关地址dysmsapi.aliyuncs.comAccessKey 不要用主账号的建议在 RAM 里单独建一个用户只授予AliyunDysmsFullAccess。短信签名和模板审核通常需要一点时间项目排期时最好提前申请别等联调那天才提交。提示endpoint一般不需要按服务器地域改阿里云短信走统一网关dysmsapi.aliyuncs.com。网上有些教程让你改成dysmsapi.ap-southeast-1.aliyuncs.com那是国际站或特殊场景才需要。2.2 ThinkPHP 配置文件里的短信参数怎么排布在 ThinkPHP 5/6 中新建config/sms.php在 TP3.2 中放在Application/Common/Conf/sms.php。所有短信相关参数集中到一个数组业务代码里只读配置不关心参数来源。// ThinkPHP 5/6 放在 config/sms.php // ThinkPHP 3.2 放在 Application/Common/Conf/sms.php return [ sms [ access_key_id env(SMS_ACCESS_KEY_ID, ), access_key_secret env(SMS_ACCESS_KEY_SECRET, ), sign_name 智简科技, template_code SMS_123456789, endpoint dysmsapi.aliyuncs.com, // 验证码业务参数 code_length 6, code_expire 300, send_interval 60, send_daily_limit 10, scene_list [register, login, bind_mobile], ], ];读取方式也很直接TP5/6 用config(sms)TP3.2 用C(sms)。code_length控制验证码位数code_expire是缓存过期秒数send_interval是同手机号同场景的最小重发间隔send_daily_limit是当天发送上限。这些值放在配置里运营要调整时不用翻控制器代码。如果项目有测试环境和生产环境AccessKey 不要直接写死在文件里。TP5/6 可以用env()读取.envTP3.2 则建议放到服务器环境变量或独立配置文件里避免把密钥带进代码仓库。2.3 验证码缓存键名与有效期把手机号加上业务场景前缀同一个手机号可能同时走注册、登录、换绑三个流程缓存 key 如果只放手机号会出现“注册验证码被登录接口校验掉”的问题。所以 key 必须带业务场景前缀。缓存键值有效期sms_code:register:13800138000412356300 秒sms_code_send:register:13800138000171111111160 秒sms_code_daily:20260506:register:13800138000386400 秒冒号分隔是为了可读也方便在 Redis 里按前缀统计。真正出问题的往往是前后两端没用同一个 key发送时写sms_code:reg:手机号校验时读sms_code:register:手机号那神仙也验不过。验证码有效期建议控制在 300 秒左右不要图方便设成 30 分钟。验证码本就是低时效凭证窗口越长截图转发被滥用的风险越高。业务侧需要记住code_expire不只是短信模板文案里的“5分钟”它同时决定缓存里的 key 何时自动失效。多台应用服务器负载均衡时缓存类型必须从文件换成 Redis。文件缓存只存在本机用户第一次请求落在 A 节点提交验证码落在 B 节点就读不到 A 节点写入的验证码。TP5/6 的config/cache.php里把default改成 redis并设置独立前缀default env(cache.driver, file), stores [ redis [ type redis, host 127.0.0.1, port 6379, password , prefix sms_, ], ],3. 用阿里云短信 SDK 发送验证码SmsService 类与返回码判断参数准备好以后发送部分不需要自己拼 HTTP 签名。阿里云官方 SDK 已经处理好签名、请求序列化和异常结构安装后直接调接口即可这样也避免把 RPC 签名算法写错导致线上SignatureDoesNotMatch。3.1 安装 alibabacloud/dysmsapi-20170525 并处理 TP3.2 的自动加载在 ThinkPHP 项目根目录执行composer require alibabacloud/dysmsapi-20170525这个包是阿里云短信服务的官方 SDK当前主版本对 PHP 版本有要求尽量在 PHP 7.2 及以上环境使用。TP5/6 的入口已经自动加载vendor/autoload.php不需要额外处理。TP3.2 老项目如果原本没接 Composer需要在Public/index.php或框架入口早期手动加载require_once dirname(__DIR__) . /vendor/autoload.php;如果 TP3.2 项目不使用命名空间也可以把下面的SmsService类去掉namespace放到Application/Common/Service/SmsService.class.php然后手动require。SDK 本身的命名空间不受影响Composer 会自动加载。3.2 SmsService发送验证码、写缓存、校验验证码在一个类里完成实际开发中我习惯把发送和校验放在同一个服务类里。这样控制器只需要知道“发验证码”和“验验证码”两个动作不需要知道缓存 key 长什么样。?php namespace app\common\service; use AlibabaCloud\SDK\Dysmsapi\V20170525\Dysmsapi; use AlibabaCloud\SDK\Dysmsapi\V20170525\Models\SendSmsRequest; use AlibabaCloud\Tea\Config; use AlibabaCloud\Tea\Utils\Utils\RuntimeOptions; class SmsService { protected $config; public function __construct(array $config) { $this-config $config; } public function sendVerifyCode(string $mobile, string $scene register): array { // 60 秒内同场景不能重发 $lockKey sms_code_send:{$scene}:{$mobile}; if ($this-hasCache($lockKey)) { return [code REPEAT_SEND, message 发送太频繁请稍后再试]; } $code $this-buildCode(); $result $this-sendSms($mobile, [code $code]); if ($result[code] ! OK) { return $result; } // 发送成功后才写缓存验证码才有效 $this-setCache(sms_code:{$scene}:{$mobile}, $code, $this-config[code_expire]); // 重发锁防止短信轰炸 $this-setCache($lockKey, time(), $this-config[send_interval]); return [code OK, request_id $result[request_id]]; } public function verifyCode(string $mobile, string $inputCode, string $scene register): bool { $key sms_code:{$scene}:{$mobile}; $storedCode $this-getCache($key); if ($storedCode null || $storedCode ) { return false; } // 用 hash_equals 做长度恒定的比较避免时序攻击 if (!hash_equals((string) $storedCode, (string) $inputCode)) { return false; } // 验证码一次性使用校验通过立即删除 $this-deleteCache($key); $this-deleteCache(sms_code_send:{$scene}:{$mobile}); return true; } protected function hasCache(string $key): bool { return function_exists(cache) ? cache($key) ! null : S($key) ! false; } protected function getCache(string $key) { return function_exists(cache) ? cache($key) : S($key); } protected function setCache(string $key, string $value, int $expire): void { if (function_exists(cache)) { cache($key, $value, $expire); } else { S($key, $value, $expire); } } protected function deleteCache(string $key): void { if (function_exists(cache)) { cache($key, null); } else { S($key, null); } } protected function buildCode(): string { $code ; for ($i 0; $i (int) $this-config[code_length]; $i) { $code . random_int(0, 9); } return $code; } protected function sendSms(string $mobile, array $params): array { $client new Dysmsapi(new Config([ accessKeyId $this-config[access_key_id], accessKeySecret $this-config[access_key_secret], endpoint $this-config[endpoint] ?? dysmsapi.aliyuncs.com, ])); $request new SendSmsRequest([ phoneNumbers $mobile, signName $this-config[sign_name], templateCode $this-config[template_code], templateParam json_encode($params, JSON_UNESCAPED_UNICODE), ]); $response $client-sendSmsWithOptions($request, new RuntimeOptions()); $body $response-body; return [ code $body-code, message $body-message, request_id $body-requestId, biz_id $body-bizId, ]; } }这段代码里兼容了 TP3.2 和 TP5/6 的缓存差异。function_exists(cache)为 true 时走 ThinkPHP 5/6 的全局缓存函数否则走 TP3.2 的S()。如果项目只用一个框架版本可以把四层缓存封装简化成Cache::或直接S()调用。控制器用起来很短$sms new SmsService(config(sms)); $result $sms-sendVerifyCode(13800138000, register); if ($result[code] OK) { return json([message 验证码已发送]); } return json($result, 400);TP3.2 里把config(sms)换成C(sms)返回 JSON 用ajaxReturn即可。模板变量名也要匹配如果阿里云模板变量是${code}templateParam里的键就是code换成${number}就需要把数组键写成number。3.3 阿里云短信返回 Code 判断表不是 HTTP 200 就等于成功SDK 调用成功后HTTP 层面通常都是 200真正能反映发送状态的是响应体里的code。只判 HTTP 状态码等于没做判断必须看Code是否为OK。返回 Code含义处理建议OK发送成功把验证码写进缓存并提示用户isv.MOBILE_NUMBER_ILLEGAL手机号格式不正确后端用/^1[3-9]\d{9}$/校验isv.BUSINESS_LIMIT_CONTROL触发阿里云发送频控提示用户稍后再试isv.SMS_SIGNATURE_ILLEGAL签名不存在或未审核检查配置里的 sign_nameisv.SMS_TEMPLATE_ILLEGAL模板不存在或未审核检查 template_codeSignatureDoesNotMatchAccessKey 或签名串不正确检查密钥、服务器时钟InvalidTimeStamp.Expired服务器时间偏差过大NTP 同步服务器时间有一个很容易犯的错先写缓存再调发送接口。如果发送失败但缓存已写用户输入验证码时永远校验失败还会以为是缓存过期。正确顺序是先把发送结果确认成OK再执行setCache上面的sendVerifyCode就是这个顺序。提示isv.BUSINESS_LIMIT_CONTROL不一定是恶意请求也可能是模板类型和场景不匹配比如同一模板在短时间内发给同一个号码的次数超过阈值。先查阿里云控制台消息记录再决定要不要放宽自己的频控参数。4. 手机号验证靠缓存读写ThinkPHP 3.2 的 S() 与 TP5/6 的 cache()发送完成不代表验证码流程结束。手机号验证的本质是“从缓存里读出刚才存的值和用户输入做比较”。这个环节的难点不是比较本身而是不同 ThinkPHP 版本的缓存函数差异以及缓存 key 的一致性。4.1 S() 和 cache() 的对应关系键名、有效期、删除方式TP3.2 使用的S()和 TP5/6 的cache()在参数顺序和删除方式上都有区别。老项目升级或迁移时最容易踩坑的是删除TP3.2 用S($key, null)TP5/6 用cache($key, null)。操作ThinkPHP 3.2ThinkPHP 5/6写缓存S($key, $value, $expire)cache($key, $value, $expire)读缓存S($key)cache($key)删除缓存S($key, null)cache($key, null)判断是否存在S($key) ! falsecache($key) ! null// TP3.2 S(sms_code:{$scene}:{$mobile}, $code, 300); $stored S(sms_code:{$scene}:{$mobile}); S(sms_code:{$scene}:{$mobile}, null);// TP5/6 cache(sms_code:{$scene}:{$mobile}, $code, 300); $stored cache(sms_code:{$scene}:{$mobile}); cache(sms_code:{$scene}:{$mobile}, null);这里有个细节写缓存时传入的$expire是相对秒数不是绝对时间戳。cache($key, $value, 300)表示 300 秒后过期而不是到某个具体时间点。如果需要精确控制到时分秒可以把过期时间改成时间戳但会依赖底层缓存驱动对时间类型 TTL 的支持。4.2 提交手机号验证码后从缓存读值比对成功再删除校验逻辑单独拆开看就是三步读缓存、比较、删除。上面的SmsService里已经有完整实现这里再把核心部分提出来说明。public function verifyCode(string $mobile, string $inputCode, string $scene register): bool { $key sms_code:{$scene}:{$mobile}; $storedCode cache($key); if ($storedCode null || $storedCode ) { return false; } if (!hash_equals((string) $storedCode, (string) $inputCode)) { return false; } cache($key, null); return true; }比较时用hash_equals而不是是因为会做类型转换123456 123456可能为 true在某些边界情况下还会让攻击者通过时间差异猜验证码。hash_equals两个参数都转成字符串后按字节比较安全性更好。验证码必须是“一次性凭证”。校验通过后立刻删 key否则同一个验证码可以被重复提交。如果业务流程是“校验验证码 → 下一步修改手机号”建议在校验通过后马上删除缓存并在会话或请求里写入一个临时 token 表示“已验证”不要继续依赖缓存里的验证码。4.3 校验失败时保留缓存但记录次数防止暴力猜码一次性删除是最简单的策略但如果每次输错都删用户就真的只有一次机会。实际产品中常见做法是验证码允许错 4 次第 5 次才作废。$failKey sms_code_fail:{$scene}:{$mobile}; $failCount (int) cache($failKey) 1; if ($failCount 5) { cache(sms_code:{$scene}:{$mobile}, null); cache($failKey, null); return false; } cache($failKey, $failCount, 600); return [error 验证码错误, retry 5 - $failCount];这段逻辑说明两件事验证码缓存和失败次数缓存是两个 key失败次数有效期设 600 秒防止攻击者反复试到缓存过期。每次用户重新获取验证码时记得同时清掉失败次数 key否则上一轮输错的记录会带到下一轮。单机低并发下这种先读后写的计数会有一点点竞态风险但对短信验证码这种低频接口影响基本可以忽略。如果真要做高并发防刷失败次数应该用 Redis 的INCR和EXPIRE原子操作而不是get set。5. 上线前调好这组参数60 秒重发限制与缓存命中排错生产环境里验证码出问题大多数不是代码逻辑而是配置参数没有按线上环境调。这里把必调的参数和排查顺序固定下来。参数推荐值只管什么code_expire300 秒验证码从发送到失效的窗口send_interval60 秒同一手机号同一场景的最小重发间隔send_daily_limit10 次同一手机号当天的发送上限cache.driverredis多实例下验证码能否共享send_daily_limit需要自己在发送前判断。在sendVerifyCode里加上这个逻辑比依赖阿里云频控更直观$dailyKey sms_code_daily: . date(Ymd) . :{$scene}:{$mobile}; $dailyCount (int) $this-getCache($dailyKey); if ($dailyCount $this-config[send_daily_limit]) { return [code DAILY_LIMIT, message 今日发送次数已用完]; } $this-setCache($dailyKey, $dailyCount 1, 86400);key 里带date(Ymd)即使缓存没有在零点准时失效第二天也会自动换一个新 key不会出现“昨天发满 10 次今天继续锁死”的问题。如果出现“阿里云返回 OK但验证码一直校验失败”的情况按这个顺序排查先看服务端返回的code是否为OKOK不代表手机一定能收到短信只代表阿里云已受理然后在校验接口里临时打印缓存// 临时定位用排查完删掉 dump(cache(sms_code:{$scene}: . input(mobile)));打印结果为null时基本可以确定缓存 key 不一致或已过期。检查发送和校验两处是否用了同一个$scene比如发送时传register校验时传reg就会永远读不到。再检查缓存驱动是file还是redis文件缓存在多台服务器负载均衡环境下必然命不中。还有一个小坑线上排查时不要顺手执行php think clear。这个命令会把所有缓存清掉正在等待验证码的用户全部失效而且误伤其他业务缓存。改配置、改代码、清缓存建议挑低峰期操作。最后再检查一遍服务类里校验成功后有没有删 key。验证码必须是一次性的没删 key就把它当成一个显式的定时炸弹处理缩短有效期、加失败次数上限、加 60 秒重发锁。到这一步这套 ThinkPHP 阿里云短信验证码方案才算真正接住了线上流量。本文还有配套的精品资源点击获取

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

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

免费获取报价