☰
基于Spring Boot的短信验证码Demo:从签名到频控的完整接入实践
2026/10/16 6:42:41 网站建设 项目流程

简介:阿里云短信接口demo.zip 是一份面向 PHP 开发者的短信服务集成示例,帮助快速掌握阿里云短信 API 的调用流程。压缩包体积 3.35MB,文件以 PHP 源码为主,包含 SDK 核心库、示例脚本及必要的配置文件,可配合官方文档完成短信发送、状态查询等基础功能。已有 274 人浏览学习。示例覆盖了从 AccessKey 配置、创建 SDK 实例、调用 SendSms 发送短信,到短信模板变量替换、签名使用及异常处理等完整环节;同时还整理了同步与异步调用方式、错误码定位和 HTTPS 安全传输等要点,便于开发者直接参考改造。无论是实现验证码登录、通知提醒还是营销消息,这份 demo 都能帮助 PHP 项目快速接入阿里云短信服务,减少接口联调与排错成本。此外还给出了频控与重试等最佳实践建议,帮助规避常见发送问题,适合作为项目上线前的联调验证参考。

1. 短信接口远不止"调个API"那么简单:这个Demo包里有什么

一个短信验证码Demo,看起来就是把手机号传进去、把验证码发出去、再校验一下,能有多复杂?真等你上手做一遍才发现,从签名申请、模板审核、参数序列化,到错误码映射、频控限流、密钥安全,每一个环节都能让你卡上好几天。这份短信接口Demo要解决的就是这么个问题:把一群调用短信服务最常见的坑提前踩平,给你一条从零到发送成功的直通路。它适合准备在自己的新项目里接入短信验证码、通知推送的开发者,尤其是那种要在一到两天内把功能落地、不想在对接环节反复折腾的人。

2. 技术栈与工程结构:一次短信请求的完整链路

2.1 技术选型:为什么是Spring Boot + 轻量SDK

这个Demo用的是Java技术栈,构建在Spring Boot之上。你打开压缩包后会看到标准的Maven工程结构,pom.xml里只引了必要的依赖——Web、Lombok、Validation,还有一个短信服务商的官方SDK。没有引入消息队列、Redis这些重组件,原因是Demo的定位是"最短路径跑通",依赖多了反而干扰你把注意力放在短信本身的调用链路上。

我一般会建议初上手的人参考这个依赖取舍逻辑:先跑通再加重。短信发送本质是一个HTTP调用,在Spring Boot里用官方SDK封装好签名、超时重试之后,业务代码只需要面对一个smsService.sendCode(phone)方法。这份Demo就是把这一层封装做给你看,你拿到手后即使不用它的Controller,单抽出Service层也能直接嵌进自己的项目。

2.2 目录结构:每个包都在管什么

解压后你会看到这样一个包结构,我按职责分了层:

src/main/java ├── config │ └── SmsConfig.java // 读取配置文件,初始化客户端 ├── controller │ └── SmsController.java // HTTP入口,接收发送/校验请求 ├── service │ ├── SmsService.java // 业务接口:发送验证码、校验验证码 │ └── impl │ └── SmsServiceImpl.java // 核心业务逻辑,调用客户端 ├── common │ ├── Result.java // 统一返回结构 │ ├── ErrorCode.java // 错误码枚举 │ └── BizException.java // 业务异常 └── util └── CodeGenerator.java // 验证码生成工具

这个分层思路对应到你自己项目里同样适用:Controller只做参数接收和结果包装,不碰任何短信逻辑;Service层承载业务规则(生成验证码、调用发送、校验);config只负责组装外部客户端。好处是当你想把短信从SmsService换成其他服务商实现时,只需要改config和SmsServiceImpl内部逻辑,暴露给Controller的接口签名可以完全不变。

配置文件在src/main/resources/application.yml里,占位符放好了服务商的应用标识、密钥、签名名称和模板编码。第一次运行前你只需要把这些值替换成自己在短信平台后台申请到的真实值,不需要改动任何代码。

2.3 配置加载链路:密钥为什么不能写死在代码里

很多翻车案例的起点都是把应用密钥直接写死在Service类里,图一时方便,后面不是在换环境时改到崩溃,就是不小心把代码推到公开仓库导致密钥泄露。这份Demo把密钥全部挪到了application.yml,并配合SmsConfig做一个配置绑定:

@Configuration @ConfigurationProperties(prefix = "sms.provider") public class SmsConfig { private String accessKeyId; private String accessKeySecret; private String signName; private String templateCode; private String endpoint; // 省略 getter/setter }

@ConfigurationProperties会从配置文件里按前缀sms.provider自动映射字段,省掉手写@Value注解的繁琐。这样配置和代码分离之后,你在本地环境、测试环境、生产环境之间切换只需要维护不同的配置文件,代码本身不用动。密钥也不会散落在业务代码各处,审计和轮换都方便。

顺带说一句,accessKeySecret这种敏感信息在真实项目里不应该以明文形式放在application.yml里。常见的做法是用环境变量或者配置中心去覆盖占位符,比如access-key-secret: ${SMS_SECRET},让密钥从环境变量注入,这份Demo里留的是明文占位符,是为了让你本地快速跑通,上线前务必改掉。

3. 核心代码拆解:验证码短信从签名到校验的完整走读

3.1 客户端初始化与签名机制

短信服务商的OpenAPI要求每个请求带上签名,用来验证调用者身份。签名的计算规则大同小异:把请求参数按字典序排序、拼接、用AccessKeySecret做HMAC加密,最后Base64编码放进请求头。这些底层的实现细节官方SDK已经帮你处理好了,你要做的只是初始化一个客户端对象。

@Component public class SmsClientHolder { private final SmsConfig smsConfig; private volatile SmsClient client; public SmsClientHolder(SmsConfig smsConfig) { this.smsConfig = smsConfig; } public SmsClient getClient() { if (client == null) { synchronized (this) { if (client == null) { client = SmsClient.builder() .accessKeyId(smsConfig.getAccessKeyId()) .accessKeySecret(smsConfig.getAccessKeySecret()) .endpoint(smsConfig.getEndpoint()) .build(); } } } return client; } }

这里用了一个双重检查锁的单例模式,避免每个请求都重新new一个客户端。短信客户端内部持有连接池和HTTP连接复用,所以全局只有一个实例是最优解。如果你的项目里用了Spring容器管理Bean,把SmsClient直接声明成一个@Bean交给容器管理更省心,但需要注意确保它是单例的,不要误配成@Scope("prototype")。

endpoint的值对应短信服务的地域接入地址,不同地域的域名不一样。Demo里默认配的是公共接入地址,如果你所在网络环境访问这个地址延迟较高,可以换成离自己更近的地域节点。这属于调优项,本地跑通阶段用默认配置就行。

3.2 发送验证码的完整代码走读

发送短信是整个Demo最核心的一个动作。从Controller层接收请求,到Service层组织参数并调用客户端,再到解析返回结果,整个链路的代码顺着往下读就能建立直觉。

@RestController @RequestMapping("/api/sms") public class SmsController { private final SmsService smsService; public SmsController(SmsService smsService) { this.smsService = smsService; } @PostMapping("/send") public Result<String> sendCode(@RequestBody SendCodeRequest request) { if (!Pattern.matches("^1\\d{10}$", request.getPhone())) { return Result.fail(ErrorCode.PHONE_FORMAT_ERROR); } smsService.sendVerificationCode(request.getPhone()); return Result.success("验证码已发送,请注意查收"); } }

Controller的职责很纯粹:校验手机号格式,然后交给Service。手机号正则这里用的是^1\d{10}$,只校验了位数和首位数,没有做号段级别的严格校验,因为号段会不断更新,写死反而容易误伤新号段。如果你需要更严格的校验,可以调第三方号码库,但是一个短信验证码场景下,运营商会在发送端做二次校验,格式校验做到这一步够了。

@Override public void sendVerificationCode(String phone) { String code = CodeGenerator.generate(6); SmsSendRequest sendRequest = new SmsSendRequest(); sendRequest.setPhone(phone); sendRequest.setSignName(smsConfig.getSignName()); sendRequest.setTemplateCode(smsConfig.getTemplateCode()); // 模板参数必须是JSON字符串,而不是直接传Map sendRequest.setTemplateParam("{\"code\":\"" + code + "\"}"); SmsSendResponse response = smsClientHolder.getClient().send(sendRequest); if (response.isSuccess()) { verificationCodeStore.save(phone, code, Duration.ofMinutes(5)); } else { throw new BizException(ErrorCode.mapFrom(response.getCode())); } }

这段代码有几个值得注意的细节。templateParam要求的是JSON字符串,这里用字符串拼接构造了{"code":"123456"}。如果验证码是纯数字,这样拼没问题;但如果模板里有多个变量,比如{"code":"123456","product":"某应用"},手拼JSON很容易在转义上翻车。我一般会用ObjectMapper来序列化一个Map<String, String>,让Jackson去处理引号和转义,能省掉很多隐藏的坑。

发送成功后立刻把验证码存起来。Demo里verificationCodeStore是一个简单的内存Map实现,键是手机号,值是验证码和过期时间。这种实现方式只能用于本地demo,真实项目里内存Map会有两个致命问题:一是应用重启验证码全部丢失,二是多实例部署时请求打到不同实例会校验失败。后面改造章节我会讲怎么换成Redis。

3.3 发送结果解析:错误码映射与状态机

短信发送接口的返回结果不是简单的一个Success布尔值,而是一个包含状态码、请求ID、消息描述的结构。状态码为"OK"才表示提交成功,注意是提交成功而不是送达成功,实际下发还有延迟和失败率。比"OK"常见的是各种错误码,比如签名不存在、模板不存在、触发日发送限额、手机号被列入黑名单等。

public class SmsSendResponse { private String code; private String message; private String requestId; public boolean isSuccess() { return "OK".equals(code); } }

ErrorCode枚举里做了状态码映射:

public enum ErrorCode { PHONE_FORMAT_ERROR(40001, "手机号格式不正确"), SIGN_DOES_NOT_EXIST(40002, "签名不存在或未审核通过"), TEMPLATE_PARAM_INVALID(40003, "模板参数格式不正确"), FREQUENCY_LIMITED(40004, "触发频控"), INTERNAL_ERROR(50000, "服务内部错误"); private final int code; private final String message; }

mapFrom方法根据短信平台返回的字符串code找到对应的枚举项。如果匹配不到就归到INTERNAL_ERROR,避免把平台返回的原始code直接抛给前端。这种错误码映射的好处是业务层只面向自己定义的异常体系,不依赖某个短信服务商的特定字符串。你未来切换服务商时,只需要改mapFrom的内部逻辑。

请求ID是一个容易被忽略但很重要的字段。当线上出现"用户说收不到短信"的客诉时,你查日志第一件事就是找这个requestId,拿它去短信平台后台查详单,能定位到短信究竟是在提交环节失败,还是下发后手机端拦截了。Demo里把requestId放在日志里打印出来,这个习惯值得保持。

3.4 验证码的生成策略与过期处理

验证码生成看起来再简单不过,但具体生成规则里还是有讲究的。CodeGenerator里的实现是生成6位随机数字,并且确保第一位不为0,避免出现5位数的情况。

public class CodeGenerator { private static final SecureRandom RANDOM = new SecureRandom(); public static String generate(int length) { StringBuilder sb = new StringBuilder(); for (int i = 0; i < length; i++) { sb.append(RANDOM.nextInt(10)); } return sb.toString(); } }

注意这里用的是SecureRandom而不是Math.random()。原因在于验证码是安全敏感值,如果使用可预测的伪随机数发生器,攻击者有可能推算出下一个验证码的值,从而绕过短信验证保护。SecureRandom在Linux环境下会从/dev/urandom获取熵,安全性高一个量级。

验证码存储带过期时间的设计也很关键。内存实现里每次保存时记录expireAt,取的时候判断当前时间是否晚于expireAt。另外一个生产级系统还要考虑频繁发送的冷却时间——用户刚收到一条短信,马上又点了一次发送,如果没有90秒到120秒的冷却限制,用户可能会连续触发多条短信,既浪费资费又构成骚扰。这份Demo的Controller里没有做冷却限制,我在二次开发章节会补上这个逻辑。

4. 避坑指南:短信接口接入的翻车现场

4.1 签名审核不通过:问题大多不在代码

现象:本地代码逻辑检查了无数遍,发送接口就是不报错,但短信平台返回"签名不存在或未审核通过"。翻车原因:签名在短信平台后台需要单独申请审核,你代码里传的signName必须和申请通过的签名完全一致,包括括号、空格这些细节。更隐蔽的是新申请的签名审核状态是"待审核",你代码调的时候它还不是"已通过",需要等人工审核完成后才生效。

解决:先去平台后台确认签名审核状态是"已通过",再核对application.yml里sign-name的值和后台显示的是否一模一样。我踩过最离谱的是后台显示签名带了个空格,代码里没带,查了半天。签名申请时建议选"自用"而不是"通用",审核会快一些。解决完这一层,错误码基本就不会再报签名问题了。

4.2 模板参数JSON转义:报错"模板变量缺少"

现象:代码看着没问题,模板里写的变量是${code},传的templateParam也是{"code":"123456"},但平台返回"模板变量缺少"。翻车原因:短信模板必须先用{code}这种格式在后台申请,而且变量名必须和代码里JSON的key严格一致,大小写敏感。另一个常见原因是模板里定义了多个变量,但你只传了一个,缺失的变量就会触发这个错误。

解决:把后台模板内容原样复制出来,数清楚变量的名字和个数,再对照调整templateParam。调试阶段我通常会把模板内容和templateParam打印到日志里,肉眼对齐一眼就能看出少了什么。如果你用了字符串拼接JSON,还容易败在双引号转义上,改成用ObjectMapper序列化Map之后,这类问题基本清零。

4.3 密钥硬编码与泄漏风险

现象:代码提交到Git仓库后,用户反馈收到了来自平台的告警邮件,说AccessKey疑似泄露。翻车原因:压缩包里的application.yml带着明文密钥,如果用压缩包里的Demo直接改改就提交,accessKeySecret跟着代码一起进了仓库。很多人在本地测试完忘了清理,或者干脆觉得"这是测试密钥没关系"——但有些测试密钥绑定了真实的生产权限。

解决:拿到这份Demo后第一步就是把access-key-secret改成占位符引用环境变量,比如access-key-secret: ${SMS_ACCESS_SECRET}。在启动脚本里用环境变量注入真实值。还需要去平台后台给子账号设置最小权限,只开短信发送权限,不开ReadOnly之外的其他管理权限。这算是一条血泪经验了,从那以后我每次初始化项目都会先检查配置文件里有没有明文密码。

4.4 验证码接口被刷:流量全打到了短信平台

现象:上线第一天收到资费告警,短信发送量是预期的几十倍,日志里同一手机号在短时间内发了上百次。翻车原因:接口没有做频控,攻击者可以直接对着/api/sms/send接口猛刷,每发一次短信平台就扣一次费。纯业务的验证码接口天然就是资费黑洞,必须从接口层挡住。

解决:两个基本手段,一是同一手机号设置发送间隔,比如90秒内不允许重复发送,直接查存储里是否还有未过期的验证码记录;二是单IP限流,比如每分钟超过20次返回错误码。Demo里有verificationCodeStore存储验证码,可以在保存时顺带记录最后发送时间,实现冷却判断。先加这两层,再考虑接网关层的高级防护。

4.5 测试手机号收不到短信的几种原因

现象:代码跑通了,返回结果也显示"OK",但手机就是不响。翻车原因:这种情况最常见的是手机管家把短信拦截进了骚扰箱,尤其是验证码类短信容易触发拦截规则。其次是短信平台的测试专用号码池和真实号码池是分开的,如果你用的是平台提供的测试号,状态是"OK"但实际不会下发到真实手机。

解决:先用真实手机号在平台后台的"手动发送调试"页面发一条,确认手机能收到,再去代码里调试。如果后台能发而代码发不了,对比代码里的签名和模板与后台是否一致。如果代码发返回"OK"而手机收不到,检查骚扰拦截列表,并确认不是测试号码池。这个问题属于无论代码写得多正确都容易忽视的一类,最好在联调一开始就锁定测试环境。

5. 二次开发:把Demo改造成生产级短信服务

5.1 抽出接口层:把Controller逻辑完全解耦

当前Demo的Controller直接注入了SmsService,Service里处理了生成验证码、调用客户端、存储验证码三件事。改造第一步,我建议把三条逻辑拆成三个独立组件:CodeGenerator负责生成、SmsSender负责发送、VerificationCodeStore负责存取。Controller只负责把参数交给SmsService编排三者,后面替换任何一环都不影响其他代码,也为单元测试打桩提供便利。

5.2 用Redis替换内存Map存储

内存Map在重启丢失数据、多实例部署时校验失败的问题之前说过,改造方式是引入Redis。把保存操作换成redisTemplate.opsForValue().set(key, code, 5, TimeUnit.MINUTES),key建议用sms:verify:${phone}这样的前缀,方便排查问题。校验时先取出再删除,保证验证码一次性使用。另外加一个冷却key,比如sms:cooldown:${phone},设置90秒过期,发送前先查这个key存不存在。这个改造做完,短信模块才算具备上生产环境的基本条件。

5.3 异步化改造与上线检查清单

短信发送是IO操作,如果同步阻塞在业务链路上,接口响应时间会飙到几百毫秒甚至更久。生产环境我一般会引入一个事件机制,Controller接受到请求后立即返回"已受理",后台线程池去执行真正的发送。注意异步发送后错误码就不能直接返回给前端了,要在异步任务里记录日志、做重试和告警,前端只能感知到"已提交"。

上线前我习惯把这几项过一遍:确认短信签名和模板审核通过;确认accessKeySecret只存在于环境变量;给手机号加发送冷却;验证码过期时间与业务诉求匹配(一般5分钟合理);日志里打印requestId并确认能根据手机号查到发送记录。任何一个环节有疑问,直接用后台的发送详单功能对照排查。短信服务踩坑多半不在代码本身,而在于状态不同步,从那以后我每次对接外部服务都会强制走一遍后台配置核对流程,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询