Ver Fonte

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

YunaiV há 11 meses atrás
pai
commit
30fd7996f6
71 ficheiros alterados com 1622 adições e 916 exclusões
  1. 3 3
      pom.xml
  2. 18 1
      yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/http/HttpUtils.java
  3. 1 1
      yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClient.java
  4. 1 0
      yudao-module-infra/yudao-module-infra-biz/src/main/resources/codegen/java/service/serviceImpl.vm
  5. 5 6
      yudao-module-infra/yudao-module-infra-biz/src/main/resources/codegen/vue3/views/components/form_sub_erp.vue.vm
  6. 10 12
      yudao-module-infra/yudao-module-infra-biz/src/main/resources/codegen/vue3/views/components/form_sub_normal.vue.vm
  7. 5 6
      yudao-module-infra/yudao-module-infra-biz/src/main/resources/codegen/vue3/views/form.vue.vm
  8. 9 0
      yudao-module-mall/yudao-module-product-api/src/main/java/cn/iocoder/yudao/module/product/api/spu/dto/ProductSpuRespDTO.java
  9. 1 1
      yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/comment/ProductCommentServiceImpl.java
  10. 20 1
      yudao-module-mall/yudao-module-promotion-api/src/main/java/cn/iocoder/yudao/module/promotion/api/coupon/CouponApi.java
  11. 40 12
      yudao-module-mall/yudao-module-promotion-api/src/main/java/cn/iocoder/yudao/module/promotion/api/reward/dto/RewardActivityMatchRespDTO.java
  12. 2 1
      yudao-module-mall/yudao-module-promotion-api/src/main/java/cn/iocoder/yudao/module/promotion/enums/ErrorCodeConstants.java
  13. 16 4
      yudao-module-mall/yudao-module-promotion-api/src/main/java/cn/iocoder/yudao/module/promotion/enums/common/PromotionProductScopeEnum.java
  14. 1 2
      yudao-module-mall/yudao-module-promotion-api/src/main/java/cn/iocoder/yudao/module/promotion/enums/coupon/CouponStatusEnum.java
  15. 13 1
      yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/api/coupon/CouponApiImpl.java
  16. 5 6
      yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/admin/reward/RewardActivityController.java
  17. 19 18
      yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/admin/reward/vo/RewardActivityBaseVO.java
  18. 47 19
      yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/app/activity/AppActivityController.java
  19. 0 29
      yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/convert/reward/RewardActivityConvert.java
  20. 0 1
      yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/dal/dataobject/coupon/CouponDO.java
  21. 11 9
      yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/dal/dataobject/reward/RewardActivityDO.java
  22. 4 8
      yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/dal/mysql/reward/RewardActivityMapper.java
  23. 7 2
      yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/service/combination/CombinationRecordServiceImpl.java
  24. 64 43
      yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/service/coupon/CouponService.java
  25. 146 74
      yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/service/coupon/CouponServiceImpl.java
  26. 4 1
      yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/service/discount/DiscountActivityServiceImpl.java
  27. 1 2
      yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/service/reward/RewardActivityService.java
  28. 59 71
      yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/service/reward/RewardActivityServiceImpl.java
  29. 68 61
      yudao-module-mall/yudao-module-promotion-biz/src/test/java/cn/iocoder/yudao/module/promotion/service/reward/RewardActivityServiceImplTest.java
  30. 3 3
      yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/api/order/TradeOrderApi.java
  31. 3 0
      yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/enums/ErrorCodeConstants.java
  32. 2 1
      yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/enums/order/TradeOrderCancelTypeEnum.java
  33. 3 3
      yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/api/order/TradeOrderApiImpl.java
  34. 23 0
      yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/dataobject/order/TradeOrderDO.java
  35. 0 5
      yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageUserServiceImpl.java
  36. 15 6
      yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/order/TradeOrderUpdateService.java
  37. 41 4
      yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/order/TradeOrderUpdateServiceImpl.java
  38. 32 4
      yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/order/handler/TradeCouponOrderHandler.java
  39. 29 2
      yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/price/bo/TradePriceCalculateRespBO.java
  40. 4 2
      yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/price/calculator/TradeCouponPriceCalculator.java
  41. 16 6
      yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/price/calculator/TradeDeliveryPriceCalculator.java
  42. 3 3
      yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/price/calculator/TradePriceCalculatorHelper.java
  43. 77 11
      yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/price/calculator/TradeRewardActivityPriceCalculator.java
  44. 20 9
      yudao-module-mall/yudao-module-trade-biz/src/test/java/cn/iocoder/yudao/module/trade/service/price/calculator/TradeRewardActivityPriceCalculatorTest.java
  45. 1 0
      yudao-module-mall/yudao-module-trade-biz/src/test/resources/sql/create_tables.sql
  46. 2 10
      yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/MemberUserController.java
  47. 2 3
      yudao-module-pay/yudao-module-pay-api/src/main/java/cn/iocoder/yudao/module/pay/enums/wallet/PayWalletBizTypeEnum.java
  48. 22 3
      yudao-module-pay/yudao-module-pay-biz/src/main/java/cn/iocoder/yudao/module/pay/controller/admin/wallet/PayWalletController.java
  49. 19 0
      yudao-module-pay/yudao-module-pay-biz/src/main/java/cn/iocoder/yudao/module/pay/controller/admin/wallet/vo/wallet/PayWalletUpdateBalanceReqVO.java
  50. 4 1
      yudao-module-pay/yudao-module-pay-biz/src/main/java/cn/iocoder/yudao/module/pay/service/wallet/PayWalletServiceImpl.java
  51. 14 9
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/sms/SmsCallbackController.java
  52. 2 1
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/SmsClientFactory.java
  53. 0 6
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/AbstractSmsClient.java
  54. 3 6
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/AliyunSmsClient.java
  55. 0 4
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/DebugDingTalkSmsClient.java
  56. 106 166
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/HuaweiSmsClient.java
  57. 155 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/QiniuSmsClient.java
  58. 3 1
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/SmsClientFactoryImpl.java
  59. 43 71
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/TencentSmsClient.java
  60. 2 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/enums/SmsChannelEnum.java
  61. 4 2
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2TokenServiceImpl.java
  62. 10 73
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/sms/SmsChannelServiceImpl.java
  63. 127 0
      yudao-module-system/yudao-module-system-biz/src/test/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/HuaweiSmsClientTest.java
  64. 131 0
      yudao-module-system/yudao-module-system-biz/src/test/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/QiniuSmsClientTest.java
  65. 54 46
      yudao-module-system/yudao-module-system-biz/src/test/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/SmsClientTests.java
  66. 26 1
      yudao-module-system/yudao-module-system-biz/src/test/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/TencentSmsClientTest.java
  67. 5 24
      yudao-module-system/yudao-module-system-biz/src/test/java/cn/iocoder/yudao/module/system/service/sms/SmsChannelServiceTest.java
  68. 30 30
      yudao-server/pom.xml
  69. 2 2
      yudao-server/src/main/resources/application-dev.yaml
  70. 3 3
      yudao-server/src/main/resources/application-local.yaml
  71. 1 0
      yudao-server/src/main/resources/application.yaml

+ 3 - 3
pom.xml

@@ -15,12 +15,12 @@
         <!-- 各种 module 拓展 -->
         <module>yudao-module-system</module>
         <module>yudao-module-infra</module>
-<!--        <module>yudao-module-member</module>-->
+        <module>yudao-module-member</module>
 <!--        <module>yudao-module-bpm</module>-->
 <!--        <module>yudao-module-report</module>-->
 <!--        <module>yudao-module-mp</module>-->
-<!--        <module>yudao-module-pay</module>-->
-<!--        <module>yudao-module-mall</module>-->
+        <module>yudao-module-pay</module>
+        <module>yudao-module-mall</module>
 <!--        <module>yudao-module-crm</module>-->
 <!--        <module>yudao-module-erp</module>-->
 <!--        <module>yudao-module-ai</module>-->

+ 18 - 1
yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/http/HttpUtils.java

@@ -111,7 +111,7 @@ public class HttpUtils {
             authorization = Base64.decodeStr(authorization);
             clientId = StrUtil.subBefore(authorization, ":", false);
             clientSecret = StrUtil.subAfter(authorization, ":", false);
-        // 再从 Param 中获取
+            // 再从 Param 中获取
         } else {
             clientId = request.getParameter("client_id");
             clientSecret = request.getParameter("client_secret");
@@ -143,4 +143,21 @@ public class HttpUtils {
         }
     }
 
+    /**
+     * HTTP get 请求,基于 {@link cn.hutool.http.HttpUtil} 实现
+     *
+     * 为什么要封装该方法,因为 HttpUtil 默认封装的方法,没有允许传递 headers 参数
+     *
+     * @param url URL
+     * @param headers 请求头
+     * @return 请求结果
+     */
+    public static String get(String url, Map<String, String> headers) {
+        try (HttpResponse response = HttpRequest.get(url)
+                .addHeaders(headers)
+                .execute()) {
+            return response.body();
+        }
+    }
+
 }

+ 1 - 1
yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClient.java

@@ -91,7 +91,7 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
      * 开启 VirtualStyle 模式
      */
     private void enableVirtualStyleEndpoint() {
-        if (StrUtil.containsAll(config.getEndpoint(),
+        if (StrUtil.containsAny(config.getEndpoint(),
                 S3FileClientConfig.ENDPOINT_TENCENT, // 腾讯云 https://cloud.tencent.com/document/product/436/41284
                 S3FileClientConfig.ENDPOINT_VOLCES)) { // 火山云 https://www.volcengine.com/docs/6349/1288493
             client.enableVirtualStyleEndpoint();

+ 1 - 0
yudao-module-infra/yudao-module-infra-biz/src/main/resources/codegen/java/service/serviceImpl.vm

@@ -286,6 +286,7 @@ public class ${table.className}ServiceImpl implements ${table.className}Service
         // 校验存在
         validate${subSimpleClassName}Exists(${subClassNameVar}.getId());
         // 更新
+        ${subClassNameVar}.setUpdater(null).setUpdateTime(null); // 解决更新情况下:updateTime 不更新
         ${subClassNameVars.get($index)}Mapper.updateById(${subClassNameVar});
     }
 

+ 5 - 6
yudao-module-infra/yudao-module-infra-biz/src/main/resources/codegen/vue3/views/components/form_sub_erp.vue.vm

@@ -64,12 +64,11 @@
           <el-checkbox
             v-for="dict in $dictMethod(DICT_TYPE.$dictType.toUpperCase())"
             :key="dict.value"
-            :label="dict.value"
-          >
-            {{ dict.label }}
-          </el-checkbox>
+            :label="dict.label"
+            :value="dict.value"
+          />
                 #else##没数据字典
-          <el-checkbox>请选择字典生成</el-checkbox>
+          <el-checkbox label="请选择字典生成" />
                 #end
         </el-checkbox-group>
       </el-form-item>
@@ -85,7 +84,7 @@
             {{ dict.label }}
           </el-radio>
                 #else##没数据字典
-          <el-radio label="1">请选择字典生成</el-radio>
+          <el-radio value="1">请选择字典生成</el-radio>
                 #end
         </el-radio-group>
       </el-form-item>

+ 10 - 12
yudao-module-infra/yudao-module-infra-biz/src/main/resources/codegen/vue3/views/components/form_sub_normal.vue.vm

@@ -92,12 +92,11 @@
                 <el-checkbox
                   v-for="dict in $dictMethod(DICT_TYPE.$dictType.toUpperCase())"
                   :key="dict.value"
-                  :label="dict.value"
-                >
-                  {{ dict.label }}
-                </el-checkbox>
+                  :label="dict.label"
+                  :value="dict.value"
+                />
               #else##没数据字典
-                <el-checkbox>请选择字典生成</el-checkbox>
+                <el-checkbox label="请选择字典生成" />
               #end
             </el-checkbox-group>
           </el-form-item>
@@ -117,7 +116,7 @@
                   {{ dict.label }}
                 </el-radio>
               #else##没数据字典
-                <el-radio label="1">请选择字典生成</el-radio>
+                <el-radio value="1">请选择字典生成</el-radio>
               #end
             </el-radio-group>
           </el-form-item>
@@ -219,12 +218,11 @@
         <el-checkbox
           v-for="dict in $dictMethod(DICT_TYPE.$dictType.toUpperCase())"
           :key="dict.value"
-          :label="dict.value"
-        >
-          {{ dict.label }}
-        </el-checkbox>
+          :label="dict.label"
+          :value="dict.value"
+        />
               #else##没数据字典
-        <el-checkbox>请选择字典生成</el-checkbox>
+        <el-checkbox label="请选择字典生成" />
               #end
       </el-checkbox-group>
     </el-form-item>
@@ -240,7 +238,7 @@
           {{ dict.label }}
         </el-radio>
               #else##没数据字典
-        <el-radio label="1">请选择字典生成</el-radio>
+        <el-radio value="1">请选择字典生成</el-radio>
               #end
       </el-radio-group>
     </el-form-item>

+ 5 - 6
yudao-module-infra/yudao-module-infra-biz/src/main/resources/codegen/vue3/views/form.vue.vm

@@ -75,12 +75,11 @@
           <el-checkbox
             v-for="dict in $dictMethod(DICT_TYPE.$dictType.toUpperCase())"
             :key="dict.value"
-            :label="dict.value"
-          >
-            {{ dict.label }}
-          </el-checkbox>
+            :label="dict.label"
+            :value="dict.value"
+          />
                 #else##没数据字典
-          <el-checkbox>请选择字典生成</el-checkbox>
+          <el-checkbox label="请选择字典生成" />
                 #end
         </el-checkbox-group>
       </el-form-item>
@@ -96,7 +95,7 @@
             {{ dict.label }}
           </el-radio>
                 #else##没数据字典
-          <el-radio label="1">请选择字典生成</el-radio>
+          <el-radio value="1">请选择字典生成</el-radio>
                 #end
         </el-radio-group>
       </el-form-item>

+ 9 - 0
yudao-module-mall/yudao-module-product-api/src/main/java/cn/iocoder/yudao/module/product/api/spu/dto/ProductSpuRespDTO.java

@@ -3,6 +3,8 @@ package cn.iocoder.yudao.module.product.api.spu.dto;
 import cn.iocoder.yudao.module.product.enums.spu.ProductSpuStatusEnum;
 import lombok.Data;
 
+import java.util.List;
+
 /**
  * 商品 SPU 信息 Response DTO
  *
@@ -68,6 +70,13 @@ public class ProductSpuRespDTO {
 
     // ========== 物流相关字段 =========
 
+    /**
+     * 配送方式数组
+     *
+     * 对应 DeliveryTypeEnum 枚举
+     */
+    private List<Integer> deliveryTypes;
+
     /**
      * 物流配置模板编号
      *

+ 1 - 1
yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/comment/ProductCommentServiceImpl.java

@@ -67,7 +67,7 @@ public class ProductCommentServiceImpl implements ProductCommentService {
         // 校验 SPU
         ProductSpuDO spu = validateSpu(sku.getSpuId());
         // 校验评论
-        validateCommentExists(createReqDTO.getUserId(), createReqDTO.getOrderId());
+        validateCommentExists(createReqDTO.getUserId(), createReqDTO.getOrderItemId());
         // 获取用户详细信息
         MemberUserRespDTO user = memberUserApi.getUser(createReqDTO.getUserId());
 

+ 20 - 1
yudao-module-mall/yudao-module-promotion-api/src/main/java/cn/iocoder/yudao/module/promotion/api/coupon/CouponApi.java

@@ -3,9 +3,11 @@ package cn.iocoder.yudao.module.promotion.api.coupon;
 import cn.iocoder.yudao.module.promotion.api.coupon.dto.CouponRespDTO;
 import cn.iocoder.yudao.module.promotion.api.coupon.dto.CouponUseReqDTO;
 import cn.iocoder.yudao.module.promotion.api.coupon.dto.CouponValidReqDTO;
-
 import jakarta.validation.Valid;
 
+import java.util.List;
+import java.util.Map;
+
 /**
  * 优惠劵 API 接口
  *
@@ -35,4 +37,21 @@ public interface CouponApi {
      */
     CouponRespDTO validateCoupon(@Valid CouponValidReqDTO validReqDTO);
 
+    /**
+     * 【管理员】给指定用户批量发送优惠券
+     *
+     * @param giveCoupons  key: 优惠劵模版编号,value:对应的数量
+     * @param userId      用户编号
+     * @return 优惠券编号列表
+     */
+    List<Long> takeCouponsByAdmin(Map<Long, Integer> giveCoupons, Long userId);
+
+    /**
+     * 【管理员】作废指定用户的指定优惠劵
+     *
+     * @param giveCouponIds  赠送的优惠券编号
+     * @param userId         用户编号
+     */
+    void invalidateCouponsByAdmin(List<Long> giveCouponIds, Long userId);
+
 }

+ 40 - 12
yudao-module-mall/yudao-module-promotion-api/src/main/java/cn/iocoder/yudao/module/promotion/api/reward/dto/RewardActivityMatchRespDTO.java

@@ -1,9 +1,14 @@
 package cn.iocoder.yudao.module.promotion.api.reward.dto;
 
+import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
 import cn.iocoder.yudao.module.promotion.enums.common.PromotionConditionTypeEnum;
+import cn.iocoder.yudao.module.promotion.enums.common.PromotionProductScopeEnum;
 import lombok.Data;
 
+import java.io.Serializable;
+import java.time.LocalDateTime;
 import java.util.List;
+import java.util.Map;
 
 /**
  * 满减送活动的匹配 Response DTO
@@ -21,6 +26,24 @@ public class RewardActivityMatchRespDTO {
      * 活动标题
      */
     private String name;
+    /**
+     * 状态
+     *
+     * 枚举 {@link CommonStatusEnum}
+     */
+    private Integer status;
+    /**
+     * 开始时间
+     */
+    private LocalDateTime startTime;
+    /**
+     * 结束时间
+     */
+    private LocalDateTime endTime;
+    /**
+     * 备注
+     */
+    private String remark;
     /**
      * 条件类型
      *
@@ -28,21 +51,25 @@ public class RewardActivityMatchRespDTO {
      */
     private Integer conditionType;
     /**
-     * 优惠规则的数组
+     * 商品范围
+     *
+     * 枚举 {@link PromotionProductScopeEnum}
      */
-    private List<Rule> rules;
-
+    private Integer productScope;
     /**
      * 商品 SPU 编号的数组
      */
-    private List<Long> spuIds;
+    private List<Long> productScopeValues;
+    /**
+     * 优惠规则的数组
+     */
+    private List<Rule> rules;
 
-    // TODO 芋艿:后面 RewardActivityRespDTO 有了之后,Rule 可以放过去
     /**
      * 优惠规则
      */
     @Data
-    public static class Rule {
+    public static class Rule implements Serializable {
 
         /**
          * 优惠门槛
@@ -64,13 +91,14 @@ public class RewardActivityMatchRespDTO {
          */
         private Integer point;
         /**
-         * 赠送的优惠劵编号的数组
-         */
-        private List<Long> couponIds;
-        /**
-         * 赠送的优惠券数量的数组
+         * 赠送的优惠劵
+         *
+         * key: 优惠劵模版编号
+         * value:对应的优惠券数量
+         *
+         * 目的:用于订单支付后赠送优惠券
          */
-        private List<Integer> couponCounts;
+        private Map<Long, Integer> giveCouponTemplateCounts;
 
     }
 

+ 2 - 1
yudao-module-mall/yudao-module-promotion-api/src/main/java/cn/iocoder/yudao/module/promotion/enums/ErrorCodeConstants.java

@@ -44,7 +44,8 @@ public interface ErrorCodeConstants {
     ErrorCode REWARD_ACTIVITY_UPDATE_FAIL_STATUS_CLOSED = new ErrorCode(1_013_006_002, "满减送活动已关闭,不能修改");
     ErrorCode REWARD_ACTIVITY_DELETE_FAIL_STATUS_NOT_CLOSED = new ErrorCode(1_013_006_003, "满减送活动未关闭,不能删除");
     ErrorCode REWARD_ACTIVITY_CLOSE_FAIL_STATUS_CLOSED = new ErrorCode(1_013_006_004, "满减送活动已关闭,不能重复关闭");
-    ErrorCode REWARD_ACTIVITY_CLOSE_FAIL_STATUS_END = new ErrorCode(1_013_006_005, "满减送活动已结束,不能关闭");
+    ErrorCode REWARD_ACTIVITY_SCOPE_ALL_EXISTS = new ErrorCode(1_013_006_005, "已存在商品范围为全场的满减送活动");
+    ErrorCode REWARD_ACTIVITY_SCOPE_CATEGORY_EXISTS = new ErrorCode(1_013_006_006, "存在商品类型参加了其它满减送活动");
 
     // ========== TODO 空着 1-013-007-000 ============
 

+ 16 - 4
yudao-module-mall/yudao-module-promotion-api/src/main/java/cn/iocoder/yudao/module/promotion/enums/common/PromotionProductScopeEnum.java

@@ -5,6 +5,7 @@ import lombok.AllArgsConstructor;
 import lombok.Getter;
 
 import java.util.Arrays;
+import java.util.Objects;
 
 /**
  * 营销的商品范围枚举
@@ -15,10 +16,9 @@ import java.util.Arrays;
 @AllArgsConstructor
 public enum PromotionProductScopeEnum implements IntArrayValuable {
 
-    ALL(1, "通用券"), // 全部商品
-    SPU(2, "商品券"), // 指定商品
-    CATEGORY(3, "品类券"), // 指定品类
-    ;
+    ALL(1, "全部商品"),
+    SPU(2, "指定商品"),
+    CATEGORY(3, "指定品类");
 
     public static final int[] ARRAYS = Arrays.stream(values()).mapToInt(PromotionProductScopeEnum::getScope).toArray();
 
@@ -36,4 +36,16 @@ public enum PromotionProductScopeEnum implements IntArrayValuable {
         return ARRAYS;
     }
 
+    public static boolean isAll(Integer scope) {
+        return Objects.equals(scope, ALL.scope);
+    }
+
+    public static boolean isSpu(Integer scope) {
+        return Objects.equals(scope, SPU.scope);
+    }
+
+    public static boolean isCategory(Integer scope) {
+        return Objects.equals(scope, CATEGORY.scope);
+    }
+
 }

+ 1 - 2
yudao-module-mall/yudao-module-promotion-api/src/main/java/cn/iocoder/yudao/module/promotion/enums/coupon/CouponStatusEnum.java

@@ -17,8 +17,7 @@ public enum CouponStatusEnum implements IntArrayValuable {
 
     UNUSED(1, "未使用"),
     USED(2, "已使用"),
-    EXPIRE(3, "已过期"),
-    ;
+    EXPIRE(3, "已过期");
 
     public static final int[] ARRAYS = Arrays.stream(values()).mapToInt(CouponStatusEnum::getStatus).toArray();
 

+ 13 - 1
yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/api/coupon/CouponApiImpl.java

@@ -7,10 +7,12 @@ import cn.iocoder.yudao.module.promotion.api.coupon.dto.CouponValidReqDTO;
 import cn.iocoder.yudao.module.promotion.convert.coupon.CouponConvert;
 import cn.iocoder.yudao.module.promotion.dal.dataobject.coupon.CouponDO;
 import cn.iocoder.yudao.module.promotion.service.coupon.CouponService;
+import jakarta.annotation.Resource;
 import org.springframework.stereotype.Service;
 import org.springframework.validation.annotation.Validated;
 
-import jakarta.annotation.Resource;
+import java.util.List;
+import java.util.Map;
 
 /**
  * 优惠劵 API 实现类
@@ -41,4 +43,14 @@ public class CouponApiImpl implements CouponApi {
         return CouponConvert.INSTANCE.convert(coupon);
     }
 
+    @Override
+    public List<Long> takeCouponsByAdmin(Map<Long, Integer> giveCoupons, Long userId) {
+        return couponService.takeCouponsByAdmin(giveCoupons, userId);
+    }
+
+    @Override
+    public void invalidateCouponsByAdmin(List<Long> giveCouponIds, Long userId) {
+        couponService.invalidateCouponsByAdmin(giveCouponIds, userId);
+    }
+
 }

+ 5 - 6
yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/admin/reward/RewardActivityController.java

@@ -2,23 +2,22 @@ package cn.iocoder.yudao.module.promotion.controller.admin.reward;
 
 import cn.iocoder.yudao.framework.common.pojo.CommonResult;
 import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
 import cn.iocoder.yudao.module.promotion.controller.admin.reward.vo.RewardActivityCreateReqVO;
 import cn.iocoder.yudao.module.promotion.controller.admin.reward.vo.RewardActivityPageReqVO;
 import cn.iocoder.yudao.module.promotion.controller.admin.reward.vo.RewardActivityRespVO;
 import cn.iocoder.yudao.module.promotion.controller.admin.reward.vo.RewardActivityUpdateReqVO;
-import cn.iocoder.yudao.module.promotion.convert.reward.RewardActivityConvert;
 import cn.iocoder.yudao.module.promotion.dal.dataobject.reward.RewardActivityDO;
 import cn.iocoder.yudao.module.promotion.service.reward.RewardActivityService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.Parameter;
 import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.Resource;
+import jakarta.validation.Valid;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.validation.annotation.Validated;
 import org.springframework.web.bind.annotation.*;
 
-import jakarta.annotation.Resource;
-import jakarta.validation.Valid;
-
 import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
 
 @Tag(name = "管理后台 - 满减送活动")
@@ -69,7 +68,7 @@ public class RewardActivityController {
     @PreAuthorize("@ss.hasPermission('promotion:reward-activity:query')")
     public CommonResult<RewardActivityRespVO> getRewardActivity(@RequestParam("id") Long id) {
         RewardActivityDO rewardActivity = rewardActivityService.getRewardActivity(id);
-        return success(RewardActivityConvert.INSTANCE.convert(rewardActivity));
+        return success(BeanUtils.toBean(rewardActivity, RewardActivityRespVO.class));
     }
 
     @GetMapping("/page")
@@ -77,7 +76,7 @@ public class RewardActivityController {
     @PreAuthorize("@ss.hasPermission('promotion:reward-activity:query')")
     public CommonResult<PageResult<RewardActivityRespVO>> getRewardActivityPage(@Valid RewardActivityPageReqVO pageVO) {
         PageResult<RewardActivityDO> pageResult = rewardActivityService.getRewardActivityPage(pageVO);
-        return success(RewardActivityConvert.INSTANCE.convertPage(pageResult));
+        return success(BeanUtils.toBean(pageResult, RewardActivityRespVO.class));
     }
 
 }

+ 19 - 18
yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/admin/reward/vo/RewardActivityBaseVO.java

@@ -6,18 +6,17 @@ import cn.iocoder.yudao.module.promotion.enums.common.PromotionConditionTypeEnum
 import cn.iocoder.yudao.module.promotion.enums.common.PromotionProductScopeEnum;
 import com.fasterxml.jackson.annotation.JsonIgnore;
 import io.swagger.v3.oas.annotations.media.Schema;
-import lombok.Data;
-import org.springframework.format.annotation.DateTimeFormat;
-
 import jakarta.validation.Valid;
 import jakarta.validation.constraints.AssertTrue;
 import jakarta.validation.constraints.Future;
 import jakarta.validation.constraints.Min;
 import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
 import java.time.LocalDateTime;
 import java.util.List;
-
-import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
+import java.util.Map;
+import java.util.Objects;
 
 /**
 * 满减送活动 Base VO,提供给添加、修改、详细的子 VO 使用
@@ -32,12 +31,10 @@ public class RewardActivityBaseVO {
 
     @Schema(description = "开始时间", requiredMode = Schema.RequiredMode.REQUIRED)
     @NotNull(message = "开始时间不能为空")
-    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
     private LocalDateTime startTime;
 
     @Schema(description = "结束时间", requiredMode = Schema.RequiredMode.REQUIRED)
     @NotNull(message = "结束时间不能为空")
-    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
     @Future(message = "结束时间必须大于当前时间")
     private LocalDateTime endTime;
 
@@ -54,8 +51,8 @@ public class RewardActivityBaseVO {
     @InEnum(value = PromotionProductScopeEnum.class, message = "商品范围必须是 {value}")
     private Integer productScope;
 
-    @Schema(description = "商品 SPU 编号的数组", example = "1,2,3")
-    private List<Long> productSpuIds;
+    @Schema(description = "商品范围编号的数组", example = "[1, 3]")
+    private List<Long> productScopeValues;
 
     /**
      * 优惠规则的数组
@@ -76,24 +73,28 @@ public class RewardActivityBaseVO {
         private Integer discountPrice;
 
         @Schema(description = "是否包邮", requiredMode = Schema.RequiredMode.REQUIRED, example = "true")
+        @NotNull(message = "规则是否包邮不能为空")
         private Boolean freeDelivery;
 
         @Schema(description = "赠送的积分", requiredMode = Schema.RequiredMode.REQUIRED, example = "100")
-        @Min(value = 1L, message = "赠送的积分必须大于等于 1")
         private Integer point;
 
-        @Schema(description = "赠送的优惠劵编号的数组", example = "1,2,3")
-        private List<Long> couponIds;
-
-        @Schema(description = "赠送的优惠券数量的数组", example = "1,2,3")
-        private List<Integer> couponCounts;
+        @Schema(description = "赠送的优惠劵编号的数组")
+        private Map<Long, Integer> giveCouponTemplateCounts;
 
-        @AssertTrue(message = "优惠劵和数量必须一一对应")
+        @AssertTrue(message = "赠送的积分不能小于 0")
         @JsonIgnore
-        public boolean isCouponCountsValid() {
-            return CollUtil.size(couponCounts) == CollUtil.size(couponCounts);
+        public boolean isPointValid() {
+            return point == null || point >= 0;
         }
 
     }
 
+    @AssertTrue(message = "商品范围编号的数组不能为空")
+    @JsonIgnore
+    public boolean isProductScopeValuesValid() {
+        return Objects.equals(productScope, PromotionProductScopeEnum.ALL.getScope()) // 全部范围时,可以为空
+                || CollUtil.isNotEmpty(productScopeValues);
+    }
+
 }

+ 47 - 19
yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/app/activity/AppActivityController.java

@@ -2,8 +2,11 @@ package cn.iocoder.yudao.module.promotion.controller.app.activity;
 
 import cn.hutool.core.collection.CollUtil;
 import cn.hutool.core.map.MapUtil;
+import cn.hutool.core.util.ObjUtil;
 import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
 import cn.iocoder.yudao.framework.common.pojo.CommonResult;
+import cn.iocoder.yudao.module.product.api.spu.ProductSpuApi;
+import cn.iocoder.yudao.module.product.api.spu.dto.ProductSpuRespDTO;
 import cn.iocoder.yudao.module.promotion.controller.app.activity.vo.AppActivityRespVO;
 import cn.iocoder.yudao.module.promotion.dal.dataobject.bargain.BargainActivityDO;
 import cn.iocoder.yudao.module.promotion.dal.dataobject.combination.CombinationActivityDO;
@@ -11,7 +14,7 @@ import cn.iocoder.yudao.module.promotion.dal.dataobject.discount.DiscountActivit
 import cn.iocoder.yudao.module.promotion.dal.dataobject.discount.DiscountProductDO;
 import cn.iocoder.yudao.module.promotion.dal.dataobject.reward.RewardActivityDO;
 import cn.iocoder.yudao.module.promotion.dal.dataobject.seckill.SeckillActivityDO;
-import cn.iocoder.yudao.module.promotion.enums.common.PromotionActivityStatusEnum;
+import cn.iocoder.yudao.module.promotion.enums.common.PromotionProductScopeEnum;
 import cn.iocoder.yudao.module.promotion.enums.common.PromotionTypeEnum;
 import cn.iocoder.yudao.module.promotion.service.bargain.BargainActivityService;
 import cn.iocoder.yudao.module.promotion.service.combination.CombinationActivityService;
@@ -30,7 +33,6 @@ import org.springframework.web.bind.annotation.RestController;
 
 import java.time.LocalDateTime;
 import java.util.*;
-import java.util.stream.Collectors;
 
 import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
 import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*;
@@ -52,6 +54,9 @@ public class AppActivityController {
     @Resource
     private RewardActivityService rewardActivityService;
 
+    @Resource
+    private ProductSpuApi productSpuApi;
+
     @GetMapping("/list-by-spu-id")
     @Operation(summary = "获得单个商品,近期参与的每个活动")
     @Parameter(name = "spuId", description = "商品编号", required = true)
@@ -87,7 +92,7 @@ public class AppActivityController {
         // 4. 限时折扣活动
         getDiscountActivities(spuIds, now, activityList);
         // 5. 满减送活动
-        getRewardActivities(spuIds, now, activityList);
+        getRewardActivityList(spuIds, now, activityList);
         return activityList;
     }
 
@@ -144,28 +149,51 @@ public class AppActivityController {
                 item.getName(), productMap.get(item.getId()), item.getStartTime(), item.getEndTime())));
     }
 
-    private void getRewardActivities(Collection<Long> spuIds, LocalDateTime now, List<AppActivityRespVO> activityList) {
-        // TODO @puhui999:有 3 范围,不只 spuId,还有 categoryId,全部
-        List<RewardActivityDO> rewardActivityList = rewardActivityService.getRewardActivityBySpuIdsAndStatusAndDateTimeLt(
-                spuIds, PromotionActivityStatusEnum.RUN.getStatus(), now);
+    private void getRewardActivityList(Collection<Long> spuIds, LocalDateTime now, List<AppActivityRespVO> activityList) {
+        // 1.1 获得所有的活动
+        List<RewardActivityDO> rewardActivityList = rewardActivityService.getRewardActivityListByStatusAndDateTimeLt(
+                CommonStatusEnum.ENABLE.getStatus(), now);
         if (CollUtil.isEmpty(rewardActivityList)) {
             return;
         }
+        // 1.2 获得所有的商品信息
+        List<ProductSpuRespDTO> spuList = productSpuApi.getSpuList(spuIds);
+        if (CollUtil.isEmpty(spuList)) {
+            return;
+        }
 
-        Map<Long, Optional<RewardActivityDO>> spuIdAndActivityMap = spuIds.stream()
-                .collect(Collectors.toMap(
-                        spuId -> spuId,
-                        spuId -> rewardActivityList.stream()
-                                .filter(activity -> activity.getProductSpuIds().contains(spuId))
-                                .max(Comparator.comparing(RewardActivityDO::getCreateTime))));
-        for (Long supId : spuIdAndActivityMap.keySet()) {
-            if (spuIdAndActivityMap.get(supId).isEmpty()) {
-                continue;
+        // 2. 构建活动
+        for (RewardActivityDO rewardActivity : rewardActivityList) {
+            // 情况一:所有商品都能参加
+            if (PromotionProductScopeEnum.isAll(rewardActivity.getProductScope())) {
+                buildAppActivityRespVO(rewardActivity, spuIds, activityList);
+            }
+            // 情况二:指定商品参加
+            if (PromotionProductScopeEnum.isSpu(rewardActivity.getProductScope())) {
+                List<Long> fSpuIds = spuList.stream().map(ProductSpuRespDTO::getId).filter(id ->
+                        rewardActivity.getProductScopeValues().contains(id)).toList();
+                buildAppActivityRespVO(rewardActivity, fSpuIds, activityList);
             }
+            // 情况三:指定商品类型参加
+            if (PromotionProductScopeEnum.isCategory(rewardActivity.getProductScope())) {
+                List<Long> fSpuIds = spuList.stream().filter(spuItem -> rewardActivity.getProductScopeValues()
+                        .contains(spuItem.getCategoryId())).map(ProductSpuRespDTO::getId).toList();
+                buildAppActivityRespVO(rewardActivity, fSpuIds, activityList);
+            }
+        }
+    }
 
-            RewardActivityDO rewardActivityDO = spuIdAndActivityMap.get(supId).get();
-            activityList.add(new AppActivityRespVO(rewardActivityDO.getId(), PromotionTypeEnum.REWARD_ACTIVITY.getType(),
-                    rewardActivityDO.getName(), supId, rewardActivityDO.getStartTime(), rewardActivityDO.getEndTime()));
+    private static void buildAppActivityRespVO(RewardActivityDO rewardActivity, Collection<Long> spuIds,
+                                               List<AppActivityRespVO> activityList) {
+        for (Long spuId : spuIds) {
+            // 校验商品是否已经加入过活动
+            if (anyMatch(activityList, appActivity -> ObjUtil.equal(appActivity.getId(), rewardActivity.getId()) &&
+                    ObjUtil.equal(appActivity.getSpuId(), spuId))) {
+                continue;
+            }
+            activityList.add(new AppActivityRespVO(rewardActivity.getId(),
+                    PromotionTypeEnum.REWARD_ACTIVITY.getType(), rewardActivity.getName(), spuId,
+                    rewardActivity.getStartTime(), rewardActivity.getEndTime()));
         }
     }
 

+ 0 - 29
yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/convert/reward/RewardActivityConvert.java

@@ -1,29 +0,0 @@
-package cn.iocoder.yudao.module.promotion.convert.reward;
-
-import cn.iocoder.yudao.framework.common.pojo.PageResult;
-import cn.iocoder.yudao.module.promotion.controller.admin.reward.vo.RewardActivityCreateReqVO;
-import cn.iocoder.yudao.module.promotion.controller.admin.reward.vo.RewardActivityRespVO;
-import cn.iocoder.yudao.module.promotion.controller.admin.reward.vo.RewardActivityUpdateReqVO;
-import cn.iocoder.yudao.module.promotion.dal.dataobject.reward.RewardActivityDO;
-import org.mapstruct.Mapper;
-import org.mapstruct.factory.Mappers;
-
-/**
- * 满减送活动 Convert
- *
- * @author 芋道源码
- */
-@Mapper
-public interface RewardActivityConvert {
-
-    RewardActivityConvert INSTANCE = Mappers.getMapper(RewardActivityConvert.class);
-
-    RewardActivityDO convert(RewardActivityCreateReqVO bean);
-
-    RewardActivityDO convert(RewardActivityUpdateReqVO bean);
-
-    RewardActivityRespVO convert(RewardActivityDO bean);
-
-    PageResult<RewardActivityRespVO> convertPage(PageResult<RewardActivityDO> page);
-
-}

+ 0 - 1
yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/dal/dataobject/coupon/CouponDO.java

@@ -50,7 +50,6 @@ public class CouponDO extends BaseDO {
      *
      * 枚举 {@link CouponStatusEnum}
      */
-    // TODO 芋艿:已作废?
     private Integer status;
 
     // TODO 芋艿:发放 adminid?

+ 11 - 9
yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/dal/dataobject/reward/RewardActivityDO.java

@@ -1,8 +1,8 @@
 package cn.iocoder.yudao.module.promotion.dal.dataobject.reward;
 
+import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
 import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
 import cn.iocoder.yudao.framework.mybatis.core.type.LongListTypeHandler;
-import cn.iocoder.yudao.module.promotion.enums.common.PromotionActivityStatusEnum;
 import cn.iocoder.yudao.module.promotion.enums.common.PromotionConditionTypeEnum;
 import cn.iocoder.yudao.module.promotion.enums.common.PromotionProductScopeEnum;
 import com.baomidou.mybatisplus.annotation.KeySequence;
@@ -16,6 +16,7 @@ import lombok.EqualsAndHashCode;
 import java.io.Serializable;
 import java.time.LocalDateTime;
 import java.util.List;
+import java.util.Map;
 
 /**
  * 满减送活动 DO
@@ -40,7 +41,7 @@ public class RewardActivityDO extends BaseDO {
     /**
      * 状态
      *
-     * 枚举 {@link PromotionActivityStatusEnum}
+     * 枚举 {@link CommonStatusEnum}
      */
     private Integer status;
     /**
@@ -71,7 +72,7 @@ public class RewardActivityDO extends BaseDO {
      * 商品 SPU 编号的数组
      */
     @TableField(typeHandler = LongListTypeHandler.class)
-    private List<Long> productSpuIds;
+    private List<Long> productScopeValues;
     /**
      * 优惠规则的数组
      */
@@ -104,13 +105,14 @@ public class RewardActivityDO extends BaseDO {
          */
         private Integer point;
         /**
-         * 赠送的优惠劵编号的数组
-         */
-        private List<Long> couponIds;
-        /**
-         * 赠送的优惠券数量的数组
+         * 赠送的优惠劵
+         *
+         * key: 优惠劵模版编号
+         * value:对应的优惠券数量
+         *
+         * 目的:用于订单支付后赠送优惠券
          */
-        private List<Integer> couponCounts;
+        private Map<Long, Integer> giveCouponTemplateCounts;
 
     }
 

+ 4 - 8
yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/dal/mysql/reward/RewardActivityMapper.java

@@ -30,10 +30,6 @@ public interface RewardActivityMapper extends BaseMapperX<RewardActivityDO> {
                 .orderByDesc(RewardActivityDO::getId));
     }
 
-    default List<RewardActivityDO> selectListByStatus(Collection<Integer> statuses) {
-        return selectList(RewardActivityDO::getStatus, statuses);
-    }
-
     default List<RewardActivityDO> selectListByProductScopeAndStatus(Integer productScope, Integer status) {
         return selectList(new LambdaQueryWrapperX<RewardActivityDO>()
                 .eq(RewardActivityDO::getProductScope, productScope)
@@ -53,16 +49,16 @@ public interface RewardActivityMapper extends BaseMapperX<RewardActivityDO> {
      * 获取指定活动编号的活动列表且
      * 开始时间和结束时间小于给定时间 dateTime 的活动列表
      *
-     * @param ids      活动编号
+     * @param status   状态
      * @param dateTime 指定日期
      * @return 活动列表
      */
-    default List<RewardActivityDO> selectListByIdsAndDateTimeLt(Collection<Long> ids, LocalDateTime dateTime) {
+    default List<RewardActivityDO> selectListByStatusAndDateTimeLt(Integer status, LocalDateTime dateTime) {
         return selectList(new LambdaQueryWrapperX<RewardActivityDO>()
-                .in(RewardActivityDO::getId, ids)
+                .eq(RewardActivityDO::getStatus, status)
                 .lt(RewardActivityDO::getStartTime, dateTime)
                 .gt(RewardActivityDO::getEndTime, dateTime)// 开始时间 < 指定时间 < 结束时间,也就是说获取指定时间段的活动
-                .orderByDesc(RewardActivityDO::getCreateTime)
+                .orderByAsc(RewardActivityDO::getStartTime)
         );
     }
 

+ 7 - 2
yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/service/combination/CombinationRecordServiceImpl.java

@@ -27,6 +27,7 @@ import cn.iocoder.yudao.module.promotion.enums.combination.CombinationRecordStat
 import cn.iocoder.yudao.module.system.api.social.SocialClientApi;
 import cn.iocoder.yudao.module.system.api.social.dto.SocialWxaSubscribeMessageSendReqDTO;
 import cn.iocoder.yudao.module.trade.api.order.TradeOrderApi;
+import cn.iocoder.yudao.module.trade.enums.order.TradeOrderCancelTypeEnum;
 import jakarta.annotation.Nullable;
 import jakarta.annotation.Resource;
 import lombok.extern.slf4j.Slf4j;
@@ -37,7 +38,10 @@ import org.springframework.transaction.annotation.Transactional;
 import org.springframework.validation.annotation.Validated;
 
 import java.time.LocalDateTime;
-import java.util.*;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
 
 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
 import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*;
@@ -335,7 +339,8 @@ public class CombinationRecordServiceImpl implements CombinationRecordService {
         List<CombinationRecordDO> headAndRecords = updateBatchCombinationRecords(headRecord,
                 CombinationRecordStatusEnum.FAILED);
         // 2. 订单取消
-        headAndRecords.forEach(item -> tradeOrderApi.cancelPaidOrder(item.getUserId(), item.getOrderId()));
+        headAndRecords.forEach(item -> tradeOrderApi.cancelPaidOrder(item.getUserId(), item.getOrderId(),
+                TradeOrderCancelTypeEnum.COMBINATION_CLOSE.getType()));
     }
 
     /**

+ 64 - 43
yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/service/coupon/CouponService.java

@@ -38,14 +38,6 @@ public interface CouponService {
      */
     void validCoupon(CouponDO coupon);
 
-    /**
-     * 获得优惠劵分页
-     *
-     * @param pageReqVO 分页查询
-     * @return 优惠劵分页
-     */
-    PageResult<CouponDO> getCouponPage(CouponPageReqVO pageReqVO);
-
     /**
      * 使用优惠劵
      *
@@ -69,42 +61,44 @@ public interface CouponService {
      */
     void deleteCoupon(Long id);
 
-    /**
-     * 获得用户的优惠劵列表
-     *
-     * @param userId 用户编号
-     * @param status 优惠劵状态
-     * @return 优惠劵列表
-     */
-    List<CouponDO> getCouponList(Long userId, Integer status);
-
-    /**
-     * 获得未使用的优惠劵数量
-     *
-     * @param userId 用户编号
-     * @return 未使用的优惠劵数量
-     */
-    Long getUnusedCouponCount(Long userId);
-
     /**
      * 领取优惠券
      *
      * @param templateId 优惠券模板编号
      * @param userIds    用户编号列表
      * @param takeType   领取方式
+     * @return key: userId, value: 优惠券编号列表
      */
-    void takeCoupon(Long templateId, Set<Long> userIds, CouponTakeTypeEnum takeType);
+    Map<Long, List<Long>> takeCoupon(Long templateId, Set<Long> userIds, CouponTakeTypeEnum takeType);
 
     /**
      * 【管理员】给用户发送优惠券
      *
      * @param templateId 优惠券模板编号
      * @param userIds    用户编号列表
+     * @return key: userId, value: 优惠券编号列表
      */
-    default void takeCouponByAdmin(Long templateId, Set<Long> userIds) {
-        takeCoupon(templateId, userIds, CouponTakeTypeEnum.ADMIN);
+    default Map<Long, List<Long>> takeCouponByAdmin(Long templateId, Set<Long> userIds) {
+        return takeCoupon(templateId, userIds, CouponTakeTypeEnum.ADMIN);
     }
 
+    /**
+     * 【管理员】给指定用户批量发送优惠券
+     *
+     * @param giveCoupons  key: 优惠劵模版编号,value:对应的数量
+     * @param userId      用户编号
+     * @return 优惠券编号列表
+     */
+    List<Long> takeCouponsByAdmin(Map<Long, Integer> giveCoupons, Long userId);
+
+    /**
+     * 【管理员】作废指定用户的指定优惠劵
+     *
+     * @param giveCouponIds  赠送的优惠券编号
+     * @param userId         用户编号
+     */
+    void invalidateCouponsByAdmin(List<Long> giveCouponIds, Long userId);
+
     /**
      * 【会员】领取优惠券
      *
@@ -123,16 +117,38 @@ public interface CouponService {
     void takeCouponByRegister(Long userId);
 
     /**
-     * 获取会员领取指定优惠券的数量
+     * 过期优惠券
      *
-     * @param templateId 优惠券模板编号
-     * @param userId     用户编号
-     * @return 领取优惠券的数量
+     * @return 过期数量
      */
-    default Integer getTakeCount(Long templateId, Long userId) {
-        Map<Long, Integer> map = getTakeCountMapByTemplateIds(Collections.singleton(templateId), userId);
-        return MapUtil.getInt(map, templateId, 0);
-    }
+    int expireCoupon();
+
+    // ======================= 查询相关 =======================
+
+    /**
+     * 获得未使用的优惠劵数量
+     *
+     * @param userId 用户编号
+     * @return 未使用的优惠劵数量
+     */
+    Long getUnusedCouponCount(Long userId);
+
+    /**
+     * 获得优惠劵分页
+     *
+     * @param pageReqVO 分页查询
+     * @return 优惠劵分页
+     */
+    PageResult<CouponDO> getCouponPage(CouponPageReqVO pageReqVO);
+
+    /**
+     * 获得用户的优惠劵列表
+     *
+     * @param userId 用户编号
+     * @param status 优惠劵状态
+     * @return 优惠劵列表
+     */
+    List<CouponDO> getCouponList(Long userId, Integer status);
 
     /**
      * 统计会员领取优惠券的数量
@@ -144,20 +160,25 @@ public interface CouponService {
     Map<Long, Integer> getTakeCountMapByTemplateIds(Collection<Long> templateIds, Long userId);
 
     /**
-     * 获取用户匹配的优惠券列表
+     * 获取会员领取指定优惠券的数量
      *
+     * @param templateId 优惠券模板编号
      * @param userId     用户编号
-     * @param matchReqVO 匹配参数
-     * @return 优惠券列表
+     * @return 领取优惠券的数量
      */
-    List<CouponDO> getMatchCouponList(Long userId, AppCouponMatchReqVO matchReqVO);
+    default Integer getTakeCount(Long templateId, Long userId) {
+        Map<Long, Integer> map = getTakeCountMapByTemplateIds(Collections.singleton(templateId), userId);
+        return MapUtil.getInt(map, templateId, 0);
+    }
 
     /**
-     * 过期优惠券
+     * 获取用户匹配的优惠券列表
      *
-     * @return 过期数量
+     * @param userId     用户编号
+     * @param matchReqVO 匹配参数
+     * @return 优惠券列表
      */
-    int expireCoupon();
+    List<CouponDO> getMatchCouponList(Long userId, AppCouponMatchReqVO matchReqVO);
 
     /**
      * 获取用户是否可以领取优惠券

+ 146 - 74
yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/service/coupon/CouponServiceImpl.java

@@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.promotion.service.coupon;
 import cn.hutool.core.collection.CollStreamUtil;
 import cn.hutool.core.collection.CollUtil;
 import cn.hutool.core.map.MapUtil;
+import cn.hutool.core.util.ObjUtil;
 import cn.hutool.core.util.ObjectUtil;
 import cn.hutool.core.util.StrUtil;
 import cn.hutool.extra.spring.SpringUtil;
@@ -19,19 +20,19 @@ import cn.iocoder.yudao.module.promotion.dal.mysql.coupon.CouponMapper;
 import cn.iocoder.yudao.module.promotion.enums.coupon.CouponStatusEnum;
 import cn.iocoder.yudao.module.promotion.enums.coupon.CouponTakeTypeEnum;
 import cn.iocoder.yudao.module.promotion.enums.coupon.CouponTemplateValidityTypeEnum;
+import jakarta.annotation.Resource;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.validation.annotation.Validated;
 
-import jakarta.annotation.Resource;
-
 import java.time.LocalDateTime;
 import java.util.*;
 import java.util.stream.Collectors;
 
 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
 import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*;
+import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.findAndThen;
 import static cn.iocoder.yudao.module.promotion.enums.ErrorCodeConstants.*;
 import static java.util.Arrays.asList;
 
@@ -76,20 +77,6 @@ public class CouponServiceImpl implements CouponService {
         }
     }
 
-    @Override
-    public PageResult<CouponDO> getCouponPage(CouponPageReqVO pageReqVO) {
-        // 获得用户编号
-        if (StrUtil.isNotEmpty(pageReqVO.getNickname())) {
-            List<MemberUserRespDTO> users = memberUserApi.getUserListByNickname(pageReqVO.getNickname());
-            if (CollUtil.isEmpty(users)) {
-                return PageResult.empty();
-            }
-            pageReqVO.setUserIds(convertSet(users, MemberUserRespDTO::getId));
-        }
-        // 分页查询
-        return couponMapper.selectPage(pageReqVO);
-    }
-
     @Override
     public void useCoupon(Long id, Long userId, Long orderId) {
         // 校验优惠劵
@@ -147,25 +134,8 @@ public class CouponServiceImpl implements CouponService {
     }
 
     @Override
-    public List<CouponDO> getCouponList(Long userId, Integer status) {
-        return couponMapper.selectListByUserIdAndStatus(userId, status);
-    }
-
-    private CouponDO validateCouponExists(Long id) {
-        CouponDO coupon = couponMapper.selectById(id);
-        if (coupon == null) {
-            throw exception(COUPON_NOT_EXISTS);
-        }
-        return coupon;
-    }
-
-    @Override
-    public Long getUnusedCouponCount(Long userId) {
-        return couponMapper.selectCountByUserIdAndStatus(userId, CouponStatusEnum.UNUSED.getStatus());
-    }
-
-    @Override
-    public void takeCoupon(Long templateId, Set<Long> userIds, CouponTakeTypeEnum takeType) {
+    @Transactional(rollbackFor = Exception.class)
+    public Map<Long, List<Long>> takeCoupon(Long templateId, Set<Long> userIds, CouponTakeTypeEnum takeType) {
         CouponTemplateDO template = couponTemplateService.getCouponTemplate(templateId);
         // 1. 过滤掉达到领取限制的用户
         removeTakeLimitUser(userIds, template);
@@ -173,37 +143,86 @@ public class CouponServiceImpl implements CouponService {
         validateCouponTemplateCanTake(template, userIds, takeType);
 
         // 3. 批量保存优惠劵
-        couponMapper.insertBatch(convertList(userIds, userId -> CouponConvert.INSTANCE.convert(template, userId)));
+        List<CouponDO> couponList = convertList(userIds, userId -> CouponConvert.INSTANCE.convert(template, userId));
+        couponMapper.insertBatch(couponList);
 
-        // 3. 增加优惠劵模板的领取数量
+        // 4. 增加优惠劵模板的领取数量
         couponTemplateService.updateCouponTemplateTakeCount(templateId, userIds.size());
+
+        return convertMultiMap(couponList, CouponDO::getUserId, CouponDO::getId);
     }
 
     @Override
-    @Transactional(rollbackFor = Exception.class)
-    public void takeCouponByRegister(Long userId) {
-        List<CouponTemplateDO> templates = couponTemplateService.getCouponTemplateListByTakeType(CouponTakeTypeEnum.REGISTER);
-        for (CouponTemplateDO template : templates) {
-            takeCoupon(template.getId(), CollUtil.newHashSet(userId), CouponTakeTypeEnum.REGISTER);
+    public List<Long> takeCouponsByAdmin(Map<Long, Integer> giveCoupons, Long userId) {
+        if (CollUtil.isEmpty(giveCoupons)) {
+            return Collections.emptyList();
         }
+
+        List<Long> couponIds = new ArrayList<>();
+        // 循环发放
+        for (Map.Entry<Long, Integer> entry : giveCoupons.entrySet()) {
+            try {
+                for (int i = 0; i < entry.getValue(); i++) {
+                    Map<Long, List<Long>> userCouponIdsMap = getSelf().takeCoupon(entry.getKey(), CollUtil.newHashSet(userId),
+                            CouponTakeTypeEnum.ADMIN);
+                    findAndThen(userCouponIdsMap, userId, couponIds::addAll);
+                }
+            } catch (Exception e) {
+                log.error("[takeCouponsByAdmin][coupon({}) 优惠券发放失败]", entry, e);
+            }
+        }
+        return couponIds;
     }
 
     @Override
-    public Map<Long, Integer> getTakeCountMapByTemplateIds(Collection<Long> templateIds, Long userId) {
-        if (CollUtil.isEmpty(templateIds)) {
-            return Collections.emptyMap();
+    public void invalidateCouponsByAdmin(List<Long> giveCouponIds, Long userId) {
+        // 循环收回
+        for (Long couponId : giveCouponIds) {
+            try {
+                getSelf().invalidateCoupon(couponId, userId);
+            } catch (Exception e) {
+                log.error("[invalidateCouponsByAdmin][couponId({}) 收回优惠券失败]", couponId, e);
+            }
         }
-        return couponMapper.selectCountByUserIdAndTemplateIdIn(userId, templateIds);
+    }
+
+    /**
+     * 【管理员】收回优惠券
+     *
+     * @param couponId 模版编号
+     * @param userId   用户编号
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public void invalidateCoupon(Long couponId, Long userId) {
+        // 1.1 校验优惠券
+        CouponDO coupon = couponMapper.selectByIdAndUserId(couponId, userId);
+        if (coupon == null) {
+            throw exception(COUPON_NOT_EXISTS);
+        }
+        // 1.2 校验模板
+        CouponTemplateDO couponTemplate = couponTemplateService.getCouponTemplate(coupon.getTemplateId());
+        if (couponTemplate == null) {
+            throw exception(COUPON_TEMPLATE_NOT_EXISTS);
+        }
+        // 1.3 校验优惠券是否已经使用,如若使用则先不管
+        if (ObjUtil.equal(coupon.getStatus(), CouponStatusEnum.USED.getStatus())) {
+            log.info("[invalidateCoupon][coupon({}) 已经使用,无法作废]", couponId);
+            return;
+        }
+
+        // 2.1 减少优惠劵模板的领取数量
+        couponTemplateService.updateCouponTemplateTakeCount(couponTemplate.getId(), -1);
+        // 2.2 作废优惠劵
+        couponMapper.deleteById(couponId);
     }
 
     @Override
-    public List<CouponDO> getMatchCouponList(Long userId, AppCouponMatchReqVO matchReqVO) {
-        List<CouponDO> list = couponMapper.selectListByUserIdAndStatusAndUsePriceLeAndProductScope(userId,
-                CouponStatusEnum.UNUSED.getStatus(),
-                matchReqVO.getPrice(), matchReqVO.getSpuIds(), matchReqVO.getCategoryIds());
-        // 兜底逻辑:如果 CouponExpireJob 未执行,status 未变成 EXPIRE ,但是 validEndTime 已经过期了,需要进行过滤
-        list.removeIf(coupon -> !LocalDateTimeUtils.isBetween(coupon.getValidStartTime(), coupon.getValidEndTime()));
-        return list;
+    @Transactional(rollbackFor = Exception.class)
+    public void takeCouponByRegister(Long userId) {
+        List<CouponTemplateDO> templates = couponTemplateService.getCouponTemplateListByTakeType(CouponTakeTypeEnum.REGISTER);
+        for (CouponTemplateDO template : templates) {
+            takeCoupon(template.getId(), CollUtil.newHashSet(userId), CouponTakeTypeEnum.REGISTER);
+        }
     }
 
     @Override
@@ -230,27 +249,6 @@ public class CouponServiceImpl implements CouponService {
         return count;
     }
 
-    @Override
-    public Map<Long, Boolean> getUserCanCanTakeMap(Long userId, List<CouponTemplateDO> templates) {
-        // 1. 未登录时,都显示可以领取
-        Map<Long, Boolean> userCanTakeMap = convertMap(templates, CouponTemplateDO::getId, templateId -> true);
-        if (userId == null) {
-            return userCanTakeMap;
-        }
-
-        // 2.1 过滤领取数量无限制的
-        Set<Long> templateIds = convertSet(templates, CouponTemplateDO::getId, template -> template.getTakeLimitCount() != -1);
-        // 2.2 检查用户领取的数量是否超过限制
-        if (CollUtil.isNotEmpty(templateIds)) {
-            Map<Long, Integer> couponTakeCountMap = this.getTakeCountMapByTemplateIds(templateIds, userId);
-            for (CouponTemplateDO template : templates) {
-                Integer takeCount = couponTakeCountMap.get(template.getId());
-                userCanTakeMap.put(template.getId(), takeCount == null || takeCount < template.getTakeLimitCount());
-            }
-        }
-        return userCanTakeMap;
-    }
-
     /**
      * 过期单个优惠劵
      *
@@ -322,11 +320,84 @@ public class CouponServiceImpl implements CouponService {
         userIds.removeIf(userId -> MapUtil.getInt(userTakeCountMap, userId, 0) >= couponTemplate.getTakeLimitCount());
     }
 
+    //======================= 查询相关 =======================
+
+    @Override
+    public Long getUnusedCouponCount(Long userId) {
+        return couponMapper.selectCountByUserIdAndStatus(userId, CouponStatusEnum.UNUSED.getStatus());
+    }
+
+    @Override
+    public PageResult<CouponDO> getCouponPage(CouponPageReqVO pageReqVO) {
+        // 获得用户编号
+        if (StrUtil.isNotEmpty(pageReqVO.getNickname())) {
+            List<MemberUserRespDTO> users = memberUserApi.getUserListByNickname(pageReqVO.getNickname());
+            if (CollUtil.isEmpty(users)) {
+                return PageResult.empty();
+            }
+            pageReqVO.setUserIds(convertSet(users, MemberUserRespDTO::getId));
+        }
+        // 分页查询
+        return couponMapper.selectPage(pageReqVO);
+    }
+
+    @Override
+    public List<CouponDO> getCouponList(Long userId, Integer status) {
+        return couponMapper.selectListByUserIdAndStatus(userId, status);
+    }
+
+    @Override
+    public Map<Long, Integer> getTakeCountMapByTemplateIds(Collection<Long> templateIds, Long userId) {
+        if (CollUtil.isEmpty(templateIds)) {
+            return Collections.emptyMap();
+        }
+        return couponMapper.selectCountByUserIdAndTemplateIdIn(userId, templateIds);
+    }
+
+    @Override
+    public List<CouponDO> getMatchCouponList(Long userId, AppCouponMatchReqVO matchReqVO) {
+        List<CouponDO> list = couponMapper.selectListByUserIdAndStatusAndUsePriceLeAndProductScope(userId,
+                CouponStatusEnum.UNUSED.getStatus(),
+                matchReqVO.getPrice(), matchReqVO.getSpuIds(), matchReqVO.getCategoryIds());
+        // 兜底逻辑:如果 CouponExpireJob 未执行,status 未变成 EXPIRE ,但是 validEndTime 已经过期了,需要进行过滤
+        list.removeIf(coupon -> !LocalDateTimeUtils.isBetween(coupon.getValidStartTime(), coupon.getValidEndTime()));
+        return list;
+    }
+
+    @Override
+    public Map<Long, Boolean> getUserCanCanTakeMap(Long userId, List<CouponTemplateDO> templates) {
+        // 1. 未登录时,都显示可以领取
+        Map<Long, Boolean> userCanTakeMap = convertMap(templates, CouponTemplateDO::getId, templateId -> true);
+        if (userId == null) {
+            return userCanTakeMap;
+        }
+
+        // 2.1 过滤领取数量无限制的
+        Set<Long> templateIds = convertSet(templates, CouponTemplateDO::getId, template -> template.getTakeLimitCount() != -1);
+        // 2.2 检查用户领取的数量是否超过限制
+        if (CollUtil.isNotEmpty(templateIds)) {
+            Map<Long, Integer> couponTakeCountMap = this.getTakeCountMapByTemplateIds(templateIds, userId);
+            for (CouponTemplateDO template : templates) {
+                Integer takeCount = couponTakeCountMap.get(template.getId());
+                userCanTakeMap.put(template.getId(), takeCount == null || takeCount < template.getTakeLimitCount());
+            }
+        }
+        return userCanTakeMap;
+    }
+
     @Override
     public CouponDO getCoupon(Long userId, Long id) {
         return couponMapper.selectByIdAndUserId(id, userId);
     }
 
+    private CouponDO validateCouponExists(Long id) {
+        CouponDO coupon = couponMapper.selectById(id);
+        if (coupon == null) {
+            throw exception(COUPON_NOT_EXISTS);
+        }
+        return coupon;
+    }
+
     /**
      * 获得自身的代理对象,解决 AOP 生效问题
      *
@@ -335,4 +406,5 @@ public class CouponServiceImpl implements CouponService {
     private CouponServiceImpl getSelf() {
         return SpringUtil.getBean(getClass());
     }
+
 }

+ 4 - 1
yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/service/discount/DiscountActivityServiceImpl.java

@@ -104,7 +104,10 @@ public class DiscountActivityServiceImpl implements DiscountActivityService {
         }
         // 计算新增的记录
         List<DiscountProductDO> newDiscountProducts = convertList(updateReqVO.getProducts(),
-                product -> DiscountActivityConvert.INSTANCE.convert(product).setActivityId(updateReqVO.getId()));
+                product -> DiscountActivityConvert.INSTANCE.convert(product)
+                        .setActivityId(updateReqVO.getId())
+                        .setActivityStartTime(updateReqVO.getStartTime())
+                        .setActivityEndTime(updateReqVO.getEndTime()));
         newDiscountProducts.removeIf(product -> dbDiscountProducts.stream().anyMatch(
                 dbProduct -> DiscountActivityConvert.INSTANCE.isEquals(dbProduct, product))); // 如果匹配到,说明是更新的
         if (CollectionUtil.isNotEmpty(newDiscountProducts)) {

+ 1 - 2
yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/service/reward/RewardActivityService.java

@@ -75,11 +75,10 @@ public interface RewardActivityService {
     /**
      * 获取指定 spu 编号最近参加的活动,每个 spuId 只返回一条记录
      *
-     * @param spuIds   spu 编号
      * @param status   状态
      * @param dateTime 当前日期时间
      * @return 满减送活动列表
      */
-    List<RewardActivityDO> getRewardActivityBySpuIdsAndStatusAndDateTimeLt(Collection<Long> spuIds, Integer status, LocalDateTime dateTime);
+    List<RewardActivityDO> getRewardActivityListByStatusAndDateTimeLt(Integer status, LocalDateTime dateTime);
 
 }

+ 59 - 71
yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/service/reward/RewardActivityServiceImpl.java

@@ -1,15 +1,18 @@
 package cn.iocoder.yudao.module.promotion.service.reward;
 
-import cn.hutool.core.collection.CollUtil;
+import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
 import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
+import cn.iocoder.yudao.module.product.api.category.ProductCategoryApi;
+import cn.iocoder.yudao.module.product.api.spu.ProductSpuApi;
 import cn.iocoder.yudao.module.promotion.api.reward.dto.RewardActivityMatchRespDTO;
+import cn.iocoder.yudao.module.promotion.controller.admin.reward.vo.RewardActivityBaseVO;
 import cn.iocoder.yudao.module.promotion.controller.admin.reward.vo.RewardActivityCreateReqVO;
 import cn.iocoder.yudao.module.promotion.controller.admin.reward.vo.RewardActivityPageReqVO;
 import cn.iocoder.yudao.module.promotion.controller.admin.reward.vo.RewardActivityUpdateReqVO;
-import cn.iocoder.yudao.module.promotion.convert.reward.RewardActivityConvert;
 import cn.iocoder.yudao.module.promotion.dal.dataobject.reward.RewardActivityDO;
 import cn.iocoder.yudao.module.promotion.dal.mysql.reward.RewardActivityMapper;
-import cn.iocoder.yudao.module.promotion.enums.common.PromotionActivityStatusEnum;
+import cn.iocoder.yudao.module.promotion.enums.common.PromotionProductScopeEnum;
 import cn.iocoder.yudao.module.promotion.util.PromotionUtils;
 import jakarta.annotation.Resource;
 import org.springframework.stereotype.Service;
@@ -17,13 +20,13 @@ import org.springframework.validation.annotation.Validated;
 
 import java.time.LocalDateTime;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.List;
+import java.util.Objects;
 
+import static cn.hutool.core.collection.CollUtil.intersectionDistinct;
 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
-import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
+import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.anyMatch;
 import static cn.iocoder.yudao.module.promotion.enums.ErrorCodeConstants.*;
-import static java.util.Arrays.asList;
 
 /**
  * 满减送活动 Service 实现类
@@ -37,13 +40,20 @@ public class RewardActivityServiceImpl implements RewardActivityService {
     @Resource
     private RewardActivityMapper rewardActivityMapper;
 
+    @Resource
+    private ProductCategoryApi productCategoryApi;
+    @Resource
+    private ProductSpuApi productSpuApi;
+
     @Override
     public Long createRewardActivity(RewardActivityCreateReqVO createReqVO) {
-        // 校验商品是否冲突
-        validateRewardActivitySpuConflicts(null, createReqVO.getProductSpuIds());
+        // 1.1 校验商品范围
+        validateProductScope(createReqVO.getProductScope(), createReqVO.getProductScopeValues());
+        // 1.2 校验商品是否冲突
+        validateRewardActivitySpuConflicts(null, createReqVO);
 
-        // 插入
-        RewardActivityDO rewardActivity = RewardActivityConvert.INSTANCE.convert(createReqVO)
+        // 2. 插入
+        RewardActivityDO rewardActivity = BeanUtils.toBean(createReqVO, RewardActivityDO.class)
                 .setStatus(PromotionUtils.calculateActivityStatus(createReqVO.getEndTime()));
         rewardActivityMapper.insert(rewardActivity);
         // 返回
@@ -52,16 +62,18 @@ public class RewardActivityServiceImpl implements RewardActivityService {
 
     @Override
     public void updateRewardActivity(RewardActivityUpdateReqVO updateReqVO) {
-        // 校验存在
+        // 1.1 校验存在
         RewardActivityDO dbRewardActivity = validateRewardActivityExists(updateReqVO.getId());
-        if (dbRewardActivity.getStatus().equals(PromotionActivityStatusEnum.CLOSE.getStatus())) { // 已关闭的活动,不能修改噢
+        if (dbRewardActivity.getStatus().equals(CommonStatusEnum.DISABLE.getStatus())) { // 已关闭的活动,不能修改噢
             throw exception(REWARD_ACTIVITY_UPDATE_FAIL_STATUS_CLOSED);
         }
-        // 校验商品是否冲突
-        validateRewardActivitySpuConflicts(updateReqVO.getId(), updateReqVO.getProductSpuIds());
+        // 1.2 校验商品范围
+        validateProductScope(updateReqVO.getProductScope(), updateReqVO.getProductScopeValues());
+        // 1.3 校验商品是否冲突
+        validateRewardActivitySpuConflicts(updateReqVO.getId(), updateReqVO);
 
-        // 更新
-        RewardActivityDO updateObj = RewardActivityConvert.INSTANCE.convert(updateReqVO)
+        // 2. 更新
+        RewardActivityDO updateObj = BeanUtils.toBean(updateReqVO, RewardActivityDO.class)
                 .setStatus(PromotionUtils.calculateActivityStatus(updateReqVO.getEndTime()));
         rewardActivityMapper.updateById(updateObj);
     }
@@ -70,15 +82,12 @@ public class RewardActivityServiceImpl implements RewardActivityService {
     public void closeRewardActivity(Long id) {
         // 校验存在
         RewardActivityDO dbRewardActivity = validateRewardActivityExists(id);
-        if (dbRewardActivity.getStatus().equals(PromotionActivityStatusEnum.CLOSE.getStatus())) { // 已关闭的活动,不能关闭噢
+        if (dbRewardActivity.getStatus().equals(CommonStatusEnum.DISABLE.getStatus())) { // 已关闭的活动,不能关闭噢
             throw exception(REWARD_ACTIVITY_CLOSE_FAIL_STATUS_CLOSED);
         }
-        if (dbRewardActivity.getStatus().equals(PromotionActivityStatusEnum.END.getStatus())) { // 已关闭的活动,不能关闭噢
-            throw exception(REWARD_ACTIVITY_CLOSE_FAIL_STATUS_END);
-        }
 
         // 更新
-        RewardActivityDO updateObj = new RewardActivityDO().setId(id).setStatus(PromotionActivityStatusEnum.CLOSE.getStatus());
+        RewardActivityDO updateObj = new RewardActivityDO().setId(id).setStatus(CommonStatusEnum.DISABLE.getStatus());
         rewardActivityMapper.updateById(updateObj);
     }
 
@@ -86,7 +95,7 @@ public class RewardActivityServiceImpl implements RewardActivityService {
     public void deleteRewardActivity(Long id) {
         // 校验存在
         RewardActivityDO dbRewardActivity = validateRewardActivityExists(id);
-        if (!dbRewardActivity.getStatus().equals(PromotionActivityStatusEnum.CLOSE.getStatus())) { // 未关闭的活动,不能删除噢
+        if (dbRewardActivity.getStatus().equals(CommonStatusEnum.ENABLE.getStatus())) { // 未关闭的活动,不能删除噢
             throw exception(REWARD_ACTIVITY_DELETE_FAIL_STATUS_NOT_CLOSED);
         }
 
@@ -102,41 +111,39 @@ public class RewardActivityServiceImpl implements RewardActivityService {
         return activity;
     }
 
-    // TODO @芋艿:逻辑有问题,需要优化;要分成全场、和指定来校验;
-
     /**
      * 校验商品参加的活动是否冲突
      *
-     * @param id     活动编号
-     * @param spuIds 商品 SPU 编号数组
+     * @param id             活动编号
+     * @param rewardActivity 请求
      */
-    private void validateRewardActivitySpuConflicts(Long id, Collection<Long> spuIds) {
-        if (CollUtil.isEmpty(spuIds)) {
-            return;
-        }
-        // 查询商品参加的活动
-        List<RewardActivityDO> rewardActivityList = getRewardActivityListBySpuIds(spuIds,
-                asList(PromotionActivityStatusEnum.WAIT.getStatus(), PromotionActivityStatusEnum.RUN.getStatus()));
+    private void validateRewardActivitySpuConflicts(Long id, RewardActivityBaseVO rewardActivity) {
+        List<RewardActivityDO> list = rewardActivityMapper.selectList(RewardActivityDO::getProductScope,
+                rewardActivity.getProductScope(), RewardActivityDO::getStatus, CommonStatusEnum.ENABLE.getStatus());
         if (id != null) { // 排除自己这个活动
-            rewardActivityList.removeIf(activity -> id.equals(activity.getId()));
+            list.removeIf(activity -> id.equals(activity.getId()));
         }
-        // 如果非空,则说明冲突
-        if (CollUtil.isNotEmpty(rewardActivityList)) {
-            throw exception(REWARD_ACTIVITY_SPU_CONFLICTS);
+
+        // 情况一:全部商品参加
+        if (PromotionProductScopeEnum.isAll(rewardActivity.getProductScope()) && !list.isEmpty()) {
+            throw exception(REWARD_ACTIVITY_SCOPE_ALL_EXISTS);
+        }
+        if (PromotionProductScopeEnum.isSpu(rewardActivity.getProductScope()) ||  // 情况二:指定商品参加
+                PromotionProductScopeEnum.isCategory(rewardActivity.getProductScope())) {  // 情况三:指定商品类型参加
+            if (anyMatch(list, item -> !intersectionDistinct(item.getProductScopeValues(),
+                    rewardActivity.getProductScopeValues()).isEmpty())) {
+                throw exception(PromotionProductScopeEnum.isSpu(rewardActivity.getProductScope()) ?
+                        REWARD_ACTIVITY_SPU_CONFLICTS : REWARD_ACTIVITY_SCOPE_CATEGORY_EXISTS);
+            }
         }
     }
 
-    /**
-     * 获得商品参加的满减送活动的数组
-     *
-     * @param spuIds   商品 SPU 编号数组
-     * @param statuses 活动状态数组
-     * @return 商品参加的满减送活动的数组
-     */
-    private List<RewardActivityDO> getRewardActivityListBySpuIds(Collection<Long> spuIds,
-                                                                 Collection<Integer> statuses) {
-        List<RewardActivityDO> list = rewardActivityMapper.selectListByStatus(statuses);
-        return CollUtil.filter(list, activity -> CollUtil.containsAny(activity.getProductSpuIds(), spuIds));
+    private void validateProductScope(Integer productScope, List<Long> productScopeValues) {
+        if (Objects.equals(PromotionProductScopeEnum.SPU.getScope(), productScope)) {
+            productSpuApi.validateSpuList(productScopeValues);
+        } else if (Objects.equals(PromotionProductScopeEnum.CATEGORY.getScope(), productScope)) {
+            productCategoryApi.validateCategoryList(productScopeValues);
+        }
     }
 
     @Override
@@ -151,32 +158,13 @@ public class RewardActivityServiceImpl implements RewardActivityService {
 
     @Override
     public List<RewardActivityMatchRespDTO> getMatchRewardActivityList(Collection<Long> spuIds) {
-        // TODO 芋艿:待实现;先指定,然后再全局的;
-//        // 如果有全局活动,则直接选择它
-//        List<RewardActivityDO> allActivities = rewardActivityMapper.selectListByProductScopeAndStatus(
-//                PromotionProductScopeEnum.ALL.getScope(), PromotionActivityStatusEnum.RUN.getStatus());
-//        if (CollUtil.isNotEmpty(allActivities)) {
-//            return MapUtil.builder(allActivities.get(0), spuIds).build();
-//        }
-//
-//        // 查询某个活动参加的活动
-//        List<RewardActivityDO> productActivityList = getRewardActivityListBySpuIds(spuIds,
-//                singleton(PromotionActivityStatusEnum.RUN.getStatus()));
-//        return convertMap(productActivityList, activity -> activity,
-//                rewardActivityDO -> intersectionDistinct(rewardActivityDO.getProductSpuIds(), spuIds)); // 求交集返回
-        return null;
+        List<RewardActivityDO> list = rewardActivityMapper.selectListBySpuIdsAndStatus(spuIds, CommonStatusEnum.ENABLE.getStatus());
+        return BeanUtils.toBean(list, RewardActivityMatchRespDTO.class);
     }
 
     @Override
-    public List<RewardActivityDO> getRewardActivityBySpuIdsAndStatusAndDateTimeLt(Collection<Long> spuIds, Integer status, LocalDateTime dateTime) {
-        // 1. 查询出指定 spuId 的 spu 参加的活动
-        List<RewardActivityDO> rewardActivityList = rewardActivityMapper.selectListBySpuIdsAndStatus(spuIds, status);
-        if (CollUtil.isEmpty(rewardActivityList)) {
-            return Collections.emptyList();
-        }
-
-        // 2. 查询活动详情
-        return rewardActivityMapper.selectListByIdsAndDateTimeLt(convertSet(rewardActivityList, RewardActivityDO::getId), dateTime);
+    public List<RewardActivityDO> getRewardActivityListByStatusAndDateTimeLt(Integer status, LocalDateTime dateTime) {
+        return rewardActivityMapper.selectListByStatusAndDateTimeLt(status, dateTime);
     }
 
 }

+ 68 - 61
yudao-module-mall/yudao-module-promotion-biz/src/test/java/cn/iocoder/yudao/module/promotion/service/reward/RewardActivityServiceImplTest.java

@@ -1,21 +1,23 @@
 package cn.iocoder.yudao.module.promotion.service.reward;
 
+import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
 import cn.iocoder.yudao.framework.common.pojo.PageResult;
 import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
+import cn.iocoder.yudao.module.promotion.api.reward.dto.RewardActivityMatchRespDTO;
 import cn.iocoder.yudao.module.promotion.controller.admin.reward.vo.RewardActivityCreateReqVO;
 import cn.iocoder.yudao.module.promotion.controller.admin.reward.vo.RewardActivityPageReqVO;
 import cn.iocoder.yudao.module.promotion.controller.admin.reward.vo.RewardActivityUpdateReqVO;
 import cn.iocoder.yudao.module.promotion.dal.dataobject.reward.RewardActivityDO;
 import cn.iocoder.yudao.module.promotion.dal.mysql.reward.RewardActivityMapper;
-import cn.iocoder.yudao.module.promotion.enums.common.PromotionActivityStatusEnum;
 import cn.iocoder.yudao.module.promotion.enums.common.PromotionConditionTypeEnum;
 import cn.iocoder.yudao.module.promotion.enums.common.PromotionProductScopeEnum;
+import jakarta.annotation.Resource;
 import org.junit.jupiter.api.Disabled;
 import org.junit.jupiter.api.Test;
 import org.springframework.context.annotation.Import;
 
-import jakarta.annotation.Resource;
 import java.time.Duration;
+import java.util.List;
 import java.util.Set;
 
 import static cn.hutool.core.util.RandomUtil.randomEle;
@@ -27,15 +29,15 @@ import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServic
 import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId;
 import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo;
 import static cn.iocoder.yudao.module.promotion.enums.ErrorCodeConstants.REWARD_ACTIVITY_NOT_EXISTS;
-import static java.util.Arrays.asList;
+import static com.google.common.primitives.Longs.asList;
 import static java.util.Collections.singletonList;
 import static org.junit.jupiter.api.Assertions.*;
 
 /**
-* {@link RewardActivityServiceImpl} 的单元测试类
-*
-* @author 芋道源码
-*/
+ * {@link RewardActivityServiceImpl} 的单元测试类
+ *
+ * @author 芋道源码
+ */
 @Disabled // TODO 芋艿:后续 fix 补充的单测
 @Import(RewardActivityServiceImpl.class)
 public class RewardActivityServiceImplTest extends BaseDbUnitTest {
@@ -63,7 +65,7 @@ public class RewardActivityServiceImplTest extends BaseDbUnitTest {
         // 校验记录的属性是否正确
         RewardActivityDO rewardActivity = rewardActivityMapper.selectById(rewardActivityId);
         assertPojoEquals(reqVO, rewardActivity, "rules");
-        assertEquals(rewardActivity.getStatus(), PromotionActivityStatusEnum.WAIT.getStatus());
+        assertEquals(rewardActivity.getStatus(), CommonStatusEnum.DISABLE.getStatus());
         for (int i = 0; i < reqVO.getRules().size(); i++) {
             assertPojoEquals(reqVO.getRules().get(i), rewardActivity.getRules().get(i));
         }
@@ -72,7 +74,7 @@ public class RewardActivityServiceImplTest extends BaseDbUnitTest {
     @Test
     public void testUpdateRewardActivity_success() {
         // mock 数据
-        RewardActivityDO dbRewardActivity = randomPojo(RewardActivityDO.class, o -> o.setStatus(PromotionActivityStatusEnum.WAIT.getStatus()));
+        RewardActivityDO dbRewardActivity = randomPojo(RewardActivityDO.class, o -> o.setStatus(CommonStatusEnum.DISABLE.getStatus()));
         rewardActivityMapper.insert(dbRewardActivity);// @Sql: 先插入出一条存在的数据
         // 准备参数
         RewardActivityUpdateReqVO reqVO = randomPojo(RewardActivityUpdateReqVO.class, o -> {
@@ -88,7 +90,7 @@ public class RewardActivityServiceImplTest extends BaseDbUnitTest {
         // 校验是否更新正确
         RewardActivityDO rewardActivity = rewardActivityMapper.selectById(reqVO.getId()); // 获取最新的
         assertPojoEquals(reqVO, rewardActivity, "rules");
-        assertEquals(rewardActivity.getStatus(), PromotionActivityStatusEnum.WAIT.getStatus());
+        assertEquals(rewardActivity.getStatus(), CommonStatusEnum.DISABLE.getStatus());
         for (int i = 0; i < reqVO.getRules().size(); i++) {
             assertPojoEquals(reqVO.getRules().get(i), rewardActivity.getRules().get(i));
         }
@@ -97,7 +99,7 @@ public class RewardActivityServiceImplTest extends BaseDbUnitTest {
     @Test
     public void testCloseRewardActivity() {
         // mock 数据
-        RewardActivityDO dbRewardActivity = randomPojo(RewardActivityDO.class, o -> o.setStatus(PromotionActivityStatusEnum.WAIT.getStatus()));
+        RewardActivityDO dbRewardActivity = randomPojo(RewardActivityDO.class, o -> o.setStatus(CommonStatusEnum.DISABLE.getStatus()));
         rewardActivityMapper.insert(dbRewardActivity);// @Sql: 先插入出一条存在的数据
         // 准备参数
         Long id = dbRewardActivity.getId();
@@ -106,7 +108,7 @@ public class RewardActivityServiceImplTest extends BaseDbUnitTest {
         rewardActivityService.closeRewardActivity(id);
         // 校验状态
         RewardActivityDO rewardActivity = rewardActivityMapper.selectById(id);
-        assertEquals(rewardActivity.getStatus(), PromotionActivityStatusEnum.CLOSE.getStatus());
+        assertEquals(rewardActivity.getStatus(), CommonStatusEnum.DISABLE.getStatus());
     }
 
     @Test
@@ -121,15 +123,15 @@ public class RewardActivityServiceImplTest extends BaseDbUnitTest {
     @Test
     public void testDeleteRewardActivity_success() {
         // mock 数据
-        RewardActivityDO dbRewardActivity = randomPojo(RewardActivityDO.class, o -> o.setStatus(PromotionActivityStatusEnum.CLOSE.getStatus()));
+        RewardActivityDO dbRewardActivity = randomPojo(RewardActivityDO.class, o -> o.setStatus(CommonStatusEnum.DISABLE.getStatus()));
         rewardActivityMapper.insert(dbRewardActivity);// @Sql: 先插入出一条存在的数据
         // 准备参数
         Long id = dbRewardActivity.getId();
 
         // 调用
         rewardActivityService.deleteRewardActivity(id);
-       // 校验数据不存在了
-       assertNull(rewardActivityMapper.selectById(id));
+        // 校验数据不存在了
+        assertNull(rewardActivityMapper.selectById(id));
     }
 
     @Test
@@ -143,77 +145,82 @@ public class RewardActivityServiceImplTest extends BaseDbUnitTest {
 
     @Test
     public void testGetRewardActivityPage() {
-       // mock 数据
-       RewardActivityDO dbRewardActivity = randomPojo(RewardActivityDO.class, o -> { // 等会查询到
-           o.setName("芋艿");
-           o.setStatus(PromotionActivityStatusEnum.CLOSE.getStatus());
-       });
-       rewardActivityMapper.insert(dbRewardActivity);
-       // 测试 name 不匹配
-       rewardActivityMapper.insert(cloneIgnoreId(dbRewardActivity, o -> o.setName("土豆")));
-       // 测试 status 不匹配
-       rewardActivityMapper.insert(cloneIgnoreId(dbRewardActivity, o -> o.setStatus(PromotionActivityStatusEnum.RUN.getStatus())));
-       // 准备参数
-       RewardActivityPageReqVO reqVO = new RewardActivityPageReqVO();
-       reqVO.setName("芋艿");
-       reqVO.setStatus(PromotionActivityStatusEnum.CLOSE.getStatus());
-
-       // 调用
-       PageResult<RewardActivityDO> pageResult = rewardActivityService.getRewardActivityPage(reqVO);
-       // 断言
-       assertEquals(1, pageResult.getTotal());
-       assertEquals(1, pageResult.getList().size());
-       assertPojoEquals(dbRewardActivity, pageResult.getList().get(0), "rules");
+        // mock 数据
+        RewardActivityDO dbRewardActivity = randomPojo(RewardActivityDO.class, o -> { // 等会查询到
+            o.setName("芋艿");
+            o.setStatus(CommonStatusEnum.DISABLE.getStatus());
+        });
+        rewardActivityMapper.insert(dbRewardActivity);
+        // 测试 name 不匹配
+        rewardActivityMapper.insert(cloneIgnoreId(dbRewardActivity, o -> o.setName("土豆")));
+        // 测试 status 不匹配
+        rewardActivityMapper.insert(cloneIgnoreId(dbRewardActivity, o -> o.setStatus(CommonStatusEnum.ENABLE.getStatus())));
+        // 准备参数
+        RewardActivityPageReqVO reqVO = new RewardActivityPageReqVO();
+        reqVO.setName("芋艿");
+        reqVO.setStatus(CommonStatusEnum.DISABLE.getStatus());
+
+        // 调用
+        PageResult<RewardActivityDO> pageResult = rewardActivityService.getRewardActivityPage(reqVO);
+        // 断言
+        assertEquals(1, pageResult.getTotal());
+        assertEquals(1, pageResult.getList().size());
+        assertPojoEquals(dbRewardActivity, pageResult.getList().get(0), "rules");
     }
 
     @Test
     public void testGetRewardActivities_all() {
         // mock 数据
-        RewardActivityDO allActivity = randomPojo(RewardActivityDO.class, o -> o.setStatus(PromotionActivityStatusEnum.RUN.getStatus())
+        RewardActivityDO allActivity = randomPojo(RewardActivityDO.class, o -> o.setStatus(CommonStatusEnum.ENABLE.getStatus())
                 .setProductScope(PromotionProductScopeEnum.ALL.getScope()));
         rewardActivityMapper.insert(allActivity);
-        RewardActivityDO productActivity = randomPojo(RewardActivityDO.class, o -> o.setStatus(PromotionActivityStatusEnum.RUN.getStatus())
-                .setProductScope(PromotionProductScopeEnum.SPU.getScope()).setProductSpuIds(asList(1L, 2L)));
+        RewardActivityDO productActivity = randomPojo(RewardActivityDO.class, o -> o.setStatus(CommonStatusEnum.ENABLE.getStatus())
+                .setProductScope(PromotionProductScopeEnum.SPU.getScope()).setProductScopeValues(asList(1L, 2L)));
         rewardActivityMapper.insert(productActivity);
         // 准备参数
         Set<Long> spuIds = asSet(1L, 2L);
 
         // 调用 TODO getMatchRewardActivities 没有这个方法,但是找到了 getMatchRewardActivityList
-        //Map<RewardActivityDO, Set<Long>> matchRewardActivities = rewardActivityService.getMatchRewardActivities(spuIds);
+        List<RewardActivityMatchRespDTO> matchRewardActivityList = rewardActivityService.getMatchRewardActivityList(spuIds);
         // 断言
-        //assertEquals(matchRewardActivities.size(), 1);
-        //Map.Entry<RewardActivityDO, Set<Long>> next = matchRewardActivities.entrySet().iterator().next();
-        //assertPojoEquals(next.getKey(), allActivity);
-        //assertEquals(next.getValue(), spuIds);
+        assertEquals(matchRewardActivityList.size(), 1);
+        matchRewardActivityList.forEach((activity) -> {
+            if (activity.getId().equals(productActivity.getId())) {
+                assertPojoEquals(activity, productActivity);
+                assertEquals(activity.getProductScopeValues(), asList(1L, 2L));
+            } else {
+                fail();
+            }
+        });
     }
 
     @Test
     public void testGetRewardActivities_product() {
         // mock 数据
-        RewardActivityDO productActivity01 = randomPojo(RewardActivityDO.class, o -> o.setStatus(PromotionActivityStatusEnum.RUN.getStatus())
-                .setProductScope(PromotionProductScopeEnum.SPU.getScope()).setProductSpuIds(asList(1L, 2L)));
+        RewardActivityDO productActivity01 = randomPojo(RewardActivityDO.class, o -> o.setStatus(CommonStatusEnum.ENABLE.getStatus())
+                .setProductScope(PromotionProductScopeEnum.SPU.getScope()).setProductScopeValues(asList(1L, 2L)));
         rewardActivityMapper.insert(productActivity01);
-        RewardActivityDO productActivity02 = randomPojo(RewardActivityDO.class, o -> o.setStatus(PromotionActivityStatusEnum.RUN.getStatus())
-                .setProductScope(PromotionProductScopeEnum.SPU.getScope()).setProductSpuIds(singletonList(3L)));
+        RewardActivityDO productActivity02 = randomPojo(RewardActivityDO.class, o -> o.setStatus(CommonStatusEnum.ENABLE.getStatus())
+                .setProductScope(PromotionProductScopeEnum.SPU.getScope()).setProductScopeValues(singletonList(3L)));
         rewardActivityMapper.insert(productActivity02);
         // 准备参数
         Set<Long> spuIds = asSet(1L, 2L, 3L);
 
         // 调用  TODO getMatchRewardActivities 没有这个方法,但是找到了 getMatchRewardActivityList
-        //Map<RewardActivityDO, Set<Long>> matchRewardActivities = rewardActivityService.getMatchRewardActivities(spuIds);
+        List<RewardActivityMatchRespDTO> matchRewardActivityList = rewardActivityService.getMatchRewardActivityList(spuIds);
         // 断言
-        //assertEquals(matchRewardActivities.size(), 2);
-        //matchRewardActivities.forEach((activity, activitySpuIds) -> {
-        //    if (activity.getId().equals(productActivity01.getId())) {
-        //        assertPojoEquals(activity, productActivity01);
-        //        assertEquals(activitySpuIds, asSet(1L, 2L));
-        //    } else if (activity.getId().equals(productActivity02.getId())) {
-        //        assertPojoEquals(activity, productActivity02);
-        //        assertEquals(activitySpuIds, asSet(3L));
-        //    } else {
-        //        fail();
-        //    }
-        //});
+        assertEquals(matchRewardActivityList.size(), 2);
+        matchRewardActivityList.forEach((activity) -> {
+            if (activity.getId().equals(productActivity01.getId())) {
+                assertPojoEquals(activity, productActivity01);
+                assertEquals(activity.getProductScopeValues(), asList(1L, 2L));
+            } else if (activity.getId().equals(productActivity02.getId())) {
+                assertPojoEquals(activity, productActivity02);
+                assertEquals(activity.getProductScopeValues(), singletonList(3L));
+            } else {
+                fail();
+            }
+        });
     }
 
 }

+ 3 - 3
yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/api/order/TradeOrderApi.java

@@ -28,13 +28,13 @@ public interface TradeOrderApi {
      */
     TradeOrderRespDTO getOrder(Long id);
 
-    // TODO 芋艿:需要优化下;
     /**
      * 取消支付订单
      *
-     * @param userId  用户编号
+     * @param userId 用户编号
      * @param orderId 订单编号
+     * @param cancelType 取消类型
      */
-    void cancelPaidOrder(Long userId, Long orderId);
+    void cancelPaidOrder(Long userId, Long orderId, Integer cancelType);
 
 }

+ 3 - 0
yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/enums/ErrorCodeConstants.java

@@ -35,6 +35,7 @@ public interface ErrorCodeConstants {
     ErrorCode ORDER_RECEIVE_FAIL_DELIVERY_TYPE_NOT_PICK_UP = new ErrorCode(1_011_000_030, "交易订单自提失败,收货方式不是【用户自提】");
     ErrorCode ORDER_UPDATE_ADDRESS_FAIL_STATUS_NOT_DELIVERED = new ErrorCode(1_011_000_031, "交易订单修改收货地址失败,原因:订单不是【待发货】状态");
     ErrorCode ORDER_CREATE_FAIL_EXIST_UNPAID = new ErrorCode(1_011_000_032, "交易订单创建失败,原因:存在未付款订单");
+    ErrorCode ORDER_CANCEL_PAID_FAIL = new ErrorCode(1_011_000_033, "交易订单取消支付失败,原因:订单不是【{}】状态");
 
     // ========== After Sale 模块 1-011-000-100 ==========
     ErrorCode AFTER_SALE_NOT_FOUND = new ErrorCode(1_011_000_100, "售后单不存在");
@@ -59,6 +60,8 @@ public interface ErrorCodeConstants {
     ErrorCode PRICE_CALCULATE_DELIVERY_PRICE_TEMPLATE_NOT_FOUND = new ErrorCode(1_011_003_002, "计算快递运费异常,找不到对应的运费模板");
     ErrorCode PRICE_CALCULATE_COUPON_NOT_MATCH_NORMAL_ORDER = new ErrorCode(1_011_003_004, "参与秒杀、拼团、砍价的营销商品,无法使用优惠劵");
     ErrorCode PRICE_CALCULATE_SECKILL_TOTAL_LIMIT_COUNT = new ErrorCode(1_011_003_005, "参与秒杀的商品,超过了秒杀总限购数量");
+    ErrorCode PRICE_CALCULATE_DELIVERY_PRICE_TYPE_ILLEGAL = new ErrorCode(1_011_003_006, "计算快递运费异常,配送方式不匹配");
+    ErrorCode PRICE_CALCULATE_COUPON_PRICE_TOO_MUCH = new ErrorCode(1_011_003_007, "该优惠劵无法使用,原因:优惠金额超过订单金额");
 
     // ========== 物流 Express 模块 1-011-004-000 ==========
     ErrorCode EXPRESS_NOT_EXISTS = new ErrorCode(1_011_004_000, "快递公司不存在");

+ 2 - 1
yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/enums/order/TradeOrderCancelTypeEnum.java

@@ -17,7 +17,8 @@ public enum TradeOrderCancelTypeEnum implements IntArrayValuable {
 
     PAY_TIMEOUT(10, "超时未支付"),
     AFTER_SALE_CLOSE(20, "退款关闭"),
-    MEMBER_CANCEL(30, "买家取消");
+    MEMBER_CANCEL(30, "买家取消"),
+    COMBINATION_CLOSE(40, "拼团关闭");
 
     public static final int[] ARRAYS = Arrays.stream(values()).mapToInt(TradeOrderCancelTypeEnum::getType).toArray();
 

+ 3 - 3
yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/api/order/TradeOrderApiImpl.java

@@ -4,10 +4,10 @@ import cn.iocoder.yudao.module.trade.api.order.dto.TradeOrderRespDTO;
 import cn.iocoder.yudao.module.trade.convert.order.TradeOrderConvert;
 import cn.iocoder.yudao.module.trade.service.order.TradeOrderQueryService;
 import cn.iocoder.yudao.module.trade.service.order.TradeOrderUpdateService;
+import jakarta.annotation.Resource;
 import org.springframework.stereotype.Service;
 import org.springframework.validation.annotation.Validated;
 
-import jakarta.annotation.Resource;
 import java.util.Collection;
 import java.util.List;
 
@@ -36,8 +36,8 @@ public class TradeOrderApiImpl implements TradeOrderApi {
     }
 
     @Override
-    public void cancelPaidOrder(Long userId, Long orderId) {
-        tradeOrderUpdateService.cancelPaidOrder(userId, orderId);
+    public void cancelPaidOrder(Long userId, Long orderId, Integer cancelType) {
+        tradeOrderUpdateService.cancelPaidOrder(userId, orderId, cancelType);
     }
 
 }

+ 23 - 0
yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/dataobject/order/TradeOrderDO.java

@@ -2,6 +2,7 @@ package cn.iocoder.yudao.module.trade.dal.dataobject.order;
 
 import cn.iocoder.yudao.framework.common.enums.TerminalEnum;
 import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
+import cn.iocoder.yudao.framework.mybatis.core.type.LongListTypeHandler;
 import cn.iocoder.yudao.module.member.api.user.dto.MemberUserRespDTO;
 import cn.iocoder.yudao.module.trade.dal.dataobject.brokerage.BrokerageUserDO;
 import cn.iocoder.yudao.module.trade.dal.dataobject.delivery.DeliveryExpressDO;
@@ -12,10 +13,14 @@ import cn.iocoder.yudao.module.trade.enums.order.TradeOrderRefundStatusEnum;
 import cn.iocoder.yudao.module.trade.enums.order.TradeOrderStatusEnum;
 import cn.iocoder.yudao.module.trade.enums.order.TradeOrderTypeEnum;
 import com.baomidou.mybatisplus.annotation.KeySequence;
+import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableName;
+import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
 import lombok.*;
 
 import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
 
 /**
  * 交易订单 DO
@@ -291,6 +296,24 @@ public class TradeOrderDO extends BaseDO {
      */
     private Integer vipPrice;
 
+    /**
+     * 赠送的优惠劵
+     *
+     * key: 优惠劵模版编号
+     * value:对应的优惠券数量
+     *
+     * 目的:用于订单支付后赠送优惠券
+     */
+    @TableField(typeHandler = JacksonTypeHandler.class)
+    private Map<Long, Integer> giveCouponTemplateCounts;
+    /**
+     * 赠送的优惠劵编号
+     *
+     * 目的:用于后续取消或者售后订单时,需要扣减赠送
+     */
+    @TableField(typeHandler = LongListTypeHandler.class)
+    private List<Long> giveCouponIds;
+
     /**
      * 秒杀活动编号
      *

+ 0 - 5
yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageUserServiceImpl.java

@@ -268,11 +268,6 @@ public class BrokerageUserServiceImpl implements BrokerageUserService {
             return false;
         }
 
-        // 校验分佣模式:仅可后台手动设置推广员
-        // if (BrokerageEnabledConditionEnum.ADMIN.getCondition().equals(tradeConfig.getBrokerageEnabledCondition())) {
-        //     throw exception(BROKERAGE_BIND_CONDITION_ADMIN);
-        // }
-
         // 校验分销关系绑定模式
         if (BrokerageBindModeEnum.REGISTER.getMode().equals(tradeConfig.getBrokerageBindMode())) {
             // 判断是否为新用户:注册时间在 30 秒内的,都算新用户

+ 15 - 6
yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/order/TradeOrderUpdateService.java

@@ -1,6 +1,5 @@
 package cn.iocoder.yudao.module.trade.service.order;
 
-import cn.iocoder.yudao.framework.common.enums.TerminalEnum;
 import cn.iocoder.yudao.module.trade.controller.admin.order.vo.TradeOrderDeliveryReqVO;
 import cn.iocoder.yudao.module.trade.controller.admin.order.vo.TradeOrderRemarkReqVO;
 import cn.iocoder.yudao.module.trade.controller.admin.order.vo.TradeOrderUpdateAddressReqVO;
@@ -10,9 +9,10 @@ import cn.iocoder.yudao.module.trade.controller.app.order.vo.AppTradeOrderSettle
 import cn.iocoder.yudao.module.trade.controller.app.order.vo.AppTradeOrderSettlementRespVO;
 import cn.iocoder.yudao.module.trade.controller.app.order.vo.item.AppTradeOrderItemCommentCreateReqVO;
 import cn.iocoder.yudao.module.trade.dal.dataobject.order.TradeOrderDO;
-
 import jakarta.validation.constraints.NotNull;
 
+import java.util.List;
+
 /**
  * 交易订单【写】Service 接口
  *
@@ -187,13 +187,22 @@ public interface TradeOrderUpdateService {
      */
     void updateOrderCombinationInfo(Long orderId, Long activityId, Long combinationRecordId, Long headId);
 
-    // TODO 芋艿:拼团取消,不调这个接口哈;
     /**
      * 取消支付订单
      *
-     * @param userId  用户编号
-     * @param orderId 订单编号
+     * @param userId           用户编号
+     * @param orderId          订单编号
+     * @param cancelType       取消类型
+     */
+    void cancelPaidOrder(Long userId, Long orderId, Integer cancelType);
+
+    /**
+     * 更新下单赠送的优惠券编号到订单
+     *
+     * @param userId        用户编号
+     * @param orderId       订单编号
+     * @param giveCouponIds 赠送的优惠券编号列表
      */
-    void cancelPaidOrder(Long userId, Long orderId);
+    void updateOrderGiveCouponIds(Long userId, Long orderId, List<Long> giveCouponIds);
 
 }

+ 41 - 4
yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/order/TradeOrderUpdateServiceImpl.java

@@ -18,6 +18,8 @@ import cn.iocoder.yudao.module.member.api.address.dto.MemberAddressRespDTO;
 import cn.iocoder.yudao.module.pay.api.order.PayOrderApi;
 import cn.iocoder.yudao.module.pay.api.order.dto.PayOrderCreateReqDTO;
 import cn.iocoder.yudao.module.pay.api.order.dto.PayOrderRespDTO;
+import cn.iocoder.yudao.module.pay.api.refund.PayRefundApi;
+import cn.iocoder.yudao.module.pay.api.refund.dto.PayRefundCreateReqDTO;
 import cn.iocoder.yudao.module.pay.enums.order.PayOrderStatusEnum;
 import cn.iocoder.yudao.module.product.api.comment.ProductCommentApi;
 import cn.iocoder.yudao.module.product.api.comment.dto.ProductCommentCreateReqDTO;
@@ -111,6 +113,8 @@ public class TradeOrderUpdateServiceImpl implements TradeOrderUpdateService {
     private ProductCommentApi productCommentApi;
     @Resource
     public SocialClientApi socialClientApi;
+    @Resource
+    public PayRefundApi payRefundApi;
 
     @Resource
     private TradeOrderProperties tradeOrderProperties;
@@ -197,6 +201,8 @@ public class TradeOrderUpdateServiceImpl implements TradeOrderUpdateService {
         order.setRefundStatus(TradeOrderRefundStatusEnum.NONE.getStatus());
         order.setProductCount(getSumValue(calculateRespBO.getItems(), TradePriceCalculateRespBO.OrderItem::getCount, Integer::sum));
         order.setUserIp(getClientIP()).setTerminal(getTerminal());
+        // 使用 + 赠送优惠券
+        order.setGiveCouponTemplateCounts(calculateRespBO.getGiveCouponTemplateCounts());
         // 支付 + 退款信息
         order.setAdjustPrice(0).setPayStatus(false);
         order.setRefundStatus(TradeOrderRefundStatusEnum.NONE.getStatus()).setRefundPrice(0);
@@ -854,15 +860,46 @@ public class TradeOrderUpdateServiceImpl implements TradeOrderUpdateService {
 
     @Override
     @Transactional(rollbackFor = Exception.class)
-    public void cancelPaidOrder(Long userId, Long orderId) {
-        // TODO @puhui999:需要校验状态;已支付的情况下,才可以。
+    public void cancelPaidOrder(Long userId, Long orderId, Integer cancelType) {
+        // 1.1 这里校验下 cancelType 只允许拼团关闭;
+        if (ObjUtil.notEqual(TradeOrderCancelTypeEnum.COMBINATION_CLOSE.getType(), cancelType)) {
+            return;
+        }
+        // 1.2 检验订单存在
+        TradeOrderDO order = tradeOrderMapper.selectOrderByIdAndUserId(orderId, userId);
+        if (order == null) {
+            throw exception(ORDER_NOT_FOUND);
+        }
+
+        // 1.3 校验订单是否支付
+        if (!order.getPayStatus()) {
+            throw exception(ORDER_CANCEL_PAID_FAIL, "已支付");
+        }
+        // 1.3 校验订单是否已退款
+        if (ObjUtil.equal(TradeOrderRefundStatusEnum.NONE.getStatus(), order.getRefundStatus())) {
+            throw exception(ORDER_CANCEL_PAID_FAIL, "未退款");
+        }
+
+        // 2.1 取消订单
+        cancelOrder0(order, TradeOrderCancelTypeEnum.COMBINATION_CLOSE);
+        // 2.2 创建退款单
+        payRefundApi.createRefund(new PayRefundCreateReqDTO()
+                .setAppKey(tradeOrderProperties.getPayAppKey()).setUserIp(getClientIP()) // 支付应用
+                .setMerchantOrderId(String.valueOf(order.getId())) // 支付单号
+                .setMerchantRefundId(String.valueOf(order.getId()))
+                .setReason(TradeOrderCancelTypeEnum.COMBINATION_CLOSE.getName()).setPrice(order.getPayPrice()));// 价格信息
+    }
+
+    @Override
+    public void updateOrderGiveCouponIds(Long userId, Long orderId, List<Long> giveCouponIds) {
+        // 1. 检验订单存在
         TradeOrderDO order = tradeOrderMapper.selectOrderByIdAndUserId(orderId, userId);
         if (order == null) {
             throw exception(ORDER_NOT_FOUND);
         }
-        cancelOrder0(order, TradeOrderCancelTypeEnum.MEMBER_CANCEL);
 
-        // TODO @puhui999:需要退款
+        // 2. 更新订单赠送的优惠券编号列表
+        tradeOrderMapper.updateById(new TradeOrderDO().setId(orderId).setGiveCouponIds(giveCouponIds));
     }
 
     /**

+ 32 - 4
yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/order/handler/TradeCouponOrderHandler.java

@@ -1,12 +1,16 @@
 package cn.iocoder.yudao.module.trade.service.order.handler;
 
+import cn.hutool.core.collection.CollUtil;
 import cn.iocoder.yudao.module.promotion.api.coupon.CouponApi;
 import cn.iocoder.yudao.module.promotion.api.coupon.dto.CouponUseReqDTO;
 import cn.iocoder.yudao.module.trade.dal.dataobject.order.TradeOrderDO;
 import cn.iocoder.yudao.module.trade.dal.dataobject.order.TradeOrderItemDO;
+import cn.iocoder.yudao.module.trade.service.order.TradeOrderQueryService;
+import cn.iocoder.yudao.module.trade.service.order.TradeOrderUpdateService;
+import jakarta.annotation.Resource;
+import org.springframework.context.annotation.Lazy;
 import org.springframework.stereotype.Component;
 
-import jakarta.annotation.Resource;
 import java.util.List;
 
 /**
@@ -17,6 +21,12 @@ import java.util.List;
 @Component
 public class TradeCouponOrderHandler implements TradeOrderHandler {
 
+    @Resource
+    @Lazy // 延迟加载,避免循环依赖
+    private TradeOrderUpdateService orderUpdateService;
+    @Resource
+    private TradeOrderQueryService orderQueryService;
+
     @Resource
     private CouponApi couponApi;
 
@@ -30,13 +40,31 @@ public class TradeCouponOrderHandler implements TradeOrderHandler {
                 .setOrderId(order.getId()));
     }
 
+    @Override
+    public void afterPayOrder(TradeOrderDO order, List<TradeOrderItemDO> orderItems) {
+        if (CollUtil.isEmpty(order.getGiveCouponTemplateCounts())) {
+            return;
+        }
+        // 赠送优惠券
+        List<Long> couponIds = couponApi.takeCouponsByAdmin(order.getGiveCouponTemplateCounts(), order.getUserId());
+        if (CollUtil.isEmpty(couponIds)) {
+            return;
+        }
+        orderUpdateService.updateOrderGiveCouponIds(order.getUserId(), order.getId(), couponIds);
+    }
+
     @Override
     public void afterCancelOrder(TradeOrderDO order, List<TradeOrderItemDO> orderItems) {
-        if (order.getCouponId() == null || order.getCouponId() <= 0) {
+        // 情况一:退还订单使用的优惠券
+        if (order.getCouponId() != null && order.getCouponId() > 0) {
+            // 退回优惠劵
+            couponApi.returnUsedCoupon(order.getCouponId());
+        }
+        // 情况二:收回赠送的优惠券
+        if (CollUtil.isEmpty(order.getGiveCouponIds())) {
             return;
         }
-        // 退回优惠劵
-        couponApi.returnUsedCoupon(order.getCouponId());
+        couponApi.invalidateCouponsByAdmin(order.getGiveCouponIds(), order.getUserId());
     }
 
 }

+ 29 - 2
yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/price/bo/TradePriceCalculateRespBO.java

@@ -6,6 +6,7 @@ import cn.iocoder.yudao.module.trade.enums.order.TradeOrderTypeEnum;
 import lombok.Data;
 
 import java.util.List;
+import java.util.Map;
 
 /**
  * 价格计算 Response BO
@@ -67,6 +68,21 @@ public class TradePriceCalculateRespBO {
      */
     private Long bargainActivityId;
 
+    /**
+     * 是否包邮
+     */
+    private Boolean freeDelivery;
+
+    /**
+     * 赠送的优惠劵
+     *
+     * key: 优惠劵模版编号
+     * value:对应的优惠券数量
+     *
+     * 目的:用于订单支付后赠送优惠券
+     */
+    private Map<Long, Integer> giveCouponTemplateCounts;
+
     /**
      * 订单价格
      */
@@ -213,8 +229,19 @@ public class TradePriceCalculateRespBO {
          */
         private Long categoryId;
 
+        // ========== 物流相关字段 =========
+
         /**
-         * 运费模板 Id
+         * 配送方式数组
+         *
+         * 对应 DeliveryTypeEnum 枚举
+         */
+        private List<Integer> deliveryTypes;
+
+        /**
+         * 物流配置模板编号
+         *
+         * 对应 TradeDeliveryExpressTemplateDO 的 id 编号
          */
         private Long deliveryTemplateId;
 
@@ -234,7 +261,7 @@ public class TradePriceCalculateRespBO {
         private List<ProductPropertyValueDetailRespDTO> properties;
 
         /**
-         * 使用的积分
+         * 赠送的积分
          */
         private Integer givePoint;
 

+ 4 - 2
yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/price/calculator/TradeCouponPriceCalculator.java

@@ -25,6 +25,7 @@ import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.
 import static cn.iocoder.yudao.module.promotion.enums.ErrorCodeConstants.COUPON_NO_MATCH_MIN_PRICE;
 import static cn.iocoder.yudao.module.promotion.enums.ErrorCodeConstants.COUPON_NO_MATCH_SPU;
 import static cn.iocoder.yudao.module.trade.enums.ErrorCodeConstants.PRICE_CALCULATE_COUPON_NOT_MATCH_NORMAL_ORDER;
+import static cn.iocoder.yudao.module.trade.enums.ErrorCodeConstants.PRICE_CALCULATE_COUPON_PRICE_TOO_MUCH;
 
 /**
  * 优惠劵的 {@link TradePriceCalculator} 实现类
@@ -65,8 +66,9 @@ public class TradeCouponPriceCalculator implements TradePriceCalculator {
 
         // 3.1 计算可以优惠的金额
         Integer couponPrice = getCouponPrice(coupon, totalPayPrice);
-        Assert.isTrue(couponPrice < totalPayPrice,
-                "优惠劵({}) 的优惠金额({}),不能大于订单总金额({})", coupon.getId(), couponPrice, totalPayPrice);
+        if (couponPrice <= totalPayPrice) {
+            throw exception(PRICE_CALCULATE_COUPON_PRICE_TOO_MUCH);
+        }
         // 3.2 计算分摊的优惠金额
         List<Integer> divideCouponPrices = TradePriceCalculatorHelper.dividePrice(orderItems, couponPrice);
 

+ 16 - 6
yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/price/calculator/TradeDeliveryPriceCalculator.java

@@ -4,6 +4,7 @@ import cn.hutool.core.collection.CollUtil;
 import cn.hutool.core.lang.Assert;
 import cn.hutool.core.util.StrUtil;
 import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
+import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
 import cn.iocoder.yudao.module.member.api.address.MemberAddressApi;
 import cn.iocoder.yudao.module.member.api.address.dto.MemberAddressRespDTO;
 import cn.iocoder.yudao.module.trade.dal.dataobject.config.TradeConfigDO;
@@ -17,11 +18,11 @@ import cn.iocoder.yudao.module.trade.service.delivery.bo.DeliveryExpressTemplate
 import cn.iocoder.yudao.module.trade.service.price.bo.TradePriceCalculateReqBO;
 import cn.iocoder.yudao.module.trade.service.price.bo.TradePriceCalculateRespBO;
 import cn.iocoder.yudao.module.trade.service.price.bo.TradePriceCalculateRespBO.OrderItem;
+import jakarta.annotation.Resource;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.core.annotation.Order;
 import org.springframework.stereotype.Component;
 
-import jakarta.annotation.Resource;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -55,7 +56,11 @@ public class TradeDeliveryPriceCalculator implements TradePriceCalculator {
         if (param.getDeliveryType() == null) {
             return;
         }
-        // TODO @puhui999:需要校验,是不是存在商品不能门店自提,或者不能快递发货的情况。就是说,配送方式不匹配哈
+        // 校验是不是存在商品不能门店自提,或者不能快递发货的情况。就是说,配送方式不匹配哈
+        if (CollectionUtils.anyMatch(result.getItems(), item -> !item.getDeliveryTypes().contains(param.getDeliveryType()))) {
+            throw exception(PRICE_CALCULATE_DELIVERY_PRICE_TYPE_ILLEGAL);
+        }
+
         if (DeliveryTypeEnum.PICK_UP.getType().equals(param.getDeliveryType())) {
             calculateByPickUp(param);
         } else if (DeliveryTypeEnum.EXPRESS.getType().equals(param.getDeliveryType())) {
@@ -90,7 +95,12 @@ public class TradeDeliveryPriceCalculator implements TradePriceCalculator {
             return;
         }
 
-        // 情况二:快递模版
+        // 情况二:活动包邮
+        if (Boolean.TRUE.equals(result.getFreeDelivery())) {
+            return;
+        }
+
+        // 情况三:快递模版
         // 2.1 过滤出已选中的商品 SKU
         List<OrderItem> selectedItem = filterList(result.getItems(), OrderItem::getSelected);
         Set<Long> deliveryTemplateIds = convertSet(selectedItem, OrderItem::getDeliveryTemplateId);
@@ -124,7 +134,7 @@ public class TradeDeliveryPriceCalculator implements TradePriceCalculator {
         Map<Long, List<OrderItem>> template2ItemMap = convertMultiMap(selectedSkus, OrderItem::getDeliveryTemplateId);
         // 依次计算快递运费
         for (Map.Entry<Long, List<OrderItem>> entry : template2ItemMap.entrySet()) {
-            Long templateId  = entry.getKey();
+            Long templateId = entry.getKey();
             List<OrderItem> orderItems = entry.getValue();
             DeliveryExpressTemplateRespBO templateBO = expressTemplateMap.get(templateId);
             if (templateBO == null) {
@@ -144,8 +154,8 @@ public class TradeDeliveryPriceCalculator implements TradePriceCalculator {
     /**
      * 按配送方式来计算运费
      *
-     * @param orderItems SKU 商品项目
-     * @param chargeMode  配送计费方式
+     * @param orderItems     SKU 商品项目
+     * @param chargeMode     配送计费方式
      * @param templateCharge 快递运费配置
      */
     private void calculateExpressFeeByChargeMode(List<OrderItem> orderItems, Integer chargeMode,

+ 3 - 3
yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/price/calculator/TradePriceCalculatorHelper.java

@@ -11,6 +11,7 @@ import cn.iocoder.yudao.module.trade.service.price.bo.TradePriceCalculateReqBO;
 import cn.iocoder.yudao.module.trade.service.price.bo.TradePriceCalculateRespBO;
 
 import java.util.ArrayList;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 
@@ -31,8 +32,7 @@ public class TradePriceCalculatorHelper {
                                                                List<ProductSpuRespDTO> spuList, List<ProductSkuRespDTO> skuList) {
         // 创建 PriceCalculateRespDTO 对象
         TradePriceCalculateRespBO result = new TradePriceCalculateRespBO();
-        result.setType(getOrderType(param));
-        result.setPromotions(new ArrayList<>());
+        result.setType(getOrderType(param)).setPromotions(new ArrayList<>()).setGiveCouponTemplateCounts(new LinkedHashMap<>());
 
         // 创建它的 OrderItem 属性
         result.setItems(new ArrayList<>(param.getItems().size()));
@@ -60,7 +60,7 @@ public class TradePriceCalculatorHelper {
                     .setWeight(sku.getWeight()).setVolume(sku.getVolume());
             // spu 信息
             orderItem.setSpuName(spu.getName()).setCategoryId(spu.getCategoryId())
-                    .setDeliveryTemplateId(spu.getDeliveryTemplateId())
+                    .setDeliveryTypes(spu.getDeliveryTypes()).setDeliveryTemplateId(spu.getDeliveryTemplateId())
                     .setGivePoint(spu.getGiveIntegral()).setUsePoint(0);
             if (StrUtil.isBlank(orderItem.getPicUrl())) {
                 orderItem.setPicUrl(spu.getPicUrl());

+ 77 - 11
yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/price/calculator/TradeRewardActivityPriceCalculator.java

@@ -3,23 +3,30 @@ package cn.iocoder.yudao.module.trade.service.price.calculator;
 import cn.hutool.core.collection.CollUtil;
 import cn.hutool.core.util.ObjectUtil;
 import cn.hutool.core.util.StrUtil;
+import cn.iocoder.yudao.framework.common.util.number.MoneyUtils;
 import cn.iocoder.yudao.module.promotion.api.reward.RewardActivityApi;
 import cn.iocoder.yudao.module.promotion.api.reward.dto.RewardActivityMatchRespDTO;
 import cn.iocoder.yudao.module.promotion.enums.common.PromotionConditionTypeEnum;
+import cn.iocoder.yudao.module.promotion.enums.common.PromotionProductScopeEnum;
 import cn.iocoder.yudao.module.promotion.enums.common.PromotionTypeEnum;
 import cn.iocoder.yudao.module.trade.enums.order.TradeOrderTypeEnum;
 import cn.iocoder.yudao.module.trade.service.price.bo.TradePriceCalculateReqBO;
 import cn.iocoder.yudao.module.trade.service.price.bo.TradePriceCalculateRespBO;
+import jakarta.annotation.Resource;
 import org.springframework.core.annotation.Order;
 import org.springframework.stereotype.Component;
 
-import jakarta.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Comparator;
 import java.util.List;
+import java.util.Map;
 
 import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
 import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.filterList;
 import static cn.iocoder.yudao.module.trade.service.price.calculator.TradePriceCalculatorHelper.formatPrice;
 
+// TODO @puhui999:相关的单测,建议改一改
+
 /**
  * 满减送活动的 {@link TradePriceCalculator} 实现类
  *
@@ -52,7 +59,7 @@ public class TradeRewardActivityPriceCalculator implements TradePriceCalculator
     private void calculate(TradePriceCalculateReqBO param, TradePriceCalculateRespBO result,
                            RewardActivityMatchRespDTO rewardActivity) {
         // 1.1 获得满减送的订单项(商品)列表
-        List<TradePriceCalculateRespBO.OrderItem> orderItems = filterMatchCouponOrderItems(result, rewardActivity);
+        List<TradePriceCalculateRespBO.OrderItem> orderItems = filterMatchActivityOrderItems(result, rewardActivity);
         if (CollUtil.isEmpty(orderItems)) {
             return;
         }
@@ -61,7 +68,7 @@ public class TradeRewardActivityPriceCalculator implements TradePriceCalculator
         if (rule == null) {
             TradePriceCalculatorHelper.addNotMatchPromotion(result, orderItems,
                     rewardActivity.getId(), rewardActivity.getName(), PromotionTypeEnum.REWARD_ACTIVITY.getType(),
-                    getRewardActivityNotMeetTip(rewardActivity));
+                    getRewardActivityNotMeetTip(rewardActivity, orderItems));
             return;
         }
 
@@ -84,6 +91,36 @@ public class TradeRewardActivityPriceCalculator implements TradePriceCalculator
             TradePriceCalculatorHelper.recountPayPrice(orderItem);
         }
         TradePriceCalculatorHelper.recountAllPrice(result);
+
+        // 4.1 记录赠送的积分
+        if (rule.getPoint() != null && rule.getPoint() > 0) {
+            List<Integer> dividePoints = TradePriceCalculatorHelper.dividePrice(orderItems, rule.getPoint());
+            for (int i = 0; i < orderItems.size(); i++) {
+                // 商品可能赠送了积分,所以这里要加上
+                TradePriceCalculateRespBO.OrderItem orderItem = orderItems.get(i);
+                orderItem.setGivePoint(orderItem.getGivePoint() + dividePoints.get(i));
+            }
+        }
+        // 4.2 记录订单是否包邮
+        if (Boolean.TRUE.equals(rule.getFreeDelivery())) {
+            // 只要满足一个活动包邮那么这单就包邮
+            result.setFreeDelivery(true);
+        }
+        // 4.3 记录赠送的优惠券
+        if (CollUtil.isNotEmpty(rule.getGiveCouponTemplateCounts())) {
+            for (Map.Entry<Long, Integer> entry : rule.getGiveCouponTemplateCounts().entrySet()) {
+                Map<Long, Integer> giveCouponTemplateCounts = result.getGiveCouponTemplateCounts();
+                // TODO @puhui999:是不是有一种可能性,这个 key 没有,别的 key 有哈。
+                // TODO 这里还有一种简化的写法。就是下面,大概两行就可以啦
+//                result.getGiveCouponTemplateCounts().put(entry.getKey(),
+//                        result.getGiveCouponTemplateCounts().getOrDefault(entry.getKey(), 0) + entry.getValue());
+                if (giveCouponTemplateCounts.get(entry.getKey()) == null) { // 情况一:还没有赠送的优惠券
+                    result.setGiveCouponTemplateCounts(rule.getGiveCouponTemplateCounts());
+                } else { // 情况二:别的满减活动送过同类优惠券,则直接增加数量
+                    giveCouponTemplateCounts.put(entry.getKey(), giveCouponTemplateCounts.get(entry.getKey()) + entry.getValue());
+                }
+            }
+        }
     }
 
     /**
@@ -93,10 +130,23 @@ public class TradeRewardActivityPriceCalculator implements TradePriceCalculator
      * @param rewardActivity 满减送活动
      * @return 订单项(商品)列表
      */
-    private List<TradePriceCalculateRespBO.OrderItem> filterMatchCouponOrderItems(TradePriceCalculateRespBO result,
-                                                                                  RewardActivityMatchRespDTO rewardActivity) {
-        return filterList(result.getItems(),
-                orderItem -> CollUtil.contains(rewardActivity.getSpuIds(), orderItem.getSpuId()));
+    private List<TradePriceCalculateRespBO.OrderItem> filterMatchActivityOrderItems(TradePriceCalculateRespBO result,
+                                                                                    RewardActivityMatchRespDTO rewardActivity) {
+        // 情况一:全部商品都可以参与
+        if (PromotionProductScopeEnum.isAll(rewardActivity.getProductScope())) {
+            return result.getItems();
+        }
+        // 情况二:指定商品参与
+        if (PromotionProductScopeEnum.isSpu(rewardActivity.getProductScope())) {
+            return filterList(result.getItems(),
+                    orderItem -> CollUtil.contains(rewardActivity.getProductScopeValues(), orderItem.getSpuId()));
+        }
+        // 情况三:指定商品类型参与
+        if (PromotionProductScopeEnum.isCategory(rewardActivity.getProductScope())) {
+            return filterList(result.getItems(),
+                    orderItem -> CollUtil.contains(rewardActivity.getProductScopeValues(), orderItem.getCategoryId()));
+        }
+        return List.of();
     }
 
     /**
@@ -129,14 +179,30 @@ public class TradeRewardActivityPriceCalculator implements TradePriceCalculator
     }
 
     /**
-     * 获得满减送活动匹配时的提示
+     * 获得满减送活动匹配时的提示
      *
      * @param rewardActivity 满减送活动
      * @return 提示
      */
-    private String getRewardActivityNotMeetTip(RewardActivityMatchRespDTO rewardActivity) {
-        // TODO 芋艿:后面再想想;应该找第一个规则,算下还差多少即可。
-        return "TODO";
+    private String getRewardActivityNotMeetTip(RewardActivityMatchRespDTO rewardActivity,
+                                               List<TradePriceCalculateRespBO.OrderItem> orderItems) {
+        // 1. 计算数量和价格
+        Integer count = TradePriceCalculatorHelper.calculateTotalCount(orderItems);
+        Integer price = TradePriceCalculatorHelper.calculateTotalPayPrice(orderItems);
+        assert count != null && price != null;
+
+        // 2. 构建不满足时的提示信息:按最低档规则算
+        String meetTip = "满减送:购满 {} {},可以减 {} 元";
+        List<RewardActivityMatchRespDTO.Rule> rules = new ArrayList<>(rewardActivity.getRules());
+        rules.sort(Comparator.comparing(RewardActivityMatchRespDTO.Rule::getLimit)); // 按优惠门槛升序
+        RewardActivityMatchRespDTO.Rule rule = rules.get(0);
+        if (PromotionConditionTypeEnum.PRICE.getType().equals(rewardActivity.getConditionType())) {
+            return StrUtil.format(meetTip, rule.getLimit(), "元", MoneyUtils.fenToYuanStr(rule.getDiscountPrice()));
+        }
+        if (PromotionConditionTypeEnum.COUNT.getType().equals(rewardActivity.getConditionType())) {
+            return StrUtil.format(meetTip, rule.getLimit(), "件", MoneyUtils.fenToYuanStr(rule.getDiscountPrice()));
+        }
+        return StrUtil.EMPTY;
     }
 
 }

+ 20 - 9
yudao-module-mall/yudao-module-trade-biz/src/test/java/cn/iocoder/yudao/module/trade/service/price/calculator/TradeRewardActivityPriceCalculatorTest.java

@@ -4,6 +4,7 @@ import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
 import cn.iocoder.yudao.module.promotion.api.reward.RewardActivityApi;
 import cn.iocoder.yudao.module.promotion.api.reward.dto.RewardActivityMatchRespDTO;
 import cn.iocoder.yudao.module.promotion.enums.common.PromotionConditionTypeEnum;
+import cn.iocoder.yudao.module.promotion.enums.common.PromotionProductScopeEnum;
 import cn.iocoder.yudao.module.promotion.enums.common.PromotionTypeEnum;
 import cn.iocoder.yudao.module.trade.enums.order.TradeOrderTypeEnum;
 import cn.iocoder.yudao.module.trade.service.price.bo.TradePriceCalculateReqBO;
@@ -13,6 +14,7 @@ import org.mockito.InjectMocks;
 import org.mockito.Mock;
 
 import java.util.ArrayList;
+import java.util.LinkedHashMap;
 
 import static cn.iocoder.yudao.framework.common.util.collection.SetUtils.asSet;
 import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo;
@@ -47,7 +49,7 @@ public class TradeRewardActivityPriceCalculatorTest extends BaseMockitoUnitTest
         TradePriceCalculateRespBO result = new TradePriceCalculateRespBO()
                 .setType(TradeOrderTypeEnum.NORMAL.getType())
                 .setPrice(new TradePriceCalculateRespBO.Price())
-                .setPromotions(new ArrayList<>())
+                .setPromotions(new ArrayList<>()).setGiveCouponTemplateCounts(new LinkedHashMap<>())
                 .setItems(asList(
                         new TradePriceCalculateRespBO.OrderItem().setSkuId(10L).setCount(2).setSelected(true)
                                 .setPrice(100).setSpuId(1L),
@@ -60,16 +62,22 @@ public class TradeRewardActivityPriceCalculatorTest extends BaseMockitoUnitTest
         TradePriceCalculatorHelper.recountPayPrice(result.getItems());
         TradePriceCalculatorHelper.recountAllPrice(result);
 
-        // mock 方法(限时折扣 DiscountActivity 信息)
+        // mock 方法(满减送 RewardActivity 信息)
         when(rewardActivityApi.getMatchRewardActivityList(eq(asSet(1L, 2L, 3L)))).thenReturn(asList(
                 randomPojo(RewardActivityMatchRespDTO.class, o -> o.setId(1000L).setName("活动 1000 号")
-                        .setSpuIds(asList(1L, 2L)).setConditionType(PromotionConditionTypeEnum.PRICE.getType())
-                        .setRules(singletonList(new RewardActivityMatchRespDTO.Rule().setLimit(200).setDiscountPrice(70)))),
+                        .setConditionType(PromotionConditionTypeEnum.PRICE.getType())
+                        .setProductScope(PromotionProductScopeEnum.SPU.getScope()).setProductScopeValues(asList(1L, 2L))
+                        .setRules(singletonList(new RewardActivityMatchRespDTO.Rule().setLimit(20).setDiscountPrice(70)
+                                .setFreeDelivery(false)))),
                 randomPojo(RewardActivityMatchRespDTO.class, o -> o.setId(2000L).setName("活动 2000 号")
-                        .setSpuIds(singletonList(3L)).setConditionType(PromotionConditionTypeEnum.COUNT.getType())
-                        .setRules(asList(new RewardActivityMatchRespDTO.Rule().setLimit(1).setDiscountPrice(10),
-                                new RewardActivityMatchRespDTO.Rule().setLimit(2).setDiscountPrice(60), // 最大可满足,因为是 4 个
-                                new RewardActivityMatchRespDTO.Rule().setLimit(10).setDiscountPrice(100))))
+                        .setConditionType(PromotionConditionTypeEnum.COUNT.getType())
+                        .setProductScope(PromotionProductScopeEnum.SPU.getScope()).setProductScopeValues(singletonList(3L))
+                        .setRules(asList(new RewardActivityMatchRespDTO.Rule().setLimit(1).setDiscountPrice(10)
+                                        .setPoint(50).setFreeDelivery(false),
+                                new RewardActivityMatchRespDTO.Rule().setLimit(2).setDiscountPrice(60)
+                                        .setPoint(100).setFreeDelivery(false), // 最大可满足,因为是 4 个
+                                new RewardActivityMatchRespDTO.Rule().setLimit(10).setDiscountPrice(100)
+                                        .setFreeDelivery(false))))
         ));
 
         // 调用
@@ -94,6 +102,7 @@ public class TradeRewardActivityPriceCalculatorTest extends BaseMockitoUnitTest
         assertEquals(orderItem01.getCouponPrice(), 0);
         assertEquals(orderItem01.getPointPrice(), 0);
         assertEquals(orderItem01.getPayPrice(), 160);
+        assertEquals(orderItem01.getGivePoint(), 0);
         // 断言:SKU 2
         TradePriceCalculateRespBO.OrderItem orderItem02 = result.getItems().get(1);
         assertEquals(orderItem02.getSkuId(), 20L);
@@ -104,6 +113,7 @@ public class TradeRewardActivityPriceCalculatorTest extends BaseMockitoUnitTest
         assertEquals(orderItem02.getCouponPrice(), 0);
         assertEquals(orderItem02.getPointPrice(), 0);
         assertEquals(orderItem02.getPayPrice(), 120);
+        assertEquals(orderItem02.getGivePoint(), 0);
         // 断言:SKU 3
         TradePriceCalculateRespBO.OrderItem orderItem03 = result.getItems().get(2);
         assertEquals(orderItem03.getSkuId(), 30L);
@@ -114,6 +124,7 @@ public class TradeRewardActivityPriceCalculatorTest extends BaseMockitoUnitTest
         assertEquals(orderItem03.getCouponPrice(), 0);
         assertEquals(orderItem03.getPointPrice(), 0);
         assertEquals(orderItem03.getPayPrice(), 60);
+        assertEquals(orderItem03.getGivePoint(), 100);
         // 断言:Promotion 部分(第一个)
         assertEquals(result.getPromotions().size(), 2);
         TradePriceCalculateRespBO.Promotion promotion01 = result.getPromotions().get(0);
@@ -175,7 +186,7 @@ public class TradeRewardActivityPriceCalculatorTest extends BaseMockitoUnitTest
         // mock 方法(限时折扣 DiscountActivity 信息)
         when(rewardActivityApi.getMatchRewardActivityList(eq(asSet(1L, 2L)))).thenReturn(singletonList(
                 randomPojo(RewardActivityMatchRespDTO.class, o -> o.setId(1000L).setName("活动 1000 号")
-                        .setSpuIds(asList(1L, 2L)).setConditionType(PromotionConditionTypeEnum.PRICE.getType())
+                        .setProductScopeValues(asList(1L, 2L)).setConditionType(PromotionConditionTypeEnum.PRICE.getType())
                         .setRules(singletonList(new RewardActivityMatchRespDTO.Rule().setLimit(351).setDiscountPrice(70))))
         ));
 

+ 1 - 0
yudao-module-mall/yudao-module-trade-biz/src/test/resources/sql/create_tables.sql

@@ -48,6 +48,7 @@ CREATE TABLE IF NOT EXISTS "trade_order"
     "give_point"              int      NULL,
     "refund_point"            int      NULL,
     "vip_price"               int      NULL,
+    "give_coupons_map"        varchar  NULL,
     "seckill_activity_id"     long     NULL,
     "bargain_activity_id"     long     NULL,
     "bargain_record_id"       long     NULL,

+ 2 - 10
yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/MemberUserController.java

@@ -18,12 +18,12 @@ import cn.iocoder.yudao.module.member.service.user.MemberUserService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.Parameter;
 import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.Resource;
+import jakarta.validation.Valid;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.validation.annotation.Validated;
 import org.springframework.web.bind.annotation.*;
 
-import jakarta.annotation.Resource;
-import jakarta.validation.Valid;
 import java.util.Collection;
 import java.util.List;
 import java.util.Objects;
@@ -76,14 +76,6 @@ public class MemberUserController {
         return success(true);
     }
 
-    @PutMapping("/update-balance")
-    @Operation(summary = "更新会员用户余额")
-    @PreAuthorize("@ss.hasPermission('member:user:update-balance')")
-    public CommonResult<Boolean> updateUserBalance(@Valid @RequestBody Long id) {
-        // todo @jason:增加一个【修改余额】
-        return success(true);
-    }
-
     @GetMapping("/get")
     @Operation(summary = "获得会员用户")
     @Parameter(name = "id", description = "编号", required = true, example = "1024")

+ 2 - 3
yudao-module-pay/yudao-module-pay-api/src/main/java/cn/iocoder/yudao/module/pay/enums/wallet/PayWalletBizTypeEnum.java

@@ -18,9 +18,8 @@ public enum PayWalletBizTypeEnum implements IntArrayValuable {
     RECHARGE(1, "充值"),
     RECHARGE_REFUND(2, "充值退款"),
     PAYMENT(3, "支付"),
-    PAYMENT_REFUND(4, "支付退款");
-
-    // TODO 后续增加
+    PAYMENT_REFUND(4, "支付退款"),
+    UPDATE_BALANCE(5, "更新余额");
 
     /**
      * 业务分类

+ 22 - 3
yudao-module-pay/yudao-module-pay-biz/src/main/java/cn/iocoder/yudao/module/pay/controller/admin/wallet/PayWalletController.java

@@ -4,9 +4,11 @@ import cn.iocoder.yudao.framework.common.pojo.CommonResult;
 import cn.iocoder.yudao.framework.common.pojo.PageResult;
 import cn.iocoder.yudao.module.pay.controller.admin.wallet.vo.wallet.PayWalletPageReqVO;
 import cn.iocoder.yudao.module.pay.controller.admin.wallet.vo.wallet.PayWalletRespVO;
+import cn.iocoder.yudao.module.pay.controller.admin.wallet.vo.wallet.PayWalletUpdateBalanceReqVO;
 import cn.iocoder.yudao.module.pay.controller.admin.wallet.vo.wallet.PayWalletUserReqVO;
 import cn.iocoder.yudao.module.pay.convert.wallet.PayWalletConvert;
 import cn.iocoder.yudao.module.pay.dal.dataobject.wallet.PayWalletDO;
+import cn.iocoder.yudao.module.pay.enums.wallet.PayWalletBizTypeEnum;
 import cn.iocoder.yudao.module.pay.service.wallet.PayWalletService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
@@ -15,12 +17,12 @@ import jakarta.validation.Valid;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.validation.annotation.Validated;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.bind.annotation.*;
 
 import static cn.iocoder.yudao.framework.common.enums.UserTypeEnum.MEMBER;
+import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
 import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
+import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.WALLET_NOT_FOUND;
 
 @Tag(name = "管理后台 - 用户钱包")
 @RestController
@@ -48,4 +50,21 @@ public class PayWalletController {
         return success(PayWalletConvert.INSTANCE.convertPage(pageResult));
     }
 
+    @PutMapping("/update-balance")
+    @Operation(summary = "更新会员用户余额")
+    @PreAuthorize("@ss.hasPermission('pay:wallet:update-balance')")
+    public CommonResult<Boolean> updateWalletBalance(@Valid @RequestBody PayWalletUpdateBalanceReqVO updateReqVO) {
+        // 获得用户钱包
+        PayWalletDO wallet = payWalletService.getOrCreateWallet(updateReqVO.getUserId(), MEMBER.getValue());
+        if (wallet == null) {
+            log.error("[updateWalletBalance],updateReqVO({}) 用户钱包不存在.", updateReqVO);
+            throw exception(WALLET_NOT_FOUND);
+        }
+
+        // 更新钱包余额
+        payWalletService.addWalletBalance(wallet.getId(), String.valueOf(updateReqVO.getUserId()),
+                PayWalletBizTypeEnum.UPDATE_BALANCE, updateReqVO.getBalance());
+        return success(true);
+    }
+
 }

+ 19 - 0
yudao-module-pay/yudao-module-pay-biz/src/main/java/cn/iocoder/yudao/module/pay/controller/admin/wallet/vo/wallet/PayWalletUpdateBalanceReqVO.java

@@ -0,0 +1,19 @@
+package cn.iocoder.yudao.module.pay.controller.admin.wallet.vo.wallet;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+@Schema(description = "管理后台 - 修改钱包余额 Request VO")
+@Data
+public class PayWalletUpdateBalanceReqVO {
+
+    @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "23788")
+    @NotNull(message = "用户编号不能为空")
+    private Long userId;
+
+    @Schema(description = "变动余额,正数为增加,负数为减少", requiredMode = Schema.RequiredMode.REQUIRED, example = "100")
+    @NotNull(message = "变动余额不能为空")
+    private Integer balance;
+
+}

+ 4 - 1
yudao-module-pay/yudao-module-pay-biz/src/main/java/cn/iocoder/yudao/module/pay/service/wallet/PayWalletServiceImpl.java

@@ -12,12 +12,12 @@ import cn.iocoder.yudao.module.pay.enums.wallet.PayWalletBizTypeEnum;
 import cn.iocoder.yudao.module.pay.service.order.PayOrderService;
 import cn.iocoder.yudao.module.pay.service.refund.PayRefundService;
 import cn.iocoder.yudao.module.pay.service.wallet.bo.WalletTransactionCreateReqBO;
+import jakarta.annotation.Resource;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.context.annotation.Lazy;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
-import jakarta.annotation.Resource;
 import java.time.LocalDateTime;
 
 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
@@ -176,6 +176,9 @@ public class PayWalletServiceImpl implements  PayWalletService {
                 walletMapper.updateWhenRecharge(payWallet.getId(), price);
                 break;
             }
+            case UPDATE_BALANCE: // 更新余额
+                walletMapper.updateWhenRecharge(payWallet.getId(), price);
+                break;
             default: {
                 // TODO 其它类型待实现
                 throw new UnsupportedOperationException("待实现");

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

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

+ 2 - 1
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/SmsClientFactory.java

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

+ 0 - 6
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/AbstractSmsClient.java

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

+ 3 - 6
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/AliyunSmsClient.java

@@ -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); // 计算签名

+ 0 - 4
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/DebugDingTalkSmsClient.java

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

+ 106 - 166
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/HuaweiSmsClient.java

@@ -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);
     }
 
-    @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
-
-        //选填,短信状态报告接收地址,推荐使用域名,为空或者不填表示不接收状态报告
-        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());
+    /**
+     * 参数校验华为云的 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]");
     }
 
-    JSONObject sendSmsRequest(String sender,String mobile,String templateId,List<String> templateParas,String statusCallBack) throws UnsupportedEncodingException {
-
-        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 = "";//查询参数为空
-        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;
-
-        // ************* 步骤 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());
+    private String getAccessKey() {
+        return StrUtil.subBefore(properties.getApiKey(), " ", true);
     }
 
-    private SmsResponse getSmsSendResponse(JSONObject resJson) {
-        SmsResponse smsResponse = new SmsResponse();
-        smsResponse.setSuccess("000000".equals(resJson.getStr("code")));
-        smsResponse.setData(resJson);
-        return smsResponse;
+    private String getSender() {
+        return StrUtil.subAfter(properties.getApiKey(), " ", true);
     }
 
-    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;
+    @Override
+    public SmsSendRespDTO sendSms(Long sendLogId, String mobile, String apiTemplateId,
+                                  List<KeyValue<String, Object>> templateParams) throws Throwable {
+        StringBuilder requestBody = new StringBuilder();
+        appendToBody(requestBody, "from=", getSender());
+        appendToBody(requestBody, "&to=", mobile);
+        appendToBody(requestBody, "&templateId=", apiTemplateId);
+        appendToBody(requestBody, "&templateParas=", JsonUtils.toJsonString(
+                convertList(templateParams, kv -> String.valueOf(kv.getValue()))));
+        appendToBody(requestBody, "&statusCallback=", properties.getCallbackUrl());
+        appendToBody(requestBody, "&extend=", String.valueOf(sendLogId));
+        JSONObject response = request("/sms/batchSendSms/v1/", "POST", requestBody.toString());
+
+        // 2. 解析请求
+        if (!response.containsKey("result")) { // 例如说:密钥不正确
+            return new SmsSendRespDTO().setSuccess(false)
+                    .setApiCode(response.getStr("code"))
+                    .setApiMsg(response.getStr("description"));
         }
-
-        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();
+        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"));
     }
 
-    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"));
-        }
+    /**
+     * 请求华为云短信
+     *
+     * @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);
+
+        // 1.2 构建签名 Header
+        String canonicalQueryString = ""; // 查询参数为空
+        String canonicalHeaders = "content-type:application/x-www-form-urlencoded\n"
+                + "host:"+ HOST +"\n" + "x-sdk-date:" + sdkDate + "\n";
+        String canonicalRequest = method + "\n" + uri + "\n" + canonicalQueryString + "\n"
+                + canonicalHeaders + "\n" + SIGNEDHEADERS + "\n" + sha256Hex(requestBody);
+        String stringToSign = "SDK-HMAC-SHA256" + "\n" + sdkDate + "\n" + sha256Hex(canonicalRequest);
+        String signature = SecureUtil.hmacSha256(properties.getApiSecret()).digestHex(stringToSign);  // 计算签名
+        headers.put("Authorization", "SDK-HMAC-SHA256" + " " + "Access=" + getAccessKey()
+                + ", " + "SignedHeaders=" + SIGNEDHEADERS + ", " + "Signature=" + signature);
+
+        // 2. 发起请求
+        String responseBody = HttpUtils.post(URL, headers, requestBody);
+        return JSONUtil.parseObj(responseBody);
     }
+
     @Override
-    public List<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 {
-        //华为短信模板查询和发送短信,是不同的两套key和secret,与阿里、腾讯的区别较大,这里模板查询校验暂不实现。
-        return new SmsTemplateRespDTO().setId(null).setContent(null)
+        // 华为短信模板查询和发送短信,是不同的两套 key 和 secret,与阿里、腾讯的区别较大,这里模板查询校验暂不实现
+        String[] strs = apiTemplateId.split(" ");
+        Assert.isTrue(strs.length == 2, "格式不正确,需要满足:apiTemplateId sender");
+        return new SmsTemplateRespDTO().setId(apiTemplateId).setContent(null)
                 .setAuditStatus(SmsTemplateAuditStatusEnum.SUCCESS.getStatus()).setAuditReason(null);
-
-    }
-
-    @Data
-    public static class SmsResponse {
-
-        /**
-         * 是否成功
-         */
-        private boolean success;
-
-        /**
-         * 厂商原返回体
-         */
-        private Object data;
-
     }
 
-
-    /**
-     * 短信接收状态
-     *
-     * 参见 <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;
+    @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()));
+        }
     }
 
-}
+}

+ 155 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/QiniuSmsClient.java

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

+ 3 - 1
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/SmsClientFactoryImpl.java

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

+ 43 - 71
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/TencentSmsClient.java

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

+ 2 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/enums/SmsChannelEnum.java

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

+ 4 - 2
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2TokenServiceImpl.java

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

+ 10 - 73
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/sms/SmsChannelServiceImpl.java

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

+ 127 - 0
yudao-module-system/yudao-module-system-biz/src/test/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/HuaweiSmsClientTest.java

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

+ 131 - 0
yudao-module-system/yudao-module-system-biz/src/test/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/QiniuSmsClientTest.java

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

+ 54 - 46
yudao-module-system/yudao-module-system-biz/src/test/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/SmsClientTests.java

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

+ 26 - 1
yudao-module-system/yudao-module-system-biz/src/test/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/TencentSmsClientTest.java

@@ -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() {
         // 准备参数

+ 5 - 24
yudao-module-system/yudao-module-system-biz/src/test/java/cn/iocoder/yudao/module/system/service/sms/SmsChannelServiceTest.java

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

+ 30 - 30
yudao-server/pom.xml

@@ -33,11 +33,11 @@
         </dependency>
 
         <!-- 会员中心。默认注释,保证编译速度 -->
-<!--        <dependency>-->
-<!--            <groupId>cn.iocoder.boot</groupId>-->
-<!--            <artifactId>yudao-module-member-biz</artifactId>-->
-<!--            <version>${revision}</version>-->
-<!--        </dependency>-->
+        <dependency>
+            <groupId>cn.iocoder.boot</groupId>
+            <artifactId>yudao-module-member-biz</artifactId>
+            <version>${revision}</version>
+        </dependency>
 
         <!-- 数据报表。默认注释,保证编译速度 -->
 <!--        <dependency>-->
@@ -52,11 +52,11 @@
 <!--            <version>${revision}</version>-->
 <!--        </dependency>-->
         <!-- 支付服务。默认注释,保证编译速度 -->
-<!--        <dependency>-->
-<!--            <groupId>cn.iocoder.boot</groupId>-->
-<!--            <artifactId>yudao-module-pay-biz</artifactId>-->
-<!--            <version>${revision}</version>-->
-<!--        </dependency>-->
+        <dependency>
+            <groupId>cn.iocoder.boot</groupId>
+            <artifactId>yudao-module-pay-biz</artifactId>
+            <version>${revision}</version>
+        </dependency>
 
         <!-- 微信公众号模块。默认注释,保证编译速度 -->
 <!--        <dependency>-->
@@ -66,26 +66,26 @@
 <!--        </dependency>-->
 
         <!-- 商城相关模块。默认注释,保证编译速度-->
-<!--        <dependency>-->
-<!--            <groupId>cn.iocoder.boot</groupId>-->
-<!--            <artifactId>yudao-module-promotion-biz</artifactId>-->
-<!--            <version>${revision}</version>-->
-<!--        </dependency>-->
-<!--        <dependency>-->
-<!--            <groupId>cn.iocoder.boot</groupId>-->
-<!--            <artifactId>yudao-module-product-biz</artifactId>-->
-<!--            <version>${revision}</version>-->
-<!--        </dependency>-->
-<!--        <dependency>-->
-<!--            <groupId>cn.iocoder.boot</groupId>-->
-<!--            <artifactId>yudao-module-trade-biz</artifactId>-->
-<!--            <version>${revision}</version>-->
-<!--        </dependency>-->
-<!--        <dependency>-->
-<!--            <groupId>cn.iocoder.boot</groupId>-->
-<!--            <artifactId>yudao-module-statistics-biz</artifactId>-->
-<!--            <version>${revision}</version>-->
-<!--        </dependency>-->
+        <dependency>
+            <groupId>cn.iocoder.boot</groupId>
+            <artifactId>yudao-module-promotion-biz</artifactId>
+            <version>${revision}</version>
+        </dependency>
+        <dependency>
+            <groupId>cn.iocoder.boot</groupId>
+            <artifactId>yudao-module-product-biz</artifactId>
+            <version>${revision}</version>
+        </dependency>
+        <dependency>
+            <groupId>cn.iocoder.boot</groupId>
+            <artifactId>yudao-module-trade-biz</artifactId>
+            <version>${revision}</version>
+        </dependency>
+        <dependency>
+            <groupId>cn.iocoder.boot</groupId>
+            <artifactId>yudao-module-statistics-biz</artifactId>
+            <version>${revision}</version>
+        </dependency>
 
         <!-- CRM 相关模块。默认注释,保证编译速度 -->
 <!--        <dependency>-->

+ 2 - 2
yudao-server/src/main/resources/application-dev.yaml

@@ -40,12 +40,12 @@ spring:
       primary: master
       datasource:
         master:
-          url: jdbc:mysql://127.0.0.1:3306/ruoyi-vue-pro?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true # MySQL Connector/J 8.X 连接的示例
+          url: jdbc:mysql://127.0.0.1:3306/ruoyi-vue-pro?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true # MySQL Connector/J 8.X 连接的示例
           username: root
           password: 123456
         slave: # 模拟从库,可根据自己需要修改 # 模拟从库,可根据自己需要修改
           lazy: true # 开启懒加载,保证启动速度
-          url: jdbc:mysql://127.0.0.1:3306/ruoyi-vue-pro?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true # MySQL Connector/J 8.X 连接的示例
+          url: jdbc:mysql://127.0.0.1:3306/ruoyi-vue-pro?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true # MySQL Connector/J 8.X 连接的示例
           username: root
           password: 123456
 

+ 3 - 3
yudao-server/src/main/resources/application-local.yaml

@@ -45,8 +45,8 @@ spring:
       primary: master
       datasource:
         master:
-          url: jdbc:mysql://127.0.0.1:3306/ruoyi-vue-pro?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true # MySQL Connector/J 8.X 连接的示例
-          #          url: jdbc:mysql://127.0.0.1:3306/ruoyi-vue-pro?useSSL=true&allowPublicKeyRetrieval=true&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai # MySQL Connector/J 5.X 连接的示例
+          url: jdbc:mysql://127.0.0.1:3306/ruoyi-vue-pro?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true # MySQL Connector/J 8.X 连接的示例
+          #          url: jdbc:mysql://127.0.0.1:3306/ruoyi-vue-pro?useSSL=true&allowPublicKeyRetrieval=true&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true # MySQL Connector/J 5.X 连接的示例
           #          url: jdbc:postgresql://127.0.0.1:5432/ruoyi-vue-pro # PostgreSQL 连接的示例
           #          url: jdbc:oracle:thin:@127.0.0.1:1521:xe # Oracle 连接的示例
           #          url: jdbc:sqlserver://127.0.0.1:1433;DatabaseName=ruoyi-vue-pro;SelectMethod=cursor;encrypt=false;rewriteBatchedStatements=true;useUnicode=true;characterEncoding=utf-8 # SQLServer 连接的示例
@@ -63,7 +63,7 @@ spring:
           #          password: Yudao@2024 # OpenGauss 连接的示例
         slave: # 模拟从库,可根据自己需要修改
           lazy: true # 开启懒加载,保证启动速度
-          url: jdbc:mysql://127.0.0.1:3306/ruoyi-vue-pro?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true
+          url: jdbc:mysql://127.0.0.1:3306/ruoyi-vue-pro?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true&nullCatalogMeansCurrent=true
           username: root
           password: 123456
 

+ 1 - 0
yudao-server/src/main/resources/application.yaml

@@ -301,6 +301,7 @@ yudao:
       - tmp_report_data_1
       - tmp_report_data_income
     ignore-caches:
+      - user_role_ids
       - permission_menu_ids
       - oauth_client
       - notify_template