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); + } +}