SpringBoot 测试环境免发短信验证码方案,节省测试短信成本
还在为测试环境频繁发送短信扣服务费?这套配置化方案直接省掉测试短信成本。
大家好,我是 9i 编程。很多中小项目测试阶段,开发、测试人员反复拉取验证码,一个月光测试短信就能扣费几百元,造成完全没必要的资金浪费。短信验证码登录是绝大多数后台系统的基础功能,但是调用第三方短信接口会产生费用。日常开发、测试、联调阶段,测试人员会频繁请求验证码,甚至随意填写手机号,大量无效短信会造成不必要的成本消耗。
本文提供一套可配置化方案:通过开关区分测试/生产环境,测试环境不调用短信服务商,直接把验证码返回前端展示;生产环境正常下发真实短信。
文中不会过多讲解验证码过期时效、第三方短信SDK对接等基础能力,重点聚焦【环境隔离、免发短信调试】的架构设计与代码实现。
一、配置开关
在项目配置文件中新增调试开关,用于区分测试/生产环境验证码逻辑:
yaml
ivy:
auth:
debug: true
代码中通过@Value注入配置,默认关闭调试模式:
java
@Value("${ivy.auth.debug:false}")
private boolean isDebug;
二、统一验证码接口Controller
为了保证前后端交互逻辑、接口地址完全一致,测试与生产共用同一套Controller,无需新增测试专用接口。这是整套方案最核心的设计基石,也是能做到零改动适配双环境的关键。
java
package vip.wayhua.ivy.mine.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import vip.wayhua.ivy.mine.auth.annotation.Ignore;
import vip.wayhua.ivy.mine.auth.dto.Token;
import vip.wayhua.ivy.mine.core.result.Result;
import vip.wayhua.ivy.mine.dto.VerifyRes;
import vip.wayhua.ivy.mine.form.SmsCodeForm;
import vip.wayhua.ivy.mine.form.SmsVerifyFom;
import vip.wayhua.ivy.mine.service.CustAuthService;
@Ignore
@RestController
public class AuthController {
@Autowired
CustAuthService custAuthService;
// 先只做客户登录
@Operation(summary = "获取验证码")
@PostMapping(value = "/cust/getSmsCode")
public Result<VerifyRes> getSmsCode(@Valid @RequestBody SmsCodeForm form) {
VerifyRes smsCode = custAuthService.getSmsCode(form.getMobile().trim());
return Result.success(smsCode);
}
@Operation(summary = "客户端登录验证")
@PostMapping(value = "/cust/smsVerify")
public Result<Token> smsVerify(@Valid @RequestBody SmsVerifyForm form) {
String stoken = custAuthService.smsVerify(form.getMobile().trim(),
form.getCode().trim(),
form.getVerify().trim());
Token token = new Token();
token.setToken(stoken);
return Result.success(token);
}
}
核心设计点:
接口返回实体VerifyRes中增加code字段,仅调试开关开启时才会返回真实验证码;生产环境该字段为空,不会泄露验证码,上线务必确认ivy.auth.debug=false。
- 获取验证码入参 SmsCodeForm
java
package vip.wayhua.ivy.mine.form;
import io.swagger.v3.oas.annotations.media.Schema;
import vip.wayhua.ivy.mine.annotation.ValidMobile;
import java.io.Serializable;
@Schema(description = "获取验证码Form")
public class SmsCodeForm implements Serializable {
@Schema(description = "手机号")
@ValidMobile
private String mobile;
public String getMobile() {
return mobile;
}
public void setMobile(String mobile) {
this.mobile = mobile;
}
}
- 获取验证码返回实体 VerifyRes
java
package vip.wayhua.ivy.mine.dto;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(description = "获取验证码返回dto")
public class VerifyRes {
@Schema(description = "验证信息")
private String verify;
@Schema(description = "验证码【只有在调试情况下才会有值】")
private String code;
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public String getVerify() {
return verify;
}
public void setVerify(String verify) {
this.verify = verify;
}
}
- 验证码登录校验入参 SmsVerifyForm
java
package vip.wayhua.ivy.mine.form;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import vip.wayhua.ivy.mine.annotation.ValidMobile;
import java.io.Serializable;
@Schema(description = "手机验证Form")
public class SmsVerifyForm implements Serializable {
@Schema(description = "手机号")
@ValidMobile
private String mobile;
@Schema(description = "验证码")
@NotNull(message = "code不能为空")
private String code;
@NotNull(message = "验证码不能为空")
@Schema(description = "验证信息")
private String verify;
public String getMobile() {
return mobile;
}
public void setMobile(String mobile) {
this.mobile = mobile;
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public String getVerify() {
return verify;
}
public void setVerify(String verify) {
this.verify = verify;
}
}
- 登录返回 Token 实体
java
package vip.wayhua.ivy.mine.auth.dto;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(description = "返回Token")
public class Token {
public String getToken() {
return token;
}
public void setToken(String token) {
this.token = token;
}
@Schema(description = "token")
private String token;
}
三、验证码业务Service分层设计
1. 上层业务接口CustAuthService
对外暴露登录、获取验证码能力,控制整体业务流程:
java
package vip.wayhua.ivy.mine.service;
import vip.wayhua.ivy.mine.dto.VerifyRes;
public interface CustAuthService {
VerifyRes getSmsCode(String mobile);
String smsVerify(String mobile, String code, String verify);
}
2. 短信能力抽象接口SmsService
将短信生成、校验逻辑抽离,区分测试/生产两套实现,实现业务与短信发送解耦:
java
package vip.wayhua.ivy.mine.service;
import vip.wayhua.ivy.mine.dto.VerifyRes;
public interface SmsService {
VerifyRes getSmsCode(String mobile);
String smsVerify(String mobile, String code, String verify);
}
四、CustAuthServiceImpl动态切换短信实现
通过配置isDebug动态加载测试/生产短信实现类,使用SpringContextHolder获取对应 Bean;
注:当前fillService仅首次调用初始化实例,服务运行时修改 debug 配置无法自动切换短信实现,可根据需求优化为动态刷新配置。
java
package vip.wayhua.ivy.mine.service.impl;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import vip.wayhua.ivy.mine.core.utils.SpringContextHolder;
import vip.wayhua.ivy.mine.dto.VerifyRes;
import vip.wayhua.ivy.mine.service.CustAuthService;
import vip.wayhua.ivy.mine.service.SmsService;
@Service
public class CustAuthServiceImpl implements CustAuthService {
@Value("${ivy.auth.debug:false}")
private boolean isDebug;
SmsService service;
@Override
public VerifyRes getSmsCode(String mobile) {
fillService();
return service.getSmsCode(mobile);
}
@Override
public String smsVerify(String mobile, String code, String verify) {
fillService();
return service.smsVerify(mobile, code, verify);
}
/** 动态注入对应环境的短信实现类 */
private void fillService() {
if (service == null) {
if (isDebug) {
service = SpringContextHolder.getBean(DebugSmsServiceImpl.class);
} else {
service = SpringContextHolder.getBean(SmsServiceImpl.class);
}
}
}
}
补充:工具类SpringContextHolder用于手动获取 Spring 容器 Bean,也可直接使用 Hutool 提供的SpringUtil替代。
五、抽象短信父类AbstractSmsServiceImpl
AbstractSmsServiceImpl统一封装测试、生产两套环境的通用逻辑,并抽离出短信发送专用方法doSendSms。 最初我计划将该方法定义为抽象方法,但测试环境无需真实下发短信,因此改为空实现,仅生产环境子类重写完成短信发送。
类内部已内置完整通用能力:6 位验证码生成、Redis 缓存存储;调试模式下会直接将验证码返回前端;用户未查询到直接抛出异常,该校验逻辑可根据业务改为自动注册,按需调整即可。
java
@Override
public VerifyRes getSmsCode(String mobile) {
Custom custom = customService.findByMobile(mobile);
if (custom == null) {
throw new XException(ResultCode.VERIFY_NOFOUND_CUSTOM_ERROR);
}
VerifyRes result = new VerifyRes();
// 1、随机生成6位验证码
String sendVerify = VerifyUtils.genVerifyCode(6);
// 2、计算发送后台的验证码md5
String verifyMd5 = VerifyUtils.genVerifyMd5(mobile, sendVerify);
// 准备发送
if (!mobile.contains("+86")) {
mobile = "+86" + mobile;
}
// 300 秒
redisOperator.set(verifyMd5, sendVerify, 300);
result.setVerify(verifyMd5);
if (isDebug) {
result.setCode(sendVerify);
}
doSendSms(mobile, result);
return result;
}
protected void doSendSms(String mobile, VerifyRes verifyRes) {
}
验证码登录校验逻辑无环境差异,统一在抽象父类实现。
六、两套环境实现类
1. 测试环境DebugSmsServiceImpl
无需任何短信发送逻辑,直接复用父类通用能力:
java
package vip.wayhua.ivy.mine.service.impl;
import org.springframework.stereotype.Service;
@Service
public class DebugSmsServiceImpl extends AbstractSmsServiceImpl {
}
2. 生产环境 SmsServiceImpl
重写doSendSms对接阿里云/腾讯云等第三方短信SDK,完成真实短信下发:
java
package vip.wayhua.ivy.mine.service.impl;
import org.springframework.stereotype.Service;
import vip.wayhua.ivy.mine.dto.VerifyRes;
@Service
public class SmsServiceImpl extends AbstractSmsServiceImpl {
@Override
protected void doSendSms(String mobile, VerifyRes verifyRes) {
super.doSendSms(mobile, verifyRes);
// 对接第三方短信服务:阿里云/腾讯云/华为云短信API
// 可扩展:发送失败重试、RabbitMQ异步发送、发送记录入库等
}
}
扩展优化点:当前实现未监听短信发送回执,生产环境可增加异步队列、失败重试、发送日志记录等能力,网上成熟方案较多,可自行拓展。
七、前端配套改造
前端改造成本极低,获取验证码接口回调中读取返回的code字段,直接展示在页面即可,测试人员无需查看手机短信:
js
/**
* 获取验证码按钮点击事件
*/
const getCodeChange = async () => {
// 倒计时中直接拦截,防止重复点击
if (state.interTimeCode) return
// 调用后端获取验证码接口
getSmsCodeApi(state.mobile)
.then((res) => {
// 后端返回verify校验串,登录时需要携带
state.verify = res.verify
// debug模式下后端会返回code,页面直接展示验证码
state.code = res.code
// 开启60秒倒计时
state.interTimeCode = setInterval(() => {
state.time--
if (state.time <= 0) {
// 倒计时结束,清除定时器、重置文案
clearInterval(state.interTimeCode)
state.time = 60
state.codeText = '获取验证码'
} else {
state.codeText = `重新发送(${state.time}s)`
}
}, 1000)
})
.catch((error) => {
// 请求异常打印日志
log(error)
})
}
补充优化:页面销毁时执行 clearInterval (state.interTimeCode),避免定时器内存残留
八、方案总结与拓展思路
1. 方案优势
零成本测试:测试环境完全不调用第三方短信接口,大幅降低测试短信开销;
接口统一:测试/生产共用一套 Controller、入参出参,前后端无需适配两套逻辑;
低侵入:仅通过配置开关控制逻辑,不改动原有验证码核心流程;
易维护:短信发送逻辑抽离独立实现类,新增短信渠道只需新增实现类。
2. 可优化方向
1、现有fillSerivice仅初始化一次 Bean,可改为动态读取配置,支持运行时切换调试开关;
2、上层CustAuthService可改用策略模式/代理模式重构,消除手动 getBean 硬编码;
3、生产环境增加短信异步发送、限流防刷、发送失败告警、短信日志存储;
4、增加安全校验:生产环境强制过滤code字段返回,防止配置泄露导致验证码暴露。
3. 上线注意事项
项目打包发布生产环境前,务必确认配置ivy.auth.debug=false,避免验证码明文返回前端造成安全漏洞。