Browse Source

feat: integrate Aliyun SMS verification sender

master
Zhang Shun 4 weeks ago
parent
commit
b1d9dd2d31
  1. 2
      docs/openapi-rc.json
  2. 6
      mydreamjob-server/pom.xml
  3. 57
      mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/AliyunSmsCodeSender.java
  4. 35
      mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/AliyunSmsProperties.java
  5. 3
      mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/SmsCodeSender.java
  6. 26
      mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/SmsSenderConfiguration.java
  7. 2
      mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/UnavailableSmsCodeSender.java
  8. 18
      mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java
  9. 8
      mydreamjob-server/src/main/resources/application.yml
  10. 83
      mydreamjob-server/src/test/java/com/mydreamjob/auth/sms/AliyunSmsCodeSenderTest.java
  11. 42
      mydreamjob-server/src/test/java/com/mydreamjob/auth/sms/AliyunSmsConfigurationTest.java
  12. 65
      mydreamjob-server/src/test/java/com/mydreamjob/auth/sms/AliyunSmsHttpRollbackTest.java

2
docs/openapi-rc.json

@ -1461,7 +1461,7 @@
"认证" "认证"
], ],
"summary": "发送短信验证码", "summary": "发送短信验证码",
"description": "开发/测试使用 Mock 发送实现;生产环境未接入短信网关时返回服务不可用。验证码不会写入日志。", "description": "真实发送使用阿里云短信网关;Mock 仅供开发测试。网关受理不等于手机送达,验证码不会写入日志。",
"operationId": "sendSmsCode", "operationId": "sendSmsCode",
"parameters": [ "parameters": [
{ {

6
mydreamjob-server/pom.xml

@ -25,6 +25,12 @@
</properties> </properties>
<dependencies> <dependencies>
<!-- 阿里云国内短信官方 SDK:凭据仅从服务器环境注入 -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>dysmsapi20170525</artifactId>
<version>4.6.0</version>
</dependency>
<!-- Spring Boot Starters --> <!-- Spring Boot Starters -->
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>

57
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<String> 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);
}
}
}

35
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(); }
}

3
mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/SmsCodeSender.java

@ -3,7 +3,8 @@ package com.mydreamjob.auth.sms;
/** /**
* 短信验证码发送抽象。 * 短信验证码发送抽象。
* *
* <p>由 {@code app.sms.mock.enabled} 决定加载哪一个实现: * <p>真实发送由 {@code app.sms.aliyun.enabled=true} 和完整凭据配置启用;
* 未启用真实发送时,由 {@code app.sms.mock.enabled} 决定回退实现:
* <ul> * <ul>
* <li>{@code true} → {@link MockSmsCodeSender}(本地/测试环境,不调用外部网关)</li> * <li>{@code true} → {@link MockSmsCodeSender}(本地/测试环境,不调用外部网关)</li>
* <li>{@code false} 或未配置 → {@link UnavailableSmsCodeSender}(安全失败,不伪造成功)</li> * <li>{@code false} 或未配置 → {@link UnavailableSmsCodeSender}(安全失败,不伪造成功)</li>

26
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.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
/** /**
* 短信发送实现装配(单 Bean 方案)。 * 短信发送实现装配(单 Bean 方案,真实发送与 Mock 互斥)。
* *
* <p>由本配置类<strong>始终且仅</strong>产生一个 {@link SmsCodeSender},从而彻底避免 * <p>由本配置类<strong>始终且仅</strong>产生一个 {@link SmsCodeSender},从而彻底避免
* “两个互斥 {@code @ConditionalOnProperty} 在空值/空白值下都不匹配 → 0 个 Bean → * “两个互斥 {@code @ConditionalOnProperty} 在空值/空白值下都不匹配 → 0 个 Bean →
* 模糊 Spring 装配错误导致启动失败”的问题。</p> * 模糊 Spring 装配错误导致启动失败”的问题。</p>
* *
* <p>取值语义(大小写不敏感,两侧空白忽略):</p> * <p>显式启用 Aliyun 时必须具备完整配置,禁止同时启用 Mock;否则启动安全失败。
* 未启用 Aliyun 时,Mock 取值语义(大小写不敏感,两侧空白忽略):</p>
* <ul> * <ul>
* <li>{@code true} → {@link MockSmsCodeSender}</li> * <li>{@code true} → {@link MockSmsCodeSender}</li>
* <li>{@code false} / 缺失 / 空字符串 / 仅空白 / 其他非法值 → {@link UnavailableSmsCodeSender}</li> * <li>{@code false} / 缺失 / 空字符串 / 仅空白 / 其他非法值 → {@link UnavailableSmsCodeSender}</li>
@ -21,6 +23,7 @@ import org.springframework.context.annotation.Configuration;
* 既不启用 Mock,也不进入发送流程。本类不输出配置原值到日志。</p> * 既不启用 Mock,也不进入发送流程。本类不输出配置原值到日志。</p>
*/ */
@Configuration @Configuration
@EnableConfigurationProperties(AliyunSmsProperties.class)
public class SmsSenderConfiguration { public class SmsSenderConfiguration {
/** 显式启用 Mock 的唯一取值。 */ /** 显式启用 Mock 的唯一取值。 */
@ -28,8 +31,23 @@ public class SmsSenderConfiguration {
@Bean @Bean
public SmsCodeSender smsCodeSender( public SmsCodeSender smsCodeSender(
@Value("${app.sms.mock.enabled:false}") String rawEnabled) { @Value("${app.sms.mock.enabled:false}") String rawEnabled,
if (rawEnabled != null && MOCK_ENABLED_VALUE.equalsIgnoreCase(rawEnabled.trim())) { 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 MockSmsCodeSender();
} }
return new UnavailableSmsCodeSender(); return new UnavailableSmsCodeSender();

2
mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/UnavailableSmsCodeSender.java

@ -9,7 +9,7 @@ import com.mydreamjob.common.exception.ErrorCode;
* <p>由 {@link SmsSenderConfiguration} 在 {@code app.sms.mock.enabled} 为 false、缺失、 * <p>由 {@link SmsSenderConfiguration} 在 {@code app.sms.mock.enabled} 为 false、缺失、
* 空值、仅空白或其他非法值时装配(不再使用条件注解,以保证任何取值下恰好一个 Bean)。</p> * 空值、仅空白或其他非法值时装配(不再使用条件注解,以保证任何取值下恰好一个 Bean)。</p>
* *
* <p>当前未接入真实短信网关,因此本实现<strong>安全失败</strong>: * <p>未显式启用真实短信网关或 Mock 时,本实现<strong>安全失败</strong>:
* 抛出既有的“服务不可用”业务异常,绝不返回虚假的“发送成功”。 * 抛出既有的“服务不可用”业务异常,绝不返回虚假的“发送成功”。
* 异常消息为固定安全文本,不含手机号、验证码或任何配置细节。</p> * 异常消息为固定安全文本,不含手机号、验证码或任何配置细节。</p>
*/ */

18
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))); return ResponseEntity.ok(ApiResponse.ok(response, requestIdAccessor.get(request)));
} }
@Operation(summary = "发送短信验证码", description = "开发/测试使用 Mock 发送实现;生产环境未接入短信网关时返回服务不可用。验证码不会写入日志。") @Operation(summary = "发送短信验证码", description = "真实发送使用阿里云短信网关;Mock 仅供开发测试。网关受理不等于手机送达,验证码不会写入日志。")
@PostMapping("/sms/send") @PostMapping("/sms/send")
public ResponseEntity<ApiResponse<Map<String, String>>> sendSmsCode( public ResponseEntity<ApiResponse<Map<String, String>>> sendSmsCode(
@RequestParam @NotBlank(message = "phone is required") @RequestParam @NotBlank(message = "phone is required")
@Pattern(regexp = "[0-9]{11}", message = "phone must contain exactly 11 digits") String phone, @Pattern(regexp = "[0-9]{11}", message = "phone must contain exactly 11 digits") String phone,
@RequestParam(defaultValue = "login") String purpose, @RequestParam(defaultValue = "login") String purpose,
HttpServletRequest request) { HttpServletRequest request) {
try {
smsVerificationService.sendCode(phone, purpose, smsVerificationService.sendCode(phone, purpose,
requestIdAccessor.get(request), requestIdAccessor.get(request), request.getRemoteAddr(), request.getHeader("User-Agent"));
request.getRemoteAddr(), // 保留业务码,统一走异常映射;不把 SDK 或 SQL 原始异常信息写回客户端。
request.getHeader("User-Agent")); return ResponseEntity.ok(ApiResponse.ok(Map.of("message", "验证码发送请求已受理"),
return ResponseEntity.ok(ApiResponse.ok(
Map.of("message", "验证码已发送(Mock)"),
requestIdAccessor.get(request))); 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)));
}
} }
@Operation(summary = "手机号验证码登录", description = "新手机号创建待选身份统一账号;已有账号保持身份,返回 Access/Refresh Token") @Operation(summary = "手机号验证码登录", description = "新手机号创建待选身份统一账号;已有账号保持身份,返回 Access/Refresh Token")

8
mydreamjob-server/src/main/resources/application.yml

@ -121,6 +121,14 @@ app:
file-ttl-hours: ${DATA_EXPORT_TTL_HOURS:24} file-ttl-hours: ${DATA_EXPORT_TTL_HOURS:24}
sms: 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: mock:
enabled: ${SMS_MOCK_ENABLED:false} enabled: ${SMS_MOCK_ENABLED:false}
code-length: 6 code-length: 6

83
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");
});
}
}

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

65
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<String,Object> 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);
}
}
Loading…
Cancel
Save