diff --git a/docs/openapi-rc.json b/docs/openapi-rc.json
index f57076e..f141cc0 100644
--- a/docs/openapi-rc.json
+++ b/docs/openapi-rc.json
@@ -1461,7 +1461,7 @@
"认证"
],
"summary": "发送短信验证码",
- "description": "开发/测试使用 Mock 发送实现;生产环境未接入短信网关时返回服务不可用。验证码不会写入日志。",
+ "description": "真实发送使用阿里云短信网关;Mock 仅供开发测试。网关受理不等于手机送达,验证码不会写入日志。",
"operationId": "sendSmsCode",
"parameters": [
{
diff --git a/mydreamjob-server/pom.xml b/mydreamjob-server/pom.xml
index 6c77e07..0fb765f 100644
--- a/mydreamjob-server/pom.xml
+++ b/mydreamjob-server/pom.xml
@@ -25,6 +25,12 @@
+
+
+ com.aliyun
+ dysmsapi20170525
+ 4.6.0
+
org.springframework.boot
diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/AliyunSmsCodeSender.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/AliyunSmsCodeSender.java
new file mode 100644
index 0000000..ab5c0a0
--- /dev/null
+++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/AliyunSmsCodeSender.java
@@ -0,0 +1,57 @@
+package com.mydreamjob.auth.sms;
+
+import com.aliyun.dysmsapi20170525.Client;
+import com.aliyun.dysmsapi20170525.models.SendSmsRequest;
+import com.aliyun.dysmsapi20170525.models.SendSmsResponse;
+import com.aliyun.teautil.models.RuntimeOptions;
+import com.mydreamjob.common.exception.BusinessException;
+import com.mydreamjob.common.exception.ErrorCode;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import java.util.Set;
+
+/** SDK 返回 OK 只代表网关受理,不代表手机已收到;禁止自动重试非幂等发送。 */
+public final class AliyunSmsCodeSender implements SmsCodeSender {
+ private static final Logger LOG = LoggerFactory.getLogger(AliyunSmsCodeSender.class);
+ private static final Set PURPOSES = Set.of("login", "password-set", "password-reset", "company-register");
+ private final Client client;
+ private final String signName;
+ private final String templateCode;
+ private final int connectTimeoutMs;
+ private final int readTimeoutMs;
+
+ public AliyunSmsCodeSender(Client client, AliyunSmsProperties properties) {
+ properties.validateForSending();
+ this.client = client;
+ this.signName = properties.getSignName();
+ this.templateCode = properties.getTemplateCode();
+ this.connectTimeoutMs = properties.getConnectTimeoutMs();
+ this.readTimeoutMs = properties.getReadTimeoutMs();
+ }
+
+ @Override
+ public void sendVerificationCode(String phone, String purpose, String code) {
+ if (phone == null || !phone.matches("[0-9]{11}") || purpose == null || !PURPOSES.contains(purpose)
+ || code == null || !code.matches("[0-9]{6}")) {
+ throw new BusinessException(ErrorCode.BAD_REQUEST, "invalid SMS request");
+ }
+ SendSmsRequest request = new SendSmsRequest().setPhoneNumbers(phone).setSignName(signName)
+ .setTemplateCode(templateCode).setTemplateParam("{\"code\":\"" + code + "\"}");
+ RuntimeOptions runtime = new RuntimeOptions().setAutoretry(false)
+ .setConnectTimeout(connectTimeoutMs).setReadTimeout(readTimeoutMs);
+ SendSmsResponse response;
+ try {
+ response = client.sendSmsWithOptions(request, runtime);
+ } catch (Exception ignored) {
+ if (ignored instanceof InterruptedException) Thread.currentThread().interrupt();
+ // SDK 异常可能包含请求绑定值,既不透传原因也不输出堆栈。
+ LOG.warn("Aliyun SMS gateway call failed");
+ throw new BusinessException(ErrorCode.SMS_SEND_FAILED);
+ }
+ if (response == null || response.getBody() == null || response.getStatusCode() == null
+ || response.getStatusCode() != 200 || !"OK".equals(response.getBody().getCode())) {
+ LOG.warn("Aliyun SMS gateway did not accept request");
+ throw new BusinessException(ErrorCode.SMS_SEND_FAILED);
+ }
+ }
+}
diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/AliyunSmsProperties.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/AliyunSmsProperties.java
new file mode 100644
index 0000000..0761216
--- /dev/null
+++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/AliyunSmsProperties.java
@@ -0,0 +1,35 @@
+package com.mydreamjob.auth.sms;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+/** 不生成 toString,避免配置对象输出凭据。只有显式 true 才启用。 */
+@Getter
+@Setter
+@ConfigurationProperties(prefix = "app.sms.aliyun")
+public class AliyunSmsProperties {
+ private String enabled = "false";
+ private String accessKeyId;
+ private String accessKeySecret;
+ private String signName;
+ private String templateCode;
+ private int connectTimeoutMs = 3000;
+ private int readTimeoutMs = 5000;
+
+ public boolean explicitlyEnabled() {
+ return enabled != null && "true".equalsIgnoreCase(enabled.trim());
+ }
+
+ public void validateForSending() {
+ if (blank(accessKeyId) || blank(accessKeySecret) || blank(signName) || blank(templateCode)
+ || !templateCode.matches("SMS_[A-Za-z0-9_]+")
+ || connectTimeoutMs < 1000 || connectTimeoutMs > 30000
+ || readTimeoutMs < 1000 || readTimeoutMs > 30000) {
+ // 不使用包含 rejected value 的校验异常,避免绑定密钥进入启动日志。
+ throw new IllegalStateException("SMS configuration incomplete or invalid");
+ }
+ }
+
+ private static boolean blank(String value) { return value == null || value.isBlank(); }
+}
diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/SmsCodeSender.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/SmsCodeSender.java
index 41f1655..c1246ee 100644
--- a/mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/SmsCodeSender.java
+++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/SmsCodeSender.java
@@ -3,7 +3,8 @@ package com.mydreamjob.auth.sms;
/**
* 短信验证码发送抽象。
*
- * 由 {@code app.sms.mock.enabled} 决定加载哪一个实现:
+ *
真实发送由 {@code app.sms.aliyun.enabled=true} 和完整凭据配置启用;
+ * 未启用真实发送时,由 {@code app.sms.mock.enabled} 决定回退实现:
*
* - {@code true} → {@link MockSmsCodeSender}(本地/测试环境,不调用外部网关)
* - {@code false} 或未配置 → {@link UnavailableSmsCodeSender}(安全失败,不伪造成功)
diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/SmsSenderConfiguration.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/SmsSenderConfiguration.java
index 351a9d9..c5b30bf 100644
--- a/mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/SmsSenderConfiguration.java
+++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/SmsSenderConfiguration.java
@@ -3,15 +3,17 @@ package com.mydreamjob.auth.sms;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
/**
- * 短信发送实现装配(单 Bean 方案)。
+ * 短信发送实现装配(单 Bean 方案,真实发送与 Mock 互斥)。
*
* 由本配置类始终且仅产生一个 {@link SmsCodeSender},从而彻底避免
* “两个互斥 {@code @ConditionalOnProperty} 在空值/空白值下都不匹配 → 0 个 Bean →
* 模糊 Spring 装配错误导致启动失败”的问题。
*
- * 取值语义(大小写不敏感,两侧空白忽略):
+ * 显式启用 Aliyun 时必须具备完整配置,禁止同时启用 Mock;否则启动安全失败。
+ * 未启用 Aliyun 时,Mock 取值语义(大小写不敏感,两侧空白忽略):
*
* - {@code true} → {@link MockSmsCodeSender}
* - {@code false} / 缺失 / 空字符串 / 仅空白 / 其他非法值 → {@link UnavailableSmsCodeSender}
@@ -21,6 +23,7 @@ import org.springframework.context.annotation.Configuration;
* 既不启用 Mock,也不进入发送流程。本类不输出配置原值到日志。
*/
@Configuration
+@EnableConfigurationProperties(AliyunSmsProperties.class)
public class SmsSenderConfiguration {
/** 显式启用 Mock 的唯一取值。 */
@@ -28,8 +31,23 @@ public class SmsSenderConfiguration {
@Bean
public SmsCodeSender smsCodeSender(
- @Value("${app.sms.mock.enabled:false}") String rawEnabled) {
- if (rawEnabled != null && MOCK_ENABLED_VALUE.equalsIgnoreCase(rawEnabled.trim())) {
+ @Value("${app.sms.mock.enabled:false}") String rawEnabled,
+ AliyunSmsProperties properties) {
+ boolean mockEnabled = rawEnabled != null && MOCK_ENABLED_VALUE.equalsIgnoreCase(rawEnabled.trim());
+ if (properties.explicitlyEnabled()) {
+ if (mockEnabled) throw new IllegalStateException("SMS mock and real sender cannot both be enabled");
+ properties.validateForSending();
+ try {
+ var config = new com.aliyun.teaopenapi.models.Config()
+ .setAccessKeyId(properties.getAccessKeyId())
+ .setAccessKeySecret(properties.getAccessKeySecret())
+ .setEndpoint("dysmsapi.aliyuncs.com").setProtocol("HTTPS");
+ return new AliyunSmsCodeSender(new com.aliyun.dysmsapi20170525.Client(config), properties);
+ } catch (Exception ignored) {
+ throw new IllegalStateException("SMS client initialization failed");
+ }
+ }
+ if (mockEnabled) {
return new MockSmsCodeSender();
}
return new UnavailableSmsCodeSender();
diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/UnavailableSmsCodeSender.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/UnavailableSmsCodeSender.java
index f0e5ee0..c90110c 100644
--- a/mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/UnavailableSmsCodeSender.java
+++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/UnavailableSmsCodeSender.java
@@ -9,7 +9,7 @@ import com.mydreamjob.common.exception.ErrorCode;
* 由 {@link SmsSenderConfiguration} 在 {@code app.sms.mock.enabled} 为 false、缺失、
* 空值、仅空白或其他非法值时装配(不再使用条件注解,以保证任何取值下恰好一个 Bean)。
*
- * 当前未接入真实短信网关,因此本实现安全失败:
+ *
未显式启用真实短信网关或 Mock 时,本实现安全失败:
* 抛出既有的“服务不可用”业务异常,绝不返回虚假的“发送成功”。
* 异常消息为固定安全文本,不含手机号、验证码或任何配置细节。
*/
diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java b/mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java
index d571711..ef743a6 100644
--- a/mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java
+++ b/mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java
@@ -82,28 +82,18 @@ public class AuthController {
return ResponseEntity.ok(ApiResponse.ok(response, requestIdAccessor.get(request)));
}
- @Operation(summary = "发送短信验证码", description = "开发/测试使用 Mock 发送实现;生产环境未接入短信网关时返回服务不可用。验证码不会写入日志。")
+ @Operation(summary = "发送短信验证码", description = "真实发送使用阿里云短信网关;Mock 仅供开发测试。网关受理不等于手机送达,验证码不会写入日志。")
@PostMapping("/sms/send")
public ResponseEntity>> sendSmsCode(
@RequestParam @NotBlank(message = "phone is required")
@Pattern(regexp = "[0-9]{11}", message = "phone must contain exactly 11 digits") String phone,
@RequestParam(defaultValue = "login") String purpose,
HttpServletRequest request) {
- try {
- smsVerificationService.sendCode(phone, purpose,
- requestIdAccessor.get(request),
- request.getRemoteAddr(),
- request.getHeader("User-Agent"));
- return ResponseEntity.ok(ApiResponse.ok(
- Map.of("message", "验证码已发送(Mock)"),
- requestIdAccessor.get(request)));
- } catch (ConstraintViolationException e) {
- return ResponseEntity.badRequest().body(
- ApiResponse.badRequest(e.getMessage(), requestIdAccessor.get(request)));
- } catch (Exception e) {
- return ResponseEntity.badRequest().body(
- ApiResponse.error(400, e.getMessage(), requestIdAccessor.get(request)));
- }
+ smsVerificationService.sendCode(phone, purpose,
+ requestIdAccessor.get(request), request.getRemoteAddr(), request.getHeader("User-Agent"));
+ // 保留业务码,统一走异常映射;不把 SDK 或 SQL 原始异常信息写回客户端。
+ return ResponseEntity.ok(ApiResponse.ok(Map.of("message", "验证码发送请求已受理"),
+ requestIdAccessor.get(request)));
}
@Operation(summary = "手机号验证码登录", description = "新手机号创建待选身份统一账号;已有账号保持身份,返回 Access/Refresh Token")
diff --git a/mydreamjob-server/src/main/resources/application.yml b/mydreamjob-server/src/main/resources/application.yml
index 6c2ad5a..b648335 100644
--- a/mydreamjob-server/src/main/resources/application.yml
+++ b/mydreamjob-server/src/main/resources/application.yml
@@ -121,6 +121,14 @@ app:
file-ttl-hours: ${DATA_EXPORT_TTL_HOURS:24}
sms:
+ aliyun:
+ enabled: ${SMS_ALIYUN_ENABLED:false}
+ access-key-id: ${SMS_ALIYUN_ACCESS_KEY_ID:${ALIBABA_CLOUD_ACCESS_KEY_ID:}}
+ access-key-secret: ${SMS_ALIYUN_ACCESS_KEY_SECRET:${ALIBABA_CLOUD_ACCESS_KEY_SECRET:}}
+ sign-name: ${SMS_ALIYUN_SIGN_NAME:}
+ template-code: ${SMS_ALIYUN_TEMPLATE_CODE:}
+ connect-timeout-ms: ${SMS_ALIYUN_CONNECT_TIMEOUT_MS:3000}
+ read-timeout-ms: ${SMS_ALIYUN_READ_TIMEOUT_MS:5000}
mock:
enabled: ${SMS_MOCK_ENABLED:false}
code-length: 6
diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/sms/AliyunSmsCodeSenderTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/sms/AliyunSmsCodeSenderTest.java
new file mode 100644
index 0000000..a42c813
--- /dev/null
+++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/sms/AliyunSmsCodeSenderTest.java
@@ -0,0 +1,83 @@
+package com.mydreamjob.auth.sms;
+
+import com.aliyun.dysmsapi20170525.Client;
+import com.aliyun.dysmsapi20170525.models.*;
+import com.aliyun.teautil.models.RuntimeOptions;
+import com.mydreamjob.common.exception.BusinessException;
+import com.mydreamjob.common.exception.ErrorCode;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+class AliyunSmsCodeSenderTest {
+ private Client client;
+ private AliyunSmsCodeSender sender;
+ private static final String PHONE="19900009890", CODE="652901";
+ @BeforeEach void setup() {
+ client=mock(Client.class);
+ var config=new AliyunSmsProperties();
+ config.setAccessKeyId("test-key-not-real"); config.setAccessKeySecret("test-secret-not-real");
+ config.setSignName("测试签名"); config.setTemplateCode("SMS_TEST_ONLY");
+ sender=new AliyunSmsCodeSender(client,config);
+ }
+ private SendSmsResponse response(int http,String code) {
+ return new SendSmsResponse().setStatusCode(http).setBody(new SendSmsResponseBody().setCode(code));
+ }
+ @Test void acceptedRequestUsesApprovedSignAndTemplateAndNeverRetries() throws Exception {
+ when(client.sendSmsWithOptions(any(),any())).thenReturn(response(200,"OK"));
+ sender.sendVerificationCode(PHONE,"login",CODE);
+ var req=ArgumentCaptor.forClass(SendSmsRequest.class);
+ var runtime=ArgumentCaptor.forClass(RuntimeOptions.class);
+ verify(client,times(1)).sendSmsWithOptions(req.capture(),runtime.capture());
+ assertThat(req.getValue().getPhoneNumbers()).isEqualTo(PHONE);
+ assertThat(req.getValue().getSignName()).isEqualTo("测试签名");
+ assertThat(req.getValue().getTemplateCode()).isEqualTo("SMS_TEST_ONLY");
+ assertThat(req.getValue().getTemplateParam()).isEqualTo("{\"code\":\"652901\"}");
+ assertThat(runtime.getValue().getAutoretry()).isFalse();
+ assertThat(runtime.getValue().getConnectTimeout()).isEqualTo(3000);
+ assertThat(runtime.getValue().getReadTimeout()).isEqualTo(5000);
+ }
+ @Test void eachExistingPurposeCanSend() throws Exception {
+ when(client.sendSmsWithOptions(any(),any())).thenReturn(response(200,"OK"));
+ for(String purpose:new String[]{"login","password-set","password-reset","company-register"})
+ sender.sendVerificationCode(PHONE,purpose,CODE);
+ verify(client,times(4)).sendSmsWithOptions(any(),any());
+ }
+ @Test void providerBusinessRejectionNeverReturnsSuccess() throws Exception {
+ when(client.sendSmsWithOptions(any(),any())).thenReturn(response(200,"isv.BUSINESS_LIMIT_CONTROL"));
+ assertSafeFailure();
+ }
+ @Test void httpFailureEvenWithOkIsRejected() throws Exception {
+ when(client.sendSmsWithOptions(any(),any())).thenReturn(response(500,"OK")); assertSafeFailure();
+ }
+ @Test void emptyResponseIsRejected() throws Exception {
+ when(client.sendSmsWithOptions(any(),any())).thenReturn(null); assertSafeFailure();
+ }
+ @Test void emptyBodyIsRejected() throws Exception {
+ when(client.sendSmsWithOptions(any(),any())).thenReturn(new SendSmsResponse().setStatusCode(200)); assertSafeFailure();
+ }
+ @Test void missingStatusIsRejected() throws Exception {
+ when(client.sendSmsWithOptions(any(),any())).thenReturn(new SendSmsResponse().setBody(new SendSmsResponseBody().setCode("OK"))); assertSafeFailure();
+ }
+ @Test void exceptionContainingSecretsIsDiscardedAndNotRetried() throws Exception {
+ when(client.sendSmsWithOptions(any(),any())).thenThrow(new java.net.SocketTimeoutException(PHONE+CODE+"test-secret-not-real"));
+ assertSafeFailure(); verify(client,times(1)).sendSmsWithOptions(any(),any());
+ }
+ @Test void invalidInputsNeverCallProvider() {
+ for(String[] args:new String[][]{{null,"login",CODE},{"bad","login",CODE},{PHONE,null,CODE},
+ {PHONE,"anything",CODE},{PHONE,"login",null},{PHONE,"login","1234"},{PHONE,"login","\"malicious"}}) {
+ assertThatThrownBy(()->sender.sendVerificationCode(args[0],args[1],args[2])).isInstanceOf(BusinessException.class);
+ }
+ verifyNoInteractions(client);
+ }
+ private void assertSafeFailure() {
+ assertThatThrownBy(()->sender.sendVerificationCode(PHONE,"login",CODE)).isInstanceOf(BusinessException.class)
+ .satisfies(error->{
+ assertThat(((BusinessException)error).getErrorCodeInt()).isEqualTo(ErrorCode.SMS_SEND_FAILED.getCode());
+ assertThat(error).hasNoCause().hasMessage("sms send failed");
+ });
+ }
+}
diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/sms/AliyunSmsConfigurationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/sms/AliyunSmsConfigurationTest.java
new file mode 100644
index 0000000..065929a
--- /dev/null
+++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/sms/AliyunSmsConfigurationTest.java
@@ -0,0 +1,42 @@
+package com.mydreamjob.auth.sms;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import static org.assertj.core.api.Assertions.assertThat;
+
+class AliyunSmsConfigurationTest {
+ private final ApplicationContextRunner runner = new ApplicationContextRunner()
+ .withUserConfiguration(SmsSenderConfiguration.class);
+ private ApplicationContextRunner configured() {
+ return runner.withPropertyValues("app.sms.aliyun.enabled=true",
+ "app.sms.aliyun.access-key-id=test-key-not-real",
+ "app.sms.aliyun.access-key-secret=test-secret-not-real",
+ "app.sms.aliyun.sign-name=测试签名", "app.sms.aliyun.template-code=SMS_TEST_ONLY");
+ }
+ @Test void explicitlyConfiguredAliyunCreatesRealSenderWithoutNetworkCall() {
+ configured().run(context -> {
+ assertThat(context).hasNotFailed().hasSingleBean(SmsCodeSender.class);
+ assertThat(context.getBean(SmsCodeSender.class).getClass().getSimpleName()).isEqualTo("AliyunSmsCodeSender");
+ });
+ }
+ @Test void enabledWithMissingCredentialsFailsStartupSafely() {
+ runner.withPropertyValues("app.sms.aliyun.enabled=true").run(context -> {
+ assertThat(context).hasFailed();
+ assertThat(context.getStartupFailure()).hasStackTraceContaining("SMS configuration incomplete");
+ });
+ }
+ @Test void mockAndRealSenderCannotBeEnabledTogether() {
+ configured().withPropertyValues("app.sms.mock.enabled=true").run(context -> assertThat(context).hasFailed());
+ }
+ @Test void nonTrueValuesNeverEnableAliyun() {
+ for (String value : new String[]{"false", "", "yes", "1", "on"}) {
+ runner.withPropertyValues("app.sms.aliyun.enabled="+value).run(context -> {
+ assertThat(context).hasNotFailed().hasSingleBean(SmsCodeSender.class);
+ assertThat(context.getBean(SmsCodeSender.class)).isInstanceOf(UnavailableSmsCodeSender.class);
+ });
+ }
+ }
+ @Test void defaultRemainsUnavailable() {
+ runner.run(context -> assertThat(context.getBean(SmsCodeSender.class)).isInstanceOf(UnavailableSmsCodeSender.class));
+ }
+}
diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/sms/AliyunSmsHttpRollbackTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/sms/AliyunSmsHttpRollbackTest.java
new file mode 100644
index 0000000..f4c9185
--- /dev/null
+++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/sms/AliyunSmsHttpRollbackTest.java
@@ -0,0 +1,65 @@
+package com.mydreamjob.auth.sms;
+
+import com.mydreamjob.auth.SmsVerificationCodeRepository;
+import com.mydreamjob.audit.AuditLogRepository;
+import com.mydreamjob.common.exception.BusinessException;
+import com.mydreamjob.common.exception.ErrorCode;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.ValueOperations;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.web.servlet.MockMvc;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+import java.util.concurrent.TimeUnit;
+
+/** 真实 Spring 事务与 H2;外部发送器为测试替身,不冒充真实阿里云验收。 */
+@SpringBootTest(properties="spring.datasource.url=jdbc:h2:mem:aliyunsmsrollback;MODE=MySQL;DB_CLOSE_DELAY=-1")
+@AutoConfigureMockMvc
+@ActiveProfiles("integration-test")
+class AliyunSmsHttpRollbackTest {
+ private static final String PHONE="19900009891";
+ @MockBean SmsCodeSender sender;
+ @MockBean RedisTemplate redis;
+ @Autowired MockMvc mvc;
+ @Autowired SmsVerificationCodeRepository codes;
+ @Autowired AuditLogRepository audits;
+ @BeforeEach @SuppressWarnings("unchecked") void setup() {
+ assertThat(codes.findByPhoneAndPurposeOrderByCreatedAtDesc(PHONE,"login")).isEmpty();
+ var ops=mock(ValueOperations.class);
+ when(ops.setIfAbsent(anyString(),eq("1"),eq(60L),eq(TimeUnit.SECONDS))).thenReturn(true);
+ when(redis.opsForValue()).thenReturn(ops);
+ when(redis.hasKey(anyString())).thenReturn(false);
+ }
+ @AfterEach void cleanup() {
+ codes.deleteAll(codes.findByPhoneAndPurposeOrderByCreatedAtDesc(PHONE,"login"));
+ }
+ @Test void rejectionRollsBackCodeAndSuccessAuditAndPreservesBusinessCode() throws Exception {
+ long before=audits.count();
+ doThrow(new BusinessException(ErrorCode.SMS_SEND_FAILED)).when(sender).sendVerificationCode(eq(PHONE),eq("login"),anyString());
+ String body=mvc.perform(post("/auth/sms/send").param("phone",PHONE).param("purpose","login"))
+ .andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value(3006))
+ .andExpect(jsonPath("$.message").value("sms send failed")).andReturn().getResponse().getContentAsString();
+ assertThat(body).doesNotContain(PHONE,"test-secret", "Exception", "at com.");
+ assertThat(codes.findByPhoneAndPurposeOrderByCreatedAtDesc(PHONE,"login")).isEmpty();
+ assertThat(audits.count()).isEqualTo(before);
+ verify(sender,times(1)).sendVerificationCode(eq(PHONE),eq("login"),anyString());
+ }
+ @Test void acceptanceDoesNotPretendToConfirmDeliveryOrReturnCode() throws Exception {
+ var response=mvc.perform(post("/auth/sms/send").param("phone",PHONE).param("purpose","login"))
+ .andExpect(status().isOk()).andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.message").value("验证码发送请求已受理"))
+ .andReturn().getResponse().getContentAsString();
+ assertThat(response).doesNotContain("Mock",PHONE,"smsCode","验证码已送达");
+ assertThat(codes.findByPhoneAndPurposeOrderByCreatedAtDesc(PHONE,"login")).hasSize(1);
+ }
+}