Commit a5f82fedb3a47bd54f4f34f2c7d923195edd5d01
1 parent
5b7e637e
【功能优化】短信:腾讯云的实现优化
Showing
3 changed files
with
76 additions
and
73 deletions
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/TencentSmsClient.java
| 1 | 1 | package cn.iocoder.yudao.module.system.framework.sms.core.client.impl; |
| 2 | 2 | |
| 3 | +import cn.hutool.core.date.format.FastDateFormat; | |
| 3 | 4 | import cn.hutool.core.lang.Assert; |
| 5 | +import cn.hutool.core.util.HexUtil; | |
| 4 | 6 | import cn.hutool.core.util.StrUtil; |
| 7 | +import cn.hutool.crypto.digest.DigestUtil; | |
| 8 | +import cn.hutool.crypto.digest.HmacAlgorithm; | |
| 5 | 9 | import cn.hutool.json.JSONArray; |
| 6 | 10 | import cn.hutool.json.JSONObject; |
| 7 | 11 | import cn.hutool.json.JSONUtil; |
| ... | ... | @@ -14,12 +18,8 @@ import cn.iocoder.yudao.module.system.framework.sms.core.client.dto.SmsTemplateR |
| 14 | 18 | import cn.iocoder.yudao.module.system.framework.sms.core.enums.SmsTemplateAuditStatusEnum; |
| 15 | 19 | import cn.iocoder.yudao.module.system.framework.sms.core.property.SmsChannelProperties; |
| 16 | 20 | import com.google.common.annotations.VisibleForTesting; |
| 17 | -import jakarta.xml.bind.DatatypeConverter; | |
| 18 | 21 | |
| 19 | -import javax.crypto.Mac; | |
| 20 | -import javax.crypto.spec.SecretKeySpec; | |
| 21 | 22 | import java.nio.charset.StandardCharsets; |
| 22 | -import java.text.SimpleDateFormat; | |
| 23 | 23 | import java.util.*; |
| 24 | 24 | |
| 25 | 25 | import static cn.hutool.crypto.digest.DigestUtil.sha256Hex; |
| ... | ... | @@ -34,6 +34,7 @@ import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils. |
| 34 | 34 | */ |
| 35 | 35 | public class TencentSmsClient extends AbstractSmsClient { |
| 36 | 36 | |
| 37 | + private static final String HOST = "sms.tencentcloudapi.com"; | |
| 37 | 38 | private static final String VERSION = "2021-01-11"; |
| 38 | 39 | private static final String REGION = "ap-guangzhou"; |
| 39 | 40 | |
| ... | ... | @@ -89,7 +90,7 @@ public class TencentSmsClient extends AbstractSmsClient { |
| 89 | 90 | body.put("PhoneNumberSet", new String[]{mobile}); |
| 90 | 91 | body.put("SmsSdkAppId", getSdkAppId()); |
| 91 | 92 | body.put("SignName", properties.getSignature()); |
| 92 | - body.put("TemplateId",apiTemplateId); | |
| 93 | + body.put("TemplateId", apiTemplateId); | |
| 93 | 94 | body.put("TemplateParamSet", ArrayUtils.toArray(templateParams, param -> String.valueOf(param.getValue()))); |
| 94 | 95 | JSONObject response = request("SendSms", body); |
| 95 | 96 | |
| ... | ... | @@ -102,11 +103,11 @@ public class TencentSmsClient extends AbstractSmsClient { |
| 102 | 103 | .setApiCode(error.getStr("Code")) |
| 103 | 104 | .setApiMsg(error.getStr("Message")); |
| 104 | 105 | } |
| 105 | - JSONObject responseData = responseResult.getJSONArray("SendStatusSet").getJSONObject(0); | |
| 106 | - return new SmsSendRespDTO().setSuccess(Objects.equals(API_CODE_SUCCESS, responseData.getStr("Code"))) | |
| 106 | + JSONObject sendResult = responseResult.getJSONArray("SendStatusSet").getJSONObject(0); | |
| 107 | + return new SmsSendRespDTO().setSuccess(Objects.equals(API_CODE_SUCCESS, sendResult.getStr("Code"))) | |
| 107 | 108 | .setApiRequestId(responseResult.getStr("RequestId")) |
| 108 | - .setSerialNo(responseData.getStr("SerialNo")) | |
| 109 | - .setApiMsg(responseData.getStr("Message")); | |
| 109 | + .setSerialNo(sendResult.getStr("SerialNo")) | |
| 110 | + .setApiMsg(sendResult.getStr("Message")); | |
| 110 | 111 | } |
| 111 | 112 | |
| 112 | 113 | @Override |
| ... | ... | @@ -133,14 +134,13 @@ public class TencentSmsClient extends AbstractSmsClient { |
| 133 | 134 | body.put("TemplateIdSet", new Integer[]{Integer.valueOf(apiTemplateId)}); |
| 134 | 135 | JSONObject response = request("DescribeSmsTemplateList", body); |
| 135 | 136 | |
| 136 | - // TODO @scholar:会有请求失败的情况么?类似发送的(那块逻辑我补充了) | |
| 137 | - JSONObject TemplateStatusSet = response.getJSONObject("Response").getJSONArray("DescribeTemplateStatusSet").getJSONObject(0); | |
| 138 | - String content = TemplateStatusSet.get("TemplateContent").toString(); | |
| 139 | - int templateStatus = Integer.parseInt(TemplateStatusSet.get("StatusCode").toString()); | |
| 140 | - String auditReason = TemplateStatusSet.get("ReviewReply").toString(); | |
| 141 | - | |
| 142 | - return new SmsTemplateRespDTO().setId(apiTemplateId).setContent(content) | |
| 143 | - .setAuditStatus(convertSmsTemplateAuditStatus(templateStatus)).setAuditReason(auditReason); | |
| 137 | + // 2. 解析请求 | |
| 138 | + JSONObject statusResult = response.getJSONObject("Response") | |
| 139 | + .getJSONArray("DescribeTemplateStatusSet").getJSONObject(0); | |
| 140 | + return new SmsTemplateRespDTO().setId(apiTemplateId) | |
| 141 | + .setContent(statusResult.get("TemplateContent").toString()) | |
| 142 | + .setAuditStatus(convertSmsTemplateAuditStatus(statusResult.getInt("StatusCode"))) | |
| 143 | + .setAuditReason(statusResult.get("ReviewReply").toString()); | |
| 144 | 144 | } |
| 145 | 145 | |
| 146 | 146 | @VisibleForTesting |
| ... | ... | @@ -163,63 +163,39 @@ public class TencentSmsClient extends AbstractSmsClient { |
| 163 | 163 | * @return 请求结果 |
| 164 | 164 | */ |
| 165 | 165 | private JSONObject request(String action, TreeMap<String, Object> body) throws Exception { |
| 166 | - String timestamp = String.valueOf(System.currentTimeMillis() / 1000); | |
| 167 | - // TODO @scholar:这个 format,看看怎么写的可以简化点 | |
| 168 | - SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd"); | |
| 169 | - // 注意时区,否则容易出错 | |
| 170 | - sdf.setTimeZone(TimeZone.getTimeZone("UTC")); | |
| 171 | - String date = sdf.format(new Date(Long.valueOf(timestamp + "000"))); | |
| 172 | - | |
| 173 | - // TODO @scholar:这个步骤,看看怎么参考阿里云 client,归类下;1. 2.1 2.2 这种 | |
| 174 | - // ************* 步骤 1:拼接规范请求串 ************* | |
| 175 | - // TODO @scholar:这个 hsot 枚举下; | |
| 176 | - String host = "sms.tencentcloudapi.com"; //APP接入地址+接口访问URI | |
| 177 | - String httpMethod = "POST"; // 请求方式 | |
| 178 | - String canonicalUri = "/"; | |
| 179 | - String canonicalQueryString = ""; | |
| 180 | - | |
| 181 | - String canonicalHeaders = "content-type:application/json; charset=utf-8\n" | |
| 182 | - + "host:" + host + "\n" + "x-tc-action:" + action.toLowerCase() + "\n"; | |
| 183 | - String signedHeaders = "content-type;host;x-tc-action"; | |
| 184 | - String hashedRequestBody = sha256Hex(JSONUtil.toJsonStr(body)); | |
| 185 | - // TODO @scholar:换行下,不然单行太长了 | |
| 186 | - String canonicalRequest = httpMethod + "\n" + canonicalUri + "\n" + canonicalQueryString + "\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + hashedRequestBody; | |
| 187 | - | |
| 188 | - // ************* 步骤 2:拼接待签名字符串 ************* | |
| 189 | - String credentialScope = date + "/" + "sms" + "/" + "tc3_request"; | |
| 190 | - String hashedCanonicalRequest = sha256Hex(canonicalRequest); | |
| 191 | - String stringToSign = "TC3-HMAC-SHA256" + "\n" + timestamp + "\n" + credentialScope + "\n" + hashedCanonicalRequest; | |
| 192 | - | |
| 193 | - // ************* 步骤 3:计算签名 ************* | |
| 194 | - byte[] secretDate = hmac256(("TC3" + properties.getApiSecret()).getBytes(StandardCharsets.UTF_8), date); | |
| 195 | - byte[] secretService = hmac256(secretDate, "sms"); | |
| 196 | - byte[] secretSigning = hmac256(secretService, "tc3_request"); | |
| 197 | - String signature = DatatypeConverter.printHexBinary(hmac256(secretSigning, stringToSign)).toLowerCase(); | |
| 198 | - | |
| 199 | - // ************* 步骤 4:拼接 Authorization ************* | |
| 200 | - String authorization = "TC3-HMAC-SHA256" + " " + "Credential=" + getApiKey() + "/" + credentialScope + ", " | |
| 201 | - + "SignedHeaders=" + signedHeaders + ", " + "Signature=" + signature; | |
| 202 | - | |
| 203 | - // ************* 步骤 5:构造HttpRequest 并执行request请求,获得response ************* | |
| 166 | + // 1.1 请求 Header | |
| 204 | 167 | Map<String, String> headers = new HashMap<>(); |
| 205 | - headers.put("Authorization", authorization); | |
| 206 | 168 | headers.put("Content-Type", "application/json; charset=utf-8"); |
| 207 | - headers.put("Host", host); | |
| 169 | + headers.put("Host", HOST); | |
| 208 | 170 | headers.put("X-TC-Action", action); |
| 209 | - headers.put("X-TC-Timestamp", timestamp); | |
| 171 | + Date now = new Date(); | |
| 172 | + String nowStr = FastDateFormat.getInstance("yyyy-MM-dd", TimeZone.getTimeZone("UTC")).format(now); | |
| 173 | + headers.put("X-TC-Timestamp", String.valueOf(now.getTime() / 1000)); | |
| 210 | 174 | headers.put("X-TC-Version", VERSION); |
| 211 | 175 | headers.put("X-TC-Region", REGION); |
| 212 | 176 | |
| 213 | - String responseBody = HttpUtils.post("https://" + host, headers, JSONUtil.toJsonStr(body)); | |
| 214 | - | |
| 177 | + // 1.2 构建签名 Header | |
| 178 | + String canonicalQueryString = ""; | |
| 179 | + String canonicalHeaders = "content-type:application/json; charset=utf-8\n" | |
| 180 | + + "host:" + HOST + "\n" + "x-tc-action:" + action.toLowerCase() + "\n"; | |
| 181 | + String signedHeaders = "content-type;host;x-tc-action"; | |
| 182 | + String canonicalRequest = "POST" + "\n" + "/" + "\n" + canonicalQueryString + "\n" + canonicalHeaders + "\n" | |
| 183 | + + signedHeaders + "\n" + sha256Hex(JSONUtil.toJsonStr(body)); | |
| 184 | + String credentialScope = nowStr + "/" + "sms" + "/" + "tc3_request"; | |
| 185 | + String stringToSign = "TC3-HMAC-SHA256" + "\n" + now.getTime() / 1000 + "\n" + credentialScope + "\n" + | |
| 186 | + sha256Hex(canonicalRequest); | |
| 187 | + byte[] secretService = hmac256(hmac256(("TC3" + properties.getApiSecret()).getBytes(StandardCharsets.UTF_8), nowStr), "sms"); | |
| 188 | + String signature = HexUtil.encodeHexStr(hmac256(hmac256(secretService, "tc3_request"), stringToSign)); | |
| 189 | + headers.put("Authorization", "TC3-HMAC-SHA256" + " " + "Credential=" + getApiKey() + "/" + credentialScope + ", " | |
| 190 | + + "SignedHeaders=" + signedHeaders + ", " + "Signature=" + signature); | |
| 191 | + | |
| 192 | + // 2. 发起请求 | |
| 193 | + String responseBody = HttpUtils.post("https://" + HOST, headers, JSONUtil.toJsonStr(body)); | |
| 215 | 194 | return JSONUtil.parseObj(responseBody); |
| 216 | 195 | } |
| 217 | 196 | |
| 218 | - // TODO @scholar:使用 hutool 简化下 | |
| 219 | - private static byte[] hmac256(byte[] key, String msg) throws Exception { | |
| 220 | - Mac mac = Mac.getInstance("HmacSHA256"); | |
| 221 | - SecretKeySpec secretKeySpec = new SecretKeySpec(key, mac.getAlgorithm()); | |
| 222 | - mac.init(secretKeySpec); | |
| 223 | - return mac.doFinal(msg.getBytes(StandardCharsets.UTF_8)); | |
| 197 | + private static byte[] hmac256(byte[] key, String msg) { | |
| 198 | + return DigestUtil.hmac(HmacAlgorithm.HmacSHA256, key).digest(msg); | |
| 224 | 199 | } |
| 200 | + | |
| 225 | 201 | } |
| 226 | 202 | \ 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
| ... | ... | @@ -57,15 +57,16 @@ public class SmsClientTests { |
| 57 | 57 | @Test |
| 58 | 58 | @Disabled |
| 59 | 59 | public void testTencentSmsClient_sendSms() throws Throwable { |
| 60 | + String sdkAppId = "1400500458"; | |
| 60 | 61 | SmsChannelProperties properties = new SmsChannelProperties() |
| 61 | - .setApiKey("LTAI5tAicJAxaSFiZuGGeXHR 1428926523") | |
| 62 | - .setApiSecret("Fdr9vadxnDvS6GJU0W1tijQ0VmLhYz") | |
| 62 | + .setApiKey(System.getenv("SMS_TENCENT_ACCESS_KEY") + " " + sdkAppId) | |
| 63 | + .setApiSecret(System.getenv("SMS_TENCENT_SECRET_KEY")) | |
| 63 | 64 | .setSignature("芋道源码"); |
| 64 | 65 | TencentSmsClient client = new TencentSmsClient(properties); |
| 65 | 66 | // 准备参数 |
| 66 | 67 | Long sendLogId = System.currentTimeMillis(); |
| 67 | 68 | String mobile = "15601691323"; |
| 68 | - String apiTemplateId = "2136358"; | |
| 69 | + String apiTemplateId = "358212"; | |
| 69 | 70 | // 调用 |
| 70 | 71 | SmsSendRespDTO sendRespDTO = client.sendSms(sendLogId, mobile, apiTemplateId, List.of(new KeyValue<>("code", "1024"))); |
| 71 | 72 | // 打印结果 |
| ... | ... | @@ -75,13 +76,14 @@ public class SmsClientTests { |
| 75 | 76 | @Test |
| 76 | 77 | @Disabled |
| 77 | 78 | public void testTencentSmsClient_getSmsTemplate() throws Throwable { |
| 79 | + String sdkAppId = "1400500458"; | |
| 78 | 80 | SmsChannelProperties properties = new SmsChannelProperties() |
| 79 | - .setApiKey("LTAI5tAicJAxaSFiZuGGeXHR 1428926523") | |
| 80 | - .setApiSecret("Fdr9vadxnDvS6GJU0W1tijQ0VmLhYz") | |
| 81 | + .setApiKey(System.getenv("SMS_TENCENT_ACCESS_KEY") + " " + sdkAppId) | |
| 82 | + .setApiSecret(System.getenv("SMS_TENCENT_SECRET_KEY")) | |
| 81 | 83 | .setSignature("芋道源码"); |
| 82 | 84 | TencentSmsClient client = new TencentSmsClient(properties); |
| 83 | 85 | // 准备参数 |
| 84 | - String apiTemplateId = "2136358"; | |
| 86 | + String apiTemplateId = "358212"; | |
| 85 | 87 | // 调用 |
| 86 | 88 | SmsTemplateRespDTO template = client.getSmsTemplate(apiTemplateId); |
| 87 | 89 | // 打印结果 | ... | ... |
yudao-module-system/yudao-module-system-biz/src/test/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/TencentSmsClientTest.java
| ... | ... | @@ -78,7 +78,7 @@ public class TencentSmsClientTest extends BaseMockitoUnitTest { |
| 78 | 78 | } |
| 79 | 79 | |
| 80 | 80 | @Test |
| 81 | - public void testDoSendSms_fail() throws Throwable { | |
| 81 | + public void testDoSendSms_fail_01() throws Throwable { | |
| 82 | 82 | try (MockedStatic<HttpUtils> httpUtilsMockedStatic = mockStatic(HttpUtils.class)) { |
| 83 | 83 | // 准备参数 |
| 84 | 84 | Long sendLogId = randomLongId(); |
| ... | ... | @@ -118,6 +118,31 @@ public class TencentSmsClientTest extends BaseMockitoUnitTest { |
| 118 | 118 | } |
| 119 | 119 | |
| 120 | 120 | @Test |
| 121 | + public void testDoSendSms_fail_02() throws Throwable { | |
| 122 | + try (MockedStatic<HttpUtils> httpUtilsMockedStatic = mockStatic(HttpUtils.class)) { | |
| 123 | + // 准备参数 | |
| 124 | + Long sendLogId = randomLongId(); | |
| 125 | + String mobile = randomString(); | |
| 126 | + String apiTemplateId = randomString(); | |
| 127 | + List<KeyValue<String, Object>> templateParams = Lists.newArrayList( | |
| 128 | + new KeyValue<>("1", 1234), new KeyValue<>("2", "login")); | |
| 129 | + | |
| 130 | + // mock 方法 | |
| 131 | + httpUtilsMockedStatic.when(() -> HttpUtils.post(anyString(), anyMap(), anyString())) | |
| 132 | + .thenReturn("{\"Response\":{\"Error\":{\"Code\":\"AuthFailure.SecretIdNotFound\",\"Message\":\"The SecretId is not found, please ensure that your SecretId is correct.\"},\"RequestId\":\"2a88f82a-261c-4ac6-9fa9-c7d01aaa486a\"}}"); | |
| 133 | + | |
| 134 | + // 调用 | |
| 135 | + SmsSendRespDTO result = smsClient.sendSms(sendLogId, mobile, | |
| 136 | + apiTemplateId, templateParams); | |
| 137 | + // 断言 | |
| 138 | + assertFalse(result.getSuccess()); | |
| 139 | + assertEquals("2a88f82a-261c-4ac6-9fa9-c7d01aaa486a", result.getApiRequestId()); | |
| 140 | + assertEquals("AuthFailure.SecretIdNotFound", result.getApiCode()); | |
| 141 | + assertEquals("The SecretId is not found, please ensure that your SecretId is correct.", result.getApiMsg()); | |
| 142 | + } | |
| 143 | + } | |
| 144 | + | |
| 145 | + @Test | |
| 121 | 146 | public void testParseSmsReceiveStatus() { |
| 122 | 147 | // 准备参数 |
| 123 | 148 | String text = "[\n" + | ... | ... |