diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/AliyunSmsClient.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/AliyunSmsClient.java index 708683b21..d45f3051c 100644 --- a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/AliyunSmsClient.java +++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/AliyunSmsClient.java @@ -1,12 +1,15 @@ package cn.iocoder.yudao.module.system.framework.sms.core.client.impl; +import cn.hutool.core.date.format.FastDateFormat; import cn.hutool.core.lang.Assert; -import cn.hutool.core.util.HexUtil; +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.URLUtil; import cn.hutool.crypto.SecureUtil; import cn.hutool.crypto.digest.DigestUtil; import cn.hutool.http.HttpRequest; import cn.hutool.http.HttpResponse; +import cn.hutool.json.JSONArray; import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; import cn.iocoder.yudao.framework.common.core.KeyValue; @@ -17,23 +20,15 @@ import cn.iocoder.yudao.module.system.framework.sms.core.client.dto.SmsSendRespD import cn.iocoder.yudao.module.system.framework.sms.core.client.dto.SmsTemplateRespDTO; import cn.iocoder.yudao.module.system.framework.sms.core.enums.SmsTemplateAuditStatusEnum; import cn.iocoder.yudao.module.system.framework.sms.core.property.SmsChannelProperties; -import com.fasterxml.jackson.annotation.JsonFormat; -import com.fasterxml.jackson.annotation.JsonProperty; import com.google.common.annotations.VisibleForTesting; -import lombok.Data; import lombok.extern.slf4j.Slf4j; -import java.io.IOException; import java.io.UnsupportedEncodingException; -import java.net.URISyntaxException; import java.net.URLEncoder; -import java.time.LocalDateTime; import java.util.*; +import java.util.stream.Collectors; import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; -import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; -import static cn.iocoder.yudao.framework.common.util.date.DateUtils.TIME_ZONE_DEFAULT; -import java.text.SimpleDateFormat; /** * 阿里短信客户端的实现类 @@ -44,6 +39,12 @@ import java.text.SimpleDateFormat; @Slf4j public class AliyunSmsClient extends AbstractSmsClient { + private static final String URL = "https://dysmsapi.aliyuncs.com"; + private static final String HOST = "dysmsapi.aliyuncs.com"; + private static final String VERSION = "2017-05-25"; + + private static final String RESPONSE_CODE_SUCCESS = "OK"; + public AliyunSmsClient(SmsChannelProperties properties) { super(properties); Assert.notEmpty(properties.getApiKey(), "apiKey 不能为空"); @@ -52,138 +53,68 @@ public class AliyunSmsClient extends AbstractSmsClient { @Override protected void doInit() { -// IClientProfile profile = DefaultProfile.getProfile(ENDPOINT, properties.getApiKey(), properties.getApiSecret()); -// client = new DefaultAcsClient(profile); } @Override public SmsSendRespDTO sendSms(Long sendLogId, String mobile, String apiTemplateId, List> templateParams) throws Throwable { - + // 1. 执行请求 + // 参考链接 https://api.aliyun.com/document/Dysmsapi/2017-05-25/SendSms TreeMap queryParam = new TreeMap<>(); queryParam.put("PhoneNumbers",mobile); - queryParam.put("SignName",properties.getSignature()); - queryParam.put("TemplateCode",apiTemplateId); - queryParam.put("TemplateParam",JsonUtils.toJsonString(MapUtils.convertMap(templateParams))); + queryParam.put("SignName", properties.getSignature()); + queryParam.put("TemplateCode", apiTemplateId); + queryParam.put("TemplateParam", JsonUtils.toJsonString(MapUtils.convertMap(templateParams))); + queryParam.put("OutId", sendLogId); + JSONObject response = request("SendSms", queryParam); - JSONObject response = sendSmsRequest(queryParam,"sendSms"); - SmsResponse smsResponse = getSmsSendResponse(response); - - return new SmsSendRespDTO().setSuccess(smsResponse.success).setApiMsg(smsResponse.data.toString()); - } - - JSONObject sendSmsRequest(TreeMap queryParam,String apiName) throws IOException, URISyntaxException { - - // ************* 步骤 1:拼接规范请求串 ************* - String url = "https://dysmsapi.aliyuncs.com"; //APP接入地址+接口访问URI - String httpMethod = "POST"; // 请求方式 - String canonicalUri = "/"; - // 请求参数,当请求的查询字符串为空时,使用空字符串作为规范化查询字符串 - StringBuilder canonicalQueryString = new StringBuilder(); - queryParam.entrySet().stream().map(entry -> percentCode(entry.getKey()) + "=" + percentCode(String.valueOf(entry.getValue()))).forEachOrdered(queryPart -> { - // 如果canonicalQueryString已经不是空的,则在查询参数前添加"&" - if (!canonicalQueryString.isEmpty()) { - canonicalQueryString.append("&"); - } - canonicalQueryString.append(queryPart); - System.out.println("canonicalQueryString=========>\n" + canonicalQueryString); - }); - - SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"); - SDF.setTimeZone(new SimpleTimeZone(0, "GMT")); - String SdfTime = SDF.format(new Date()); - String randomUUID = UUID.randomUUID().toString(); - - TreeMap headers = new TreeMap<>(); - headers.put("host", "dysmsapi.aliyuncs.com"); - headers.put("x-acs-action", apiName); - headers.put("x-acs-version", "2017-05-25"); - headers.put("x-acs-date", SdfTime); - headers.put("x-acs-signature-nonce", randomUUID); -// headers.put("content-type", "application/json;charset=utf-8"); - - // 构造请求头,多个规范化消息头,按照消息头名称(小写)的字符代码顺序以升序排列后拼接在一起 - StringBuilder canonicalHeaders = new StringBuilder(); - // 已签名消息头列表,多个请求头名称(小写)按首字母升序排列并以英文分号(;)分隔 - StringBuilder signedHeadersSb = new StringBuilder(); - headers.entrySet().stream().filter(entry -> entry.getKey().toLowerCase().startsWith("x-acs-") || entry.getKey().equalsIgnoreCase("host") || entry.getKey().equalsIgnoreCase("content-type")).sorted(Map.Entry.comparingByKey()).forEach(entry -> { - String lowerKey = entry.getKey().toLowerCase(); - String value = String.valueOf(entry.getValue()).trim(); - canonicalHeaders.append(lowerKey).append(":").append(value).append("\n"); - signedHeadersSb.append(lowerKey).append(";"); - }); - String signedHeaders = signedHeadersSb.substring(0, signedHeadersSb.length() - 1); - - String body = "";//短信API为RPC接口,query parameters在uri中拼接,因此request body如果没有特殊要求,设置为空。 - String hashedRequestBody = HexUtil.encodeHexStr(DigestUtil.sha256(body)); - - - String canonicalRequest = httpMethod + "\n" + canonicalUri + "\n" + canonicalQueryString + "\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + hashedRequestBody; - System.out.println("canonicalRequest=========>\n" + canonicalRequest); - - // ************* 步骤 2:拼接待签名字符串 ************* - String hashedCanonicalRequest = HexUtil.encodeHexStr(DigestUtil.sha256(canonicalRequest)); - String stringToSign = "ACS3-HMAC-SHA256" + "\n" + hashedCanonicalRequest; - - // ************* 步骤 3:计算签名 ************* - String signature = SecureUtil.hmacSha256(properties.getApiSecret()).digestHex(stringToSign); - - // ************* 步骤 4:拼接 Authorization ************* - String authorization = "ACS3-HMAC-SHA256" + " " + "Credential=" + properties.getApiKey() + ", " - + "SignedHeaders=" + signedHeaders + ", " + "Signature=" + signature; - headers.put("Authorization", authorization); - - // ************* 步骤 5:构造HttpRequest 并执行request请求,获得response ************* -// url = url + canonicalUri; - String urlWithParams = url + "?" + URLUtil.buildQuery(queryParam, null); - - HttpResponse response = HttpRequest.post(urlWithParams) - .addHeaders(headers) - .body(body) - .execute(); -// URIBuilder uriBuilder = new URIBuilder(url); -// // 添加请求参数 -// for (Map.Entry entry : queryParam.entrySet()) { -// uriBuilder.addParameter(entry.getKey(), String.valueOf(entry.getValue())); -// } -// HttpUriRequest httpRequest = new HttpPost(uriBuilder.build()); -//// HttpPost httpPost = new HttpPost(uriBuilder.build()); -//// httpRequest = httpPost; -// -// // 添加http请求头 -// for (Map.Entry entry : headers.entrySet()) { -// httpRequest.addHeader(entry.getKey(), String.valueOf(entry.getValue())); -// } -// -// // 发送请求 -// CloseableHttpClient httpClient = HttpClients.createDefault(); -// CloseableHttpResponse response = httpClient.execute(httpRequest); - System.out.println("getEntity====="+response.body()); - System.out.println("response====="+response); - - return JSONUtil.parseObj(response.body()); + // 2. 解析请求 + return new SmsSendRespDTO() + .setSuccess(Objects.equals(response.getStr("Code"), RESPONSE_CODE_SUCCESS)) + .setSerialNo(response.getStr("BizId")) + .setApiRequestId(response.getStr("RequestId")) + .setApiCode(response.getStr("Code")) + .setApiMsg(response.getStr("Message")); } @Override public List parseSmsReceiveStatus(String text) { - List statuses = JsonUtils.parseArray(text, SmsReceiveStatus.class); - return convertList(statuses, status -> new SmsReceiveRespDTO().setSuccess(status.getSuccess()) - .setErrorCode(status.getErrCode()).setErrorMsg(status.getErrMsg()) - .setMobile(status.getPhoneNumber()).setReceiveTime(status.getReportTime()) - .setSerialNo(status.getBizId()).setLogId(Long.valueOf(status.getOutId()))); + JSONArray statuses = JSONUtil.parseArray(text); + // 字段参考 + return convertList(statuses, status -> { + JSONObject statusObj = (JSONObject) status; + return new SmsReceiveRespDTO() + .setSuccess(statusObj.getBool("success")) // 是否接收成功 + .setErrorCode(statusObj.getStr("err_code")) // 状态报告编码 + .setErrorMsg(statusObj.getStr("err_msg")) // 状态报告说明 + .setMobile(statusObj.getStr("phone_number")) // 手机号 + .setReceiveTime(statusObj.getLocalDateTime("report_time", null)) // 状态报告时间 + .setSerialNo(statusObj.getStr("biz_id")) // 发送序列号 + .setLogId(statusObj.getLong("out_id")); // 用户序列号 + }); } @Override public SmsTemplateRespDTO getSmsTemplate(String apiTemplateId) throws Throwable { - + // 1. 执行请求 + // 参考链接 https://api.aliyun.com/document/Dysmsapi/2017-05-25/QuerySmsTemplate TreeMap queryParam = new TreeMap<>(); - queryParam.put("TemplateCode",apiTemplateId); + queryParam.put("TemplateCode", apiTemplateId); + JSONObject response = request("QuerySmsTemplate", queryParam); - JSONObject response = sendSmsRequest(queryParam,"QuerySmsTemplate"); - QuerySmsTemplateResponse smsTemplateResponse = getSmsTemplateResponse(response); - return new SmsTemplateRespDTO().setId(smsTemplateResponse.getTemplateCode()).setContent(smsTemplateResponse.getTemplateContent()) - .setAuditStatus(convertSmsTemplateAuditStatus(smsTemplateResponse.getTemplateStatus())).setAuditReason(smsTemplateResponse.getReason()); + System.out.println("getSmsTemplate response is =====" + response.toString()); + // 2.1 请求失败 + String code = response.getStr("Code"); + if (ObjectUtil.notEqual(code, RESPONSE_CODE_SUCCESS)) { + log.error("[getSmsTemplate][模版编号({}) 响应不正确({})]", apiTemplateId, response); + return null; + } + // 2.2 请求成功 + return new SmsTemplateRespDTO().setId(apiTemplateId) + .setContent(response.getStr("TemplateContent")) + .setAuditStatus(convertSmsTemplateAuditStatus(response.getInt("TemplateStatus"))) + .setAuditReason(response.getStr("Reason")); } @VisibleForTesting @@ -196,13 +127,68 @@ public class AliyunSmsClient extends AbstractSmsClient { } } + /** + * 请求阿里云短信 + * + * @see V3 版本请求体&签名机制 + * @param apiName 请求的 API 名称 + * @param queryParams 请求参数 + * @return 请求结果 + */ + private JSONObject request(String apiName, TreeMap queryParams) { + // 1. 请求参数 + String queryString = queryParams.entrySet().stream() + .map(entry -> percentCode(entry.getKey()) + "=" + percentCode(String.valueOf(entry.getValue()))) + .collect(Collectors.joining("&")); + + // 2.1 请求 Header + TreeMap headers = new TreeMap<>(); + headers.put("host", HOST); + headers.put("x-acs-version", VERSION); + headers.put("x-acs-action", apiName); + headers.put("x-acs-date", FastDateFormat.getInstance("yyyy-MM-dd'T'HH:mm:ss'Z'", TimeZone.getTimeZone("GMT")).format(new Date())); + headers.put("x-acs-signature-nonce", IdUtil.randomUUID()); + + // 2.2 构建签名 Header + StringBuilder canonicalHeaders = new StringBuilder(); // 构造请求头,多个规范化消息头,按照消息头名称(小写)的字符代码顺序以升序排列后拼接在一起 + StringBuilder signedHeadersBuilder = new StringBuilder(); // 已签名消息头列表,多个请求头名称(小写)按首字母升序排列并以英文分号(;)分隔 + headers.entrySet().stream().filter(entry -> entry.getKey().toLowerCase().startsWith("x-acs-") + || entry.getKey().equalsIgnoreCase("host") + || entry.getKey().equalsIgnoreCase("content-type")) + .sorted(Map.Entry.comparingByKey()).forEach(entry -> { + String lowerKey = entry.getKey().toLowerCase(); + canonicalHeaders.append(lowerKey).append(":").append(String.valueOf(entry.getValue()).trim()).append("\n"); + signedHeadersBuilder.append(lowerKey).append(";"); + }); + String signedHeaders = signedHeadersBuilder.substring(0, signedHeadersBuilder.length() - 1); + + // 3. 请求 Body + String requestBody = ""; // 短信 API 为 RPC 接口,query parameters 在 uri 中拼接,因此 request body 如果没有特殊要求,设置为空。 + String hashedRequestBody = DigestUtil.sha256Hex(requestBody); + + // 4. 构建 Authorization 签名 + String canonicalRequest = "POST" + "\n" + "/" + "\n" + queryString + "\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + hashedRequestBody; + String hashedCanonicalRequest = DigestUtil.sha256Hex(canonicalRequest); + + String stringToSign = "ACS3-HMAC-SHA256" + "\n" + hashedCanonicalRequest; + String signature = SecureUtil.hmacSha256(properties.getApiSecret()).digestHex(stringToSign); // 计算签名 + headers.put("Authorization", "ACS3-HMAC-SHA256" + " " + "Credential=" + properties.getApiKey() + + ", " + "SignedHeaders=" + signedHeaders + ", " + "Signature=" + signature); + + // 5. 发起请求 + String urlWithParams = URL + "?" + queryString; + + System.out.println("urlWithParams ======" + urlWithParams); + try (HttpResponse response = HttpRequest.post(urlWithParams).addHeaders(headers).body(requestBody).execute()) { + return JSONUtil.parseObj(response.body()); + } + } /** - * 对指定的字符串进行URL编码。 - * 使用UTF-8编码字符集对字符串进行编码,并对特定的字符进行替换,以符合URL编码规范。 + * 对指定的字符串进行 URL 编码,并对特定的字符进行替换,以符合URL编码规范 * - * @param str 需要进行URL编码的字符串。 - * @return 编码后的字符串。其中,加号"+"被替换为"%20",星号"*"被替换为"%2A",波浪号"%7E"被替换为"~"。 + * @param str 需要进行URL编码的字符串 + * @return 编码后的字符串 */ public static String percentCode(String str) { if (str == null) { @@ -215,135 +201,4 @@ public class AliyunSmsClient extends AbstractSmsClient { } } - private SmsResponse getSmsSendResponse(JSONObject resJson) { - SmsResponse smsResponse = new SmsResponse(); - smsResponse.setSuccess("OK".equals(resJson.getStr("Code"))); - smsResponse.setData(resJson); -// smsResponse.setConfigId(getConfigId()); - return smsResponse; - } - - private QuerySmsTemplateResponse getSmsTemplateResponse(JSONObject resJson) { - - QuerySmsTemplateResponse smsTemplateResponse = new QuerySmsTemplateResponse(); - - smsTemplateResponse.setRequestId(resJson.getStr("RequestId")); - smsTemplateResponse.setTemplateContent(resJson.getStr("TemplateContent")); - smsTemplateResponse.setReason(resJson.getStr("Reason")); - smsTemplateResponse.setTemplateStatus(resJson.getInt("TemplateStatus")); - - return smsTemplateResponse; - } - - /** - *

类名: SmsResponse - *

说明: 发送短信返回信息 - * - * @author :scholar - * 2024/07/17 0:25 - **/ - @Data - public static class SmsResponse { - - /** - * 是否成功 - */ - private boolean success; - - /** - * 厂商原返回体 - */ - private Object data; - - /** - * 配置标识名 如未配置取对应渠道名例如 Alibaba - */ - private String configId; - } - - - /** - *

类名: QuerySmsTemplateResponse - *

说明: sms模板查询返回信息 - * - * @author :scholar - * 2024/07/17 0:25 - **/ - @Data - public static class QuerySmsTemplateResponse { - private String requestId; - private String code; - private String message; - private Integer templateStatus; - private String reason; - private String templateCode; - private Integer templateType; - private String templateName; - private String templateContent; - private String createDate; - } - - /** - * 短信接收状态 - * - * 参见 文档 - * - * @author 润普源码 - */ - @Data - public static class SmsReceiveStatus { - - /** - * 手机号 - */ - @JsonProperty("phone_number") - private String phoneNumber; - /** - * 发送时间 - */ - @JsonProperty("send_time") - @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND, timezone = TIME_ZONE_DEFAULT) - private LocalDateTime sendTime; - /** - * 状态报告时间 - */ - @JsonProperty("report_time") - @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND, timezone = TIME_ZONE_DEFAULT) - private LocalDateTime reportTime; - /** - * 是否接收成功 - */ - private Boolean success; - /** - * 状态报告说明 - */ - @JsonProperty("err_msg") - private String errMsg; - /** - * 状态报告编码 - */ - @JsonProperty("err_code") - private String errCode; - /** - * 发送序列号 - */ - @JsonProperty("biz_id") - private String bizId; - /** - * 用户序列号 - * - * 这里我们传递的是 SysSmsLogDO 的日志编号 - */ - @JsonProperty("out_id") - private String outId; - /** - * 短信长度,例如说 1、2、3 - * - * 140 字节算一条短信,短信长度超过 140 字节时会拆分成多条短信发送 - */ - @JsonProperty("sms_size") - private Integer smsSize; - - } - -} +} \ No newline at end of file