FinallySays 3 роки тому
батько
коміт
6fd52dfbb6
15 змінених файлів з 1002 додано та 356 видалено
  1. 1 1
      pom.xml
  2. 1 0
      sql/ruoyi-vue-pro.sql
  3. 7 1
      yudao-dependencies/pom.xml
  4. 22 1
      yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/collection/CollectionUtils.java
  5. 5 1
      yudao-framework/yudao-spring-boot-starter-biz-sms/pom.xml
  6. 3 1
      yudao-framework/yudao-spring-boot-starter-biz-sms/src/main/java/cn/iocoder/yudao/framework/sms/core/client/impl/SmsClientFactoryImpl.java
  7. 294 0
      yudao-framework/yudao-spring-boot-starter-biz-sms/src/main/java/cn/iocoder/yudao/framework/sms/core/client/impl/tencent/TencentSmsClient.java
  8. 50 0
      yudao-framework/yudao-spring-boot-starter-biz-sms/src/main/java/cn/iocoder/yudao/framework/sms/core/client/impl/tencent/TencentSmsCodeMapping.java
  9. 1 1
      yudao-framework/yudao-spring-boot-starter-biz-sms/src/main/java/cn/iocoder/yudao/framework/sms/core/enums/SmsChannelEnum.java
  10. 4 0
      yudao-framework/yudao-spring-boot-starter-biz-sms/src/main/java/cn/iocoder/yudao/framework/sms/core/enums/SmsFrameworkErrorCodeConstants.java
  11. 196 0
      yudao-framework/yudao-spring-boot-starter-biz-sms/src/test/java/cn/iocoder/yudao/framework/sms/core/client/impl/tencent/TencentSmsClientTest.java
  12. 50 0
      yudao-framework/yudao-spring-boot-starter-biz-sms/src/test/java/cn/iocoder/yudao/framework/sms/core/client/impl/tencent/TencentSmsCodeMappingTest.java
  13. 9 0
      yudao-module-system/yudao-module-system-impl/src/main/java/cn/iocoder/yudao/module/system/controller/admin/sms/SmsCallbackController.java
  14. 1 1
      yudao-ui-admin/package.json
  15. 358 349
      yudao-ui-admin/yarn.lock

+ 1 - 1
pom.xml

@@ -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>

+ 1 - 0
sql/ruoyi-vue-pro.sql

@@ -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;
 
 -- ----------------------------

+ 7 - 1
yudao-dependencies/pom.xml

@@ -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>

+ 22 - 1
yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/collection/CollectionUtils.java

@@ -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;
@@ -115,7 +119,7 @@ public class CollectionUtils {
             return new HashMap<>();
         }
         return from.stream()
-                   .collect(Collectors.groupingBy(keyFunc, Collectors.mapping(valueFunc, Collectors.toList())));
+                .collect(Collectors.groupingBy(keyFunc, Collectors.mapping(valueFunc, Collectors.toList())));
     }
 
     // 暂时没想好名字,先以 2 结尾噶
@@ -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;
+    }
 }

+ 5 - 1
yudao-framework/yudao-spring-boot-starter-biz-sms/pom.xml

@@ -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>
 

+ 3 - 1
yudao-framework/yudao-spring-boot-starter-biz-sms/src/main/java/cn/iocoder/yudao/framework/sms/core/client/impl/SmsClientFactoryImpl.java

@@ -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);

+ 294 - 0
yudao-framework/yudao-spring-boot-starter-biz-sms/src/main/java/cn/iocoder/yudao/framework/sms/core/client/impl/tencent/TencentSmsClient.java

@@ -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);
+        // 实例化一个认证对象,入参需要传入腾讯云账户密钥对secretId,secretKey。
+        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;
+        }
+    }
+
+
+}

+ 50 - 0
yudao-framework/yudao-spring-boot-starter-biz-sms/src/main/java/cn/iocoder/yudao/framework/sms/core/client/impl/tencent/TencentSmsCodeMapping.java

@@ -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;
+    }
+}

+ 1 - 1
yudao-framework/yudao-spring-boot-starter-biz-sms/src/main/java/cn/iocoder/yudao/framework/sms/core/enums/SmsChannelEnum.java

@@ -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", "华为云"),
     ;
 

+ 4 - 0
yudao-framework/yudao-spring-boot-starter-biz-sms/src/main/java/cn/iocoder/yudao/framework/sms/core/enums/SmsFrameworkErrorCodeConstants.java

@@ -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, "调用异常");
 

+ 196 - 0
yudao-framework/yudao-spring-boot-starter-biz-sms/src/test/java/cn/iocoder/yudao/framework/sms/core/client/impl/tencent/TencentSmsClientTest.java

@@ -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());
+    }
+
+
+}

+ 50 - 0
yudao-framework/yudao-spring-boot-starter-biz-sms/src/test/java/cn/iocoder/yudao/framework/sms/core/client/impl/tencent/TencentSmsCodeMappingTest.java

@@ -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"));
+    }
+
+}

+ 9 - 0
yudao-module-system/yudao-module-system-impl/src/main/java/cn/iocoder/yudao/module/system/controller/admin/sms/SmsCallbackController.java

@@ -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);
+    }
+
 }

+ 1 - 1
yudao-ui-admin/package.json

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

Різницю між файлами не показано, бо вона завелика
+ 358 - 349
yudao-ui-admin/yarn.lock


Деякі файли не було показано, через те що забагато файлів було змінено