做Java开发的这几年,几乎每个项目都会碰到短信发送的需求。注册验证码、登录提醒、订单状态通知、告警推送,短信看着不起眼,但真要自己从零对接一遍,坑多得能让你怀疑人生。这篇文章就围绕“java短信API示例代码”这个主题,把我在Spring Boot项目里集成短信发送功能的完整思路、代码实现和排坑经验一次说清楚,不管是刚入门的Java新手,还是需要快速搞定短信模块的老手,都能直接照着抄。
先说结论:短信发送这事,正确姿势不是去研究运营商协议,而是找一个靠谱的短信服务商,调用它的HTTP API,配上官方SDK,十几行代码就能把发送功能跑起来。真正花时间的不是写代码,而是理解签名、模板、AccessKey这些概念,以及处理好各种边界情况。
1. 短信API接入前的方案选型
1.1 短信API到底能做什么
短信API说白了就是一个HTTP接口,你往它那里提交手机号、短信内容、签名这些参数,它帮你把短信发出去,然后把发送状态通过回调或查询方式返回给你。整个过程里,你不需要关心短信网关怎么连、运营商怎么对接、通道怎么维护,这些脏活累活服务商都替你干了。
从业务角度看,短信API真正解决的是这几个刚需场景:
- 验证码:用户注册、登录、找回密码,这是短信用量最大的一块,特点是高并发、短时效、要求3秒内到达。
- 状态通知:订单发货、支付成功、预约提醒,这类消息对实时性和可追溯性要求高。
- 营销推送:促销活动、会员关怀,这类对发送频率和用户退订管理有明确合规要求。
- 系统告警:服务器异常、接口失败、安全告警,这类要求极低延迟,通常配合异步队列使用。
用API而不是自己对接运营商,最大的好处是省掉了通道费和开发成本。个人或小团队直接申请运营商通道基本不可能,动辄需要企业资质、月发送量承诺,而用服务商API,个人开发者注册账号、实名认证就能用,有免费额度,也有按量计费的套餐,灵活得多。
1.2 服务商怎么选:成本、稳定性和接入难度
市面上主流的短信服务商,国内用的比较多的是阿里云、腾讯云、华为云,还有像云片这样的老牌第三方。选哪家不能只看价格,我做了个对比,把关键维度列出来:
| 服务商 | 接入方式 | SDK成熟度 | 审核速度 | 价格区间(大约) | 适合场景 |
|---|---|---|---|---|---|
| 阿里云 | SDK / HTTP | 很高,文档全 | 较快 | 约0.045元/条 | 大多数业务场景,生态好 |
| 腾讯云 | SDK / HTTP | 高,文档较全 | 较快 | 约0.05元/条 | 腾讯生态内的项目 |
| 华为云 | SDK / HTTP | 中上 | 中规中矩 | 约0.045元/条 | 华为云上部署的项目 |
| 云片 | HTTP + SDK | 老牌,稳定 | 中等 | 按套餐 | 对价格敏感,已有合作基础 |
我的建议是,项目如果部署在某个云厂商上,优先选同一家的短信服务。为什么?一是内网调用延迟更低,二是统一账号体系,运维方便,三是部分服务可以共用VPC网络,省去公网调用的一些麻烦事。如果项目没有云厂商绑定,选阿里云或者腾讯云都行,两者的文档和社区讨论量足够多,搜问题更容易。
选型时还要注意一个隐形坑:国际短信和国内短信的通道、计费方式不一样。如果你的业务有跨境需求,提前确认服务商是否支持目标国家或地区的通道,别等上线了才发现发不出去。
1.3 技术路线:SDK优先,还是HTTP裸调
短信服务商基本都会提供两种接入方式:直接调HTTP API,或者用官方SDK。新手容易纠结,其实选择标准很简单。
HTTP裸调的好处是零依赖,一个HTTP工具类就能搞定,适合那种不想引入额外包、或者SDK版本老有兼容问题的老项目。缺点是你要自己处理签名算法、请求序列化、响应解析、异常重试,代码写起来麻烦,而且每个服务商的消息结构还不一样,后续换服务商要改一大片。
SDK的方式是我更推荐的。官方SDK帮你封装好了签名、重试、序列化这些细节,暴露给你的就是几个方法调用,代码量少,出错概率低。像阿里云的短信SDK,引入依赖、初始化Client、调用sendSms方法,三步搞定,没有任何黑魔法。
本文后面的示例代码,会以阿里云短信服务为例子,同时给出一个多服务商适配的抽象思路。用阿里云举例不是吹捧它,主要是它的SDK结构清晰、文档丰富,用它能说清楚短信API的所有关键环节,其他家思路大同小异。
2. 集成前的准备:账号、签名、模板和依赖
2.1 三个绕不开的概念:AccessKey、签名、模板
很多人第一次对接短信API,看文档时看到AccessKey、签名、模板直接懵了。这几个概念其实特别像寄快递:
- AccessKey相当于你的寄件人身份凭证,服务商靠它识别是谁在调用API。它分AccessKey ID和AccessKey Secret,ID是公开的,Secret必须保密,相当于你的银行卡号和密码的关系。
- 签名相当于快递单上的寄件人名称,比如“【某某科技】”,它必须提前在服务商那边申请审核,审核通过后,你调用API时填的这个签名才能用。
- 模板相当于快递里的内容模板,短信内容不能随便写,得先提交模板,比如“您的验证码为${code},5分钟内有效”,审核通过后会得到一个模板ID或模板Code。
实际操作中,最容易出问题的就是签名和模板。签名和模板都是需要人工审核的,审核内容主要是看有没有明显的营销骚扰嫌疑、有没有违反内容规范。个人开发者申请签名时,一般用“【个人应用】”之类的名称,注意签名类型要和你的实际使用场景匹配,频繁修改签名或申请与实际业务不符的签名,容易被驳回。
短信模板有个容易被忽略的点:模板里的变量用${}占位,比如“您的验证码为${code}”,你在调用API时传的JSON参数里要对应传一个code字段。变量名必须和模板里完全一致,大小写都要一致,否则服务商会直接报参数不匹配的错误。
2.2 开通短信服务与获取AccessKey
以阿里云为例,整个准备过程大概是这样的:
- 注册并登录阿里云账号,完成实名认证。
- 在产品控制台搜索“短信服务”,开通服务(新用户一般有免费短信额度)。
- 在“签名管理”里申请签名,填写签名内容、适用场景,等待审核。
- 在“模板管理”里申请模板,填模板内容、变量说明,等待审核。
- 在RAM访问控制里创建子用户,给它授予短信服务的权限,拿到AccessKey ID和AccessKey Secret。
这里要提醒一下,生产环境强烈建议用RAM子用户,而不是主账号的AccessKey。主账号密钥权限太大,一旦泄露,后果是整个账号下的所有资源都裸奔。RAM子用户可以把权限精确限定在短信服务这一个产品上,出了事也能及时禁用和轮换密钥。
关于审核时间,快的可能十几分钟,慢的可能一个工作日。所以我的经验是,签名和模板一定要提前申请,不要等开发完了才想起来去申请。另外,模板里有敏感词的时候审核时间会拉长,尽量用中性措辞。
2.3 开发环境与依赖引入
开发环境建议JDK 8以上,用了Spring Boot 2.x或3.x都可以。我示例用的是Spring Boot 2.7 + Maven,生产上只要不是特别老的JDK版本基本无压力。
短信SDK的依赖很简单,Maven的pom.xml里加这么一段:
<dependency> <groupId>com.aliyun</groupId> <artifactId>dysmsapi20170525</artifactId> <version>2.0.24</version> </dependency>这个SDK是阿里云短信服务专用的。它的命名方式看着有点长,实际上是标准的阿里云OpenAPI命名规则,dysmsapi是产品名,20170525是API版本号。加完依赖后,Maven会自动把依赖树拉下来,里面包含了核心的HTTP调用、签名认证、JSON序列化等底层逻辑,你完全不用关心。
另外一个好用的工具是lombok,如果你的项目已经用了,那配置类可以写得更简洁。如果你不想用lombok,手写getter/setter最多也就多点代码量,不影响功能。
3. 核心代码实现:从配置到发送一条龙
3.1 配置类:把密钥和参数集中管理
短信SDK需要一个Client对象,它包含你的AccessKey和地域信息。我习惯把短信相关配置抽到application.yml里,再用一个配置类读取,这样密钥不会散落在代码里,换环境也只需要改配置。
先看application.yml里的配置:
spring: application: name: sms-demo sms: aliyun: access-key-id: your-access-key-id access-key-secret: your-access-key-secret sign-name: 你的签名 template-code: SMS_123456789 region-id: cn-hangzhou endpoint: dysmsapi.aliyuncs.com对应写一个属性绑定类:
@Data @Component @ConfigurationProperties(prefix = "sms.aliyun") public class SmsProperties { private String accessKeyId; private String accessKeySecret; private String signName; private String templateCode; private String regionId; private String endpoint; }然后初始化SDK的Client对象:
@Configuration public class SmsClientConfig { @Bean public com.aliyun.dysmsapi20170525.Client aliyunSmsClient(SmsProperties props) { com.aliyun.teaopenapi.models.Config config = new com.aliyun.teaopenapi.models.Config() .setAccessKeyId(props.getAccessKeyId()) .setAccessKeySecret(props.getAccessKeySecret()); config.endpoint = props.getEndpoint(); return new com.aliyun.dysmsapi20170525.Client(config); } }这段代码的核心逻辑是创建一个带认证信息的SDK Client,后续所有发送操作都通过这个Client发起。你可能注意到我用了全限定类名,这是因为阿里云SDK中确实有多个叫Client的类,比如有核心RPC Client和短信专用Client,全限定类名能避免引入时的语义混淆。
3.2 发送短信的核心方法:验证码场景示例
短信发送的核心代码,本质是构建请求参数、调用Client、解析响应、处理异常。我们以发送验证码为例,写一个完整的发送方法:
@Service public class SmsService { @Resource private com.aliyun.dysmsapi20170525.Client aliyunSmsClient; @Resource private SmsProperties smsProperties; public SendSmsResult sendVerifyCode(String phone, String code) { com.aliyun.dysmsapi20170525.models.SendSmsRequest request = new com.aliyun.dysmsapi20170525.models.SendSmsRequest() .setPhoneNumbers(phone) .setSignName(smsProperties.getSignName()) .setTemplateCode(smsProperties.getTemplateCode()) .setTemplateParam("{\"code\":\"" + code + "\"}"); try { com.aliyun.dysmsapi20170525.models.SendSmsResponse response = aliyunSmsClient.sendSms(request); return parseResponse(response); } catch (Exception e) { // 记录异常,方便排查 log.error("短信发送失败, phone: {}, reason: {}", phone, e.getMessage(), e); return SendSmsResult.fail(e.getMessage()); } } private SendSmsResult parseResponse(SendSmsResponse response) { SendSmsResponseBody body = response.body; if (body != null && "OK".equals(body.code)) { return SendSmsResult.success(body.bizId); } return SendSmsResult.fail(body == null ? "响应为空" : body.message); } }整体流程很简单,但有几个关键点需要展开。
第一,setTemplateParam传入的是一个JSON字符串,不是直接填内容。短信模板是“您的验证码为${code},5分钟内有效”,调用时你把这个JSON传进去,服务端会解析它并替换模板变量。所以JSON里的key必须和模板占位符一致,value必须是字符串类型,如果是数字类型,服务商有可能会拒绝。
第二,验证码这个场景有几个隐含要求:验证码长度一般为4到6位,数字即可;有效期一般5分钟;同一个手机号发送间隔要有限制,防止短信轰炸。这些逻辑虽然API本身不强制,但产品上必须做。我习惯在服务层里加一个简单的Redis计数器来控制频率,下面这段是核心逻辑的补充思路:
public void checkSendFrequency(String phone) { String key = "sms:limit:" + phone; Long count = redisTemplate.opsForValue().increment(key); if (count != null && count > 5) { throw new BusinessException("发送太频繁,请稍后再试"); } if (count != null && count == 1) { redisTemplate.expire(key, Duration.ofMinutes(10)); } }这段代码不是短信API本身的内容,但它是短信功能上线前必须考虑的。没有这个限制,你的短信接口就是别人刷量的提款机。
第三,发送成功之后,验证码一定要存起来,等用户后续提交时做校验。存储时建议存加密后的值,至少不能明文落库。校验时要做防重放处理,比如验证通过后立刻删除这个验证码,防止同一验证码被多次使用。
3.3 查询发送状态:异步回调与主动查询
短信发送是异步的,调用sendSms接口返回成功,只能说明服务商已经受理了你的发送请求,并不代表用户真的收到了短信。真实的发送结果有两种获取方式:消息回调(SMSReport)和主动查询(QuerySendDetails)。
消息回调是推荐的生产方案。你在服务商控制台配置好回调URL,服务商会在短信真实送达后,往这个URL推一条JSON数据,包含手机号、发送状态、错误码、回执时间等信息。回调的好处是零轮询成本、延迟低,缺点是需要你的接口能稳稳定定地接收POST请求,而且要注意回调消息存在重复推送的可能,接口要做幂等处理。
主动查询则是发送后手动调用查询接口,适合低频场景。比如用户反馈没收到短信,我们可以用这个接口查一下到底发到哪一步了。查询代码如下:
public QuerySmsResult querySendStatus(String phone, String bizId) { com.aliyun.dysmsapi20170525.models.QuerySendDetailsRequest queryRequest = new com.aliyun.dysmsapi20170525.models.QuerySendDetailsRequest() .setPhoneNumber(phone) .setBizId(bizId) .setCurrentPage(1L) .setPageSize(10L) .setSendDate("20250101"); try { QuerySendDetailsResponse response = aliyunSmsClient.querySendDetails(queryRequest); // 解析明细列表 List<QuerySendDetailsResponseBody.QuerySendDetailsResponseBodySmsSendDetailDTOs> list = response.body.smsSendDetailDTOs; return QuerySmsResult.parse(list); } catch (Exception e) { log.error("查询短信状态失败, phone: {}, bizId: {}", phone, bizId, e); return QuerySmsResult.fail(); } }这里有个参数要特别注意:setSendDate是必传项,格式是YYYYMMDD,只能查询当天的发送记录,查历史数据需要调整日期参数。这也是查询接口的一个局限性,如果你的业务需要长期追溯短信状态,最可靠的做法还是把回调数据落库。
回调报文的处理和普通接口没什么区别,唯一需要注意的是回调来源验证。短信服务商推送回调时可能会在Header里带签名或Token,你要校验一下来源,防止伪造回调捣乱。这个细节很多教程都不提,但实际生产环境特别重要。
3.4 多服务商适配:面向接口编程
如果我们把短信API封装成统一的接口,后续切换服务商或者做多通道灾备,就会轻松很多。这也是我在多个项目里验证过的做法:定义一个短信发送接口,提供阿里云、腾讯云等不同实现,通过配置控制激活哪一套。
public interface SmsSender { SmsResult send(SmsRequest request); } @Data public class SmsRequest { private String phone; private String templateCode; private Map<String, String> templateParams; } public class AliyunSmsSender implements SmsSender { @Override public SmsResult send(SmsRequest request) { // 内部构建 AliSendSmsRequest,调用 SDK } } public class TencentSmsSender implements SmsSender { @Override public SmsResult send(SmsRequest request) { // 内部构建 TencentSendSmsRequest,调用 SDK } }这样设计带来的直接好处是,业务层只依赖SmsSender接口,不清楚底层到底用的是哪家。哪天阿里云涨价了、审核不过了、或者出故障了,你只需要替换实现类,业务代码一行不用改。如果要做多通道灾备,也可以通过一个路由层按权重或优先级选择不同的Sender。
不过也要提醒一下,不要为了设计而设计。如果你的项目只是内部小工具,用一个服务商就足够了,过度抽象反而浪费时间。多服务商适配方案适合那种短信量比较大、或者业务对短信可用性要求极高的场景。
4. 常见问题与排查技巧实录
4.1 错误码速查表
短信API返回的错误码,统一放在响应体的code字段里。我在实际开发中把它们分成了三类:参数类错误、权限类错误、业务类错误。下面这张表是从踩过的坑里整理出来的高频错误码:
| 错误码 | 含义 | 常见原因与解决方向 |
|---|---|---|
| isv.INVALID_PARAMETERS | 参数不合法 | 检查手机号格式、模板变量是否每一项都传了 |
| isv.SMS_SIGNATURE_ILLEGAL | 签名不合法 | 签名未审核通过,或与实际签名内容不一致 |
| isv.SMS_TEMPLATE_ILLEGAL | 模板不合法 | 模板未审核,或模板Code填错 |
| isv.MOBILE_NUMBER_ILLEGAL | 手机号格式错误 | 检查是否带了+86前缀、是否有空格 |
| isv.BUSINESS_LIMIT_CONTROL | 业务限流 | 触发服务商频控策略,需降低频率 |
| isv.AMOUNT_NOT_ENOUGH | 账户余额不足 | 充值,检查是否欠费 |
| isv.RAM_PERMISSION_DENY | RAM权限被拒绝 | 子账号未授权短信服务权限 |
| 默认错误签名 | 签名与模板不匹配 | 签名或模板归属于不同应用或版本 |
排查的时候,先判断是不是配置问题,再判断是不是业务问题。最蠢的排查方式是一开始就怀疑代码写错了,实际上八成是签名字符串少了个“【】”或者多打了个空格。
4.2 签名和模板那点事:最常见的坑
签名和模板是短信API使用中报错率最高的地方。拿签名来说,一个很典型的坑是:在控制台申请签名时填的是“某某科技”,但在代码里setSignName传的却是“【某某科技】”。不同服务商对签名格式要求不一样,阿里云要求传纯签名内容,不带【】;腾讯云则要看具体API版本,有的是需要带【】的。所以一定要先确认你用的服务商的具体要求。
模板方面的坑也很多。最经典的一个就是模板变量里加了特殊字符,比如JSON里传的value带了换行符,服务商那边解析时直接报参数不合法。遇到这种情况,建议在发送前对参数值做一次trim和长度校验。
另外还有一类是审核期间的坑。签名和模板提交后,在审核通过前的状态可能显示“待审核”或“审核中”,此时调用API大概率会失败。我的经验是,写代码前先看控制台上签名和模板的状态,别闷头写代码,写完发现啥都发不出去。
4.3 生产环境必须处理好的三个问题
第一个问题是超时设置。短信API是外部调用,网络抖动、服务商繁忙都可能导致请求超时。SDK默认的超时时间可能偏长或偏短,建议显式设置连接超时和读取超时,一般连接超时设3秒,读取超时设5秒比较合适。超时后要做重试,但重试策略要有上限,比如最多3次,并且使用指数退避,防止雪崩。
第二个问题是日志记录。发送短信涉及用户隐私和费用消耗,每个请求都要记日志,包括手机号、模板Code、参数、返回码、耗时。排查用户投诉“收不到短信”时,没有日志就只能干瞪眼。我习惯把短信日志单独放一个Logger,输出到独立的日志文件,方便按时间线排查。
第三个问题是异步发送。如果短信是登录流程里的关键环节,不要在用户请求线程里同步等待短信服务商返回,然后才给用户响应。正确做法是把发送任务丢到消息队列或线程池里异步执行,用户先收到“验证码已发送”的页面反馈,短信在后台飞。这样用户体验好,短信服务商偶发延迟也不至于拖垮整个请求链路。
如果要用线程池异步处理,下面是一个简单的示例:
@Component public class SmsExecutor { private final ExecutorService executor = Executors.newFixedThreadPool(8); public void submit(Runnable task) { executor.execute(task); } }这里要注意线程池的拒绝策略。如果短信并发量特别大,或者线程池队列满了,需要选择合理的拒绝策略,至少不要是AbortPolicy直接把任务丢掉。我用的是CallerRunsPolicy,发现线程池满的时候,让调用线程自己执行,宁可慢一点也不要丢短信。
4.4 从“能发短信”到“发得好”:踩坑之后的心得
短信功能写完之后,并不是万事大吉。上线前有几件事一定要做:
第一,准备好一套测试手机号。不同运营商的手机号对短信接收有细微差别,建议至少准备移动、联通、电信各一个,实测一遍。有些短信服务商的通道在某个运营商下会有延迟,提前发现比上线后接到投诉强。
第二,签名和模板的审核不代表永久有效。某个服务商的规范调整后,你的模板可能会被重新审核,内容违规或者敏感词命中,会被禁用。建议定时去控制台看一眼模板状态,也可以用开放API批量查询模板列表,把这个检查放到告警平台里。
第三,短信费用要监控。充值的钱扣完了,短信会静默失败,用户那边毫无感知。我见过真实线上事故,用户注册收不到验证码,后台日志一堆AMOUNT_NOT_ENOUGH,一查余额是0。所以余额监控必须做,低于阈值就触发告警,最好接上钉钉或企微机器人。
最后想说一点个人体会。短信API本身不复杂,真正难的是把它嵌入业务场景后的各种边界处理:频率控制、幂等、超时重试、状态通知、费用监控。很多项目上线时“能发短信”,但一遇到流量高峰就各种挂。把这些基础功课做扎实了,才不会在半夜被用户投诉电话叫醒。
如果你也正在做Java项目的短信对接,建议从最小可用版本起步:一个配置文件、一个发送方法、一条验证码下发链路。跑通了再逐步补全查询、回调、多通道这些能力。别一开始就铺太大,把核心链路踩稳了,后面都是加分项。