فهرست منبع

Merge remote-tracking branch 'origin/master'

lk 1 هفته پیش
والد
کامیت
00a9c79bc8
76فایلهای تغییر یافته به همراه2459 افزوده شده و 1336 حذف شده
  1. 30 25
      fs-admin/src/main/java/com/fs/his/task/Task.java
  2. 10 0
      fs-admin/src/main/java/com/fs/hisStore/controller/FsStoreAfterSalesScrmController.java
  3. 76 20
      fs-admin/src/main/java/com/fs/hisStore/task/MallStoreTask.java
  4. 11 5
      fs-admin/src/main/java/com/fs/qw/controller/CorporateWeChatSpaceController.java
  5. 4 0
      fs-ipad-task/src/main/java/com/fs/app/service/IpadSendServer.java
  6. 16 0
      fs-ipad-task/src/main/java/com/fs/app/task/SendMsg.java
  7. 2 1
      fs-service/src/main/java/com/fs/common/service/impl/SmsServiceImpl.java
  8. 1 1
      fs-service/src/main/java/com/fs/company/mapper/CompanyVoiceRoboticCalleesMapper.java
  9. 11 0
      fs-service/src/main/java/com/fs/erp/service/IErpOrderService.java
  10. 105 3
      fs-service/src/main/java/com/fs/erp/service/impl/JSTErpOrderServiceImpl.java
  11. 30 0
      fs-service/src/main/java/com/fs/gtPush/service/impl/uniPush2ServiceImpl.java
  12. 2 0
      fs-service/src/main/java/com/fs/gtPush/service/uniPush2Service.java
  13. 11 2
      fs-service/src/main/java/com/fs/his/domain/FsCourseCouponUser.java
  14. 3 0
      fs-service/src/main/java/com/fs/his/dto/PayloadDTO.java
  15. 7 0
      fs-service/src/main/java/com/fs/his/mapper/FsCourseCouponUserMapper.java
  16. 1 1
      fs-service/src/main/java/com/fs/his/mapper/FsUserIntegralLogsMapper.java
  17. 1 0
      fs-service/src/main/java/com/fs/his/param/FsUserIntegralLogsParam.java
  18. 3 0
      fs-service/src/main/java/com/fs/his/service/IFsCourseCouponUserService.java
  19. 193 110
      fs-service/src/main/java/com/fs/his/service/impl/FsCourseCouponServiceImpl.java
  20. 9 1
      fs-service/src/main/java/com/fs/his/service/impl/FsCourseCouponUserServiceImpl.java
  21. 38 0
      fs-service/src/main/java/com/fs/his/vo/CourseCouponUserListUVO.java
  22. 15 0
      fs-service/src/main/java/com/fs/hisStore/config/StoreConfig.java
  23. 3 0
      fs-service/src/main/java/com/fs/hisStore/domain/FsStoreAfterSalesScrm.java
  24. 19 0
      fs-service/src/main/java/com/fs/hisStore/param/FsStoreAfterSalesErpStatusParam.java
  25. 2 0
      fs-service/src/main/java/com/fs/hisStore/service/IFsStoreAfterSalesScrmService.java
  26. 94 6
      fs-service/src/main/java/com/fs/hisStore/service/impl/FsStoreAfterSalesScrmServiceImpl.java
  27. 2 1
      fs-service/src/main/java/com/fs/hisStore/service/impl/FsStoreProductPurchaseLimitScrmServiceImpl.java
  28. 3 0
      fs-service/src/main/java/com/fs/hisStore/vo/FsStoreAfterSalesVO.java
  29. 2 0
      fs-service/src/main/java/com/fs/im/service/OpenIMService.java
  30. 49 0
      fs-service/src/main/java/com/fs/im/service/impl/OpenIMServiceImpl.java
  31. 1 1
      fs-service/src/main/java/com/fs/qw/service/ICorporateWeChatSpaceService.java
  32. 41 0
      fs-service/src/main/java/com/fs/qw/service/impl/AsyncSopTestService.java
  33. 149 34
      fs-service/src/main/java/com/fs/qw/service/impl/ICorporateWeChatSpaceServiceImpl.java
  34. 49 0
      fs-service/src/main/java/com/fs/qw/utils/WeChatSpaceDecryptUtil.java
  35. 32 2
      fs-service/src/main/java/com/fs/qw/utils/WeChatSpaceUtil.java
  36. 0 38
      fs-service/src/main/java/com/fs/qw/utils/WeComSignatureUtil.java
  37. 22 8
      fs-service/src/main/java/com/fs/qw/vo/QwSessionConfigVo.java
  38. 54 0
      fs-service/src/main/java/com/fs/sop/service/impl/SopUserLogsInfoServiceImpl.java
  39. 1 1
      fs-service/src/main/resources/application-config-druid-sxsm.yml
  40. 1 1
      fs-service/src/main/resources/application-druid-bjzm-test.yml
  41. 1 1
      fs-service/src/main/resources/application-druid-bjzm.yml
  42. 1 1
      fs-service/src/main/resources/mapper/course/FsUserCourseMapper.xml
  43. 5 1
      fs-service/src/main/resources/mapper/hisStore/FsStoreAfterSalesScrmMapper.xml
  44. 38 7
      fs-spec-zone/Dockerfile
  45. 18 0
      fs-spec-zone/docker镜像制作步骤
  46. 45 37
      fs-spec-zone/pom.xml
  47. 0 32
      fs-spec-zone/src/main/java/README_SDK.md
  48. 0 43
      fs-spec-zone/src/main/java/com/fs/speczone/DebugModeRunner.java
  49. 0 13
      fs-spec-zone/src/main/java/com/fs/speczone/SpecZoneApplication.java
  50. 0 237
      fs-spec-zone/src/main/java/com/fs/speczone/controller/CallbackController.java
  51. 0 33
      fs-spec-zone/src/main/java/com/fs/speczone/controller/WeComApiController.java
  52. 0 25
      fs-spec-zone/src/main/java/com/fs/speczone/handler/FetchConversationsHandler.java
  53. 0 22
      fs-spec-zone/src/main/java/com/fs/speczone/handler/ProgramActionHandler.java
  54. 0 155
      fs-spec-zone/src/main/java/com/fs/speczone/sdk/SpecSdkAdapter.java
  55. 0 132
      fs-spec-zone/src/main/java/com/fs/speczone/service/ConversationService.java
  56. 0 11
      fs-spec-zone/src/main/java/com/fs/speczone/service/WeComService.java
  57. 0 149
      fs-spec-zone/src/main/java/com/fs/speczone/service/impl/WeComServiceImpl.java
  58. 0 65
      fs-spec-zone/src/main/java/com/fs/speczone/util/WeChatDecryptUtil.java
  59. 0 63
      fs-spec-zone/src/main/java/com/fs/speczone/util/WeChatTokenUtil.java
  60. 0 46
      fs-spec-zone/src/main/java/com/fs/speczone/util/WeComSignatureUtil.java
  61. 63 0
      fs-spec-zone/src/main/java/mytype/mycom/mygroup/CommonUtils.java
  62. 175 0
      fs-spec-zone/src/main/java/mytype/mycom/mygroup/DataBaseUtils.java
  63. 79 0
      fs-spec-zone/src/main/java/mytype/mycom/mygroup/DemoCallProgramHandler.java
  64. 72 0
      fs-spec-zone/src/main/java/mytype/mycom/mygroup/DemoReceiveCallBackHandler.java
  65. 123 0
      fs-spec-zone/src/main/java/mytype/mycom/mygroup/NetworkService.java
  66. 62 0
      fs-spec-zone/src/main/java/mytype/mycom/mygroup/RequestContext.java
  67. 263 0
      fs-spec-zone/src/main/java/mytype/mycom/mygroup/RequestProcessor.java
  68. 171 0
      fs-spec-zone/src/main/java/mytype/mycom/mygroup/ResourceMonitor.java
  69. 115 0
      fs-spec-zone/src/main/java/mytype/mycom/mygroup/SpecDemo.java
  70. 31 0
      fs-spec-zone/src/main/java/mytype/mycom/mygroup/SvrConfig.java
  71. 33 0
      fs-spec-zone/src/main/java/mytype/mycom/mygroup/ThreadPoolSingleton.java
  72. 20 0
      fs-spec-zone/src/main/java/mytype/mycom/mygroup/UserLogicHandler.java
  73. 3 0
      fs-user-app/src/main/java/com/fs/app/controller/AppLoginController.java
  74. 7 2
      fs-user-app/src/main/java/com/fs/app/controller/FsCourseCouponUserController.java
  75. 25 0
      fs-user-app/src/main/java/com/fs/app/controller/store/IndexScrmController.java
  76. 6 0
      fs-user-app/src/main/java/com/fs/app/controller/store/ProductScrmController.java

+ 30 - 25
fs-admin/src/main/java/com/fs/his/task/Task.java

@@ -926,35 +926,40 @@ public class Task {
     }
 
     public void CreateWeizouErpPush() {
-        List<Long> omsList = fsStoreOrderMapper.selectFsStoreOrderNoCreateOms();
-        List<Long> integrals = fsIntegralOrderMapper.selectFsStoreOrderNoCreateOms();
-        List<Long> scrms = fsStoreOrderScrmMapper.selectFsStoreOrderNoCreateOms();
-        logger.info("推送订单id====>{}", omsList);
-        logger.info("推送积分订单id====>{}", integrals);
-        logger.info("推送SCRM订单id====>{}", scrms);
-
-        for (Long l : omsList) {
-            try {
-                fsStoreOrderService.weizouPush(l);
-            } catch (Exception e) {
-                logger.error("推送订单异常:", e);
+        try {
+            List<Long> omsList = fsStoreOrderMapper.selectFsStoreOrderNoCreateOms();
+            List<Long> integrals = fsIntegralOrderMapper.selectFsStoreOrderNoCreateOms();
+            List<Long> scrms = fsStoreOrderScrmMapper.selectFsStoreOrderNoCreateOms();
+            logger.info("推送订单id====>{}", omsList);
+            logger.info("推送积分订单id====>{}", integrals);
+            logger.info("推送SCRM订单id====>{}", scrms);
+
+            for (Long l : omsList) {
+                try {
+                    fsStoreOrderService.weizouPush(l);
+                } catch (Exception e) {
+                    logger.error("推送订单异常:", e);
+                }
             }
-        }
-        for (Long l : integrals) {
-            try {
-                fsStoreOrderService.weizouPushIntergral(l);
-            } catch (Exception e) {
-                logger.error("推送积分订单异常:", e);
+            for (Long l : integrals) {
+                try {
+                    fsStoreOrderService.weizouPushIntergral(l);
+                } catch (Exception e) {
+                    logger.error("推送积分订单异常:", e);
+                }
             }
-        }
-        for (Long l : scrms) {
-            try {
-                fsStoreOrderService.weizouPushScrm(l);
-            } catch (Exception e) {
-                logger.error("推送SCRM订单异常:", e);
+            for (Long l : scrms) {
+                try {
+                    fsStoreOrderService.weizouPushScrm(l);
+                } catch (Exception e) {
+                    logger.error("推送SCRM订单异常:", e);
+                }
             }
+        }catch (Exception e){
+            logger.error("推送订单异常:", e);
         }
-    }
+        }
+
     public void CreateOmsAndHis() {
         List<Long> omsList = fsStoreOrderMapper.selectFsStoreOrderNoCreateOms();
         logger.info("推送订单id====>{}", omsList);

+ 10 - 0
fs-admin/src/main/java/com/fs/hisStore/controller/FsStoreAfterSalesScrmController.java

@@ -20,6 +20,7 @@ import com.fs.hisStore.domain.*;
 import com.fs.hisStore.param.FsStoreAfterSalesAudit1Param;
 import com.fs.hisStore.param.FsStoreAfterSalesAudit2Param;
 import com.fs.hisStore.param.FsStoreAfterSalesCancelParam;
+import com.fs.hisStore.param.FsStoreAfterSalesErpStatusParam;
 import com.fs.hisStore.param.FsStoreAfterSalesRefundParam;
 import com.fs.hisStore.service.IFsStoreAfterSalesItemScrmService;
 import com.fs.hisStore.service.IFsStoreAfterSalesScrmService;
@@ -225,6 +226,15 @@ public class FsStoreAfterSalesScrmController extends BaseController
     }
 
 
+    @PreAuthorize("@ss.hasPermi('store:storeAfterSales:edit')")
+    @PostMapping("/confirmErpStatus")
+    public R confirmErpStatus(@RequestBody FsStoreAfterSalesErpStatusParam param)
+    {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        param.setOperator(loginUser.getUser().getNickName());
+        return fsStoreAfterSalesService.updateErpExceptionStatus(param);
+    }
+
     @PreAuthorize("@ss.hasPermi('store:storeAfterSales:audit1')")
     @PostMapping("/audit1")
     //平台审核

+ 76 - 20
fs-admin/src/main/java/com/fs/hisStore/task/MallStoreTask.java

@@ -62,9 +62,11 @@ import java.text.SimpleDateFormat;
 import java.time.LocalTime;
 import java.util.ArrayList;
 import java.util.Date;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.CompletableFuture;
+import java.util.stream.Collectors;
 
 import static com.fs.hisStore.constants.StoreConstants.DELIVERY;
 
@@ -270,36 +272,90 @@ public class MallStoreTask
     }
 
     //每5分钟执行一次
-    public void deliveryOp()
+    public void deliveryOp() throws InterruptedException
     {
         List<FsStoreOrderScrm> list = fsStoreOrderMapper.selectUpdateExpress();
-        Date nowDate = DateUtils.getNowDate();
-        for (FsStoreOrderScrm order : list){
-            order.setUpdateTime(new Date());
-            orderService.updateFsStoreOrderDb(order);
+        if (list == null || list.isEmpty()) {
+            return;
+        }
+        IErpOrderService erpOrderService = getErpOrderService();
+        if (erpOrderService == null) {
+            return;
+        }
+
+        // 聚水潭:批量查询物流并同步发货
+        if (erpOrderService == jSTOrderService) {
+            jstErpOrderDeliveryScrm(list);
+            return;
+        }
+        // 其他 ERP:逐单查询
+        if (erpOrderService == dfOrderService) {
+            return;
+        }
+        for (FsStoreOrderScrm order : list) {
+            if (StringUtils.isEmpty(order.getExtendOrderId())) {
+                continue;
+            }
             ErpOrderQueryRequert request = new ErpOrderQueryRequert();
             request.setCode(order.getExtendOrderId());
-            IErpOrderService erpOrderService = getErpOrderService();
             ErpOrderQueryResponse response = erpOrderService.getScrmOrder(request);
-            if (erpOrderService != dfOrderService) {
-                if(response.getOrders()!=null && !response.getOrders().isEmpty()){
-                    for(ErpOrderQuery orderQuery : response.getOrders()){
-                        if (orderQuery.getDeliverys() != null && !orderQuery.getDeliverys().isEmpty()) {
-                            for (ErpDeliverys delivery : orderQuery.getDeliverys()) {
-                                if (delivery.getDelivery() && StringUtils.isNotEmpty(delivery.getMail_no())) {
-                                    //更新商订单状态 删除REDIS
-                                    orderService.deliveryOrder(order.getOrderCode(), delivery.getMail_no(), delivery.getExpress_code(), delivery.getExpress_name());
-                                    redisCache.deleteObject(DELIVERY + ":" + order.getExtendOrderId());
-                                }
-                            }
+            syncScrmDeliveryFromErpResponse(response, buildOrderCodeMap(list));
+        }
+    }
 
-                        }
-                    }
+    /**
+     * 聚水潭批量查询发货状态(参考 ffhx StoreTask#jstErpOrderDelivery)
+     */
+    private void jstErpOrderDeliveryScrm(List<FsStoreOrderScrm> list) throws InterruptedException {
+        List<FsStoreOrderScrm> pendingList = list.stream()
+                .filter(order -> StringUtils.isNotEmpty(order.getExtendOrderId()))
+                .collect(Collectors.toList());
+        if (pendingList.isEmpty()) {
+            return;
+        }
+        Map<String, FsStoreOrderScrm> orderCodeMap = buildOrderCodeMap(pendingList);
+        int batchSize = 100;
+        for (int i = 0; i < pendingList.size(); i += batchSize) {
+            if (i > 0) {
+                Thread.sleep(300);
+            }
+            int end = Math.min(i + batchSize, pendingList.size());
+            List<FsStoreOrderScrm> batchOrders = pendingList.subList(i, end);
+            ErpOrderQueryResponse response = jSTOrderService.batchGetScrmOrder(batchOrders);
+            syncScrmDeliveryFromErpResponse(response, orderCodeMap);
+        }
+    }
 
-                }
+    private Map<String, FsStoreOrderScrm> buildOrderCodeMap(List<FsStoreOrderScrm> list) {
+        Map<String, FsStoreOrderScrm> orderCodeMap = new HashMap<>();
+        for (FsStoreOrderScrm order : list) {
+            if (StringUtils.isNotEmpty(order.getOrderCode())) {
+                orderCodeMap.put(order.getOrderCode(), order);
             }
         }
+        return orderCodeMap;
+    }
 
+    private void syncScrmDeliveryFromErpResponse(ErpOrderQueryResponse response, Map<String, FsStoreOrderScrm> orderCodeMap) {
+        if (response == null || response.getOrders() == null || response.getOrders().isEmpty()) {
+            return;
+        }
+        for (ErpOrderQuery orderQuery : response.getOrders()) {
+            if (orderQuery.getDeliverys() == null || orderQuery.getDeliverys().isEmpty()) {
+                continue;
+            }
+            FsStoreOrderScrm order = orderCodeMap.get(orderQuery.getCode());
+            if (order == null) {
+                continue;
+            }
+            for (ErpDeliverys delivery : orderQuery.getDeliverys()) {
+                if (delivery.getDelivery() && StringUtils.isNotEmpty(delivery.getMail_no())) {
+                    orderService.deliveryOrder(order.getOrderCode(), delivery.getMail_no(),
+                            delivery.getExpress_code(), delivery.getExpress_name());
+                    redisCache.deleteObject(DELIVERY + ":" + order.getExtendOrderId());
+                }
+            }
+        }
     }
 
 

+ 11 - 5
fs-admin/src/main/java/com/fs/qw/controller/CorporateWeChatSpaceController.java

@@ -5,6 +5,7 @@ import com.fs.common.core.controller.BaseController;
 import com.fs.common.core.domain.AjaxResult;
 import com.fs.common.exception.CustomException;
 import com.fs.qw.service.ICorporateWeChatSpaceService;
+import com.fs.qw.vo.QwSessionConfigVo;
 import lombok.RequiredArgsConstructor;
 import org.springframework.web.bind.annotation.*;
 
@@ -21,18 +22,17 @@ public class CorporateWeChatSpaceController extends BaseController {
     // 企业微信会话专区中转接口
     @GetMapping("/conversations")
     public JSONObject getConversations(
-            @RequestParam(defaultValue = "0") long seq,
             @RequestParam(defaultValue = "100") long limit,
-            @RequestParam(defaultValue = "0") long proxy,
             @RequestParam(defaultValue = "30") long timeout,
+            @RequestParam(required = false) String cursor,
             @RequestParam(required = false) String customerId,
-            @RequestParam(required = false) String staffUserId) throws Exception {
+            @RequestParam(required = false) String staffUserId) {
         if (customerId == null|| customerId.isEmpty()) {
             throw new CustomException("客户id不能为空");
         }else if (staffUserId == null|| staffUserId.isEmpty()) {
             throw new CustomException("员工id不能为空");
         }
-        return weChatSpaceService.fetchConversations(seq, limit, proxy, timeout, customerId,staffUserId);
+        return weChatSpaceService.fetchConversations(limit, timeout, cursor, customerId,staffUserId);
     }
 
 
@@ -51,6 +51,12 @@ public class CorporateWeChatSpaceController extends BaseController {
     //获取企业微信专区会话配置
     @GetMapping("/getQwSessionConfig")
     public AjaxResult getQwSessionConfig() {
-        return AjaxResult.success(weChatSpaceService.getQwSessionConfig());
+        QwSessionConfigVo qwSessionConfig = weChatSpaceService.getQwSessionConfig();
+        //敏感信息设置为null
+        qwSessionConfig.setPrivateKey(null);
+        qwSessionConfig.setAgentSecret(null);
+        qwSessionConfig.setProgramId(null);
+        qwSessionConfig.setAbilityIds(null);
+        return AjaxResult.success(qwSessionConfig);
     }
 }

+ 4 - 0
fs-ipad-task/src/main/java/com/fs/app/service/IpadSendServer.java

@@ -777,6 +777,10 @@ public class IpadSendServer {
                     // 发送直播短链
                     sendLiveShortLink(vo, content, miniMap);
                     break;
+                case "20":
+                    content.setSendStatus(0);
+                    content.setSendRemarks("APP直播待发送");
+                    break;
                 case "21":
                     content.setSendStatus(0);
                     content.setSendRemarks("短信待发送");

+ 16 - 0
fs-ipad-task/src/main/java/com/fs/app/task/SendMsg.java

@@ -367,6 +367,10 @@ public class SendMsg {
                             if (!settings.isEmpty()) {
                                 asyncSopTestService.asyncSendMsgBySopAppMP3NormalIM(settings, qwSopLogs.getCorpId(), qwUser.getCompanyUserId(), qwSopLogs.getFsUserId());
                             }
+                            List<QwSopCourseFinishTempSetting.Setting> liveSettings = JSON.parseArray(JSON.toJSONString(setting.getSetting()), QwSopCourseFinishTempSetting.Setting.class).stream().filter(e -> "20".equals(e.getContentType())).collect(Collectors.toList());
+                            if (!liveSettings.isEmpty()) {
+                                asyncSopTestService.asyncSendMsgBySopAppLiveIM(liveSettings, qwSopLogs.getCorpId(), qwUser.getCompanyUserId(), qwSopLogs.getFsUserId(), qwSopLogs.getId(), qwUser.getCompanyId());
+                            }
                         } catch (Exception e) {
                             log.error("推送APP失败", e);
                         }
@@ -405,6 +409,18 @@ public class SendMsg {
                             successCount++;
                         }
                     }
+
+                    // app直播卡片
+                    settings = setting.getSetting().stream().filter(e -> "20".equals(e.getContentType())).collect(Collectors.toList());
+                    if (!settings.isEmpty()) {
+                        actualCount++;
+                        hasAppSend = true;
+                        boolean sendFlag = asyncSopTestService.asyncSendMsgBySopAppLiveIM(
+                                settings, qwSopLogs.getCorpId(), qwUser.getCompanyUserId(), qwSopLogs.getFsUserId(), qwSopLogs.getId(), qwUser.getCompanyId());
+                        if (sendFlag) {
+                            successCount++;
+                        }
+                    }
                 }
             }
             qwSopLogs.setSend(true);

+ 2 - 1
fs-service/src/main/java/com/fs/common/service/impl/SmsServiceImpl.java

@@ -1043,7 +1043,7 @@ public class SmsServiceImpl implements ISmsService
      * @param temp
      * @param param
      */
-//    @Async
+    @Async
     public void batchSmsOp4AiSend(CompanySmsTemp temp, SmsSendBatchParam param){
         CompanyUser companyUser=companyUserService.selectCompanyUserById(param.getCompanyUserId());
         CompanyVoiceRoboticCallBlacklistCheckParam companyVoiceRoboticCallBlacklistCheckParam = new CompanyVoiceRoboticCallBlacklistCheckParam();
@@ -1106,6 +1106,7 @@ public class SmsServiceImpl implements ISmsService
                 } catch (UnsupportedEncodingException e) {
                     e.printStackTrace();
                 }
+
                 String post = HttpRequest.get(urls)
 //                            .body(String.valueOf(jsonObject))
                         .execute().body();

+ 1 - 1
fs-service/src/main/java/com/fs/company/mapper/CompanyVoiceRoboticCalleesMapper.java

@@ -34,7 +34,7 @@ public interface CompanyVoiceRoboticCalleesMapper extends BaseMapper<CompanyVoic
     public List<CompanyVoiceRoboticCallees> selectCompanyVoiceRoboticCalleesList(CompanyVoiceRoboticCallees companyVoiceRoboticCallees);
 
     @Select("select cv.*,cw.is_add,cw.customer_id from company_voice_robotic_callees  cv " +
-            "left join  company_wx_client cw on cv.robotic_id = cw.robotic_id " +
+            "inner join  company_wx_client cw on cv.robotic_id = cw.robotic_id and cv.user_id=cw.customer_id " +
             "where cv.robotic_id = #{roboticId}")
     public List<CompanyVoiceRoboticCallees> selectCompanyVoiceRoboticCalleesListByRoboticId(@Param("roboticId") Long id);
 

+ 11 - 0
fs-service/src/main/java/com/fs/erp/service/IErpOrderService.java

@@ -7,6 +7,7 @@ import com.fs.his.domain.FsStoreOrder;
 import com.fs.hisStore.domain.FsStoreOrderScrm;
 import com.fs.live.domain.LiveOrder;
 
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 
@@ -45,6 +46,16 @@ public interface IErpOrderService
      */
     ErpOrderQueryResponse batchGetLiveOrder(List<LiveOrder> orderList);
 
+    /**
+     * 批量查询商城订单(聚水潭等)
+     */
+    default ErpOrderQueryResponse batchGetScrmOrder(List<FsStoreOrderScrm> orderList) {
+        ErpOrderQueryResponse response = new ErpOrderQueryResponse();
+        response.setOrders(Collections.emptyList());
+        response.setSuccess(true);
+        return response;
+    }
+
     //  出库查询接口 目前只实现了 旺店通
     default Map<String, Object> stockOutOrderQueryTrade(ErpOrderQueryRequert param)
     {

+ 105 - 3
fs-service/src/main/java/com/fs/erp/service/impl/JSTErpOrderServiceImpl.java

@@ -1006,10 +1006,13 @@ public class JSTErpOrderServiceImpl implements IErpOrderService {
     }
 
     private ErpOrderQuery convertToErpOrderQueryScrm(OrderQueryResponseDTO.Order order) {
-        ErpOrderQuery erpOrder = new ErpOrderQuery();
-
         FsStoreOrderScrm fsStoreOrder = fsStoreOrderService.selectFsStoreOrderScrmByOrderCode(order.getSoId());
-        Asserts.notNull(fsStoreOrder,"该订单号没有找到!");
+        Asserts.notNull(fsStoreOrder, "该订单号没有找到!");
+        return convertToErpOrderQueryScrm(order, fsStoreOrder);
+    }
+
+    private ErpOrderQuery convertToErpOrderQueryScrm(OrderQueryResponseDTO.Order order, FsStoreOrderScrm fsStoreOrder) {
+        ErpOrderQuery erpOrder = new ErpOrderQuery();
 
         // 设置基本订单信息
         erpOrder.setCode(order.getSoId());
@@ -1281,6 +1284,105 @@ public class JSTErpOrderServiceImpl implements IErpOrderService {
 
     }
 
+    /**
+     * 批量查询商城订单(聚水潭)
+     */
+    @Override
+    public ErpOrderQueryResponse batchGetScrmOrder(List<FsStoreOrderScrm> orderList) {
+        ErpOrderQueryResponse response = new ErpOrderQueryResponse();
+        if (CollectionUtils.isEmpty(orderList)) {
+            response.setOrders(Collections.emptyList());
+            response.setSuccess(true);
+            return response;
+        }
+
+        Map<String, FsStoreOrderScrm> orderByExtendId = orderList.stream()
+                .filter(order -> StringUtils.isNotEmpty(order.getExtendOrderId()))
+                .collect(Collectors.toMap(FsStoreOrderScrm::getExtendOrderId, order -> order, (a, b) -> a));
+        Map<String, FsStoreOrderScrm> orderByOrderCode = orderList.stream()
+                .filter(order -> StringUtils.isNotEmpty(order.getOrderCode()))
+                .collect(Collectors.toMap(FsStoreOrderScrm::getOrderCode, order -> order, (a, b) -> a));
+
+        List<Long> extendOrderIds = orderByExtendId.keySet().stream()
+                .map(Long::valueOf)
+                .collect(Collectors.toList());
+        if (CollectionUtils.isEmpty(extendOrderIds)) {
+            response.setOrders(Collections.emptyList());
+            response.setSuccess(true);
+            return response;
+        }
+
+        List<ErpOrderQuery> erpOrders = new ArrayList<>();
+        int batchSize = 100;
+        for (int i = 0; i < extendOrderIds.size(); i += batchSize) {
+            List<Long> batch = extendOrderIds.subList(i, Math.min(i + batchSize, extendOrderIds.size()));
+            try {
+                OrderQueryRequestDTO requestDTO = new OrderQueryRequestDTO();
+                requestDTO.setOIds(batch);
+                OrderQueryResponseDTO query = jstErpHttpService.query(requestDTO);
+                if (query.getOrders() == null || query.getOrders().isEmpty()) {
+                    continue;
+                }
+                for (OrderQueryResponseDTO.Order jstOrder : query.getOrders()) {
+                    appendScrmErpOrderFromJst(jstOrder, orderByExtendId, orderByOrderCode, erpOrders);
+                }
+            } catch (Exception e) {
+                log.error("聚水潭批量查询商城订单失败,batchSize={}", batch.size(), e);
+            }
+        }
+
+        response.setOrders(erpOrders);
+        response.setSuccess(true);
+        return response;
+    }
+
+    private void appendScrmErpOrderFromJst(OrderQueryResponseDTO.Order jstOrder,
+                                           Map<String, FsStoreOrderScrm> orderByExtendId,
+                                           Map<String, FsStoreOrderScrm> orderByOrderCode,
+                                           List<ErpOrderQuery> erpOrders) {
+        if (jstOrder == null) {
+            return;
+        }
+        if (ErpQueryOrderStatusEnum.MERGED.getCode().equals(jstOrder.getStatus())
+                && StringUtils.isNotEmpty(jstOrder.getLinkOId())) {
+            OrderQueryResponseDTO mergedWrapper = new OrderQueryResponseDTO();
+            mergedWrapper.setOrders(Collections.singletonList(jstOrder));
+            OrderQueryResponseDTO mergeQuery = followMergedJstQueryResponse(mergedWrapper, 1);
+            if (mergeQuery != null && !CollectionUtils.isEmpty(mergeQuery.getOrders())) {
+                for (OrderQueryResponseDTO.Order mergedOrder : mergeQuery.getOrders()) {
+                    FsStoreOrderScrm localOrder = resolveScrmOrder(mergedOrder, orderByExtendId, orderByOrderCode);
+                    if (localOrder != null) {
+                        erpOrders.add(convertToErpOrderQueryScrm(mergedOrder, localOrder));
+                    }
+                }
+                persistOuterOiIdFromJstMergedOrder(jstOrder);
+            }
+            return;
+        }
+        FsStoreOrderScrm localOrder = resolveScrmOrder(jstOrder, orderByExtendId, orderByOrderCode);
+        if (localOrder != null) {
+            erpOrders.add(convertToErpOrderQueryScrm(jstOrder, localOrder));
+        }
+    }
+
+    private FsStoreOrderScrm resolveScrmOrder(OrderQueryResponseDTO.Order jstOrder,
+                                              Map<String, FsStoreOrderScrm> orderByExtendId,
+                                              Map<String, FsStoreOrderScrm> orderByOrderCode) {
+        if (jstOrder == null) {
+            return null;
+        }
+        if (StringUtils.isNotEmpty(jstOrder.getSoId())) {
+            FsStoreOrderScrm byCode = orderByOrderCode.get(jstOrder.getSoId());
+            if (byCode != null) {
+                return byCode;
+            }
+        }
+        if (jstOrder.getOId() != null) {
+            return orderByExtendId.get(String.valueOf(jstOrder.getOId()));
+        }
+        return null;
+    }
+
     /**
      * 批量查询直播订单
      * @param orderList 订单列表

+ 30 - 0
fs-service/src/main/java/com/fs/gtPush/service/impl/uniPush2ServiceImpl.java

@@ -277,6 +277,36 @@ public class uniPush2ServiceImpl implements uniPush2Service {
         return openIMService.sendCourse(fsUserId, companyUserId, appLinkUrl, linkDescribe, linkImageUrl, cropId);
     }
 
+    @Override
+    public OpenImResponseDTO pushSopAppLinkMsgByLiveIM(String cropId, String linkTile, String linkDescribe, Long liveId, String link, Long companyUserId, Long fsUserId, Long companyId) throws JsonProcessingException {
+        if (companyUserId == null || fsUserId == null || fsUserId == 0) {
+            OpenImResponseDTO errorResponse = new OpenImResponseDTO();
+            errorResponse.setErrCode(-1);
+            errorResponse.setErrMsg("参数错误:用户未绑定销售");
+            errorResponse.setErrDlt("缺少必要参数");
+            return errorResponse;
+        }
+
+        FsUser fsUser = fsUserMapper.selectFsUserByUserId(fsUserId);
+        if (fsUser == null) {
+            OpenImResponseDTO errorResponse = new OpenImResponseDTO();
+            errorResponse.setErrCode(-2);
+            errorResponse.setErrMsg("未找到对应的用户信息");
+            errorResponse.setErrDlt("用户ID: " + fsUserId);
+            return errorResponse;
+        }
+
+        if (StringUtils.isEmpty(fsUser.getHistoryApp())) {
+            OpenImResponseDTO errorResponse = new OpenImResponseDTO();
+            errorResponse.setErrCode(-3);
+            errorResponse.setErrMsg("用户未绑定APP");
+            errorResponse.setErrDlt("用户历史APP信息为空");
+            return errorResponse;
+        }
+
+        return openIMService.sendLive(fsUserId, companyUserId, link, linkTile, liveId, cropId, companyId);
+    }
+
     /**
      * 专用于 pushIm 的参数构建方法
      */

+ 2 - 0
fs-service/src/main/java/com/fs/gtPush/service/uniPush2Service.java

@@ -16,4 +16,6 @@ public interface uniPush2Service {
     void pushIm(Long userId, Long businessId, String purl, String title, String content, Float type, Integer desType,String imJsonString);
 
     OpenImResponseDTO pushSopAppLinkMsgByExternalIMV2(String cropId, String linkTitle, String linkDescribe, String linkImageUrl, String appLinkUrl, Long companyUserId, Long fsUserId) throws JsonProcessingException;
+
+    OpenImResponseDTO pushSopAppLinkMsgByLiveIM(String cropId, String linkTile, String linkDescribe, Long liveId, String link, Long companyUserId, Long fsUserId, Long companyId) throws JsonProcessingException;
 }

+ 11 - 2
fs-service/src/main/java/com/fs/his/domain/FsCourseCouponUser.java

@@ -1,6 +1,9 @@
 package com.fs.his.domain;
 
 import java.util.Date;
+
+import com.baomidou.mybatisplus.annotation.FieldFill;
+import com.baomidou.mybatisplus.annotation.TableField;
 import com.fasterxml.jackson.annotation.JsonFormat;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.fs.common.annotation.Excel;
@@ -15,8 +18,7 @@ import lombok.EqualsAndHashCode;
  * @date 2026-05-13
  */
 @Data
-@EqualsAndHashCode(callSuper = true)
-public class FsCourseCouponUser extends BaseEntity{
+public class FsCourseCouponUser {
 
     /** $column.columnComment */
     private Long id;
@@ -48,5 +50,12 @@ public class FsCourseCouponUser extends BaseEntity{
      */
     private Long logId;
 
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @TableField(fill = FieldFill.INSERT)
+    private Date createTime;
 
+    /** 更新时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private Date updateTime;
 }

+ 3 - 0
fs-service/src/main/java/com/fs/his/dto/PayloadDTO.java

@@ -1,6 +1,7 @@
 package com.fs.his.dto;
 
 import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fs.live.domain.Live;
 import lombok.Data;
 
 import java.io.Serializable;
@@ -36,6 +37,8 @@ public class PayloadDTO implements Serializable {
         private Long companyUserId;
         private Long doctorId;
         private Long userInformationId;
+        private Long liveId;
+        private Live live;
     }
 
 }

+ 7 - 0
fs-service/src/main/java/com/fs/his/mapper/FsCourseCouponUserMapper.java

@@ -3,6 +3,7 @@ package com.fs.his.mapper;
 import java.util.List;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.fs.his.domain.FsCourseCouponUser;
+import com.fs.his.vo.CourseCouponUserListUVO;
 import org.apache.ibatis.annotations.Param;
 import org.apache.ibatis.annotations.Select;
 
@@ -66,4 +67,10 @@ public interface FsCourseCouponUserMapper extends BaseMapper<FsCourseCouponUser>
 
     @Select("SELECT * FROM fs_course_coupon_user WHERE log_id = #{logId}")
     FsCourseCouponUser selectByLogId(@Param("logId") Long logId);
+    @Select("SELECT cu.*,c.title couponName \n" +
+            "FROM `fs_course_coupon_user` cu \n" +
+            "LEFT JOIN fs_course_coupon c ON cu.coupon_id = c.id \n" +
+            "WHERE cu.user_id = #{param.userId} \n" +
+            "AND cu.`status` = #{param.status}")
+    List<CourseCouponUserListUVO> selectCourseCouponUserList(@Param("param") FsCourseCouponUser fsCourseCouponUser);
 }

+ 1 - 1
fs-service/src/main/java/com/fs/his/mapper/FsUserIntegralLogsMapper.java

@@ -77,7 +77,7 @@ public interface FsUserIntegralLogsMapper
             "LEFT JOIN fs_user u ON u.user_id=l.user_id "+
             " <where>  \n" +
             "            <if test=\"userId != null \"> and l.user_id = #{userId}</if>\n" +
-            "            <if test=\"nick_name != null \"> and u.nick_name = #{nickName}</if>\n" +
+            "            <if test=\"nickName != null \"> and u.nick_name = #{nickName}</if>\n" +
             "            <if test=\"logType != null  and logType != ''\"> and log_type = #{logType}</if>\n" +
             "            <if test=\"phone != null \"> and u.phone = #{phone}</if>\n" +
             "            <if test=\"businessId != null  and businessId != ''\"> and business_id = #{businessId}</if>\n" +

+ 1 - 0
fs-service/src/main/java/com/fs/his/param/FsUserIntegralLogsParam.java

@@ -26,6 +26,7 @@ public class FsUserIntegralLogsParam {
     /** 积分余额 */
     @Excel(name = "积分余额")
     private Long balance;
+
     private String nickName;
 
     private String phone;

+ 3 - 0
fs-service/src/main/java/com/fs/his/service/IFsCourseCouponUserService.java

@@ -4,6 +4,7 @@ import java.util.List;
 import com.baomidou.mybatisplus.extension.service.IService;
 import com.fs.common.core.domain.R;
 import com.fs.his.domain.FsCourseCouponUser;
+import com.fs.his.vo.CourseCouponUserListUVO;
 
 /**
  * 用户看课优惠券Service接口
@@ -61,4 +62,6 @@ public interface IFsCourseCouponUserService extends IService<FsCourseCouponUser>
     int deleteFsCourseCouponUserById(Long id);
 
     R useCoupon(Long userId,Long couponUserId);
+
+    List<CourseCouponUserListUVO> selectCourseCouponUserUVOList(FsCourseCouponUser courseCouponUser);
 }

+ 193 - 110
fs-service/src/main/java/com/fs/his/service/impl/FsCourseCouponServiceImpl.java

@@ -18,6 +18,7 @@ import com.fs.course.mapper.FsUserCourseVideoMapper;
 import com.fs.course.mapper.FsUserCourseVideoRedPackageMapper;
 import com.fs.his.domain.FsCourseCouponUser;
 import com.fs.his.mapper.FsCourseCouponUserMapper;
+import com.fs.his.param.CourseFinishRewardParam;
 import com.fs.his.vo.CourseFinishRewardVO;
 import com.fs.system.service.ISysConfigService;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -134,109 +135,210 @@ public class FsCourseCouponServiceImpl extends ServiceImpl<FsCourseCouponMapper,
     @Override
     @Transactional
     public R sendAutoCourseCoupon(Long videoId, Long userId,Long companyId,Long qwUserId,Long qwExternalId) {
-        CourseFinishRewardVO rewardVO = new CourseFinishRewardVO();
-        rewardVO.setTag(0);
-        Integer integral = null;
-        BigDecimal amount = null;
-        String couponName = null;
-
-        boolean isSend = true;
-        //课程小节信息
-        FsUserCourseVideo courseVideo = userCourseVideoMapper.selectFsUserCourseVideoByVideoId(videoId);
-        if (courseVideo == null) {
-            return R.error("课程小节不存在");
-        }
-
-        FsCourseWatchLog log = courseWatchLogMapper.getWatchCourseVideo(userId, videoId, qwUserId.toString(), qwExternalId);
-        if (log == null){
-            return R.error("请先观看课程");
-        }
-        FsCourseCouponUser selectByLogId = courseCouponUserMapper.selectByLogId(log.getLogId());
-        if (selectByLogId != null) {
-            return R.ok().put("data", rewardVO);
-        }
-        //优惠券
-        FsCourseCoupon fsCourseCoupon = baseMapper.selectFsCourseCouponById(courseVideo.getCourseCouponId());
-        if (fsCourseCoupon != null) {
-            if (fsCourseCoupon.getStatus() != 1) {
-                isSend = false;
-            }
-            if (fsCourseCoupon.getRemainNumber() <= 0) {
-                isSend = false;
-            }
-            //用户领取优惠券总数
-            Integer count = courseCouponUserMapper.selectCountByUserIdAndCouponId(userId, courseVideo.getCourseCouponId());
-            if (count >= fsCourseCoupon.getLimitCount()) {
-                isSend = false;
-            }
-        } else {
-            isSend = false;
-        }
-
-        if (isSend) {
-            //发放优惠券
-            FsCourseCouponUser couponUser = new FsCourseCouponUser();
-            couponUser.setCouponId(courseVideo.getCourseCouponId());
-            couponUser.setUserId(userId);
-            couponUser.setStartTime(new Date());
-            couponUser.setLimitTime(fsCourseCoupon.getLimitTime());
-            couponUser.setCreateTime(new Date());
-            couponUser.setLogId(log.getLogId());
-            int i = courseCouponUserMapper.insertFsCourseCouponUser(couponUser);
-
-            //返回的优惠券名称
-            couponName = fsCourseCoupon.getTitle();
-            if (i > 0) {
-                FsCourseCoupon coupon = new FsCourseCoupon();
-                coupon.setId(courseVideo.getCourseCouponId());
-                coupon.setRemainNumber(fsCourseCoupon.getRemainNumber() - 1);
-                baseMapper.updateFsCourseCoupon(coupon);
-            }
-        }
-        // 获取配置信息
-        String json = configService.selectConfigByKey("course.config");
-        CourseConfig config = JSONUtil.toBean(json, CourseConfig.class);
-        Integer rewardType = config.getRewardType();
-
-        if (rewardType == 1) {
-            FsUserCourseVideoRedPackage redPackage = fsUserCourseVideoRedPackageMapper.selectRedPacketByVideoIdAndType(videoId, companyId, 1);
-            if (redPackage != null && redPackage.getRedPacketMoney() != null) {
-                amount = redPackage.getRedPacketMoney();
-            } else if (courseVideo.getRedPacketMoney() != null) {
-                amount = courseVideo.getRedPacketMoney();
-            }
-        }
-        if (rewardType == 2) {
-            integral = config.getAnswerIntegral();
-        }
-        rewardVO.setCouponName(couponName);
-        rewardVO.setIntegral(integral);
-        rewardVO.setRedPacketMoney(amount);
-        rewardVO.setTag(1);
-        return R.ok().put("data",rewardVO);
+        CourseFinishRewardParam param = new CourseFinishRewardParam();
+        param.setVideoId(videoId);
+        param.setUserId(userId);
+        param.setCompanyId(companyId);
+        param.setQwUsrId(qwUserId);
+        param.setQwExternalId(qwExternalId);
+        return sendCoupon(param,1);
+//        CourseFinishRewardVO rewardVO = new CourseFinishRewardVO();
+//        rewardVO.setTag(0);
+//        String couponName = null;
+//
+//        boolean isSend = true;
+//        //课程小节信息
+//        FsUserCourseVideo courseVideo = userCourseVideoMapper.selectFsUserCourseVideoByVideoId(videoId);
+//        if (courseVideo == null) {
+//            return R.error("课程小节不存在");
+//        }
+//        if (courseVideo.getCourseCouponId() == null){
+//            //没用优惠券直接返回
+//            return R.ok().put("data", rewardVO);
+//        }
+//
+//        FsCourseWatchLog log = courseWatchLogMapper.getWatchCourseVideo(userId, videoId, qwUserId.toString(), qwExternalId);
+//        if (log == null){
+//            return R.error("请先观看课程");
+//        }
+//        FsCourseCouponUser selectByLogId = courseCouponUserMapper.selectByLogId(log.getLogId());
+//        if (selectByLogId != null) {
+//            return R.ok().put("data", rewardVO);
+//        }
+//        //优惠券
+//        FsCourseCoupon fsCourseCoupon = baseMapper.selectFsCourseCouponById(courseVideo.getCourseCouponId());
+//        if (fsCourseCoupon != null) {
+//            if (fsCourseCoupon.getStatus() != 1) {
+//                isSend = false;
+//            }
+//            if (fsCourseCoupon.getRemainNumber() <= 0) {
+//                isSend = false;
+//            }
+//            //用户领取优惠券总数
+//            Integer count = courseCouponUserMapper.selectCountByUserIdAndCouponId(userId, courseVideo.getCourseCouponId());
+//            if (count >= fsCourseCoupon.getLimitCount()) {
+//                isSend = false;
+//            }
+//        } else {
+//            isSend = false;
+//        }
+//
+//        if (isSend) {
+//            //发放优惠券
+//            FsCourseCouponUser couponUser = new FsCourseCouponUser();
+//            couponUser.setCouponId(courseVideo.getCourseCouponId());
+//            couponUser.setUserId(userId);
+//            couponUser.setStartTime(new Date());
+//            couponUser.setLimitTime(fsCourseCoupon.getLimitTime());
+//            couponUser.setCreateTime(new Date());
+//            couponUser.setLogId(log.getLogId());
+//            int i = courseCouponUserMapper.insertFsCourseCouponUser(couponUser);
+//
+//            //返回的优惠券名称
+//            couponName = fsCourseCoupon.getTitle();
+//            if (i > 0) {
+//                FsCourseCoupon coupon = new FsCourseCoupon();
+//                coupon.setId(courseVideo.getCourseCouponId());
+//                coupon.setRemainNumber(fsCourseCoupon.getRemainNumber() - 1);
+//                baseMapper.updateFsCourseCoupon(coupon);
+//            }
+//            rewardVO.setCouponName(couponName);
+//            rewardVO.setTag(1);
+//            return R.ok().put("data",rewardVO);
+//        } else {
+//            return R.ok().put("data",rewardVO);
+//        }
     }
 
     //手动
     @Transactional
     @Override
     public R sendCourseCoupon(Long videoId, Long userId,Long companyId,Long periodId,Long companyUserId) {
+        CourseFinishRewardParam param = new CourseFinishRewardParam();
+        param.setVideoId(videoId);
+        param.setUserId(userId);
+        param.setCompanyId(companyId);
+        param.setPeriodId(periodId);
+        param.setCompanyUserId(companyUserId);
+        return sendCoupon(param,2);
+//        CourseFinishRewardVO rewardVO = new CourseFinishRewardVO();
+//        rewardVO.setTag(0);
+//        String couponName;
+//
+//        boolean isSend = true;
+//        //课程小节信息
+//        FsUserCourseVideo courseVideo = userCourseVideoMapper.selectFsUserCourseVideoByVideoId(videoId);
+//        if (courseVideo == null) {
+//            return R.error("课程小节不存在");
+//        }
+//
+//        if (courseVideo.getCourseCouponId() == null){
+//            //没用优惠券直接返回
+//            return R.ok().put("data", rewardVO);
+//        }
+//
+//        FsCourseWatchLog log = courseWatchLogMapper.getWatchLogByFsUserAndPeriodId(videoId, userId, companyUserId, periodId);
+//        if (log == null) {
+//            return R.error("请先观看课程");
+//        }
+//        FsCourseCouponUser selectByLogId = courseCouponUserMapper.selectByLogId(log.getLogId());
+//        if (selectByLogId != null) {
+//            return R.ok().put("data", rewardVO);
+//        }
+//
+//        //优惠券
+//        FsCourseCoupon fsCourseCoupon = baseMapper.selectFsCourseCouponById(courseVideo.getCourseCouponId());
+//        if (fsCourseCoupon != null) {
+//            if (fsCourseCoupon.getStatus() != 1) {
+//                isSend = false;
+//            }
+//            if (fsCourseCoupon.getRemainNumber() <= 0) {
+//                isSend = false;
+//            }
+//            //用户领取优惠券总数
+//            Integer count = courseCouponUserMapper.selectCountByUserIdAndCouponId(userId, courseVideo.getCourseCouponId());
+//            if (count >= fsCourseCoupon.getLimitCount()) {
+//                isSend = false;
+//            }
+//        } else {
+//            isSend = false;
+//        }
+//
+//        if (isSend) {
+//            //发放优惠券
+//            FsCourseCouponUser couponUser = new FsCourseCouponUser();
+//            couponUser.setCouponId(courseVideo.getCourseCouponId());
+//            couponUser.setUserId(userId);
+//            couponUser.setStartTime(new Date());
+//            couponUser.setLimitTime(fsCourseCoupon.getLimitTime());
+//            couponUser.setCreateTime(new Date());
+//            couponUser.setLogId(log.getLogId());
+//            int i = courseCouponUserMapper.insertFsCourseCouponUser(couponUser);
+//
+//            //返回的优惠券名称
+//            couponName = fsCourseCoupon.getTitle();
+//            if (i > 0) {
+//                FsCourseCoupon coupon = new FsCourseCoupon();
+//                coupon.setId(courseVideo.getCourseCouponId());
+//                coupon.setRemainNumber(fsCourseCoupon.getRemainNumber() - 1);
+//                baseMapper.updateFsCourseCoupon(coupon);
+//            }
+//            rewardVO.setCouponName(couponName);
+//            rewardVO.setTag(1);
+//            return R.ok().put("data",rewardVO);
+//        } else {
+//            return R.ok().put("data", rewardVO);
+//        }
+////        // 获取配置信息
+////        String json = configService.selectConfigByKey("course.config");
+////        CourseConfig config = JSONUtil.toBean(json, CourseConfig.class);
+////        Integer rewardType = config.getRewardType();
+////
+////        if (rewardType == 1) {
+////            FsUserCourseVideoRedPackage redPackage = fsUserCourseVideoRedPackageMapper.selectRedPacketByCompanyId(videoId,companyId,periodId);
+////            if (redPackage != null && redPackage.getRedPacketMoney() != null) {
+////                amount = redPackage.getRedPacketMoney();
+////            } else if (courseVideo.getRedPacketMoney() != null) {
+////                amount = courseVideo.getRedPacketMoney();
+////            }
+////        }
+////        if (rewardType == 2) {
+////            integral = config.getAnswerIntegral();
+////        }
+    }
+
+    /**
+     * 发送看课优惠券
+     * @param param 优惠券参数
+     * @param type 1 自动 2 手动
+     * @return 结果
+     */
+    private R sendCoupon(CourseFinishRewardParam param,Integer type){
         CourseFinishRewardVO rewardVO = new CourseFinishRewardVO();
+        //默认不弹窗
         rewardVO.setTag(0);
-        Integer integral = null;
-        BigDecimal amount = null;
-        String couponName = null;
-
+        //默认发送优惠券
         boolean isSend = true;
         //课程小节信息
-        FsUserCourseVideo courseVideo = userCourseVideoMapper.selectFsUserCourseVideoByVideoId(videoId);
+        FsUserCourseVideo courseVideo = userCourseVideoMapper.selectFsUserCourseVideoByVideoId(param.getVideoId());
         if (courseVideo == null) {
             return R.error("课程小节不存在");
         }
 
-        FsCourseWatchLog log = courseWatchLogMapper.getWatchLogByFsUserAndPeriodId(videoId, userId, companyUserId, periodId);
+        if (courseVideo.getCourseCouponId() == null){
+            //没用优惠券直接返回
+            return R.ok().put("data", rewardVO);
+        }
+        FsCourseWatchLog log = null;
+        if (type == 1) {
+            log = courseWatchLogMapper.getWatchCourseVideo(param.getUserId(), param.getVideoId(), param.getQwUsrId().toString(), param.getQwExternalId());
+        } else {
+            log = courseWatchLogMapper.getWatchLogByFsUserAndPeriodId(param.getVideoId(), param.getUserId(), param.getCompanyUserId(), param.getPeriodId());
+        }
         if (log == null) {
             return R.error("请先观看课程");
         }
+
         FsCourseCouponUser selectByLogId = courseCouponUserMapper.selectByLogId(log.getLogId());
         if (selectByLogId != null) {
             return R.ok().put("data", rewardVO);
@@ -252,7 +354,7 @@ public class FsCourseCouponServiceImpl extends ServiceImpl<FsCourseCouponMapper,
                 isSend = false;
             }
             //用户领取优惠券总数
-            Integer count = courseCouponUserMapper.selectCountByUserIdAndCouponId(userId, courseVideo.getCourseCouponId());
+            Integer count = courseCouponUserMapper.selectCountByUserIdAndCouponId(param.getUserId(), courseVideo.getCourseCouponId());
             if (count >= fsCourseCoupon.getLimitCount()) {
                 isSend = false;
             }
@@ -264,42 +366,23 @@ public class FsCourseCouponServiceImpl extends ServiceImpl<FsCourseCouponMapper,
             //发放优惠券
             FsCourseCouponUser couponUser = new FsCourseCouponUser();
             couponUser.setCouponId(courseVideo.getCourseCouponId());
-            couponUser.setUserId(userId);
+            couponUser.setUserId(param.getUserId());
             couponUser.setStartTime(new Date());
             couponUser.setLimitTime(fsCourseCoupon.getLimitTime());
             couponUser.setCreateTime(new Date());
             couponUser.setLogId(log.getLogId());
             int i = courseCouponUserMapper.insertFsCourseCouponUser(couponUser);
 
-            //返回的优惠券名称
-            couponName = fsCourseCoupon.getTitle();
             if (i > 0) {
                 FsCourseCoupon coupon = new FsCourseCoupon();
                 coupon.setId(courseVideo.getCourseCouponId());
                 coupon.setRemainNumber(fsCourseCoupon.getRemainNumber() - 1);
                 baseMapper.updateFsCourseCoupon(coupon);
             }
+            //返回的优惠券名称
+            rewardVO.setCouponName(fsCourseCoupon.getTitle());
+            rewardVO.setTag(1);
         }
-        // 获取配置信息
-        String json = configService.selectConfigByKey("course.config");
-        CourseConfig config = JSONUtil.toBean(json, CourseConfig.class);
-        Integer rewardType = config.getRewardType();
-
-        if (rewardType == 1) {
-            FsUserCourseVideoRedPackage redPackage = fsUserCourseVideoRedPackageMapper.selectRedPacketByCompanyId(videoId,companyId,periodId);
-            if (redPackage != null && redPackage.getRedPacketMoney() != null) {
-                amount = redPackage.getRedPacketMoney();
-            } else if (courseVideo.getRedPacketMoney() != null) {
-                amount = courseVideo.getRedPacketMoney();
-            }
-        }
-        if (rewardType == 2) {
-            integral = config.getAnswerIntegral();
-        }
-        rewardVO.setCouponName(couponName);
-        rewardVO.setIntegral(integral);
-        rewardVO.setRedPacketMoney(amount);
-        rewardVO.setTag(1);
         return R.ok().put("data",rewardVO);
     }
 }

+ 9 - 1
fs-service/src/main/java/com/fs/his/service/impl/FsCourseCouponUserServiceImpl.java

@@ -1,5 +1,6 @@
 package com.fs.his.service.impl;
 
+import java.util.Collections;
 import java.util.Date;
 import java.util.List;
 import java.util.Objects;
@@ -7,6 +8,7 @@ import java.util.Objects;
 import com.fs.common.core.domain.R;
 import com.fs.common.utils.DateUtils;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.fs.his.vo.CourseCouponUserListUVO;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import com.fs.his.mapper.FsCourseCouponUserMapper;
@@ -113,12 +115,18 @@ public class FsCourseCouponUserServiceImpl extends ServiceImpl<FsCourseCouponUse
             return R.error("优惠券已过期");
         }
         FsCourseCouponUser map = new FsCourseCouponUser();
-        map.setId(couponUser.getCouponId());
+        map.setId(couponUser.getId());
         map.setStatus(1);
+        map.setUpdateTime(new Date());
         int i = baseMapper.updateById(map);
         if (i > 0) {
             return R.ok("优惠券使用成功");
         }
         return R.error("优惠券使用失败");
     }
+
+    @Override
+    public List<CourseCouponUserListUVO> selectCourseCouponUserUVOList(FsCourseCouponUser courseCouponUser) {
+        return baseMapper.selectCourseCouponUserList(courseCouponUser);
+    }
 }

+ 38 - 0
fs-service/src/main/java/com/fs/his/vo/CourseCouponUserListUVO.java

@@ -0,0 +1,38 @@
+package com.fs.his.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.util.Date;
+
+@Data
+public class CourseCouponUserListUVO {
+    private Long id;
+
+    /** 优惠券id */
+    private Long couponId;
+
+    /** 用户id */
+    private Long userId;
+
+    /** 有效期 */
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    private Date limitTime;
+
+    /** 开始时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    private Date startTime;
+
+    /** 核销状态 0-未核销 1-已核销 */
+    private Integer status;
+
+    /**
+     * 看课记录ID
+     */
+    private Long logId;
+
+    /**
+     * 优惠券名称
+     */
+    private String couponName;
+}

+ 15 - 0
fs-service/src/main/java/com/fs/hisStore/config/StoreConfig.java

@@ -69,4 +69,19 @@ public class StoreConfig implements Serializable {
      * 是否开启商城订单归属绑定(开启后,orderType=0的商城订单会自动绑定归属销售)
      */
     private Boolean enableStoreOrderAttribution;
+
+    /**
+     * 商城首页模块一展示
+     */
+    private Boolean enableHomeModuleOneShow;
+
+    /**
+     * 商城首页模块二展示
+     */
+    private Boolean enableHomeModuleTwoShow;
+
+    /**
+     * 取消商城聚水潭订单校验(true=取消校验,ERP失败仍回滚;false=开启校验,失败标记异常不阻断申请)
+     */
+    private Boolean cancelStoreJstOrderCheck;
 }

+ 3 - 0
fs-service/src/main/java/com/fs/hisStore/domain/FsStoreAfterSalesScrm.java

@@ -157,6 +157,9 @@ public class FsStoreAfterSalesScrm extends BaseEntity
 
     private String auditReasonName;
 
+    /** ERP异常状态:1-异常,2-已处理 */
+    private Integer erpExceptionStatus;
+
     /** 列表/详情查询时别名映射:一级原因展示文案 */
     @TableField(exist = false)
     private String reasonValue1;

+ 19 - 0
fs-service/src/main/java/com/fs/hisStore/param/FsStoreAfterSalesErpStatusParam.java

@@ -0,0 +1,19 @@
+package com.fs.hisStore.param;
+
+import lombok.Data;
+
+/**
+ * 售后 ERP 异常状态确认参数
+ */
+@Data
+public class FsStoreAfterSalesErpStatusParam {
+
+    /** 售后单 ID */
+    private Long salesId;
+
+    /** ERP异常状态:1-异常,2-已处理 */
+    private Integer erpExceptionStatus;
+
+    /** 操作人 */
+    private String operator;
+}

+ 2 - 0
fs-service/src/main/java/com/fs/hisStore/service/IFsStoreAfterSalesScrmService.java

@@ -95,6 +95,8 @@ public interface IFsStoreAfterSalesScrmService
 
     R audit1(FsStoreAfterSalesAudit1Param param);
 
+    R updateErpExceptionStatus(FsStoreAfterSalesErpStatusParam param);
+
     R audit2(FsStoreAfterSalesAudit2Param param);
 
     List<FsStoreAfterSalesScrm> selectFsStoreAfterSalesByDoAudit();

+ 94 - 6
fs-service/src/main/java/com/fs/hisStore/service/impl/FsStoreAfterSalesScrmServiceImpl.java

@@ -489,18 +489,73 @@ public class FsStoreAfterSalesScrmServiceImpl implements IFsStoreAfterSalesScrmS
         // 团购订单申请退款时先把占的名额还回去,SQL 内部只对未成团的团生效,已成团的不会动
         releaseGroupSlotIfNeeded(order);
         if (StringUtils.isNotBlank(order.getExtendOrderId())){
-            BaseResponse response=erpOrderService.refundUpdateScrm(request);
-            if(response.getSuccess()){
-                return R.ok();
-            }
-            else{
+            boolean jstCheckEnabled = isStoreJstOrderCheckEnabled();
+            try {
+                BaseResponse response = erpOrderService.refundUpdateScrm(request);
+                if (response.getSuccess()) {
+                    return R.ok();
+                }
+                if (jstCheckEnabled) {
+                    markErpException(storeAfterSales.getId(), userId, response.getErrorDesc());
+                    return R.ok();
+                }
                 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
                 return R.error(response.getErrorDesc());
+            } catch (Exception e) {
+                logger.error("聚水潭售后同步异常,orderCode={},afterSalesId={}",
+                        order.getOrderCode(), storeAfterSales.getId(), e);
+                if (jstCheckEnabled) {
+                    markErpException(storeAfterSales.getId(), userId, e.getMessage());
+                    return R.ok();
+                }
+                TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
+                return R.error("聚水潭订单同步失败:" + e.getMessage());
             }
         }
         return R.ok();
     }
 
+    /**
+     * 是否开启商城聚水潭订单校验(未取消校验时开启)
+     */
+    private boolean isStoreJstOrderCheckEnabled() {
+        StoreConfig config = loadHisStoreConfig();
+        return Boolean.TRUE.equals(config.getCancelStoreJstOrderCheck());
+    }
+
+    private StoreConfig loadHisStoreConfig() {
+        String json = configService.selectConfigByKey("his.store");
+        if (StringUtils.isBlank(json)) {
+            return new StoreConfig();
+        }
+        return JSONUtil.toBean(json, StoreConfig.class);
+    }
+
+    /**
+     * 标记售后单 ERP 异常并记录操作日志
+     */
+    private void markErpException(Long afterSalesId, Long userId, String errorMsg) {
+        FsStoreAfterSalesScrm update = new FsStoreAfterSalesScrm();
+        update.setId(afterSalesId);
+        update.setErpExceptionStatus(1);
+        fsStoreAfterSalesMapper.updateFsStoreAfterSales(update);
+
+        FsStoreAfterSalesStatusScrm storeAfterSalesStatus = new FsStoreAfterSalesStatusScrm();
+        storeAfterSalesStatus.setStoreAfterSalesId(afterSalesId);
+        storeAfterSalesStatus.setChangeType(0);
+        String message = "聚水潭订单同步异常"
+                + (StringUtils.isNotBlank(errorMsg) ? ":" + errorMsg : "");
+        storeAfterSalesStatus.setChangeMessage(message);
+        storeAfterSalesStatus.setChangeTime(Timestamp.valueOf(LocalDateTime.now()));
+        if (userId != null) {
+            FsUserScrm user = userService.selectFsUserById(userId);
+            if (user != null) {
+                storeAfterSalesStatus.setOperator(user.getNickname());
+            }
+        }
+        afterSalesStatusService.insertFsStoreAfterSalesStatus(storeAfterSalesStatus);
+    }
+
     /**
      * 团购订单申请退款时还回占的名额。
      * <p>仅对 orderType=8 且已有 groupBuyId 的订单生效;
@@ -1006,7 +1061,7 @@ public class FsStoreAfterSalesScrmServiceImpl implements IFsStoreAfterSalesScrmS
                 FsStoreOrderItemVO itemVO = orderItemVOS.stream().filter(i -> i.getProductId().equals(item.getProductId())).findFirst().orElse(null);
                 if(Objects.nonNull(itemVO) && itemVO.getIsAfterSales() == 1 && Objects.nonNull(item.getNum())){
                     // 1商城订单 2 直播订单
-                    if (order.getOrderType()==1) {
+                    if (order.getOrderType()==1 || order.getOrderType()==0) {
                         productService.incProductStock(item.getNum().longValue(), item.getProductId(), null);
                     }else if(order.getOrderType()==2){
                     // 是个bug,直播订单合并到商城订单,购买的时候  +直播商品库存-直播商品销量,取消的时候又+商品管理库存-商品管理销量(反正销量和库存可以随便改,这里直播库存暂定不做修改了)
@@ -1284,6 +1339,36 @@ public class FsStoreAfterSalesScrmServiceImpl implements IFsStoreAfterSalesScrmS
         return fsStoreAfterSalesMapper.selectFsStoreAfterSalesVOByOrderCode(orderCode);
     }
 
+    @Override
+    public R updateErpExceptionStatus(FsStoreAfterSalesErpStatusParam param) {
+        if (param.getSalesId() == null) {
+            return R.error("售后单ID不能为空");
+        }
+        if (param.getErpExceptionStatus() == null
+                || (param.getErpExceptionStatus() != 1 && param.getErpExceptionStatus() != 2)) {
+            return R.error("ERP异常状态无效");
+        }
+        FsStoreAfterSalesScrm storeAfterSales = fsStoreAfterSalesMapper.selectFsStoreAfterSalesById(param.getSalesId());
+        if (storeAfterSales == null) {
+            return R.error("未查询到售后订单信息");
+        }
+        FsStoreAfterSalesScrm update = new FsStoreAfterSalesScrm();
+        update.setId(param.getSalesId());
+        update.setErpExceptionStatus(param.getErpExceptionStatus());
+        fsStoreAfterSalesMapper.updateFsStoreAfterSales(update);
+
+        FsStoreAfterSalesStatusScrm storeAfterSalesStatus = new FsStoreAfterSalesStatusScrm();
+        storeAfterSalesStatus.setStoreAfterSalesId(storeAfterSales.getId());
+        storeAfterSalesStatus.setChangeType(0);
+        storeAfterSalesStatus.setChangeMessage(param.getErpExceptionStatus() == 2
+                ? "ERP订单状态确认:通过"
+                : "ERP订单状态确认:不通过");
+        storeAfterSalesStatus.setChangeTime(Timestamp.valueOf(LocalDateTime.now()));
+        storeAfterSalesStatus.setOperator(param.getOperator());
+        afterSalesStatusService.insertFsStoreAfterSalesStatus(storeAfterSalesStatus);
+        return R.ok("操作成功");
+    }
+
     @Override
     public R audit1(FsStoreAfterSalesAudit1Param param) {
         FsStoreAfterSalesScrm storeAfterSales = fsStoreAfterSalesMapper.selectFsStoreAfterSalesById(param.getSalesId());
@@ -1293,6 +1378,9 @@ public class FsStoreAfterSalesScrmServiceImpl implements IFsStoreAfterSalesScrmS
         if (!storeAfterSales.getStatus().equals(AfterStatusEnum.STATUS_0.getValue())) {
             throw new CustomException("非法操作");
         }
+        if (storeAfterSales.getErpExceptionStatus() != null && storeAfterSales.getErpExceptionStatus() == 1) {
+            throw new CustomException("订单ERP异常未处理,请先确认ERP订单状态");
+        }
         //仅退款
         if(storeAfterSales.getServiceType().equals(0)){
             //仅退款未发货处理

+ 2 - 1
fs-service/src/main/java/com/fs/hisStore/service/impl/FsStoreProductPurchaseLimitScrmServiceImpl.java

@@ -178,8 +178,9 @@ public class FsStoreProductPurchaseLimitScrmServiceImpl implements IFsStoreProdu
                 limit.setProductId(productId);
                 limit.setUserId(userId);
                 limit.setNum(num);
+                int i = insertFsStoreProductPurchaseLimit(limit);
                 redisCacheT.setCacheObject(key, limit);
-                return insertFsStoreProductPurchaseLimit(limit);
+                return i;
             } else {
                 // 更新现有记录
                 limit.setNum(limit.getNum() + num);

+ 3 - 0
fs-service/src/main/java/com/fs/hisStore/vo/FsStoreAfterSalesVO.java

@@ -149,6 +149,9 @@ public class FsStoreAfterSalesVO implements Serializable
 
     private Long reasonId1;
 
+    /** ERP异常状态:1-异常,2-已处理 */
+    private Integer erpExceptionStatus;
+
     private Long reasonId2;
     @Excel(name = "退款类型")
     private String refundType;

+ 2 - 0
fs-service/src/main/java/com/fs/im/service/OpenIMService.java

@@ -34,6 +34,8 @@ public interface OpenIMService {
     R accountCheck(String userId, String type);
     void checkAndImportFriend(Long companyUserId,String fsUserId);
     OpenImResponseDTO sendCourse(Long userId,Long companyUserId,String url,String title,String linkImageUrl,String cropId) throws JsonProcessingException;
+
+    OpenImResponseDTO sendLive(Long userId, Long companyUserId, String url, String title, Long liveId, String cropId, Long companyId) throws JsonProcessingException;
     void checkAndImportFriendByDianBo(Long companyUserId,String fsUserId,String cropId, boolean isUpdate);
 
     OpenImResponseDTO updateUserInfo(CompanyUser companyUser);

+ 49 - 0
fs-service/src/main/java/com/fs/im/service/impl/OpenIMServiceImpl.java

@@ -33,6 +33,8 @@ import com.fs.his.dto.PayloadDTO;
 import com.fs.his.mapper.FsDoctorMapper;
 import com.fs.his.mapper.FsFollowMapper;
 import com.fs.his.mapper.FsUserMapper;
+import com.fs.live.domain.Live;
+import com.fs.live.mapper.LiveMapper;
 import com.fs.im.config.IMConfig;
 import com.fs.im.domain.FsImMsgSendDetail;
 import com.fs.im.domain.FsImMsgSendLog;
@@ -80,6 +82,8 @@ public class OpenIMServiceImpl implements OpenIMService {
     @Autowired
     private CompanyMapper companyMapper;
     @Autowired
+    private LiveMapper liveMapper;
+    @Autowired
     private FsFollowMapper fsFollowMapper;
     @Autowired
     private QwExternalContactMapper qwExternalContactMapper;
@@ -558,6 +562,51 @@ public class OpenIMServiceImpl implements OpenIMService {
         content = null;
         return openImResponseDTO;
     }
+
+    @Override
+    public OpenImResponseDTO sendLive(Long userId, Long companyUserId, String url, String title, Long liveId, String cropId, Long companyId) throws JsonProcessingException {
+        Live live = liveMapper.selectLiveByLiveId(liveId);
+        Company company = companyMapper.selectCompanyById(companyId);
+        CompanyUser companyUser = companyUserMapper.selectCompanyUserById(companyUserId);
+        ObjectMapper objectMapper = new ObjectMapper();
+        objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
+        checkAndImportFriendByDianBo(companyUserId, userId.toString(), cropId, true);
+        OpenImMsgDTO.Content content = new OpenImMsgDTO.Content();
+        OpenImMsgDTO.ImData imData = new OpenImMsgDTO.ImData();
+        PayloadDTO payload = new PayloadDTO();
+        PayloadDTO.Extension extension = new PayloadDTO.Extension();
+        payload.setData("live");
+        extension.setTitle(title);
+        extension.setAppRealLink(url);
+        extension.setSendTime(new Date());
+        extension.setLiveId(liveId);
+        if (company != null) {
+            extension.setCompanyId(company.getCompanyId());
+        }
+        extension.setCompanyUserId(companyUserId);
+        extension.setLive(live);
+        payload.setExtension(extension);
+        imData.setPayload(payload);
+        String imJson = objectMapper.writeValueAsString(imData);
+        content.setData(imJson);
+
+        OpenImMsgDTO.OfflinePushInfo offlinePushInfo = new OpenImMsgDTO.OfflinePushInfo();
+        offlinePushInfo.setDesc(title);
+        if (companyUser != null) {
+            offlinePushInfo.setTitle(StringUtils.isNotEmpty(companyUser.getNickName()) ? companyUser.getNickName() : companyUser.getUserName());
+        }
+
+        OpenImMsgDTO openImMsgDTO = new OpenImMsgDTO();
+        openImMsgDTO.setOfflinePushInfo(offlinePushInfo);
+        openImMsgDTO.setContent(content);
+        openImMsgDTO.setSendID("C" + companyUserId);
+        openImMsgDTO.setRecvID("U" + userId);
+        openImMsgDTO.setContentType(110);
+        openImMsgDTO.setSessionType(1);
+        log.info("app直播消息: {}", JSON.toJSONString(openImMsgDTO));
+        return openIMSendMsg(openImMsgDTO);
+    }
+
     @Override
     public OpenImResponseDTO sendPackageUtil(String sendID, String recvID, Integer contentType, String payloadData,String packageName,String packageId){
         try {

+ 1 - 1
fs-service/src/main/java/com/fs/qw/service/ICorporateWeChatSpaceService.java

@@ -7,7 +7,7 @@ public interface ICorporateWeChatSpaceService {
     /**
      * 通过专区中转获取会话记录
      */
-    JSONObject fetchConversations(long seq, long limit, long proxy, long timeout, String customerId,String staffUserId);
+    JSONObject fetchConversations(long limit,long timeout,String cursor, String customerId,String staffUserId);
 
     /**
      * 获取 agentConfig 签名(供前端 JS-SDK 使用)

+ 41 - 0
fs-service/src/main/java/com/fs/qw/service/impl/AsyncSopTestService.java

@@ -745,4 +745,45 @@ public class AsyncSopTestService {
         log.info("APP语音发送完成,logId={}", logId);
         return success;
     }
+
+    public boolean asyncSendMsgBySopAppLiveIM(
+            List<QwSopCourseFinishTempSetting.Setting> setting,
+            String cropId,
+            Long companyUserId,
+            Long fsUserId,
+            String logId,
+            Long companyId) {
+        boolean success = true;
+        for (QwSopCourseFinishTempSetting.Setting item : setting) {
+            item.setSendStatus(2);
+            item.setSendRemarks("APP直播发送失败");
+            try {
+                OpenImResponseDTO resp = push2Service.pushSopAppLinkMsgByLiveIM(
+                        cropId,
+                        item.getMiniprogramTitle(),
+                        item.getMiniprogramTitle(),
+                        Long.valueOf(item.getLiveId()),
+                        item.getMiniprogramPicUrl(),
+                        companyUserId,
+                        fsUserId,
+                        companyId
+                );
+                if (resp != null && resp.getErrCode() != null && resp.getErrCode() == 0) {
+                    item.setSendStatus(1);
+                    item.setSendRemarks("APP直播发送成功");
+                } else {
+                    success = false;
+                    if (resp != null) {
+                        item.setSendRemarks(resp.getErrMsg());
+                    }
+                }
+            } catch (Exception e) {
+                success = false;
+                item.setSendRemarks("异常:" + e.getMessage());
+                log.error("APP直播发送异常 logId={}", logId, e);
+            }
+        }
+        log.info("APP直播发送完成,logId={},结果={}", logId, success);
+        return success;
+    }
 }

+ 149 - 34
fs-service/src/main/java/com/fs/qw/service/impl/ICorporateWeChatSpaceServiceImpl.java

@@ -2,18 +2,27 @@ package com.fs.qw.service.impl;
 
 
 import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
 import com.alibaba.fastjson.JSONObject;
 import com.fs.common.exception.CustomException;
 import com.fs.qw.service.ICorporateWeChatSpaceService;
-import com.fs.qw.utils.WeChatTokenUtil;
-import com.fs.qw.utils.WeComSignatureUtil;
+import com.fs.qw.utils.WeChatSpaceDecryptUtil;
+import com.fs.qw.utils.WeChatSpaceUtil;
 import com.fs.qw.vo.QwSessionConfigVo;
 import com.fs.system.service.ISysConfigService;
-import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import org.springframework.web.client.RestTemplate;
+import lombok.RequiredArgsConstructor;
+
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.stream.Collectors;
 
 @Slf4j
 @Service
@@ -23,73 +32,100 @@ public class ICorporateWeChatSpaceServiceImpl implements ICorporateWeChatSpaceSe
     @Autowired
     private ISysConfigService sysConfigService;
 
+    private final RestTemplate restTemplate = new RestTemplate();
+
+    private final ConcurrentHashMap<String, String> consumedCodes = new ConcurrentHashMap<>();
+
+    // 系统配置缓存前缀
     private final static String CONFIG_KEY = "qw.sessionConfig";
 
-    private final RestTemplate restTemplate = new RestTemplate();
-    private final java.util.concurrent.ConcurrentHashMap<String, String> consumedCodes = new java.util.concurrent.ConcurrentHashMap<>();
+    //获取会话记录Key
+    private final static String targetKey = "invokeSyncMsg";
+
+    private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
 
     // =============== 核心:通过专区中转拉取会话 ===============
     @Override
-    public JSONObject fetchConversations(long seq, long limit, long proxy, long timeout,
+    public JSONObject fetchConversations(long limit,long timeout,String cursor,
                                          String customerId, String staffUserId) {
         JSONObject result = new JSONObject();
         try {
-            // 1. 获取企业微信配置(避免重复调用 getQwSessionConfig())
             QwSessionConfigVo qwConfig = getQwSessionConfig();
             String corpid = qwConfig.getCorpid();
             String agentSecret = qwConfig.getAgentSecret();
-            // 2. 获取 access_token
-            String accessToken = WeChatTokenUtil.getAccessToken(corpid, agentSecret);
+            String accessToken = WeChatSpaceUtil.getAccessToken(corpid, agentSecret);
 
-            // 3. 构建 request_data(与专区程序约定一致
+            // 构建 request_data(invoke_sync_msg 能力要求
             JSONObject requestData = new JSONObject();
-            requestData.put("action", qwConfig.getAbilityAction());  // 对应能力 action
-            requestData.put("seq", seq);
-            requestData.put("limit", limit);
-            requestData.put("proxy", proxy);
-            requestData.put("timeout", timeout);
-            requestData.put("customerId", customerId);
-            requestData.put("staffUserId", staffUserId);
-
-            // 4. 能力ID
-            String abilityId = qwConfig.getFetchConversationAbilityId();
-            if (abilityId==null){
-                throw new CustomException("专区能力ID未配置");
+            if (StringUtils.isNotBlank(cursor)){// 首次为空不传
+                requestData.put("cursor", cursor);
             }
-            // 5. 调用 sync_call_program 接口
+
+            requestData.put("limit", limit > 0 ? limit : 200);  // 限制 1-1000
+            //requestData.put("token", "");  // 暂不传 token,有频率限制但测试够用
+
+            String abilityId = null;
+
+            if (qwConfig.getAbilityIds() != null) {
+                //根据配置的能力Key找到对应的 abilityId
+                abilityId = qwConfig.getAbilityIds().stream()
+                        .filter(item -> targetKey.equals(item.getKey()))
+                        .map(QwSessionConfigVo.AbilityItem::getValue)
+                        .findFirst()
+                        .orElse(null);
+            }
+            if (abilityId == null) {
+                throw new CustomException("未配置获取会话记录的能力ID");
+            }
+
+            // 调用 sync_call_program
             String url = "https://qyapi.weixin.qq.com/cgi-bin/chatdata/sync_call_program?access_token=" + accessToken;
             JSONObject requestBody = new JSONObject();
+            requestBody.put("program_id", qwConfig.getProgramId());
             requestBody.put("ability_id", abilityId);
             requestBody.put("request_data", JSON.toJSONString(requestData));
-            requestBody.put("program_id", qwConfig.getProgramId());
+
             log.info("调用专区接口: ability_id={}, request_data={}", abilityId, requestData);
             JSONObject response = restTemplate.postForObject(url, requestBody, JSONObject.class);
-            log.info("专区响应: {}", response);
+            //log.info("专区响应: {}", response);
 
-            // 6. 处理返回结果
             if (response != null && response.getInteger("errcode") == 0) {
                 String responseDataStr = response.getString("response_data");
                 if (responseDataStr != null) {
                     JSONObject responseData = JSON.parseObject(responseDataStr);
-                    if (responseData.getInteger("errcode") == 0) {
+                    Integer innerErrCode = responseData.getInteger("errcode");
+                    if (innerErrCode != null && innerErrCode == 0) {
+                        //获取 cursor用于下次拉取更多数据
+                        String nextCursor = responseData.getString("next_cursor");
+                        // 获取消息列表并处理
+                        JSONArray msgList = responseData.getJSONArray("msg_list");
+                        if (msgList != null && !msgList.isEmpty()) {
+                            // 解密 + 过滤 + 格式化
+                            JSONArray processedList = processMessages(msgList, customerId, staffUserId, qwConfig);
+                            result.put("data", processedList);
+                        } else {
+                            result.put("data", new JSONArray());
+                        }
                         result.put("errcode", 0);
                         result.put("errmsg", "ok");
-                        result.put("msgList", responseData.get("data"));
+                        //返回 has_more 和 next_cursor 给前端,当没有更多数据时,返回 has_more 为 0
+                        result.put("has_more", responseData.getInteger("has_more"));
+                        result.put("next_cursor", nextCursor);
                     } else {
-                        result.put("errcode", responseData.getInteger("errcode"));
+                        // 专区内部错误
+                        result.put("errcode", innerErrCode);
                         result.put("errmsg", responseData.getString("errmsg"));
                     }
                 } else {
                     result.put("errcode", -1);
                     result.put("errmsg", "专区返回数据格式错误");
                 }
-
             } else {
                 result.put("errcode", response != null ? response.getInteger("errcode") : -1);
-                result.put("errmsg", response != null ? response.getString("errmsg") : "专区调用失败");
+                result.put("errmsg", response != null ? response.getString("errmsg") : "调用专区失败");
             }
         } catch (Exception e) {
-            log.error("专区中转调用异常", e);
+            log.error("获取会话记录失败", e);
             result.put("errcode", -1);
             result.put("errmsg", "内部错误:" + e.getMessage());
         }
@@ -97,10 +133,11 @@ public class ICorporateWeChatSpaceServiceImpl implements ICorporateWeChatSpaceSe
     }
 
 
+
     @Override
     public JSONObject getAgentConfigSignature(String url) {
         QwSessionConfigVo qwSessionConfig = getQwSessionConfig();
-        return WeComSignatureUtil.generateAgentConfigSignature(qwSessionConfig.getCorpid(), qwSessionConfig.getAgentSecret(), qwSessionConfig.getAgentid(), url);
+        return WeChatSpaceUtil.generateAgentConfigSignature(qwSessionConfig.getCorpid(), qwSessionConfig.getAgentSecret(), qwSessionConfig.getAgentid(), url);
     }
 
     @Override
@@ -118,7 +155,7 @@ public class ICorporateWeChatSpaceServiceImpl implements ICorporateWeChatSpaceSe
         }
         QwSessionConfigVo qwSessionConfig = getQwSessionConfig();
         try {
-            String accessToken = WeChatTokenUtil.getAccessToken(qwSessionConfig.getCorpid(), qwSessionConfig.getAgentSecret());
+            String accessToken = WeChatSpaceUtil.getAccessToken(qwSessionConfig.getCorpid(), qwSessionConfig.getAgentSecret());
             String url = "https://qyapi.weixin.qq.com/cgi-bin/user/getuserinfo?access_token="
                     + accessToken + "&code=" + code;
             JSONObject resp = restTemplate.getForObject(url, JSONObject.class);
@@ -155,4 +192,82 @@ public class ICorporateWeChatSpaceServiceImpl implements ICorporateWeChatSpaceSe
         }
         return qwSessionConfig;
     }
+
+
+    private JSONArray processMessages(JSONArray msgList, String customerId, String staffUserId, QwSessionConfigVo qwConfig) {
+        return msgList.parallelStream()
+                .map(obj -> (JSONObject) obj)
+                .filter(msg -> isMessageRelatedToUsers(msg, customerId, staffUserId))
+                .map(msg -> decryptAndFormatMessage(msg, qwConfig))
+                .filter(Objects::nonNull)
+                .collect(Collectors.toCollection(JSONArray::new));
+    }
+
+    private boolean isMessageRelatedToUsers(JSONObject msg, String customerId, String staffUserId) {
+        // 如果都为空,则不过滤,返回全部
+        if ((customerId == null || customerId.isEmpty()) && (staffUserId == null || staffUserId.isEmpty())) {
+            return true;
+        }
+        boolean hasCustomer = (customerId == null || customerId.isEmpty());
+        boolean hasStaff = (staffUserId == null || staffUserId.isEmpty());
+
+        JSONObject sender = msg.getJSONObject("sender");
+        JSONArray receivers = msg.getJSONArray("receiver_list");
+
+        // 检查发送者
+        if (sender != null) {
+            String senderId = sender.getString("id");
+            int senderType = sender.getIntValue("type");
+            if (!hasCustomer && senderType == 2 && customerId.equals(senderId)) hasCustomer = true;
+            if (!hasStaff && senderType == 1 && staffUserId.equals(senderId)) hasStaff = true;
+        }
+        if (hasCustomer && hasStaff) return true;
+
+        // 检查接收者
+        if (receivers != null) {
+            for (int i = 0; i < receivers.size(); i++) {
+                JSONObject recv = receivers.getJSONObject(i);
+                String recvId = recv.getString("id");
+                int recvType = recv.getIntValue("type");
+                if (!hasCustomer && recvType == 2 && customerId.equals(recvId)) hasCustomer = true;
+                if (!hasStaff && recvType == 1 && staffUserId.equals(recvId)) hasStaff = true;
+                if (hasCustomer && hasStaff) return true;
+            }
+        }
+        return hasCustomer && hasStaff;
+    }
+
+    private JSONObject decryptAndFormatMessage(JSONObject msg, QwSessionConfigVo qwConfig) {
+        JSONObject result = new JSONObject();
+        try {
+            JSONObject encryptInfo = msg.getJSONObject("service_encrypt_info");
+            if (encryptInfo == null) return null;
+            String encryptedKey = encryptInfo.getString("encrypted_secret_key");
+            if (encryptedKey == null) return null;
+
+            // 解密得到 secretKey
+            String secretKey = WeChatSpaceDecryptUtil.decryptSecretKey(encryptedKey, qwConfig.getPrivateKey());
+
+            // 复制需要返回的字段
+            result.put("msgid", msg.getString("msgid"));
+            result.put("secretKey", secretKey);
+            result.put("sender", msg.get("sender"));
+            result.put("receiver_list", msg.get("receiver_list"));
+            result.put("msgtype", msg.getInteger("msgtype"));
+
+            Long sendTime = msg.getLong("send_time");
+            if (sendTime != null) {
+                String formattedTime = Instant.ofEpochSecond(sendTime)
+                        .atZone(ZoneId.systemDefault())
+                        .toLocalDateTime()
+                        .format(DATE_TIME_FORMATTER);
+                result.put("send_time_str", formattedTime);
+                result.put("send_time", sendTime);
+            }
+            return result;
+        } catch (Exception e) {
+            log.error("解密消息失败, msgid: {}", msg.getString("msgid"), e);
+            return null;
+        }
+    }
 }

+ 49 - 0
fs-service/src/main/java/com/fs/qw/utils/WeChatSpaceDecryptUtil.java

@@ -0,0 +1,49 @@
+package com.fs.qw.utils;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+import javax.crypto.Cipher;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyFactory;
+import java.security.PrivateKey;
+import java.security.spec.PKCS8EncodedKeySpec;
+import java.util.Base64;
+
+@Component
+public class WeChatSpaceDecryptUtil {
+
+    private static final Logger log = LoggerFactory.getLogger(WeChatSpaceDecryptUtil.class);
+
+    /**
+     * 解密 encrypted_secret_key
+     * @param encryptedSecretKey Base64 编码的 RSA 密文
+     * @param privateKeyPem PEM 格式私钥字符串(含 -----BEGIN/END-----)
+     * @return 原始 AES 密钥字符串
+     */
+    public static String decryptSecretKey(String encryptedSecretKey, String privateKeyPem) throws Exception {
+        if (privateKeyPem == null || privateKeyPem.isEmpty()) {
+            throw new IllegalArgumentException("私钥不能为空");
+        }
+        PrivateKey privateKey = parsePrivateKey(privateKeyPem);
+        byte[] encryptedData = Base64.getDecoder().decode(encryptedSecretKey);
+        Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
+        cipher.init(Cipher.DECRYPT_MODE, privateKey);
+        byte[] decryptedData = cipher.doFinal(encryptedData);
+        return new String(decryptedData, StandardCharsets.UTF_8);
+    }
+
+    private static PrivateKey parsePrivateKey(String privateKeyPem) throws Exception {
+        String privateKeyBase64 = privateKeyPem
+                .replace("-----BEGIN PRIVATE KEY-----", "")
+                .replace("-----END PRIVATE KEY-----", "")
+                .replace("-----BEGIN RSA PRIVATE KEY-----", "")
+                .replace("-----END RSA PRIVATE KEY-----", "")
+                .replaceAll("\\s", "");
+        byte[] keyBytes = Base64.getDecoder().decode(privateKeyBase64);
+        PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes);
+        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
+        return keyFactory.generatePrivate(spec);
+    }
+}

+ 32 - 2
fs-service/src/main/java/com/fs/qw/utils/WeChatTokenUtil.java → fs-service/src/main/java/com/fs/qw/utils/WeChatSpaceUtil.java

@@ -1,12 +1,15 @@
 package com.fs.qw.utils;
 
 import com.alibaba.fastjson.JSONObject;
+import org.apache.commons.codec.digest.DigestUtils;
 import org.springframework.web.client.RestTemplate;
 
+import java.util.UUID;
+
 /**
  * 企业微信 token / ticket 工具类
  */
-public class WeChatTokenUtil {
+public class WeChatSpaceUtil {
 
     private static final RestTemplate restTemplate = new RestTemplate();
     // 缓存 access_token,实际生产应使用 redis 或数据库
@@ -57,5 +60,32 @@ public class WeChatTokenUtil {
         throw new RuntimeException("获取 agent_ticket 失败: " + (resp == null ? "null response" : resp.getString("errmsg")));
     }
 
-    // 如果需要普通 jsapi_ticket,也可类似实现,但本场景不需要
+    /**
+     * 生成 agentConfig 签名(使用 agent_ticket)
+     * @param corpId     企业ID
+     * @param corpSecret 应用 secret
+     * @param agentId    应用ID
+     * @param url        当前页面完整URL(不含#)
+     */
+    public static JSONObject generateAgentConfigSignature(String corpId, String corpSecret, String agentId, String url) {
+        try {
+            // 1. 获取 agent_ticket
+            String ticket = WeChatSpaceUtil.getAgentTicket(corpId, corpSecret, agentId);
+            // 2. 生成随机串和时间戳
+            String nonceStr = UUID.randomUUID().toString().replaceAll("-", "");
+            String timestamp = Long.toString(System.currentTimeMillis() / 1000);
+            // 3. 拼接签名字符串
+            String signStr = "jsapi_ticket=" + ticket + "&noncestr=" + nonceStr + "&timestamp=" + timestamp + "&url=" + url;
+            // 4. SHA1 签名
+            String signature = DigestUtils.sha1Hex(signStr);
+
+            JSONObject result = new JSONObject();
+            result.put("timestamp", timestamp);
+            result.put("nonceStr", nonceStr);
+            result.put("signature", signature);
+            return result;
+        } catch (Exception e) {
+            throw new RuntimeException("生成 agentConfig 签名失败", e);
+        }
+    }
 }

+ 0 - 38
fs-service/src/main/java/com/fs/qw/utils/WeComSignatureUtil.java

@@ -1,38 +0,0 @@
-package com.fs.qw.utils;
-
-import com.alibaba.fastjson.JSONObject;
-import org.apache.commons.codec.digest.DigestUtils;
-
-import java.util.UUID;
-
-public class WeComSignatureUtil {
-
-    /**
-     * 生成 agentConfig 签名(使用 agent_ticket)
-     * @param corpId     企业ID
-     * @param corpSecret 应用 secret
-     * @param agentId    应用ID
-     * @param url        当前页面完整URL(不含#)
-     */
-    public static JSONObject generateAgentConfigSignature(String corpId, String corpSecret, String agentId, String url) {
-        try {
-            // 1. 获取 agent_ticket
-            String ticket = WeChatTokenUtil.getAgentTicket(corpId, corpSecret, agentId);
-            // 2. 生成随机串和时间戳
-            String nonceStr = UUID.randomUUID().toString().replaceAll("-", "");
-            String timestamp = Long.toString(System.currentTimeMillis() / 1000);
-            // 3. 拼接签名字符串
-            String signStr = "jsapi_ticket=" + ticket + "&noncestr=" + nonceStr + "&timestamp=" + timestamp + "&url=" + url;
-            // 4. SHA1 签名
-            String signature = DigestUtils.sha1Hex(signStr);
-
-            JSONObject result = new JSONObject();
-            result.put("timestamp", timestamp);
-            result.put("nonceStr", nonceStr);
-            result.put("signature", signature);
-            return result;
-        } catch (Exception e) {
-            throw new RuntimeException("生成 agentConfig 签名失败", e);
-        }
-    }
-}

+ 22 - 8
fs-service/src/main/java/com/fs/qw/vo/QwSessionConfigVo.java

@@ -1,9 +1,12 @@
 package com.fs.qw.vo;
 
 import lombok.Data;
+
+import java.util.List;
+
 /**
  * 企业微信专区配置-会话
- * */
+ */
 @Data
 public class QwSessionConfigVo {
 
@@ -13,15 +16,26 @@ public class QwSessionConfigVo {
 
     private String agentSecret;
 
-    // 会话专区查询会话记录能力id
-    private String fetchConversationAbilityId;
-
     //专区程序ID
     private String programId;
 
-    //专区程序能力action
-    private String abilityAction;
-
     //自建应用可信域名
     private String domain;
-}
+
+    //企业会话密钥
+    private String privateKey;
+
+    /**
+     * 动态能力ID列表
+     */
+    private List<AbilityItem> abilityIds;
+
+    /**
+     * 内部类:承载具体的 Key-Value
+     */
+    @Data
+    public static class AbilityItem {
+        private String key;   // 能力标识,例如 "invokeSyncMsg"
+        private String value; // 能力具体ID,例如 "invoke_sync_msg"
+    }
+}

+ 54 - 0
fs-service/src/main/java/com/fs/sop/service/impl/SopUserLogsInfoServiceImpl.java

@@ -916,6 +916,24 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                     log.error("跳转app直播模板解析失败:" + e);
                                 }
                                 break;
+                            // APP直播卡片
+                            case "20":
+                                try {
+                                    String jsonLive = configService.selectConfigByKey("his.config");
+                                    FSSysConfig sysConfigLive = JSON.parseObject(jsonLive, FSSysConfig.class);
+                                    createLiveWatchLogAndInsert(
+                                            qwUser.getCompanyId().toString(),
+                                            qwUser.getCompanyUserId().toString(),
+                                            groupUser.getId().toString(),
+                                            Long.valueOf(st.getLiveId()),
+                                            sysConfigLive.getAppId(),
+                                            2,
+                                            String.valueOf(qwUser.getId()),
+                                            param.getCorpId());
+                                } catch (Exception e) {
+                                    log.error("APP直播模板解析失败:" + e);
+                                }
+                                break;
                             //群公告
                             case "11":
                                 sopLogs.setSendType(21); // 设置为群公告类型
@@ -1703,6 +1721,24 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                 log.error("跳转app直播模板解析失败:" + e);
                             }
                             break;
+                        // APP直播卡片
+                        case "20":
+                            try {
+                                String jsonLive = configService.selectConfigByKey("his.config");
+                                FSSysConfig sysConfigLive = JSON.parseObject(jsonLive, FSSysConfig.class);
+                                createLiveWatchLogAndInsert(
+                                        qwUser.getCompanyId().toString(),
+                                        qwUser.getCompanyUserId().toString(),
+                                        item.getExternalId().toString(),
+                                        Long.valueOf(st.getLiveId()),
+                                        sysConfigLive.getAppId(),
+                                        2,
+                                        String.valueOf(qwUser.getId()),
+                                        param.getCorpId());
+                            } catch (Exception e) {
+                                log.error("APP直播模板解析失败:" + e);
+                            }
+                            break;
                         //群公告(仅用于一键群发,个人不应该有群公告)
                         case "11":
                             log.warn("群公告不能发给个人,跳过处理,sopId:{}, externalId:{}", param.getSopId(), item.getExternalId());
@@ -2613,6 +2649,24 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                         log.error("跳转app直播模板解析失败:" + e);
                     }
                     break;
+                // APP直播卡片
+                case "20":
+                    try {
+                        String jsonLive = configService.selectConfigByKey("his.config");
+                        FSSysConfig sysConfigLive = JSON.parseObject(jsonLive, FSSysConfig.class);
+                        createLiveWatchLogAndInsert(
+                                qwUser.getCompanyId().toString(),
+                                qwUser.getCompanyUserId().toString(),
+                                item.getExternalId().toString(),
+                                Long.valueOf(st.getLiveId()),
+                                sysConfigLive.getAppId(),
+                                2,
+                                String.valueOf(qwUser.getId()),
+                                param.getCorpId());
+                    } catch (Exception e) {
+                        log.error("APP直播模板解析失败:" + e);
+                    }
+                    break;
                 default:
                     break;
             }

+ 1 - 1
fs-service/src/main/resources/application-config-druid-sxsm.yml

@@ -97,7 +97,7 @@ headerImg:
   #下载海报地址
   download_poster_url: https://sxsm-1431314362.cos.ap-chongqing.myqcloud.com/sxsm.jpg
 ipad:
-  ipadUrl: http://ipad
+  ipadUrl: http://ipad.mxzjkwbvk.cn
   aiApi: http://49.232.181.28:3000/api
   wxIpadUrl:
   voiceApi:

+ 1 - 1
fs-service/src/main/resources/application-druid-bjzm-test.yml

@@ -153,7 +153,7 @@ rocketmq:
 openIM:
     secret: openIM123
     userID: imAdmin
-    url: https://localhost/api
+    url: https://webim.klbycp.com/api
 #是否为新商户,新商户不走mpOpenId
 isNewWxMerchant: true
 #是否使用新im

+ 1 - 1
fs-service/src/main/resources/application-druid-bjzm.yml

@@ -230,7 +230,7 @@ rocketmq:
 openIM:
     secret: openIM123
     userID: imAdmin
-    url: https://localhost/api
+    url: https://webim.klbycp.com/api
 #是否为新商户,新商户不走mpOpenId
 isNewWxMerchant: true
 #是否使用新im

+ 1 - 1
fs-service/src/main/resources/mapper/course/FsUserCourseMapper.xml

@@ -411,7 +411,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <if test="q.yxxTag != null and q.yxxTag == 1">
             AND c.cate_id = (select cate_id from fs_user_course_category WHERE cate_name like '%央广原乡行%' AND cate_type = 1 limit 1)
         </if>
-        <if test="q.yxxTag == null">
+        <if test="q.yxxTag == null or q.yxxTag == 0">
             AND c.cate_id != (select cate_id from fs_user_course_category WHERE cate_name like '%央广原乡行%' AND cate_type = 1 limit 1)
         </if>
           AND EXISTS (

+ 5 - 1
fs-service/src/main/resources/mapper/hisStore/FsStoreAfterSalesScrmMapper.xml

@@ -34,12 +34,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="reasonLevel2Text"    column="reason_level2_text"    />
         <result property="auditRemark"    column="audit_remark"    />
         <result property="auditReasonName"    column="audit_reason_name"    />
+        <result property="erpExceptionStatus"    column="erp_exception_status"    />
     </resultMap>
 
     <sql id="selectFsStoreAfterSalesVo">
         select id, order_code, refund_amount, service_type, reasons, explains, explain_img, shipper_code, delivery_sn, delivery_name, status, sales_status
                ,order_status, create_time, is_del, user_id, consignee, phone_number, address,company_id,company_user_id,is_package,package_json,reason_id1
-             ,reason_id2,reason_level1_text,reason_level2_text,audit_remark,audit_reason_name
+             ,reason_id2,reason_level1_text,reason_level2_text,audit_remark,audit_reason_name,erp_exception_status
         from fs_store_after_sales_scrm
     </sql>
 
@@ -157,6 +158,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="reasonLevel2Text != null">reason_level2_text,</if>
             <if test="auditRemark != null">audit_remark,</if>
             <if test="auditReasonName != null">audit_reason_name,</if>
+            <if test="erpExceptionStatus != null">erp_exception_status,</if>
          </trim>
         <trim prefix="values (" suffix=")" suffixOverrides=",">
             <if test="orderCode != null">#{orderCode},</if>
@@ -187,6 +189,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="reasonLevel2Text != null">#{reasonLevel2Text},</if>
             <if test="auditRemark != null">#{auditRemark},</if>
             <if test="auditReasonName != null">#{auditReasonName},</if>
+            <if test="erpExceptionStatus != null">#{erpExceptionStatus},</if>
          </trim>
     </insert>
 
@@ -221,6 +224,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="reasonLevel2Text != null">reason_level2_text = #{reasonLevel2Text},</if>
             <if test="auditRemark != null">audit_remark = #{auditRemark},</if>
             <if test="auditReasonName != null">audit_reason_name = #{auditReasonName},</if>
+            <if test="erpExceptionStatus != null">erp_exception_status = #{erpExceptionStatus},</if>
         </trim>
         where id = #{id}
     </update>

+ 38 - 7
fs-spec-zone/Dockerfile

@@ -1,12 +1,43 @@
-FROM alpine:3.18
+# 请自行寻找基础镜像,需包含包管理和bash
+# 使用 Debian 12 (bookworm) 精简版作为基础镜像,体积小且 glibc 兼容性极强
+FROM debian:bookworm-slim
 
-RUN apk add --no-cache openjdk8-jre openssl3
+# 设置非交互模式,防止 apt 安装过程中弹出交互式提示导致构建卡住
+ENV DEBIAN_FRONTEND=noninteractive
 
-WORKDIR /app
-COPY target/fs-spec-zone-1.0.0.jar app.jar
-COPY libWeWorkSpecSDK.so /usr/lib/
+# 更新软件源,并安装编译工具、OpenJDK以及 OpenSSL 编译所需的开发库
+RUN apt-get update && apt-get install -y --no-install-recommends \
+    gcc \
+    make \
+    perl \
+    wget \
+    tar \
+    default-jdk-headless \
+    zlib1g-dev \
+    libssl-dev \
+    ca-certificates \
+    && rm -rf /var/lib/apt/lists/*
 
-ENV JAVA_OPTS="-Xms256m -Xmx512m"
+# 下载并编译 OpenSSL 3.0(LTS 版本),为企微SDK提供必须的 libcrypto.so.3 和 libssl.so.3
+RUN cd /tmp && \
+    wget https://www.openssl.org/source/openssl-3.0.12.tar.gz && \
+    tar -xzf openssl-3.0.12.tar.gz && \
+    cd openssl-3.0.12 && \
+    ./config --prefix=/usr/local/openssl3 --openssldir=/usr/local/openssl3 shared zlib && \
+    make -j$(nproc) && make install && \
+    cd /tmp && rm -rf /tmp/openssl-3.0.12*
+
+# 【修复】直接写死动态库路径,彻底消除 UndefinedVar 警告
+ENV LD_LIBRARY_PATH=/usr/local/openssl3/lib64
+
+# 复制 SDK 动态库到 Debian 标准的系统库路径
+COPY libWeWorkSpecSDK.so /usr/lib/x86_64-linux-gnu/
+# 复制打包好的业务 jar 包
+COPY target/SpecDemo-with-dependencies.jar /app/
 
 EXPOSE 8080
-ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -Djava.library.path=/usr/lib -jar app.jar"]
+WORKDIR /app/
+
+# 启动 Java 应用,明确指定 java.library.path 包含自定义的 OpenSSL 路径
+ENTRYPOINT [ "/usr/bin/java" ]
+CMD [ "-Djava.library.path=/usr/lib/x86_64-linux-gnu:/usr/local/openssl3/lib64", "-Dfile.encoding=UTF-8", "-jar", "/app/SpecDemo-with-dependencies.jar" ]

+ 18 - 0
fs-spec-zone/docker镜像制作步骤

@@ -0,0 +1,18 @@
+1.构建 Docker 镜像
+docker build --no-cache -t wecom-spec-demo:1.0 .
+
+2.启动容器进行本地仿真测试
+docker run -d -p 8080:8080 --name wecom-test wecom-spec-demo:1.0
+
+3.查看日志并验证接口
+docker logs -f wecom-test
+
+4.停止容器并使用 export 导出为 .tar 文件
+# 1. 停止测试容器
+docker stop wecom-test
+
+# 2. 使用 docker export 将容器的文件系统导出为 tar 包
+docker export -o wecom-spec-demo.tar wecom-test
+
+# 3. 导出完成后,删除本地测试容器(因为 tar 包已经生成)
+docker rm wecom-test

+ 45 - 37
fs-spec-zone/pom.xml

@@ -11,65 +11,73 @@
     <name>fs-spec-zone</name>
     <description>企业微信数据与智能专区 - 专区程序</description>
 
-    <parent>
-        <groupId>org.springframework.boot</groupId>
-        <artifactId>spring-boot-starter-parent</artifactId>
-        <version>2.2.13.RELEASE</version>
-        <relativePath/>
-    </parent>
-
     <properties>
-        <java.version>1.8</java.version>
         <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+        <maven.compiler.source>1.8</maven.compiler.source>
+        <maven.compiler.target>1.8</maven.compiler.target>
     </properties>
 
     <dependencies>
         <dependency>
-            <groupId>org.springframework.boot</groupId>
-            <artifactId>spring-boot-starter-web</artifactId>
+            <groupId>com.alibaba.fastjson2</groupId>
+            <artifactId>fastjson2</artifactId>
+            <version>2.0.50</version>
+            <scope>compile</scope>
         </dependency>
 
         <dependency>
-            <groupId>com.alibaba</groupId>
-            <artifactId>fastjson</artifactId>
-            <version>1.2.83</version>
+            <groupId>io.netty</groupId>
+            <artifactId>netty-all</artifactId>
+            <version>4.1.68.Final</version>
         </dependency>
 
         <dependency>
-            <groupId>org.projectlombok</groupId>
-            <artifactId>lombok</artifactId>
-            <optional>true</optional>
+            <groupId>commons-cli</groupId>
+            <artifactId>commons-cli</artifactId>
+            <version>1.4</version>
         </dependency>
 
-        <dependency>
-            <groupId>org.springframework.boot</groupId>
-            <artifactId>spring-boot-starter-test</artifactId>
-            <scope>test</scope>
-        </dependency>
+        <!-- <dependency>
+            <groupId>io.micrometer</groupId>
+            <artifactId>micrometer-core</artifactId>
+            <version>1.13.2</version>
+        </dependency> -->
 
-        <dependency>
-            <groupId>org.bouncycastle</groupId>
-            <artifactId>bcpkix-jdk15on</artifactId>
-            <version>1.70</version>
-            <scope>compile</scope>
-        </dependency>
-
-        <dependency>
-            <groupId>commons-codec</groupId>
-            <artifactId>commons-codec</artifactId>
-            <version>1.15</version>
-        </dependency>
     </dependencies>
 
     <build>
         <plugins>
             <plugin>
-                <groupId>org.springframework.boot</groupId>
-                <artifactId>spring-boot-maven-plugin</artifactId>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-dependency-plugin</artifactId>
+                <version>3.1.2</version>
+            </plugin>
+
+            <plugin>
+                <artifactId>maven-assembly-plugin</artifactId>
+                <version>3.3.0</version>
                 <configuration>
-                    <mainClass>com.fs.speczone.SpecZoneApplication</mainClass>
+                    <finalName>SpecDemo-with-dependencies</finalName>
+                    <appendAssemblyId>false</appendAssemblyId>
+                    <archive>
+                        <manifest>
+                            <mainClass>mytype.mycom.mygroup.SpecDemo</mainClass>
+                        </manifest>
+                    </archive>
+                    <descriptorRefs>
+                        <descriptorRef>jar-with-dependencies</descriptorRef>
+                    </descriptorRefs>
                 </configuration>
+                <executions>
+                    <execution>
+                        <id>make-assembly</id>
+                        <phase>package</phase>
+                        <goals>
+                            <goal>single</goal>
+                        </goals>
+                    </execution>
+                </executions>
             </plugin>
         </plugins>
     </build>
-</project>
+</project>

+ 0 - 32
fs-spec-zone/src/main/java/README_SDK.md

@@ -1,32 +0,0 @@
-# wwopenspec java sdk
-
-## 目录结构
-
-- libWeWorkSpecSDK.so:java sdk所需的动态链接库
-- com:专区SDK的源码
-- README_SDK.md:本说明
-
-## SDK结构说明
-
-com
-└── tencent
-    └── wework                          注意:需保持com.tencent.wework结构
-        ├── SpecCallbackSDK.java:       SpecCallbackSDK接口
-        ├── SpecSDK.java:               SpecSDK接口
-        └── SpecUtil.java:              SDK内的通用工具
-
-## 环境配置
-
-- 使用时应将动态链接库拷贝到Java查找本地库的路径(`java.library.path`)下(如`/usr/lib`),或添加本地库查找路径
-    - 本地库查找路径可在Java程序内调用`System.getProperty("java.library.path")`或命令行界面调用`java -XshowSettings:properties -version`查看
-  
-- sdk依赖`openssl3`,开发者请自行下载最新版。需要`libcrypto.so.3`和`libssl.so.3`,配置参考如下
-    - 源码安装:进入openssl目录,构建需要的两个库:`make libcrypto.so`、`make libssl.so`。将so放入本地库加载路径,您的本地库加载路径可通过`cat /etc/ld.so.conf`查看。或者修改您的环境变量`LD_LIBRARY_PATH`添加动态链接库的查找路径
-    - 包管理安装:使用您镜像的包管理下载安装x86_64的openssl3即可
-
-## 其他说明
-
-- `.so`是类Unix系统(如Linux)的动态链接库,只能在类Unix系统使用,Windows系统(`.dll`)和Mac系统(`.dylib`)的动态链接库将在后续推出,敬请期待
-
-- **需保持包结构**,不要将sdk的源文件复制到项目包中,否则JNI的本地方法引用会失效
-    - 原因:JNI注册的方法包含包结构,详见javah生成本地方法头文件

+ 0 - 43
fs-spec-zone/src/main/java/com/fs/speczone/DebugModeRunner.java

@@ -1,43 +0,0 @@
-package com.fs.speczone;
-
-import com.fs.speczone.util.WeChatTokenUtil;
-import com.tencent.wework.SpecUtil;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.boot.CommandLineRunner;
-import org.springframework.stereotype.Component;
-
-@Slf4j
-@Component
-public class DebugModeRunner implements CommandLineRunner {
-
-    @Value("${debug.token:}")   // 冒号后面留空,代表默认空串
-    private String debugToken;
-
-    @Value("${debug.corp-id:}")
-    private String corpId;
-
-    @Value("${debug.corp-secret:}")
-    private String corpSecret;
-
-    @Override
-    public void run(String... args) {
-        // 如果配置为空,直接跳过,不尝试开启调试
-        if (debugToken == null || debugToken.isEmpty()) {
-            log.warn("未配置 debug.token,跳过本地调试模式开启");
-            return;
-        }
-        try {
-            String accessToken = WeChatTokenUtil.getAccessToken(corpId, corpSecret);
-            log.info("成功获取 access_token,准备开启调试模式...");
-            boolean success = SpecUtil.SpecOpenDebugMode(debugToken, accessToken);
-            if (success) {
-                log.info("✅ 本地调试模式已成功开启!");
-            } else {
-                log.error("❌ 本地调试模式开启失败");
-            }
-        } catch (Exception e) {
-            log.error("开启调试模式时发生异常: {}", e.getMessage(), e);
-        }
-    }
-}

+ 0 - 13
fs-spec-zone/src/main/java/com/fs/speczone/SpecZoneApplication.java

@@ -1,13 +0,0 @@
-package com.fs.speczone;
-
-import org.springframework.boot.SpringApplication;
-import org.springframework.boot.autoconfigure.SpringBootApplication;
-import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
-
-@SpringBootApplication(exclude = { DataSourceAutoConfiguration.class })
-public class SpecZoneApplication {
-
-    public static void main(String[] args) {
-        SpringApplication.run(SpecZoneApplication.class, args);
-    }
-}

+ 0 - 237
fs-spec-zone/src/main/java/com/fs/speczone/controller/CallbackController.java

@@ -1,237 +0,0 @@
-package com.fs.speczone.controller;
-
-import com.alibaba.fastjson.JSON;
-import com.alibaba.fastjson.JSONObject;
-import com.fs.speczone.handler.ProgramActionHandler;
-import com.tencent.wework.SpecCallbackSDK;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.http.HttpHeaders;
-import org.springframework.http.HttpStatus;
-import org.springframework.http.ResponseEntity;
-import org.springframework.web.bind.annotation.*;
-
-import javax.annotation.PostConstruct;
-import javax.annotation.Resource;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.function.Function;
-import java.util.stream.Collectors;
-
-/**
- * 企业微信数据与智能专区 - 统一回调入口
- *
- * 所有来自企业微信的请求(URL验证、应用调用、事件回调)都通过此Controller处理。
- * 由于企业微信会对请求进行加密,所有操作都必须使用 SpecCallbackSDK 进行解密和验签,
- * 并将返回数据加密后回传。
- *
- * 核心流程:
- * 1. GET  /callback → URL 验证(首次配置回调地址时使用)
- * 2. POST /callback → 接收具体业务调用或事件
- *    - callType=1:应用调用(SCRM 系统通过企微 API 触发)
- *    - callType=2:事件回调(会话存档同意、关键词命中等)
- */
-@Slf4j
-@RestController
-public class CallbackController {
-
-    // Spring 会自动将所有实现了 ProgramActionHandler 接口的 Bean 注入此列表
-    @Resource
-    private List<ProgramActionHandler> handlerList;
-
-    /**
-     * 动作 → 处理器的映射表
-     * key:action 名称(如 "fetch_conversations")
-     * value:对应的处理器实例
-     */
-    private Map<String, ProgramActionHandler> handlerMap;
-
-    /**
-     * 在 Bean 初始化后,将 handlerList 转换为 handlerMap,
-     * 便于后续根据 action 快速查找处理器。
-     *
-     * 这样设计的好处是新增一个 action 时,无需修改 Controller 代码,
-     * 只需创建一个新的 @Component 类实现 ProgramActionHandler 接口即可。
-     */
-    @PostConstruct
-    public void init() {
-        handlerMap = handlerList.stream()
-                .collect(Collectors.toMap(
-                        ProgramActionHandler::getAction,  // key:处理器能处理的 action
-                        Function.identity()               // value:处理器本身
-                ));
-    }
-
-    // ==================== URL 验证(GET) ====================
-
-    /**
-     * 企业微信首次配置回调 URL 时,会发送 GET 请求进行验证。
-     * 验证成功后,URL 才能被保存。
-     *
-     * @param msgSignature 企业微信生成的签名
-     * @param timestamp     时间戳
-     * @param nonce         随机字符串
-     * @param echostr       加密的验证字符串
-     * @return 解密后的 echostr 明文,企业微信确认后通过验证
-     */
-    @GetMapping("/callback")
-    public ResponseEntity<String> verifyUrl(
-            @RequestParam("msg_signature") String msgSignature,
-            @RequestParam("timestamp") String timestamp,
-            @RequestParam("nonce") String nonce,
-            @RequestParam("echostr") String echostr) {
-
-        log.info("收到应用 GET 回调验证");
-
-        // 构造请求头,SDK 需要从 headers 中提取签名信息
-        Map<String, String> headers = new HashMap<>();
-        headers.put("msg_signature", msgSignature);
-        headers.put("timestamp", timestamp);
-        headers.put("nonce", nonce);
-
-        // 使用 SpecCallbackSDK 进行解析和解密
-        SpecCallbackSDK sdk = new SpecCallbackSDK("GET", headers, echostr);
-        if (!sdk.IsOk()) {
-            log.error("URL 验证解密失败");
-            return ResponseEntity.status(403).body("verify failed");
-        }
-
-        // 解密后得到明文 echostr,原样返回即可完成验证
-        return ResponseEntity.ok(sdk.GetData());
-    }
-
-    // ==================== 业务入口(POST) ====================
-
-    /**
-     * 接收企业微信的后台调用请求(POST)。
-     * 解密后根据 callType 区分:
-     *   callType=1 → 应用调用(同步/异步程序调用)
-     *   callType=2 → 事件回调(会话存档同意、关键词命中等)
-     *
-     * @param headers 请求头,包含签名和加密信息
-     * @param body    加密的请求体
-     * @return 加密后的响应
-     */
-    @PostMapping("/callback")
-    public ResponseEntity<String> handleCallback(
-            @RequestHeader Map<String, String> headers,
-            @RequestBody String body) {
-
-        log.info("收到应用 POST 业务请求");
-
-        // 1. 解密和验签
-        SpecCallbackSDK sdk = new SpecCallbackSDK("POST", headers, body);
-        if (!sdk.IsOk()) {
-            log.error("回调验签/解密失败");
-            return ResponseEntity.ok("verify failed");
-        }
-
-        // 2. 提取解密后的数据
-        String decryptedData = sdk.GetData();           // 明文请求体(JSON)
-        long callType = sdk.GetCallType();              // 1: 应用调用, 2: 事件回调
-        String corpid = sdk.GetCorpId();                // 企业 ID
-        long agentId = sdk.GetAgentId();                // 应用 ID
-        log.info("收到回调 corpid={} agentid={} callType={} data={}",
-                corpid, agentId, callType, decryptedData);
-
-        // 3. 根据调用类型分发处理
-        String responsePlain;
-        if (callType == 1) {
-            // 应用调用(SCRM 系统通过企业微信 API 触发的请求)
-            responsePlain = handleProgramCall(decryptedData, sdk);
-        } else if (callType == 2) {
-            // 事件回调(企业微信主动推送的事件)
-            responsePlain = handleEvent(decryptedData);
-        } else {
-            responsePlain = "{}";
-        }
-
-        // 4. 加密响应并返回
-        sdk.BuildResponseHeaderBody(responsePlain);
-        Map<String, String> respHeaders = sdk.GetResponseHeaders();
-        String respBody = sdk.GetResponseBody();
-
-        HttpHeaders httpHeaders = new HttpHeaders();
-        respHeaders.forEach(httpHeaders::add);
-        return new ResponseEntity<>(respBody, httpHeaders, HttpStatus.OK);
-    }
-
-    // ==================== 应用调用处理 ====================
-
-    /**
-     * 处理来自 SCRM 系统的程序调用(callType=1)。
-     * data 本身即为输入协议(input_protocol),包含 action 和业务参数。
-     *
-     * 通过 action 找到对应的 ProgramActionHandler 并执行。
-     *
-     * @param data 解密后的请求数据(JSON 字符串)
-     * @param sdk  SpecCallbackSDK 实例,可获取 ability_id、job_info 等上下文
-     * @return 明文的响应 JSON(会被加密后返回)
-     */
-    private String handleProgramCall(String data, SpecCallbackSDK sdk) {
-        try {
-            JSONObject inputProtocol = JSON.parseObject(data);  // 直接解析 request_data
-            String action = inputProtocol.getString("action");   // 提取 action
-
-            ProgramActionHandler handler = handlerMap.get(action);
-            JSONObject output;
-            if (handler != null) {
-                output = handler.handle(inputProtocol, sdk);
-            } else {
-                output = new JSONObject();
-                output.put("errcode", 400);
-                output.put("errmsg", "未知的 action: " + action);
-            }
-            return JSON.toJSONString(output);
-        } catch (Exception e) {
-            log.error("处理程序调用异常", e);
-            JSONObject err = new JSONObject();
-            err.put("errcode", -1);
-            err.put("errmsg", "内部错误: " + e.getMessage());
-            return err.toJSONString();
-        }
-    }
-
-    // ==================== 事件回调处理 ====================
-
-    /**
-     * 处理企业微信推送的事件回调(callType=2)。
-     * 例如:客户同意会话存档、关键词规则命中、新消息产生等。
-     *
-     * 当前只打印日志,实际业务可在此扩展。
-     *
-     * @param data 解密后的事件数据(JSON 字符串)
-     * @return 空 JSON(暂无特殊处理)
-     */
-    private String handleEvent(String data) {
-        try {
-            JSONObject event = JSON.parseObject(data);
-            String eventType = event.getString("event_type");
-
-            // 根据事件类型进行不同处理(可扩展为类似 Handler 的策略模式)
-            if ("keyword_rule_hit".equals(eventType)) {
-                log.info("关键词规则命中事件: {}", event);
-                // TODO: 后续可调用 ConversationService 或通知 SCRM 系统
-            } else if ("chat_record".equals(eventType)) {
-                log.info("会话记录事件: {}", event);
-                // TODO: 后续可进行实时分析、存储等操作
-            } else {
-                log.warn("未处理的事件类型: {}", eventType);
-            }
-        } catch (Exception e) {
-            log.error("解析事件失败", e);
-        }
-        return "{}";
-    }
-
-    // ==================== 健康检查 ====================
-
-    /**
-     * 健康检查接口,用于确认服务是否正常运行。
-     * Nginx 反向代理或外部监控可通过此端点检测服务状态。
-     */
-    @GetMapping("/health")
-    public ResponseEntity<String> health() {
-        return ResponseEntity.ok("OK");
-    }
-}

+ 0 - 33
fs-spec-zone/src/main/java/com/fs/speczone/controller/WeComApiController.java

@@ -1,33 +0,0 @@
-package com.fs.speczone.controller;
-
-import com.alibaba.fastjson.JSONObject;
-import com.fs.speczone.service.WeComService;
-import lombok.RequiredArgsConstructor;
-import org.springframework.web.bind.annotation.*;
-/**
- * 企业微信会话-统一前端 API 接口
- * */
-@RestController
-@RequestMapping("/api")
-@RequiredArgsConstructor
-public class WeComApiController {
-
-    private final WeComService weComService;
-
-    // 会话记录
-    @GetMapping("/conversations")
-    public JSONObject getConversations(
-            @RequestParam(defaultValue = "0") long seq,
-            @RequestParam(defaultValue = "100") long limit,
-            @RequestParam(defaultValue = "0") long proxy,
-            @RequestParam(defaultValue = "30") long timeout,
-            @RequestParam(required = false) String customerId,
-            @RequestParam(required = false) String staffUserId) throws Exception {
-        if (customerId == null|| customerId.isEmpty()) {
-            throw new Exception("客户id不能为空");
-        }else if (staffUserId == null|| staffUserId.isEmpty()) {
-            throw new Exception("员工id不能为空");
-        }
-        return weComService.fetchConversations(seq, limit, proxy, timeout, customerId,staffUserId);
-    }
-}

+ 0 - 25
fs-spec-zone/src/main/java/com/fs/speczone/handler/FetchConversationsHandler.java

@@ -1,25 +0,0 @@
-package com.fs.speczone.handler;
-
-import com.alibaba.fastjson.JSONObject;
-import com.fs.speczone.service.ConversationService;
-import com.tencent.wework.SpecCallbackSDK;
-import org.springframework.stereotype.Component;
-
-import javax.annotation.Resource;
-
-@Component
-public class FetchConversationsHandler implements ProgramActionHandler {
-
-    @Resource
-    private ConversationService conversationService;
-
-    @Override
-    public String getAction() {
-        return "fetch_conversations";
-    }
-
-    @Override
-    public JSONObject handle(JSONObject inputProtocol, SpecCallbackSDK sdk) {
-        return conversationService.fetchConversations(inputProtocol, sdk);
-    }
-}

+ 0 - 22
fs-spec-zone/src/main/java/com/fs/speczone/handler/ProgramActionHandler.java

@@ -1,22 +0,0 @@
-package com.fs.speczone.handler;
-
-import com.alibaba.fastjson.JSONObject;
-import com.tencent.wework.SpecCallbackSDK;
-
-/**
- * 程序动作处理器接口(目前有知识集、关键词、搜索会话等)
- */
-public interface ProgramActionHandler {
-    /**
-     * 返回该处理器对应的 action 名称
-     */
-    String getAction();
-
-    /**
-     * 处理请求
-     * @param inputProtocol 请求参数(即 request_data 解析后的 JSON)
-     * @param sdk 回调上下文
-     * @return 处理结果
-     */
-    JSONObject handle(JSONObject inputProtocol, SpecCallbackSDK sdk);
-}

+ 0 - 155
fs-spec-zone/src/main/java/com/fs/speczone/sdk/SpecSdkAdapter.java

@@ -1,155 +0,0 @@
-package com.fs.speczone.sdk;
-
-import com.alibaba.fastjson.JSON;
-import com.alibaba.fastjson.JSONArray;
-import com.alibaba.fastjson.JSONObject;
-import com.tencent.wework.SpecCallbackSDK;
-import com.tencent.wework.SpecSDK;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.stereotype.Component;
-
-/**
- * 专区SDK适配器
- * 封装 com.tencent.wework.SpecSDK 的调用
- */
-@Slf4j
-@Component
-public class SpecSdkAdapter {
-
-    @Value("${wecom.corpid:test_corpid}")
-    private String corpid;
-    @Value("${wecom.agentid:0}")
-    private long agentId;
-
-    /**
-     * 获取会话记录
-     */
-    public JSONObject getConversations(long seq, long limit, long proxyId, long timeout,
-                                       SpecCallbackSDK callbackSdk) {
-        JSONObject result = new JSONObject();
-        try {
-            SpecSDK sdk = createSpecSDK(callbackSdk);
-            JSONObject req = new JSONObject();
-            req.put("seq", seq);
-            req.put("limit", limit);
-            req.put("proxy", proxyId);
-            req.put("timeout", timeout);
-            sdk.SetRequest(req.toJSONString());
-            int ret = sdk.Invoke("sync_msg");
-            if (ret == 0) {
-                result = JSON.parseObject(sdk.GetResponse());
-            } else {
-                result.put("errcode", ret);
-                result.put("errmsg", "SpecSDK.Invoke failed, ret=" + ret);
-            }
-        } catch (Exception e) {
-            log.error("获取会话异常", e);
-            result.put("errcode", -1);
-            result.put("errmsg", e.getMessage());
-        }
-        return result;
-    }
-
-    /**
-     * 关键词搜索会话(示例,未完整实现)
-     */
-    public JSONObject searchConversationsByKeyword(String keyword, int chatType,
-                                                   long startTime, long endTime, long limit,
-                                                   SpecCallbackSDK callbackSdk) {
-        JSONObject result = new JSONObject();
-        try {
-            // 实际调用 SpecSDK.Invoke("search_msg") 等接口,此处略
-            log.info("关键词搜索会话 keyword={}, chatType={}, start={}, end={}", keyword, chatType, startTime, endTime);
-            result.put("errcode", 0);
-        } catch (Exception e) {
-            log.error("关键词搜索失败", e);
-            result.put("errcode", -1);
-            result.put("errmsg", e.getMessage());
-        }
-        return result;
-    }
-
-    /**
-     * 获取内部群信息(示例)
-     */
-    public JSONObject getInternalGroup(String roomId) {
-        JSONObject result = new JSONObject();
-        try {
-            log.info("获取内部群信息 roomId={}", roomId);
-            result.put("errcode", 0);
-        } catch (Exception e) {
-            log.error("获取内部群信息失败", e);
-            result.put("errcode", -1);
-            result.put("errmsg", e.getMessage());
-        }
-        return result;
-    }
-
-    /**
-     * 管理关键词规则(示例)
-     */
-    public JSONObject manageKeywordRule(String action, JSONArray rules, SpecCallbackSDK callbackSdk) {
-        JSONObject result = new JSONObject();
-        try {
-            // 实际调用 SpecSDK.Invoke("create_rule") 等接口
-            log.info("关键词规则管理 action={}, rules={}", action, rules);
-            result.put("errcode", 0);
-        } catch (Exception e) {
-            log.error("关键词规则管理失败", e);
-            result.put("errcode", -1);
-            result.put("errmsg", e.getMessage());
-        }
-        return result;
-    }
-
-    /**
-     * 通知应用
-     */
-    public String notifyApp(String appId, String notifyData) {
-        try {
-            log.info("通知应用 appId={}, data={}", appId, notifyData);
-            JSONObject notify = new JSONObject();
-            notify.put("code", 0);
-            notify.put("msg", "success");
-            notify.put("notify_id", java.util.UUID.randomUUID().toString());
-            return JSON.toJSONString(notify);
-        } catch (Exception e) {
-            log.error("通知应用失败", e);
-            return "{\"code\":-1,\"msg\":\"" + e.getMessage() + "\"}";
-        }
-    }
-
-    /**
-     * 获取知识集列表
-     */
-    public JSONObject invokeKnowledgeBaseList(SpecCallbackSDK callbackSdk) {
-        JSONObject result = new JSONObject();
-        try {
-            SpecSDK sdk = createSpecSDK(callbackSdk);   // 复用已有的 createSpecSDK 方法
-            sdk.SetRequest("{}");  // knowledge_base_list 接口无参数
-            int ret = sdk.Invoke("knowledge_base_list");
-            if (ret == 0) {
-                result = JSON.parseObject(sdk.GetResponse());
-            } else {
-                result.put("errcode", ret);
-                result.put("errmsg", "SpecSDK.Invoke failed, ret=" + ret);
-            }
-        } catch (Exception e) {
-            log.error("获取知识集列表异常", e);
-            result.put("errcode", -1);
-            result.put("errmsg", e.getMessage());
-        }
-        return result;
-    }
-
-    /**
-     * 优先使用 SpecCallbackSDK 上下文构造 SpecSDK
-     */
-    private SpecSDK createSpecSDK(SpecCallbackSDK callbackSdk) {
-        if (callbackSdk != null && callbackSdk.IsOk()) {
-            return new SpecSDK(callbackSdk);
-        }
-        return new SpecSDK(corpid, agentId);
-    }
-}

+ 0 - 132
fs-spec-zone/src/main/java/com/fs/speczone/service/ConversationService.java

@@ -1,132 +0,0 @@
-package com.fs.speczone.service;
-
-import com.alibaba.fastjson.JSONArray;
-import com.alibaba.fastjson.JSONObject;
-import com.fs.speczone.sdk.SpecSdkAdapter;
-import com.fs.speczone.util.WeChatDecryptUtil;
-import com.tencent.wework.SpecCallbackSDK;
-import com.tencent.wework.SpecSDK;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.stereotype.Service;
-
-import javax.annotation.Resource;
-import java.time.Instant;
-import java.time.ZoneId;
-import java.time.format.DateTimeFormatter;
-import java.util.List;
-import java.util.Objects;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.stream.Collectors;
-
-@Slf4j
-@Service
-public class ConversationService {
-
-    @Resource
-    private SpecSdkAdapter specSdkAdapter;
-
-    private final ConcurrentHashMap<String, String> notifyDataStore = new ConcurrentHashMap<>();
-
-    /**
-     * 专区版:拉取会话记录(含解密、过滤、格式化)
-     */
-    public JSONObject fetchConversations(JSONObject inputProtocol, SpecCallbackSDK callbackSdk) {
-        JSONObject result = new JSONObject();
-
-        long seq = inputProtocol.getLongValue("seq");
-        long limit = inputProtocol.getLongValue("limit");
-        if (limit <= 0) limit = 1000;
-        long proxy = inputProtocol.getLongValue("proxy");
-        long timeout = inputProtocol.getLongValue("timeout");
-        if (timeout <= 0) timeout = 30;
-
-        String customerId = inputProtocol.getString("customerId");
-        String staffUserId = inputProtocol.getString("staffUserId");
-
-        // 调用 SpecSDK 拉取加密会话
-        JSONObject rawResp = specSdkAdapter.getConversations(seq, limit, proxy, timeout, callbackSdk);
-        if (rawResp == null || rawResp.getInteger("errcode") != 0) {
-            result.put("errcode", rawResp != null ? rawResp.getInteger("errcode") : -1);
-            result.put("errmsg", "获取会话失败");
-            result.put("data", new JSONArray());
-            return result;
-        }
-
-        JSONArray msgList = rawResp.getJSONArray("msg_list");
-        if (msgList == null || msgList.isEmpty()) {
-            result.put("errcode", 0);
-            result.put("errmsg", "ok");
-            result.put("data", new JSONArray());
-            return result;
-        }
-
-        // 解密、过滤、格式化
-        List<JSONObject> cleaned = msgList.parallelStream()
-                .map(obj -> (JSONObject) obj)
-                .filter(msg -> isMessageRelatedToUsers(msg, customerId, staffUserId))
-                .map(msg -> {
-                    JSONObject encryptInfo = msg.getJSONObject("service_encrypt_info");
-                    if (encryptInfo == null) return null;
-                    String encryptedKey = encryptInfo.getString("encrypted_secret_key");
-                    if (encryptedKey == null) return null;
-                    try {
-                        String secretKey = WeChatDecryptUtil.decryptSecretKey(encryptedKey);
-                        JSONObject item = new JSONObject();
-                        item.put("msgid", msg.getString("msgid"));
-                        item.put("secretKey", secretKey);
-                        item.put("sender", msg.get("sender"));
-                        item.put("receiver_list", msg.get("receiver_list"));
-                        Long sendTime = msg.getLong("send_time");
-                        if (sendTime != null) {
-                            String formattedTime = Instant.ofEpochMilli(sendTime * 1000)
-                                    .atZone(ZoneId.systemDefault())
-                                    .toLocalDateTime()
-                                    .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"));
-                            item.put("displayTime", formattedTime);
-                        }
-                        return item;
-                    } catch (Exception e) {
-                        log.error("解密失败 msgid: {}", msg.getString("msgid"), e);
-                        return null;
-                    }
-                })
-                .filter(Objects::nonNull)
-                .collect(Collectors.toList());
-
-        result.put("errcode", 0);
-        result.put("errmsg", "ok");
-        result.put("data", cleaned);
-        return result;
-    }
-
-    /**
-     * 过滤消息:是否与指定客户和员工相关
-     */
-    private boolean isMessageRelatedToUsers(JSONObject msg, String customerId, String staffUserId) {
-        boolean hasCustomer = (customerId == null || customerId.isEmpty());
-        boolean hasStaff = (staffUserId == null || staffUserId.isEmpty());
-
-        JSONObject sender = msg.getJSONObject("sender");
-        JSONArray receivers = msg.getJSONArray("receiver_list");
-
-        if (sender != null) {
-            String senderId = sender.getString("id");
-            int senderType = sender.getIntValue("type");
-            if (!hasCustomer && senderType == 2 && customerId.equals(senderId)) hasCustomer = true;
-            if (!hasStaff && senderType == 1 && staffUserId.equals(senderId)) hasStaff = true;
-        }
-        if (hasCustomer && hasStaff) return true;
-
-        if (receivers != null) {
-            for (int i = 0; i < receivers.size(); i++) {
-                JSONObject recv = receivers.getJSONObject(i);
-                String recvId = recv.getString("id");
-                int recvType = recv.getIntValue("type");
-                if (!hasCustomer && recvType == 2 && customerId.equals(recvId)) hasCustomer = true;
-                if (!hasStaff && recvType == 1 && staffUserId.equals(recvId)) hasStaff = true;
-                if (hasCustomer && hasStaff) return true;
-            }
-        }
-        return hasCustomer && hasStaff;
-    }
-}

+ 0 - 11
fs-spec-zone/src/main/java/com/fs/speczone/service/WeComService.java

@@ -1,11 +0,0 @@
-package com.fs.speczone.service;
-
-import com.alibaba.fastjson.JSONObject;
-
-public interface WeComService {
-    /**
-     * 拉取会话记录(主动调用,供前端 /api/conversations 使用)
-     */
-    JSONObject fetchConversations(long seq, long limit, long proxy, long timeout, String customerId,String staffUserId);
-
-}

+ 0 - 149
fs-spec-zone/src/main/java/com/fs/speczone/service/impl/WeComServiceImpl.java

@@ -1,149 +0,0 @@
-package com.fs.speczone.service.impl;
-
-import com.alibaba.fastjson.JSONArray;
-import com.alibaba.fastjson.JSONObject;
-import com.fs.speczone.service.WeComService;
-import com.fs.speczone.util.WeChatDecryptUtil;
-import com.fs.speczone.util.WeChatTokenUtil;
-import com.fs.speczone.util.WeComSignatureUtil;
-import com.tencent.wework.SpecSDK;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.stereotype.Service;
-import org.springframework.web.client.RestTemplate;
-
-import java.time.Instant;
-import java.time.ZoneId;
-import java.time.format.DateTimeFormatter;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Objects;
-import java.util.stream.Collectors;
-
-@Slf4j
-@Service
-@RequiredArgsConstructor
-public class WeComServiceImpl implements WeComService {
-
-    @Value("${wecom.corpid}")
-    private String corpId;
-
-    @Value("${wecom.agentid}")
-    private long agentId;
-
-    @Override
-    public JSONObject fetchConversations(long seq, long limit, long proxy, long timeout,
-                                         String customerId, String staffUserId) {
-        JSONObject result = new JSONObject();
-        SpecSDK sdk = new SpecSDK(corpId, agentId);
-
-        JSONObject req = new JSONObject();
-        req.put("seq", seq);
-        req.put("limit", limit);
-        req.put("proxy", proxy);
-        req.put("timeout", timeout);
-        sdk.SetRequest(req.toJSONString());
-
-        int ret = sdk.Invoke("sync_msg");
-        if (ret != 0) {
-            result.put("errcode", ret);
-            result.put("errmsg", "sync_msg failed");
-            return result;
-        }
-
-        JSONObject rawResp = JSONObject.parseObject(sdk.GetResponse());
-        if (rawResp.getInteger("errcode") != 0) return rawResp;
-
-        JSONArray msgList = rawResp.getJSONArray("msg_list");
-        List<JSONObject> cleaned = new ArrayList<>();
-        if (msgList != null) {
-            cleaned = msgList.parallelStream()
-                    .map(obj -> (JSONObject) obj)
-                    // ========== 核心修改:同时按客户和员工过滤 ==========
-                    .filter(msg -> {
-                        // 如果两个参数都没传,不过滤
-                        if ((customerId == null || customerId.isEmpty()) &&
-                                (staffUserId == null || staffUserId.isEmpty())) {
-                            return true;
-                        }
-                        // 否则检查消息是否同时满足客户和员工(如果都传了)或只满足其中一个
-                        return isMessageRelatedToUsers(msg, customerId, staffUserId);
-                    })
-                    // ====================================================
-                    .map(msg -> {
-                        JSONObject encryptInfo = msg.getJSONObject("service_encrypt_info");
-                        if (encryptInfo == null) return null;
-                        String encryptedKey = encryptInfo.getString("encrypted_secret_key");
-                        if (encryptedKey == null) return null;
-                        try {
-                            String secretKey = WeChatDecryptUtil.decryptSecretKey(encryptedKey);
-                            JSONObject item = new JSONObject();
-                            item.put("msgid", msg.getString("msgid"));
-                            item.put("secretKey", secretKey);
-                            item.put("sender", msg.get("sender"));
-                            item.put("receiver_list", msg.get("receiver_list"));
-                            Long sendTime = msg.getLong("send_time");
-                            if (sendTime != null) {
-                                String formattedTime = Instant.ofEpochMilli(sendTime * 1000)
-                                        .atZone(ZoneId.systemDefault())
-                                        .toLocalDateTime()
-                                        .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"));
-                                item.put("displayTime", formattedTime);
-                            }
-                            return item;
-                        } catch (Exception e) {
-                            log.error("解密失败 msgid: {}", msg.getString("msgid"), e);
-                            return null;
-                        }
-                    })
-                    .filter(Objects::nonNull)
-                    .collect(Collectors.toList());
-        }
-
-        result.put("errcode", 0);
-        result.put("errmsg", "ok");
-        result.put("msgList", cleaned);
-        return result;
-    }
-
-    /**
-     * 判断一条消息是否同时与指定客户和指定员工相关(新方法)
-     * 如果 customerId 不为空,则消息的发送者或接收者必须包含该客户(type=2)
-     * 如果 staffUserId 不为空,则消息的发送者或接收者必须包含该员工(type=1)
-     * 如果两个都为空,则由调用方提前返回 true,本方法不再处理
-     */
-    private boolean isMessageRelatedToUsers(JSONObject msg, String customerId, String staffUserId) {
-        boolean hasCustomer = (customerId == null || customerId.isEmpty());
-        boolean hasStaff = (staffUserId == null || staffUserId.isEmpty());
-
-        JSONObject sender = msg.getJSONObject("sender");
-        JSONArray receivers = msg.getJSONArray("receiver_list");
-
-        // 检查发送者
-        if (sender != null) {
-            String senderId = sender.getString("id");
-            int senderType = sender.getIntValue("type");
-            if (!hasCustomer && senderType == 2 && customerId.equals(senderId)) hasCustomer = true;
-            if (!hasStaff && senderType == 1 && staffUserId.equals(senderId)) hasStaff = true;
-        }
-
-        // 如果已经同时满足,直接返回 true
-        if (hasCustomer && hasStaff) return true;
-
-        // 检查接收者列表
-        if (receivers != null) {
-            for (int i = 0; i < receivers.size(); i++) {
-                JSONObject recv = receivers.getJSONObject(i);
-                String recvId = recv.getString("id");
-                int recvType = recv.getIntValue("type");
-                if (!hasCustomer && recvType == 2 && customerId.equals(recvId)) hasCustomer = true;
-                if (!hasStaff && recvType == 1 && staffUserId.equals(recvId)) hasStaff = true;
-                if (hasCustomer && hasStaff) return true; // 一旦两者都满足即可返回
-            }
-        }
-
-        // 返回是否两个条件都满足(如果只传了其中一个,则另一个默认为 true)
-        return hasCustomer && hasStaff;
-    }
-}

+ 0 - 65
fs-spec-zone/src/main/java/com/fs/speczone/util/WeChatDecryptUtil.java

@@ -1,65 +0,0 @@
-package com.fs.speczone.util;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.stereotype.Component;
-
-import javax.annotation.PostConstruct;
-import javax.crypto.Cipher;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.Paths;
-import java.security.KeyFactory;
-import java.security.PrivateKey;
-import java.security.spec.PKCS8EncodedKeySpec;
-import java.util.Base64;
-
-@Component
-public class WeChatDecryptUtil {
-
-    private static final Logger log = LoggerFactory.getLogger(WeChatDecryptUtil.class);
-    private static PrivateKey privateKey;
-
-    @Value("${wecom.message.private-key-path:/speczone/private_key.pem}")
-    private String privateKeyPath;
-
-    @PostConstruct
-    public void init() {
-        try {
-            log.info("加载私钥文件: {}", privateKeyPath);
-            String privateKeyPem = new String(Files.readAllBytes(Paths.get(privateKeyPath)));
-            String privateKeyBase64 = privateKeyPem
-                    .replace("-----BEGIN PRIVATE KEY-----", "")
-                    .replace("-----END PRIVATE KEY-----", "")
-                    .replace("-----BEGIN RSA PRIVATE KEY-----", "")
-                    .replace("-----END RSA PRIVATE KEY-----", "")
-                    .replaceAll("\\s", "");
-            byte[] keyBytes = Base64.getDecoder().decode(privateKeyBase64);
-            PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes);
-            KeyFactory keyFactory = KeyFactory.getInstance("RSA");
-            privateKey = keyFactory.generatePrivate(spec);
-            log.info("私钥加载成功");
-        } catch (Exception e) {
-            log.error("私钥加载失败", e);
-            throw new RuntimeException("初始化企业微信私钥失败", e);
-        }
-    }
-
-    /**
-     * 解密 encrypted_secret_key,返回原始 AES 密钥字符串(直接供前端使用)
-     * @param encryptedSecretKey Base64 编码的 RSA 密文
-     * @return 原始 AES 密钥字符串(无需再次编码)
-     */
-    public static String decryptSecretKey(String encryptedSecretKey) throws Exception {
-        if (privateKey == null) {
-            throw new IllegalStateException("私钥未初始化");
-        }
-        byte[] encryptedData = Base64.getDecoder().decode(encryptedSecretKey);
-        Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
-        cipher.init(Cipher.DECRYPT_MODE, privateKey);
-        byte[] decryptedData = cipher.doFinal(encryptedData);
-        // 关键:直接转为字符串,不要再次进行 Base64 编码
-        return new String(decryptedData, StandardCharsets.UTF_8);
-    }
-}

+ 0 - 63
fs-spec-zone/src/main/java/com/fs/speczone/util/WeChatTokenUtil.java

@@ -1,63 +0,0 @@
-package com.fs.speczone.util;
-
-import com.alibaba.fastjson.JSONObject;
-import org.springframework.web.client.RestTemplate;
-
-import java.util.concurrent.ConcurrentHashMap;
-
-/**
- * 企业微信 token / ticket 工具类
- */
-public class WeChatTokenUtil {
-
-    private static final RestTemplate restTemplate = new RestTemplate();
-    // 缓存 access_token,实际生产应使用 redis 或数据库
-    private static String accessTokenCache;
-    private static long accessTokenExpireTime = 0;
-
-    // 缓存 agent_ticket
-    private static String agentTicketCache;
-    private static long agentTicketExpireTime = 0;
-
-    /**
-     * 获取企业 access_token(带缓存)
-     */
-    public static String getAccessToken(String corpId, String corpSecret) {
-        long now = System.currentTimeMillis() / 1000;
-        if (accessTokenCache != null && now < accessTokenExpireTime) {
-            return accessTokenCache;
-        }
-        String url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=" + corpId + "&corpsecret=" + corpSecret;
-        JSONObject resp = restTemplate.getForObject(url, JSONObject.class);
-        if (resp != null && resp.getIntValue("errcode") == 0) {
-            accessTokenCache = resp.getString("access_token");
-            accessTokenExpireTime = now + resp.getIntValue("expires_in") - 300; // 提前5分钟过期
-            return accessTokenCache;
-        }
-        throw new RuntimeException("获取 access_token 失败: " + (resp == null ? "null response" : resp.getString("errmsg")));
-    }
-
-    /**
-     * 获取 agent_ticket(必须用于 agentConfig 签名)
-     * @param corpId     企业ID
-     * @param corpSecret 应用 secret
-     * @param agentId    应用ID
-     */
-    public static String getAgentTicket(String corpId, String corpSecret, String agentId) {
-        long now = System.currentTimeMillis() / 1000;
-        if (agentTicketCache != null && now < agentTicketExpireTime) {
-            return agentTicketCache;
-        }
-        String accessToken = getAccessToken(corpId, corpSecret);
-        String url = "https://qyapi.weixin.qq.com/cgi-bin/ticket/get?access_token=" + accessToken + "&type=agent_config";
-        JSONObject resp = restTemplate.getForObject(url, JSONObject.class);
-        if (resp != null && resp.getIntValue("errcode") == 0) {
-            agentTicketCache = resp.getString("ticket");
-            agentTicketExpireTime = now + resp.getIntValue("expires_in") - 300; // 提前5分钟刷新
-            return agentTicketCache;
-        }
-        throw new RuntimeException("获取 agent_ticket 失败: " + (resp == null ? "null response" : resp.getString("errmsg")));
-    }
-
-    // 如果需要普通 jsapi_ticket,也可类似实现,但本场景不需要
-}

+ 0 - 46
fs-spec-zone/src/main/java/com/fs/speczone/util/WeComSignatureUtil.java

@@ -1,46 +0,0 @@
-package com.fs.speczone.util;
-
-import com.alibaba.fastjson.JSONObject;
-import org.apache.commons.codec.digest.DigestUtils;
-
-import java.util.UUID;
-
-public class WeComSignatureUtil {
-
-    /**
-     * 生成普通 config 签名(使用 jsapi_ticket,本场景暂不调用)
-     */
-    public static JSONObject generateConfigSignature(String corpId, String corpSecret, String url) {
-        // 如果需要,可调用 WeChatTokenUtil.getJsapiTicket(...)
-        throw new UnsupportedOperationException("本演示未实现 jsapi_ticket 获取");
-    }
-
-    /**
-     * 生成 agentConfig 签名(使用 agent_ticket)
-     * @param corpId     企业ID
-     * @param corpSecret 应用 secret
-     * @param agentId    应用ID
-     * @param url        当前页面完整URL(不含#)
-     */
-    public static JSONObject generateAgentConfigSignature(String corpId, String corpSecret, String agentId, String url) {
-        try {
-            // 1. 获取 agent_ticket
-            String ticket = WeChatTokenUtil.getAgentTicket(corpId, corpSecret, agentId);
-            // 2. 生成随机串和时间戳
-            String nonceStr = UUID.randomUUID().toString().replaceAll("-", "");
-            String timestamp = Long.toString(System.currentTimeMillis() / 1000);
-            // 3. 拼接签名字符串
-            String signStr = "jsapi_ticket=" + ticket + "&noncestr=" + nonceStr + "&timestamp=" + timestamp + "&url=" + url;
-            // 4. SHA1 签名
-            String signature = DigestUtils.sha1Hex(signStr);
-
-            JSONObject result = new JSONObject();
-            result.put("timestamp", timestamp);
-            result.put("nonceStr", nonceStr);
-            result.put("signature", signature);
-            return result;
-        } catch (Exception e) {
-            throw new RuntimeException("生成 agentConfig 签名失败", e);
-        }
-    }
-}

+ 63 - 0
fs-spec-zone/src/main/java/mytype/mycom/mygroup/CommonUtils.java

@@ -0,0 +1,63 @@
+package mytype.mycom.mygroup;
+
+import com.tencent.wework.SpecUtil;
+
+import java.nio.charset.Charset;
+
+/**
+ * @usage 不含第三方依赖的通用工具类,提供接口如下
+ *        1. 查询接口是否支持
+ *        2. 生成包裹错误信息的json string
+ *        3. 展示部分系统参数
+ */
+public class CommonUtils {
+    /**
+     * @usage demo内部统一错误码,用户可根据需要自定义
+     */
+    private static final int DEMO_INTERNAL_ERRORCODE = 710660;
+
+    /**
+     * @usage 统一错误信息格式,注意错误字段应在输出协议中注册
+     */
+    private static final String ERROR_RESPONSE_FORMAT = "{\"errcode\":%d,\"errmsg\":\"%s,%s,%s\"}";
+
+    /**
+     * @usage 将错误信息包装为统一形式返回,以通过平台对用户注册能力的输出协议校验
+     * @param errMsg 内部错误提示信息
+     * @return 包装好的错误信息,json格式,errcode为统一的demo内部错误码,errmsg为文件名、行号和错误信息
+     */
+    public static String getErrorResponse(String errMsg) {
+        StackTraceElement element = Thread.currentThread().getStackTrace()[3];
+        SpecUtil.SpecLogNative(
+            'E', 
+            element.getFileName(), 
+            element.getLineNumber(), 
+            errMsg
+        );
+        return String.format(
+            ERROR_RESPONSE_FORMAT,
+            DEMO_INTERNAL_ERRORCODE,
+            element.getFileName(),
+            element.getLineNumber(),
+            errMsg
+        );
+    }
+
+    /**
+     * @usage 展示JVM关于字符编码的配置
+     */
+    public static void displayJvmCharsetConfig() {
+        StringBuffer sb = new StringBuffer();
+        sb.append("Default Charset: " + Charset.defaultCharset())
+            .append(", System Charset: " + System.getProperty("file.encoding"))
+            .append(", Default Charset in Use: " + new java.io.OutputStreamWriter(new java.io.ByteArrayOutputStream()).getEncoding())
+            .append(", Default OS Charset: " + System.getProperty("sun.jnu.encoding"))
+            .append(", Default Locale Charset: " + Charset.defaultCharset());
+        SpecUtil.WWSpecLogInfo(sb.toString());
+    }
+
+    /**
+     * @usage 私有构造函数,防止被实例化
+     */
+    private CommonUtils() {};
+}

+ 175 - 0
fs-spec-zone/src/main/java/mytype/mycom/mygroup/DataBaseUtils.java

@@ -0,0 +1,175 @@
+package mytype.mycom.mygroup;
+
+import com.tencent.wework.SpecUtil;
+
+import java.io.*;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.concurrent.TimeUnit;
+
+/** 
+ * @usage 提供数据的本地存储功能和过期数据的清理功能
+ *        每个记录使用一个文件,文件名为notifyId,文件内容为响应数据,单行
+ *        过期文件的定期清理使用独立线程完成,文件默认20分钟过期,每10分钟扫描一次
+ */
+public class DataBaseUtils {
+    /**
+     * @usage 存储响应数据的路径,类的静态代码会自动创建
+     */
+    private static final String DATA_DIR = "/mnt/data/callback_notify/";
+    
+    /**
+     * @usage 清理过期文件的扫描间隔,单位为分钟 
+     */
+    private static final int SCAN_INTERVAL = 10;  
+
+    /**
+     * @usage 文件过期时间,单位为分钟
+     */
+    private static final int EXPIRE_TIME = 20;  
+
+    /**
+     * @usage 1. 创建存放数据的目录
+     *        2. 启动清理过期文件的线程
+     */
+    static {
+        Path dirPath = Paths.get(DATA_DIR);
+        if (!Files.exists(dirPath)) {
+            try {
+                Files.createDirectories(dirPath);
+            } catch (IOException e) {
+                SpecUtil.WWSpecLogError("create directory " + DATA_DIR + " failed");
+                e.printStackTrace();
+                System.exit(1);
+            }
+            SpecUtil.WWSpecLogInfo("create directory " + DATA_DIR + " success");
+        }
+
+        Thread cleanerThread = new Thread(new Runnable() {
+            @Override
+            public void run() {
+                try {
+                    while (true) {
+                        File folder = new File(DATA_DIR);
+                        File[] files = folder.listFiles();
+                        if (files != null) {
+                            for (File file : files) {
+                                if (file.isFile() && isFileOld(file)) {
+                                    SpecUtil.WWSpecLogInfo("Deleted expired file: " + file.getName());
+                                    file.delete();
+                                }
+                            }
+                        }
+                        TimeUnit.MINUTES.sleep(SCAN_INTERVAL); // 每10分钟执行一次
+                    }
+                } catch (InterruptedException e) {
+                    Thread.currentThread().interrupt();
+                    SpecUtil.WWSpecLogError("File cleaning thread was interrupted.");
+                }
+            }
+
+            private boolean isFileOld(File file) {
+                try {
+                    BasicFileAttributes attrs = Files.readAttributes(file.toPath(), BasicFileAttributes.class);
+                    long fileCreationTime = attrs.creationTime().toMillis();
+                    return (System.currentTimeMillis() - fileCreationTime) > EXPIRE_TIME * 60 * 1000;
+                } catch (Exception e) {
+                    SpecUtil.WWSpecLogError("Error reading file attributes: " + e.getStackTrace());
+                    return false;
+                }
+            }
+        });
+
+        cleanerThread.setDaemon(true);
+        cleanerThread.start();
+    }
+
+    public static enum ErrorCode {
+        // 成功
+        SUCCESS,
+        // IO异常
+        IO_FAIL,
+        // 文件夹不存在
+        DIRECTORY_NOT_EXIST,
+        // notify id(即文件)不存在
+        NOTIFY_ID_NOT_EXIST,
+        // 文件为空
+        EMPTY_FILE
+    }
+
+    /**
+     * @usage 存储响应数据
+     * @param notifyId 通知ID
+     * @param data 响应数据
+     * @return ErrorCode 错误码
+     * @warning 写入时需要指定为UTF-8编码
+     */
+    public static ErrorCode setNotifyData(String notifyId, String data) {
+        
+        try {
+            String fileName = DATA_DIR + notifyId;
+            File file = new File(fileName);
+            if (!file.exists()) {
+                file.createNewFile();
+                SpecUtil.WWSpecLogInfo("create file '" + fileName + "' success");
+            }
+
+            BufferedWriter writer = new BufferedWriter(
+                new OutputStreamWriter(
+                    new FileOutputStream(fileName), StandardCharsets.UTF_8));
+            writer.write(data);
+            writer.close();
+
+        } catch (IOException e) {
+            e.printStackTrace();
+            return ErrorCode.IO_FAIL;
+        }
+
+        return ErrorCode.SUCCESS;
+    }
+
+    /**
+     * @usage 读取指定notify_id的数据
+     * @param notifyId 通知ID
+     * @param dataBuffer 响应数据缓冲区,用于接收数据
+     * @return ErrorCode 错误码
+     * @warning 读取时需要指定为UTF-8编码
+     */
+    public static ErrorCode getByNotifyId(String notifyId, StringBuffer dataBuffer) {
+        String fileName = DATA_DIR + notifyId;
+        File file = new File(fileName);
+        if (!file.exists()) {
+            SpecUtil.WWSpecLogError("notify_id not exist");
+            return ErrorCode.NOTIFY_ID_NOT_EXIST;
+        }
+
+        try {
+            BufferedReader reader = new BufferedReader(
+                new InputStreamReader(
+                    new FileInputStream(fileName), StandardCharsets.UTF_8));
+            String line = reader.readLine();
+            if (line == null || line.isEmpty()) {
+                SpecUtil.WWSpecLogError("file: '" + fileName + "' is empty");
+                reader.close();
+                reader.close();
+                return ErrorCode.EMPTY_FILE;
+            }
+            dataBuffer.append(line);
+            reader.close();
+            
+        } catch (IOException e) {
+            e.printStackTrace();
+            return ErrorCode.IO_FAIL;
+        }
+
+        return ErrorCode.SUCCESS;
+    }
+
+    /**
+     * @usage 私有构造函数,防止外部实例化
+     */ 
+    private DataBaseUtils() {}
+}

+ 79 - 0
fs-spec-zone/src/main/java/mytype/mycom/mygroup/DemoCallProgramHandler.java

@@ -0,0 +1,79 @@
+package mytype.mycom.mygroup;
+
+import com.tencent.wework.SpecCallbackSDK;
+import com.tencent.wework.SpecSDK;
+
+/*
+ * @usage 应用调用专区接口的handler示例Demo类
+ */
+public class DemoCallProgramHandler implements  UserLogicHandler{
+
+
+    /*
+     * @usage 透传请求的能力ID标识
+     */
+    private static final String TRANSMIT_SDK_PREFIX = "invoke_";
+
+    /*
+     * @usage 企业应用来获取回调事件数据的请求的能力ID
+     */
+    private static final String ABILITY_GET_CALLBACK_DATA = "get_callback_data";
+
+    @Override
+    public boolean isValidAblility(String abilityId){
+        return abilityId.startsWith(TRANSMIT_SDK_PREFIX) || ABILITY_GET_CALLBACK_DATA.equals(abilityId);
+    }
+
+    @Override
+    public String process(SpecCallbackSDK callback){
+        String abilityId = callback.GetAbilityId();
+        if(!isValidAblility(abilityId)) {
+            return CommonUtils.getErrorResponse("unknown abilityId: " + abilityId);
+        }
+
+        if (abilityId.startsWith(TRANSMIT_SDK_PREFIX)) {
+            return transmitRequest(callback, abilityId.substring(7));
+        } else if (ABILITY_GET_CALLBACK_DATA.equals(abilityId)) {
+            return getCallbackData(callback);
+        } else {
+            return CommonUtils.getErrorResponse("unknown abilityId: " + abilityId);
+        }
+    }
+
+    /*
+     * @usage 透传企业应用的请求,转发到企微专区后台,相当于企业应用的代理
+     * @param callback 企业应用的请求
+     * @return sdk的回包
+     */
+    private static String transmitRequest(SpecCallbackSDK callback, String apiName) {
+        SpecSDK sdk = new SpecSDK(callback);
+        sdk.SetRequest(callback.GetData());
+
+        int ret = sdk.Invoke(apiName);
+        if (ret != 0) {
+            return CommonUtils.getErrorResponse("invoke failed, ret = " + ret + ", api name = " + apiName);
+        }
+
+        return sdk.GetResponse();
+    }
+
+    /*
+     * @usage 从专区程序本地读取回调事件的数据
+     * @param callback 企业应用的请求
+     * @return 回调事件的数据
+     */
+    private static String getCallbackData(SpecCallbackSDK callback) {
+        String notifyId = callback.GetNotifyId();
+        if (notifyId.isEmpty()) {
+            return CommonUtils.getErrorResponse("notify_id is empty");
+        }
+
+        StringBuffer dataBuffer = new StringBuffer();
+        DataBaseUtils.ErrorCode errorCode = DataBaseUtils.getByNotifyId(notifyId, dataBuffer);
+        if (errorCode != DataBaseUtils.ErrorCode.SUCCESS) {
+            return CommonUtils.getErrorResponse("get data by notify_id failed, errorCode = " + errorCode);
+        }
+
+        return dataBuffer.toString();
+    }
+}

+ 72 - 0
fs-spec-zone/src/main/java/mytype/mycom/mygroup/DemoReceiveCallBackHandler.java

@@ -0,0 +1,72 @@
+package mytype.mycom.mygroup;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONObject;
+import com.alibaba.fastjson2.JSONValidator;
+import com.tencent.wework.SpecCallbackSDK;
+import com.tencent.wework.SpecSDK;
+import com.tencent.wework.SpecUtil;
+
+public class DemoReceiveCallBackHandler implements UserLogicHandler{
+    /*
+     * @usage 企微专区后台回包的错误码的key
+     */
+    private static final String QW_BACKEND_ERRCODE_KEY = "errcode";
+
+    @Override
+    public boolean isValidAblility(String abilityid){
+        return true;
+    }
+
+    @Override
+    public String process(SpecCallbackSDK callback){
+        return backendCallbackEvent(callback);
+    }
+
+    /*
+     * @param callback 企微专区后台的回调事件
+     * @return 响应消息体,形式为json string
+     * @description 企业微信回调事件, 暂存回调数据,产生"program_notify"事件通知应用主动来获取,详见:
+     *              https://developer.work.weixin.qq.com/document/path/99843
+     *              https://developer.work.weixin.qq.com/document/path/99992
+     * @note 1. sdk version 1.0.6新增特性:spec notify app支持自定义notify id,
+     *          用户可使用SpecUtil.GenerateNotifyId生成notify idh或自定义notify id
+     *          先将其与回调数据作关联存储,再调用spec notify app将该notify id递送到企业应用
+     *       2. 用于仍可以按照原版本:先通知企业应用,再将回包的notify id与回调事件数据作关联存储
+     *          专区环境能保证专区程序比企业应用早收到回包,调试模式下可能出现数据还未写入完毕就被请求的情况
+     */
+    private static String backendCallbackEvent(SpecCallbackSDK callback) {
+        // 生成notify id,将其和回调事件的数据作关联存储
+        String notifyId = SpecUtil.GenerateNotifyId();
+        DataBaseUtils.ErrorCode errorCode = DataBaseUtils.setNotifyData(notifyId, callback.GetData());
+        if (errorCode != DataBaseUtils.ErrorCode.SUCCESS) {
+            return CommonUtils.getErrorResponse("notify data store failed, errorCode = " + errorCode);
+        }
+
+        // 存储完毕后,调用sdk通知企业应用来获取回调事件的数据
+        SpecSDK sdk = new SpecSDK(callback);
+        sdk.SetRequest(String.format("{\"notify_id\": \"%s\"}", notifyId));
+        int ret = sdk.Invoke("spec_notify_app");
+        if (ret != 0) {
+            return CommonUtils.getErrorResponse("invoke failed, ret = " + ret);
+        }
+        SpecUtil.WWSpecLogInfo("invoke spec notify app done", "notify id = " + notifyId);
+
+        // 检验回包参数
+        String responseStr = sdk.GetResponse();
+        if (!JSONValidator.from(responseStr).validate() || responseStr.isEmpty()) {
+            return CommonUtils.getErrorResponse("notifyRsp is invalid, notifyRsp = " + responseStr);
+        }
+        JSONObject responseJson = JSON.parseObject(responseStr);
+        if (!responseJson.containsKey(QW_BACKEND_ERRCODE_KEY)) {
+            return CommonUtils.getErrorResponse("missing errcode in notifyRsp");
+        }
+        Integer errcode = responseJson.getInteger("errcode");
+        if (errcode != 0) {
+            return CommonUtils.getErrorResponse("invoke spec notify app failed, errcode = " + errcode + ", errmsg = " + responseJson.getString("errmsg"));
+        }
+
+        // 这里不需要包装,回调事件无需发送回包
+        return "notify and store success";
+    }
+}

+ 123 - 0
fs-spec-zone/src/main/java/mytype/mycom/mygroup/NetworkService.java

@@ -0,0 +1,123 @@
+package mytype.mycom.mygroup;
+
+
+import com.tencent.wework.SpecUtil;
+
+import io.netty.bootstrap.ServerBootstrap;
+import io.netty.channel.*;
+import io.netty.channel.nio.NioEventLoopGroup;
+import io.netty.channel.socket.nio.NioServerSocketChannel;
+import io.netty.handler.codec.http.*;
+import io.netty.channel.socket.SocketChannel;
+import java.util.UUID;
+
+
+/*
+ * @usage 网络服务实现类
+ */
+public class NetworkService {
+
+    /*
+     * @description 网络服务监听的IP
+     */
+    public static final String HOST = "0.0.0.0";
+
+    /*
+     * @description 网络服务监听的端口
+     */
+    public static final int PORT = 8080;
+
+    /*
+     * @description DEMO使用的SDK的版本号
+     */
+    private static final String SDK_VERSION = SpecUtil.GetSDKVersion();
+
+    /*
+     * @usage 初始化并启动netty服务
+     */
+    public static void startServer() throws Exception {
+
+        EventLoopGroup bossGroup = new NioEventLoopGroup(SvrConfig.boss_group_size);
+        EventLoopGroup workerGroup = new NioEventLoopGroup(SvrConfig.io_gorup_size);
+
+        try {
+            // 初始化服务器
+            ServerBootstrap sbs = new ServerBootstrap();
+            sbs.group(bossGroup, workerGroup)
+                    .channel(NioServerSocketChannel.class)
+                    // 设置bossGroup接收连接的队列长度
+                    .option(ChannelOption.SO_BACKLOG, 4096)
+                    // 地址复用,允许绑定处于TIME_WAIT状态下的socket
+                    .option(ChannelOption.SO_REUSEADDR, true)
+                    // 禁用Nagle算法,允许立即发送数据包,从而降低延迟
+                    .childOption(ChannelOption.TCP_NODELAY, true)
+                    // 设置请求处理器的流水线
+                    .childHandler(new ChannelInitializer<SocketChannel>() {
+                        @Override
+                        protected void initChannel(SocketChannel socketChannel) {
+                            ChannelPipeline cp = socketChannel.pipeline();
+                            // HTTP 解码器:将字节流解码为 HTTP 对象
+                            cp.addLast(new HttpServerCodec());
+                            // 聚合器:将多个 HTTP 消息部分聚合成完整的 HTTP 消息
+                            cp.addLast(new HttpObjectAggregator(64 * 1024 * 1024));
+                            // 业务逻辑处理器
+                            cp.addLast(new SpecHandler());
+                        }
+                    });
+
+            // 启动服务器
+            Channel ch = sbs.bind(HOST, PORT).sync().channel();
+            SpecUtil.WWSpecLogInfo(
+                    "server started",
+                    "host: " + NetworkService.HOST,
+                    "port: " + NetworkService.PORT,
+                    "SDK version: " + SDK_VERSION,
+                    "boss group size: " + SvrConfig.boss_group_size,
+                    "io worker group size: " + SvrConfig.io_gorup_size,
+                    "process in io thread:" + SvrConfig.process_in_iothread
+            );
+
+            // 启动性能监控线程
+            ResourceMonitor monitor = new ResourceMonitor(SvrConfig.monitor_interval);
+            monitor.start();
+
+            ch.closeFuture().sync();
+
+        } finally {
+            bossGroup.shutdownGracefully();
+            workerGroup.shutdownGracefully();
+            SpecUtil.WWSpecLogInfo("server shutdown");
+        }
+    }
+
+    /*
+     * @usage 入参解析和发送响应,不涉及具体的业务处理逻辑
+     */
+    private static class SpecHandler extends SimpleChannelInboundHandler<FullHttpRequest> {
+
+        @Override
+        protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest request) {
+            String requestId = request.headers().get("ww-req-id");
+            if(requestId==null || requestId.isEmpty()) {
+                requestId = UUID.randomUUID().toString().replace("-", "");
+                request.headers().set("ww-req-id", requestId);
+            }
+
+            RequestContext context = new RequestContext(ctx, requestId, SvrConfig.process_in_iothread);
+            RequestProcessor processor = new RequestProcessor();
+            processor.process(context, request);
+        }
+
+        @Override
+        public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws   Exception {
+            SpecUtil.WWSpecLogError("server exception: " + cause.getStackTrace());
+            cause.printStackTrace();
+            ctx.close();
+        }
+    }
+
+    /*
+     * @usage 私有构造函数,防止被实例化
+     */
+    private NetworkService() {}
+}

+ 62 - 0
fs-spec-zone/src/main/java/mytype/mycom/mygroup/RequestContext.java

@@ -0,0 +1,62 @@
+package mytype.mycom.mygroup;
+
+
+/*
+ * @usage 请求包上下文
+ *
+ * */
+
+import io.netty.channel.ChannelHandlerContext;
+
+
+public class RequestContext {
+    private ChannelHandlerContext channelContext;
+    /*
+     * @usage 请求包在io线程中开始channelRead0处理时间
+     */
+    private long startReadTime;
+
+    /*
+     * @usage 请求包在io线程投递到业务线程时间
+     */
+    private long addTaskTime;
+
+
+    /*
+     * @usage 请求id标识
+     */
+    private String reqid;
+
+    private boolean processInIoThread;
+
+    public RequestContext(ChannelHandlerContext channelContext, String requestId, boolean processInIoThread) {
+        this.channelContext = channelContext;
+        this.startReadTime = System.currentTimeMillis();
+        this.reqid = requestId;
+        this.processInIoThread = processInIoThread;
+    }
+
+    public ChannelHandlerContext getChannelHandlerContext() {
+        return channelContext;
+    }
+
+    public long getStartReadTime() {
+        return startReadTime;
+    }
+
+    public String getReqid() {
+        return reqid;
+    }
+
+    public boolean isProcessInIoThread() {return processInIoThread;}
+
+    public void setAddTaskTime(long addTaskTime) {
+        this.addTaskTime = addTaskTime;
+    }
+
+    public long getAddTaskTime() {
+        return addTaskTime;
+    }
+
+
+}

+ 263 - 0
fs-spec-zone/src/main/java/mytype/mycom/mygroup/RequestProcessor.java

@@ -0,0 +1,263 @@
+package mytype.mycom.mygroup;
+
+import io.netty.channel.ChannelFuture;
+import io.netty.handler.codec.http.*;
+
+import java.util.Arrays;
+import java.util.concurrent.*;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import com.tencent.wework.SpecCallbackSDK;
+import com.tencent.wework.SpecUtil;
+
+import io.netty.buffer.Unpooled;
+import io.netty.util.CharsetUtil;
+
+/*
+ * @usage 请求包处理器
+ * 支持使用自定义线程池处理请求包
+ *
+ * */
+
+public class RequestProcessor {
+    /*
+     * @description 处理POST请求的路径
+     */
+    private static final String POST_CONTEXT = "/";
+
+    public RequestProcessor() {
+    }
+
+    /*
+     * @usage 验证http请求是否合法
+     */
+    private HttpResponseStatus isValidRequest(FullHttpRequest request) {
+        // 检查请求是否按照HTTP协议规范发送
+        if (request.decoderResult().isFailure()) {
+            return HttpResponseStatus.BAD_REQUEST;
+        }
+        // 检查请求方法是否为POST
+        if (!HttpMethod.POST.equals(request.method())) {
+
+            return HttpResponseStatus.METHOD_NOT_ALLOWED;
+        }
+
+        // 检查请求路径是否为"/"
+        if (!request.uri().equals(POST_CONTEXT)) {
+            return HttpResponseStatus.NOT_FOUND;
+        }
+        return HttpResponseStatus.OK;
+    }
+
+    /*
+     * @param response http回包结构
+     * @usage 通过io线程发送回包
+     */
+    public void sendResponse(RequestContext ctx, FullHttpResponse response) {
+        long beginWriteTime = System.currentTimeMillis();
+        ChannelFuture future = ctx.getChannelHandlerContext().writeAndFlush(response);
+        future.addListener(writeFunc -> {
+            // 打印请求处理耗时、等待耗时(从io线程收到请求到业务线程开始执行)、以及写回包耗时
+            long writeTimeMs = System.currentTimeMillis() - beginWriteTime;
+            if (!writeFunc.isSuccess()) {
+                SpecUtil.WWSpecLogInfoWithReqId(ctx.getReqid(), "write err cost" + writeTimeMs);
+            }
+            else if(writeTimeMs > 10) {
+                SpecUtil.WWSpecLogInfoWithReqId(ctx.getReqid(), "write cost  " + writeTimeMs + " ms");
+            }
+        });
+    }
+
+    /*
+     * @return FullHttpResponse
+     * @usage 构造出错时回包
+     */
+    private FullHttpResponse buildErrResponse(HttpResponseStatus errStatus) {
+        return new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, errStatus);
+    }
+
+    /*
+     * @param ctx 请求上下文
+     * @param request http请求体FullHttpRequest
+     * @usage 将请求投递到业务线程异池步处理
+     */
+    public void process(RequestContext ctx, FullHttpRequest request) {
+
+        //校验请求是否合法
+        HttpResponseStatus status = isValidRequest(request);
+        if(status != HttpResponseStatus.OK) {
+            SpecUtil.WWSpecLogErrorWithReqId(ctx.getReqid(), "invalid http method: " + request.method() +  "status:" + status.code());
+            FullHttpResponse response = buildErrResponse(status);
+            sendResponse(ctx, response);
+            ctx.getChannelHandlerContext().close();
+            return;
+        }
+
+        if(ctx.isProcessInIoThread())
+        {
+            new ProcessTask(ctx, request).run();
+        }
+        else {
+            try {
+                request.retain();
+                ctx.setAddTaskTime(System.currentTimeMillis());
+                ThreadPoolSingleton.getInstance().execute(new ProcessTask(ctx, request));
+
+            } catch (RejectedExecutionException e) {
+                SpecUtil.WWSpecLogErrorWithReqId(ctx.getReqid(), "reject:queue is full reject " + Arrays.toString(e.getStackTrace()));
+                FullHttpResponse response = buildErrResponse(HttpResponseStatus.INTERNAL_SERVER_ERROR);
+                sendResponse(ctx, response);
+                ctx.getChannelHandlerContext().close();
+            }
+        }
+    }
+
+    class ProcessTask implements Runnable {
+
+        RequestContext ctx;
+        FullHttpRequest req;
+
+        public ProcessTask(RequestContext ctx, FullHttpRequest req) {
+            this.ctx = ctx;
+            this.req = req;
+        }
+
+        @Override
+        public void run() {
+            long processStartTime = System.currentTimeMillis();
+            try {
+                RequestHandler handler= new RequestHandler();
+                handler.process(ctx, req);
+            } catch (Throwable e) {
+                SpecUtil.WWSpecLogInfoWithReqId(ctx.getReqid(), " exception " + Arrays.toString(e.getStackTrace()));
+            } finally {
+                if(!ctx.isProcessInIoThread()) {
+                    req.release();
+                }
+
+                long currentTimeMillis = System.currentTimeMillis();
+                long total_cost = currentTimeMillis - ctx.getStartReadTime();
+                long processTimeMs = currentTimeMillis - processStartTime;
+
+                if(ctx.isProcessInIoThread()){
+                    SpecUtil.WWSpecLogInfoWithReqId(ctx.getReqid(), "run total " + total_cost +
+                            " ms handle " + processTimeMs +  " ms");
+                }
+                else {
+                    long wait_cost = processStartTime - ctx.getAddTaskTime();
+                    SpecUtil.WWSpecLogInfoWithReqId(ctx.getReqid(), "run total " + total_cost + " ms wait " +
+                            wait_cost + " ms handle " + processTimeMs + " ms");
+                }
+            }
+        }
+    }
+
+    /*
+     * @usage 请求包处理逻辑类
+     * 包括解包、调用业务逻辑、发送回包
+     */
+    class  RequestHandler{
+
+        /*
+         * @return SDK的SpecCallbackSDK结构
+         * @usage 将netty的FullHttpRequest结构解析为SDK的SpecCallbackSDK结构
+         */
+        private SpecCallbackSDK parseRequest(RequestContext ctx, FullHttpRequest request) {
+            Map<String, String> requestHeaders = new HashMap<>(request.headers().size());
+            for (Map.Entry<String, String> entry : request.headers().entries()) {
+                requestHeaders.put(entry.getKey(), entry.getValue());
+            }
+
+            //SpecUtil.WWSpecLogInfoWithReqId(ctx.getReqid(),"req header: " + requestHeaders);
+
+            // 使用UTF-8编码解析请求体,请求体为加密后经过base64编码的形式
+            String requestBody = request.content().toString(CharsetUtil.UTF_8);
+
+            //SpecUtil.WWSpecLogInfoWithReqId(ctx.getReqid(),"req body: " + requestBody);
+            return new SpecCallbackSDK("POST", requestHeaders, requestBody);
+        }
+
+        /*
+         * @return FullHttpResponse
+         * @usage 构造回包
+         */
+        private FullHttpResponse buildFullResponse(SpecCallbackSDK callback, String responseContent) {
+
+            // 构造响应头,加密响应体
+            callback.BuildResponseHeaderBody(responseContent);
+            Map<String, String> responseHeaders = callback.GetResponseHeaders();
+            String responseBody = callback.GetResponseBody();
+            //SpecUtil.WWSpecLogInfo("response build done, response headers: " + responseHeaders);
+
+            // 返回响应
+            FullHttpResponse response = new DefaultFullHttpResponse(
+                    HttpVersion.HTTP_1_1,
+                    HttpResponseStatus.OK,
+                    Unpooled.copiedBuffer(responseBody, CharsetUtil.UTF_8)
+            );
+            response.headers().set(HttpHeaderNames.CONTENT_LENGTH, response.content().readableBytes());
+            for (Map.Entry<String, String> header : responseHeaders.entrySet()) {
+                response.headers().set(header.getKey(), header.getValue());
+            }
+
+            return response;
+        }
+
+        /*
+         * @usage 执行用户的业务逻辑
+         * 先解包,然后根据请求的类型分别调用业务自己实现的业务逻辑类,然后返回响应
+         * 请求类型说明:1-应用主动调用,2-专区后台通知
+         */
+        public void process(RequestContext ctx, FullHttpRequest request) {
+
+            SpecCallbackSDK callback = parseRequest(ctx, request);
+            String responseContent;
+
+            // 解包校验签名等失败
+            if (!callback.IsOk()) {
+                responseContent = CommonUtils.getErrorResponse("callback is not ok");
+            }
+            else
+            {
+                //打印解密后的请求参数
+                SpecUtil.WWSpecLogInfoWithReqId(ctx.getReqid(),
+                        "req args corpId = " + callback.GetCorpId(),
+                        "agentId = " + callback.GetAgentId(),
+                        "callType = " + callback.GetCallType(),
+                        "isAsync = " + callback.GetIsAsync(),
+                        "jobInfo = " + callback.GetJobInfo(),
+                        "abilityId = " + callback.GetAbilityId(),
+                        "notifyId = " + callback.GetNotifyId(),
+                        "data = " + callback.GetData());
+
+
+                //根据callType来分别调用不同的用户业务逻辑处理类
+                switch ((int) callback.GetCallType()) {
+                    // 应用主动调用专区程序,详见:https://developer.work.weixin.qq.com/document/53336
+                    case 1: {
+                        DemoCallProgramHandler callProgramHandler = new DemoCallProgramHandler();
+                        responseContent = callProgramHandler.process(callback);
+                        break;
+
+                    }
+                    // 专区后台通知专区程序,详见:https://developer.work.weixin.qq.com/document/53419
+                    case 2: {
+                        DemoReceiveCallBackHandler receiveCallBackHandler = new DemoReceiveCallBackHandler();
+                        responseContent = receiveCallBackHandler.process(callback);
+                        break;
+                    }
+                    default:
+                        responseContent = CommonUtils.getErrorResponse("unknown call type: " + callback.GetCallType());
+                        break;
+                }
+            }
+
+            SpecUtil.WWSpecLogInfoWithReqId(ctx.getReqid(), "sdk_ver:"+ SpecUtil.GetSDKVersion(), "sz "+ responseContent.length() + " rsp " + responseContent);
+            FullHttpResponse response = buildFullResponse(callback, responseContent);
+            RequestProcessor.this.sendResponse(ctx, response);
+        }
+    }
+}
+

+ 171 - 0
fs-spec-zone/src/main/java/mytype/mycom/mygroup/ResourceMonitor.java

@@ -0,0 +1,171 @@
+package mytype.mycom.mygroup;
+
+import com.tencent.wework.SpecUtil;
+
+import java.lang.management.*;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * @usage 收集服务器指标,输出到日志
+ * @note  1. 监控日志同样需要使用SpecUtil输出,才能被日志管理平台收集
+ *        2. 性能监控日志应结合
+ */
+public class ResourceMonitor {
+
+    /**
+     * @description 监控指标收集器
+     */
+    private final MemoryMXBean memoryMXBean;
+    private final List<GarbageCollectorMXBean> gcBeans;
+    private final ThreadMXBean threadMXBean;
+    private final ClassLoadingMXBean classLoadingMXBean;
+    private final OperatingSystemMXBean osMXBean;
+
+    /**
+     * @description 收集监控指标用到的常量
+     */
+    private static final int INITIAL_DELAY = 0;
+    private   int OUTPUT_PERIOD = 10;
+    private static final double MB_SIZE = 1024.0 * 1024.0;
+    private static final int WAIT_THREADS_TIME = 100;
+
+    /**
+     * @description 日志格式
+     */
+    private static final String MEMORY_LOG_FORMAT = "MEM(MB) Heap: Init: %.2f, Max: %.2f, Used: %.2f, Committed: %.2f " +
+                                                    "Non-Heap: Init: %.2f, Max: %.2fMB, Used: %.2f, Committed: %.2f ";
+    private static final String GC_LOG_FORMAT = "GC : [%s], Cnt: %d, Time: %dms ";
+    private static final String THREAD_LOG_FORMAT = "ThreadCnt: %d, busy IO: %d Worker: %d. ";
+    private static final String CLASS_LOADING_LOG_FORMAT = "ClassCnt Total: %d, Loaded: %d, Unloaded: %d ";
+    private static final String OS_LOG_FORMAT = "Cpu Load: %.2f ";
+
+    /**
+     * @usage 实例化JMX监控对象
+     */
+    public ResourceMonitor(int monitor_interval) {
+        this.memoryMXBean = ManagementFactory.getMemoryMXBean();
+        this.gcBeans = ManagementFactory.getGarbageCollectorMXBeans();
+        this.threadMXBean = ManagementFactory.getThreadMXBean();
+        this.classLoadingMXBean = ManagementFactory.getClassLoadingMXBean();
+        this.osMXBean = ManagementFactory.getOperatingSystemMXBean();
+        this.OUTPUT_PERIOD = monitor_interval;
+
+    }
+
+    /**
+     * @usage 启动输出监控日志的定时任务
+     */
+    public void start() {
+        ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
+        scheduler.scheduleAtFixedRate(() -> {
+            try {
+                SpecUtil.WWSpecLogInfo(collectData());
+            } catch (Exception e) {
+                SpecUtil.WWSpecLogError("collect data fail", e.getMessage());
+                e.printStackTrace();
+            }
+        }, INITIAL_DELAY, OUTPUT_PERIOD, TimeUnit.SECONDS);        
+    }
+
+    /**
+     * @usage 收集监控数据,考虑到日志会被刷新,这里将输出部分静态的配置信息
+     * @return JMX能监控的数据
+     */
+    private String collectData() {
+        StringBuffer sb = new StringBuffer();
+
+        // 获取负载
+        sb.append(String.format(OS_LOG_FORMAT,
+                osMXBean.getSystemLoadAverage()
+        ));
+
+        // 获取线程信息
+        sb.append(String.format(THREAD_LOG_FORMAT,
+                threadMXBean.getThreadCount(),
+                getActiveThreadCount("nioEventLoopGroup"),
+                SvrConfig.process_in_iothread?0:ThreadPoolSingleton.getInstance().getActiveCount()
+        ));
+
+        // 获取内存使用情况
+        MemoryUsage heapMemoryUsage = memoryMXBean.getHeapMemoryUsage();
+        MemoryUsage nonHeapMemoryUsage = memoryMXBean.getNonHeapMemoryUsage();        
+        sb.append(String.format(MEMORY_LOG_FORMAT, 
+            bytesToMb(heapMemoryUsage.getInit()),
+            bytesToMb(heapMemoryUsage.getMax()),
+            bytesToMb(heapMemoryUsage.getUsed()),
+            bytesToMb(heapMemoryUsage.getCommitted()),
+            bytesToMb(nonHeapMemoryUsage.getInit()),
+            bytesToMb(nonHeapMemoryUsage.getMax()),
+            bytesToMb(nonHeapMemoryUsage.getUsed()),
+            bytesToMb(nonHeapMemoryUsage.getCommitted())
+        ));
+
+        // 获取垃圾回收信息
+        for (GarbageCollectorMXBean gcBean : gcBeans) {
+            sb.append(String.format(GC_LOG_FORMAT,
+                gcBean.getName(),
+                gcBean.getCollectionCount(),
+                gcBean.getCollectionTime()
+            ));
+        }
+
+
+        // 获取类加载信息
+        sb.append(String.format(CLASS_LOADING_LOG_FORMAT,
+            classLoadingMXBean.getTotalLoadedClassCount(),
+            classLoadingMXBean.getLoadedClassCount(),
+            classLoadingMXBean.getUnloadedClassCount()
+        ));
+
+
+
+        return sb.toString();
+    }
+
+    private static double bytesToMb(long bytes) {
+        return bytes / MB_SIZE;
+    }
+
+    /**
+     * @usage 获取指定线程名前缀的活跃线程数
+     */
+    private int getActiveThreadCount(String threadNamePrefix) {
+        if (!threadMXBean.isThreadCpuTimeSupported()) {
+            return -1;
+        }
+
+        // 收集netty线程的累计运行时间,单位纳秒
+        Map<Long, Long> nettyThreadRunningTime = new HashMap<>(threadMXBean.getThreadCount());
+        for (long threadId : threadMXBean.getAllThreadIds()) {
+            ThreadInfo threadInfo = threadMXBean.getThreadInfo(threadId);
+            if (threadInfo != null && threadInfo.getThreadName().startsWith(threadNamePrefix)) {
+                if (threadMXBean.getThreadCpuTime(threadId) > 0) {
+                    nettyThreadRunningTime.put(threadId, threadMXBean.getThreadCpuTime(threadId));
+                }
+            }
+        }
+
+        // 让出CPU等待,确保线程的CPU时间已经更新
+        try {
+            TimeUnit.MILLISECONDS.sleep(WAIT_THREADS_TIME);
+        } catch (InterruptedException e) {
+            SpecUtil.WWSpecLogError("Failed to sleep", e.getMessage());
+            e.printStackTrace();
+            return -1;
+        }
+
+        // 通过检查CPU时间是否增长来判断线程是否活跃
+        int activeThreadCount = 0;
+        for (long threadId : nettyThreadRunningTime.keySet()) {
+            if (threadMXBean.getThreadCpuTime(threadId) > nettyThreadRunningTime.get(threadId)) {
+                activeThreadCount++;
+            }
+        }
+        return activeThreadCount;
+    }
+}

+ 115 - 0
fs-spec-zone/src/main/java/mytype/mycom/mygroup/SpecDemo.java

@@ -0,0 +1,115 @@
+package mytype.mycom.mygroup;
+
+import com.tencent.wework.SpecUtil;
+import org.apache.commons.cli.*;
+
+import java.io.PrintStream;
+
+/**
+ * @note 开发调试前必读:
+ * 1. 如需用于生产环境,建议完善程序的可靠性
+ * 2. 启动app需指定字符编码为UTF-8:java -Dfile.encoding=UTF-8 -jar myapp.jar
+ * 3. 调试前请先阅读各public类的注释,了解demo server和SDK的基本框架和逻辑功能
+ * 4. 如需打日志,必须封装SpecUtil.SpecLog或SpecUtil.SpecLogNative方法
+ * 5. 所有demo源代码和SDK包含大量中文注释,源文件的字符编码均为UTF-8
+ * @expamle 调试前请先查看下列示例:
+ * 1. 应用使用token获取会话记录全流程:https://developer.work.weixin.qq.com/document/path/100052#应用获取会话记录的流程示例
+ * 2. 专区程序示例镜像配置说明:https://developer.work.weixin.qq.com/document/54166
+ */
+public class SpecDemo {
+
+    /**
+     * @usage 1. 设置全局字符编码为UTF-8(企微专区后台为UTF-8)
+     *        2. 加载DatabaseUtil,启动过期文件清除线程
+     *        3. 检查部分关键配置
+     */
+    static {
+        try {
+            System.setOut(new PrintStream(System.out, true, "UTF-8"));
+            System.setErr(new PrintStream(System.err, true, "UTF-8"));
+            System.setProperty("file.encoding", "UTF-8");
+            // Java Native Interface (JNI) 使用的编码方式
+            System.setProperty("sun.jnu.encoding", "UTF-8");
+        } catch (Exception e) {
+            SpecUtil.WWSpecLogError("charset init error");
+            e.printStackTrace();
+            System.exit(1);
+        }
+
+        try {
+            Class.forName("mytype.mycom.mygroup.DataBaseUtils");
+        } catch (ClassNotFoundException e) {
+            SpecUtil.WWSpecLogError("load DataBaseUtils failed");
+            e.printStackTrace();
+            System.exit(1);
+        }
+
+        CommonUtils.displayJvmCharsetConfig();
+    }
+
+    /**
+     * @usage 解析入参,处理进程级调用,启动服务器,启动调试模式的参数如下:
+     */
+    public static void main(String[] args) {
+        // CommandLineParser parser = new DefaultParser(args);
+        CommandLineParser parser = new DefaultParser();
+        HelpFormatter formatter = new HelpFormatter();
+        Options options = new Options();
+        options.addOption("d", true, "debugToken 调试模式时必须传该参数");
+        options.addOption("a", true, "accessToken 调试模式时必须传该参数");
+        options.addOption("i", true, "io worker thread count NioEventLoop worker线程池大小,默认为256。若开启业务线程执行,io线程建议调小");
+        options.addOption("b", true, "business worker thread count  自定义业务worker线程池大小,默认为256,仅当processInIoThread为1时生效");
+        options.addOption("p", true, "processInIoThread 业务逻辑是否在io线程执行。1:使用io线程执行业务逻辑 0: 使用自定义业务线程执行业务逻辑,默认为1");
+        options.addOption("m", true, "monitor interval 监控线程打日志时间间隔,默认为60s");
+
+        try {
+            CommandLine cmd = parser.parse(options, args);
+            if (cmd.hasOption("i")) {
+                SvrConfig.io_gorup_size = Integer.parseInt(cmd.getOptionValue("i"));
+            }
+
+            if (cmd.hasOption("b")) {
+                SvrConfig.business_gorup_size = Integer.parseInt(cmd.getOptionValue("b"));
+            }
+
+            if (cmd.hasOption("p")) {
+                SvrConfig.process_in_iothread = Integer.parseInt(cmd.getOptionValue("p")) != 0;
+                if(!SvrConfig.process_in_iothread)
+                {
+                    ThreadPoolSingleton.initialize(SvrConfig.business_gorup_size);
+                }
+            }
+
+            if (cmd.hasOption("m")) {
+                SvrConfig.monitor_interval = Integer.parseInt(cmd.getOptionValue("m"));
+            }
+
+            if (cmd.hasOption("d")) {
+                if (!cmd.hasOption("a")) {
+                    SpecUtil.WWSpecLogError("cmdline miss -a access_token");
+                    System.exit(1);
+                }
+                if (!SpecUtil.SpecOpenDebugMode(cmd.getOptionValue("d"), cmd.getOptionValue("a"))) {
+                    SpecUtil.WWSpecLogError("open debug mode failed");
+                    System.exit(1);
+                }
+            }
+
+            try {
+                NetworkService.startServer();
+            } catch (Exception e) {
+                SpecUtil.WWSpecLogError("start server failed" + e.getStackTrace());
+                formatter.printHelp("Options", options);
+                e.printStackTrace();
+            }
+
+
+
+        } catch (ParseException e) {
+            SpecUtil.WWSpecLogError("cmd options err");
+            formatter.printHelp("Options", options);
+            System.exit(1);
+        }
+
+    }
+}

+ 31 - 0
fs-spec-zone/src/main/java/mytype/mycom/mygroup/SvrConfig.java

@@ -0,0 +1,31 @@
+package mytype.mycom.mygroup;
+
+public class SvrConfig {
+
+    /**
+     * @description 负责接收连接的线程池大小
+     */
+    public static int boss_group_size = 1;
+    /**
+     * @description 负责处理请求的NioEventLoop worker线程池大小
+     * @note 如需按照CPU处理器数的一定倍率设置worker数,需注意k8s集群环境下是否读取到了母机的配置
+     */
+    public static int io_gorup_size = 256;
+
+    /**
+     * @description 负责处理业务的自定义线程池大小
+     */
+    public static int business_gorup_size = 256;
+
+
+    /**
+     * @description 业务逻辑是否在io线程上处理,false:在自定义的newCachedThreadPool线程池执行逻辑
+     */
+    public static boolean process_in_iothread = true;
+
+    /**
+     * @description 监控线程打日志间隔,默认60秒
+     */
+    public static int monitor_interval = 60;
+
+}

+ 33 - 0
fs-spec-zone/src/main/java/mytype/mycom/mygroup/ThreadPoolSingleton.java

@@ -0,0 +1,33 @@
+package mytype.mycom.mygroup;
+
+import java.util.concurrent.*;
+
+/*
+ * @usage 自定义业务线程池单例
+ * */
+public class ThreadPoolSingleton {
+
+    private ThreadPoolSingleton() {}
+
+    // 静态内部类
+    private static class Holder {
+        private static ThreadPoolExecutor instance_;
+
+        private static void initialize(int poolSize) {
+            if (instance_ == null) {
+                instance_ = new ThreadPoolExecutor(32, poolSize, 1000, TimeUnit.SECONDS, new ArrayBlockingQueue<Runnable>(16));
+            }
+        }
+    }
+
+    public static void initialize(int poolSize) {
+        Holder.initialize(poolSize);
+    }
+
+    public static ThreadPoolExecutor getInstance() {
+        if (Holder.instance_ == null) {
+            throw new IllegalStateException("ThreadPoolExecutor is not initialized. Call initialize() first.");
+        }
+        return Holder.instance_;
+    }
+}

+ 20 - 0
fs-spec-zone/src/main/java/mytype/mycom/mygroup/UserLogicHandler.java

@@ -0,0 +1,20 @@
+package mytype.mycom.mygroup;
+
+import com.tencent.wework.SpecCallbackSDK;
+
+public interface UserLogicHandler{
+    /**
+     * @description 判断能力id是否合法
+     * @param abilityid 能力id
+     * @return 返回结果
+     */
+    public boolean isValidAblility(String abilityid);
+
+    /**
+     * @description 业务逻辑处理函数
+     * @param callback SpecCallbackSdk结构
+     * @return 返回http回包body
+     */
+    public String process(SpecCallbackSDK callback);
+
+}

+ 3 - 0
fs-user-app/src/main/java/com/fs/app/controller/AppLoginController.java

@@ -626,6 +626,9 @@ public class AppLoginController extends AppBaseController{
         if (CollectionUtil.isEmpty(user)){
             user = userService.selectFsUserListByPhone(encryptPhoneOldKey(phone));
         }
+        if (CollectionUtil.isEmpty(user)){
+            user = userService.selectFsUserListByPhone(phone);
+        }
         if (CollectionUtil.isEmpty(user)){
             return R.error("此电话号码未绑定用户");
         }

+ 7 - 2
fs-user-app/src/main/java/com/fs/app/controller/FsCourseCouponUserController.java

@@ -6,9 +6,12 @@ import com.fs.common.core.domain.R;
 import com.fs.his.domain.FsCourseCouponUser;
 import com.fs.his.param.FsCourseCouponUserUParam;
 import com.fs.his.service.IFsCourseCouponUserService;
+import com.fs.his.vo.CourseCouponUserListUVO;
 import com.github.pagehelper.PageHelper;
 import com.github.pagehelper.PageInfo;
 import io.swagger.annotations.Api;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.*;
 
@@ -19,6 +22,7 @@ import java.util.List;
 @RequestMapping(value="/app/courseCouponUser")
 public class FsCourseCouponUserController extends AppBaseController {
 
+    Logger logger= LoggerFactory.getLogger(getClass());
     @Autowired
     private IFsCourseCouponUserService fsCourseCouponUserService;
 
@@ -31,8 +35,8 @@ public class FsCourseCouponUserController extends AppBaseController {
         FsCourseCouponUser couponUser = new FsCourseCouponUser();
         couponUser.setStatus(param.getStatus());
         couponUser.setUserId(userId);
-        List<FsCourseCouponUser> fsCourseCouponUsers = fsCourseCouponUserService.selectFsCourseCouponUserList(couponUser);
-        PageInfo<FsCourseCouponUser> pageInfo = new PageInfo<>(fsCourseCouponUsers);
+        List<CourseCouponUserListUVO> courseCouponUserListUVOS = fsCourseCouponUserService.selectCourseCouponUserUVOList(couponUser);
+        PageInfo<CourseCouponUserListUVO> pageInfo = new PageInfo<>(courseCouponUserListUVOS);
         return R.ok().put("data",pageInfo);
     }
 
@@ -41,6 +45,7 @@ public class FsCourseCouponUserController extends AppBaseController {
     public R useCoupon(@RequestBody FsCourseCouponUser courseCouponUser) {
         long userId = Long.parseLong(getUserId());
         courseCouponUser.setUserId(userId);
+        logger.info("用户使用优惠券:{}",courseCouponUser);
         return fsCourseCouponUserService.useCoupon(courseCouponUser.getUserId(), courseCouponUser.getId());
     }
 

+ 25 - 0
fs-user-app/src/main/java/com/fs/app/controller/store/IndexScrmController.java

@@ -12,6 +12,7 @@ import com.fs.common.utils.StringUtils;
 import com.fs.course.domain.FsCoursePlaySourceConfig;
 import com.fs.course.service.IFsCoursePlaySourceConfigService;
 import com.fs.his.config.AgreementConfig;
+import com.fs.hisStore.config.StoreConfig;
 import com.fs.hisStore.domain.*;
 import com.fs.hisStore.param.*;
 import com.fs.hisStore.service.*;
@@ -390,6 +391,30 @@ public class IndexScrmController extends AppBaseController {
 		return R.ok().put("data",data);
 	}
 
+	/**
+	 * 商城配置
+	 * @return
+	 */
+	@GetMapping("/getHisStoreConfig")
+	public R getHisStoreConfig(@RequestParam("name") String name)
+	{
+		String json=configService.selectConfigByKey("his.store");
+		Object moduleOne = null;
+		Object moduleTwo = null;
+		switch (name){
+			case "moduleShow":
+			case "enableHomeModuleTwoShow":
+				StoreConfig storeConfig = JSONUtil.toBean(json, StoreConfig.class);
+				moduleOne = storeConfig.getEnableHomeModuleOneShow();
+				moduleTwo = storeConfig.getEnableHomeModuleTwoShow();
+				break;
+			default:
+				break;
+		}
+
+		return R.ok().put("moduleOneShow",moduleOne).put("moduleTwoShow",moduleTwo);
+	}
+
 	/**
 	 * 获取小程序logo
 	 * @return

+ 6 - 0
fs-user-app/src/main/java/com/fs/app/controller/store/ProductScrmController.java

@@ -251,6 +251,12 @@ public class ProductScrmController extends AppBaseController {
             }
         }
 
+
+        //临时做个修改 商城的订单 压根不需要商品属性值对象里的库存,表结构对应不上,改成 商品库存
+        // 将 productValues 中每个元素的 stock 替换为 product 的 stock
+        if (productValues != null && product.getStock() != null) {
+            productValues.forEach(value -> value.setStock(product.getStock().intValue()));
+        }
         return R.ok().put("product",product)
                 .put("productAttr",productAttr)
                 .put("productValues",productValues)