From 18f51687b04557267aeeadf68e3e44fa61dbe4a3 Mon Sep 17 00:00:00 2001 From: Shenlijun Date: Tue, 30 Jun 2026 07:35:14 +0800 Subject: [PATCH 1/6] =?UTF-8?q?docs=20=E6=96=B0=E5=A2=9E=20sms=20=E7=9F=AD?= =?UTF-8?q?=E4=BF=A1=E5=8A=9F=E8=83=BD=20enabled=20=E5=BC=80=E5=85=B3?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对齐 mail.enabled 的软开关方案:新增 SmsProperties、条件化 SmsAutoConfiguration、 CaptchaController.smsCode 调用层判断。详见 docs/superpowers/specs/2026-06-30-sms-toggle-design.md Co-Authored-By: Claude --- .../specs/2026-06-30-sms-toggle-design.md | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-30-sms-toggle-design.md diff --git a/docs/superpowers/specs/2026-06-30-sms-toggle-design.md b/docs/superpowers/specs/2026-06-30-sms-toggle-design.md new file mode 100644 index 000000000..6b5bb3da8 --- /dev/null +++ b/docs/superpowers/specs/2026-06-30-sms-toggle-design.md @@ -0,0 +1,142 @@ +# 设计文档:为 SMS 增加 `enabled` 开关(对齐 mail) + +- 日期:2026-06-30 +- 分支:5.X +- 状态:已批准(待实现) + +## 1. 背景与目标 + +RuoYi-Vue-Plus 中,部分服务在配置文件里带开关,例如 `mail.enabled` 可以关闭邮件功能;但 `sms` 没有开关,短信功能始终启用。 + +**目标**:为 `sms` 增加一个 `enabled` 开关,使其与 `mail.enabled` 在语义与代码结构上完全对齐(软开关)。 + +## 2. 现状 + +### 2.1 Mail(有开关)——两层防护 + +- **配置层**:`application-dev.yml` 中 `mail.enabled: false`。 +- **Bean 层**:`MailConfig`(`ruoyi-common-mail/.../config/MailConfig.java`)用 `@ConditionalOnProperty(value="mail.enabled", havingValue="true")` 创建 `MailAccount`;关掉时不创建该 Bean。 +- **调用层**:`CaptchaController.emailCode()` 显式判断 `mailProperties.getEnabled()`,关掉时返回 `R.fail("当前系统没有开启邮箱功能!")`,并拆出独立的 `emailCodeImpl()` 承载 `@RateLimiter`,避免功能关闭后仍消耗限流配额。 + +### 2.2 SMS(无开关) + +- **配置层**:`application-dev.yml` 中 `sms:` 只有 `config-type` / `restricted` / `minute-max` / `account-max` / `blends`,无 `enabled`。 +- **Bean 层**:`SmsAutoConfiguration`(`ruoyi-common-sms/.../config/SmsAutoConfiguration.java`)**无条件**注册 `PlusSmsDao`(`@Primary SmsDao`,Redis 实现)与 `SmsExceptionHandler`;模块内**没有** properties 类、也没有 `SmsUtils`。 +- **第三方层**:`sms4j-spring-boot-starter` 自身的自动配置读取 `sms.blends.*`,始终初始化 `SmsFactory` / `SmsBlend`。 +- **调用层**:直接调用 `SmsFactory.getSmsBlend("config1")`。 + - 活跃调用方:`CaptchaController.smsCode()`(真实场景,短信验证码)、`SmsController`(demo)。 + - `FlwCommonServiceImpl` 中对 `SmsFactory` 的引用是**注释代码**,不计入范围。 + +### 2.3 关键差异 + +`MailAccount` 是**项目自有** Bean,可用 `@ConditionalOnProperty` 精确掐断;`SmsFactory` 是**第三方 sms4j** 自动初始化的,无法用同一注解直接掐掉。本设计采用软开关:真正"关掉发送"由调用层判断完成;Bean 层只做与 `MailConfig` 对齐的清理(关掉时不创建项目自有的 SMS Bean)。 + +## 3. 决策记录 + +- **开关语义:软开关**(已确认)。`sms.enabled=false` 时:调用层返回友好"未开启"提示;项目自有的 `PlusSmsDao` / `SmsExceptionHandler` 不创建;sms4j starter 仍会在后台初始化(其默认 in-memory `SmsDao` 兜底,不影响应用启动,且不会真实发信)。 +- **默认值:`false`**(与 mail 一致)。 +- **命名:`sms.enabled`**(与 `mail.enabled` 完全一致)。 +- **范围:仅覆盖真实调用方 `CaptchaController.smsCode()`**;demo `SmsController` 不动。 + +## 4. 详细设计 + +### 4.1 配置文件 + +文件:`ruoyi-admin/src/main/resources/application-dev.yml` 与 `application-prod.yml`。 + +在 `sms:` 节点顶部新增 `enabled`: + +```yaml +--- # sms 短信 支持 阿里云 腾讯云 云片 等等各式各样的短信服务商 +sms: + # 是否开启短信功能(默认关,与 mail 保持一致) + enabled: false + # 配置源类型用于标定配置来源(interface,yaml) + config-type: yaml + # ...其余原配置不变 +``` + +### 4.2 新增 `SmsProperties` + +文件:`ruoyi-common/ruoyi-common-sms/src/main/java/org/dromara/common/sms/config/properties/SmsProperties.java` + +```java +package org.dromara.common.sms.config.properties; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * 短信配置属性 + */ +@Data +@ConfigurationProperties(prefix = "sms") +public class SmsProperties { + + /** + * 是否开启短信功能 + */ + private Boolean enabled; +} +``` + +> `sms` 前缀与 sms4j 的 `blends` 等配置共存。Spring Boot 允许同一前缀绑定多个 `@ConfigurationProperties` 类,只要字段不冲突;`enabled` 不属于 sms4j 使用的字段。该假设须在实现阶段通过启动验证。 + +### 4.3 `SmsAutoConfiguration` 条件化 + +文件:`ruoyi-common/ruoyi-common-sms/src/main/java/org/dromara/common/sms/config/SmsAutoConfiguration.java` + +- 类上新增 `@EnableConfigurationProperties(SmsProperties.class)`。 +- `smsDao()` 与 `smsExceptionHandler()` 两个 `@Bean` 方法各加 `@ConditionalOnProperty(prefix = "sms", name = "enabled", havingValue = "true")`。 + +效果:`sms.enabled=false` 时不创建 `PlusSmsDao`、`SmsExceptionHandler`(对齐 `MailConfig` 关掉后不创建 `MailAccount`)。 + +### 4.4 `CaptchaController.smsCode` 调用层判断 + +文件:`ruoyi-admin/src/main/java/org/dromara/web/controller/CaptchaController.java` + +照搬 `emailCode` / `emailCodeImpl` 的拆分手法: + +1. 类字段注入 `SmsProperties`(与已有的 `MailProperties` 并列)。 +2. `smsCode(String phonenumber)`: + - 保留 `@GetMapping("/resource/sms/code")`。 + - **移除**方法上的 `@RateLimiter`。 + - 开头判断 `if (!smsProperties.getEnabled()) return R.fail("当前系统没有开启短信功能!");`。 + - 否则调用 `SpringUtils.getAopProxy(this).smsCodeImpl(phonenumber);` 并 `return R.ok();`。 +3. 新增 `smsCodeImpl(String phonenumber)`: + - 标注 `@RateLimiter(key = "#phonenumber", time = 60, count = 1)`。 + - 承载原 `smsCode` 内部的验证码生成、Redis 缓存、`SmsFactory.getSmsBlend("config1").sendMessage(...)` 逻辑。 + +> 这样功能关闭时不再消耗限流配额,与 `emailCode` 的实现一致。 + +## 5. 不在范围内(YAGNI) + +- 不新建 `SmsUtils` 包装类:现有调用方直接使用 `SmsFactory`,开关不需要它。 +- 不修改 demo `SmsController`:仅测试用途;如需一致可后续补充。 +- 不改动 sms4j 自身的自动配置:属于"硬开关"范畴,本次明确排除。 + +## 6. 风险与验证(TDD) + +| 风险 | 验证方式 | +|---|---| +| `sms` 前缀与 sms4j 共存绑定时启动报错 | 启动 Spring 上下文,断言 `SmsProperties.enabled` 能被正确读取 | +| 关掉 `PlusSmsDao` 后 sms4j 找不到 `SmsDao` 而崩溃 | `sms.enabled=false` 下上下文正常启动,sms4j 使用默认 in-memory `SmsDao` 兜底 | + +> **`PlusSmsDao` 条件化的回退决策**:实现阶段先验证 sms4j 是否自带默认 `SmsDao`。 +> - 若**自带默认** `SmsDao`:按设计对 `PlusSmsDao` 加条件化(关掉时用默认兜底)。 +> - 若**不自带**且启动强依赖 `SmsDao` Bean:则 `PlusSmsDao` **保持无条件注册**(始终提供 Redis 实现),仅对 `SmsExceptionHandler` 加条件化,真正"关掉发送"完全由 4.4 的调用层判断承担。届时回退方案不影响本设计的开关语义(仍是软开关)。 +| 开关行为不符合预期 | `enabled=false`:无 `PlusSmsDao` Bean + 调用 `/resource/sms/code` 返回"未开启"提示;`enabled=true`:逻辑与现状一致 | + +测试要点: +- `sms.enabled=false`:(1) 不存在 `PlusSmsDao` Bean;(2) 调用 `smsCode` 返回失败提示且**不**进入 `smsCodeImpl`;(3) 上下文正常启动。 +- `sms.enabled=true`:`PlusSmsDao` 存在且为 `@Primary`;`smsCode` 正常进入发送流程(与改动前行为一致)。 + +## 7. 影响面 + +- 配置:`application-dev.yml`、`application-prod.yml`(`sms:` 节点新增 `enabled`)。 +- 代码: + - 新增 `SmsProperties.java`。 + - 修改 `SmsAutoConfiguration.java`。 + - 修改 `CaptchaController.java`(`smsCode` 拆分 + 注入 `SmsProperties`)。 +- 注册:`SmsAutoConfiguration` 已在 `ruoyi-common-sms` 的 `AutoConfiguration.imports` 中,无需改动;新增的 `SmsProperties` 通过 `@EnableConfigurationProperties` 注册,无需改动 imports。 +- 兼容性:默认 `false` 与 mail 一致;现有未配置 `sms.enabled` 的环境需显式设置才能开启短信功能。 From 9d9fd42aefb7597da04a3992e0da9591d7f08b20 Mon Sep 17 00:00:00 2001 From: Shenlijun Date: Tue, 30 Jun 2026 20:36:45 +0800 Subject: [PATCH 2/6] =?UTF-8?q?add=20=E6=96=B0=E5=A2=9E=20SmsProperties=20?= =?UTF-8?q?=E5=B9=B6=E6=B3=A8=E5=86=8C=20sms.enabled=20=E5=BC=80=E5=85=B3?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E5=85=B3=E9=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/resources/application-dev.yml | 2 + .../src/main/resources/application-prod.yml | 2 + .../org/dromara/test/SmsPropertiesTest.java | 46 +++++++++++++++++++ .../sms/config/SmsAutoConfiguration.java | 3 ++ .../sms/config/properties/SmsProperties.java | 19 ++++++++ 5 files changed, 72 insertions(+) create mode 100644 ruoyi-admin/src/test/java/org/dromara/test/SmsPropertiesTest.java create mode 100644 ruoyi-common/ruoyi-common-sms/src/main/java/org/dromara/common/sms/config/properties/SmsProperties.java diff --git a/ruoyi-admin/src/main/resources/application-dev.yml b/ruoyi-admin/src/main/resources/application-dev.yml index 1b52fab96..747c88617 100644 --- a/ruoyi-admin/src/main/resources/application-dev.yml +++ b/ruoyi-admin/src/main/resources/application-dev.yml @@ -156,6 +156,8 @@ mail: --- # sms 短信 支持 阿里云 腾讯云 云片 等等各式各样的短信服务商 # https://sms4j.com/doc3/ 差异配置文档地址 支持单厂商多配置,可以配置多个同时使用 sms: + # 是否开启短信功能(默认关,与 mail 保持一致) + enabled: false # 配置源类型用于标定配置来源(interface,yaml) config-type: yaml # 用于标定yml中的配置是否开启短信拦截,接口配置不受此限制 diff --git a/ruoyi-admin/src/main/resources/application-prod.yml b/ruoyi-admin/src/main/resources/application-prod.yml index d77ddf57c..9cc14637e 100644 --- a/ruoyi-admin/src/main/resources/application-prod.yml +++ b/ruoyi-admin/src/main/resources/application-prod.yml @@ -159,6 +159,8 @@ mail: --- # sms 短信 支持 阿里云 腾讯云 云片 等等各式各样的短信服务商 # https://sms4j.com/doc3/ 差异配置文档地址 支持单厂商多配置,可以配置多个同时使用 sms: + # 是否开启短信功能(默认关,与 mail 保持一致) + enabled: false # 配置源类型用于标定配置来源(interface,yaml) config-type: yaml # 用于标定yml中的配置是否开启短信拦截,接口配置不受此限制 diff --git a/ruoyi-admin/src/test/java/org/dromara/test/SmsPropertiesTest.java b/ruoyi-admin/src/test/java/org/dromara/test/SmsPropertiesTest.java new file mode 100644 index 000000000..52f63b5a4 --- /dev/null +++ b/ruoyi-admin/src/test/java/org/dromara/test/SmsPropertiesTest.java @@ -0,0 +1,46 @@ +package org.dromara.test; + +import org.dromara.common.sms.config.SmsAutoConfiguration; +import org.dromara.common.sms.config.properties.SmsProperties; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * SmsProperties 绑定 + SmsAutoConfiguration 条件化测试 + *

使用 ApplicationContextRunner,无需启动完整上下文(不需要 MySQL/Redis) + */ +@DisplayName("SMS 开关配置测试") +public class SmsPropertiesTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(SmsAutoConfiguration.class)); + + @Tag("dev") + @DisplayName("sms.enabled=false 时绑定到 SmsProperties.enabled=false") + @Test + public void shouldBindEnabledFalse() { + contextRunner + .withPropertyValues("sms.enabled=false") + .run(context -> { + assertThat(context).hasSingleBean(SmsProperties.class); + assertThat(context.getBean(SmsProperties.class).getEnabled()).isEqualTo(false); + }); + } + + @Tag("dev") + @DisplayName("sms.enabled=true 时绑定到 SmsProperties.enabled=true") + @Test + public void shouldBindEnabledTrue() { + contextRunner + .withPropertyValues("sms.enabled=true") + .run(context -> { + assertThat(context).hasSingleBean(SmsProperties.class); + assertThat(context.getBean(SmsProperties.class).getEnabled()).isEqualTo(true); + }); + } +} diff --git a/ruoyi-common/ruoyi-common-sms/src/main/java/org/dromara/common/sms/config/SmsAutoConfiguration.java b/ruoyi-common/ruoyi-common-sms/src/main/java/org/dromara/common/sms/config/SmsAutoConfiguration.java index 3a39cc216..ba9f578b3 100644 --- a/ruoyi-common/ruoyi-common-sms/src/main/java/org/dromara/common/sms/config/SmsAutoConfiguration.java +++ b/ruoyi-common/ruoyi-common-sms/src/main/java/org/dromara/common/sms/config/SmsAutoConfiguration.java @@ -1,10 +1,12 @@ package org.dromara.common.sms.config; +import org.dromara.common.sms.config.properties.SmsProperties; import org.dromara.common.sms.core.dao.PlusSmsDao; import org.dromara.common.sms.handler.SmsExceptionHandler; import org.dromara.sms4j.api.dao.SmsDao; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Primary; @@ -14,6 +16,7 @@ import org.springframework.context.annotation.Primary; * @author Feng */ @AutoConfiguration(after = {RedisAutoConfiguration.class}) +@EnableConfigurationProperties(SmsProperties.class) public class SmsAutoConfiguration { @Primary diff --git a/ruoyi-common/ruoyi-common-sms/src/main/java/org/dromara/common/sms/config/properties/SmsProperties.java b/ruoyi-common/ruoyi-common-sms/src/main/java/org/dromara/common/sms/config/properties/SmsProperties.java new file mode 100644 index 000000000..d6398f08b --- /dev/null +++ b/ruoyi-common/ruoyi-common-sms/src/main/java/org/dromara/common/sms/config/properties/SmsProperties.java @@ -0,0 +1,19 @@ +package org.dromara.common.sms.config.properties; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * 短信配置属性 + * + * @author ruoyi + */ +@Data +@ConfigurationProperties(prefix = "sms") +public class SmsProperties { + + /** + * 是否开启短信功能(默认关闭,与 mail.enabled 保持一致) + */ + private Boolean enabled; +} From 7865f5210ed217d969c56791bbb498f116ddfa72 Mon Sep 17 00:00:00 2001 From: Shenlijun Date: Tue, 30 Jun 2026 20:47:19 +0800 Subject: [PATCH 3/6] =?UTF-8?q?update=20=E6=9D=A1=E4=BB=B6=E5=8C=96=20SmsA?= =?UTF-8?q?utoConfiguration=20=E7=9A=84=20SmsDao/=E5=BC=82=E5=B8=B8?= =?UTF-8?q?=E5=A4=84=E7=90=86=E5=99=A8=20Bean?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/dromara/test/SmsPropertiesTest.java | 26 +++++++++++++++++++ .../sms/config/SmsAutoConfiguration.java | 3 +++ 2 files changed, 29 insertions(+) diff --git a/ruoyi-admin/src/test/java/org/dromara/test/SmsPropertiesTest.java b/ruoyi-admin/src/test/java/org/dromara/test/SmsPropertiesTest.java index 52f63b5a4..0c5c51ee1 100644 --- a/ruoyi-admin/src/test/java/org/dromara/test/SmsPropertiesTest.java +++ b/ruoyi-admin/src/test/java/org/dromara/test/SmsPropertiesTest.java @@ -2,6 +2,8 @@ package org.dromara.test; import org.dromara.common.sms.config.SmsAutoConfiguration; import org.dromara.common.sms.config.properties.SmsProperties; +import org.dromara.common.sms.handler.SmsExceptionHandler; +import org.dromara.sms4j.api.dao.SmsDao; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; @@ -43,4 +45,28 @@ public class SmsPropertiesTest { assertThat(context.getBean(SmsProperties.class).getEnabled()).isEqualTo(true); }); } + + @Tag("dev") + @DisplayName("sms.enabled=false 时不创建 PlusSmsDao / SmsExceptionHandler") + @Test + public void shouldNotCreateSmsBeansWhenDisabled() { + contextRunner + .withPropertyValues("sms.enabled=false") + .run(context -> { + assertThat(context).doesNotHaveBean(SmsDao.class); + assertThat(context).doesNotHaveBean(SmsExceptionHandler.class); + }); + } + + @Tag("dev") + @DisplayName("sms.enabled=true 时创建 PlusSmsDao / SmsExceptionHandler") + @Test + public void shouldCreateSmsBeansWhenEnabled() { + contextRunner + .withPropertyValues("sms.enabled=true") + .run(context -> { + assertThat(context).hasSingleBean(SmsDao.class); + assertThat(context).hasSingleBean(SmsExceptionHandler.class); + }); + } } diff --git a/ruoyi-common/ruoyi-common-sms/src/main/java/org/dromara/common/sms/config/SmsAutoConfiguration.java b/ruoyi-common/ruoyi-common-sms/src/main/java/org/dromara/common/sms/config/SmsAutoConfiguration.java index ba9f578b3..d125b5cd2 100644 --- a/ruoyi-common/ruoyi-common-sms/src/main/java/org/dromara/common/sms/config/SmsAutoConfiguration.java +++ b/ruoyi-common/ruoyi-common-sms/src/main/java/org/dromara/common/sms/config/SmsAutoConfiguration.java @@ -5,6 +5,7 @@ import org.dromara.common.sms.core.dao.PlusSmsDao; import org.dromara.common.sms.handler.SmsExceptionHandler; import org.dromara.sms4j.api.dao.SmsDao; import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; @@ -21,6 +22,7 @@ public class SmsAutoConfiguration { @Primary @Bean + @ConditionalOnProperty(prefix = "sms", name = "enabled", havingValue = "true") public SmsDao smsDao() { return new PlusSmsDao(); } @@ -29,6 +31,7 @@ public class SmsAutoConfiguration { * 异常处理器 */ @Bean + @ConditionalOnProperty(prefix = "sms", name = "enabled", havingValue = "true") public SmsExceptionHandler smsExceptionHandler() { return new SmsExceptionHandler(); } From 447f6cdd39a2266acbe9833e775b2e099ac5571a Mon Sep 17 00:00:00 2001 From: Shenlijun Date: Tue, 30 Jun 2026 20:54:38 +0800 Subject: [PATCH 4/6] =?UTF-8?q?update=20CaptchaController.smsCode=20?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=9F=AD=E4=BF=A1=E5=BC=80=E5=85=B3=E5=88=A4?= =?UTF-8?q?=E6=96=AD=E5=AF=B9=E9=BD=90=20emailCode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/controller/CaptchaController.java | 19 ++++++++-- .../org/dromara/test/CaptchaSmsCodeTest.java | 35 +++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 ruoyi-admin/src/test/java/org/dromara/test/CaptchaSmsCodeTest.java diff --git a/ruoyi-admin/src/main/java/org/dromara/web/controller/CaptchaController.java b/ruoyi-admin/src/main/java/org/dromara/web/controller/CaptchaController.java index 2586addab..09d2f90ac 100644 --- a/ruoyi-admin/src/main/java/org/dromara/web/controller/CaptchaController.java +++ b/ruoyi-admin/src/main/java/org/dromara/web/controller/CaptchaController.java @@ -22,6 +22,7 @@ import org.dromara.common.ratelimiter.enums.LimitType; import org.dromara.common.redis.utils.RedisUtils; import org.dromara.common.web.core.WaveAndCircleCaptcha; import org.dromara.common.web.config.properties.CaptchaProperties; +import org.dromara.common.sms.config.properties.SmsProperties; import org.dromara.sms4j.api.SmsBlend; import org.dromara.sms4j.api.entity.SmsResponse; import org.dromara.sms4j.core.factory.SmsFactory; @@ -51,15 +52,28 @@ public class CaptchaController { private final CaptchaProperties captchaProperties; private final MailProperties mailProperties; + private final SmsProperties smsProperties; /** * 短信验证码 * * @param phonenumber 用户手机号 */ - @RateLimiter(key = "#phonenumber", time = 60, count = 1) @GetMapping("/resource/sms/code") public R smsCode(@NotBlank(message = "{user.phonenumber.not.blank}") String phonenumber) { + if (!smsProperties.getEnabled()) { + return R.fail("当前系统没有开启短信功能!"); + } + SpringUtils.getAopProxy(this).smsCodeImpl(phonenumber); + return R.ok(); + } + + /** + * 短信验证码 + * 独立方法避免短信功能关闭之后仍然走限流 + */ + @RateLimiter(key = "#phonenumber", time = 60, count = 1) + public void smsCodeImpl(@NotBlank(message = "{user.phonenumber.not.blank}") String phonenumber) { String key = GlobalConstants.CAPTCHA_CODE_KEY + phonenumber; String code = RandomUtil.randomNumbers(4); RedisUtils.setCacheObject(key, code, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION)); @@ -71,9 +85,8 @@ public class CaptchaController { SmsResponse smsResponse = smsBlend.sendMessage(phonenumber, templateId, map); if (!smsResponse.isSuccess()) { log.error("验证码短信发送异常 => {}", smsResponse); - return R.fail(smsResponse.getData().toString()); + throw new ServiceException(smsResponse.getData().toString()); } - return R.ok(); } /** diff --git a/ruoyi-admin/src/test/java/org/dromara/test/CaptchaSmsCodeTest.java b/ruoyi-admin/src/test/java/org/dromara/test/CaptchaSmsCodeTest.java new file mode 100644 index 000000000..fd76cc3ae --- /dev/null +++ b/ruoyi-admin/src/test/java/org/dromara/test/CaptchaSmsCodeTest.java @@ -0,0 +1,35 @@ +package org.dromara.test; + +import org.dromara.common.core.domain.R; +import org.dromara.common.mail.config.properties.MailProperties; +import org.dromara.common.sms.config.properties.SmsProperties; +import org.dromara.common.web.config.properties.CaptchaProperties; +import org.dromara.web.controller.CaptchaController; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * CaptchaController.smsCode 关闭路径单元测试 + *

直接构造 Controller 调用,不启动 Spring 上下文(无需 MySQL/Redis) + */ +@DisplayName("短信验证码开关单元测试") +public class CaptchaSmsCodeTest { + + @Tag("dev") + @DisplayName("sms.enabled=false 时 smsCode 返回未开启提示且不发信") + @Test + public void shouldReturnFailWhenSmsDisabled() { + SmsProperties smsProperties = new SmsProperties(); + smsProperties.setEnabled(false); + // 字段声明顺序决定构造参数顺序:captchaProperties, mailProperties, smsProperties + CaptchaController controller = new CaptchaController( + new CaptchaProperties(), new MailProperties(), smsProperties); + + R result = controller.smsCode("13800000000"); + + Assertions.assertEquals(R.FAIL, result.getCode()); + Assertions.assertEquals("当前系统没有开启短信功能!", result.getMsg()); + } +} From b8a8c6a0c3774ae269583497b2051707f62b188b Mon Sep 17 00:00:00 2001 From: Shenlijun Date: Tue, 30 Jun 2026 21:06:37 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix=20PlusSmsDao=20=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E6=97=A0=E6=9D=A1=E4=BB=B6=E6=B3=A8=E5=86=8C(sms4j=20=E5=8F=97?= =?UTF-8?q?=E9=99=90=E5=A4=84=E7=90=86=E5=99=A8=E5=90=AF=E5=8A=A8=E6=9C=9F?= =?UTF-8?q?=E6=8B=89=E5=8F=96=20SmsDao=20=E4=BC=9A=E6=89=93=20ERROR=20?= =?UTF-8?q?=E6=97=A5=E5=BF=97)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 应用 spec §6 预留的 fallback:从 smsDao() 移除 @ConditionalOnProperty, PlusSmsDao 重新无条件注册。原因:sms4j restricted=true 默认下,启动期 SmsBlendsInitializer 注册的 RestrictedProcessor/BlackListProcessor 等 SmsDaoAware 处理器会调 getBean(SmsDao.class),bean 被门控关闭时抛 NoSuchBeanDefinitionException → ERROR 日志 → 被 swallow 后回落默认 dao。 app 能启动但每次 boot 都打一条 ERROR(sms.enabled=false 是 dev+prod 出厂默认)。 PlusSmsDao 无条件在场 → getBean 成功 → 无异常 → 无 ERROR 日志。 软开关语义不变:发短信的真正拦截在 CaptchaController.smsCode 的 enabled 检查。 smsExceptionHandler() 仍保留 @ConditionalOnProperty(只在启用时注册)。 更新 SmsPropertiesTest:shouldAlwaysRegisterSmsDaoButOmitHandlerWhenDisabled 反映 enabled=false 时 SmsDao 在场、Handler 缺席的新语义。 Co-Authored-By: Claude --- .../src/test/java/org/dromara/test/SmsPropertiesTest.java | 6 +++--- .../org/dromara/common/sms/config/SmsAutoConfiguration.java | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/ruoyi-admin/src/test/java/org/dromara/test/SmsPropertiesTest.java b/ruoyi-admin/src/test/java/org/dromara/test/SmsPropertiesTest.java index 0c5c51ee1..feb3a03e4 100644 --- a/ruoyi-admin/src/test/java/org/dromara/test/SmsPropertiesTest.java +++ b/ruoyi-admin/src/test/java/org/dromara/test/SmsPropertiesTest.java @@ -47,13 +47,13 @@ public class SmsPropertiesTest { } @Tag("dev") - @DisplayName("sms.enabled=false 时不创建 PlusSmsDao / SmsExceptionHandler") + @DisplayName("sms.enabled=false 时仍注册 SmsDao,但不创建 SmsExceptionHandler") @Test - public void shouldNotCreateSmsBeansWhenDisabled() { + public void shouldAlwaysRegisterSmsDaoButOmitHandlerWhenDisabled() { contextRunner .withPropertyValues("sms.enabled=false") .run(context -> { - assertThat(context).doesNotHaveBean(SmsDao.class); + assertThat(context).hasSingleBean(SmsDao.class); assertThat(context).doesNotHaveBean(SmsExceptionHandler.class); }); } diff --git a/ruoyi-common/ruoyi-common-sms/src/main/java/org/dromara/common/sms/config/SmsAutoConfiguration.java b/ruoyi-common/ruoyi-common-sms/src/main/java/org/dromara/common/sms/config/SmsAutoConfiguration.java index d125b5cd2..fd83b471a 100644 --- a/ruoyi-common/ruoyi-common-sms/src/main/java/org/dromara/common/sms/config/SmsAutoConfiguration.java +++ b/ruoyi-common/ruoyi-common-sms/src/main/java/org/dromara/common/sms/config/SmsAutoConfiguration.java @@ -22,7 +22,6 @@ public class SmsAutoConfiguration { @Primary @Bean - @ConditionalOnProperty(prefix = "sms", name = "enabled", havingValue = "true") public SmsDao smsDao() { return new PlusSmsDao(); } From ef030a364050d16c87e7fc6ee030046a30859a07 Mon Sep 17 00:00:00 2001 From: CyberShen <8096927+CyberShen123@user.noreply.gitee.com> Date: Wed, 1 Jul 2026 02:39:06 +0000 Subject: [PATCH 6/6] =?UTF-8?q?=E5=88=A0=E9=99=A4=E6=96=87=E4=BB=B6=20docs?= =?UTF-8?q?/superpowers/specs/2026-06-30-sms-toggle-design.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../specs/2026-06-30-sms-toggle-design.md | 142 ------------------ 1 file changed, 142 deletions(-) delete mode 100644 docs/superpowers/specs/2026-06-30-sms-toggle-design.md diff --git a/docs/superpowers/specs/2026-06-30-sms-toggle-design.md b/docs/superpowers/specs/2026-06-30-sms-toggle-design.md deleted file mode 100644 index 6b5bb3da8..000000000 --- a/docs/superpowers/specs/2026-06-30-sms-toggle-design.md +++ /dev/null @@ -1,142 +0,0 @@ -# 设计文档:为 SMS 增加 `enabled` 开关(对齐 mail) - -- 日期:2026-06-30 -- 分支:5.X -- 状态:已批准(待实现) - -## 1. 背景与目标 - -RuoYi-Vue-Plus 中,部分服务在配置文件里带开关,例如 `mail.enabled` 可以关闭邮件功能;但 `sms` 没有开关,短信功能始终启用。 - -**目标**:为 `sms` 增加一个 `enabled` 开关,使其与 `mail.enabled` 在语义与代码结构上完全对齐(软开关)。 - -## 2. 现状 - -### 2.1 Mail(有开关)——两层防护 - -- **配置层**:`application-dev.yml` 中 `mail.enabled: false`。 -- **Bean 层**:`MailConfig`(`ruoyi-common-mail/.../config/MailConfig.java`)用 `@ConditionalOnProperty(value="mail.enabled", havingValue="true")` 创建 `MailAccount`;关掉时不创建该 Bean。 -- **调用层**:`CaptchaController.emailCode()` 显式判断 `mailProperties.getEnabled()`,关掉时返回 `R.fail("当前系统没有开启邮箱功能!")`,并拆出独立的 `emailCodeImpl()` 承载 `@RateLimiter`,避免功能关闭后仍消耗限流配额。 - -### 2.2 SMS(无开关) - -- **配置层**:`application-dev.yml` 中 `sms:` 只有 `config-type` / `restricted` / `minute-max` / `account-max` / `blends`,无 `enabled`。 -- **Bean 层**:`SmsAutoConfiguration`(`ruoyi-common-sms/.../config/SmsAutoConfiguration.java`)**无条件**注册 `PlusSmsDao`(`@Primary SmsDao`,Redis 实现)与 `SmsExceptionHandler`;模块内**没有** properties 类、也没有 `SmsUtils`。 -- **第三方层**:`sms4j-spring-boot-starter` 自身的自动配置读取 `sms.blends.*`,始终初始化 `SmsFactory` / `SmsBlend`。 -- **调用层**:直接调用 `SmsFactory.getSmsBlend("config1")`。 - - 活跃调用方:`CaptchaController.smsCode()`(真实场景,短信验证码)、`SmsController`(demo)。 - - `FlwCommonServiceImpl` 中对 `SmsFactory` 的引用是**注释代码**,不计入范围。 - -### 2.3 关键差异 - -`MailAccount` 是**项目自有** Bean,可用 `@ConditionalOnProperty` 精确掐断;`SmsFactory` 是**第三方 sms4j** 自动初始化的,无法用同一注解直接掐掉。本设计采用软开关:真正"关掉发送"由调用层判断完成;Bean 层只做与 `MailConfig` 对齐的清理(关掉时不创建项目自有的 SMS Bean)。 - -## 3. 决策记录 - -- **开关语义:软开关**(已确认)。`sms.enabled=false` 时:调用层返回友好"未开启"提示;项目自有的 `PlusSmsDao` / `SmsExceptionHandler` 不创建;sms4j starter 仍会在后台初始化(其默认 in-memory `SmsDao` 兜底,不影响应用启动,且不会真实发信)。 -- **默认值:`false`**(与 mail 一致)。 -- **命名:`sms.enabled`**(与 `mail.enabled` 完全一致)。 -- **范围:仅覆盖真实调用方 `CaptchaController.smsCode()`**;demo `SmsController` 不动。 - -## 4. 详细设计 - -### 4.1 配置文件 - -文件:`ruoyi-admin/src/main/resources/application-dev.yml` 与 `application-prod.yml`。 - -在 `sms:` 节点顶部新增 `enabled`: - -```yaml ---- # sms 短信 支持 阿里云 腾讯云 云片 等等各式各样的短信服务商 -sms: - # 是否开启短信功能(默认关,与 mail 保持一致) - enabled: false - # 配置源类型用于标定配置来源(interface,yaml) - config-type: yaml - # ...其余原配置不变 -``` - -### 4.2 新增 `SmsProperties` - -文件:`ruoyi-common/ruoyi-common-sms/src/main/java/org/dromara/common/sms/config/properties/SmsProperties.java` - -```java -package org.dromara.common.sms.config.properties; - -import lombok.Data; -import org.springframework.boot.context.properties.ConfigurationProperties; - -/** - * 短信配置属性 - */ -@Data -@ConfigurationProperties(prefix = "sms") -public class SmsProperties { - - /** - * 是否开启短信功能 - */ - private Boolean enabled; -} -``` - -> `sms` 前缀与 sms4j 的 `blends` 等配置共存。Spring Boot 允许同一前缀绑定多个 `@ConfigurationProperties` 类,只要字段不冲突;`enabled` 不属于 sms4j 使用的字段。该假设须在实现阶段通过启动验证。 - -### 4.3 `SmsAutoConfiguration` 条件化 - -文件:`ruoyi-common/ruoyi-common-sms/src/main/java/org/dromara/common/sms/config/SmsAutoConfiguration.java` - -- 类上新增 `@EnableConfigurationProperties(SmsProperties.class)`。 -- `smsDao()` 与 `smsExceptionHandler()` 两个 `@Bean` 方法各加 `@ConditionalOnProperty(prefix = "sms", name = "enabled", havingValue = "true")`。 - -效果:`sms.enabled=false` 时不创建 `PlusSmsDao`、`SmsExceptionHandler`(对齐 `MailConfig` 关掉后不创建 `MailAccount`)。 - -### 4.4 `CaptchaController.smsCode` 调用层判断 - -文件:`ruoyi-admin/src/main/java/org/dromara/web/controller/CaptchaController.java` - -照搬 `emailCode` / `emailCodeImpl` 的拆分手法: - -1. 类字段注入 `SmsProperties`(与已有的 `MailProperties` 并列)。 -2. `smsCode(String phonenumber)`: - - 保留 `@GetMapping("/resource/sms/code")`。 - - **移除**方法上的 `@RateLimiter`。 - - 开头判断 `if (!smsProperties.getEnabled()) return R.fail("当前系统没有开启短信功能!");`。 - - 否则调用 `SpringUtils.getAopProxy(this).smsCodeImpl(phonenumber);` 并 `return R.ok();`。 -3. 新增 `smsCodeImpl(String phonenumber)`: - - 标注 `@RateLimiter(key = "#phonenumber", time = 60, count = 1)`。 - - 承载原 `smsCode` 内部的验证码生成、Redis 缓存、`SmsFactory.getSmsBlend("config1").sendMessage(...)` 逻辑。 - -> 这样功能关闭时不再消耗限流配额,与 `emailCode` 的实现一致。 - -## 5. 不在范围内(YAGNI) - -- 不新建 `SmsUtils` 包装类:现有调用方直接使用 `SmsFactory`,开关不需要它。 -- 不修改 demo `SmsController`:仅测试用途;如需一致可后续补充。 -- 不改动 sms4j 自身的自动配置:属于"硬开关"范畴,本次明确排除。 - -## 6. 风险与验证(TDD) - -| 风险 | 验证方式 | -|---|---| -| `sms` 前缀与 sms4j 共存绑定时启动报错 | 启动 Spring 上下文,断言 `SmsProperties.enabled` 能被正确读取 | -| 关掉 `PlusSmsDao` 后 sms4j 找不到 `SmsDao` 而崩溃 | `sms.enabled=false` 下上下文正常启动,sms4j 使用默认 in-memory `SmsDao` 兜底 | - -> **`PlusSmsDao` 条件化的回退决策**:实现阶段先验证 sms4j 是否自带默认 `SmsDao`。 -> - 若**自带默认** `SmsDao`:按设计对 `PlusSmsDao` 加条件化(关掉时用默认兜底)。 -> - 若**不自带**且启动强依赖 `SmsDao` Bean:则 `PlusSmsDao` **保持无条件注册**(始终提供 Redis 实现),仅对 `SmsExceptionHandler` 加条件化,真正"关掉发送"完全由 4.4 的调用层判断承担。届时回退方案不影响本设计的开关语义(仍是软开关)。 -| 开关行为不符合预期 | `enabled=false`:无 `PlusSmsDao` Bean + 调用 `/resource/sms/code` 返回"未开启"提示;`enabled=true`:逻辑与现状一致 | - -测试要点: -- `sms.enabled=false`:(1) 不存在 `PlusSmsDao` Bean;(2) 调用 `smsCode` 返回失败提示且**不**进入 `smsCodeImpl`;(3) 上下文正常启动。 -- `sms.enabled=true`:`PlusSmsDao` 存在且为 `@Primary`;`smsCode` 正常进入发送流程(与改动前行为一致)。 - -## 7. 影响面 - -- 配置:`application-dev.yml`、`application-prod.yml`(`sms:` 节点新增 `enabled`)。 -- 代码: - - 新增 `SmsProperties.java`。 - - 修改 `SmsAutoConfiguration.java`。 - - 修改 `CaptchaController.java`(`smsCode` 拆分 + 注入 `SmsProperties`)。 -- 注册:`SmsAutoConfiguration` 已在 `ruoyi-common-sms` 的 `AutoConfiguration.imports` 中,无需改动;新增的 `SmsProperties` 通过 `@EnableConfigurationProperties` 注册,无需改动 imports。 -- 兼容性:默认 `false` 与 mail 一致;现有未配置 `sms.enabled` 的环境需显式设置才能开启短信功能。