“mitao”
2026-04-17 4bb966f16d4d41c9799522069bc6c3cf70cfde89
feat:短信对接
1个文件已删除
5个文件已添加
8个文件已修改
866 ■■■■■ 已修改文件
tcm-training-admin-backend/pom.xml 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/config/AppAliyunSmsProperties.java 140 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/config/AppSmsAuthProperties.java 95 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AliyunAppSmsSender.java 100 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppSmsCodeService.java 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/LoggingAppSmsSender.java 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/utils/MsgUtils.java 93 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/resources/application-local.example.yml 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/resources/application-prod.yml 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/resources/application.yml 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/auth/service/AliyunAppSmsSenderTest.java 93 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/auth/service/AppSmsCodeServiceTest.java 113 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages.json 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/home/home.vue 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/pom.xml
@@ -130,6 +130,12 @@
            <artifactId>jiguang-sdk</artifactId>
            <version>5.3.0</version>
        </dependency>
        <!-- 阿里云短信 -->
        <dependency>
            <groupId>com.aliyun</groupId>
            <artifactId>dysmsapi20170525</artifactId>
            <version>2.0.10</version>
        </dependency>
    </dependencies>
    <build>
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/config/AppAliyunSmsProperties.java
New file
@@ -0,0 +1,140 @@
package com.tcm.training.admin.app.auth.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
 * APP 阿里云短信发送配置。
 */
@Component
@ConfigurationProperties(prefix = "app.auth.sms.aliyun")
public class AppAliyunSmsProperties {
    private String accessKeyId = "";
    private String accessKeySecret = "";
    private String signName = "";
    private Templates templates = new Templates();
    /**
     * 获取阿里云 AccessKey ID。
     */
    public String getAccessKeyId() {
        return accessKeyId;
    }
    /**
     * 设置阿里云 AccessKey ID。
     */
    public void setAccessKeyId(String accessKeyId) {
        this.accessKeyId = accessKeyId;
    }
    /**
     * 获取阿里云 AccessKey Secret。
     */
    public String getAccessKeySecret() {
        return accessKeySecret;
    }
    /**
     * 设置阿里云 AccessKey Secret。
     */
    public void setAccessKeySecret(String accessKeySecret) {
        this.accessKeySecret = accessKeySecret;
    }
    /**
     * 获取短信签名。
     */
    public String getSignName() {
        return signName;
    }
    /**
     * 设置短信签名。
     */
    public void setSignName(String signName) {
        this.signName = signName;
    }
    /**
     * 获取验证码模板配置。
     */
    public Templates getTemplates() {
        return templates;
    }
    /**
     * 设置验证码模板配置。
     */
    public void setTemplates(Templates templates) {
        this.templates = templates;
    }
    /**
     * 四类验证码模板编码配置。
     */
    public static class Templates {
        private String login = "";
        private String register = "";
        private String passwordReset = "";
        private String phoneChange = "";
        /**
         * 获取登录验证码模板编码。
         */
        public String getLogin() {
            return login;
        }
        /**
         * 设置登录验证码模板编码。
         */
        public void setLogin(String login) {
            this.login = login;
        }
        /**
         * 获取注册验证码模板编码。
         */
        public String getRegister() {
            return register;
        }
        /**
         * 设置注册验证码模板编码。
         */
        public void setRegister(String register) {
            this.register = register;
        }
        /**
         * 获取忘记密码验证码模板编码。
         */
        public String getPasswordReset() {
            return passwordReset;
        }
        /**
         * 设置忘记密码验证码模板编码。
         */
        public void setPasswordReset(String passwordReset) {
            this.passwordReset = passwordReset;
        }
        /**
         * 获取修改手机号验证码模板编码。
         */
        public String getPhoneChange() {
            return phoneChange;
        }
        /**
         * 设置修改手机号验证码模板编码。
         */
        public void setPhoneChange(String phoneChange) {
            this.phoneChange = phoneChange;
        }
    }
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/config/AppSmsAuthProperties.java
@@ -11,8 +11,10 @@
public class AppSmsAuthProperties {
    private int expireSeconds = 300;
    private boolean debugEnabled = false;
    private String debugCode = "";
    private DebugScene login = new DebugScene();
    private DebugScene register = new DebugScene();
    private DebugScene passwordReset = new DebugScene();
    private DebugScene phoneChange = new DebugScene();
    /**
     * 获取验证码有效期秒数。
@@ -29,30 +31,95 @@
    }
    /**
     * 获取是否启用开发调试验证码。
     * 获取短信登录调试验证码配置。
     */
    public boolean isDebugEnabled() {
        return debugEnabled;
    public DebugScene getLogin() {
        return login;
    }
    /**
     * 设置是否启用开发调试验证码。
     * 设置短信登录调试验证码配置。
     */
    public void setDebugEnabled(boolean debugEnabled) {
        this.debugEnabled = debugEnabled;
    public void setLogin(DebugScene login) {
        this.login = login;
    }
    /**
     * 获取开发调试验证码。
     * 获取注册调试验证码配置。
     */
    public String getDebugCode() {
        return debugCode;
    public DebugScene getRegister() {
        return register;
    }
    /**
     * 设置开发调试验证码。
     * 设置注册调试验证码配置。
     */
    public void setDebugCode(String debugCode) {
        this.debugCode = debugCode;
    public void setRegister(DebugScene register) {
        this.register = register;
    }
    /**
     * 获取重置密码调试验证码配置。
     */
    public DebugScene getPasswordReset() {
        return passwordReset;
    }
    /**
     * 设置重置密码调试验证码配置。
     */
    public void setPasswordReset(DebugScene passwordReset) {
        this.passwordReset = passwordReset;
    }
    /**
     * 获取修改手机号调试验证码配置。
     */
    public DebugScene getPhoneChange() {
        return phoneChange;
    }
    /**
     * 设置修改手机号调试验证码配置。
     */
    public void setPhoneChange(DebugScene phoneChange) {
        this.phoneChange = phoneChange;
    }
    /**
     * 单个短信验证码场景的调试配置。
     */
    public static class DebugScene {
        private boolean enabled = false;
        private String code = "";
        /**
         * 获取当前场景是否启用调试验证码。
         */
        public boolean isEnabled() {
            return enabled;
        }
        /**
         * 设置当前场景是否启用调试验证码。
         */
        public void setEnabled(boolean enabled) {
            this.enabled = enabled;
        }
        /**
         * 获取当前场景调试验证码。
         */
        public String getCode() {
            return code;
        }
        /**
         * 设置当前场景调试验证码。
         */
        public void setCode(String code) {
            this.code = code;
        }
    }
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AliyunAppSmsSender.java
New file
@@ -0,0 +1,100 @@
package com.tcm.training.admin.app.auth.service;
import com.tcm.training.admin.app.auth.config.AppAliyunSmsProperties;
import com.tcm.training.admin.common.PhoneMasker;
import com.tcm.training.admin.utils.MsgUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.Map;
/**
 * APP 短信发送阿里云实现。
 */
@Service
public class AliyunAppSmsSender implements AppSmsSender {
    private static final Logger log = LoggerFactory.getLogger(AliyunAppSmsSender.class);
    private final MsgUtils msgUtils;
    private final AppAliyunSmsProperties properties;
    /**
     * 注入阿里云短信工具与模板配置。
     */
    public AliyunAppSmsSender(MsgUtils msgUtils, AppAliyunSmsProperties properties) {
        this.msgUtils = msgUtils;
        this.properties = properties;
    }
    /**
     * 发送 APP 登录验证码。
     */
    @Override
    public void sendLoginCode(String phone, String code) {
        sendCode(phone, code, properties.getTemplates().getLogin(), "登录");
    }
    /**
     * 发送 APP 注册验证码。
     */
    @Override
    public void sendRegisterCode(String phone, String code) {
        sendCode(phone, code, properties.getTemplates().getRegister(), "注册");
    }
    /**
     * 发送 APP 忘记密码验证码。
     */
    @Override
    public void sendPasswordResetCode(String phone, String code) {
        sendCode(phone, code, properties.getTemplates().getPasswordReset(), "忘记密码");
    }
    /**
     * 发送 APP 修改手机号验证码。
     */
    @Override
    public void sendPhoneChangeCode(String phone, String code) {
        sendCode(phone, code, properties.getTemplates().getPhoneChange(), "修改手机号");
    }
    /**
     * 按场景发送验证码短信,并统一处理配置校验与日志。
     */
    private void sendCode(String phone, String code, String templateCode, String sceneName) {
        validateSmsConfig(templateCode);
        Map<String, String> params = Collections.singletonMap("code", code);
        try {
            msgUtils.sendMsg(phone, params, templateCode, properties.getSignName());
            log.info("APP{}验证码发送成功,手机号={}, templateCode={}", sceneName, PhoneMasker.mask(phone), templateCode);
        } catch (Exception exception) {
            log.error("APP{}验证码发送失败,手机号={}, templateCode={}", sceneName, PhoneMasker.mask(phone), templateCode, exception);
            if (exception instanceof IllegalStateException) {
                throw (IllegalStateException) exception;
            }
            throw new IllegalStateException("短信发送失败,请稍后重试");
        }
    }
    /**
     * 校验阿里云短信发送配置是否完整。
     */
    private void validateSmsConfig(String templateCode) {
        if (isBlank(properties.getAccessKeyId())
                || isBlank(properties.getAccessKeySecret())
                || isBlank(properties.getSignName())
                || isBlank(templateCode)) {
            throw new IllegalStateException("短信发送配置不完整");
        }
    }
    /**
     * 判断字符串是否为空白。
     */
    private boolean isBlank(String value) {
        return value == null || value.trim().isEmpty();
    }
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppSmsCodeService.java
@@ -14,6 +14,10 @@
@Service
public class AppSmsCodeService {
    private static final String SCENE_LOGIN = "login";
    private static final String SCENE_REGISTER = "register";
    private static final String SCENE_PASSWORD_RESET = "password-reset";
    private static final String SCENE_PHONE_CHANGE = "phone-change";
    private static final String LOGIN_CODE_CACHE_PREFIX = "app:sms:login:";
    private static final String REGISTER_CODE_CACHE_PREFIX = "app:sms:register:";
    private static final String PASSWORD_RESET_CODE_CACHE_PREFIX = "app:sms:password-reset:";
@@ -66,28 +70,28 @@
     * 校验登录验证码,成功后销毁缓存中的一次性验证码。
     */
    public void validateLoginCode(String phone, String code) {
        validateCode(phone, code, LOGIN_CODE_CACHE_PREFIX);
        validateCode(phone, code, LOGIN_CODE_CACHE_PREFIX, SCENE_LOGIN);
    }
    /**
     * 校验注册验证码,成功后销毁缓存中的一次性验证码。
     */
    public void validateRegisterCode(String phone, String code) {
        validateCode(phone, code, REGISTER_CODE_CACHE_PREFIX);
        validateCode(phone, code, REGISTER_CODE_CACHE_PREFIX, SCENE_REGISTER);
    }
    /**
     * 校验忘记密码验证码,成功后销毁缓存中的一次性验证码。
     */
    public void validatePasswordResetCode(String phone, String code) {
        validateCode(phone, code, PASSWORD_RESET_CODE_CACHE_PREFIX);
        validateCode(phone, code, PASSWORD_RESET_CODE_CACHE_PREFIX, SCENE_PASSWORD_RESET);
    }
    /**
     * 校验修改手机号验证码,成功后销毁缓存中的一次性验证码。
     */
    public void validatePhoneChangeCode(String phone, String code) {
        validateCode(phone, code, PHONE_CHANGE_CODE_CACHE_PREFIX);
        validateCode(phone, code, PHONE_CHANGE_CODE_CACHE_PREFIX, SCENE_PHONE_CHANGE);
    }
    /**
@@ -95,30 +99,30 @@
     */
    private AppSmsCodeSendResponse createCode(String phone, String cachePrefix, String scene) {
        String code = generateCode();
        if (SCENE_LOGIN.equals(scene)) {
            appSmsSender.sendLoginCode(phone, code);
        } else if (SCENE_REGISTER.equals(scene)) {
            appSmsSender.sendRegisterCode(phone, code);
        } else if (SCENE_PASSWORD_RESET.equals(scene)) {
            appSmsSender.sendPasswordResetCode(phone, code);
        } else {
            appSmsSender.sendPhoneChangeCode(phone, code);
        }
        stringRedisTemplate.opsForValue().set(
                buildCacheKey(cachePrefix, phone),
                code,
                appSmsAuthProperties.getExpireSeconds(),
                TimeUnit.SECONDS
        );
        if ("login".equals(scene)) {
            appSmsSender.sendLoginCode(phone, code);
        } else if ("register".equals(scene)) {
            appSmsSender.sendRegisterCode(phone, code);
        } else if ("password-reset".equals(scene)) {
            appSmsSender.sendPasswordResetCode(phone, code);
        } else {
            appSmsSender.sendPhoneChangeCode(phone, code);
        }
        return new AppSmsCodeSendResponse(appSmsAuthProperties.getExpireSeconds());
    }
    /**
     * 统一校验指定场景的验证码并执行一次性销毁。
     */
    private void validateCode(String phone, String code, String cachePrefix) {
        if (matchesDebugCode(code)) {
            // 开发环境允许固定验证码直通,便于前后端联调。
    private void validateCode(String phone, String code, String cachePrefix, String scene) {
        if (matchesDebugCode(scene, code)) {
            // 调试验证码按场景控制,生产环境仅放开短信登录场景。
            return;
        }
        String cacheKey = buildCacheKey(cachePrefix, phone);
@@ -142,17 +146,37 @@
    }
    /**
     * 判断是否命中本地调试验证码。
     * 判断当前场景是否命中调试验证码。
     */
    private boolean matchesDebugCode(String code) {
        if (!appSmsAuthProperties.isDebugEnabled()) {
    private boolean matchesDebugCode(String scene, String code) {
        AppSmsAuthProperties.DebugScene debugScene = resolveDebugScene(scene);
        if (debugScene == null || !debugScene.isEnabled()) {
            return false;
        }
        String debugCode = appSmsAuthProperties.getDebugCode();
        String debugCode = debugScene.getCode();
        return debugCode != null && !debugCode.trim().isEmpty() && debugCode.equals(code);
    }
    /**
     * 读取指定场景的调试验证码配置。
     */
    private AppSmsAuthProperties.DebugScene resolveDebugScene(String scene) {
        if (SCENE_LOGIN.equals(scene)) {
            return appSmsAuthProperties.getLogin();
        }
        if (SCENE_REGISTER.equals(scene)) {
            return appSmsAuthProperties.getRegister();
        }
        if (SCENE_PASSWORD_RESET.equals(scene)) {
            return appSmsAuthProperties.getPasswordReset();
        }
        if (SCENE_PHONE_CHANGE.equals(scene)) {
            return appSmsAuthProperties.getPhoneChange();
        }
        return null;
    }
    /**
     * 构造手机号维度的验证码缓存 key。
     */
    private String buildCacheKey(String cachePrefix, String phone) {
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/LoggingAppSmsSender.java
File was deleted
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/utils/MsgUtils.java
New file
@@ -0,0 +1,93 @@
package com.tcm.training.admin.utils;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.tcm.training.admin.app.auth.config.AppAliyunSmsProperties;
import org.springframework.beans.factory.annotation.Autowired;
import com.aliyun.dysmsapi20170525.models.SendSmsRequest;
import com.aliyun.dysmsapi20170525.models.SendSmsResponse;
import com.aliyun.tea.TeaException;
import com.aliyun.teaopenapi.models.Config;
import com.aliyun.teautil.models.RuntimeOptions;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.Map;
@Component
@Slf4j
public class MsgUtils {
    private final AppAliyunSmsProperties properties;
    private final ObjectMapper objectMapper;
    /**
     * 注入阿里云短信配置与 JSON 序列化工具。
     */
    @Autowired
    public MsgUtils(AppAliyunSmsProperties properties, ObjectMapper objectMapper) {
        this.properties = properties;
        this.objectMapper = objectMapper;
    }
    /**
     * 使用AK&SK初始化账号Client
     * @param accessKeyId
     * @param accessKeySecret
     * @return Client
     * @throws Exception
     */
    public static com.aliyun.dysmsapi20170525.Client createClient(String accessKeyId, String accessKeySecret) throws Exception {
        Config config = new Config()
                // 您的 AccessKey ID
                .setAccessKeyId(accessKeyId)
                // 您的 AccessKey Secret
                .setAccessKeySecret(accessKeySecret);
        // 访问的域名
        config.endpoint = "dysmsapi.aliyuncs.com";
        return new com.aliyun.dysmsapi20170525.Client(config);
    }
    /**
     * 发送阿里云短信验证码。
     */
    public void sendMsg(String phone, Map<String,String> params, String templateCode, String signName) throws Exception {
        com.aliyun.dysmsapi20170525.Client client = MsgUtils.createClient(
                properties.getAccessKeyId(),
                properties.getAccessKeySecret()
        );
        SendSmsRequest sendSmsRequest = new SendSmsRequest()
                .setSignName(signName)
                .setTemplateCode(templateCode)
                .setPhoneNumbers(phone);
        if (params != null && !params.isEmpty()) {
            sendSmsRequest.setTemplateParam(toTemplateParam(params));
        }
        RuntimeOptions runtime = new RuntimeOptions();
        try {
            SendSmsResponse sendSmsResponse = client.sendSmsWithOptions(sendSmsRequest, runtime);
            String responseCode = sendSmsResponse.getBody() == null ? null : sendSmsResponse.getBody().getCode();
            if (!"OK".equalsIgnoreCase(responseCode)) {
                String message = sendSmsResponse.getBody() == null ? "短信发送失败" : sendSmsResponse.getBody().getMessage();
                throw new IllegalStateException(message == null || message.trim().isEmpty() ? "短信发送失败,请稍后重试" : message);
            }
            log.info("阿里云短信发送成功,templateCode={}, statusCode={}", templateCode, sendSmsResponse.getStatusCode());
        } catch (TeaException error) {
            com.aliyun.teautil.Common.assertAsString(error.message);
            log.error("阿里云短信发送失败,templateCode={}, message={}", templateCode, error.message);
            throw new IllegalStateException("短信发送失败,请稍后重试");
        } catch (Exception _error) {
            TeaException error = new TeaException(_error.getMessage(), _error);
            com.aliyun.teautil.Common.assertAsString(error.message);
            log.error("阿里云短信发送异常,templateCode={}, message={}", templateCode, error.message);
            throw new IllegalStateException("短信发送失败,请稍后重试");
        }
    }
    /**
     * 将模板变量转换为阿里云要求的 JSON 字符串。
     */
    private String toTemplateParam(Map<String, String> params) throws JsonProcessingException {
        return objectMapper.writeValueAsString(params);
    }
}
tcm-training-admin-backend/src/main/resources/application-local.example.yml
@@ -35,6 +35,25 @@
    path: ./logs
  auth:
    sms:
      # 本地联调可开启固定验证码
      debug-enabled: true
      debug-code: "666666"
      aliyun:
        access-key-id: your-access-key-id
        access-key-secret: your-access-key-secret
        sign-name: your-sign-name
        templates:
          login: SMS_505135234
          register: SMS_505065330
          password-reset: SMS_505095303
          phone-change: SMS_505135235
      # 本地联调可按场景开启固定验证码
      login:
        enabled: true
        code: "666666"
      register:
        enabled: true
        code: "666666"
      password-reset:
        enabled: true
        code: "666666"
      phone-change:
        enabled: true
        code: "666666"
tcm-training-admin-backend/src/main/resources/application-prod.yml
@@ -33,5 +33,25 @@
app:
  auth:
    sms:
      debug-enabled: false
      debug-code: ""
      aliyun:
        access-key-id: "LTAI5t65dd3j1vT5kxAp8KHy"
        access-key-secret: "XPbEgmD3eSQLfPfFvuGhQchJzqWpF8"
        sign-name: "海诊通海南健康科技"
        templates:
          login: SMS_505135234
          register: SMS_505065330
          password-reset: SMS_505095303
          phone-change: SMS_505135235
      # 生产环境仅允许短信登录使用默认验证码,其他场景继续走真实验证码。
      login:
        enabled: true
        code: "666666"
      register:
        enabled: false
        code: ""
      password-reset:
        enabled: false
        code: ""
      phone-change:
        enabled: false
        code: ""
tcm-training-admin-backend/src/main/resources/application.yml
@@ -78,10 +78,28 @@
    sms:
      # 短信验证码有效期(秒)
      expire-seconds: 300
      # 默认关闭开发调试验证码,避免误入生产环境
      debug-enabled: false
      # 开发环境调试验证码,默认留空,需在 application-local.yml 中显式开启
      debug-code: ""
      aliyun:
        access-key-id: ""
        access-key-secret: ""
        sign-name: ""
        templates:
          login: ""
          register: ""
          password-reset: ""
          phone-change: ""
      # 默认关闭所有场景调试验证码,避免误入生产环境
      login:
        enabled: false
        code: ""
      register:
        enabled: false
        code: ""
      password-reset:
        enabled: false
        code: ""
      phone-change:
        enabled: false
        code: ""
    wechat:
      # 微信开放平台 AppId,当前阶段可先留空,补齐后启用服务端换票模式
      app-id: ""
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/auth/service/AliyunAppSmsSenderTest.java
New file
@@ -0,0 +1,93 @@
package com.tcm.training.admin.app.auth.service;
import com.tcm.training.admin.app.auth.config.AppAliyunSmsProperties;
import com.tcm.training.admin.utils.MsgUtils;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
/**
 * APP 阿里云短信发送实现测试。
 */
@ExtendWith(MockitoExtension.class)
class AliyunAppSmsSenderTest {
    @Mock
    private MsgUtils msgUtils;
    @Captor
    private ArgumentCaptor<Map<String, String>> paramsCaptor;
    private AppAliyunSmsProperties properties;
    private AliyunAppSmsSender aliyunAppSmsSender;
    /**
     * 每个用例前构造阿里云短信发送器。
     */
    @BeforeEach
    void setUp() {
        properties = new AppAliyunSmsProperties();
        properties.setAccessKeyId("test-ak");
        properties.setAccessKeySecret("test-sk");
        properties.setSignName("海诊通");
        properties.getTemplates().setLogin("SMS_505135234");
        properties.getTemplates().setRegister("SMS_505065330");
        properties.getTemplates().setPasswordReset("SMS_505095303");
        properties.getTemplates().setPhoneChange("SMS_505135235");
        aliyunAppSmsSender = new AliyunAppSmsSender(msgUtils, properties);
    }
    /**
     * 登录验证码应使用登录模板并透传 code 参数。
     */
    @Test
    void sendLoginCodeShouldUseLoginTemplate() throws Exception {
        doNothing().when(msgUtils).sendMsg(eq("13800138000"), paramsCaptor.capture(), eq("SMS_505135234"), eq("海诊通"));
        aliyunAppSmsSender.sendLoginCode("13800138000", "666666");
        assertEquals("666666", paramsCaptor.getValue().get("code"));
    }
    /**
     * 短信发送失败时应向上抛出统一业务异常。
     */
    @Test
    void sendRegisterCodeShouldThrowWhenMsgUtilsFails() throws Exception {
        doThrow(new IllegalStateException("短信发送失败,请稍后重试"))
                .when(msgUtils).sendMsg(eq("13800138001"), org.mockito.ArgumentMatchers.anyMap(), eq("SMS_505065330"), eq("海诊通"));
        IllegalStateException exception = assertThrows(IllegalStateException.class,
                () -> aliyunAppSmsSender.sendRegisterCode("13800138001", "123456"));
        assertEquals("短信发送失败,请稍后重试", exception.getMessage());
    }
    /**
     * 模板配置不完整时应直接拒绝发送。
     */
    @Test
    void sendPhoneChangeCodeShouldFailWhenTemplateMissing() {
        properties.getTemplates().setPhoneChange("");
        IllegalStateException exception = assertThrows(IllegalStateException.class,
                () -> aliyunAppSmsSender.sendPhoneChangeCode("13800138002", "123456"));
        assertEquals("短信发送配置不完整", exception.getMessage());
    }
}
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/auth/service/AppSmsCodeServiceTest.java
New file
@@ -0,0 +1,113 @@
package com.tcm.training.admin.app.auth.service;
import com.tcm.training.admin.app.auth.config.AppSmsAuthProperties;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
 * APP 短信验证码场景化调试配置测试。
 */
@ExtendWith(MockitoExtension.class)
class AppSmsCodeServiceTest {
    @Mock
    private StringRedisTemplate stringRedisTemplate;
    @Mock
    private ValueOperations<String, String> valueOperations;
    @Mock
    private AppSmsSender appSmsSender;
    private AppSmsAuthProperties appSmsAuthProperties;
    private AppSmsCodeService appSmsCodeService;
    /**
     * 每个用例前重新构造短信验证码服务。
     */
    @BeforeEach
    void setUp() {
        appSmsAuthProperties = new AppSmsAuthProperties();
        appSmsAuthProperties.getLogin().setEnabled(true);
        appSmsAuthProperties.getLogin().setCode("666666");
        appSmsAuthProperties.getRegister().setEnabled(false);
        appSmsAuthProperties.getRegister().setCode("");
        appSmsAuthProperties.getPasswordReset().setEnabled(false);
        appSmsAuthProperties.getPasswordReset().setCode("");
        appSmsAuthProperties.getPhoneChange().setEnabled(false);
        appSmsAuthProperties.getPhoneChange().setCode("");
        appSmsCodeService = new AppSmsCodeService(
                stringRedisTemplate,
                appSmsSender,
                appSmsAuthProperties
        );
    }
    /**
     * 仅开启登录调试验证码时,666666 应允许短信登录直接通过。
     */
    @Test
    void validateLoginCodeShouldAllowConfiguredDebugCode() {
        assertDoesNotThrow(() -> appSmsCodeService.validateLoginCode("13800138000", "666666"));
        verify(stringRedisTemplate, never()).opsForValue();
    }
    /**
     * 未开启注册调试验证码时,666666 不应绕过真实验证码校验。
     */
    @Test
    void validateRegisterCodeShouldRejectDebugCodeWhenSceneDisabled() {
        when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations);
        when(valueOperations.get(anyString())).thenReturn(null);
        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
                () -> appSmsCodeService.validateRegisterCode("13800138000", "666666"));
        assertEquals("验证码已失效,请重新获取", exception.getMessage());
    }
    /**
     * 短信发送成功后才应写入验证码缓存。
     */
    @Test
    void createLoginCodeShouldCacheCodeAfterSendSucceeds() {
        when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations);
        appSmsCodeService.createLoginCode("13800138000");
        verify(appSmsSender).sendLoginCode(eq("13800138000"), anyString());
        verify(valueOperations).set(anyString(), anyString(), anyLong(), eq(java.util.concurrent.TimeUnit.SECONDS));
    }
    /**
     * 短信发送失败时不应写入验证码缓存。
     */
    @Test
    void createLoginCodeShouldNotCacheCodeWhenSendFails() {
        org.mockito.Mockito.doThrow(new IllegalStateException("短信发送失败,请稍后重试"))
                .when(appSmsSender).sendLoginCode(eq("13800138000"), anyString());
        IllegalStateException exception = assertThrows(IllegalStateException.class,
                () -> appSmsCodeService.createLoginCode("13800138000"));
        assertEquals("短信发送失败,请稍后重试", exception.getMessage());
        verify(valueOperations, never()).set(anyString(), anyString(), anyLong(), eq(java.util.concurrent.TimeUnit.SECONDS));
    }
}
traditionalApp/pages.json
@@ -8,7 +8,8 @@
    "pages": [{
            "path": "pages/home/home",
            "style": {
                "navigationStyle": "custom"
                "navigationStyle": "custom",
                "enablePullDownRefresh": true
            }
        }, {
            "path": "pages/login/login",
traditionalApp/pages/home/home.vue
@@ -105,7 +105,7 @@
<script setup lang="ts">
    import UpdatePopup from "@/components/updatePopup.vue";
    import { ref, onMounted, computed } from "vue";
    import { onPageScroll, onShow } from "@dcloudio/uni-app";
    import { onPageScroll, onPullDownRefresh, onShow } from "@dcloudio/uni-app";
    import { getBannerList, getHomeFeaturedContent, getHomeLiveCourseList } from "./service";
    interface HomeBannerItem {
@@ -162,6 +162,7 @@
    const defaultLiveCover = '/static/image/home/class.png';
    const defaultCourseCover = '/static/image/home/class.png';
    const defaultPopularizationCover = '/static/image/home/zixun.png';
    const refreshing = ref(false);
    /**
     * 初始化导航栏所需系统信息。
@@ -178,9 +179,18 @@
     * 每次进入首页都刷新首页模块数据,确保展示最新 banner 与直播状态。
     */
    onShow(() => {
        loadBannerList();
        loadHomeLiveCourseList();
        loadFeaturedContent();
        refreshHomeData();
    });
    /**
     * 首页下拉时重新拉取首页聚合数据,并在结束后关闭原生刷新状态。
     */
    onPullDownRefresh(async () => {
        try {
            await refreshHomeData();
        } finally {
            uni.stopPullDownRefresh();
        }
    });
    /**
@@ -270,6 +280,25 @@
    };
    /**
     * 聚合刷新首页三块内容,避免 onShow 和下拉刷新分别维护重复请求逻辑。
     */
    const refreshHomeData = async () => {
        if (refreshing.value) {
            return;
        }
        refreshing.value = true;
        try {
            await Promise.all([
                loadBannerList(),
                loadHomeLiveCourseList(),
                loadFeaturedContent()
            ]);
        } finally {
            refreshing.value = false;
        }
    };
    /**
     * 将课程价格格式化为首页展示文案。
     */
    const formatPrice = (price?: number | string) => {