package cn.iocoder.yudao.module.system.framework.sms.core.client.impl; import cn.hutool.core.collection.ListUtil; import cn.hutool.core.date.format.FastDateFormat; import cn.hutool.core.lang.Assert; import cn.hutool.core.util.StrUtil; import cn.hutool.crypto.SecureUtil; import cn.hutool.http.HttpUtil; 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.http.HttpUtils; import cn.iocoder.yudao.framework.common.util.json.JsonUtils; 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 lombok.extern.slf4j.Slf4j; import java.nio.charset.StandardCharsets; import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneId; import java.util.*; import static cn.hutool.crypto.digest.DigestUtil.sha256Hex; import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; /** * 华为短信客户端的实现类 * * @author scholar * @since 2024/6/02 11:55 */ @Slf4j public class HuaweiSmsClient extends AbstractSmsClient { private static final String URL = "https://smsapi.cn-north-4.myhuaweicloud.com:443/sms/batchSendSms/v1";//APP接入地址+接口访问URI private static final String HOST = "smsapi.cn-north-4.myhuaweicloud.com:443"; private static final String SIGNEDHEADERS = "content-type;host;x-sdk-date"; private static final String RESPONSE_CODE_SUCCESS = "000000"; public HuaweiSmsClient(SmsChannelProperties properties) { super(properties); Assert.notEmpty(properties.getApiKey(), "apiKey 不能为空"); Assert.notEmpty(properties.getApiSecret(), "apiSecret 不能为空"); validateSender(properties); } /** * 参数校验华为云的 sender 通道号 * * 原因是:验华为云发放短信的时候,需要额外的参数 sender * * 解决方案:考虑到不破坏原有的 apiKey + apiSecret 的结构,所以将 secretId 拼接到 apiKey 字段中,格式为 "secretId sdkAppId"。 * * @param properties 配置 */ private static void validateSender(SmsChannelProperties properties) { String combineKey = properties.getApiKey(); Assert.notEmpty(combineKey, "apiKey 不能为空"); String[] keys = combineKey.trim().split(" "); Assert.isTrue(keys.length == 2, "华为云短信 apiKey 配置格式错误,请配置 为[accessKeyId sender]"); } private String getAccessKey() { return StrUtil.subBefore(properties.getApiKey(), " ", true); } private String getSender() { return StrUtil.subAfter(properties.getApiKey(), " ", true); } @Override public SmsSendRespDTO sendSms(Long sendLogId, String mobile, String apiTemplateId, List> templateParams) throws Throwable { StringBuilder requestBody = new StringBuilder(); appendToBody(requestBody, "from=", getSender()); appendToBody(requestBody, "&to=", mobile); appendToBody(requestBody, "&templateId=", apiTemplateId); appendToBody(requestBody, "&templateParas=", JsonUtils.toJsonString( convertList(templateParams, kv -> String.valueOf(kv.getValue())))); appendToBody(requestBody, "&statusCallback=", properties.getCallbackUrl()); appendToBody(requestBody, "&extend=", String.valueOf(sendLogId)); JSONObject response = request("/sms/batchSendSms/v1/", "POST", requestBody.toString()); // 2. 解析请求 if (!response.containsKey("result")) { // 例如说:密钥不正确 return new SmsSendRespDTO().setSuccess(false) .setApiCode(response.getStr("code")) .setApiMsg(response.getStr("description")); } JSONObject sendResult = response.getJSONArray("result").getJSONObject(0); return new SmsSendRespDTO().setSuccess(RESPONSE_CODE_SUCCESS.equals(response.getStr("code"))) .setSerialNo(sendResult.getStr("smsMsgId")).setApiCode(sendResult.getStr("status")); } /** * 请求华为云短信 * * @see https://support.huaweicloud.com/api-msgsms/sms_05_0046.html * @param uri 请求 URI * @param method 请求 Method * @param requestBody 请求 Body * @return 请求结果 */ private JSONObject request(String uri, String method, String requestBody) { // 1.1 请求 Header TreeMap headers = new TreeMap<>(); headers.put("Content-Type", "application/x-www-form-urlencoded"); String sdkDate = FastDateFormat.getInstance("yyyyMMdd'T'HHmmss'Z'", TimeZone.getTimeZone("UTC")).format(new Date()); headers.put("X-Sdk-Date", sdkDate); headers.put("host", HOST); // 1.2 构建签名 Header String canonicalQueryString = ""; // 查询参数为空 String canonicalHeaders = "content-type:application/x-www-form-urlencoded\n" + "host:"+ HOST +"\n" + "x-sdk-date:" + sdkDate + "\n"; String canonicalRequest = method + "\n" + uri + "\n" + canonicalQueryString + "\n" + canonicalHeaders + "\n" + SIGNEDHEADERS + "\n" + sha256Hex(requestBody); String stringToSign = "SDK-HMAC-SHA256" + "\n" + sdkDate + "\n" + sha256Hex(canonicalRequest); String signature = SecureUtil.hmacSha256(properties.getApiSecret()).digestHex(stringToSign); // 计算签名 headers.put("Authorization", "SDK-HMAC-SHA256" + " " + "Access=" + getAccessKey() + ", " + "SignedHeaders=" + SIGNEDHEADERS + ", " + "Signature=" + signature); // 2. 发起请求 String responseBody = HttpUtils.post(URL, headers, requestBody); return JSONUtil.parseObj(responseBody); } @Override public List parseSmsReceiveStatus(String requestBody) { Map params = HttpUtil.decodeParamMap(requestBody, StandardCharsets.UTF_8); // 字段参考 https://support.huaweicloud.com/api-msgsms/sms_05_0003.html return ListUtil.of(new SmsReceiveRespDTO() .setSuccess("DELIVRD".equals(params.get("status"))) // 是否接收成功 .setErrorCode(params.get("status")) // 状态报告编码 .setErrorMsg(params.get("statusDesc")) .setMobile(params.get("to")) // 手机号 .setReceiveTime(LocalDateTime.ofInstant(Instant.parse(params.get("updateTime")), ZoneId.of("UTC"))) // 状态报告时间 .setSerialNo(params.get("smsMsgId")) // 发送序列号 .setLogId(Long.valueOf(params.get("extend")))); // 用户序列号 } @Override public SmsTemplateRespDTO getSmsTemplate(String apiTemplateId) throws Throwable { // 华为短信模板查询和发送短信,是不同的两套 key 和 secret,与阿里、腾讯的区别较大,这里模板查询校验暂不实现 String[] strs = apiTemplateId.split(" "); Assert.isTrue(strs.length == 2, "格式不正确,需要满足:apiTemplateId sender"); return new SmsTemplateRespDTO().setId(apiTemplateId).setContent(null) .setAuditStatus(SmsTemplateAuditStatusEnum.SUCCESS.getStatus()).setAuditReason(null); } private static void appendToBody(StringBuilder body, String key, String value) { if (StrUtil.isNotEmpty(value)) { body.append(key).append(HttpUtils.encodeUtf8(value)); } } }