sendSMS接口必须实施服务端强校验,采用Redis原子操作(Lua脚本封装INCR+EXPIRE+GET),实现手机号60秒限1次、IP每分钟限5次并动态黑名单(超20次封1小时),图形验证码作为风控触发的保底机制,严禁依赖前端限制或非原子缓存。

sendSMS 接口不加限流,等于把短信通道直接暴露给黑产工具——不是“会不会被刷”,而是“什么时候被刷爆”。必须用服务端强校验+原子计数+多维维度控制,前端倒计时和 disabled 只是障眼法,绕过成本几乎为零。
同一手机号 60 秒内只允许发 1 条
这是防手动重试的底线。不能只靠 $_SESSION 或本地文件,必须走 Redis 原子操作:
-
INCR+EXPIRE必须包裹在 Lua 脚本里执行,否则存在竞态(比如两个并发请求同时读到 0,都通过) - 键名别用
"sms:mobile:{$mobile}"这种裸格式——Redis 驱动下冒号开头会导致KEYS sms:*批量失效;推荐"sms_mobile_{$mobile}" - TTL 设 60 秒,但注意:如果用户第 59 秒又点一次,要拒绝,而不是覆盖重置;所以得先
GET再判断,不能只依赖INCR
同一 IP 每分钟最多 5 次,超 20 次进黑名单
IP 限制针对的是批量轰炸机,但要注意真实场景:
- 运营商 NAT 会让几百人共用一个出口 IP,所以不能单靠 IP 封禁;建议只限频次,不永久拉黑
- 用
INCRBY统计次数,配合EXPIRE设 60 秒 TTL,但得在每次INCRBY后立刻GET当前值做判断——因为INCRBY返回的是新值,而你可能需要区分“刚到第 5 次”和“已是第 6 次” - 黑名单用单独 key,如
"sms_ip_blacklist:{$ip}",设 TTL 1 小时,避免误伤
Redis Lua 脚本必须封装成原子校验
PHP 里拼 INCR + GET + EXPIRE 多条命令,本质是三个网络往返+三次 PHP 判断,中间任何一步失败或并发都会崩。正确姿势是:
立即学习“PHP免费学习笔记(深入)”;
local mobileKey = KEYS[1]
local ipKey = KEYS[2]
local blackKey = KEYS[3]
local mobileLimit = tonumber(ARGV[1])
local ipLimit = tonumber(ARGV[2])
local blackThreshold = tonumber(ARGV[3])
<p>if redis.call("GET", blackKey) then
return 0 -- 黑名单拒绝
end</p><p>local mobileCount = redis.call("GET", mobileKey)
if mobileCount and tonumber(mobileCount) >= 1 then
return -1 -- 手机号已发过
end</p><p>local ipCount = redis.call("INCR", ipKey)
if ipCount == 1 then
redis.call("EXPIRE", ipKey, 60)
end</p><p>if ipCount > ipLimit then
redis.call("SET", blackKey, "1")
redis.call("EXPIRE", blackKey, 3600)
return 0
end</p><p>redis.call("SET", mobileKey, "1")
redis.call("EXPIRE", mobileKey, 60)
return 1调用时传入 [$mobileKey, $ipKey, $blackKey, 1, 5, 20] 即可,返回 1 才真正放行。
图形验证码不是可选项,而是保底开关
当某 IP 或手机号触发阈值(比如 3 分钟内请求超 8 次),就强制下一请求必须带有效图形验证码 token。关键点:
- token 必须绑定生成时的
$_SERVER['REMOTE_ADDR']和 UA 片段,防止 token 被复用 - 验证通过后,token 立即失效(
DEL),且只允许一次使用 - 不要在所有请求上无差别加图形验证码——影响转化率;只在风控模型判定“可疑”时触发
实际部署时最容易漏掉的是时区和缓存驱动:PHP 时区没设 Asia/Shanghai,阿里云签名会报 InvalidTimeStamp.Expired;用 File 缓存驱动做限流,在多实例部署下完全失效。这些细节比逻辑本身更致命。



















