SpringBoot 测试环境免发短信验证码方案,节省测试短信成本

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

  1. 获取验证码入参 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;
    }
}
  1. 获取验证码返回实体 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;
    }
}
  1. 验证码登录校验入参 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;
    }
}
  1. 登录返回 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,避免验证码明文返回前端造成安全漏洞。

相关推荐
Ai拆代码的曹操1 小时前
把线程 Dump 读薄:从 BLOCKED/WAITING/RUNNABLE 到问题定位的完整方法论
后端
雪隐2 小时前
个人电脑玩AI-09让5060 Ti给你打工——让 AI 读懂你的资料
人工智能·后端
小满zs2 小时前
Go语言第一章(入门)
后端·go
用户6757049885022 小时前
Kafka 太重?试试 NSQ:一个优雅到极致的消息队列
后端·go
铁皮饭盒3 小时前
S3已成为文件存储标准,阿里/腾讯/华为云都支持,Bun率先原生支持
前端·javascript·后端
洛卡卡了3 小时前
Claude Code Hook,当 CLAUDE.md 规则不生效时,我们还需要强制拦截机制
后端·agent·claude
用户6757049885023 小时前
RabbitMQ 太重,Kafka 太复杂?Go 开发者:Asynq分布式任务队列就刚刚好
后端·go
AlbertLuo3 小时前
CodeMirror使用: 编写一个在线编辑HTML、JS、CSS文件,网页的模板页面-初实现
后端
SamDeepThinking3 小时前
裁掉那个差程序员后,给你看团队里高手的代码:这个习惯,希望你有
java·后端·程序员