Merge branch 'develop' of https://gitee.com/zhijiantianya/ruoyi-vue-pro into master-jdk17

This commit is contained in:
YunaiV
2024-09-07 08:47:56 +08:00
71 changed files with 1624 additions and 918 deletions

View File

@ -6,9 +6,7 @@ import cn.iocoder.yudao.module.system.framework.sms.core.enums.SmsChannelEnum;
import cn.iocoder.yudao.module.system.service.sms.SmsSendService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;
import jakarta.annotation.Resource;
import jakarta.annotation.security.PermitAll;
@ -26,7 +24,7 @@ public class SmsCallbackController {
@PostMapping("/aliyun")
@PermitAll
@Operation(summary = "阿里云短信的回调", description = "参见 https://help.aliyun.com/zh/sms/developer-reference/configure-delivery-receipts-1 文档")
@Operation(summary = "阿里云短信的回调", description = "参见 https://help.aliyun.com/document_detail/120998.html 文档")
public CommonResult<Boolean> receiveAliyunSmsStatus(HttpServletRequest request) throws Throwable {
String text = ServletUtils.getBody(request);
smsSendService.receiveSmsStatus(SmsChannelEnum.ALIYUN.getCode(), text);
@ -35,7 +33,7 @@ public class SmsCallbackController {
@PostMapping("/tencent")
@PermitAll
@Operation(summary = "腾讯云短信的回调", description = "参见 https://cloud.tencent.com/document/product/382/59178 文档")
@Operation(summary = "腾讯云短信的回调", description = "参见 https://cloud.tencent.com/document/product/382/52077 文档")
public CommonResult<Boolean> receiveTencentSmsStatus(HttpServletRequest request) throws Throwable {
String text = ServletUtils.getBody(request);
smsSendService.receiveSmsStatus(SmsChannelEnum.TENCENT.getCode(), text);
@ -46,10 +44,17 @@ public class SmsCallbackController {
@PostMapping("/huawei")
@PermitAll
@Operation(summary = "华为云短信的回调", description = "参见 https://support.huaweicloud.com/api-msgsms/sms_05_0003.html 文档")
public CommonResult<Boolean> receiveHuaweiSmsStatus(HttpServletRequest request) throws Throwable {
String text = ServletUtils.getBody(request);
smsSendService.receiveSmsStatus(SmsChannelEnum.HUAWEI.getCode(), text);
public CommonResult<Boolean> receiveHuaweiSmsStatus(@RequestBody String requestBody) throws Throwable {
smsSendService.receiveSmsStatus(SmsChannelEnum.HUAWEI.getCode(), requestBody);
return success(true);
}
}
@PostMapping("/qiniu")
@PermitAll
@Operation(summary = "七牛云短信的回调", description = "参见 https://developer.qiniu.com/sms/5910/message-push 文档")
public CommonResult<Boolean> receiveQiniuSmsStatus(@RequestBody String requestBody) throws Throwable {
smsSendService.receiveSmsStatus(SmsChannelEnum.QINIU.getCode(), requestBody);
return success(true);
}
}

View File

@ -30,7 +30,8 @@ public interface SmsClientFactory {
* 创建短信 Client
*
* @param properties 配置对象
* @return 短信 Client
*/
void createOrUpdateSmsClient(SmsChannelProperties properties);
SmsClient createOrUpdateSmsClient(SmsChannelProperties properties);
}

View File

@ -26,15 +26,9 @@ public abstract class AbstractSmsClient implements SmsClient {
* 初始化
*/
public final void init() {
doInit();
log.debug("[init][配置({}) 初始化完成]", properties);
}
/**
* 自定义初始化
*/
protected abstract void doInit();
public final void refresh(SmsChannelProperties properties) {
// 判断是否更新
if (properties.equals(this.properties)) {

View File

@ -50,10 +50,6 @@ public class AliyunSmsClient extends AbstractSmsClient {
Assert.notEmpty(properties.getApiSecret(), "apiSecret 不能为空");
}
@Override
protected void doInit() {
}
@Override
public SmsSendRespDTO sendSms(Long sendLogId, String mobile, String apiTemplateId,
List<KeyValue<String, Object>> templateParams) throws Throwable {
@ -80,7 +76,7 @@ public class AliyunSmsClient extends AbstractSmsClient {
@Override
public List<SmsReceiveRespDTO> parseSmsReceiveStatus(String text) {
JSONArray statuses = JSONUtil.parseArray(text);
// 字段参考
// 字段参考 https://help.aliyun.com/zh/sms/developer-reference/smsreport-2
return convertList(statuses, status -> {
JSONObject statusObj = (JSONObject) status;
return new SmsReceiveRespDTO()
@ -166,7 +162,8 @@ public class AliyunSmsClient extends AbstractSmsClient {
String hashedRequestBody = DigestUtil.sha256Hex(requestBody);
// 4. 构建 Authorization 签名
String canonicalRequest = "POST" + "\n" + "/" + "\n" + queryString + "\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + hashedRequestBody;
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); // 计算签名

View File

@ -36,10 +36,6 @@ public class DebugDingTalkSmsClient extends AbstractSmsClient {
Assert.notEmpty(properties.getApiSecret(), "apiSecret 不能为空");
}
@Override
protected void doInit() {
}
@Override
public SmsSendRespDTO sendSms(Long sendLogId, String mobile,
String apiTemplateId, List<KeyValue<String, Object>> templateParams) throws Throwable {

View File

@ -1,41 +1,35 @@
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.CharsetUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.SecureUtil;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
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.collection.CollectionUtils;
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 com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.text.SimpleDateFormat;
import java.util.*;
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;
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;
// todo @scholar参考阿里云在优化下
/**
* 华为短信客户端的实现类
*
@ -45,182 +39,128 @@ import static cn.iocoder.yudao.framework.common.util.date.DateUtils.TIME_ZONE_DE
@Slf4j
public class HuaweiSmsClient extends AbstractSmsClient {
/**
* 调用成功 code
*/
public static final String URL = "https://smsapi.cn-north-4.myhuaweicloud.com:443/sms/batchSendSms/v1";//APP接入地址+接口访问URI
public static final String HOST = "smsapi.cn-north-4.myhuaweicloud.com:443";
public static final String SIGNEDHEADERS = "content-type;host;x-sdk-date";
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";
@Override
protected void doInit() {
}
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<KeyValue<String, Object>> templateParams) throws Throwable {
// 参考链接 https://support.huaweicloud.com/api-msgsms/sms_05_0001.html
// 相比较阿里短信,华为短信发送的时候需要额外的参数“通道号”,考虑到不破坏原有的的结构
// 所以将 通道号 拼接到 apiTemplateId 字段中,格式为 "apiTemplateId 通道号"。空格为分隔符。
String sender = StrUtil.subAfter(apiTemplateId, " ", true); //中国大陆短信签名通道号或全球短信通道号
String templateId = StrUtil.subBefore(apiTemplateId, " ", true); //模板ID
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());
//选填,短信状态报告接收地址,推荐使用域名,为空或者不填表示不接收状态报告
String statusCallBack = properties.getCallbackUrl();
List<String> templateParas = CollectionUtils.convertList(templateParams, kv -> String.valueOf(kv.getValue()));
JSONObject JsonResponse = sendSmsRequest(sender,mobile,templateId,templateParas,statusCallBack);
SmsResponse smsResponse = getSmsSendResponse(JsonResponse);
return new SmsSendRespDTO().setSuccess(smsResponse.success).setApiMsg(smsResponse.data.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"));
}
JSONObject sendSmsRequest(String sender,String mobile,String templateId,List<String> templateParas,String statusCallBack) throws UnsupportedEncodingException {
/**
* 请求华为云短信
*
* @see <a href="认证鉴权">https://support.huaweicloud.com/api-msgsms/sms_05_0046.html</a>
* @param uri 请求 URI
* @param method 请求 Method
* @param requestBody 请求 Body
* @return 请求结果
*/
private JSONObject request(String uri, String method, String requestBody) {
// 1.1 请求 Header
TreeMap<String, String> 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);
SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'", Locale.ENGLISH);
sdf.setTimeZone(TimeZone.getTimeZone("UTC"));
String sdkDate = sdf.format(new Date());
// ************* 步骤 1拼接规范请求串 *************
String httpRequestMethod = "POST";
String canonicalUri = "/sms/batchSendSms/v1/";
String canonicalQueryString = "";//查询参数为空
// 1.2 构建签名 Header
String canonicalQueryString = ""; // 查询参数为空
String canonicalHeaders = "content-type:application/x-www-form-urlencoded\n"
+ "host:"+ HOST +"\n"
+ "x-sdk-date:" + sdkDate + "\n";
//请求Body,不携带签名名称时,signature请填null
String body = buildRequestBody(sender, mobile, templateId, templateParas, statusCallBack, null);
if (null == body || body.isEmpty()) {
return null;
}
String hashedRequestBody = sha256Hex(body);
String canonicalRequest = httpRequestMethod + "\n" + canonicalUri + "\n" + canonicalQueryString + "\n"
+ canonicalHeaders + "\n" + SIGNEDHEADERS + "\n" + hashedRequestBody;
+ "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 hashedCanonicalRequest = sha256Hex(canonicalRequest);
String stringToSign = "SDK-HMAC-SHA256" + "\n" + sdkDate + "\n" + hashedCanonicalRequest;
// ************* 步骤 3计算签名 *************
String signature = SecureUtil.hmacSha256(properties.getApiSecret()).digestHex(stringToSign);
// ************* 步骤 4拼接 Authorization *************
String authorization = "SDK-HMAC-SHA256" + " " + "Access=" + properties.getApiKey() + ", "
+ "SignedHeaders=" + SIGNEDHEADERS + ", " + "Signature=" + signature;
// ************* 步骤 5构造HttpRequest 并执行request请求获得response *************
HttpResponse response = HttpRequest.post(URL)
.header("Content-Type", "application/x-www-form-urlencoded")
.header("X-Sdk-Date", sdkDate)
.header("host",HOST)
.header("Authorization", authorization)
.body(body)
.execute();
return JSONUtil.parseObj(response.body());
// 2. 发起请求
String responseBody = HttpUtils.post(URL, headers, requestBody);
return JSONUtil.parseObj(responseBody);
}
private SmsResponse getSmsSendResponse(JSONObject resJson) {
SmsResponse smsResponse = new SmsResponse();
smsResponse.setSuccess("000000".equals(resJson.getStr("code")));
smsResponse.setData(resJson);
return smsResponse;
}
static String buildRequestBody(String sender, String receiver, String templateId, List<String> templateParas,
String statusCallBack, String signature) throws UnsupportedEncodingException {
if (null == sender || null == receiver || null == templateId || sender.isEmpty() || receiver.isEmpty()
|| templateId.isEmpty()) {
System.out.println("buildRequestBody(): sender, receiver or templateId is null.");
return null;
}
StringBuilder body = new StringBuilder();
appendToBody(body, "from=", sender);
appendToBody(body, "&to=", receiver);
appendToBody(body, "&templateId=", templateId);
appendToBody(body, "&templateParas=", JsonUtils.toJsonString(templateParas));
appendToBody(body, "&statusCallback=", statusCallBack);
appendToBody(body, "&signature=", signature);
return body.toString();
}
private static void appendToBody(StringBuilder body, String key, String val) throws UnsupportedEncodingException {
if (null != val && !val.isEmpty()) {
body.append(key).append(URLEncoder.encode(val, "UTF-8"));
}
}
@Override
public List<SmsReceiveRespDTO> parseSmsReceiveStatus(String text) {
List<SmsReceiveStatus> statuses = JsonUtils.parseArray(text, SmsReceiveStatus.class);
return convertList(statuses, status -> new SmsReceiveRespDTO().setSuccess(Objects.equals(status.getStatus(),"DELIVRD"))
.setErrorCode(status.getStatus()).setErrorMsg(status.getStatus())
.setMobile(status.getPhoneNumber()).setReceiveTime(status.getUpdateTime())
.setSerialNo(status.getSmsMsgId()));
public List<SmsReceiveRespDTO> parseSmsReceiveStatus(String requestBody) {
Map<String, String> 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 {
//华为短信模板查询和发送短信是不同的两套keysecret与阿里、腾讯的区别较大这里模板查询校验暂不实现
return new SmsTemplateRespDTO().setId(null).setContent(null)
// 华为短信模板查询和发送短信,是不同的两套 keysecret与阿里、腾讯的区别较大这里模板查询校验暂不实现
String[] strs = apiTemplateId.split(" ");
Assert.isTrue(strs.length == 2, "格式不正确需要满足apiTemplateId sender");
return new SmsTemplateRespDTO().setId(apiTemplateId).setContent(null)
.setAuditStatus(SmsTemplateAuditStatusEnum.SUCCESS.getStatus()).setAuditReason(null);
}
@Data
public static class SmsResponse {
/**
* 是否成功
*/
private boolean success;
/**
* 厂商原返回体
*/
private Object data;
@SuppressWarnings("CharsetObjectCanBeUsed")
private static void appendToBody(StringBuilder body, String key, String value) throws UnsupportedEncodingException {
if (StrUtil.isNotEmpty(value)) {
body.append(key).append(URLEncoder.encode(value, CharsetUtil.CHARSET_UTF_8.name()));
}
}
/**
* 短信接收状态
*
* 参见 <a href="https://support.huaweicloud.com/api-msgsms/sms_05_0003.html">文档</a>
*
* @author scholar
*/
@Data
public static class SmsReceiveStatus {
/**
* 本条状态报告对应的短信的接收方号码仅当状态报告中携带了extend参数时才会同时携带该参数
*/
@JsonProperty("to")
private String phoneNumber;
/**
* 短信资源的更新时间,通常为短信平台接收短信状态报告的时间
*/
@JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND, timezone = TIME_ZONE_DEFAULT)
private LocalDateTime updateTime;
/**
* 短信状态报告枚举值
*/
private String status;
/**
* 发送短信成功时返回的短信唯一标识。
*/
private String smsMsgId;
}
}
}

View File

@ -0,0 +1,155 @@
package cn.iocoder.yudao.module.system.framework.sms.core.client.impl;
import cn.hutool.core.collection.CollStreamUtil;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.date.LocalDateTimeUtil;
import cn.hutool.core.lang.Assert;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.crypto.SecureUtil;
import cn.hutool.crypto.digest.HmacAlgorithm;
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.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 lombok.extern.slf4j.Slf4j;
import java.util.*;
import java.util.function.Function;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
/**
* 七牛云短信客户端的实现类
*
* @author scholar
* @since 2024/08/26 15:35
*/
@Slf4j
public class QiniuSmsClient extends AbstractSmsClient {
private static final String HOST = "sms.qiniuapi.com";
public QiniuSmsClient(SmsChannelProperties properties) {
super(properties);
Assert.notEmpty(properties.getApiKey(), "apiKey 不能为空");
Assert.notEmpty(properties.getApiSecret(), "apiSecret 不能为空");
}
public SmsSendRespDTO sendSms(Long sendLogId, String mobile, String apiTemplateId,
List<KeyValue<String, Object>> templateParams) throws Throwable {
// 1. 执行请求
// 参考链接 https://developer.qiniu.com/sms/5824/through-the-api-send-text-messages
LinkedHashMap<String, Object> body = new LinkedHashMap<>();
body.put("template_id", apiTemplateId);
body.put("mobile", mobile);
body.put("parameters", CollStreamUtil.toMap(templateParams, KeyValue::getKey, KeyValue::getValue));
body.put("seq", Long.toString(sendLogId));
JSONObject response = request("POST", body, "/v1/message/single");
// 2. 解析请求
if (ObjectUtil.isNotEmpty(response.getStr("error"))) {
// 短信请求失败
return new SmsSendRespDTO().setSuccess(false)
.setApiCode(response.getStr("error"))
.setApiRequestId(response.getStr("request_id"))
.setApiMsg(response.getStr("message"));
}
return new SmsSendRespDTO().setSuccess(response.containsKey("message_id"))
.setSerialNo(response.getStr("message_id"));
}
/**
* 请求七牛云短信
*
* @see <a href="https://developer.qiniu.com/sms/5842/sms-api-authentication"</>
* @param httpMethod http请求方法
* @param body http请求消息体
* @param path URL path
* @return 请求结果
*/
private JSONObject request(String httpMethod, LinkedHashMap<String, Object> body, String path) {
String signDate = DateUtil.date().setTimeZone(TimeZone.getTimeZone("UTC")).toString("yyyyMMdd'T'HHmmss'Z'");
// 1. 请求头
Map<String, String> header = new HashMap<>(4);
header.put("HOST", HOST);
header.put("Authorization", getSignature(httpMethod, path, body != null ? JSONUtil.toJsonStr(body) : "", signDate));
header.put("Content-Type", "application/json");
header.put("X-Qiniu-Date", signDate);
// 2. 发起请求
String responseBody;
if (Objects.equals(httpMethod, "POST")){
responseBody = HttpUtils.post("https://" + HOST + path, header, JSONUtil.toJsonStr(body));
} else {
responseBody = HttpUtils.get("https://" + HOST + path, header);
}
return JSONUtil.parseObj(responseBody);
}
private String getSignature(String method, String path, String body, String signDate) {
StringBuilder dataToSign = new StringBuilder();
dataToSign.append(method.toUpperCase()).append(" ").append(path)
.append("\nHost: ").append(HOST)
.append("\n").append("Content-Type").append(": ").append("application/json")
.append("\n").append("X-Qiniu-Date").append(": ").append(signDate)
.append("\n\n");
if (ObjectUtil.isNotEmpty(body)) {
dataToSign.append(body);
}
String signature = SecureUtil.hmac(HmacAlgorithm.HmacSHA1, properties.getApiSecret())
.digestBase64(dataToSign.toString(), true);
return "Qiniu " + properties.getApiKey() + ":" + signature;
}
@Override
public List<SmsReceiveRespDTO> parseSmsReceiveStatus(String text) {
JSONObject status = JSONUtil.parseObj(text);
// 字段参考 https://developer.qiniu.com/sms/5910/message-push
return convertList(status.getJSONArray("items"), new Function<Object, SmsReceiveRespDTO>() {
@Override
public SmsReceiveRespDTO apply(Object item) {
JSONObject statusObj = (JSONObject) item;
return new SmsReceiveRespDTO()
.setSuccess("DELIVRD".equals(statusObj.getStr("status"))) // 是否接收成功
.setErrorMsg(statusObj.getStr("status")) // 状态报告编码
.setMobile(statusObj.getStr("mobile")) // 手机号
.setReceiveTime(LocalDateTimeUtil.of(statusObj.getLong("delivrd_at") * 1000L)) // 状态报告时间
.setSerialNo(statusObj.getStr("message_id")) // 发送序列号
.setLogId(statusObj.getLong("seq")); // 用户序列号
}
});
}
@Override
public SmsTemplateRespDTO getSmsTemplate(String apiTemplateId) throws Throwable {
// 1. 执行请求
// 参考链接 https://developer.qiniu.com/sms/5969/query-a-single-template
JSONObject response = request("GET", null, "/v1/template/" + apiTemplateId);
// 2.2 解析请求
return new SmsTemplateRespDTO()
.setId(response.getStr("id"))
.setContent(response.getStr("template"))
.setAuditStatus(convertSmsTemplateAuditStatus(response.getStr("audit_status")))
.setAuditReason(response.getStr("reject_reason"));
}
@VisibleForTesting
Integer convertSmsTemplateAuditStatus(String templateStatus) {
switch (templateStatus) {
case "passed": return SmsTemplateAuditStatusEnum.SUCCESS.getStatus();
case "reviewing": return SmsTemplateAuditStatusEnum.CHECKING.getStatus();
case "rejected": return SmsTemplateAuditStatusEnum.FAIL.getStatus();
default:
throw new IllegalArgumentException(String.format("未知审核状态(%str)", templateStatus));
}
}
}

View File

@ -59,7 +59,7 @@ public class SmsClientFactoryImpl implements SmsClientFactory {
}
@Override
public void createOrUpdateSmsClient(SmsChannelProperties properties) {
public SmsClient createOrUpdateSmsClient(SmsChannelProperties properties) {
AbstractSmsClient client = channelIdClients.get(properties.getId());
if (client == null) {
client = this.createSmsClient(properties);
@ -68,6 +68,7 @@ public class SmsClientFactoryImpl implements SmsClientFactory {
} else {
client.refresh(properties);
}
return client;
}
private AbstractSmsClient createSmsClient(SmsChannelProperties properties) {
@ -79,6 +80,7 @@ public class SmsClientFactoryImpl implements SmsClientFactory {
case DEBUG_DING_TALK: return new DebugDingTalkSmsClient(properties);
case TENCENT: return new TencentSmsClient(properties);
case HUAWEI: return new HuaweiSmsClient(properties);
case QINIU: return new QiniuSmsClient(properties);
}
// 创建失败,错误日志 + 抛出异常
log.error("[createSmsClient][配置({}) 找不到合适的客户端实现]", properties);

View File

@ -1,7 +1,11 @@
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;
@ -14,12 +18,8 @@ import cn.iocoder.yudao.module.system.framework.sms.core.client.dto.SmsTemplateR
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 jakarta.xml.bind.DatatypeConverter;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.util.*;
import static cn.hutool.crypto.digest.DigestUtil.sha256Hex;
@ -34,6 +34,7 @@ import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.
*/
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";
@ -56,10 +57,6 @@ public class TencentSmsClient extends AbstractSmsClient {
validateSdkAppId(properties);
}
@Override
protected void doInit() {
}
/**
* 参数校验腾讯云的 SDK AppId
*
@ -93,7 +90,7 @@ public class TencentSmsClient extends AbstractSmsClient {
body.put("PhoneNumberSet", new String[]{mobile});
body.put("SmsSdkAppId", getSdkAppId());
body.put("SignName", properties.getSignature());
body.put("TemplateId",apiTemplateId);
body.put("TemplateId", apiTemplateId);
body.put("TemplateParamSet", ArrayUtils.toArray(templateParams, param -> String.valueOf(param.getValue())));
JSONObject response = request("SendSms", body);
@ -106,11 +103,11 @@ public class TencentSmsClient extends AbstractSmsClient {
.setApiCode(error.getStr("Code"))
.setApiMsg(error.getStr("Message"));
}
JSONObject responseData = responseResult.getJSONArray("SendStatusSet").getJSONObject(0);
return new SmsSendRespDTO().setSuccess(Objects.equals(API_CODE_SUCCESS, responseData.getStr("Code")))
JSONObject sendResult = responseResult.getJSONArray("SendStatusSet").getJSONObject(0);
return new SmsSendRespDTO().setSuccess(Objects.equals(API_CODE_SUCCESS, sendResult.getStr("Code")))
.setApiRequestId(responseResult.getStr("RequestId"))
.setSerialNo(responseData.getStr("SerialNo"))
.setApiMsg(responseData.getStr("Message"));
.setSerialNo(sendResult.getStr("SerialNo"))
.setApiMsg(sendResult.getStr("Message"));
}
@Override
@ -137,14 +134,13 @@ public class TencentSmsClient extends AbstractSmsClient {
body.put("TemplateIdSet", new Integer[]{Integer.valueOf(apiTemplateId)});
JSONObject response = request("DescribeSmsTemplateList", body);
// TODO @scholar会有请求失败的情况么类似发送的那块逻辑我补充了
JSONObject TemplateStatusSet = response.getJSONObject("Response").getJSONArray("DescribeTemplateStatusSet").getJSONObject(0);
String content = TemplateStatusSet.get("TemplateContent").toString();
int templateStatus = Integer.parseInt(TemplateStatusSet.get("StatusCode").toString());
String auditReason = TemplateStatusSet.get("ReviewReply").toString();
return new SmsTemplateRespDTO().setId(apiTemplateId).setContent(content)
.setAuditStatus(convertSmsTemplateAuditStatus(templateStatus)).setAuditReason(auditReason);
// 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
@ -166,64 +162,40 @@ public class TencentSmsClient extends AbstractSmsClient {
* @param body 请求参数
* @return 请求结果
*/
private JSONObject request(String action, TreeMap<String, Object> body) throws Exception {
String timestamp = String.valueOf(System.currentTimeMillis() / 1000);
// TODO @scholar这个 format看看怎么写的可以简化点
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
// 注意时区,否则容易出错
sdf.setTimeZone(TimeZone.getTimeZone("UTC"));
String date = sdf.format(new Date(Long.valueOf(timestamp + "000")));
// TODO @scholar这个步骤看看怎么参考阿里云 client归类下1. 2.1 2.2 这种
// ************* 步骤 1拼接规范请求串 *************
// TODO @scholar这个 hsot 枚举下;
String host = "sms.tencentcloudapi.com"; //APP接入地址+接口访问URI
String httpMethod = "POST"; // 请求方式
String canonicalUri = "/";
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 hashedRequestBody = sha256Hex(JSONUtil.toJsonStr(body));
// TODO @scholar换行下不然单行太长了
String canonicalRequest = httpMethod + "\n" + canonicalUri + "\n" + canonicalQueryString + "\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + hashedRequestBody;
// ************* 步骤 2拼接待签名字符串 *************
String credentialScope = date + "/" + "sms" + "/" + "tc3_request";
String hashedCanonicalRequest = sha256Hex(canonicalRequest);
String stringToSign = "TC3-HMAC-SHA256" + "\n" + timestamp + "\n" + credentialScope + "\n" + hashedCanonicalRequest;
// ************* 步骤 3计算签名 *************
byte[] secretDate = hmac256(("TC3" + properties.getApiSecret()).getBytes(StandardCharsets.UTF_8), date);
byte[] secretService = hmac256(secretDate, "sms");
byte[] secretSigning = hmac256(secretService, "tc3_request");
String signature = DatatypeConverter.printHexBinary(hmac256(secretSigning, stringToSign)).toLowerCase();
// ************* 步骤 4拼接 Authorization *************
String authorization = "TC3-HMAC-SHA256" + " " + "Credential=" + getApiKey() + "/" + credentialScope + ", "
+ "SignedHeaders=" + signedHeaders + ", " + "Signature=" + signature;
// ************* 步骤 5构造HttpRequest 并执行request请求获得response *************
private JSONObject request(String action, TreeMap<String, Object> body) {
// 1.1 请求 Header
Map<String, String> headers = new HashMap<>();
headers.put("Authorization", authorization);
headers.put("Content-Type", "application/json; charset=utf-8");
headers.put("Host", host);
headers.put("Host", HOST);
headers.put("X-TC-Action", action);
headers.put("X-TC-Timestamp", timestamp);
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);
String responseBody = HttpUtils.post("https://" + host, headers, JSONUtil.toJsonStr(body));
// 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);
}
// TODO @scholar使用 hutool 简化下
private static byte[] hmac256(byte[] key, String msg) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
SecretKeySpec secretKeySpec = new SecretKeySpec(key, mac.getAlgorithm());
mac.init(secretKeySpec);
return mac.doFinal(msg.getBytes(StandardCharsets.UTF_8));
private static byte[] hmac256(byte[] key, String msg) {
return DigestUtil.hmac(HmacAlgorithm.HmacSHA256, key).digest(msg);
}
}

View File

@ -18,6 +18,7 @@ public enum SmsChannelEnum {
ALIYUN("ALIYUN", "阿里云"),
TENCENT("TENCENT", "腾讯云"),
HUAWEI("HUAWEI", "华为云"),
QINIU("QINIU", "七牛云"),
;
/**
@ -34,3 +35,4 @@ public enum SmsChannelEnum {
}
}

View File

@ -56,7 +56,7 @@ public class OAuth2TokenServiceImpl implements OAuth2TokenService {
private AdminUserService adminUserService;
@Override
@Transactional
@Transactional(rollbackFor = Exception.class)
public OAuth2AccessTokenDO createAccessToken(Long userId, Integer userType, String clientId, List<String> scopes) {
OAuth2ClientDO clientDO = oauth2ClientService.validOAuthClientFromCache(clientId);
// 创建刷新令牌
@ -66,6 +66,7 @@ public class OAuth2TokenServiceImpl implements OAuth2TokenService {
}
@Override
@Transactional(rollbackFor = Exception.class)
public OAuth2AccessTokenDO refreshAccessToken(String refreshToken, String clientId) {
// 查询访问令牌
OAuth2RefreshTokenDO refreshTokenDO = oauth2RefreshTokenMapper.selectByRefreshToken(refreshToken);
@ -82,7 +83,7 @@ public class OAuth2TokenServiceImpl implements OAuth2TokenService {
// 移除相关的访问令牌
List<OAuth2AccessTokenDO> accessTokenDOs = oauth2AccessTokenMapper.selectListByRefreshToken(refreshToken);
if (CollUtil.isNotEmpty(accessTokenDOs)) {
oauth2AccessTokenMapper.deleteBatchIds(convertSet(accessTokenDOs, OAuth2AccessTokenDO::getId));
oauth2AccessTokenMapper.deleteByIds(convertSet(accessTokenDOs, OAuth2AccessTokenDO::getId));
oauth2AccessTokenRedisDAO.deleteList(convertSet(accessTokenDOs, OAuth2AccessTokenDO::getAccessToken));
}
@ -126,6 +127,7 @@ public class OAuth2TokenServiceImpl implements OAuth2TokenService {
}
@Override
@Transactional(rollbackFor = Exception.class)
public OAuth2AccessTokenDO removeAccessToken(String accessToken) {
// 删除访问令牌
OAuth2AccessTokenDO accessTokenDO = oauth2AccessTokenMapper.selectByAccessToken(accessToken);

View File

@ -1,27 +1,21 @@
package cn.iocoder.yudao.module.system.service.sms;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.system.framework.sms.core.client.SmsClient;
import cn.iocoder.yudao.module.system.framework.sms.core.client.SmsClientFactory;
import cn.iocoder.yudao.module.system.framework.sms.core.property.SmsChannelProperties;
import cn.iocoder.yudao.module.system.controller.admin.sms.vo.channel.SmsChannelPageReqVO;
import cn.iocoder.yudao.module.system.controller.admin.sms.vo.channel.SmsChannelSaveReqVO;
import cn.iocoder.yudao.module.system.dal.dataobject.sms.SmsChannelDO;
import cn.iocoder.yudao.module.system.dal.mysql.sms.SmsChannelMapper;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import lombok.Getter;
import cn.iocoder.yudao.module.system.framework.sms.core.client.SmsClient;
import cn.iocoder.yudao.module.system.framework.sms.core.client.SmsClientFactory;
import cn.iocoder.yudao.module.system.framework.sms.core.property.SmsChannelProperties;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import jakarta.annotation.Resource;
import java.time.Duration;
import java.util.List;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.util.cache.CacheUtils.buildAsyncReloadingCache;
import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.SMS_CHANNEL_HAS_CHILDREN;
import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.SMS_CHANNEL_NOT_EXISTS;
@ -34,46 +28,6 @@ import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.SMS_CHANNE
@Slf4j
public class SmsChannelServiceImpl implements SmsChannelService {
/**
* {@link SmsClient} 缓存,通过它异步刷新 smsClientFactory
*/
@Getter
private final LoadingCache<Long, SmsClient> idClientCache = buildAsyncReloadingCache(Duration.ofSeconds(10L),
new CacheLoader<Long, SmsClient>() {
@Override
public SmsClient load(Long id) {
// 查询,然后尝试刷新
SmsChannelDO channel = smsChannelMapper.selectById(id);
if (channel != null) {
SmsChannelProperties properties = BeanUtils.toBean(channel, SmsChannelProperties.class);
smsClientFactory.createOrUpdateSmsClient(properties);
}
return smsClientFactory.getSmsClient(id);
}
});
/**
* {@link SmsClient} 缓存,通过它异步刷新 smsClientFactory
*/
@Getter
private final LoadingCache<String, SmsClient> codeClientCache = buildAsyncReloadingCache(Duration.ofSeconds(60L),
new CacheLoader<String, SmsClient>() {
@Override
public SmsClient load(String code) {
// 查询,然后尝试刷新
SmsChannelDO channel = smsChannelMapper.selectByCode(code);
if (channel != null) {
SmsChannelProperties properties = BeanUtils.toBean(channel, SmsChannelProperties.class);
smsClientFactory.createOrUpdateSmsClient(properties);
}
return smsClientFactory.getSmsClient(code);
}
});
@Resource
private SmsClientFactory smsClientFactory;
@ -93,41 +47,22 @@ public class SmsChannelServiceImpl implements SmsChannelService {
@Override
public void updateSmsChannel(SmsChannelSaveReqVO updateReqVO) {
// 校验存在
SmsChannelDO channel = validateSmsChannelExists(updateReqVO.getId());
validateSmsChannelExists(updateReqVO.getId());
// 更新
SmsChannelDO updateObj = BeanUtils.toBean(updateReqVO, SmsChannelDO.class);
smsChannelMapper.updateById(updateObj);
// 清空缓存
clearCache(updateReqVO.getId(), channel.getCode());
}
@Override
public void deleteSmsChannel(Long id) {
// 校验存在
SmsChannelDO channel = validateSmsChannelExists(id);
validateSmsChannelExists(id);
// 校验是否有在使用该账号的模版
if (smsTemplateService.getSmsTemplateCountByChannelId(id) > 0) {
throw exception(SMS_CHANNEL_HAS_CHILDREN);
}
// 删除
smsChannelMapper.deleteById(id);
// 清空缓存
clearCache(id, channel.getCode());
}
/**
* 清空指定渠道编号的缓存
*
* @param id 渠道编号
* @param code 渠道编码
*/
private void clearCache(Long id, String code) {
idClientCache.invalidate(id);
if (StrUtil.isNotEmpty(code)) {
codeClientCache.invalidate(code);
}
}
private SmsChannelDO validateSmsChannelExists(Long id) {
@ -155,12 +90,14 @@ public class SmsChannelServiceImpl implements SmsChannelService {
@Override
public SmsClient getSmsClient(Long id) {
return idClientCache.getUnchecked(id);
SmsChannelDO channel = smsChannelMapper.selectById(id);
SmsChannelProperties properties = BeanUtils.toBean(channel, SmsChannelProperties.class);
return smsClientFactory.createOrUpdateSmsClient(properties);
}
@Override
public SmsClient getSmsClient(String code) {
return codeClientCache.getUnchecked(code);
return smsClientFactory.getSmsClient(code);
}
}

View File

@ -0,0 +1,127 @@
package cn.iocoder.yudao.module.system.framework.sms.core.client.impl;
import cn.iocoder.yudao.framework.common.core.KeyValue;
import cn.iocoder.yudao.framework.common.util.http.HttpUtils;
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
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.property.SmsChannelProperties;
import com.google.common.collect.Lists;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.MockedStatic;
import java.time.LocalDateTime;
import java.util.List;
import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mockStatic;
/**
* {@link HuaweiSmsClient} 的单元测试
*
* @author scholar
*/
public class HuaweiSmsClientTest extends BaseMockitoUnitTest {
private final SmsChannelProperties properties = new SmsChannelProperties()
.setApiKey(randomString() + " " + randomString()) // 随机一个 apiKey避免构建报错
.setApiSecret(randomString()) // 随机一个 apiSecret避免构建报错
.setSignature("芋道源码");
@InjectMocks
private HuaweiSmsClient smsClient = new HuaweiSmsClient(properties);
@Test
public void testDoSendSms_success() throws Throwable {
try (MockedStatic<HttpUtils> httpUtilsMockedStatic = mockStatic(HttpUtils.class)) {
// 准备参数
Long sendLogId = randomLongId();
String mobile = randomString();
String apiTemplateId = randomString() + " " + randomString();
List<KeyValue<String, Object>> templateParams = Lists.newArrayList(
new KeyValue<>("1", 1234), new KeyValue<>("2", "login"));
// mock 方法
httpUtilsMockedStatic.when(() -> HttpUtils.post(anyString(), anyMap(), anyString()))
.thenReturn("{\"result\":[{\"originTo\":\"+86155****5678\",\"createTime\":\"2018-05-25T16:34:34Z\",\"from\":\"1069********0012\",\"smsMsgId\":\"d6e3cdd0-522b-4692-8304-a07553cdf591_8539659\",\"status\":\"000000\",\"countryId\":\"CN\",\"total\":2}],\"code\":\"000000\",\"description\":\"Success\"}\n");
// 调用
SmsSendRespDTO result = smsClient.sendSms(sendLogId, mobile,
apiTemplateId, templateParams);
// 断言
assertTrue(result.getSuccess());
assertEquals("d6e3cdd0-522b-4692-8304-a07553cdf591_8539659", result.getSerialNo());
assertEquals("000000", result.getApiCode());
}
}
@Test
public void testDoSendSms_fail_01() throws Throwable {
try (MockedStatic<HttpUtils> httpUtilsMockedStatic = mockStatic(HttpUtils.class)) {
// 准备参数
Long sendLogId = randomLongId();
String mobile = randomString();
String apiTemplateId = randomString() + " " + randomString();
List<KeyValue<String, Object>> templateParams = Lists.newArrayList(
new KeyValue<>("1", 1234), new KeyValue<>("2", "login"));
// mock 方法
httpUtilsMockedStatic.when(() -> HttpUtils.post(anyString(), anyMap(), anyString()))
.thenReturn("{\"result\":[{\"total\":1,\"originTo\":\"17321315478\",\"createTime\":\"2024-08-18T11:32:20Z\",\"from\":\"x8824060312575\",\"smsMsgId\":\"06e4b966-ad87-479f-8b74-f57fb7aafb60_304613461\",\"countryId\":\"CN\",\"status\":\"E200033\"}],\"code\":\"E000510\",\"description\":\"The SMS fails to be sent. For details, see status.\"}");
// 调用
SmsSendRespDTO result = smsClient.sendSms(sendLogId, mobile,
apiTemplateId, templateParams);
// 断言
assertFalse(result.getSuccess());
assertEquals("06e4b966-ad87-479f-8b74-f57fb7aafb60_304613461", result.getSerialNo());
assertEquals("E200033", result.getApiCode());
}
}
@Test
public void testDoSendSms_fail_02() throws Throwable {
try (MockedStatic<HttpUtils> httpUtilsMockedStatic = mockStatic(HttpUtils.class)) {
// 准备参数
Long sendLogId = randomLongId();
String mobile = randomString();
String apiTemplateId = randomString() + " " + randomString();
List<KeyValue<String, Object>> templateParams = Lists.newArrayList(
new KeyValue<>("1", 1234), new KeyValue<>("2", "login"));
// mock 方法
httpUtilsMockedStatic.when(() -> HttpUtils.post(anyString(), anyMap(), anyString()))
.thenReturn("{\"code\":\"E000102\",\"description\":\"Invalid app_key.\"}");
// 调用
SmsSendRespDTO result = smsClient.sendSms(sendLogId, mobile,
apiTemplateId, templateParams);
// 断言
assertFalse(result.getSuccess());
assertEquals("E000102", result.getApiCode());
assertEquals("Invalid app_key.", result.getApiMsg());
}
}
@Test
public void testParseSmsReceiveStatus() {
// 准备参数
String text = "sequence=1&total=1&statusDesc=%E7%94%A8%E6%88%B7%E5%B7%B2%E6%88%90%E5%8A%9F%E6%94%B6%E5%88%B0%E7%9F%AD%E4%BF%A1&updateTime=2024-08-15T03%3A00%3A34Z&source=2&smsMsgId=70207ed7-1d02-41b0-8537-bb25fd1c2364_143684459&status=DELIVRD&extend=176";
// 调用
List<SmsReceiveRespDTO> statuses = smsClient.parseSmsReceiveStatus(text);
// 断言
assertEquals(1, statuses.size());
SmsReceiveRespDTO status = statuses.get(0);
assertTrue(status.getSuccess());
assertEquals("DELIVRD", status.getErrorCode());
assertEquals(LocalDateTime.of(2024, 8, 15, 3, 0, 34), status.getReceiveTime());
assertEquals("70207ed7-1d02-41b0-8537-bb25fd1c2364_143684459", status.getSerialNo());
}
}

View File

@ -0,0 +1,131 @@
package cn.iocoder.yudao.module.system.framework.sms.core.client.impl;
import cn.iocoder.yudao.framework.common.core.KeyValue;
import cn.iocoder.yudao.framework.common.util.http.HttpUtils;
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
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.collect.Lists;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.MockedStatic;
import java.time.LocalDateTime;
import java.util.List;
import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mockStatic;
/**
* {@link QiniuSmsClient} 的单元测试
*
* @author scholar
*/
public class QiniuSmsClientTest extends BaseMockitoUnitTest {
private final SmsChannelProperties properties = new SmsChannelProperties()
.setApiKey(randomString())// 随机一个 apiKey避免构建报错
.setApiSecret(randomString()) // 随机一个 apiSecret避免构建报错
.setSignature("芋道源码");
@InjectMocks
private QiniuSmsClient smsClient = new QiniuSmsClient(properties);
@Test
public void testDoSendSms_success() throws Throwable {
try (MockedStatic<HttpUtils> httpUtilsMockedStatic = mockStatic(HttpUtils.class)) {
// 准备参数
Long sendLogId = randomLongId();
String mobile = randomString();
String apiTemplateId = randomString() + " " + randomString();
List<KeyValue<String, Object>> templateParams = Lists.newArrayList(
new KeyValue<>("1", 1234), new KeyValue<>("2", "login"));
// mock 方法
httpUtilsMockedStatic.when(() -> HttpUtils.post(anyString(), anyMap(), anyString()))
.thenReturn("{\"message_id\":\"17245678901\"}");
// 调用
SmsSendRespDTO result = smsClient.sendSms(sendLogId, mobile,
apiTemplateId, templateParams);
// 断言
assertTrue(result.getSuccess());
assertEquals("17245678901", result.getSerialNo());
}
}
@Test
public void testDoSendSms_fail() throws Throwable {
try (MockedStatic<HttpUtils> httpUtilsMockedStatic = mockStatic(HttpUtils.class)) {
// 准备参数
Long sendLogId = randomLongId();
String mobile = randomString();
String apiTemplateId = randomString() + " " + randomString();
List<KeyValue<String, Object>> templateParams = Lists.newArrayList(
new KeyValue<>("1", 1234), new KeyValue<>("2", "login"));
// mock 方法
httpUtilsMockedStatic.when(() -> HttpUtils.post(anyString(), anyMap(), anyString()))
.thenReturn("{\"error\":\"BadToken\",\"message\":\"Your authorization token is invalid\",\"request_id\":\"etziWcJFo1C8Ne8X\"}");
// 调用
SmsSendRespDTO result = smsClient.sendSms(sendLogId, mobile,
apiTemplateId, templateParams);
// 断言
assertFalse(result.getSuccess());
assertEquals("BadToken", result.getApiCode());
assertEquals("Your authorization token is invalid", result.getApiMsg());
assertEquals("etziWcJFo1C8Ne8X", result.getApiRequestId());
}
}
@Test
public void testGetSmsTemplate() throws Throwable {
try (MockedStatic<HttpUtils> httpUtilsMockedStatic = mockStatic(HttpUtils.class)) {
// 准备参数
String apiTemplateId = randomString();
// mock 方法
httpUtilsMockedStatic.when(() -> HttpUtils.get(anyString(), anyMap()))
.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}");
// 调用
SmsTemplateRespDTO result = smsClient.getSmsTemplate(apiTemplateId);
// 断言
assertEquals("1826184073773596672", result.getId());
assertEquals("您的验证码为:${code}", result.getContent());
assertEquals(SmsTemplateAuditStatusEnum.SUCCESS.getStatus(), result.getAuditStatus());
assertEquals("", result.getAuditReason());
}
}
@Test
public void testParseSmsReceiveStatus() {
// 准备参数
String text = "{\"items\":[{\"mobile\":\"18881234567\",\"message_id\":\"10135515063508004167\",\"status\":\"DELIVRD\",\"delivrd_at\":1724591666,\"error\":\"DELIVRD\",\"seq\":\"123\"}]}";
// 调用
List<SmsReceiveRespDTO> statuses = smsClient.parseSmsReceiveStatus(text);
// 断言
assertEquals(1, statuses.size());
SmsReceiveRespDTO status = statuses.get(0);
assertTrue(status.getSuccess());
assertEquals("DELIVRD", status.getErrorMsg());
assertEquals(LocalDateTime.of(2024, 8, 25, 21, 14, 26), status.getReceiveTime());
assertEquals("18881234567", status.getMobile());
assertEquals("10135515063508004167", status.getSerialNo());
assertEquals(123, status.getLogId());
}
@Test
public void testConvertSmsTemplateAuditStatus() {
assertEquals(SmsTemplateAuditStatusEnum.SUCCESS.getStatus(),
smsClient.convertSmsTemplateAuditStatus("passed"));
assertEquals(SmsTemplateAuditStatusEnum.CHECKING.getStatus(),
smsClient.convertSmsTemplateAuditStatus("reviewing"));
assertEquals(SmsTemplateAuditStatusEnum.FAIL.getStatus(),
smsClient.convertSmsTemplateAuditStatus("rejected"));
assertThrows(IllegalArgumentException.class, () -> smsClient.convertSmsTemplateAuditStatus("unknown"),
"未知审核状态(3)");
}
}

View File

@ -2,7 +2,7 @@ package cn.iocoder.yudao.module.system.framework.sms.core.client.impl;
import cn.hutool.core.collection.ListUtil;
import cn.iocoder.yudao.framework.common.core.KeyValue;
import cn.iocoder.yudao.module.system.framework.sms.core.client.dto.SmsReceiveRespDTO;
import cn.iocoder.yudao.module.system.framework.sms.core.client.SmsClient;
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.property.SmsChannelProperties;
@ -12,7 +12,7 @@ import org.junit.jupiter.api.Test;
import java.util.List;
/**
* 各种 {@link SmsClientTests 集成测试
* 各种 {@link SmsClient} 的集成测试
*
* @author 芋道源码
*/
@ -24,8 +24,8 @@ public class SmsClientTests {
@Disabled
public void testAliyunSmsClient_getSmsTemplate() throws Throwable {
SmsChannelProperties properties = new SmsChannelProperties()
.setApiKey("LTAI5tAicJAxaSFiZuGGeXHR")
.setApiSecret("Fdr9vadxnDvS6GJU0W1tijQ0VmLhYz");
.setApiKey(System.getenv("SMS_ALIYUN_ACCESS_KEY"))
.setApiSecret(System.getenv("SMS_ALIYUN_SECRET_KEY"));
AliyunSmsClient client = new AliyunSmsClient(properties);
// 准备参数
String apiTemplateId = "SMS_207945135";
@ -39,9 +39,9 @@ public class SmsClientTests {
@Disabled
public void testAliyunSmsClient_sendSms() throws Throwable {
SmsChannelProperties properties = new SmsChannelProperties()
.setApiKey("LTAI5tAicJAxaSFiZuGGeXHR")
.setApiSecret("Fdr9vadxnDvS6GJU0W1tijQ0VmLhYz")
.setSignature("runpu");
.setApiKey(System.getenv("SMS_ALIYUN_ACCESS_KEY"))
.setApiSecret(System.getenv("SMS_ALIYUN_SECRET_KEY"))
.setSignature("Ballcat");
AliyunSmsClient client = new AliyunSmsClient(properties);
// 准备参数
Long sendLogId = System.currentTimeMillis();
@ -53,49 +53,21 @@ public class SmsClientTests {
System.out.println(sendRespDTO);
}
@Test
@Disabled
public void testAliyunSmsClient_parseSmsReceiveStatus() {
SmsChannelProperties properties = new SmsChannelProperties()
.setApiKey("LTAI5tAicJAxaSFiZuGGeXHR")
.setApiSecret("Fdr9vadxnDvS6GJU0W1tijQ0VmLhYz");
AliyunSmsClient client = new AliyunSmsClient(properties);
// 准备参数
String text = "[\n" +
" {\n" +
" \"phone_number\" : \"13900000001\",\n" +
" \"send_time\" : \"2017-01-01 11:12:13\",\n" +
" \"report_time\" : \"2017-02-02 22:23:24\",\n" +
" \"success\" : true,\n" +
" \"err_code\" : \"DELIVERED\",\n" +
" \"err_msg\" : \"用户接收成功\",\n" +
" \"sms_size\" : \"1\",\n" +
" \"biz_id\" : \"12345\",\n" +
" \"out_id\" : \"67890\"\n" +
" }\n" +
"]";
// mock 方法
// 调用
List<SmsReceiveRespDTO> statuses = client.parseSmsReceiveStatus(text);
// 打印结果
System.out.println(statuses);
}
// ========== 腾讯云 ==========
@Test
@Disabled
public void testTencentSmsClient_sendSms() throws Throwable {
String sdkAppId = "1400500458";
SmsChannelProperties properties = new SmsChannelProperties()
.setApiKey("LTAI5tAicJAxaSFiZuGGeXHR 1428926523")
.setApiSecret("Fdr9vadxnDvS6GJU0W1tijQ0VmLhYz")
.setApiKey(System.getenv("SMS_TENCENT_ACCESS_KEY") + " " + sdkAppId)
.setApiSecret(System.getenv("SMS_TENCENT_SECRET_KEY"))
.setSignature("芋道源码");
TencentSmsClient client = new TencentSmsClient(properties);
// 准备参数
Long sendLogId = System.currentTimeMillis();
String mobile = "15601691323";
String apiTemplateId = "2136358";
String apiTemplateId = "358212";
// 调用
SmsSendRespDTO sendRespDTO = client.sendSms(sendLogId, mobile, apiTemplateId, ListUtil.of(new KeyValue<>("code", "1024")));
// 打印结果
@ -105,13 +77,14 @@ public class SmsClientTests {
@Test
@Disabled
public void testTencentSmsClient_getSmsTemplate() throws Throwable {
String sdkAppId = "1400500458";
SmsChannelProperties properties = new SmsChannelProperties()
.setApiKey("LTAI5tAicJAxaSFiZuGGeXHR 1428926523")
.setApiSecret("Fdr9vadxnDvS6GJU0W1tijQ0VmLhYz")
.setApiKey(System.getenv("SMS_TENCENT_ACCESS_KEY") + " " + sdkAppId)
.setApiSecret(System.getenv("SMS_TENCENT_SECRET_KEY"))
.setSignature("芋道源码");
TencentSmsClient client = new TencentSmsClient(properties);
// 准备参数
String apiTemplateId = "2136358";
String apiTemplateId = "358212";
// 调用
SmsTemplateRespDTO template = client.getSmsTemplate(apiTemplateId);
// 打印结果
@ -123,15 +96,16 @@ public class SmsClientTests {
@Test
@Disabled
public void testHuaweiSmsClient_sendSms() throws Throwable {
String sender = "x8824060312575";
SmsChannelProperties properties = new SmsChannelProperties()
.setApiKey("123")
.setApiSecret("456")
.setApiKey(System.getenv("SMS_HUAWEI_ACCESS_KEY") + " " + sender)
.setApiSecret(System.getenv("SMS_HUAWEI_SECRET_KEY"))
.setSignature("runpu");
HuaweiSmsClient client = new HuaweiSmsClient(properties);
// 准备参数
Long sendLogId = System.currentTimeMillis();
String mobile = "15601691323";
String apiTemplateId = "xx test01";
String mobile = "17321315478";
String apiTemplateId = "3644cdab863546a3b718d488659a99ef";
List<KeyValue<String, Object>> templateParams = ListUtil.of(new KeyValue<>("code", "1024"));
// 调用
SmsSendRespDTO smsSendRespDTO = client.sendSms(sendLogId, mobile, apiTemplateId, templateParams);
@ -139,5 +113,39 @@ public class SmsClientTests {
System.out.println(smsSendRespDTO);
}
// ========== 七牛云 ==========
@Test
@Disabled
public void testQiniuSmsClient_sendSms() throws Throwable {
SmsChannelProperties properties = new SmsChannelProperties()
.setApiKey("SMS_QINIU_ACCESS_KEY")
.setApiSecret("SMS_QINIU_SECRET_KEY");
QiniuSmsClient client = new QiniuSmsClient(properties);
// 准备参数
Long sendLogId = System.currentTimeMillis();
String mobile = "17321315478";
String apiTemplateId = "3644cdab863546a3b718d488659a99ef";
List<KeyValue<String, Object>> templateParams = ListUtil.of(new KeyValue<>("code", "1122"));
// 调用
SmsSendRespDTO smsSendRespDTO = client.sendSms(sendLogId, mobile, apiTemplateId, templateParams);
// 打印结果
System.out.println(smsSendRespDTO);
}
@Test
@Disabled
public void testQiniuSmsClient_getSmsTemplate() throws Throwable {
SmsChannelProperties properties = new SmsChannelProperties()
.setApiKey("SMS_QINIU_ACCESS_KEY")
.setApiSecret("SMS_QINIU_SECRET_KEY");
QiniuSmsClient client = new QiniuSmsClient(properties);
// 准备参数
String apiTemplateId = "3644cdab863546a3b718d488659a99ef";
// 调用
SmsTemplateRespDTO template = client.getSmsTemplate(apiTemplateId);
// 打印结果
System.out.println(template);
}
}

View File

@ -78,7 +78,7 @@ public class TencentSmsClientTest extends BaseMockitoUnitTest {
}
@Test
public void testDoSendSms_fail() throws Throwable {
public void testDoSendSms_fail_01() throws Throwable {
try (MockedStatic<HttpUtils> httpUtilsMockedStatic = mockStatic(HttpUtils.class)) {
// 准备参数
Long sendLogId = randomLongId();
@ -117,6 +117,31 @@ public class TencentSmsClientTest extends BaseMockitoUnitTest {
}
}
@Test
public void testDoSendSms_fail_02() throws Throwable {
try (MockedStatic<HttpUtils> httpUtilsMockedStatic = mockStatic(HttpUtils.class)) {
// 准备参数
Long sendLogId = randomLongId();
String mobile = randomString();
String apiTemplateId = randomString();
List<KeyValue<String, Object>> templateParams = Lists.newArrayList(
new KeyValue<>("1", 1234), new KeyValue<>("2", "login"));
// mock 方法
httpUtilsMockedStatic.when(() -> HttpUtils.post(anyString(), anyMap(), anyString()))
.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\"}}");
// 调用
SmsSendRespDTO result = smsClient.sendSms(sendLogId, mobile,
apiTemplateId, templateParams);
// 断言
assertFalse(result.getSuccess());
assertEquals("2a88f82a-261c-4ac6-9fa9-c7d01aaa486a", result.getApiRequestId());
assertEquals("AuthFailure.SecretIdNotFound", result.getApiCode());
assertEquals("The SecretId is not found, please ensure that your SecretId is correct.", result.getApiMsg());
}
}
@Test
public void testParseSmsReceiveStatus() {
// 准备参数

View File

@ -57,9 +57,6 @@ public class SmsChannelServiceTest extends BaseDbUnitTest {
// 校验记录的属性是否正确
SmsChannelDO smsChannel = smsChannelMapper.selectById(smsChannelId);
assertPojoEquals(reqVO, smsChannel, "id");
// 断言 cache
assertNull(smsChannelService.getIdClientCache().getIfPresent(smsChannel.getId()));
assertNull(smsChannelService.getCodeClientCache().getIfPresent(smsChannel.getCode()));
}
@Test
@ -79,9 +76,6 @@ public class SmsChannelServiceTest extends BaseDbUnitTest {
// 校验是否更新正确
SmsChannelDO smsChannel = smsChannelMapper.selectById(reqVO.getId()); // 获取最新的
assertPojoEquals(reqVO, smsChannel);
// 断言 cache
assertNull(smsChannelService.getIdClientCache().getIfPresent(smsChannel.getId()));
assertNull(smsChannelService.getCodeClientCache().getIfPresent(smsChannel.getCode()));
}
@Test
@ -105,9 +99,6 @@ public class SmsChannelServiceTest extends BaseDbUnitTest {
smsChannelService.deleteSmsChannel(id);
// 校验数据不存在了
assertNull(smsChannelMapper.selectById(id));
// 断言 cache
assertNull(smsChannelService.getIdClientCache().getIfPresent(dbSmsChannel.getId()));
assertNull(smsChannelService.getCodeClientCache().getIfPresent(dbSmsChannel.getCode()));
}
@Test
@ -196,29 +187,23 @@ public class SmsChannelServiceTest extends BaseDbUnitTest {
// mock 数据
SmsChannelDO channel = randomPojo(SmsChannelDO.class);
smsChannelMapper.insert(channel);
// mock 参数
// 准备参数
Long id = channel.getId();
// mock 方法
SmsClient mockClient = mock(SmsClient.class);
when(smsClientFactory.getSmsClient(eq(id))).thenReturn(mockClient);
SmsChannelProperties properties = BeanUtils.toBean(channel, SmsChannelProperties.class);
when(smsClientFactory.createOrUpdateSmsClient(eq(properties))).thenReturn(mockClient);
// 调用
SmsClient client = smsChannelService.getSmsClient(id);
// 断言
assertSame(client, mockClient);
verify(smsClientFactory).createOrUpdateSmsClient(argThat(arg -> {
SmsChannelProperties properties = BeanUtils.toBean(channel, SmsChannelProperties.class);
return properties.equals(arg);
}));
}
@Test
public void testGetSmsClient_code() {
// mock 数据
SmsChannelDO channel = randomPojo(SmsChannelDO.class);
smsChannelMapper.insert(channel);
// mock 参数
String code = channel.getCode();
// 准备参数
String code = randomString();
// mock 方法
SmsClient mockClient = mock(SmsClient.class);
when(smsClientFactory.getSmsClient(eq(code))).thenReturn(mockClient);
@ -227,10 +212,6 @@ public class SmsChannelServiceTest extends BaseDbUnitTest {
SmsClient client = smsChannelService.getSmsClient(code);
// 断言
assertSame(client, mockClient);
verify(smsClientFactory).createOrUpdateSmsClient(argThat(arg -> {
SmsChannelProperties properties = BeanUtils.toBean(channel, SmsChannelProperties.class);
return properties.equals(arg);
}));
}
}