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.StrUtil;
import cn.hutool.crypto.digest.DigestUtil;
import cn.hutool.crypto.digest.HmacAlgorithm;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import cn.iocoder.yudao.framework.common.core.KeyValue;
import cn.iocoder.yudao.framework.common.util.collection.ArrayUtils;
import cn.iocoder.yudao.framework.common.util.http.HttpUtils;
import cn.iocoder.yudao.module.system.framework.sms.core.client.dto.SmsReceiveRespDTO;
import cn.iocoder.yudao.module.system.framework.sms.core.client.dto.SmsSendRespDTO;
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.google.common.annotations.VisibleForTesting;
import java.nio.charset.StandardCharsets;
import java.util.*;
import static cn.hutool.crypto.digest.DigestUtil.sha256Hex;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
/**
* 腾讯云短信功能实现
*
* 参见 文档
*
* @author shiwp
*/
public class TencentSmsClient extends AbstractSmsClient {
private static final String HOST = "sms.tencentcloudapi.com";
private static final String VERSION = "2021-01-11";
private static final String REGION = "ap-guangzhou";
/**
* 调用成功 code
*/
public static final String API_CODE_SUCCESS = "Ok";
/**
* 是否国际/港澳台短信:
*
* 0:表示国内短信。
* 1:表示国际/港澳台短信。
*/
private static final long INTERNATIONAL_CHINA = 0L;
public TencentSmsClient(SmsChannelProperties properties) {
super(properties);
Assert.notEmpty(properties.getApiSecret(), "apiSecret 不能为空");
validateSdkAppId(properties);
}
/**
* 参数校验腾讯云的 SDK AppId
*
* 原因是:腾讯云发放短信的时候,需要额外的参数 sdkAppId
*
* 解决方案:考虑到不破坏原有的 apiKey + apiSecret 的结构,所以将 secretId 拼接到 apiKey 字段中,格式为 "secretId sdkAppId"。
*
* @param properties 配置
*/
private static void validateSdkAppId(SmsChannelProperties properties) {
String combineKey = properties.getApiKey();
Assert.notEmpty(combineKey, "apiKey 不能为空");
String[] keys = combineKey.trim().split(" ");
Assert.isTrue(keys.length == 2, "腾讯云短信 apiKey 配置格式错误,请配置 为[secretId sdkAppId]");
}
private String getSdkAppId() {
return StrUtil.subAfter(properties.getApiKey(), " ", true);
}
private String getApiKey() {
return StrUtil.subBefore(properties.getApiKey(), " ", true);
}
@Override
public SmsSendRespDTO sendSms(Long sendLogId, String mobile,
String apiTemplateId, List> templateParams) throws Throwable {
// 1. 执行请求
// 参考链接 https://cloud.tencent.com/document/product/382/55981
TreeMap body = new TreeMap<>();
body.put("PhoneNumberSet", new String[]{mobile});
body.put("SmsSdkAppId", getSdkAppId());
body.put("SignName", properties.getSignature());
body.put("TemplateId", apiTemplateId);
body.put("TemplateParamSet", ArrayUtils.toArray(templateParams, param -> String.valueOf(param.getValue())));
JSONObject response = request("SendSms", body);
// 2. 解析请求
JSONObject responseResult = response.getJSONObject("Response");
JSONObject error = responseResult.getJSONObject("Error");
if (error != null) {
return new SmsSendRespDTO().setSuccess(false)
.setApiRequestId(responseResult.getStr("RequestId"))
.setApiCode(error.getStr("Code"))
.setApiMsg(error.getStr("Message"));
}
JSONObject sendResult = responseResult.getJSONArray("SendStatusSet").getJSONObject(0);
return new SmsSendRespDTO().setSuccess(Objects.equals(API_CODE_SUCCESS, sendResult.getStr("Code")))
.setApiRequestId(responseResult.getStr("RequestId"))
.setSerialNo(sendResult.getStr("SerialNo"))
.setApiMsg(sendResult.getStr("Message"));
}
@Override
public List parseSmsReceiveStatus(String text) {
JSONArray statuses = JSONUtil.parseArray(text);
// 字段参考
return convertList(statuses, status -> {
JSONObject statusObj = (JSONObject) status;
return new SmsReceiveRespDTO()
.setSuccess("SUCCESS".equals(statusObj.getStr("report_status"))) // 是否接收成功
.setErrorCode(statusObj.getStr("errmsg")) // 状态报告编码
.setErrorMsg(statusObj.getStr("description")) // 状态报告描述
.setMobile(statusObj.getStr("mobile")) // 手机号
.setReceiveTime(statusObj.getLocalDateTime("user_receive_time", null)) // 状态报告时间
.setSerialNo(statusObj.getStr("sid")); // 发送序列号
});
}
@Override
public SmsTemplateRespDTO getSmsTemplate(String apiTemplateId) throws Throwable {
// 1. 构建请求
// 参考链接 https://cloud.tencent.com/document/product/382/52067
TreeMap body = new TreeMap<>();
body.put("International", INTERNATIONAL_CHINA);
body.put("TemplateIdSet", new Integer[]{Integer.valueOf(apiTemplateId)});
JSONObject response = request("DescribeSmsTemplateList", body);
// 2. 解析请求
JSONObject statusResult = response.getJSONObject("Response")
.getJSONArray("DescribeTemplateStatusSet").getJSONObject(0);
return new SmsTemplateRespDTO().setId(apiTemplateId)
.setContent(statusResult.get("TemplateContent").toString())
.setAuditStatus(convertSmsTemplateAuditStatus(statusResult.getInt("StatusCode")))
.setAuditReason(statusResult.get("ReviewReply").toString());
}
@VisibleForTesting
Integer convertSmsTemplateAuditStatus(int templateStatus) {
switch (templateStatus) {
case 1: return SmsTemplateAuditStatusEnum.CHECKING.getStatus();
case 0: return SmsTemplateAuditStatusEnum.SUCCESS.getStatus();
case -1: return SmsTemplateAuditStatusEnum.FAIL.getStatus();
default: throw new IllegalArgumentException(String.format("未知审核状态(%d)", templateStatus));
}
}
/**
* 请求腾讯云短信
*
* @see 签名方法 v3
*
* @param action 请求的 API 名称
* @param body 请求参数
* @return 请求结果
*/
private JSONObject request(String action, TreeMap body) {
// 1.1 请求 Header
Map headers = new HashMap<>();
headers.put("Content-Type", "application/json; charset=utf-8");
headers.put("Host", HOST);
headers.put("X-TC-Action", action);
Date now = new Date();
String nowStr = FastDateFormat.getInstance("yyyy-MM-dd", TimeZone.getTimeZone("UTC")).format(now);
headers.put("X-TC-Timestamp", String.valueOf(now.getTime() / 1000));
headers.put("X-TC-Version", VERSION);
headers.put("X-TC-Region", REGION);
// 1.2 构建签名 Header
String canonicalQueryString = "";
String canonicalHeaders = "content-type:application/json; charset=utf-8\n"
+ "host:" + HOST + "\n" + "x-tc-action:" + action.toLowerCase() + "\n";
String signedHeaders = "content-type;host;x-tc-action";
String canonicalRequest = "POST" + "\n" + "/" + "\n" + canonicalQueryString + "\n" + canonicalHeaders + "\n"
+ signedHeaders + "\n" + sha256Hex(JSONUtil.toJsonStr(body));
String credentialScope = nowStr + "/" + "sms" + "/" + "tc3_request";
String stringToSign = "TC3-HMAC-SHA256" + "\n" + now.getTime() / 1000 + "\n" + credentialScope + "\n" +
sha256Hex(canonicalRequest);
byte[] secretService = hmac256(hmac256(("TC3" + properties.getApiSecret()).getBytes(StandardCharsets.UTF_8), nowStr), "sms");
String signature = HexUtil.encodeHexStr(hmac256(hmac256(secretService, "tc3_request"), stringToSign));
headers.put("Authorization", "TC3-HMAC-SHA256" + " " + "Credential=" + getApiKey() + "/" + credentialScope + ", "
+ "SignedHeaders=" + signedHeaders + ", " + "Signature=" + signature);
// 2. 发起请求
String responseBody = HttpUtils.post("https://" + HOST, headers, JSONUtil.toJsonStr(body));
return JSONUtil.parseObj(responseBody);
}
private static byte[] hmac256(byte[] key, String msg) {
return DigestUtil.hmac(HmacAlgorithm.HmacSHA256, key).digest(msg);
}
}