接入腾讯云短信

This commit is contained in:
FinallySays 2022-03-27 12:08:56 +08:00
parent e923bc661d
commit 6fd52dfbb6
15 changed files with 9120 additions and 8229 deletions

View File

@ -25,7 +25,7 @@
<url>https://github.com/YunaiV/ruoyi-vue-pro</url>
<properties>
<revision>1.6.1-snapshot</revision>
<revision>1.6.2-snapshot</revision>
<!-- Maven 相关 -->
<java.version>1.8</java.version>
<maven.compiler.source>${java.version}</maven.compiler.source>

View File

@ -2615,6 +2615,7 @@ INSERT INTO `system_dict_data` VALUES (1151, 10, '本地磁盘', '10', 'infra_fi
INSERT INTO `system_dict_data` VALUES (1152, 11, 'FTP 服务器', '11', 'infra_file_storage', 0, 'default', '', NULL, '1', '2022-03-15 00:26:06', '1', '2022-03-15 00:26:10', b'0');
INSERT INTO `system_dict_data` VALUES (1153, 12, 'SFTP 服务器', '12', 'infra_file_storage', 0, 'default', '', NULL, '1', '2022-03-15 00:26:22', '1', '2022-03-15 00:26:22', b'0');
INSERT INTO `system_dict_data` VALUES (1154, 20, 'S3 对象存储', '20', 'infra_file_storage', 0, 'default', '', NULL, '1', '2022-03-15 00:26:31', '1', '2022-03-15 00:26:45', b'0');
INSERT INTO `system_dict_data` VALUES (1155, 3, '腾讯云', 'TENCENT', 'system_sms_channel_code', 0, 'warning', '', NULL, '1', '2021-04-05 01:05:26', '1', '2022-02-16 10:09:52', b'0');
COMMIT;
-- ----------------------------

View File

@ -14,7 +14,7 @@
<url>https://github.com/YunaiV/ruoyi-vue-pro</url>
<properties>
<revision>1.6.1-snapshot</revision>
<revision>1.6.2-snapshot</revision>
<!-- 统一依赖管理 -->
<spring.boot.version>2.5.10</spring.boot.version>
<!-- Web 相关 -->
@ -60,6 +60,7 @@
<minio.version>8.2.2</minio.version>
<aliyun-java-sdk-core.version>4.5.25</aliyun-java-sdk-core.version>
<aliyun-java-sdk-dysmsapi.version>2.1.0</aliyun-java-sdk-dysmsapi.version>
<tencentcloud-sdk-java.version>3.1.471</tencentcloud-sdk-java.version>
<yunpian-java-sdk.version>1.2.7</yunpian-java-sdk.version>
<justauth.version>1.4.0</justauth.version>
</properties>
@ -552,6 +553,11 @@
<artifactId>aliyun-java-sdk-dysmsapi</artifactId>
<version>${aliyun-java-sdk-dysmsapi.version}</version>
</dependency>
<dependency>
<groupId>com.tencentcloudapi</groupId>
<artifactId>tencentcloud-sdk-java</artifactId>
<version>${tencentcloud-sdk-java.version}</version>
</dependency>
<!-- SMS SDK end -->
<dependency>

View File

@ -2,8 +2,12 @@ package cn.iocoder.yudao.framework.common.util.collection;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.collection.CollectionUtil;
import com.fasterxml.jackson.core.type.TypeReference;
import com.google.common.collect.ImmutableMap;
import java.lang.reflect.Array;
import java.lang.reflect.GenericArrayType;
import java.lang.reflect.Type;
import java.util.*;
import java.util.function.BinaryOperator;
import java.util.function.Function;
@ -169,4 +173,21 @@ public class CollectionUtils {
public static <T> Collection<T> singleton(T deptId) {
return deptId == null ? Collections.emptyList() : Collections.singleton(deptId);
}
public static <T, V> V[] toArray(List<T> from, Function<T, V> mapper) {
return toArray(convertList(from, mapper));
}
@SuppressWarnings("unchecked")
public static <T> T[] toArray(List<T> from) {
if (CollectionUtil.isEmpty(from)) {
return (T[]) (new Object[0]);
}
Class<T> clazz = (Class<T>) from.get(0).getClass();
T[] result = (T[]) Array.newInstance(clazz, from.size());
for (int i = 0; i < from.size(); i++) {
result[i] = from.get(i);
}
return result;
}
}

View File

@ -12,7 +12,7 @@
<packaging>jar</packaging>
<name>${project.artifactId}</name>
<description>短信拓展,支持阿里云、云片</description>
<description>短信拓展,支持阿里云、云片、腾讯云</description>
<url>https://github.com/YunaiV/ruoyi-vue-pro</url>
<dependencies>
@ -77,6 +77,10 @@
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-dysmsapi</artifactId>
</dependency>
<dependency>
<groupId>com.tencentcloudapi</groupId>
<artifactId>tencentcloud-sdk-java</artifactId>
</dependency>
<!-- SMS SDK end -->
</dependencies>

View File

@ -4,6 +4,7 @@ import cn.iocoder.yudao.framework.sms.core.client.SmsClient;
import cn.iocoder.yudao.framework.sms.core.client.SmsClientFactory;
import cn.iocoder.yudao.framework.sms.core.client.impl.aliyun.AliyunSmsClient;
import cn.iocoder.yudao.framework.sms.core.client.impl.debug.DebugDingTalkSmsClient;
import cn.iocoder.yudao.framework.sms.core.client.impl.tencent.TencentSmsClient;
import cn.iocoder.yudao.framework.sms.core.client.impl.yunpian.YunpianSmsClient;
import cn.iocoder.yudao.framework.sms.core.enums.SmsChannelEnum;
import cn.iocoder.yudao.framework.sms.core.property.SmsChannelProperties;
@ -44,7 +45,7 @@ public class SmsClientFactoryImpl implements SmsClientFactory {
Arrays.stream(SmsChannelEnum.values()).forEach(channel -> {
// 创建一个空的 SmsChannelProperties 对象
SmsChannelProperties properties = new SmsChannelProperties().setCode(channel.getCode())
.setApiKey("default").setApiSecret("default");
.setApiKey("default default").setApiSecret("default");
// 创建 Sms 客户端
AbstractSmsClient smsClient = createSmsClient(properties);
channelCodeClients.put(channel.getCode(), smsClient);
@ -81,6 +82,7 @@ public class SmsClientFactoryImpl implements SmsClientFactory {
case ALIYUN: return new AliyunSmsClient(properties);
case YUN_PIAN: return new YunpianSmsClient(properties);
case DEBUG_DING_TALK: return new DebugDingTalkSmsClient(properties);
case TENCENT: return new TencentSmsClient(properties);
}
// 创建失败错误日志 + 抛出异常
log.error("[createSmsClient][配置({}) 找不到合适的客户端实现]", properties);

View File

@ -0,0 +1,294 @@
package cn.iocoder.yudao.framework.sms.core.client.impl.tencent;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.lang.Assert;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.core.KeyValue;
import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import cn.iocoder.yudao.framework.sms.core.client.SmsCommonResult;
import cn.iocoder.yudao.framework.sms.core.client.dto.SmsReceiveRespDTO;
import cn.iocoder.yudao.framework.sms.core.client.dto.SmsSendRespDTO;
import cn.iocoder.yudao.framework.sms.core.client.dto.SmsTemplateRespDTO;
import cn.iocoder.yudao.framework.sms.core.client.impl.AbstractSmsClient;
import cn.iocoder.yudao.framework.sms.core.enums.SmsTemplateAuditStatusEnum;
import cn.iocoder.yudao.framework.sms.core.property.SmsChannelProperties;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.google.common.annotations.VisibleForTesting;
import com.tencentcloudapi.common.Credential;
import com.tencentcloudapi.common.exception.TencentCloudSDKException;
import com.tencentcloudapi.sms.v20210111.SmsClient;
import com.tencentcloudapi.sms.v20210111.models.*;
import lombok.Data;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Optional;
import java.util.function.Function;
import java.util.function.Supplier;
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;
/**
* 腾讯云短信功能实现
* <p>
* 参见 https://cloud.tencent.com/document/product/382/52077
*
* @author : shiwp
*/
public class TencentSmsClient extends AbstractSmsClient {
private SmsClient client;
public TencentSmsClient(SmsChannelProperties properties) {
// 腾讯云发放短信的时候需要额外的参数sdkAppId 所以和secretId组合在一起放到apiKey字段中,格式为[secretId sdkAppId]
// 这边需要做拆分重新封装到properties内
super(TencentSmsChannelProperties.build(properties), new TencentSmsCodeMapping());
Assert.notEmpty(properties.getApiSecret(), "apiSecret 不能为空");
}
@Override
protected void doInit() {
// init或者refresh时需要重新封装properties
final SmsChannelProperties p = properties;
properties = TencentSmsChannelProperties.build(p);
// 实例化一个认证对象入参需要传入腾讯云账户密钥对secretIdsecretKey
Credential credential = new Credential(properties.getApiKey(), properties.getApiSecret());
client = new SmsClient(credential, "ap-nanjing");
}
@Override
protected SmsCommonResult<SmsSendRespDTO> doSendSms(Long sendLogId,
String mobile,
String apiTemplateId,
List<KeyValue<String, Object>> templateParams) throws Throwable {
return invoke(() -> buildSendSmsRequest(sendLogId, mobile, apiTemplateId, templateParams),
this::doSendSms0,
response -> {
SendStatus sendStatus = response.getSendStatusSet()[0];
return SmsCommonResult.build(sendStatus.getCode(), sendStatus.getMessage(), response.getRequestId(),
new SmsSendRespDTO().setSerialNo(sendStatus.getSerialNo()), codeMapping);
});
}
/**
* 调用腾讯云SDK发送短信
*
* @param request 发送短信请求
* @return 发送短信响应
* @throws TencentCloudSDKException SDK用来封装发送短信失败
*/
private SendSmsResponse doSendSms0(SendSmsRequest request) throws TencentCloudSDKException {
return client.SendSms(request);
}
/**
* 封装腾讯云发送短信请求
*
* @param sendLogId 日志编号
* @param mobile 手机号
* @param apiTemplateId 短信 API 的模板编号
* @param templateParams 短信模板参数通过 List 数组保证参数的顺序
* @return 腾讯云发送短信请求
*/
private SendSmsRequest buildSendSmsRequest(Long sendLogId,
String mobile,
String apiTemplateId,
List<KeyValue<String, Object>> templateParams) {
SendSmsRequest request = new SendSmsRequest();
request.setSmsSdkAppId(((TencentSmsChannelProperties) properties).getSdkAppId());
request.setPhoneNumberSet(CollectionUtils.toArray(Collections.singletonList(mobile)));
request.setSignName(properties.getSignature());
request.setTemplateId(apiTemplateId);
request.setTemplateParamSet(CollectionUtils.toArray(templateParams, e -> String.valueOf(e.getValue())));
request.setSessionContext(JsonUtils.toJsonString(new SessionContext().setLogId(sendLogId)));
return request;
}
@Override
protected List<SmsReceiveRespDTO> doParseSmsReceiveStatus(String text) throws Throwable {
List<SmsReceiveStatus> callback = JsonUtils.parseArray(text, SmsReceiveStatus.class);
return CollectionUtils.convertList(callback, status -> {
SmsReceiveRespDTO data = new SmsReceiveRespDTO();
data.setErrorCode(status.getErrCode()).setErrorMsg(status.getDescription());
data.setReceiveTime(status.getReceiveTime()).setSuccess("SUCCESS".equalsIgnoreCase(status.getStatus()));
data.setMobile(status.getMobile()).setSerialNo(status.getSerialNo());
Optional.ofNullable(status.getSessionContext()).map(SessionContext::getLogId)
.ifPresentOrElse(data::setLogId, () -> {
throw new IllegalStateException(StrUtil.format("未回传logId需联系腾讯云解决。"));
});
return data;
});
}
@Override
protected SmsCommonResult<SmsTemplateRespDTO> doGetSmsTemplate(String apiTemplateId) throws Throwable {
return invoke(() -> this.buildSmsTemplateStatusRequest(apiTemplateId),
this::doGetSmsTemplate0,
response -> {
SmsTemplateRespDTO data = convertTemplateStatusDTO(response.getDescribeTemplateStatusSet()[0]);
return SmsCommonResult.build("Ok", null, response.getRequestId(), data, codeMapping);
});
}
@VisibleForTesting
SmsTemplateRespDTO convertTemplateStatusDTO(DescribeTemplateListStatus templateStatus) {
if (templateStatus == null) {
return null;
}
SmsTemplateAuditStatusEnum auditStatus;
Assert.notNull(templateStatus.getStatusCode(),
StrUtil.format("短信模版审核状态为null模版id{}", templateStatus.getTemplateId()));
switch (templateStatus.getStatusCode().intValue()) {
case -1:
auditStatus = SmsTemplateAuditStatusEnum.FAIL;
break;
case 0:
auditStatus = SmsTemplateAuditStatusEnum.SUCCESS;
break;
case 1:
auditStatus = SmsTemplateAuditStatusEnum.CHECKING;
break;
default:
throw new IllegalStateException(StrUtil.format("不能解析短信模版审核状态{}模版id{}",
templateStatus.getStatusCode(), templateStatus.getTemplateId()));
}
SmsTemplateRespDTO data = new SmsTemplateRespDTO();
data.setId(String.valueOf(templateStatus.getTemplateId())).setContent(templateStatus.getTemplateContent());
data.setAuditStatus(auditStatus.getStatus()).setAuditReason(templateStatus.getReviewReply());
return data;
}
/**
* 封装查询模版审核状态请求
* @param apiTemplateId api的模版id
* @return 查询模版审核状态请求
*/
private DescribeSmsTemplateListRequest buildSmsTemplateStatusRequest(String apiTemplateId) {
DescribeSmsTemplateListRequest request = new DescribeSmsTemplateListRequest();
request.setTemplateIdSet(CollectionUtils.toArray(Collections.singletonList(apiTemplateId), Long::parseLong));
// 地区
request.setInternational(0L);
return request;
}
/**
* 调用腾讯云SDK查询短信模版状态
*
* @param request 查询短信模版状态请求
* @return 查询短信模版状态响应
* @throws TencentCloudSDKException SDK用来封装查询短信模版状态失败
*/
private DescribeSmsTemplateListResponse doGetSmsTemplate0(DescribeSmsTemplateListRequest request) throws TencentCloudSDKException {
return client.DescribeSmsTemplateList(request);
}
<Q, P, R> SmsCommonResult<R> invoke(Supplier<Q> requestSupplier,
SdkFunction<Q, P> responseSupplier,
Function<P, SmsCommonResult<R>> resultGen) {
// 构建请求body
Q request = requestSupplier.get();
P response;
// 调用腾讯云发送短信
try {
response = responseSupplier.apply(request);
} catch (TencentCloudSDKException e) {
// 调用异常封装结果
return SmsCommonResult.build(e.getErrorCode(), e.getMessage(), e.getRequestId(), null, codeMapping);
}
return resultGen.apply(response);
}
@Data
private static class SmsReceiveStatus {
/**
* 用户实际接收到短信的时间
*/
@JsonProperty("user_receive_time")
@JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND, timezone = TIME_ZONE_DEFAULT)
private Date receiveTime;
/**
* 国家或地区
*/
@JsonProperty("nationcode")
private String nationCode;
/**
* 手机号码
*/
private String mobile;
/**
* 实际是否收到短信接收状态SUCCESS成功FAIL失败
*/
@JsonProperty("report_status")
private String status;
/**
* 用户接收短信状态码错误信息
*/
@JsonProperty("errmsg")
private String errCode;
/**
* 用户接收短信状态描述
*/
@JsonProperty("description")
private String description;
/**
* 本次发送标识 ID与发送接口返回的SerialNo对应
*/
@JsonProperty("sid")
private String serialNo;
/**
* 用户的 session 内容与发送接口的请求参数SessionContext一致
*/
@JsonProperty("ext")
private SessionContext sessionContext;
}
@VisibleForTesting
@Data
static class SessionContext {
/**
* 发送短信记录id
*/
private Long logId;
}
private interface SdkFunction<T, R> {
R apply(T t) throws TencentCloudSDKException;
}
@Data
private static class TencentSmsChannelProperties extends SmsChannelProperties {
private String sdkAppId;
public static TencentSmsChannelProperties build(SmsChannelProperties properties) {
if (properties instanceof TencentSmsChannelProperties) {
return (TencentSmsChannelProperties) properties;
}
TencentSmsChannelProperties result = BeanUtil.toBean(properties, TencentSmsChannelProperties.class);
String combKey = properties.getApiKey();
Assert.notEmpty(combKey, "apiKey 不能为空");
String[] keys = combKey.trim().split(" ");
Assert.isTrue(keys.length == 2 && StrUtil.isNotBlank(keys[0]) && StrUtil.isNotBlank(keys[1]),
"腾讯云短信api配置格式错误请配置为[secretId sdkAppId]");
result.setSdkAppId(keys[1]).setApiKey(keys[0]);
return result;
}
}
}

View File

@ -0,0 +1,50 @@
package cn.iocoder.yudao.framework.sms.core.client.impl.tencent;
import cn.iocoder.yudao.framework.common.exception.ErrorCode;
import cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants;
import cn.iocoder.yudao.framework.sms.core.client.SmsCodeMapping;
import cn.iocoder.yudao.framework.sms.core.enums.SmsFrameworkErrorCodeConstants;
import static cn.iocoder.yudao.framework.sms.core.enums.SmsFrameworkErrorCodeConstants.*;
/**
* 腾讯云的 SmsCodeMapping 实现类
*
* 参见 https://cloud.tencent.com/document/api/382/52075#.E5.85.AC.E5.85.B1.E9.94.99.E8.AF.AF.E7.A0.81
*
* @author : shiwp
*/
public class TencentSmsCodeMapping implements SmsCodeMapping {
@Override
public ErrorCode apply(String apiCode) {
switch (apiCode) {
case "Ok": return GlobalErrorCodeConstants.SUCCESS;
case "FailedOperation.ContainSensitiveWord": return SMS_SEND_CONTENT_INVALID;
case "FailedOperation.JsonParseFail":
case "MissingParameter.EmptyPhoneNumberSet":
case "LimitExceeded.PhoneNumberCountLimit":
case "FailedOperation.FailResolvePacket": return GlobalErrorCodeConstants.BAD_REQUEST;
case "FailedOperation.InsufficientBalanceInSmsPackage": return SMS_ACCOUNT_MONEY_NOT_ENOUGH;
case "FailedOperation.MarketingSendTimeConstraint": return SMS_SEND_MARKET_LIMIT_CONTROL;
case "FailedOperation.PhoneNumberInBlacklist": return SMS_MOBILE_BLACK;
case "FailedOperation.SignatureIncorrectOrUnapproved": return SMS_SIGN_INVALID;
case "FailedOperation.MissingTemplateToModify":
case "FailedOperation.TemplateIncorrectOrUnapproved": return SMS_TEMPLATE_INVALID;
case "InvalidParameterValue.IncorrectPhoneNumber": return SMS_MOBILE_INVALID;
case "InvalidParameterValue.SdkAppIdNotExist": return SMS_APP_ID_INVALID;
case "InvalidParameterValue.TemplateParameterLengthLimit":
case "InvalidParameterValue.TemplateParameterFormatError": return SMS_TEMPLATE_PARAM_ERROR;
case "LimitExceeded.PhoneNumberDailyLimit": return SMS_SEND_DAY_LIMIT_CONTROL;
case "LimitExceeded.PhoneNumberThirtySecondLimit":
case "LimitExceeded.PhoneNumberOneHourLimit": return SMS_SEND_BUSINESS_LIMIT_CONTROL;
case "UnauthorizedOperation.RequestPermissionDeny":
case "FailedOperation.ForbidAddMarketingTemplates":
case "FailedOperation.NotEnterpriseCertification":
case "UnauthorizedOperation.IndividualUserMarketingSmsPermissionDeny": return SMS_PERMISSION_DENY;
case "UnauthorizedOperation.RequestIpNotInWhitelist": return SMS_IP_DENY;
case "AuthFailure.SecretIdNotFound": return SMS_ACCOUNT_INVALID;
}
return SmsFrameworkErrorCodeConstants.SMS_UNKNOWN;
}
}

View File

@ -17,7 +17,7 @@ public enum SmsChannelEnum {
DEBUG_DING_TALK("DEBUG_DING_TALK", "调试(钉钉)"),
YUN_PIAN("YUN_PIAN", "云片"),
ALIYUN("ALIYUN", "阿里云"),
// TENCENT("TENCENT", "腾讯云"),
TENCENT("TENCENT", "腾讯云"),
// HUA_WEI("HUA_WEI", "华为云"),
;

View File

@ -26,6 +26,9 @@ public interface SmsFrameworkErrorCodeConstants {
ErrorCode SMS_SEND_CONTENT_INVALID = new ErrorCode(2001000104, "短信内容有敏感词");
// 腾讯云为避免骚扰用户营销短信只允许在8点到22点发送
ErrorCode SMS_SEND_MARKET_LIMIT_CONTROL = new ErrorCode(2001000105, "营销短信发送时间限制");
// ========== 模板相关 2001000200 ==========
ErrorCode SMS_TEMPLATE_INVALID = new ErrorCode(2001000200, "短信模板不合法"); // 包括短信模板不存在
ErrorCode SMS_TEMPLATE_PARAM_ERROR = new ErrorCode(2001000201, "模板参数不正确");
@ -41,6 +44,7 @@ public interface SmsFrameworkErrorCodeConstants {
ErrorCode SMS_API_PARAM_ERROR = new ErrorCode(2001000900, "请求参数缺失");
ErrorCode SMS_MOBILE_INVALID = new ErrorCode(2001000901, "手机格式不正确");
ErrorCode SMS_MOBILE_BLACK = new ErrorCode(2001000902, "手机号在黑名单中");
ErrorCode SMS_APP_ID_INVALID = new ErrorCode(2001000903, "SdkAppId不合法");
ErrorCode EXCEPTION = new ErrorCode(2001000999, "调用异常");

View File

@ -0,0 +1,196 @@
package cn.iocoder.yudao.framework.sms.core.client.impl.tencent;
import cn.hutool.core.util.ReflectUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.core.KeyValue;
import cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants;
import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
import cn.iocoder.yudao.framework.common.util.collection.MapUtils;
import cn.iocoder.yudao.framework.common.util.date.DateUtils;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import cn.iocoder.yudao.framework.sms.core.client.SmsCommonResult;
import cn.iocoder.yudao.framework.sms.core.client.dto.SmsReceiveRespDTO;
import cn.iocoder.yudao.framework.sms.core.client.dto.SmsSendRespDTO;
import cn.iocoder.yudao.framework.sms.core.client.dto.SmsTemplateRespDTO;
import cn.iocoder.yudao.framework.sms.core.enums.SmsTemplateAuditStatusEnum;
import cn.iocoder.yudao.framework.sms.core.property.SmsChannelProperties;
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
import com.google.common.collect.Lists;
import com.tencentcloudapi.sms.v20210111.SmsClient;
import com.tencentcloudapi.sms.v20210111.models.DescribeSmsTemplateListResponse;
import com.tencentcloudapi.sms.v20210111.models.DescribeTemplateListStatus;
import com.tencentcloudapi.sms.v20210111.models.SendSmsResponse;
import com.tencentcloudapi.sms.v20210111.models.SendStatus;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import java.util.ArrayList;
import java.util.List;
import static cn.iocoder.yudao.framework.common.util.json.JsonUtils.toJsonString;
import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.when;
/**
* {@link TencentSmsClient} 的单元测试
*
* @author : shiwp
*/
public class TencentSmsClientTest extends BaseMockitoUnitTest {
private final SmsChannelProperties properties = new SmsChannelProperties()
.setApiKey(randomString() + " " + randomString()) // 随机一个 apiKey避免构建报错
.setApiSecret(randomString()) // 随机一个 apiSecret避免构建报错
.setSignature("芋道源码");
@InjectMocks
private TencentSmsClient smsClient = new TencentSmsClient(properties);
@Mock
private SmsClient client;
@Test
public void testDoInit() {
// 准备参数
// mock 方法
// 调用
smsClient.doInit();
// 断言
assertNotSame(client, ReflectUtil.getFieldValue(smsClient, "client"));
}
@Test
public void testDoSendSms() throws Throwable {
// 准备参数
Long sendLogId = randomLongId();
String mobile = randomString();
String apiTemplateId = randomString();
List<KeyValue<String, Object>> templateParams = Lists.newArrayList(
new KeyValue<>("1", 1234), new KeyValue<>("2", "login"));
String requestId = randomString();
String serialNo = randomString();
// mock 方法
SendSmsResponse response = randomPojo(SendSmsResponse.class, o -> {
o.setRequestId(requestId);
SendStatus[] sendStatuses = new SendStatus[1];
o.setSendStatusSet(sendStatuses);
SendStatus sendStatus = new SendStatus();
sendStatuses[0] = sendStatus;
sendStatus.setCode("Ok");
sendStatus.setMessage("send success");
sendStatus.setSerialNo(serialNo);
});
when(client.SendSms(argThat(request -> {
assertEquals(mobile, request.getPhoneNumberSet()[0]);
assertEquals(properties.getSignature(), request.getSignName());
assertEquals(apiTemplateId, request.getTemplateId());
assertEquals(toJsonString(CollectionUtils.toArray(new ArrayList<>(MapUtils.convertMap(templateParams).values()), String::valueOf)), toJsonString(request.getTemplateParamSet()));
assertEquals(sendLogId, ReflectUtil.getFieldValue(JsonUtils.parseObject(request.getSessionContext(), TencentSmsClient.SessionContext.class), "logId"));
return true;
}))).thenReturn(response);
// 调用
SmsCommonResult<SmsSendRespDTO> result = smsClient.doSendSms(sendLogId, mobile,
apiTemplateId, templateParams);
// 断言
assertEquals(response.getSendStatusSet()[0].getCode(), result.getApiCode());
assertEquals(response.getSendStatusSet()[0].getMessage(), result.getApiMsg());
assertEquals(GlobalErrorCodeConstants.SUCCESS.getCode(), result.getCode());
assertEquals(GlobalErrorCodeConstants.SUCCESS.getMsg(), result.getMsg());
assertEquals(response.getRequestId(), result.getApiRequestId());
// 断言结果
assertEquals(response.getSendStatusSet()[0].getSerialNo(), result.getData().getSerialNo());
}
@Test
public void testDoTParseSmsReceiveStatus() throws Throwable {
// 准备参数
String text = "[\n" +
" {\n" +
" \"user_receive_time\": \"2015-10-17 08:03:04\",\n" +
" \"nationcode\": \"86\",\n" +
" \"mobile\": \"13900000001\",\n" +
" \"report_status\": \"SUCCESS\",\n" +
" \"errmsg\": \"DELIVRD\",\n" +
" \"description\": \"用户短信送达成功\",\n" +
" \"sid\": \"12345\",\n" +
" \"ext\": {\"logId\":\"67890\"}\n" +
" }\n" +
"]";
// mock 方法
// 调用
List<SmsReceiveRespDTO> statuses = smsClient.doParseSmsReceiveStatus(text);
// 断言
assertEquals(1, statuses.size());
assertTrue(statuses.get(0).getSuccess());
assertEquals("DELIVRD", statuses.get(0).getErrorCode());
assertEquals("用户短信送达成功", statuses.get(0).getErrorMsg());
assertEquals("13900000001", statuses.get(0).getMobile());
assertEquals(DateUtils.buildTime(2015, 10, 17, 8, 3, 4), statuses.get(0).getReceiveTime());
assertEquals("12345", statuses.get(0).getSerialNo());
assertEquals(67890L, statuses.get(0).getLogId());
}
@Test
public void testDoGetSmsTemplate() throws Throwable {
// 准备参数
Long apiTemplateId = randomLongId();
String requestId = randomString();
// mock 方法
DescribeSmsTemplateListResponse response = randomPojo(DescribeSmsTemplateListResponse.class, o -> {
DescribeTemplateListStatus[] describeTemplateListStatuses = new DescribeTemplateListStatus[1];
DescribeTemplateListStatus templateStatus = new DescribeTemplateListStatus();
templateStatus.setTemplateId(apiTemplateId);
templateStatus.setStatusCode(0L);// 设置模板通过
describeTemplateListStatuses[0] = templateStatus;
o.setDescribeTemplateStatusSet(describeTemplateListStatuses);
o.setRequestId(requestId);
});
when(client.DescribeSmsTemplateList(argThat(request -> {
assertEquals(apiTemplateId, request.getTemplateIdSet()[0]);
return true;
}))).thenReturn(response);
// 调用
SmsCommonResult<SmsTemplateRespDTO> result = smsClient.doGetSmsTemplate(apiTemplateId.toString());
// 断言
assertEquals("Ok", result.getApiCode());
assertNull(result.getApiMsg());
assertEquals(GlobalErrorCodeConstants.SUCCESS.getCode(), result.getCode());
assertEquals(GlobalErrorCodeConstants.SUCCESS.getMsg(), result.getMsg());
assertEquals(response.getRequestId(), result.getApiRequestId());
// 断言结果
assertEquals(response.getDescribeTemplateStatusSet()[0].getTemplateId().toString(), result.getData().getId());
assertEquals(response.getDescribeTemplateStatusSet()[0].getTemplateContent(), result.getData().getContent());
assertEquals(SmsTemplateAuditStatusEnum.SUCCESS.getStatus(), result.getData().getAuditStatus());
assertEquals(response.getDescribeTemplateStatusSet()[0].getReviewReply(), result.getData().getAuditReason());
}
@Test
public void testConvertTemplateStatusDTO() {
testTemplateStatus(SmsTemplateAuditStatusEnum.SUCCESS, 0L);
testTemplateStatus(SmsTemplateAuditStatusEnum.CHECKING, 1L);
testTemplateStatus(SmsTemplateAuditStatusEnum.FAIL, -1L);
DescribeTemplateListStatus templateStatus = new DescribeTemplateListStatus();
templateStatus.setStatusCode(3L);
Long templateId = randomLongId();
assertThrows(IllegalStateException.class, () -> smsClient.convertTemplateStatusDTO(templateStatus),
StrUtil.format("不能解析短信模版审核状态[3]模版id[{}]", templateId));
}
private void testTemplateStatus(SmsTemplateAuditStatusEnum expected, Long value) {
DescribeTemplateListStatus templateStatus = new DescribeTemplateListStatus();
templateStatus.setStatusCode(value);
SmsTemplateRespDTO result = smsClient.convertTemplateStatusDTO(templateStatus);
assertEquals(expected.getStatus(), result.getAuditStatus());
}
}

View File

@ -0,0 +1,50 @@
package cn.iocoder.yudao.framework.sms.core.client.impl.tencent;
import cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants;
import cn.iocoder.yudao.framework.sms.core.enums.SmsFrameworkErrorCodeConstants;
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* {@link TencentSmsCodeMapping} 的单元测试
*
* @author : shiwp
*/
public class TencentSmsCodeMappingTest extends BaseMockitoUnitTest {
@InjectMocks
private TencentSmsCodeMapping codeMapping;
@Test
public void testApply() {
assertEquals(GlobalErrorCodeConstants.SUCCESS, codeMapping.apply("Ok"));
assertEquals(SmsFrameworkErrorCodeConstants.SMS_SEND_CONTENT_INVALID, codeMapping.apply("FailedOperation.ContainSensitiveWord"));
assertEquals(GlobalErrorCodeConstants.BAD_REQUEST, codeMapping.apply("FailedOperation.JsonParseFail"));
assertEquals(GlobalErrorCodeConstants.BAD_REQUEST, codeMapping.apply("MissingParameter.EmptyPhoneNumberSet"));
assertEquals(GlobalErrorCodeConstants.BAD_REQUEST, codeMapping.apply("LimitExceeded.PhoneNumberCountLimit"));
assertEquals(GlobalErrorCodeConstants.BAD_REQUEST, codeMapping.apply("FailedOperation.FailResolvePacket"));
assertEquals(SmsFrameworkErrorCodeConstants.SMS_ACCOUNT_MONEY_NOT_ENOUGH, codeMapping.apply("FailedOperation.InsufficientBalanceInSmsPackage"));
assertEquals(SmsFrameworkErrorCodeConstants.SMS_SEND_MARKET_LIMIT_CONTROL, codeMapping.apply("FailedOperation.MarketingSendTimeConstraint"));
assertEquals(SmsFrameworkErrorCodeConstants.SMS_MOBILE_BLACK, codeMapping.apply("FailedOperation.PhoneNumberInBlacklist"));
assertEquals(SmsFrameworkErrorCodeConstants.SMS_SIGN_INVALID, codeMapping.apply("FailedOperation.SignatureIncorrectOrUnapproved"));
assertEquals(SmsFrameworkErrorCodeConstants.SMS_TEMPLATE_INVALID, codeMapping.apply("FailedOperation.MissingTemplateToModify"));
assertEquals(SmsFrameworkErrorCodeConstants.SMS_TEMPLATE_INVALID, codeMapping.apply("FailedOperation.TemplateIncorrectOrUnapproved"));
assertEquals(SmsFrameworkErrorCodeConstants.SMS_MOBILE_INVALID, codeMapping.apply("InvalidParameterValue.IncorrectPhoneNumber"));
assertEquals(SmsFrameworkErrorCodeConstants.SMS_APP_ID_INVALID, codeMapping.apply("InvalidParameterValue.SdkAppIdNotExist"));
assertEquals(SmsFrameworkErrorCodeConstants.SMS_TEMPLATE_PARAM_ERROR, codeMapping.apply("InvalidParameterValue.TemplateParameterLengthLimit"));
assertEquals(SmsFrameworkErrorCodeConstants.SMS_TEMPLATE_PARAM_ERROR, codeMapping.apply("InvalidParameterValue.TemplateParameterFormatError"));
assertEquals(SmsFrameworkErrorCodeConstants.SMS_SEND_DAY_LIMIT_CONTROL, codeMapping.apply("LimitExceeded.PhoneNumberDailyLimit"));
assertEquals(SmsFrameworkErrorCodeConstants.SMS_SEND_BUSINESS_LIMIT_CONTROL, codeMapping.apply("LimitExceeded.PhoneNumberThirtySecondLimit"));
assertEquals(SmsFrameworkErrorCodeConstants.SMS_SEND_BUSINESS_LIMIT_CONTROL, codeMapping.apply("LimitExceeded.PhoneNumberOneHourLimit"));
assertEquals(SmsFrameworkErrorCodeConstants.SMS_PERMISSION_DENY, codeMapping.apply("UnauthorizedOperation.RequestPermissionDeny"));
assertEquals(SmsFrameworkErrorCodeConstants.SMS_PERMISSION_DENY, codeMapping.apply("FailedOperation.ForbidAddMarketingTemplates"));
assertEquals(SmsFrameworkErrorCodeConstants.SMS_PERMISSION_DENY, codeMapping.apply("FailedOperation.NotEnterpriseCertification"));
assertEquals(SmsFrameworkErrorCodeConstants.SMS_PERMISSION_DENY, codeMapping.apply("UnauthorizedOperation.IndividualUserMarketingSmsPermissionDeny"));
assertEquals(SmsFrameworkErrorCodeConstants.SMS_IP_DENY, codeMapping.apply("UnauthorizedOperation.RequestIpNotInWhitelist"));
assertEquals(SmsFrameworkErrorCodeConstants.SMS_ACCOUNT_INVALID, codeMapping.apply("AuthFailure.SecretIdNotFound"));
}
}

View File

@ -46,4 +46,13 @@ public class SmsCallbackController {
return success(true);
}
@PostMapping("/tencent")
@ApiOperation(value = "腾讯云短信的回调", notes = "参见 https://cloud.tencent.com/document/product/382/52077 文档")
@OperateLog(enable = false)
public CommonResult<Boolean> receiveTencentSmsStatus(HttpServletRequest request) throws Throwable {
String text = ServletUtil.getBody(request);
smsSendService.receiveSmsStatus(SmsChannelEnum.TENCENT.getCode(), text);
return success(true);
}
}

View File

@ -1,6 +1,6 @@
{
"name": "yudao-ui-admin",
"version": "1.6.1-snapshot",
"version": "1.6.2-snapshot",
"description": "芋道管理系统",
"author": "芋道",
"license": "MIT",

File diff suppressed because it is too large Load Diff