Commit d745a1832dfc448439567a956510cab82a16045c

Authored by YunaiV
2 parents 88cc4c98 1c1abae5

Merge branch 'develop' of https://gitee.com/scholarli/ruoyi-vue-pro_1 into develop

# Conflicts:
#	yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/http/HttpUtils.java
#	yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/sms/SmsCallbackController.java
#	yudao-module-system/yudao-module-system-biz/src/test/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/SmsClientTests.java
yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/http/HttpUtils.java
@@ -111,7 +111,7 @@ public class HttpUtils { @@ -111,7 +111,7 @@ public class HttpUtils {
111 authorization = Base64.decodeStr(authorization); 111 authorization = Base64.decodeStr(authorization);
112 clientId = StrUtil.subBefore(authorization, ":", false); 112 clientId = StrUtil.subBefore(authorization, ":", false);
113 clientSecret = StrUtil.subAfter(authorization, ":", false); 113 clientSecret = StrUtil.subAfter(authorization, ":", false);
114 - // 再从 Param 中获取 114 + // 再从 Param 中获取
115 } else { 115 } else {
116 clientId = request.getParameter("client_id"); 116 clientId = request.getParameter("client_id");
117 clientSecret = request.getParameter("client_secret"); 117 clientSecret = request.getParameter("client_secret");
@@ -143,4 +143,21 @@ public class HttpUtils { @@ -143,4 +143,21 @@ public class HttpUtils {
143 } 143 }
144 } 144 }
145 145
  146 + /**
  147 + * HTTP get 请求,基于 {@link cn.hutool.http.HttpUtil} 实现
  148 + *
  149 + * 为什么要封装该方法,因为 HttpUtil 默认封装的方法,没有允许传递 headers 参数
  150 + *
  151 + * @param url URL
  152 + * @param headers 请求头
  153 + * @return 请求结果
  154 + */
  155 + public static String get(String url, Map<String, String> headers) {
  156 + try (HttpResponse response = HttpRequest.get(url)
  157 + .addHeaders(headers)
  158 + .execute()) {
  159 + return response.body();
  160 + }
  161 + }
  162 +
146 } 163 }
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/sms/SmsCallbackController.java
@@ -49,4 +49,12 @@ public class SmsCallbackController { @@ -49,4 +49,12 @@ public class SmsCallbackController {
49 return success(true); 49 return success(true);
50 } 50 }
51 51
52 -} 52 + @PostMapping("/qiniu")
  53 + @PermitAll
  54 + @Operation(summary = "七牛云短信的回调", description = "参见 https://developer.qiniu.com/sms/5910/message-push 文档")
  55 + public CommonResult<Boolean> receiveQiniuSmsStatus(@RequestBody String requestBody) throws Throwable {
  56 + smsSendService.receiveSmsStatus(SmsChannelEnum.QINIU.getCode(), requestBody);
  57 + return success(true);
  58 + }
  59 +
  60 +}
53 \ No newline at end of file 61 \ No newline at end of file
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/QiniuSmsClient.java 0 → 100644
  1 +package cn.iocoder.yudao.module.system.framework.sms.core.client.impl;
  2 +
  3 +import cn.hutool.core.collection.CollStreamUtil;
  4 +import cn.hutool.core.collection.ListUtil;
  5 +import cn.hutool.core.date.DateUtil;
  6 +import cn.hutool.core.date.LocalDateTimeUtil;
  7 +import cn.hutool.core.lang.Assert;
  8 +import cn.hutool.core.util.ObjUtil;
  9 +import cn.hutool.core.util.ObjectUtil;
  10 +import cn.hutool.crypto.SecureUtil;
  11 +import cn.hutool.crypto.digest.HmacAlgorithm;
  12 +import cn.hutool.json.JSONObject;
  13 +import cn.hutool.json.JSONUtil;
  14 +import cn.iocoder.yudao.framework.common.core.KeyValue;
  15 +import cn.iocoder.yudao.framework.common.util.http.HttpUtils;
  16 +import cn.iocoder.yudao.module.system.framework.sms.core.client.dto.SmsReceiveRespDTO;
  17 +import cn.iocoder.yudao.module.system.framework.sms.core.client.dto.SmsSendRespDTO;
  18 +import cn.iocoder.yudao.module.system.framework.sms.core.client.dto.SmsTemplateRespDTO;
  19 +import cn.iocoder.yudao.module.system.framework.sms.core.enums.SmsTemplateAuditStatusEnum;
  20 +import cn.iocoder.yudao.module.system.framework.sms.core.property.SmsChannelProperties;
  21 +import com.google.common.annotations.VisibleForTesting;
  22 +import lombok.extern.slf4j.Slf4j;
  23 +
  24 +import java.util.*;
  25 +
  26 +/**
  27 + * 七牛云短信客户端的实现类
  28 + *
  29 + * @author scholar
  30 + * @since 2024/08/26 15:35
  31 + */
  32 +@Slf4j
  33 +public class QiniuSmsClient extends AbstractSmsClient {
  34 +
  35 + private static final String HOST = "sms.qiniuapi.com";
  36 +
  37 + private static final String PATH = "/v1/message/single";
  38 +
  39 + private static final String TEMPLATE_PATH = "/v1/template";
  40 +
  41 + public QiniuSmsClient(SmsChannelProperties properties) {
  42 + super(properties);
  43 + Assert.notEmpty(properties.getApiKey(), "apiKey 不能为空");
  44 + Assert.notEmpty(properties.getApiSecret(), "apiSecret 不能为空");
  45 + }
  46 +
  47 + protected void doInit() {
  48 + }
  49 +
  50 + public SmsSendRespDTO sendSms(Long sendLogId, String mobile, String apiTemplateId,
  51 + List<KeyValue<String, Object>> templateParams) throws Throwable {
  52 + // 1. 执行请求
  53 + // 参考链接 https://developer.qiniu.com/sms/5824/through-the-api-send-text-messages
  54 + LinkedHashMap<String, Object> body = new LinkedHashMap<>();
  55 + body.put("template_id", apiTemplateId);
  56 + body.put("mobile", mobile);
  57 + body.put("parameters", CollStreamUtil.toMap(templateParams, KeyValue::getKey, KeyValue::getValue));
  58 + body.put("seq", Long.toString(sendLogId));
  59 +
  60 + JSONObject response = request("POST", body, PATH);
  61 + // 2. 解析请求
  62 + if (ObjectUtil.isNotEmpty(response.getStr("error"))){//短信请求失败
  63 + return new SmsSendRespDTO().setSuccess(false)
  64 + .setApiCode(response.getStr("error"))
  65 + .setApiRequestId(response.getStr("request_id"))
  66 + .setApiMsg(response.getStr("message"));
  67 + }
  68 +
  69 + return new SmsSendRespDTO().setSuccess(response.containsKey("message_id"))
  70 + .setSerialNo(response.getStr("message_id"));
  71 + }
  72 +
  73 + /**
  74 + * 请求七牛云短信
  75 + *
  76 + * @see <a href="https://developer.qiniu.com/sms/5842/sms-api-authentication"</>
  77 + * @param httpMethod http请求方法
  78 + * @param body http请求消息体
  79 + * @param path URL path
  80 + * @return 请求结果
  81 + */
  82 + private JSONObject request(String httpMethod, LinkedHashMap<String, Object> body, String path) {
  83 + String signDate = DateUtil.date().setTimeZone(TimeZone.getTimeZone("UTC")).toString("yyyyMMdd'T'HHmmss'Z'");
  84 + //请求头
  85 + Map<String, String> header = new HashMap<>(4);
  86 + header.put("HOST", HOST);
  87 + header.put("Authorization", getSignature(httpMethod, HOST, path, body != null ? JSONUtil.toJsonStr(body) : "", signDate));
  88 + header.put("Content-Type", "application/json");
  89 + header.put("X-Qiniu-Date", signDate);
  90 +
  91 + String responseBody ="";
  92 + if (Objects.equals(httpMethod, "POST")){// POST 发送短消息用POST请求
  93 + responseBody = HttpUtils.post("https://" + HOST + path, header, JSONUtil.toJsonStr(body));
  94 + }else { // GET 查询template状态用GET请求
  95 + responseBody = HttpUtils.get("https://" + HOST + path, header);
  96 + }
  97 + return JSONUtil.parseObj(responseBody);
  98 + }
  99 +
  100 + public String getSignature(String method, String host, String path, String body, String signDate) {
  101 + StringBuilder dataToSign = new StringBuilder();
  102 + dataToSign.append(method.toUpperCase()).append(" ").append(path);
  103 + dataToSign.append("\nHost: ").append(host);
  104 + dataToSign.append("\n").append("Content-Type").append(": ").append("application/json");
  105 + dataToSign.append("\n").append("X-Qiniu-Date").append(": ").append(signDate);
  106 + dataToSign.append("\n\n");
  107 + if (ObjectUtil.isNotEmpty(body)) {
  108 + dataToSign.append(body);
  109 + }
  110 + String encodedSignature = SecureUtil.hmac(HmacAlgorithm.HmacSHA1, properties.getApiSecret()).digestBase64(dataToSign.toString(), true);
  111 +
  112 + return "Qiniu " + properties.getApiKey() + ":" + encodedSignature;
  113 + }
  114 +
  115 + @Override
  116 + public List<SmsReceiveRespDTO> parseSmsReceiveStatus(String text) {
  117 + JSONObject status = JSONUtil.parseObj(text);
  118 + // 字段参考 https://developer.qiniu.com/sms/5910/message-push
  119 + return ListUtil.of(new SmsReceiveRespDTO()
  120 + .setSuccess("DELIVRD".equals(status.getJSONArray("items").getJSONObject(0).getStr("status"))) // 是否接收成功
  121 + .setErrorMsg(status.getJSONArray("items").getJSONObject(0).getStr("status"))
  122 + .setMobile(status.getJSONArray("items").getJSONObject(0).getStr("mobile")) // 手机号
  123 + .setReceiveTime(LocalDateTimeUtil.of(status.getJSONArray("items").getJSONObject(0).getLong("delivrd_at")*1000L))
  124 + .setSerialNo(status.getJSONArray("items").getJSONObject(0).getStr("message_id")) // 发送序列号
  125 + .setLogId(Long.valueOf(status.getJSONArray("items").getJSONObject(0).getStr("seq")))); // logId
  126 + }
  127 +
  128 + @Override
  129 + public SmsTemplateRespDTO getSmsTemplate(String apiTemplateId) throws Throwable {
  130 + // 1. 执行请求
  131 + // 参考链接 https://developer.qiniu.com/sms/5969/query-a-single-template
  132 + JSONObject response = request("GET", null, TEMPLATE_PATH + "/" + apiTemplateId);
  133 + // 2.1 请求失败
  134 + if (ObjUtil.notEqual(response.getStr("audit_status"), "passed")) {
  135 + log.error("[getSmsTemplate][模版编号({}) 响应不正确({})]", apiTemplateId, response);
  136 + return null;
  137 + }
  138 +
  139 + // 2.2 请求成功
  140 + return new SmsTemplateRespDTO()
  141 + .setId(response.getStr("id"))
  142 + .setContent(response.getStr("template"))
  143 + .setAuditStatus(convertSmsTemplateAuditStatus(response.getStr("audit_status")))
  144 + .setAuditReason(response.getStr("reject_reason"));
  145 + }
  146 +
  147 + @VisibleForTesting
  148 + Integer convertSmsTemplateAuditStatus(String templateStatus) {
  149 + return switch (templateStatus) {
  150 + case "passed" -> SmsTemplateAuditStatusEnum.SUCCESS.getStatus();
  151 + case "reviewing" -> SmsTemplateAuditStatusEnum.CHECKING.getStatus();
  152 + case "rejected" -> SmsTemplateAuditStatusEnum.FAIL.getStatus();
  153 + case null, default ->
  154 + throw new IllegalArgumentException(String.format("未知审核状态(%str)", templateStatus));
  155 + };
  156 + }
  157 +}
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/enums/SmsChannelEnum.java
@@ -18,6 +18,7 @@ public enum SmsChannelEnum { @@ -18,6 +18,7 @@ public enum SmsChannelEnum {
18 ALIYUN("ALIYUN", "阿里云"), 18 ALIYUN("ALIYUN", "阿里云"),
19 TENCENT("TENCENT", "腾讯云"), 19 TENCENT("TENCENT", "腾讯云"),
20 HUAWEI("HUAWEI", "华为云"), 20 HUAWEI("HUAWEI", "华为云"),
  21 + QINIU("QINIU", "七牛云"),
21 ; 22 ;
22 23
23 /** 24 /**
@@ -34,3 +35,4 @@ public enum SmsChannelEnum { @@ -34,3 +35,4 @@ public enum SmsChannelEnum {
34 } 35 }
35 36
36 } 37 }
  38 +
yudao-module-system/yudao-module-system-biz/src/test/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/QiniuSmsClientTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.system.framework.sms.core.client.impl;
  2 +
  3 +import cn.iocoder.yudao.framework.common.core.KeyValue;
  4 +import cn.iocoder.yudao.framework.common.util.http.HttpUtils;
  5 +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
  6 +import cn.iocoder.yudao.module.system.framework.sms.core.client.dto.SmsReceiveRespDTO;
  7 +import cn.iocoder.yudao.module.system.framework.sms.core.client.dto.SmsSendRespDTO;
  8 +import cn.iocoder.yudao.module.system.framework.sms.core.client.dto.SmsTemplateRespDTO;
  9 +import cn.iocoder.yudao.module.system.framework.sms.core.enums.SmsTemplateAuditStatusEnum;
  10 +import cn.iocoder.yudao.module.system.framework.sms.core.property.SmsChannelProperties;
  11 +import com.google.common.collect.Lists;
  12 +
  13 +import org.junit.jupiter.api.Test;
  14 +import org.mockito.InjectMocks;
  15 +import org.mockito.MockedStatic;
  16 +
  17 +import java.time.LocalDateTime;
  18 +import java.util.List;
  19 +
  20 +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.*;
  21 +import static org.junit.jupiter.api.Assertions.*;
  22 +import static org.mockito.ArgumentMatchers.*;
  23 +import static org.mockito.ArgumentMatchers.anyString;
  24 +import static org.mockito.Mockito.mockStatic;
  25 +
  26 +/**
  27 + * {@link QiniuSmsClient} 的单元测试
  28 + *
  29 + * @author scholar
  30 + */
  31 +public class QiniuSmsClientTest extends BaseMockitoUnitTest {
  32 + private final SmsChannelProperties properties = new SmsChannelProperties()
  33 + .setApiKey(randomString())// 随机一个 apiKey,避免构建报错
  34 + .setApiSecret(randomString()) // 随机一个 apiSecret,避免构建报错
  35 + .setSignature("芋道源码");
  36 +
  37 + @InjectMocks
  38 + private QiniuSmsClient smsClient = new QiniuSmsClient(properties);
  39 +
  40 + @Test
  41 + public void testDoInit() {
  42 + // 调用
  43 + smsClient.doInit();
  44 + }
  45 +
  46 + @Test
  47 + public void testDoSendSms_success() throws Throwable {
  48 + try (MockedStatic<HttpUtils> httpUtilsMockedStatic = mockStatic(HttpUtils.class)) {
  49 + // 准备参数
  50 + Long sendLogId = randomLongId();
  51 + String mobile = randomString();
  52 + String apiTemplateId = randomString() + " " + randomString();
  53 + List<KeyValue<String, Object>> templateParams = Lists.newArrayList(
  54 + new KeyValue<>("1", 1234), new KeyValue<>("2", "login"));
  55 + // mock 方法
  56 + httpUtilsMockedStatic.when(() -> HttpUtils.post(anyString(), anyMap(), anyString()))
  57 + .thenReturn("{\"message_id\":\"17245678901\"}");
  58 + // 调用
  59 + SmsSendRespDTO result = smsClient.sendSms(sendLogId, mobile,
  60 + apiTemplateId, templateParams);
  61 + // 断言
  62 + assertTrue(result.getSuccess());
  63 + assertEquals("17245678901", result.getSerialNo());
  64 + }
  65 + }
  66 +
  67 + @Test
  68 + public void testDoSendSms_fail() throws Throwable {
  69 + try (MockedStatic<HttpUtils> httpUtilsMockedStatic = mockStatic(HttpUtils.class)) {
  70 + // 准备参数
  71 + Long sendLogId = randomLongId();
  72 + String mobile = randomString();
  73 + String apiTemplateId = randomString() + " " + randomString();
  74 + List<KeyValue<String, Object>> templateParams = Lists.newArrayList(
  75 + new KeyValue<>("1", 1234), new KeyValue<>("2", "login"));
  76 + // mock 方法
  77 + httpUtilsMockedStatic.when(() -> HttpUtils.post(anyString(), anyMap(), anyString()))
  78 + .thenReturn("{\"error\":\"BadToken\",\"message\":\"Your authorization token is invalid\",\"request_id\":\"etziWcJFo1C8Ne8X\"}");
  79 + // 调用
  80 + SmsSendRespDTO result = smsClient.sendSms(sendLogId, mobile,
  81 + apiTemplateId, templateParams);
  82 + // 断言
  83 + assertFalse(result.getSuccess());
  84 + assertEquals("BadToken", result.getApiCode());
  85 + assertEquals("Your authorization token is invalid", result.getApiMsg());
  86 + assertEquals("etziWcJFo1C8Ne8X", result.getApiRequestId());
  87 + }
  88 + }
  89 +
  90 + @Test
  91 + public void testGetSmsTemplate() throws Throwable {
  92 + try (MockedStatic<HttpUtils> httpUtilsMockedStatic = mockStatic(HttpUtils.class)) {
  93 + // 准备参数
  94 + String apiTemplateId = randomString();
  95 + // mock 方法
  96 + httpUtilsMockedStatic.when(() -> HttpUtils.get(anyString(), anyMap()))
  97 + .thenReturn("{\"audit_status\":\"passed\",\"created_at\":1724231187,\"description\":\"\",\"disable_broadcast\":false,\"disable_broadcast_reason\":\"\",\"disable_reason\":\"\",\"disabled\":false,\"id\":\"1826184073773596672\",\"is_oversea\":false,\"name\":\"dd\",\"parameters\":[\"code\"],\"reject_reason\":\"\",\"signature_id\":\"1826099896017498112\",\"signature_text\":\"yudao\",\"template\":\"您的验证码为:${code}\",\"type\":\"verification\",\"uid\":1383022432,\"updated_at\":1724288561,\"variable_count\":0}");
  98 + // 调用
  99 + SmsTemplateRespDTO result = smsClient.getSmsTemplate(apiTemplateId);
  100 + // 断言
  101 + assertEquals("1826184073773596672", result.getId());
  102 + assertEquals("您的验证码为:${code}", result.getContent());
  103 + assertEquals(SmsTemplateAuditStatusEnum.SUCCESS.getStatus(), result.getAuditStatus());
  104 + assertEquals("", result.getAuditReason());
  105 + }
  106 + }
  107 +
  108 + @Test
  109 + public void testParseSmsReceiveStatus() {
  110 + // 准备参数
  111 + String text = "{\"items\":[{\"mobile\":\"18881234567\",\"message_id\":\"10135515063508004167\",\"status\":\"DELIVRD\",\"delivrd_at\":1724591666,\"error\":\"DELIVRD\",\"seq\":\"123\"}]}";
  112 + // 调用
  113 + List<SmsReceiveRespDTO> statuses = smsClient.parseSmsReceiveStatus(text);
  114 + // 断言
  115 + assertEquals(1, statuses.size());
  116 + assertTrue(statuses.getFirst().getSuccess());
  117 + assertEquals("DELIVRD", statuses.getFirst().getErrorMsg());
  118 + assertEquals(LocalDateTime.of(2024, 8, 25, 21, 14, 26), statuses.getFirst().getReceiveTime());
  119 + assertEquals("18881234567", statuses.getFirst().getMobile());
  120 + assertEquals("10135515063508004167", statuses.getFirst().getSerialNo());
  121 + assertEquals(123, statuses.getFirst().getLogId());
  122 + }
  123 +
  124 + @Test
  125 + public void testConvertSmsTemplateAuditStatus() {
  126 + assertEquals(SmsTemplateAuditStatusEnum.SUCCESS.getStatus(),
  127 + smsClient.convertSmsTemplateAuditStatus("passed"));
  128 + assertEquals(SmsTemplateAuditStatusEnum.CHECKING.getStatus(),
  129 + smsClient.convertSmsTemplateAuditStatus("reviewing"));
  130 + assertEquals(SmsTemplateAuditStatusEnum.FAIL.getStatus(),
  131 + smsClient.convertSmsTemplateAuditStatus("rejected"));
  132 + assertThrows(IllegalArgumentException.class, () -> smsClient.convertSmsTemplateAuditStatus("unknown"),
  133 + "未知审核状态(3)");
  134 + }
  135 +}
0 \ No newline at end of file 136 \ No newline at end of file
yudao-module-system/yudao-module-system-biz/src/test/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/SmsClientTests.java
@@ -112,5 +112,39 @@ public class SmsClientTests { @@ -112,5 +112,39 @@ public class SmsClientTests {
112 System.out.println(smsSendRespDTO); 112 System.out.println(smsSendRespDTO);
113 } 113 }
114 114
  115 + // ========== 七牛云 ==========
  116 +
  117 + @Test
  118 + @Disabled
  119 + public void testQiniuSmsClient_sendSms() throws Throwable {
  120 + SmsChannelProperties properties = new SmsChannelProperties()
  121 + .setApiKey("SMS_QINIU_ACCESS_KEY")
  122 + .setApiSecret("SMS_QINIU_SECRET_KEY");
  123 + QiniuSmsClient client = new QiniuSmsClient(properties);
  124 + // 准备参数
  125 + Long sendLogId = System.currentTimeMillis();
  126 + String mobile = "17321315478";
  127 + String apiTemplateId = "3644cdab863546a3b718d488659a99ef";
  128 + List<KeyValue<String, Object>> templateParams = List.of(new KeyValue<>("code", "1122"));
  129 + // 调用
  130 + SmsSendRespDTO smsSendRespDTO = client.sendSms(sendLogId, mobile, apiTemplateId, templateParams);
  131 + // 打印结果
  132 + System.out.println(smsSendRespDTO);
  133 + }
  134 +
  135 + @Test
  136 + @Disabled
  137 + public void testQiniuSmsClient_getSmsTemplate() throws Throwable {
  138 + SmsChannelProperties properties = new SmsChannelProperties()
  139 + .setApiKey("SMS_QINIU_ACCESS_KEY")
  140 + .setApiSecret("SMS_QINIU_SECRET_KEY");
  141 + QiniuSmsClient client = new QiniuSmsClient(properties);
  142 + // 准备参数
  143 + String apiTemplateId = "3644cdab863546a3b718d488659a99ef";
  144 + // 调用
  145 + SmsTemplateRespDTO template = client.getSmsTemplate(apiTemplateId);
  146 + // 打印结果
  147 + System.out.println(template);
  148 + }
115 } 149 }
116 150