Browse Source

Merge branch 'refs/heads/master' into 康年堂

# Conflicts:
#	fs-admin/src/main/java/com/fs/hisStore/controller/FsStoreHealthOrderScrmController.java
ct 3 days ago
parent
commit
22dc5d8795
100 changed files with 4390 additions and 150 deletions
  1. 41 0
      fs-admin/src/main/java/com/fs/course/controller/FsUserCoursePeriodController.java
  2. 9 6
      fs-admin/src/main/java/com/fs/hisStore/controller/FsStoreHealthOrderScrmController.java
  3. 3 3
      fs-admin/src/main/java/com/fs/hisStore/controller/FsStoreOrderScrmController.java
  4. 1 1
      fs-admin/src/main/java/com/fs/hisStore/task/LiveTask.java
  5. 4 0
      fs-admin/src/main/java/com/fs/live/controller/LiveAfterSalesController.java
  6. 35 0
      fs-admin/src/main/java/com/fs/live/controller/LiveController.java
  7. 14 1
      fs-admin/src/main/java/com/fs/qw/controller/QwExternalContactController.java
  8. 3 0
      fs-admin/src/main/java/com/fs/qw/controller/QwSopTempController.java
  9. 3 3
      fs-admin/src/main/resources/logback.xml
  10. 3 3
      fs-common-api/src/main/resources/logback.xml
  11. 3 1
      fs-common/src/main/java/com/fs/common/constant/LiveKeysConstant.java
  12. 3 3
      fs-company-app/src/main/resources/logback.xml
  13. 4 0
      fs-company/src/main/java/com/fs/company/controller/live/LiveAfterSalesController.java
  14. 1 1
      fs-company/src/main/java/com/fs/company/controller/live/LiveDataController.java
  15. 106 0
      fs-company/src/main/java/com/fs/company/controller/live/LiveWatchLogController.java
  16. 111 0
      fs-company/src/main/java/com/fs/company/controller/qw/LuckyBagCollectRecordController.java
  17. 88 0
      fs-company/src/main/java/com/fs/company/controller/qw/LuckyBagController.java
  18. 3 0
      fs-company/src/main/java/com/fs/company/controller/qw/QwSopTempController.java
  19. 3 3
      fs-company/src/main/resources/logback.xml
  20. 17 0
      fs-ipad-task/src/main/java/com/fs/app/service/IpadSendServer.java
  21. 18 1
      fs-ipad-task/src/main/java/com/fs/app/task/SendMsg.java
  22. 1 1
      fs-live-app/src/main/java/com/fs/live/controller/LiveController.java
  23. 159 0
      fs-live-app/src/main/java/com/fs/live/task/LiveCompletionPointsTask.java
  24. 183 0
      fs-live-app/src/main/java/com/fs/live/task/Task.java
  25. 322 22
      fs-live-app/src/main/java/com/fs/live/websocket/service/WebSocketServer.java
  26. 3 3
      fs-qw-api/src/main/resources/logback.xml
  27. 13 1
      fs-qw-task/src/main/java/com/fs/app/task/UserCourseWatchCountTask.java
  28. 216 11
      fs-qw-task/src/main/java/com/fs/app/taskService/impl/SopLogsTaskServiceImpl.java
  29. 3 3
      fs-qw-task/src/main/resources/logback.xml
  30. 3 3
      fs-qwhook-msg/src/main/resources/logback.xml
  31. 3 3
      fs-qwhook-sop/src/main/resources/logback.xml
  32. 3 3
      fs-qwhook/src/main/resources/logback.xml
  33. 8 0
      fs-service/pom.xml
  34. 5 0
      fs-service/src/main/java/com/fs/company/mapper/CompanyMapper.java
  35. 4 0
      fs-service/src/main/java/com/fs/config/cloud/CloudHostProper.java
  36. 30 0
      fs-service/src/main/java/com/fs/core/config/VolcEngineConfiguration.java
  37. 5 0
      fs-service/src/main/java/com/fs/course/domain/FsCourseRealLink.java
  38. 2 0
      fs-service/src/main/java/com/fs/course/domain/FsCourseWatchLog.java
  39. 7 0
      fs-service/src/main/java/com/fs/course/domain/FsUserCourseVideo.java
  40. 69 0
      fs-service/src/main/java/com/fs/course/domain/LuckyBag.java
  41. 151 0
      fs-service/src/main/java/com/fs/course/domain/LuckyBagCollectRecord.java
  42. 1 0
      fs-service/src/main/java/com/fs/course/mapper/FsCourseAnswerLogsMapper.java
  43. 2 2
      fs-service/src/main/java/com/fs/course/mapper/FsCourseFinishTempMapper.java
  44. 1 0
      fs-service/src/main/java/com/fs/course/mapper/FsCourseRedPacketLogMapper.java
  45. 6 0
      fs-service/src/main/java/com/fs/course/mapper/FsUserCourseVideoMapper.java
  46. 2 0
      fs-service/src/main/java/com/fs/course/param/FsCourseAnswerLogsParam.java
  47. 1 0
      fs-service/src/main/java/com/fs/course/param/FsCourseRedPacketLogParam.java
  48. 3 0
      fs-service/src/main/java/com/fs/course/param/FsCourseWatchLogListParam.java
  49. 22 0
      fs-service/src/main/java/com/fs/course/param/LuckyBagActualRewardsParam.java
  50. 12 0
      fs-service/src/main/java/com/fs/course/service/IFsUserCourseVideoService.java
  51. 0 19
      fs-service/src/main/java/com/fs/course/service/impl/FsCourseWatchLogServiceImpl.java
  52. 213 6
      fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java
  53. 54 0
      fs-service/src/main/java/com/fs/course/vo/FsPeriodCountExportVO.java
  54. 3 0
      fs-service/src/main/java/com/fs/course/vo/FsUserCourseVideoQVO.java
  55. 3 0
      fs-service/src/main/java/com/fs/course/vo/FsUserCourseVideoVO.java
  56. 1 1
      fs-service/src/main/java/com/fs/erp/service/impl/JSTErpOrderServiceImpl.java
  57. 4 4
      fs-service/src/main/java/com/fs/fastGpt/service/impl/AiHookServiceImpl.java
  58. 4 4
      fs-service/src/main/java/com/fs/fastGpt/service/impl/AiNewServiceImpl.java
  59. 4 4
      fs-service/src/main/java/com/fs/fastGpt/service/impl/AiServiceImpl.java
  60. 8 0
      fs-service/src/main/java/com/fs/his/domain/FsUserIntegralLogs.java
  61. 2 0
      fs-service/src/main/java/com/fs/his/mapper/FsUserInformationCollectionMapper.java
  62. 26 0
      fs-service/src/main/java/com/fs/his/param/FsReceiveLuckyBagParam.java
  63. 1 0
      fs-service/src/main/java/com/fs/his/service/impl/MerchantAppConfigServiceImpl.java
  64. 4 0
      fs-service/src/main/java/com/fs/hisStore/domain/FsStoreAfterSalesScrm.java
  65. 3 0
      fs-service/src/main/java/com/fs/hisStore/mapper/FsStoreAfterSalesScrmMapper.java
  66. 16 1
      fs-service/src/main/java/com/fs/hisStore/mapper/FsStoreOrderItemScrmMapper.java
  67. 17 2
      fs-service/src/main/java/com/fs/hisStore/mapper/FsStoreOrderScrmMapper.java
  68. 12 0
      fs-service/src/main/java/com/fs/hisStore/mapper/FsStoreProductAttrValueScrmMapper.java
  69. 10 0
      fs-service/src/main/java/com/fs/hisStore/mapper/MergedOrderMapper.java
  70. 31 0
      fs-service/src/main/java/com/fs/hisStore/param/MergedAfterSalesDeliveryParam.java
  71. 43 0
      fs-service/src/main/java/com/fs/hisStore/param/MergedAfterSalesParam.java
  72. 23 0
      fs-service/src/main/java/com/fs/hisStore/param/MergedAfterSalesQueryParam.java
  73. 22 0
      fs-service/src/main/java/com/fs/hisStore/param/MergedAfterSalesRevokeParam.java
  74. 25 0
      fs-service/src/main/java/com/fs/hisStore/param/MergedOrderDeleteParam.java
  75. 56 1
      fs-service/src/main/java/com/fs/hisStore/service/IMergedOrderService.java
  76. 42 4
      fs-service/src/main/java/com/fs/hisStore/service/impl/FsStoreOrderScrmServiceImpl.java
  77. 230 1
      fs-service/src/main/java/com/fs/hisStore/service/impl/MergedOrderServiceImpl.java
  78. 103 0
      fs-service/src/main/java/com/fs/hisStore/vo/MergedAfterSalesVO.java
  79. 2 0
      fs-service/src/main/java/com/fs/im/dto/OpenImMsgDTO.java
  80. 6 0
      fs-service/src/main/java/com/fs/live/domain/Live.java
  81. 58 0
      fs-service/src/main/java/com/fs/live/domain/LiveCompletionPointsRecord.java
  82. 4 0
      fs-service/src/main/java/com/fs/live/domain/LiveOrder.java
  83. 66 0
      fs-service/src/main/java/com/fs/live/domain/LiveTagConfig.java
  84. 1 1
      fs-service/src/main/java/com/fs/live/domain/LiveWatchConfig.java
  85. 89 0
      fs-service/src/main/java/com/fs/live/domain/LiveWatchLog.java
  86. 52 0
      fs-service/src/main/java/com/fs/live/mapper/LiveCompletionPointsRecordMapper.java
  87. 67 0
      fs-service/src/main/java/com/fs/live/mapper/LiveTagConfigMapper.java
  88. 1 1
      fs-service/src/main/java/com/fs/live/mapper/LiveVideoMapper.java
  89. 74 0
      fs-service/src/main/java/com/fs/live/mapper/LiveWatchLogMapper.java
  90. 6 0
      fs-service/src/main/java/com/fs/live/mapper/LiveWatchUserMapper.java
  91. 59 0
      fs-service/src/main/java/com/fs/live/param/LiveIsAddKfParam.java
  92. 43 0
      fs-service/src/main/java/com/fs/live/service/ILiveCompletionPointsRecordService.java
  93. 7 0
      fs-service/src/main/java/com/fs/live/service/ILiveService.java
  94. 61 0
      fs-service/src/main/java/com/fs/live/service/ILiveWatchLogService.java
  95. 31 0
      fs-service/src/main/java/com/fs/live/service/ILiveWatchUserService.java
  96. 349 0
      fs-service/src/main/java/com/fs/live/service/impl/LiveCompletionPointsRecordServiceImpl.java
  97. 78 9
      fs-service/src/main/java/com/fs/live/service/impl/LiveOrderServiceImpl.java
  98. 51 1
      fs-service/src/main/java/com/fs/live/service/impl/LiveServiceImpl.java
  99. 94 0
      fs-service/src/main/java/com/fs/live/service/impl/LiveWatchLogServiceImpl.java
  100. 518 13
      fs-service/src/main/java/com/fs/live/service/impl/LiveWatchUserServiceImpl.java

+ 41 - 0
fs-admin/src/main/java/com/fs/course/controller/FsUserCoursePeriodController.java

@@ -1,5 +1,6 @@
 package com.fs.course.controller;
 
+import com.fs.baidu.domain.BdAccount;
 import com.fs.common.annotation.Log;
 import com.fs.common.core.controller.BaseController;
 import com.fs.common.core.domain.AjaxResult;
@@ -15,6 +16,7 @@ import com.fs.course.service.IFsUserCoursePeriodDaysService;
 import com.fs.course.service.IFsUserCoursePeriodService;
 import com.fs.course.service.IFsUserCourseVideoRedPackageService;
 import com.fs.course.vo.*;
+import com.fs.course.vo.newfs.FsCourseAnalysisCountVO;
 import com.fs.his.vo.OptionsVO;
 import com.github.pagehelper.PageHelper;
 import com.github.pagehelper.PageInfo;
@@ -27,6 +29,7 @@ import org.springframework.web.bind.annotation.*;
 
 import java.math.BigDecimal;
 import java.time.LocalDateTime;
+import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -231,6 +234,44 @@ public class FsUserCoursePeriodController extends BaseController {
         return R.ok(result);
     }
 
+    /**
+     * @Description: 会员营期导出课程统计
+     * @Param:
+     * @Return:
+     * @Author xgb
+     * @Date 2025/12/12 11:37
+     */
+    @PostMapping("/exportInfo")
+    @ApiOperation("营期统计")
+    public AjaxResult exportInfo(@RequestBody PeriodCountParam param) {
+        List<FsPeriodCountExportVO> exportList = new ArrayList<>();
+        PageHelper.startPage(param.getPageNum(), param.getPageSize());
+        List<FsPeriodCountVO> list = fsUserCoursePeriodDaysService.periodCourseCount(param);
+        list.forEach(item -> {
+            FsPeriodCountExportVO exportVO = new FsPeriodCountExportVO();
+            exportVO.setTitle(item.getTitle());
+            exportVO.setDayDate(item.getDayDate());
+            if(item.getCountDetailsVO() != null){
+                FsCourseAnalysisCountVO countDetailsVO =item.getCountDetailsVO();
+                exportVO.setCourseWatchTimes(countDetailsVO.getCourseWatchTimes());
+                exportVO.setCourseCompleteTimes(countDetailsVO.getCourseCompleteTimes());
+                exportVO.setCourseWatchNum(countDetailsVO.getCourseWatchNum());
+                exportVO.setCourseCompleteNum(countDetailsVO.getCourseCompleteNum());
+                exportVO.setCompleteRate(countDetailsVO.getCompleteRate()+"%");
+                exportVO.setAnswerTimes(countDetailsVO.getAnswerTimes());
+                exportVO.setAnswerNum(countDetailsVO.getAnswerNum());
+                exportVO.setAnswerRightNum(countDetailsVO.getAnswerRightNum());
+                exportVO.setAnswerRightRate(countDetailsVO.getAnswerRightRate()+"%");
+                exportVO.setRedPacketNum(countDetailsVO.getRedPacketNum());
+                exportVO.setRedPacketAmount(countDetailsVO.getRedPacketAmount());
+            }
+            exportList.add(exportVO);
+        });
+
+        ExcelUtil<FsPeriodCountExportVO> util = new ExcelUtil<FsPeriodCountExportVO>(FsPeriodCountExportVO.class);
+        return util.exportExcel(exportList, "百度账号数据");
+    }
+
     @GetMapping("/getPeriodListLikeName")
     public R getPeriodListLikeName(@RequestParam(required = false) String name,
                                    @RequestParam(required = false) Long campId,

+ 9 - 6
fs-admin/src/main/java/com/fs/hisStore/controller/FsStoreHealthOrderScrmController.java

@@ -29,6 +29,7 @@ import com.fs.hisStore.param.FsStoreOrderParam;
 import com.fs.hisStore.service.*;
 import com.fs.hisStore.vo.*;
 import com.fs.system.service.ISysRoleService;
+import com.github.pagehelper.PageHelper;
 import org.springframework.beans.BeanUtils;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Value;
@@ -85,7 +86,7 @@ public class FsStoreHealthOrderScrmController extends BaseController {
 //    @PreAuthorize("@ss.hasPermi('store:healthStoreOrder:list')")
       @PostMapping("/healthList")
       public TableDataInfo healthStoreList(@RequestBody FsStoreOrderParam param) {
-        startPage();
+          PageHelper.startPage(param.getPageNum(), param.getPageSize());
         if(!StringUtils.isEmpty(param.getCreateTimeRange())){
             param.setCreateTimeList(param.getCreateTimeRange().split("--"));
         }
@@ -121,8 +122,10 @@ public class FsStoreHealthOrderScrmController extends BaseController {
                     }
                 }
                 //
-                if ((loginUser.getPermissions().contains("his:storeAfterSales:finance") || loginUser.getPermissions().contains("*:*:*") && (vo.getCost() !=null && vo.getTotalNum() != null))) {
-                    vo.setFPrice(vo.getCost().multiply(BigDecimal.valueOf(vo.getTotalNum())));
+                if (loginUser.getPermissions().contains("his:storeAfterSales:finance") || loginUser.getPermissions().contains("*:*:*") ) {
+                    if((vo.getCost() !=null && vo.getTotalNum() != null)){
+                        vo.setFPrice(vo.getCost().multiply(BigDecimal.valueOf(vo.getTotalNum())));
+                    }
                 } else {
                     vo.setPayPostage(BigDecimal.ZERO);
                     vo.setCost(BigDecimal.ZERO);
@@ -331,7 +334,7 @@ public class FsStoreHealthOrderScrmController extends BaseController {
                     }
                 }
                 //
-                if (loginUser.getPermissions().contains("his:storeAfterSales:finance") || loginUser.getPermissions().contains("*:*:*")) {
+                if ((loginUser.getPermissions().contains("his:storeAfterSales:finance") || loginUser.getPermissions().contains("*:*:*") ) && !Objects.isNull(vo.getCost())) {
                     vo.setFPrice(vo.getCost().multiply(BigDecimal.valueOf(vo.getTotalNum())));
                 } else {
                     vo.setPayPostage(BigDecimal.ZERO);
@@ -396,7 +399,7 @@ public class FsStoreHealthOrderScrmController extends BaseController {
                             } catch (Exception e) {
                             }
                         }
-                        if (loginUser.getPermissions().contains("his:storeAfterSales:finance") || loginUser.getPermissions().contains("*:*:*")) {
+                        if ((loginUser.getPermissions().contains("his:storeAfterSales:finance") || loginUser.getPermissions().contains("*:*:*") ) && !Objects.isNull(vo.getCost())) {
                             vo.setFPrice(vo.getCost().multiply(BigDecimal.valueOf(vo.getTotalNum())));
                         } else {
                             vo.setPayPostage(BigDecimal.ZERO);
@@ -422,7 +425,7 @@ public class FsStoreHealthOrderScrmController extends BaseController {
                     } catch (Exception e) {
                     }
                 }
-                if (loginUser.getPermissions().contains("his:storeAfterSales:finance") || loginUser.getPermissions().contains("*:*:*")) {
+                if ((loginUser.getPermissions().contains("his:storeAfterSales:finance") || loginUser.getPermissions().contains("*:*:*") ) && !Objects.isNull(vo.getCost())) {
                     vo.setFPrice(vo.getCost().multiply(BigDecimal.valueOf(vo.getTotalNum())));
                 } else {
                     vo.setPayPostage(BigDecimal.ZERO);

+ 3 - 3
fs-admin/src/main/java/com/fs/hisStore/controller/FsStoreOrderScrmController.java

@@ -499,7 +499,7 @@ public class FsStoreOrderScrmController extends BaseController {
                     }
                 }
                 //
-                if (loginUser.getPermissions().contains("his:storeAfterSales:finance") || loginUser.getPermissions().contains("*:*:*")) {
+                if ((loginUser.getPermissions().contains("his:storeAfterSales:finance") || loginUser.getPermissions().contains("*:*:*") ) && !Objects.isNull(vo.getCost())) {
                     vo.setFPrice(vo.getCost().multiply(BigDecimal.valueOf(vo.getTotalNum())));
                 } else {
                     vo.setPayPostage(BigDecimal.ZERO);
@@ -568,7 +568,7 @@ public class FsStoreOrderScrmController extends BaseController {
                         }
                     }
                     //
-                    if (loginUser.getPermissions().contains("his:storeAfterSales:finance") || loginUser.getPermissions().contains("*:*:*")) {
+                    if ((loginUser.getPermissions().contains("his:storeAfterSales:finance") || loginUser.getPermissions().contains("*:*:*") ) && !Objects.isNull(vo.getCost())) {
                         vo.setFPrice(vo.getCost().multiply(BigDecimal.valueOf(vo.getTotalNum())));
                     } else {
                         vo.setPayPostage(BigDecimal.ZERO);
@@ -596,7 +596,7 @@ public class FsStoreOrderScrmController extends BaseController {
                     }
                 }
                 //
-                if (loginUser.getPermissions().contains("his:storeAfterSales:finance") || loginUser.getPermissions().contains("*:*:*")) {
+                if ((loginUser.getPermissions().contains("his:storeAfterSales:finance") || loginUser.getPermissions().contains("*:*:*") ) && !Objects.isNull(vo.getCost())) {
                     vo.setFPrice(vo.getCost().multiply(BigDecimal.valueOf(vo.getTotalNum())));
                 } else {
                     vo.setPayPostage(BigDecimal.ZERO);

+ 1 - 1
fs-admin/src/main/java/com/fs/hisStore/task/LiveTask.java

@@ -172,7 +172,7 @@ public class LiveTask {
 
     // 订单银行回调数据丢失补偿
     public void recoveryBankOrder() {
-        // 查询出来最近三十分钟的订单 待支付 未退款
+        // 查询出来最近15分钟的订单 待支付 未退款
         List<LiveOrder> list = liveOrderService.selectBankOrder();
         if(list == null || list.isEmpty()) return;
         for (LiveOrder liveOrder : list) {

+ 4 - 0
fs-admin/src/main/java/com/fs/live/controller/LiveAfterSalesController.java

@@ -93,6 +93,10 @@ public class LiveAfterSalesController extends BaseController
     public TableDataInfo list(LiveAfterSalesVo liveAfterSales)
     {
         startPage();
+        // 将productName映射到productNameQuery用于查询
+        if (liveAfterSales.getProductName() != null && !liveAfterSales.getProductName().isEmpty()) {
+            liveAfterSales.setProductNameQuery(liveAfterSales.getProductName());
+        }
         List<LiveAfterSalesVo> list = liveAfterSalesService.selectLiveAfterSalesVoList(liveAfterSales);
         for (LiveAfterSalesVo liveAfterSalesVo : list) {
             liveAfterSalesVo.setUserPhone(ParseUtils.parsePhone(liveAfterSalesVo.getUserPhone()));

+ 35 - 0
fs-admin/src/main/java/com/fs/live/controller/LiveController.java

@@ -9,12 +9,18 @@ import com.fs.common.core.page.TableDataInfo;
 import com.fs.common.enums.BusinessType;
 import com.fs.common.utils.ServletUtils;
 import com.fs.common.utils.poi.ExcelUtil;
+import com.fs.company.vo.CompanyVO;
 import com.fs.framework.web.service.TokenService;
 import com.fs.hisStore.task.LiveTask;
 import com.fs.hisStore.task.MallStoreTask;
 import com.fs.live.domain.Live;
 import com.fs.live.service.ILiveService;
 import com.fs.live.vo.LiveListVo;
+import com.fs.qw.domain.QwTagGroup;
+import com.fs.qw.service.IQwTagGroupService;
+import com.fs.qw.service.impl.QwUserServiceImpl;
+import com.fs.qw.vo.QwOptionsVO;
+import com.fs.qw.vo.QwTagGroupListVO;
 import com.hc.openapi.tool.fastjson.JSON;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -41,6 +47,12 @@ public class LiveController extends BaseController {
     @Autowired
     private TokenService tokenService;
 
+    @Autowired
+    QwUserServiceImpl qwUserService;
+
+    @Autowired
+    private IQwTagGroupService qwTagGroupService;
+
 
     /**
      * 查询直播列表
@@ -186,4 +198,27 @@ public class LiveController extends BaseController {
     }
 
 
+    /**
+     * 获取公司下拉列表
+     * @return
+     */
+    @GetMapping("/getCompanyDropList")
+    public R getCompanyDropList(){
+        List<CompanyVO> companyDropList = liveService.getCompanyDropList();
+        return R.ok().put("data",companyDropList);
+    }
+
+    @GetMapping("/getQwCorpList/{companyId}")
+    public R getQwCorpList(@PathVariable Long companyId){
+        List<QwOptionsVO> qwOptionsVOS = qwUserService.selectQwCompanyListOptionsVOByCompanyId(companyId);
+        return R.ok().put("data",qwOptionsVOS);
+    }
+
+    @GetMapping("/getTagsListByCorpId")
+    public TableDataInfo getTagsListByCorpId(QwTagGroup qwTagGroup){
+        startPage();
+        List<QwTagGroupListVO> list = qwTagGroupService.selectQwTagGroupListVO(qwTagGroup);
+        return getDataTable(list);
+    }
+
 }

+ 14 - 1
fs-admin/src/main/java/com/fs/qw/controller/QwExternalContactController.java

@@ -5,14 +5,17 @@ import java.util.Objects;
 
 import cn.hutool.core.collection.CollectionUtil;
 import cn.hutool.core.util.ObjectUtil;
+import com.fs.common.core.domain.R;
 import com.fs.common.exception.ServiceException;
 import com.fs.qw.param.QwExternalContactParam;
 import com.fs.qw.param.QwTagSearchParam;
+import com.fs.qw.service.IQwExternalContactInfoService;
 import com.fs.qw.service.IQwTagService;
 import com.fs.qw.vo.QwExternalContactUnionIdExportVO;
 import com.fs.qw.vo.QwExternalContactVO;
 import com.google.gson.Gson;
 import com.google.gson.reflect.TypeToken;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PostMapping;
@@ -33,7 +36,7 @@ import com.fs.common.core.page.TableDataInfo;
 
 /**
  * 企业微信客户Controller
- * 
+ *
  * @author fs
  * @date 2025-06-13
  */
@@ -44,6 +47,9 @@ public class QwExternalContactController extends BaseController
     private IQwExternalContactService qwExternalContactService;
     private IQwTagService iQwTagService;
 
+    @Autowired
+    private IQwExternalContactInfoService qwExternalContactInfoService;
+
     QwExternalContactController(IQwExternalContactService qwExternalContactService,IQwTagService iQwTagService){
         this.qwExternalContactService=qwExternalContactService;
         this.iQwTagService=iQwTagService;
@@ -168,4 +174,11 @@ public class QwExternalContactController extends BaseController
     {
         return toAjax(qwExternalContactService.deleteQwExternalContactByIds(ids));
     }
+
+    @GetMapping(value = "getUserInfo/{id}")
+    public R getUserInfo(@PathVariable("id") Long id)
+    {
+        return R.ok().put("data",qwExternalContactInfoService.selectQwExternalContactInfoByExternalContactId(id));
+    }
+
 }

+ 3 - 0
fs-admin/src/main/java/com/fs/qw/controller/QwSopTempController.java

@@ -219,6 +219,9 @@ public class QwSopTempController extends BaseController
     @PreAuthorize("@ss.hasPermi('qw:sopTemp:edit')")
     @PostMapping("/sortDay")
     public AjaxResult sortDay(@RequestBody List<SortDayVo> list){
+        if (list == null || list.isEmpty()){
+            return AjaxResult.error("请先调整排序");
+        }
         qwSopTempService.sortDay(list);
         return toAjax(1);
     }

+ 3 - 3
fs-admin/src/main/resources/logback.xml

@@ -20,7 +20,7 @@
             <!-- 日志文件名格式 -->
 			<fileNamePattern>${log.path}/sys-info.%d{yyyy-MM-dd}.log</fileNamePattern>
 			<!-- 日志最大的历史 30 -->
-			<maxHistory>30</maxHistory>
+			<maxHistory>7</maxHistory>
 		</rollingPolicy>
 		<encoder>
 			<pattern>${log.pattern}</pattern>
@@ -42,7 +42,7 @@
             <!-- 日志文件名格式 -->
             <fileNamePattern>${log.path}/sys-error.%d{yyyy-MM-dd}.log</fileNamePattern>
 			<!-- 日志最大的历史 30 -->
-			<maxHistory>30</maxHistory>
+			<maxHistory>7</maxHistory>
         </rollingPolicy>
         <encoder>
             <pattern>${log.pattern}</pattern>
@@ -64,7 +64,7 @@
             <!-- 按天回滚 daily -->
             <fileNamePattern>${log.path}/sys-user.%d{yyyy-MM-dd}.log</fileNamePattern>
             <!-- 日志最大的历史 30 -->
-            <maxHistory>30</maxHistory>
+            <maxHistory>7</maxHistory>
         </rollingPolicy>
         <encoder>
             <pattern>${log.pattern}</pattern>

+ 3 - 3
fs-common-api/src/main/resources/logback.xml

@@ -20,7 +20,7 @@
             <!-- 日志文件名格式 -->
 			<fileNamePattern>${log.path}/sys-info.%d{yyyy-MM-dd}.log</fileNamePattern>
 			<!-- 日志最大的历史 30 -->
-			<maxHistory>30</maxHistory>
+			<maxHistory>7</maxHistory>
 		</rollingPolicy>
 		<encoder>
 			<pattern>${log.pattern}</pattern>
@@ -42,7 +42,7 @@
             <!-- 日志文件名格式 -->
             <fileNamePattern>${log.path}/sys-error.%d{yyyy-MM-dd}.log</fileNamePattern>
 			<!-- 日志最大的历史 30 -->
-			<maxHistory>30</maxHistory>
+			<maxHistory>7</maxHistory>
         </rollingPolicy>
         <encoder>
             <pattern>${log.pattern}</pattern>
@@ -64,7 +64,7 @@
             <!-- 按天回滚 daily -->
             <fileNamePattern>${log.path}/sys-user.%d{yyyy-MM-dd}.log</fileNamePattern>
             <!-- 日志最大的历史 30 -->
-            <maxHistory>30</maxHistory>
+            <maxHistory>7</maxHistory>
         </rollingPolicy>
         <encoder>
             <pattern>${log.pattern}</pattern>

+ 3 - 1
fs-common/src/main/java/com/fs/common/constant/LiveKeysConstant.java

@@ -28,7 +28,7 @@ public class LiveKeysConstant {
     public static final String TOP_MSG = "topMsg"; //抽奖记录
 
     public static final String LIVE_FLAG_CACHE = "live:flag:%s"; //直播间直播/回放状态缓存
-    public static final Integer LIVE_FLAG_CACHE_EXPIRE = 300; //直播间状态缓存过期时间(秒)
+    public static final Integer LIVE_FLAG_CACHE_EXPIRE = 60; //直播间状态缓存过期时间(秒)
 
     public static final String LIVE_DATA_CACHE = "live:data:%s"; //直播间数据缓存
     public static final Integer LIVE_DATA_CACHE_EXPIRE = 300; //直播间数据缓存过期时间(秒)
@@ -36,5 +36,7 @@ public class LiveKeysConstant {
     public static final String PRODUCT_DETAIL_CACHE = "product:detail:%s"; //商品详情缓存
     public static final Integer PRODUCT_DETAIL_CACHE_EXPIRE = 300; //商品详情缓存过期时间(秒)
 
+    public static final String LIVE_TAG_MARK_CACHE = "live:tag:mark:%s"; //直播间打标签缓存,存储直播间ID、开始时间和视频时长
+
 
 }

+ 3 - 3
fs-company-app/src/main/resources/logback.xml

@@ -20,7 +20,7 @@
             <!-- 日志文件名格式 -->
 			<fileNamePattern>${log.path}/sys-info.%d{yyyy-MM-dd}.log</fileNamePattern>
 			<!-- 日志最大的历史 30 -->
-			<maxHistory>30</maxHistory>
+			<maxHistory>7</maxHistory>
 		</rollingPolicy>
 		<encoder>
 			<pattern>${log.pattern}</pattern>
@@ -42,7 +42,7 @@
             <!-- 日志文件名格式 -->
             <fileNamePattern>${log.path}/sys-error.%d{yyyy-MM-dd}.log</fileNamePattern>
 			<!-- 日志最大的历史 30 -->
-			<maxHistory>30</maxHistory>
+			<maxHistory>7</maxHistory>
         </rollingPolicy>
         <encoder>
             <pattern>${log.pattern}</pattern>
@@ -64,7 +64,7 @@
             <!-- 按天回滚 daily -->
             <fileNamePattern>${log.path}/sys-user.%d{yyyy-MM-dd}.log</fileNamePattern>
             <!-- 日志最大的历史 30 -->
-            <maxHistory>30</maxHistory>
+            <maxHistory>7</maxHistory>
         </rollingPolicy>
         <encoder>
             <pattern>${log.pattern}</pattern>

+ 4 - 0
fs-company/src/main/java/com/fs/company/controller/live/LiveAfterSalesController.java

@@ -59,6 +59,10 @@ public class LiveAfterSalesController extends BaseController
         startPage();
         CompanyUser user = SecurityUtils.getLoginUser().getUser();
         liveAfterSales.setCompanyId(user.getCompanyId());
+        // 将productName映射到productNameQuery用于查询
+        if (liveAfterSales.getProductName() != null && !liveAfterSales.getProductName().isEmpty()) {
+            liveAfterSales.setProductNameQuery(liveAfterSales.getProductName());
+        }
         List<LiveAfterSalesVo> list = liveAfterSalesService.selectLiveAfterSalesVoList(liveAfterSales);
         for (LiveAfterSalesVo liveAfterSalesVo : list) {
             liveAfterSalesVo.setUserPhone(ParseUtils.parsePhone(liveAfterSalesVo.getUserPhone()));

+ 1 - 1
fs-company/src/main/java/com/fs/company/controller/live/LiveDataController.java

@@ -102,7 +102,7 @@ public class LiveDataController extends BaseController
         CompanyUser user = tokenService.getLoginUser(request).getUser();
         List<LiveUserDetailExportVO> list = new ArrayList<>();
         if ("00".equals(user.getUserType())) {
-            liveDataService.getLiveUserDetailListBySql(liveId, user.getCompanyId(), null);
+            list = liveDataService.exportLiveUserDetail(liveId, user.getCompanyId(), null);
         } else {
             list = liveDataService.exportLiveUserDetail(liveId,user.getCompanyId(),user.getUserId());
         }

+ 106 - 0
fs-company/src/main/java/com/fs/company/controller/live/LiveWatchLogController.java

@@ -0,0 +1,106 @@
+package com.fs.company.controller.live;
+
+import java.util.List;
+
+import com.fs.common.core.domain.R;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.enums.BusinessType;
+import com.fs.live.domain.LiveWatchLog;
+import com.fs.live.service.ILiveWatchLogService;
+import com.fs.common.utils.poi.ExcelUtil;
+import com.fs.common.core.page.TableDataInfo;
+
+/**
+ * 直播看课记录Controller
+ * 
+ * @author fs
+ * @date 2025-12-12
+ */
+@RestController
+@RequestMapping("/live/liveWatchLog")
+public class LiveWatchLogController extends BaseController
+{
+    @Autowired
+    private ILiveWatchLogService liveWatchLogService;
+
+    /**
+     * 查询直播看课记录列表
+     */
+    @PreAuthorize("@ss.hasPermi('live:liveWatchLog:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(LiveWatchLog liveWatchLog)
+    {
+        startPage();
+        List<LiveWatchLog> list = liveWatchLogService.selectLiveWatchLogList(liveWatchLog);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出直播看课记录列表
+     */
+    @PreAuthorize("@ss.hasPermi('live:liveWatchLog:export')")
+    @Log(title = "直播看课记录", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(LiveWatchLog liveWatchLog)
+    {
+        List<LiveWatchLog> list = liveWatchLogService.selectLiveWatchLogList(liveWatchLog);
+        ExcelUtil<LiveWatchLog> util = new ExcelUtil<LiveWatchLog>(LiveWatchLog.class);
+        return util.exportExcel(list, "直播看课记录数据");
+    }
+
+    /**
+     * 获取直播看课记录详细信息
+     */
+    @PreAuthorize("@ss.hasPermi('live:liveWatchLog:query')")
+    @GetMapping(value = "/{logId}")
+    public AjaxResult getInfo(@PathVariable("logId") Long logId)
+    {
+        return AjaxResult.success(liveWatchLogService.selectLiveWatchLogByLogId(logId));
+    }
+
+    /**
+     * 新增直播看课记录
+     */
+    @PreAuthorize("@ss.hasPermi('live:liveWatchLog:add')")
+    @Log(title = "直播看课记录", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody LiveWatchLog liveWatchLog)
+    {
+        return toAjax(liveWatchLogService.insertLiveWatchLog(liveWatchLog));
+    }
+
+    /**
+     * 修改直播看课记录
+     */
+    @PreAuthorize("@ss.hasPermi('live:liveWatchLog:edit')")
+    @Log(title = "直播看课记录", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody LiveWatchLog liveWatchLog)
+    {
+        return toAjax(liveWatchLogService.updateLiveWatchLog(liveWatchLog));
+    }
+
+    /**
+     * 删除直播看课记录
+     */
+    @PreAuthorize("@ss.hasPermi('live:liveWatchLog:remove')")
+    @Log(title = "直播看课记录", businessType = BusinessType.DELETE)
+	@DeleteMapping("/{logIds}")
+    public AjaxResult remove(@PathVariable Long[] logIds)
+    {
+        return toAjax(liveWatchLogService.deleteLiveWatchLogByLogIds(logIds));
+    }
+
+}

+ 111 - 0
fs-company/src/main/java/com/fs/company/controller/qw/LuckyBagCollectRecordController.java

@@ -0,0 +1,111 @@
+package com.fs.company.controller.qw;
+
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.poi.ExcelUtil;
+import com.fs.course.domain.LuckyBagCollectRecord;
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.service.TokenService;
+import com.fs.qw.service.ILuckyBagCollectRecordService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 福袋发放及领取记录Controller
+ *
+ * @author fs
+ * @date 2025-11-24
+ */
+@RestController
+@RequestMapping("/qw/luckyBagCollectRecord")
+public class LuckyBagCollectRecordController extends BaseController
+{
+    @Autowired
+    private ILuckyBagCollectRecordService luckyBagCollectRecordService;
+    @Autowired
+    private TokenService tokenService;
+    /**
+     * 查询福袋发放及领取记录列表
+     */
+    @PreAuthorize("@ss.hasPermi('qw:luckyBagCollectRecord:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(LuckyBagCollectRecord luckyBagCollectRecord)
+    {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        luckyBagCollectRecord.setCompanyId(loginUser.getCompany().getCompanyId());
+        startPage();
+        List<LuckyBagCollectRecord> list = luckyBagCollectRecordService.selectLuckyBagCollectRecordList(luckyBagCollectRecord);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出福袋发放及领取记录列表
+     */
+    @PreAuthorize("@ss.hasPermi('qw:luckyBagCollectRecord:export')")
+    @Log(title = "福袋发放及领取记录", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(LuckyBagCollectRecord luckyBagCollectRecord)
+    {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        luckyBagCollectRecord.setCompanyId(loginUser.getCompany().getCompanyId());
+        List<LuckyBagCollectRecord> list = luckyBagCollectRecordService.selectLuckyBagCollectRecordList(luckyBagCollectRecord);
+        // 数据转换处理
+        list.forEach(item -> {
+            item.setRewardTypeName(item.getRewardTypeName());
+            item.setCollectTypeName(item.getCollectTypeName());
+        });
+
+        ExcelUtil<LuckyBagCollectRecord> util = new ExcelUtil<LuckyBagCollectRecord>(LuckyBagCollectRecord.class);
+        return util.exportExcel(list, "福袋发放及领取记录数据");
+    }
+
+    /**
+     * 获取福袋发放及领取记录详细信息
+     */
+    @PreAuthorize("@ss.hasPermi('qw:luckyBagCollectRecord:query')")
+    @GetMapping(value = "/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Long id)
+    {
+        return AjaxResult.success(luckyBagCollectRecordService.selectLuckyBagCollectRecordById(id));
+    }
+
+    /**
+     * 新增福袋发放及领取记录
+     */
+    @PreAuthorize("@ss.hasPermi('qw:luckyBagCollectRecord:add')")
+    @Log(title = "福袋发放及领取记录", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody LuckyBagCollectRecord luckyBagCollectRecord)
+    {
+        return toAjax(luckyBagCollectRecordService.insertLuckyBagCollectRecord(luckyBagCollectRecord));
+    }
+
+    /**
+     * 修改福袋发放及领取记录
+     */
+    @PreAuthorize("@ss.hasPermi('qw:luckyBagCollectRecord:edit')")
+    @Log(title = "福袋发放及领取记录", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody LuckyBagCollectRecord luckyBagCollectRecord)
+    {
+        return toAjax(luckyBagCollectRecordService.updateLuckyBagCollectRecord(luckyBagCollectRecord));
+    }
+
+    /**
+     * 删除福袋发放及领取记录
+     */
+    @PreAuthorize("@ss.hasPermi('qw:luckyBagCollectRecord:remove')")
+    @Log(title = "福袋发放及领取记录", businessType = BusinessType.DELETE)
+	@DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids)
+    {
+        return toAjax(luckyBagCollectRecordService.deleteLuckyBagCollectRecordByIds(ids));
+    }
+}

+ 88 - 0
fs-company/src/main/java/com/fs/company/controller/qw/LuckyBagController.java

@@ -0,0 +1,88 @@
+package com.fs.company.controller.qw;
+
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.ServletUtils;
+import com.fs.course.domain.LuckyBag;
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.service.TokenService;
+import com.fs.qw.service.ILuckyBagService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 福袋管理
+ *
+ * @author fs
+ * @date 2024-12-02
+ */
+@RestController
+@RequestMapping("/qw/luckyBag")
+public class LuckyBagController extends BaseController
+{
+    @Autowired
+    private ILuckyBagService luckyBagService;
+    @Autowired
+    private TokenService tokenService;
+
+//    @PreAuthorize("@ss.hasPermi('course:reward:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(LuckyBag reward)
+    {
+        startPage();
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        reward.setCompanyId(String.valueOf(loginUser.getCompany().getCompanyId()));
+        List<LuckyBag> list = luckyBagService.selectLuckyBagList(reward);
+        return getDataTable(list);
+    }
+
+
+//    @PreAuthorize("@ss.hasPermi('luckybag:reward:add')")
+    @Log(title = "奖励配置", businessType = BusinessType.INSERT)
+    @PostMapping("/add")
+    public AjaxResult add(@RequestBody LuckyBag reward) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        reward.setCreateName(loginUser.getUser().getNickName());
+        reward.setCompanyId(String.valueOf(loginUser.getCompany().getCompanyId()));
+        reward.setUpdateBy(String.valueOf(loginUser.getUser().getUserId()));
+        reward.setCreateId(loginUser.getUser().getUserId());
+        reward.setCreateTime(new Date());
+        reward.setUpdateId(loginUser.getUser().getUserId());
+        reward.setUpdateTime(new Date());
+       return toAjax(luckyBagService.add(reward));
+    }
+
+
+//    @PreAuthorize("@ss.hasPermi('course:reward:query')")
+    @GetMapping(value = "/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Long id)
+    {
+        LuckyBag reward = new LuckyBag();
+        reward.setId(id);
+        return AjaxResult.success(luckyBagService.selectLuckyBagList(reward));
+    }
+
+//    @PreAuthorize("@ss.hasPermi('course:reward:edit')")
+    @Log(title = "修改福袋配置", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody LuckyBag reward) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        reward.setUpdateId(loginUser.getUser().getUserId());
+        reward.setUpdateTime(new Date());
+        return toAjax(luckyBagService.updateLuckyBag(reward));
+    }
+
+//    @PreAuthorize("@ss.hasPermi('course:reward:remove')")
+    @Log(title = "奖励配置", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids)
+    {
+        return toAjax(luckyBagService.deleteLuckyBagByIds(ids));
+    }
+}

+ 3 - 0
fs-company/src/main/java/com/fs/company/controller/qw/QwSopTempController.java

@@ -334,6 +334,9 @@ public class QwSopTempController extends BaseController
     @Log(title = "SOP模板天数调整", businessType = BusinessType.UPDATE)
     @PostMapping("/sortDay")
     public AjaxResult sortDay(@RequestBody List<SortDayVo> list){
+        if (list == null || list.isEmpty()){
+            return AjaxResult.error("请先调整排序");
+        }
         qwSopTempService.sortDay(list);
         return toAjax(1);
     }

+ 3 - 3
fs-company/src/main/resources/logback.xml

@@ -20,7 +20,7 @@
             <!-- 日志文件名格式 -->
 			<fileNamePattern>${log.path}/sys-info.%d{yyyy-MM-dd}.log</fileNamePattern>
 			<!-- 日志最大的历史 30 -->
-			<maxHistory>30</maxHistory>
+			<maxHistory>7</maxHistory>
 		</rollingPolicy>
 		<encoder>
 			<pattern>${log.pattern}</pattern>
@@ -42,7 +42,7 @@
             <!-- 日志文件名格式 -->
             <fileNamePattern>${log.path}/sys-error.%d{yyyy-MM-dd}.log</fileNamePattern>
 			<!-- 日志最大的历史 30 -->
-			<maxHistory>30</maxHistory>
+			<maxHistory>7</maxHistory>
         </rollingPolicy>
         <encoder>
             <pattern>${log.pattern}</pattern>
@@ -64,7 +64,7 @@
             <!-- 按天回滚 daily -->
             <fileNamePattern>${log.path}/sys-user.%d{yyyy-MM-dd}.log</fileNamePattern>
             <!-- 日志最大的历史 30 -->
-            <maxHistory>30</maxHistory>
+            <maxHistory>7</maxHistory>
         </rollingPolicy>
         <encoder>
             <pattern>${log.pattern}</pattern>

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

@@ -12,7 +12,9 @@ import com.fs.company.domain.CompanyMiniapp;
 import com.fs.company.service.ICompanyMiniappService;
 import com.fs.course.domain.FsCoursePlaySourceConfig;
 import com.fs.course.domain.FsCourseWatchLog;
+import com.fs.course.domain.FsUserCourseVideo;
 import com.fs.course.mapper.FsCoursePlaySourceConfigMapper;
+import com.fs.course.mapper.FsUserCourseVideoMapper;
 import com.fs.course.service.IFsCoursePlaySourceConfigService;
 import com.fs.course.service.IFsCourseWatchLogService;
 import com.fs.his.domain.FsUser;
@@ -60,6 +62,9 @@ public class IpadSendServer {
     private final ICompanyMiniappService companyMiniappService;
     private final IFsCoursePlaySourceConfigService playSourceConfigService;
     private final FsUserMapper fsUserMapper;
+    private final FsUserCourseVideoMapper fsUserCourseVideoMapper;
+
+
     private static final List<String> PROJECT_NAMES = Arrays.asList("济南联志健康", "北京存在文化","宽益堂");
     private void sendMiniProgram(BaseVo vo, QwSopCourseFinishTempSetting.Setting content, Map<String, FsCoursePlaySourceConfig> miniMap, Long companyId) {
         // 发送参数原本的appid
@@ -367,6 +372,18 @@ public class IpadSendServer {
             return false;
         }
 
+        // 查询视频是否下架
+        if(setting.getVideoId()!= null){
+            FsUserCourseVideo video = fsUserCourseVideoMapper.selectFsUserCourseVideoByVideoId( setting.getVideoId().longValue());
+            if(video != null){
+                if(video.getIsOnPut()!=null && video.getIsOnPut() == 1){
+                    log.warn("SOP_LOG_ID:{}, 视频已下架,不发送", qwSopLogs.getId());
+                    qwSopLogsService.updateQwSopLogsByWatchLogType(qwSopLogs.getId(), "视频已下架,不发送");
+                    return false;
+                }
+            }
+        }
+
         if (qwSopLogs.getSendType() != 6 && noSop) {
             // 客户的信息
 //            QwExternalContactHParam contactHParam = new QwExternalContactHParam();

+ 18 - 1
fs-ipad-task/src/main/java/com/fs/app/task/SendMsg.java

@@ -2,6 +2,7 @@ package com.fs.app.task;
 
 
 import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.fs.app.service.IpadSendServer;
 import com.fs.common.core.redis.RedisCacheT;
@@ -272,7 +273,23 @@ public class SendMsg {
                 new Thread(() -> {
                     try {
                         List<QwSopTempSetting.Content.Setting> settings = JSON.parseArray(JSON.toJSONString(setting.getSetting()), QwSopTempSetting.Content.Setting.class).stream().filter(e -> "9".equals(e.getContentType())).collect(Collectors.toList());
-                        asyncSopTestService.asyncSendMsgBySopAppLinkNormalIM(settings, qwSopLogs.getCorpId(), user.getCompanyUserId(), qwSopLogs.getFsUserId());
+                        if (!settings.isEmpty()) {
+                            asyncSopTestService.asyncSendMsgBySopAppLinkNormalIM(settings, qwSopLogs.getCorpId(), user.getCompanyUserId(), qwSopLogs.getFsUserId());
+                        }
+
+                        //app文本消息
+                        log.info("开始发送app文本消息消息开始,消息{},用户{}", JSONObject.toJSONString(settings), user.getQwUserName());
+                        settings = JSON.parseArray(JSON.toJSONString(setting.getSetting()), QwSopTempSetting.Content.Setting.class).stream().filter(e -> "15".equals(e.getContentType())).collect(Collectors.toList());
+
+                        if (!settings.isEmpty()) {
+                            asyncSopTestService.asyncSendMsgBySopAppTxtNormalIM(settings, qwSopLogs.getCorpId(), qwUser.getCompanyUserId(), qwSopLogs.getFsUserId());
+                        }
+                        //app语音消息
+                        log.info("开始发送app语音消息消息开始,消息{},用户{}", JSONObject.toJSONString(settings), user.getQwUserName());
+                        settings = JSON.parseArray(JSON.toJSONString(setting.getSetting()), QwSopTempSetting.Content.Setting.class).stream().filter(e -> "16".equals(e.getContentType())).collect(Collectors.toList());
+                        if (!settings.isEmpty()) {
+                            asyncSopTestService.asyncSendMsgBySopAppMP3NormalIM(settings, qwSopLogs.getCorpId(), qwUser.getCompanyUserId(), qwSopLogs.getFsUserId());
+                        }
                     } catch (Exception e) {
                         log.error("推送APP失败", e);
                     }

+ 1 - 1
fs-live-app/src/main/java/com/fs/live/controller/LiveController.java

@@ -126,7 +126,7 @@ public class LiveController {
 
 		LinkedHashMap<String,Object> result = (LinkedHashMap<String,Object>) params.get("WorkflowExecution");
 		String string = result.get("Object").toString();
-		videoService.updateFinishStatus(videoUrl + string.replace(".mp4", "-1080.m3u8"));
+		videoService.updateFinishStatus(string.replace(".mp4", ".m3u8"));
 
 		return R.ok();
 //		{app=200149.push.tlivecloud.com, appid=1319721001, appname=live, channel_id=673,

+ 159 - 0
fs-live-app/src/main/java/com/fs/live/task/LiveCompletionPointsTask.java

@@ -0,0 +1,159 @@
+package com.fs.live.task;
+
+import com.alibaba.fastjson.JSONObject;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.live.domain.Live;
+import com.fs.live.domain.LiveCompletionPointsRecord;
+import com.fs.live.service.ILiveCompletionPointsRecordService;
+import com.fs.live.service.ILiveService;
+import com.fs.live.websocket.bean.SendMsgVo;
+import com.fs.live.websocket.service.WebSocketServer;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 直播完课积分定时任务
+ */
+@Slf4j
+@Component
+public class LiveCompletionPointsTask {
+
+    @Autowired
+    private RedisCache redisCache;
+
+    @Autowired
+    private ILiveCompletionPointsRecordService completionPointsRecordService;
+
+    @Autowired
+    private WebSocketServer webSocketServer;
+
+    @Autowired
+    private ILiveService liveService;
+
+    /**
+     * 定时检查观看时长并创建完课记录
+     * 每分钟执行一次
+     * 优化:使用Hash结构 + 防重复推送
+     */
+    @Scheduled(cron = "0 */1 * * * ?")
+    public void checkCompletionStatus() {
+        try {
+            List<Live> activeLives = liveService.selectNoEndLiveList();
+            
+            if (activeLives == null || activeLives.isEmpty()) {
+                return;
+            }
+
+            for (Live live : activeLives) {
+                try {
+                    Long liveId = live.getLiveId();
+                    
+                    // 使用Hash结构获取该直播间所有用户的观看时长
+                    String hashKey = "live:watch:duration:hash:" + liveId;
+                    Map<Object, Object> userDurations = redisCache.redisTemplate.opsForHash().entries(hashKey);
+                    
+                    if (userDurations == null || userDurations.isEmpty()) {
+                        continue;
+                    }
+                    
+                    // 3. 逐个用户处理
+                    for (Map.Entry<Object, Object> entry : userDurations.entrySet()) {
+                        try {
+                            Long userId = Long.parseLong(entry.getKey().toString());
+                            
+                            // 4. 检查并创建完课记录(传null,自动累计直播+回放时长)
+                            completionPointsRecordService.checkAndCreateCompletionRecord(liveId, userId, null);
+
+                            // 5. 检查是否有新的完课记录待领取,推送弹窗消息(防重复)
+                            sendCompletionNotificationOnce(liveId, userId);
+
+                        } catch (Exception e) {
+                            log.error("处理用户完课状态失败, liveId={}, userId={}", liveId, entry.getKey(), e);
+                        }
+                    }
+                    
+                } catch (Exception e) {
+                    log.error("处理直播间完课状态失败, liveId={}", live.getLiveId(), e);
+                }
+            }
+
+        } catch (Exception e) {
+            log.error("检查完课状态定时任务执行失败", e);
+        }
+    }
+
+    /**
+     * 发送完课通知(通过WebSocket推送弹窗) - 防重复版本
+     */
+    private void sendCompletionNotificationOnce(Long liveId, Long userId) {
+        try {
+            // 1. 检查 Redis 是否已推送过(防止每分钟都推送)
+            String notifyKey = "live:completion:notified:" + liveId + ":" + userId;
+            Boolean hasNotified = redisCache.hasKey(notifyKey);
+            
+            if (Boolean.TRUE.equals(hasNotified)) {
+                return;  // 已经推送过,不再重复推送
+            }
+            
+            // 2. 查询未领取的完课记录
+            List<LiveCompletionPointsRecord> unreceivedRecords = 
+                completionPointsRecordService.getUserUnreceivedRecords(liveId, userId);
+            
+            if (unreceivedRecords != null && !unreceivedRecords.isEmpty()) {
+                // 3. 构造弹窗消息
+                SendMsgVo sendMsgVo = new SendMsgVo();
+                sendMsgVo.setLiveId(liveId);
+                sendMsgVo.setUserId(userId);
+                sendMsgVo.setCmd("completionPoints");
+                sendMsgVo.setMsg("完成任务!");
+                sendMsgVo.setData(JSONObject.toJSONString(unreceivedRecords.get(0)));
+
+                // 4. 通过WebSocket发送给特定用户
+                webSocketServer.sendCompletionPointsMessage(liveId, userId, sendMsgVo);
+                
+                // 5. 记录已推送,24小时后过期(第二天可以再次推送)
+                redisCache.setCacheObject(notifyKey, "1", 24, TimeUnit.HOURS);
+                
+                log.info("发送完课积分弹窗通知成功, liveId={}, userId={}, points={}", 
+                        liveId, userId, unreceivedRecords.get(0).getPointsAwarded());
+            }
+        } catch (Exception e) {
+            log.error("发送完课通知失败, liveId={}, userId={}", liveId, userId, e);
+        }
+    }
+
+    /**
+     * 发送完课通知(通过WebSocket推送弹窗) - 旧版本(保留)
+     */
+    private void sendCompletionNotification(Long liveId, Long userId) {
+        try {
+            // 查询未领取的完课记录
+            List<LiveCompletionPointsRecord> unreceivedRecords = completionPointsRecordService.getUserUnreceivedRecords(liveId, userId);
+            
+            if (unreceivedRecords != null && !unreceivedRecords.isEmpty()) {
+                // 构造弹窗消息
+                SendMsgVo sendMsgVo = new SendMsgVo();
+                sendMsgVo.setLiveId(liveId);
+                sendMsgVo.setUserId(userId);
+                sendMsgVo.setCmd("completionPoints");
+                sendMsgVo.setMsg("完成任务!");
+                sendMsgVo.setData(JSONObject.toJSONString(unreceivedRecords.get(0)));
+
+                // 通过WebSocket发送给特定用户(调用已有的发送方法)
+                webSocketServer.sendCompletionPointsMessage(liveId, userId, sendMsgVo);
+                
+                log.info("发送完课积分弹窗通知成功, liveId={}, userId={}", liveId, userId);
+            }
+        } catch (Exception e) {
+            log.error("发送完课通知失败, liveId={}, userId={}", liveId, userId, e);
+        }
+    }
+}

+ 183 - 0
fs-live-app/src/main/java/com/fs/live/task/Task.java

@@ -71,6 +71,8 @@ public class Task {
     private ILiveRedConfService liveRedConfService;
     @Autowired
     private ILiveCouponIssueService liveCouponIssueService;
+    @Autowired
+    private ILiveVideoService liveVideoService;
 
     @Autowired
     public FsJstAftersalePushService fsJstAftersalePushService;
@@ -163,6 +165,34 @@ public class Task {
                         redisCache.redisTemplate.expire(key+live.getLiveId(), 1, TimeUnit.DAYS);
                     });
                 }
+                
+                // 将开启的直播间信息写入Redis缓存,用于打标签定时任务
+                try {
+                    // 获取视频时长
+                    Long videoDuration = 0L;
+                    List<LiveVideo> videos = liveVideoService.listByLiveId(live.getLiveId(), 1);
+                    if (CollUtil.isNotEmpty(videos)) {
+                        videoDuration = videos.stream()
+                                .filter(v -> v.getDuration() != null)
+                                .mapToLong(LiveVideo::getDuration)
+                                .sum();
+                    }
+                    
+                    // 如果视频时长大于0,将直播间信息存入Redis
+                    if (videoDuration > 0 && live.getStartTime() != null) {
+                        Map<String, Object> tagMarkInfo = new HashMap<>();
+                        tagMarkInfo.put("liveId", live.getLiveId());
+                        tagMarkInfo.put("startTime", live.getStartTime().atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli());
+                        tagMarkInfo.put("videoDuration", videoDuration);
+                        
+                        String tagMarkKey = String.format(LiveKeysConstant.LIVE_TAG_MARK_CACHE, live.getLiveId());
+                        redisCache.setCacheObject(tagMarkKey, JSON.toJSONString(tagMarkInfo), 24, TimeUnit.HOURS);
+                        log.info("直播间开启,已加入打标签缓存: liveId={}, startTime={}, videoDuration={}", 
+                                live.getLiveId(), live.getStartTime(), videoDuration);
+                    }
+                } catch (Exception e) {
+                    log.error("写入直播间打标签缓存失败: liveId={}, error={}", live.getLiveId(), e.getMessage(), e);
+                }
             }
             // 重新更新所有在直播的缓存
             liveService.asyncToCache();
@@ -183,6 +213,15 @@ public class Task {
                     });
                 }
                 webSocketServer.removeLikeCountCache(live.getLiveId());
+                
+                // 删除打标签缓存
+                try {
+                    String tagMarkKey = String.format(LiveKeysConstant.LIVE_TAG_MARK_CACHE, live.getLiveId());
+                    redisCache.deleteObject(tagMarkKey);
+                    log.info("直播间结束,已删除打标签缓存: liveId={}", live.getLiveId());
+                } catch (Exception e) {
+                    log.error("删除直播间打标签缓存失败: liveId={}, error={}", live.getLiveId(), e.getMessage(), e);
+                }
             }
             // 重新更新所有在直播的缓存
             liveService.asyncToCache();
@@ -579,4 +618,148 @@ public class Task {
     public void updateRedQuantityNum() {
         liveRedConfService.updateRedQuantityNum();
     }
+
+    /**
+     * 定时扫描开启的直播间,检查是否到了打标签的时间
+     * 每10秒执行一次
+     */
+    @Scheduled(cron = "0/10 * * * * ?")
+    @DistributeLock(key = "scanLiveTagMark", scene = "task")
+    public void scanLiveTagMark() {
+        try {
+            // 获取所有打标签缓存的key
+            String pattern = String.format(LiveKeysConstant.LIVE_TAG_MARK_CACHE, "*");
+            Set<String> keys = redisCache.redisTemplate.keys(pattern);
+            
+            if (keys == null || keys.isEmpty()) {
+                return;
+            }
+            
+            long currentTimeMillis = System.currentTimeMillis();
+            List<Long> processedLiveIds = new ArrayList<>();
+            
+            for (String key : keys) {
+                try {
+                    // 从Redis获取直播间信息
+                    Object cacheValue = redisCache.getCacheObject(key);
+                    if (cacheValue == null) {
+                        continue;
+                    }
+                    
+                    String jsonStr = cacheValue.toString();
+                    JSONObject tagMarkInfo = JSON.parseObject(jsonStr);
+                    Long liveId = tagMarkInfo.getLong("liveId");
+                    Long startTimeMillis = tagMarkInfo.getLong("startTime");
+                    Long videoDuration = tagMarkInfo.getLong("videoDuration");
+                    
+                    if (liveId == null || startTimeMillis == null || videoDuration == null || videoDuration <= 0) {
+                        log.warn("直播间打标签缓存信息不完整: key={}, liveId={}, startTime={}, videoDuration={}", 
+                                key, liveId, startTimeMillis, videoDuration);
+                        continue;
+                    }
+                    
+                    // 计算结束时间:开始时间 + 视频时长(秒转毫秒)
+                    long endTimeMillis = startTimeMillis + (videoDuration * 1000);
+                    
+                    // 如果当前时间已经超过了结束时间,执行打标签操作
+                    if (currentTimeMillis >= endTimeMillis) {
+                        log.info("直播间视频播放完成,开始打标签: liveId={}, startTime={}, videoDuration={}, endTime={}, currentTime={}", 
+                                liveId, startTimeMillis, videoDuration, endTimeMillis, currentTimeMillis);
+                        
+                        // 调用打标签方法
+                        liveWatchUserService.qwTagMarkByLiveWatchLog(liveId);
+                        
+                        // 标记为已处理,稍后删除缓存
+                        processedLiveIds.add(liveId);
+                    }
+                } catch (Exception e) {
+                    log.error("处理直播间打标签缓存异常: key={}, error={}", key, e.getMessage(), e);
+                }
+            }
+            
+            // 删除已处理的直播间缓存
+            for (Long liveId : processedLiveIds) {
+                try {
+                    String tagMarkKey = String.format(LiveKeysConstant.LIVE_TAG_MARK_CACHE, liveId);
+                    redisCache.deleteObject(tagMarkKey);
+                    log.info("已删除已处理的直播间打标签缓存: liveId={}", liveId);
+                } catch (Exception e) {
+                    log.error("删除直播间打标签缓存失败: liveId={}, error={}", liveId, e.getMessage(), e);
+                }
+            }
+        } catch (Exception e) {
+            log.error("扫描直播间打标签任务异常: error={}", e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 批量同步Redis中的观看时长到数据库
+     * 每2分钟执行一次,减少数据库压力
+     */
+    @Scheduled(cron = "0 0/2 * * * ?")
+    @DistributeLock(key = "batchSyncWatchDuration", scene = "task")
+    public void batchSyncWatchDuration() {
+        try {
+            log.info("开始批量同步观看时长到数据库");
+            
+            // 优化:从所有直播间的Hash中批量获取数据
+            List<Live> activeLives = liveService.selectNoEndLiveList();
+            
+            if (activeLives == null || activeLives.isEmpty()) {
+                log.debug("当前没有活跃的直播间");
+                return;
+            }
+            
+            int totalCount = 0;
+            int successCount = 0;
+            int failCount = 0;
+            
+            // 逐个直播间处理
+            for (Live live : activeLives) {
+                try {
+                    Long liveId = live.getLiveId();
+                    
+                    // 使用Hash结构存储每个直播间的观看时长
+                    String hashKey = "live:watch:duration:hash:" + liveId;
+                    Map<Object, Object> userDurations = redisCache.redisTemplate.opsForHash().entries(hashKey);
+                    
+                    if (userDurations == null || userDurations.isEmpty()) {
+                        continue;
+                    }
+                    
+                    // 获取直播/回放标记(一次查询,所有用户复用)
+                    Map<String, Integer> flagMap = liveWatchUserService.getLiveFlagWithCache(liveId);
+                    Integer liveFlag = flagMap.get("liveFlag");
+                    Integer replayFlag = flagMap.get("replayFlag");
+                    
+                    // 批量处理该直播间的所有用户
+                    for (Map.Entry<Object, Object> entry : userDurations.entrySet()) {
+                        try {
+                            Long userId = Long.parseLong(entry.getKey().toString());
+                            Long duration = Long.parseLong(entry.getValue().toString());
+                            
+                            totalCount++;
+                            
+                            // 异步更新数据库
+                            liveWatchUserService.updateWatchDuration(liveId, userId, liveFlag, replayFlag, duration);
+                            successCount++;
+                            
+                        } catch (Exception e) {
+                            failCount++;
+                            log.error("同步用户观看时长失败: liveId={}, userId={}, error={}", 
+                                    liveId, entry.getKey(), e.getMessage());
+                        }
+                    }
+                    
+                } catch (Exception e) {
+                    log.error("处理直播间观看时长失败: liveId={}, error={}", live.getLiveId(), e.getMessage());
+                }
+            }
+            
+            log.info("批量同步观看时长完成: 总数={}, 成功={}, 失败={}", totalCount, successCount, failCount);
+            
+        } catch (Exception e) {
+            log.error("批量同步观看时长任务异常", e);
+        }
+    }
 }

+ 322 - 22
fs-live-app/src/main/java/com/fs/live/websocket/service/WebSocketServer.java

@@ -11,6 +11,9 @@ import com.fs.hisStore.domain.FsUserScrm;
 import com.fs.hisStore.service.IFsUserScrmService;
 import com.fs.live.config.ProductionWordFilter;
 import com.fs.live.mapper.LiveCouponMapper;
+import com.fs.live.vo.LiveWatchUserEntry;
+import com.fs.live.domain.LiveWatchLog;
+import com.fs.live.domain.LiveVideo;
 import com.fs.live.websocket.auth.WebSocketConfigurator;
 import com.fs.live.websocket.bean.SendMsgVo;
 import com.fs.common.core.domain.R;
@@ -55,7 +58,7 @@ public class WebSocketServer {
     // 心跳超时缓存:key=sessionId,value=最后心跳时间戳
     private final static ConcurrentHashMap<String, Long> heartbeatCache = new ConcurrentHashMap<>();
     // 心跳超时时间(毫秒):3分钟无心跳则认为超时
-    private final static long HEARTBEAT_TIMEOUT = 3 * 60 * 1000;
+    private final static long HEARTBEAT_TIMEOUT = 2 * 60 * 1000;
     // admin房间消息发送线程池(单线程,保证串行化)
     private final static ConcurrentHashMap<Long, ExecutorService> adminExecutors = new ConcurrentHashMap<>();
 
@@ -72,7 +75,12 @@ public class WebSocketServer {
     private final ILiveUserFirstEntryService liveUserFirstEntryService =  SpringUtils.getBean(ILiveUserFirstEntryService.class);
     private final ILiveCouponIssueService liveCouponIssueService =  SpringUtils.getBean(ILiveCouponIssueService.class);
     private final LiveCouponMapper liveCouponMapper = SpringUtils.getBean(LiveCouponMapper.class);
+    private final ILiveWatchLogService liveWatchLogService = SpringUtils.getBean(ILiveWatchLogService.class);
+    private final ILiveVideoService liveVideoService = SpringUtils.getBean(ILiveVideoService.class);
     private static Random random = new Random();
+    
+    // Redis key 前缀:用户进入直播间时间
+    private static final String USER_ENTRY_TIME_KEY = "live:user:entry:time:%s:%s"; // liveId:userId
 
     // 直播间在线用户缓存
 //    private static final ConcurrentHashMap<Long, Integer> liveOnlineUsers = new ConcurrentHashMap<>();
@@ -114,6 +122,11 @@ public class WebSocketServer {
 
             LiveWatchUser liveWatchUserVO = liveWatchUserService.join(fsUser,liveId, userId, location);
             room.put(userId, session);
+            
+            // 存储用户进入直播间的时间到 Redis(用于计算在线时长)
+            String entryTimeKey = String.format(USER_ENTRY_TIME_KEY, liveId, userId);
+            redisCache.setCacheObject(entryTimeKey, System.currentTimeMillis(), 24, TimeUnit.HOURS);
+            
             // 直播间浏览量 +1
             redisCache.incr(PAGE_VIEWS_KEY + liveId, 1);
 
@@ -161,6 +174,18 @@ public class WebSocketServer {
             }
 
             LiveUserFirstEntry liveUserFirstEntry = liveUserFirstEntryService.selectEntityByLiveIdUserId(liveId, userId);
+            // 如果用户连上了 socket,并且公司ID和销售ID大于0,更新 LiveWatchLog 的 logType
+
+            if ((companyId > 0 && companyUserId > 0) || (liveUserFirstEntry != null && liveUserFirstEntry.getCompanyId() > 0 && liveUserFirstEntry.getCompanyUserId() > 0 )) {
+                // 获取当前直播/回放状态
+                Map<String, Integer> flagMap = liveWatchUserService.getLiveFlagWithCache(liveId);
+                Integer currentLiveFlag = flagMap.get("liveFlag");
+
+                // 如果当前是直播状态(liveFlag = 1),更新 logType
+                if (currentLiveFlag != null && currentLiveFlag == 1) {
+                    updateLiveWatchLogTypeOnConnect(liveId, userId, companyId, companyUserId);
+                }
+            }
             if (liveUserFirstEntry != null) {
                 // 处理第一次自己进入,第二次扫码销售进入
                 if (liveUserFirstEntry.getCompanyUserId() == -1L && companyUserId != -1L) {
@@ -205,6 +230,15 @@ public class WebSocketServer {
     @OnClose
     public void onClose(Session session) {
         Map<String, Object> userProperties = session.getUserProperties();
+        // 获取公司ID和销售ID
+        long companyId = -1L;
+        long companyUserId = -1L;
+        if (!Objects.isNull(userProperties.get("companyId"))) {
+            companyId = (long) userProperties.get("companyId");
+        }
+        if (!Objects.isNull(userProperties.get("companyUserId"))) {
+            companyUserId = (long) userProperties.get("companyUserId");
+        }
 
         long liveId = (long) userProperties.get("liveId");
         long userId = (long) userProperties.get("userId");
@@ -217,6 +251,8 @@ public class WebSocketServer {
             if (Objects.isNull(fsUser)) {
                 throw new BaseException("用户信息错误");
             }
+            // 计算并更新用户在线时长
+            updateUserOnlineDuration(liveId, userId, companyId, companyUserId);
             room.remove(userId);
             if (room.isEmpty()) {
                 rooms.remove(liveId);
@@ -228,6 +264,7 @@ public class WebSocketServer {
             // 从在线用户Set中移除用户ID
             String onlineUsersSetKey = ONLINE_USERS_SET_KEY + liveId;
             redisCache.redisTemplate.opsForSet().remove(onlineUsersSetKey, String.valueOf(userId));
+
             LiveWatchUser liveWatchUserVO = liveWatchUserService.close(fsUser,liveId, userId);
 
 
@@ -278,6 +315,33 @@ public class WebSocketServer {
                 case "heartbeat":
                     // 更新心跳时间
                     heartbeatCache.put(session.getId(), System.currentTimeMillis());
+
+                    // 心跳时同步更新观看时长到Redis Hash
+                    long watchUserId = (long) userProperties.get("userId");
+                    
+                    if (msg.getData() != null && !msg.getData().isEmpty()) {
+                        try {
+                            Long currentDuration = Long.parseLong(msg.getData());
+                            
+                            // 使用Hash结构存储:一个直播间一个Hash,包含所有用户的时长
+                            String hashKey = "live:watch:duration:hash:" + liveId;
+                            String userIdField = String.valueOf(watchUserId);
+                            
+                            // 获取现有时长
+                            Object existingDuration = redisCache.redisTemplate.opsForHash().get(hashKey, userIdField);
+                            
+                            // 只有当新的时长更大时才更新(避免时间倒退)
+                            if (existingDuration == null || currentDuration > Long.parseLong(existingDuration.toString())) {
+                                // 更新Hash中的用户时长
+                                redisCache.redisTemplate.opsForHash().put(hashKey, userIdField, currentDuration.toString());
+                                // 设置过期时间(2小时)
+                                redisCache.redisTemplate.expire(hashKey, 2, TimeUnit.HOURS);
+                            }
+                        } catch (Exception e) {
+                            log.error("心跳更新观看时长失败, liveId={}, userId={}", liveId, watchUserId, e);
+                        }
+                    }
+                    
                     sendMessage(session, JSONObject.toJSONString(R.ok().put("data", msg)));
                     break;
                 case "sendMsg":
@@ -565,6 +629,23 @@ public class WebSocketServer {
         sendMsgVo.setData(String.valueOf(scoreAmount));
 
         if(Objects.isNull( session)) return;
+        // 使用带锁的sendMessage方法,保证线程安全
+        try {
+            sendMessage(session, JSONObject.toJSONString(R.ok().put("data", sendMsgVo)));
+        } catch (IOException e) {
+            log.error("发送积分消息失败: liveId={}, userId={}, error={}", liveId, userId, e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 发送完课积分弹窗通知给特定用户
+     */
+    public void sendCompletionPointsMessage(Long liveId, Long userId, SendMsgVo sendMsgVo) {
+        ConcurrentHashMap<Long, Session> room = getRoom(liveId);
+        Session session = room.get(userId);
+        if (session == null || !session.isOpen()) {
+            return;
+        }
         session.getAsyncRemote().sendText(JSONObject.toJSONString(R.ok().put("data", sendMsgVo)));
     }
 
@@ -585,40 +666,56 @@ public class WebSocketServer {
         sendMsgVo.setData(null);
 
         if(Objects.isNull( session)) return;
-        session.getAsyncRemote().sendText(JSONObject.toJSONString(R.ok().put("data", sendMsgVo)));
+        // 使用带锁的sendMessage方法,保证线程安全
+        try {
+            sendMessage(session, JSONObject.toJSONString(R.ok().put("data", sendMsgVo)));
+        } catch (IOException e) {
+            log.error("发送封禁消息失败: liveId={}, userId={}, error={}", liveId, userId, e.getMessage(), e);
+        }
     }
 
     /**
      * 广播消息
      * @param liveId   直播间ID
      * @param message  消息内容
+     * 优化:使用快照遍历,避免在遍历过程中修改集合
      */
     public void broadcastWebMessage(Long liveId, String message) {
         ConcurrentHashMap<Long, Session> room = getRoom(liveId);
+        
+        if (room.isEmpty()) {
+            return;
+        }
 
-        // 普通用户房间:并行发送
-        room.forEach((k, v) -> {
-            if (v.isOpen()) {
-                sendWithRetry(v,message,1);
+        // 普通用户房间:并行发送(使用快照遍历,避免并发修改)
+        // ConcurrentHashMap 的 entrySet() 是弱一致性的,但为了更安全,我们显式创建快照
+        for (Map.Entry<Long, Session> entry : room.entrySet()) {
+            Session session = entry.getValue();
+            if (session != null && session.isOpen()) {
+                sendWithRetry(session, message, 1);
             }
-        });
+        }
     }
 
     /**
      * 广播消息
      * @param liveId   直播间ID
      * @param message  消息内容
+     * 优化:使用快照遍历,避免在遍历过程中修改集合
      */
     public void broadcastMessage(Long liveId, String message) {
         ConcurrentHashMap<Long, Session> room = getRoom(liveId);
         List<Session> adminRoom = getAdminRoom(liveId);
 
-        // 普通用户房间:并行发送
-        room.forEach((k, v) -> {
-            if (v.isOpen()) {
-                sendWithRetry(v,message,1);
+        // 普通用户房间:并行发送(使用快照遍历,避免并发修改)
+        if (!room.isEmpty()) {
+            for (Map.Entry<Long, Session> entry : room.entrySet()) {
+                Session session = entry.getValue();
+                if (session != null && session.isOpen()) {
+                    sendWithRetry(session, message, 1);
+                }
             }
-        });
+        }
 
         // admin房间:串行发送,使用单线程执行器
         if (!adminRoom.isEmpty()) {
@@ -696,23 +793,39 @@ public class WebSocketServer {
         }
     }
 
+
     /**
      * 定时清理无效会话(每分钟执行一次)
      * 检查心跳超时的会话并关闭
+     * 优化:使用快照遍历,避免在遍历过程中修改集合
      */
     @Scheduled(fixedRate = 60000) // 每分钟执行一次
     public void cleanInactiveSessions() {
         long currentTime = System.currentTimeMillis();
         int cleanedCount = 0;
 
-        // 遍历所有直播间
+        // 遍历所有直播间(使用快照,避免在遍历过程中被修改影响)
         for (Map.Entry<Long, ConcurrentHashMap<Long, Session>> roomEntry : rooms.entrySet()) {
             Long liveId = roomEntry.getKey();
             ConcurrentHashMap<Long, Session> room = roomEntry.getValue();
+            
+            // 如果房间为空,跳过
+            if (room.isEmpty()) {
+                continue;
+            }
 
-            // 检查普通用户会话
+            // 检查普通用户会话(使用快照遍历,避免并发修改异常)
             List<Long> toRemove = new ArrayList<>();
-            room.forEach((userId, session) -> {
+            // 创建快照,避免在遍历过程中修改原集合
+            for (Map.Entry<Long, Session> userEntry : room.entrySet()) {
+                Long userId = userEntry.getKey();
+                Session session = userEntry.getValue();
+                
+                if (session == null) {
+                    toRemove.add(userId);
+                    continue;
+                }
+                
                 Long lastHeartbeat = heartbeatCache.get(session.getId());
                 if (lastHeartbeat != null && (currentTime - lastHeartbeat) > HEARTBEAT_TIMEOUT) {
                     toRemove.add(userId);
@@ -720,16 +833,35 @@ public class WebSocketServer {
                         if (session.isOpen()) {
                             session.close(new CloseReason(CloseReason.CloseCodes.NORMAL_CLOSURE, "心跳超时"));
                         }
+                        
+                        // 计算并更新用户在线时长(心跳超时断开连接)
+                        Map<String, Object> userProperties = session.getUserProperties();
+                        long companyId = -1L;
+                        long companyUserId = -1L;
+                        if (!Objects.isNull(userProperties.get("companyId"))) {
+                            companyId = (long) userProperties.get("companyId");
+                        }
+                        if (!Objects.isNull(userProperties.get("companyUserId"))) {
+                            companyUserId = (long) userProperties.get("companyUserId");
+                        }
+                        updateUserOnlineDuration(liveId, userId, companyId, companyUserId);
                     } catch (Exception e) {
                         log.error("关闭超时会话失败: sessionId={}, liveId={}, userId={}",
                                 session.getId(), liveId, userId, e);
                     }
                 }
-            });
+            }
 
             // 移除超时的会话
-            toRemove.forEach(room::remove);
-            cleanedCount += toRemove.size();
+            if (!toRemove.isEmpty()) {
+                String hashKey = String.format(LiveKeysConstant.LIVE_WATCH_USERS, liveId);
+                for (Long userId : toRemove) {
+                    room.remove(userId);
+                    // 从 Redis hash 中删除无效用户
+                    redisCache.hashDelete(hashKey, String.valueOf(userId));
+                }
+                cleanedCount += toRemove.size();
+            }
         }
 
         // 检查admin房间
@@ -770,14 +902,22 @@ public class WebSocketServer {
      * 广播点赞消息
      * @param liveId   直播间ID
      * @param message  消息内容
+     * 优化:使用快照遍历,避免在遍历过程中修改集合
      */
     public void broadcastLikeMessage(Long liveId, String message) {
         ConcurrentHashMap<Long, Session> room = getRoom(liveId);
-        room.forEach((k, v) -> {
-            if (v.isOpen()) {
-                sendWithRetry(v,message,1);
+        
+        if (room.isEmpty()) {
+            return;
+        }
+        
+        // 使用快照遍历,避免并发修改
+        for (Map.Entry<Long, Session> entry : room.entrySet()) {
+            Session session = entry.getValue();
+            if (session != null && session.isOpen()) {
+                sendWithRetry(session, message, 1);
             }
-        });
+        }
     }
 
     private void sendWithRetry(Session session, String message, int maxRetries) {
@@ -919,5 +1059,165 @@ public class WebSocketServer {
         redisCache.redisTemplate.opsForZSet().removeRangeByScore(key + liveId, data, data);
     }
 
+    /**
+     * 计算并更新用户在线时长
+     * @param liveId 直播间ID
+     * @param userId 用户ID
+     * @param companyId 公司ID
+     * @param companyUserId 销售ID
+     */
+    private void updateUserOnlineDuration(Long liveId, Long userId, Long companyId, Long companyUserId) {
+        try {
+            // 从 Redis 获取用户进入时间
+            String entryTimeKey = String.format(USER_ENTRY_TIME_KEY, liveId, userId);
+            Long entryTime = redisCache.getCacheObject(entryTimeKey);
+            
+            if (entryTime == null) {
+                // 如果没有进入时间记录,可能是旧数据,跳过
+                return;
+            }
+            
+            long currentTimeMillis = System.currentTimeMillis();
+            Date now = new Date();
+            
+            // 计算在线时长(秒)
+            long durationSeconds = (currentTimeMillis - entryTime) / 1000;
+            
+            if (durationSeconds <= 0) {
+                return;
+            }
+            
+            // 获取当前直播/回放状态
+            Map<String, Integer> flagMap = liveWatchUserService.getLiveFlagWithCache(liveId);
+            Integer currentLiveFlag = flagMap.get("liveFlag");
+            Integer currentReplayFlag = flagMap.get("replayFlag");
+            
+            // 查询用户记录
+            LiveWatchUserEntry liveWatchUser = liveWatchUserService.selectLiveWatchAndCompanyUserByFlag(
+                    liveId, userId, currentLiveFlag, currentReplayFlag);
+            
+            if (liveWatchUser != null) {
+                // 累加在线时长
+                Long onlineSeconds = liveWatchUser.getOnlineSeconds();
+                if (onlineSeconds == null) {
+                    onlineSeconds = 0L;
+                }
+                liveWatchUser.setOnlineSeconds(onlineSeconds + durationSeconds);
+                liveWatchUser.setUpdateTime(now);
+                
+                // 更新数据库
+                liveWatchUserService.updateLiveWatchUserEntry(liveWatchUser);
+                
+                // 如果 LiveWatchUserEntry 存在,并且当前是直播状态(liveFlag = 1),更新 LiveWatchLog
+                if (currentLiveFlag != null && currentLiveFlag == 1 
+                        && liveWatchUser.getCompanyId() != null && liveWatchUser.getCompanyId() > 0
+                        && liveWatchUser.getCompanyUserId() != null && liveWatchUser.getCompanyUserId() > 0) {
+                    updateLiveWatchLogTypeByDuration(liveId, userId, 
+                            liveWatchUser.getCompanyId(), liveWatchUser.getCompanyUserId(), 
+                            liveWatchUser.getOnlineSeconds());
+                }
+            }
+            
+            // 删除 Redis 中的进入时间记录
+            redisCache.deleteObject(entryTimeKey);
+        } catch (Exception e) {
+            log.error("更新用户在线时长异常:liveId={}, userId={}, error={}", 
+                    liveId, userId, e.getMessage(), e);
+        }
+    }
+    
+    /**
+     * 在连接时更新 LiveWatchLog 的 logType
+     * 如果 logType 类型不是 2,修改 logType 类型为 1(看课中)
+     */
+    private void updateLiveWatchLogTypeOnConnect(Long liveId, Long userId, Long companyId, Long companyUserId) {
+        try {
+            LiveWatchLog queryLog = new LiveWatchLog();
+            queryLog.setLiveId(liveId);
+            queryLog.setUserId(userId);
+            queryLog.setCompanyId(companyId);
+            queryLog.setCompanyUserId(companyUserId);
+            
+            List<LiveWatchLog> logs = liveWatchLogService.selectLiveWatchLogList(queryLog);
+            if (logs != null && !logs.isEmpty()) {
+                for (LiveWatchLog log : logs) {
+                    // 如果 logType 不是 2(完课),则更新为 1(看课中)
+                    if (log.getLogType() == null || log.getLogType() != 2) {
+                        log.setLogType(1);
+                        liveWatchLogService.updateLiveWatchLog(log);
+                    }
+                }
+            }
+        } catch (Exception e) {
+            log.error("更新 LiveWatchLog logType 异常(连接时):liveId={}, userId={}, error={}", 
+                    liveId, userId, e.getMessage(), e);
+        }
+    }
+    
+    /**
+     * 根据在线时长更新 LiveWatchLog 的 logType
+     * @param liveId 直播间ID
+     * @param userId 用户ID
+     * @param companyId 公司ID
+     * @param companyUserId 销售ID
+     * @param onlineSeconds 在线时长(秒)
+     */
+    private void updateLiveWatchLogTypeByDuration(Long liveId, Long userId, Long companyId, 
+                                                   Long companyUserId, Long onlineSeconds) {
+        try {
+            // 获取直播视频总时长(videoType = 1 的视频)
+            List<LiveVideo> videos = liveVideoService.listByLiveId(liveId, 1);
+            long totalVideoDuration = 0L;
+            if (videos != null && !videos.isEmpty()) {
+                totalVideoDuration = videos.stream()
+                        .filter(v -> v.getDuration() != null)
+                        .mapToLong(LiveVideo::getDuration)
+                        .sum();
+            }
+            
+            // 查询 LiveWatchLog
+            LiveWatchLog queryLog = new LiveWatchLog();
+            queryLog.setLiveId(liveId);
+            queryLog.setUserId(userId);
+            queryLog.setCompanyId(companyId);
+            queryLog.setCompanyUserId(companyUserId);
+            
+            List<LiveWatchLog> logs = liveWatchLogService.selectLiveWatchLogList(queryLog);
+            if (logs == null || logs.isEmpty()) {
+                return;
+            }
+            
+            for (LiveWatchLog log : logs) {
+                boolean needUpdate = false;
+                Integer newLogType = log.getLogType();
+                
+                // ① 如果在线时长 <= 3分钟,修改 logType 为 4(看课中断)
+                if (onlineSeconds <= 180) { // 3分钟 = 180秒
+                    newLogType = 4;
+                    needUpdate = true;
+                }
+                // ③ 如果直播视频 >= 40分钟,在线时长 >= 30分钟,logType 设置为 2(完课)
+                else if (totalVideoDuration >= 2400 && onlineSeconds >= 1800) { // 40分钟 = 2400秒,30分钟 = 1800秒
+                    newLogType = 2;
+                    needUpdate = true;
+                }
+                // 如果直播视频 >= 20分钟且 < 40分钟,在线时长 >= 20分钟,logType 设置为 2(完课)
+                else if (totalVideoDuration >= 1200 && totalVideoDuration < 2400 && onlineSeconds >= 1200) { // 20分钟 = 1200秒
+                    newLogType = 2;
+                    needUpdate = true;
+                }
+                
+                // 如果 logType 已经是 2(完课),不再更新
+                if (needUpdate && (log.getLogType() == null || log.getLogType() != 2)) {
+                    log.setLogType(newLogType);
+                    liveWatchLogService.updateLiveWatchLog(log);
+                }
+            }
+        } catch (Exception e) {
+            log.error("根据在线时长更新 LiveWatchLog logType 异常:liveId={}, userId={}, error={}", 
+                    liveId, userId, e.getMessage(), e);
+        }
+    }
+
 }
 

+ 3 - 3
fs-qw-api/src/main/resources/logback.xml

@@ -20,7 +20,7 @@
             <!-- 日志文件名格式 -->
 			<fileNamePattern>${log.path}/sys-info.%d{yyyy-MM-dd}.log</fileNamePattern>
 			<!-- 日志最大的历史 30 -->
-			<maxHistory>30</maxHistory>
+			<maxHistory>7</maxHistory>
 		</rollingPolicy>
 		<encoder>
 			<pattern>${log.pattern}</pattern>
@@ -42,7 +42,7 @@
             <!-- 日志文件名格式 -->
             <fileNamePattern>${log.path}/sys-error.%d{yyyy-MM-dd}.log</fileNamePattern>
 			<!-- 日志最大的历史 30 -->
-			<maxHistory>30</maxHistory>
+			<maxHistory>7</maxHistory>
         </rollingPolicy>
         <encoder>
             <pattern>${log.pattern}</pattern>
@@ -64,7 +64,7 @@
             <!-- 按天回滚 daily -->
             <fileNamePattern>${log.path}/sys-user.%d{yyyy-MM-dd}.log</fileNamePattern>
             <!-- 日志最大的历史 30 -->
-            <maxHistory>30</maxHistory>
+            <maxHistory>7</maxHistory>
         </rollingPolicy>
         <encoder>
             <pattern>${log.pattern}</pattern>

+ 13 - 1
fs-qw-task/src/main/java/com/fs/app/task/UserCourseWatchCountTask.java

@@ -6,18 +6,27 @@ import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
 
+import java.util.concurrent.atomic.AtomicBoolean;
+
 @Component
 @Slf4j
 public class UserCourseWatchCountTask {
     @Autowired
     private IFsUserCourseCountService userCourseCountService;
 
+    private final AtomicBoolean isRunning1 = new AtomicBoolean(false);
+
 
     /**
      * 每15分钟执行一次
      */
-    @Scheduled(cron = "0 */10 * * * ?")  // 每10分钟执行一次
+    @Scheduled(cron = "0 */20 * * * ?")  // 每10分钟执行一次
     public void userCourseCountTask() {
+        // 尝试设置标志为 true,表示任务开始执行
+        if (!isRunning1.compareAndSet(false, true)) {
+            log.warn("会员看课统计任务执行 - 上一个任务尚未完成,跳过此次执行");
+            return;
+        }
         try {
             log.info("==============会员看课统计任务执行===============开始");
             long startTime = System.currentTimeMillis();
@@ -29,6 +38,9 @@ public class UserCourseWatchCountTask {
             log.info("会员看课统计任务执行----------执行时长:{}", (endTime - startTime));
         } catch (Exception e) {
             log.error("会员看课统计任务执行----------定时任务执行失败", e);
+        } finally {
+            // 重置标志为 false,表示任务已完成
+            isRunning1.set(false);
         }
 
     }

+ 216 - 11
fs-qw-task/src/main/java/com/fs/app/taskService/impl/SopLogsTaskServiceImpl.java

@@ -20,6 +20,8 @@ import com.fs.course.domain.*;
 import com.fs.course.mapper.*;
 import com.fs.course.service.IFsCourseLinkService;
 import com.fs.course.service.IFsUserCompanyBindService;
+import com.fs.live.domain.LiveWatchLog;
+import com.fs.live.mapper.LiveWatchLogMapper;
 import com.fs.qw.domain.*;
 import com.fs.qw.mapper.QwExternalContactMapper;
 import com.fs.qw.mapper.QwUserMapper;
@@ -148,12 +150,14 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
     private final BlockingQueue<FsCourseWatchLog> watchLogsQueue = new LinkedBlockingQueue<>(20000);
     private final BlockingQueue<FsCourseLink> linkQueue = new LinkedBlockingQueue<>(20000);
     private final BlockingQueue<FsCourseSopAppLink> sopAppLinks = new LinkedBlockingQueue<>(20000);
+    private final BlockingQueue<LiveWatchLog> zmLiveWatchQueue = new LinkedBlockingQueue<>(20000);
 
     // Executors for consumer threads
     private ExecutorService qwSopLogsExecutor;
     private ExecutorService watchLogsExecutor;
     private ExecutorService courseLinkExecutor;
     private ExecutorService courseSopAppLinkExecutor;
+    private ExecutorService zmLiveWatchLogExecutor;
     @Autowired
     private IQwGroupChatService qwGroupChatService;
     @Autowired
@@ -184,6 +188,9 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
     @Autowired
     private IQwSopTempVoiceService sopTempVoiceService;
 
+    @Autowired
+    LiveWatchLogMapper liveWatchLogMapper;
+
     @PostConstruct
     public void init() {
         loadCourseConfig();
@@ -230,11 +237,17 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
             return t;
         });
 
+        zmLiveWatchLogExecutor = Executors.newSingleThreadExecutor(r -> {
+            Thread t = new Thread(r, "zmLiveWatchLogConsumer");
+            t.setDaemon(true);
+            return t;
+        });
 
         qwSopLogsExecutor.submit(this::consumeQwSopLogs);
         watchLogsExecutor.submit(this::consumeWatchLogs);
         courseLinkExecutor.submit(this::consumeCourseLink);
         courseSopAppLinkExecutor.submit(this::consumeCourseSopAppLink);
+        zmLiveWatchLogExecutor.submit(this::consumeZmLiveWatchQueue);
     }
 
     // Scheduled tasks to refresh configurations and domain names periodically
@@ -265,6 +278,7 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
         watchLogsExecutor.shutdown();
         courseLinkExecutor.shutdown();
         courseSopAppLinkExecutor.shutdown();
+        zmLiveWatchLogExecutor.shutdown();
         try {
             if (!qwSopLogsExecutor.awaitTermination(60, TimeUnit.SECONDS)) {
                 qwSopLogsExecutor.shutdownNow();
@@ -278,11 +292,15 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
             if (!courseSopAppLinkExecutor.awaitTermination(60, TimeUnit.SECONDS)) {
                 courseSopAppLinkExecutor.shutdownNow();
             }
+            if (!zmLiveWatchLogExecutor.awaitTermination(60, TimeUnit.SECONDS)) {
+                zmLiveWatchLogExecutor.shutdownNow();
+            }
         } catch (InterruptedException e) {
             qwSopLogsExecutor.shutdownNow();
             watchLogsExecutor.shutdownNow();
             courseLinkExecutor.shutdownNow();
             courseSopAppLinkExecutor.shutdownNow();
+            zmLiveWatchLogExecutor.shutdownNow();
             Thread.currentThread().interrupt();
         }
     }
@@ -873,7 +891,7 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                                       Integer grade, Integer sendMsgType ,List<Company> companies ) {
         switch (type) {
             case 1:
-                handleNormalMessage(sopLogs, content,companyUserId);
+                handleNormalMessage(sopLogs, content,companyUserId,companyId,isGroupChat,qwUserId,groupChat,externalId,logVo);
                 break;
             case 2:
                 handleCourseMessage(sopLogs, content, logVo, sendTime, courseId, videoId,
@@ -902,9 +920,73 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
         enqueueQwSopLogs(sopLogs);
     }
 
-    private void handleNormalMessage(QwSopLogs sopLogs, QwSopTempSetting.Content content,String companyUserId) {
+    private void handleNormalMessage(QwSopLogs sopLogs, QwSopTempSetting.Content content, String companyUserId, String companyId,
+                                     boolean isGroupChat,String qwUserId,QwGroupChat groupChat,String externalId,SopUserLogsVo logVo) {
 
-        sopLogs.setContentJson(JSON.toJSONString(content));
+        // 深拷贝 Content 对象,避免使用 JSON
+        QwSopTempSetting.Content clonedContent = deepCopyContent(content);
+        if (clonedContent == null) {
+            log.error("Failed to clone content, skipping handleCourseMessage.");
+            return;
+        }
+
+        List<QwSopTempSetting.Content.Setting> settings = clonedContent.getSetting();
+        if (settings == null || settings.isEmpty()) {
+            log.error("Cloned content settings are empty, skipping.");
+            return;
+        }
+        // 顺序处理每个 Setting,避免过多的并行导致线程开销
+        for (QwSopTempSetting.Content.Setting setting : settings) {
+            switch (setting.getContentType()) {
+                //直播小程序单独
+                case "12":
+                    String sortLiveLink;
+                    sortLiveLink = "/pages_course/living.html?companyId=" + companyId + "&companyUserId=" + companyUserId + "&liveId=" + setting.getLiveId() + "&corpId=" + logVo.getCorpId()+"&qwUserId=" + qwUserId;
+                    String json = configService.selectConfigByKey("his.config");
+                    FSSysConfig sysConfig = JSON.parseObject(json, FSSysConfig.class);
+                    if (isGroupChat) {
+                        try {
+                            groupChat.getChatUserList().stream().filter(e -> e.getUserList() != null && !e.getUserList().isEmpty()).forEach(e -> {
+                                Map<String, GroupUserExternalVo> userMap = PubFun.listToMapByGroupObject(e.getUserList(), GroupUserExternalVo::getUserId);
+                                GroupUserExternalVo vo = userMap.get(groupChat.getOwner());
+                                if (vo != null && vo.getId() != null) {
+                                    sopLogs.setFsUserId(vo.getFsUserId());
+                                    //写入直播待看课记录
+                                    createLiveWatchLogAndEnQueue(companyId, companyUserId, vo.getId().toString(), setting.getLiveId(), sysConfig.getAppId(), 2, qwUserId,logVo.getCorpId());
+                                }
+                            });
+                            sortLiveLink += "&chatId=" + groupChat.getChatId();
+                        } catch (Exception e) {
+                            log.error("直播小程序群聊新增报错,{}", e.getMessage(), e);
+                        }
+                    } else {
+                        try {
+                            createLiveWatchLogAndEnQueue(companyId, companyUserId, externalId, setting.getLiveId(), sysConfig.getAppId(), 1, qwUserId,logVo.getCorpId());
+                            sortLiveLink += "&externalId=" + externalId;
+                        } catch (Exception e) {
+                            log.error("直播小程序个人新增报错,{}", e.getMessage(), e);
+                        }
+                    }
+
+                    String miniprogramLiveTitle = setting.getMiniprogramTitle();
+                    int maxLiveLength = 17;
+                    setting.setMiniprogramTitle(miniprogramLiveTitle.length() > maxLiveLength ? miniprogramLiveTitle.substring(0, maxLiveLength) + "..." : miniprogramLiveTitle);
+                    setting.setMiniprogramAppid(sysConfig.getAppId());
+                    setting.setMiniprogramPage(sortLiveLink);
+                    setting.setContentType("4");
+                    try {
+                        setting.setMiniprogramPicUrl(StringUtil.strIsNullOrEmpty(setting.getMiniprogramPicUrl()) ? "https://cos.his.cdwjyyh.com/fs/20250331/ec2b4e73be8048afbd526124a655ad56.png" : setting.getMiniprogramPicUrl());
+                    } catch (Exception e) {
+                        log.error("赋值-小程序封面地址失败-" + e);
+                    }
+
+                    break;
+                default:
+                    break;
+            }
+        }
+        sopLogs.setContentJson(JSON.toJSONString(clonedContent));
+//        sopLogs.setContentJson(JSON.toJSONString(content));
         enqueueQwSopLogs(sopLogs);
     }
 
@@ -1022,14 +1104,14 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                                 GroupUserExternalVo vo = userMap.get(groupChat.getOwner());
                                 if (vo != null && vo.getId() != null) {
                                     sopLogs.setFsUserId(vo.getFsUserId());
-                                    addWatchLogIfNeeded(sopLogs, videoId, courseId, sendTime, qwUserId, companyUserId, companyId, vo.getId().toString(), logVo);
+                                    addWatchLogIfNeeded(sopLogs, videoId, courseId, sendTime, qwUserId, companyUserId, companyId, vo.getId().toString(), logVo,2);
                                 }
                             });
                         } catch (Exception e) {
                             log.error("群聊创建看课记录失败!", e);
                         }
                     } else {
-                        addWatchLogIfNeeded(sopLogs, videoId, courseId, sendTime, qwUserId, companyUserId, companyId, externalId,logVo);
+                        addWatchLogIfNeeded(sopLogs, videoId, courseId, sendTime, qwUserId, companyUserId, companyId, externalId,logVo,2);
                     }
 
                     String sortLink = createLinkByMiniApp(setting, logVo, sendTime, courseId, videoId,
@@ -1066,7 +1148,7 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                     break;
                 //app
                 case "9":
-                    addWatchLogIfNeeded(sopLogs, videoId, courseId, sendTime, qwUserId, companyUserId, companyId, externalId,logVo);
+                    addWatchLogIfNeeded(sopLogs, videoId, courseId, sendTime, qwUserId, companyUserId, companyId, externalId,logVo,1);
 
                     QwCreateLinkByAppVO linkByApp = createLinkByApp(setting, logVo, sendTime, courseId, videoId,
                             qwUserId, companyUserId, companyId, externalId,sopLogs.getCorpId(),qwUserName);
@@ -1079,7 +1161,7 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                     break;
                 //自定义小程序
                 case "10":
-                    addWatchLogIfNeeded(sopLogs, videoId, courseId, sendTime, qwUserId, companyUserId, companyId, externalId,logVo);
+                    addWatchLogIfNeeded(sopLogs, videoId, courseId, sendTime, qwUserId, companyUserId, companyId, externalId,logVo,2);
 
                     Optional<Company> matchedCompany = companies.stream()
                             .filter(company -> String.valueOf(company.getCompanyId()).equals(companyId))
@@ -1102,14 +1184,37 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                 //直播小程序单独
                 case "12":
                     String sortLiveLink;
-                    sortLiveLink = "/pages_course/living?companyId=" + companyId + "&companyUserId=" + companyUserId + "&liveId=" + setting.getLiveId();
+                    sortLiveLink = "/pages_course/living.html?companyId=" + companyId + "&companyUserId=" + companyUserId + "&liveId=" + setting.getLiveId()+"&corpId=" +logVo.getCorpId()+"&qwUserId=" + qwUserId;
+                    String json = configService.selectConfigByKey("his.config");
+                    FSSysConfig sysConfig= JSON.parseObject(json,FSSysConfig.class);
+                    if(isGroupChat){
+                        try{
+                            groupChat.getChatUserList().stream().filter(e -> e.getUserList() != null && !e.getUserList().isEmpty()).forEach(e -> {
+                                Map<String, GroupUserExternalVo> userMap = PubFun.listToMapByGroupObject(e.getUserList(), GroupUserExternalVo::getUserId);
+                                GroupUserExternalVo vo = userMap.get(groupChat.getOwner());
+                                if (vo != null && vo.getId() != null) {
+                                    sopLogs.setFsUserId(vo.getFsUserId());
+                                    //写入直播待看课记录
+                                    createLiveWatchLogAndEnQueue(companyId,companyUserId,vo.getId().toString(), setting.getLiveId(),sysConfig.getAppId(),2,qwUserId,logVo.getCorpId());
+                                }
+                            });
+                            sortLiveLink += "&chatId=" + groupChat.getChatId();
+                        }catch(Exception e){
+                            log.error("直播小程序群聊新增报错,{}", e.getMessage(),e);
+                        }
+                    }else{
+                        try{
+                            createLiveWatchLogAndEnQueue(companyId,companyUserId,externalId, setting.getLiveId(),sysConfig.getAppId(),2,qwUserId,logVo.getCorpId());
+                            sortLiveLink += "&externalId=" + externalId;
+                        }catch(Exception e){
+                            log.error("直播小程序个人新增报错,{}", e.getMessage(),e);
+                        }
+                    }
 
 
                     String miniprogramLiveTitle = setting.getMiniprogramTitle();
                     int maxLiveLength = 17;
                     setting.setMiniprogramTitle(miniprogramLiveTitle.length() > maxLiveLength ? miniprogramLiveTitle.substring(0, maxLiveLength) + "..." : miniprogramLiveTitle);
-                    String json = configService.selectConfigByKey("his.config");
-                    FSSysConfig sysConfig= JSON.parseObject(json,FSSysConfig.class);
                     setting.setMiniprogramAppid(sysConfig.getAppId());
                     setting.setMiniprogramPage(sortLiveLink);
                     setting.setContentType("4");
@@ -1480,7 +1585,7 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
 
     private void addWatchLogIfNeeded(QwSopLogs sopLogs, Long videoId, Long courseId,
                                      Date sendTime, String qwUserId, String companyUserId,
-                                     String companyId, String externalId,SopUserLogsVo logsVo) {
+                                     String companyId, String externalId,SopUserLogsVo logsVo,Integer watchType) {
         FsCourseWatchLog watchLog = new FsCourseWatchLog();
         watchLog.setVideoId(videoId != null ? videoId.longValue() : null);
         watchLog.setQwExternalContactId(externalId != null ? Long.valueOf(externalId) : null);
@@ -1495,10 +1600,51 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
         watchLog.setUpdateTime(new Date());
         watchLog.setLogType(3);
         watchLog.setUserId(sopLogs.getFsUserId());
+        watchLog.setWatchType(watchType);
         watchLog.setCampPeriodTime(convertStringToDate(logsVo.getStartTime(),"yyyy-MM-dd"));
         enqueueWatchLog(watchLog);
     }
 
+    /**
+     * 直播看课记录处理
+     * @param companyId
+     * @param companyUserId
+     * @param externalId
+     * @param liveId
+     * @param appId
+     * @param logSource
+     * @param qwUserId
+     * @param corpId
+     */
+    public void createLiveWatchLogAndEnQueue(String companyId,String companyUserId,String externalId,Long liveId,String appId,Integer logSource,String qwUserId,String corpId){
+        // 写入对应数据源的记录表
+        LiveWatchLog itemLiveWatchLog = new LiveWatchLog();
+        itemLiveWatchLog.setLiveId(liveId);
+        itemLiveWatchLog.setLogType(3);
+        itemLiveWatchLog.setSopCreateTime(new Date());
+        itemLiveWatchLog.setCompanyId(Long.valueOf(companyId));
+        itemLiveWatchLog.setCompanyUserId(Long.valueOf(companyUserId));
+        itemLiveWatchLog.setSendAppId(appId);
+        itemLiveWatchLog.setLogSource(logSource);
+        itemLiveWatchLog.setQwUserId(qwUserId);
+        itemLiveWatchLog.setExternalContactId(Long.valueOf(externalId));
+        itemLiveWatchLog.setCorpId(corpId);
+        enqueueZmLiveWatchLog(itemLiveWatchLog);
+    }
+
+    private void enqueueZmLiveWatchLog(LiveWatchLog liveWatchLog) {
+        try {
+            boolean offered = zmLiveWatchQueue.offer(liveWatchLog, 5, TimeUnit.SECONDS);
+            if (!offered) {
+                log.error("LiveWatchLog 队列已满,无法添加日志: {}", JSON.toJSONString(liveWatchLog));
+                // 处理队列已满的情况,例如记录到失败队列或持久化存储
+            }
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            log.error("插入 LiveWatchLog 队列时被中断: {}", e.getMessage(), e);
+        }
+    }
+
     /**
      * 时间字符串转Date时间
      * @param dateString
@@ -1675,6 +1821,35 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
         }
     }
 
+    /**
+     * 消费 FsCourseSopAppLink 队列并进行批量插入
+     */
+    private void consumeZmLiveWatchQueue() {
+        List<LiveWatchLog> batch = new ArrayList<>(BATCH_SIZE);
+        while (running || !zmLiveWatchQueue.isEmpty()) {
+            try {
+                LiveWatchLog livewatchLog = zmLiveWatchQueue.poll(1, TimeUnit.SECONDS);
+                if (livewatchLog != null) {
+                    batch.add(livewatchLog);
+                }
+                if (batch.size() >= BATCH_SIZE || (!batch.isEmpty() && livewatchLog == null)) {
+                    if (!batch.isEmpty()) {
+                        batchInsertLiveWatchLog(new ArrayList<>(batch));
+                        batch.clear();
+                    }
+                }
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                log.error("zmLiveWatchQueue 消费线程被中断: {}", e.getMessage(), e);
+            }
+        }
+
+        // 处理剩余的数据
+        if (!batch.isEmpty()) {
+            batchInsertLiveWatchLog(batch);
+        }
+    }
+
     /**
      * 消费 FsCourseWatchLog 队列并进行批量插入
      */
@@ -1782,6 +1957,36 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
         }
     }
 
+    /**
+     * 批量插入 卓美直播看课记录
+     */
+    @Transactional
+    @Retryable(
+            value = {Exception.class},
+            maxAttempts = 3,
+            backoff = @Backoff(delay = 2000)
+    )
+    public void batchInsertLiveWatchLog(List<LiveWatchLog> liveWatchLogToInsert) {
+        try {
+            List<LiveWatchLog> lastInsertList = new ArrayList<>();
+            //判断是否存在数据 liveId + his_qw_external_contact_id 唯一
+            for (LiveWatchLog liveWatchLog : liveWatchLogToInsert) {
+                //判断是否存在数据 存在的数据直接更新发送时间
+                if(liveWatchLogMapper.updateLiveWatchLogCondition(liveWatchLog) > 0){
+                    continue;
+                }
+                lastInsertList.add(liveWatchLog);
+            }
+            if(!lastInsertList.isEmpty()){
+                liveWatchLogMapper.insertLiveWatchLogBatch(lastInsertList);
+            }
+//            log.info("批量插入 LiveWatchLog 完成,共插入 {} 条记录。", liveWatchLogToInsert.size());
+        } catch (Exception e) {
+            log.error("批量插入 LiveWatchLog 失败: {}", e.getMessage(), e);
+            // 可选:将失败的数据记录到失败队列或持久化存储以便后续重试
+        }
+    }
+
 
     @Override
     public void updateSopLogsByCancel() {

+ 3 - 3
fs-qw-task/src/main/resources/logback.xml

@@ -20,7 +20,7 @@
             <!-- 日志文件名格式 -->
 			<fileNamePattern>${log.path}/sys-info.%d{yyyy-MM-dd}.log</fileNamePattern>
 			<!-- 日志最大的历史 30 -->
-			<maxHistory>30</maxHistory>
+			<maxHistory>7</maxHistory>
 		</rollingPolicy>
 		<encoder>
 			<pattern>${log.pattern}</pattern>
@@ -42,7 +42,7 @@
             <!-- 日志文件名格式 -->
             <fileNamePattern>${log.path}/sys-error.%d{yyyy-MM-dd}.log</fileNamePattern>
 			<!-- 日志最大的历史 30 -->
-			<maxHistory>30</maxHistory>
+			<maxHistory>7</maxHistory>
         </rollingPolicy>
         <encoder>
             <pattern>${log.pattern}</pattern>
@@ -64,7 +64,7 @@
             <!-- 按天回滚 daily -->
             <fileNamePattern>${log.path}/sys-user.%d{yyyy-MM-dd}.log</fileNamePattern>
             <!-- 日志最大的历史 30 -->
-            <maxHistory>30</maxHistory>
+            <maxHistory>7</maxHistory>
         </rollingPolicy>
         <encoder>
             <pattern>${log.pattern}</pattern>

+ 3 - 3
fs-qwhook-msg/src/main/resources/logback.xml

@@ -20,7 +20,7 @@
             <!-- 日志文件名格式 -->
 			<fileNamePattern>${log.path}/sys-info.%d{yyyy-MM-dd}.log</fileNamePattern>
 			<!-- 日志最大的历史 30 -->
-			<maxHistory>30</maxHistory>
+			<maxHistory>7</maxHistory>
 		</rollingPolicy>
 		<encoder>
 			<pattern>${log.pattern}</pattern>
@@ -42,7 +42,7 @@
             <!-- 日志文件名格式 -->
             <fileNamePattern>${log.path}/sys-error.%d{yyyy-MM-dd}.log</fileNamePattern>
 			<!-- 日志最大的历史 30 -->
-			<maxHistory>30</maxHistory>
+			<maxHistory>7</maxHistory>
         </rollingPolicy>
         <encoder>
             <pattern>${log.pattern}</pattern>
@@ -64,7 +64,7 @@
             <!-- 按天回滚 daily -->
             <fileNamePattern>${log.path}/sys-user.%d{yyyy-MM-dd}.log</fileNamePattern>
             <!-- 日志最大的历史 30 -->
-            <maxHistory>30</maxHistory>
+            <maxHistory>7</maxHistory>
         </rollingPolicy>
         <encoder>
             <pattern>${log.pattern}</pattern>

+ 3 - 3
fs-qwhook-sop/src/main/resources/logback.xml

@@ -20,7 +20,7 @@
             <!-- 日志文件名格式 -->
 			<fileNamePattern>${log.path}/sys-info.%d{yyyy-MM-dd}.log</fileNamePattern>
 			<!-- 日志最大的历史 30 -->
-			<maxHistory>30</maxHistory>
+			<maxHistory>7</maxHistory>
 		</rollingPolicy>
 		<encoder>
 			<pattern>${log.pattern}</pattern>
@@ -42,7 +42,7 @@
             <!-- 日志文件名格式 -->
             <fileNamePattern>${log.path}/sys-error.%d{yyyy-MM-dd}.log</fileNamePattern>
 			<!-- 日志最大的历史 30 -->
-			<maxHistory>30</maxHistory>
+			<maxHistory>7</maxHistory>
         </rollingPolicy>
         <encoder>
             <pattern>${log.pattern}</pattern>
@@ -64,7 +64,7 @@
             <!-- 按天回滚 daily -->
             <fileNamePattern>${log.path}/sys-user.%d{yyyy-MM-dd}.log</fileNamePattern>
             <!-- 日志最大的历史 30 -->
-            <maxHistory>30</maxHistory>
+            <maxHistory>7</maxHistory>
         </rollingPolicy>
         <encoder>
             <pattern>${log.pattern}</pattern>

+ 3 - 3
fs-qwhook/src/main/resources/logback.xml

@@ -20,7 +20,7 @@
             <!-- 日志文件名格式 -->
 			<fileNamePattern>${log.path}/sys-info.%d{yyyy-MM-dd}.log</fileNamePattern>
 			<!-- 日志最大的历史 30 -->
-			<maxHistory>30</maxHistory>
+			<maxHistory>7</maxHistory>
 		</rollingPolicy>
 		<encoder>
 			<pattern>${log.pattern}</pattern>
@@ -42,7 +42,7 @@
             <!-- 日志文件名格式 -->
             <fileNamePattern>${log.path}/sys-error.%d{yyyy-MM-dd}.log</fileNamePattern>
 			<!-- 日志最大的历史 30 -->
-			<maxHistory>30</maxHistory>
+			<maxHistory>7</maxHistory>
         </rollingPolicy>
         <encoder>
             <pattern>${log.pattern}</pattern>
@@ -64,7 +64,7 @@
             <!-- 按天回滚 daily -->
             <fileNamePattern>${log.path}/sys-user.%d{yyyy-MM-dd}.log</fileNamePattern>
             <!-- 日志最大的历史 30 -->
-            <maxHistory>30</maxHistory>
+            <maxHistory>7</maxHistory>
         </rollingPolicy>
         <encoder>
             <pattern>${log.pattern}</pattern>

+ 8 - 0
fs-service/pom.xml

@@ -291,6 +291,14 @@
 <!--            <version>1.1.26</version>-->
 <!--        </dependency>-->
 
+        <!--火山云sdk-->
+        <dependency>
+            <groupId>com.volcengine</groupId>
+            <artifactId>volc-sdk-java</artifactId>
+            <version>1.0.250</version>
+        </dependency>
+
+
     </dependencies>
 
 </project>

+ 5 - 0
fs-service/src/main/java/com/fs/company/mapper/CompanyMapper.java

@@ -245,4 +245,9 @@ public interface CompanyMapper
 
     @Select("select company_id from company where live_show=1")
     List<Long> selectLiveShowCompanyId();
+
+    @Select("select company_id,company_name from company where \n" +
+            " `status` != 0   " +
+            " and is_del != 1 ")
+    List<CompanyVO> getCompanyDropList();
 }

+ 4 - 0
fs-service/src/main/java/com/fs/config/cloud/CloudHostProper.java

@@ -15,4 +15,8 @@ public class CloudHostProper {
 
     @Value("${cloud_host.projectCode}")
     private String projectCode;
+
+    //火山云空间名称
+    @Value("${cloud_host.spaceName}")
+    public String spaceName;
 }

+ 30 - 0
fs-service/src/main/java/com/fs/core/config/VolcEngineConfiguration.java

@@ -0,0 +1,30 @@
+package com.fs.core.config;
+
+import com.volcengine.service.vod.IVodService;
+import com.volcengine.service.vod.impl.VodServiceImpl;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class VolcEngineConfiguration {
+    @Value("${hsy.access_key:''}")
+    private String access_key;
+
+    @Value("${hsy.secret_key:''}")
+    private String secret_key;
+    @Value("${hsy.region:''}")
+    private String region;
+    @Bean
+    public IVodService vodService() throws Exception {
+        // 根据区域获取火山云点播服务实例
+        IVodService vodService = VodServiceImpl.getInstance(region);
+
+        // 设置 AccessKey 和 SecretKey
+        vodService.setAccessKey(access_key);
+        vodService.setSecretKey(secret_key);
+
+        return vodService;
+    }
+}
+

+ 5 - 0
fs-service/src/main/java/com/fs/course/domain/FsCourseRealLink.java

@@ -48,4 +48,9 @@ public class FsCourseRealLink implements Serializable
 
     @ApiModelProperty(value = "项目唯一标识(PS:MYHK)")
     private String projectCode;
+
+    /**
+     * 业务id
+     */
+    private String businessId;
 }

+ 2 - 0
fs-service/src/main/java/com/fs/course/domain/FsCourseWatchLog.java

@@ -91,4 +91,6 @@ public class FsCourseWatchLog extends BaseEntity
     /** im发送消息详情id */
     private Long imMsgSendDetailId;
 
+    private Integer watchType;//看课方式:1 app  2 小程序
+
 }

+ 7 - 0
fs-service/src/main/java/com/fs/course/domain/FsUserCourseVideo.java

@@ -121,4 +121,11 @@ public class FsUserCourseVideo extends BaseEntity
     private Long listingEndTime;//商品结束售卖时间
 
     private Integer isSpeed; // 是否启用倍速 0:否 1:是
+
+    // 是否上架 0:上架,1:下架
+    private Integer isOnPut;
+
+    private String jobId;
+
+    private String vid;
 }

+ 69 - 0
fs-service/src/main/java/com/fs/course/domain/LuckyBag.java

@@ -0,0 +1,69 @@
+package com.fs.course.domain;
+
+import com.fs.common.annotation.Excel;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.math.BigDecimal;
+
+/**
+ * 奖励配置对象 fs_course_reward
+ *
+ * @author 杨衍生
+ * @date 2025-09-02
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class LuckyBag extends BaseEntity{
+
+    /** 主键ID */
+    private Long id;
+
+    /** 福袋名称 */
+    @Excel(name = "福袋名称")
+    private String name;
+
+    /** 奖励类型 (1:定值, 2:宝箱) */
+    @Excel(name = "奖励类型 (1:定值, 2:宝箱)")
+    private String type;
+
+    /**
+     * 是否删除 0-正常 1-删除
+     */
+    @Excel(name = "状态 (0:删除, 1:正常)")
+    private Long status;
+
+    /** 创建人ID */
+    @Excel(name = "创建人ID")
+    private Long createId;
+
+    /** 实际获得的奖励内容 */
+    @Excel(name = "实际获得的奖励内容")
+    private String actualRewards;
+
+    /** 实际获得的奖励内容 */
+    @Excel(name = "创建人")
+    private String createName;
+
+    @Excel(name = "所属公司")
+    private String companyId;
+
+    /** 创建人ID */
+    @Excel(name = "修改人ID")
+    private Long updateId;
+
+    /**
+     * 定额金额
+     */
+    private BigDecimal amount;
+
+    /** 状态 (0:禁用, 1:启用) */
+    private String dataStatus;
+
+    /**
+     * 奖励类型 1-定额 2-随机
+     */
+    private String rewardType;
+
+}

+ 151 - 0
fs-service/src/main/java/com/fs/course/domain/LuckyBagCollectRecord.java

@@ -0,0 +1,151 @@
+package com.fs.course.domain;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fs.common.annotation.Excel;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+/**
+ * 福袋发放及领取记录对象 lucky_bag_collect_record
+ *
+ * @author fs
+ * @date 2025-11-20
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class LuckyBagCollectRecord extends BaseEntity{
+
+
+
+    /** 领取用户ID */
+    @Excel(name = "领取用户ID")
+    private Long userId;
+
+    /** 客户名称 */
+    @Excel(name = "客户名称")
+    private String userName;
+
+    // 福袋名称
+    @Excel(name = "福袋名称")
+    private String luckyBagName;
+
+    /** 业务类型:1-群福袋 2-个人福袋 */
+//    @Excel(name = "业务类型:1-群福袋 2-个人福袋")
+    private Long rewardType;
+
+    @Excel(name = "业务类型")
+    private String rewardTypeName;
+
+    /** 福袋表主键ID */
+//    @Excel(name = "福袋表主键ID")
+    private Long luckyBagId;
+
+    /** 销售ID */
+//    @Excel(name = "销售ID")
+    private Long companyUserId;
+
+    /** 客服名称 */
+    @Excel(name = "客服名称")
+    private String companyUserName;
+
+    /** 公司ID */
+    @Excel(name = "公司ID")
+    private Long companyId;
+
+    /** 公司名称 */
+    @Excel(name = "公司名称")
+    private String companyName;
+
+    /** 芳华币数量 */
+    @Excel(name = "芳华币数量")
+    private BigDecimal coinAmount;
+
+    /** 发放时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @Excel(name = "发放时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
+    private Date sendTime;
+
+    /** 领取时间 精确到时分秒*/
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @Excel(name = "领取时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
+    private Date collectTime;
+
+    /** 失效时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd  HH:mm:ss")
+    @Excel(name = "失效时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
+    private Date expiryTime;
+
+    /** 领取状态(0-已发放 1-已领取 2-已失效) */
+//    @Excel(name = "领取状态(0-已发放 1-已领取 2-已失效,3-待发放)")
+    private String collectType;
+
+    @Excel(name = "领取状态")
+    private String collectTypeName;
+
+    /** 群聊名称 */
+    @Excel(name = "群聊名称")
+    private String externalUserName;
+
+    // 企微用户id
+    private String qwUserId;
+
+    /** 企微用户名称 */
+    @Excel(name = "企微员工名称")
+    private String qwUserName;
+
+
+    /** 群聊会话id */
+//    @Excel(name = "群聊会话id")
+    private String chatId;
+
+    /** 主键ID */
+    @Excel(name = "领取Id")
+    private Long id;
+
+    /** 关联id 关联群发记录id */
+//    @Excel(name = "关联id 关联群发记录id")
+    @Excel(name = "群领取Id")
+    private Long relationId;
+
+    /** 发送链接 */
+//    @Excel(name = "发送链接")
+    private String sendLink;
+
+    // 更新时间
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @Excel(name = "更新时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
+    private Date updateTime;
+
+    // 在 getter 中进行转换
+    public String getRewardTypeName() {
+        if (this.rewardType == null) return "";
+        switch (this.rewardType.intValue()) {
+            case 1: return "群福袋";
+            case 2: return "个人福袋";
+            default: return "";
+        }
+    }
+
+    public String getCollectTypeName() {
+        if (this.collectType == null) {
+            return "";
+        }
+        switch (this.collectType) {
+            case "0":
+                return "已发放";
+            case "1":
+                return "已领取";
+            case "2":
+                return "已失效";
+            default:
+                return this.collectType;
+        }
+    }
+
+
+
+}

+ 1 - 0
fs-service/src/main/java/com/fs/course/mapper/FsCourseAnswerLogsMapper.java

@@ -53,6 +53,7 @@ public interface FsCourseAnswerLogsMapper
             "\tLEFT JOIN company c on cal.company_id=c.company_id " +
             "        <where>  \n" +
             "            <if test=\"map.phone != null \"> and fu.phone = #{map.phone}</if>\n" +
+            "            <if test=\"map.logId != null \"> and cal.log_id = #{map.logId}</if>\n" +
             "            <if test=\"map.courseId != null \"> and uc.course_id = #{map.courseId}</if>\n" +
             "            <if test=\"map.videoId != null \"> and cal.video_id = #{map.videoId}</if>\n" +
             "            <if test=\"map.watchLogId != null \"> and cal.watch_log_id = #{map.watchLogId}</if>\n" +

+ 2 - 2
fs-service/src/main/java/com/fs/course/mapper/FsCourseFinishTempMapper.java

@@ -87,9 +87,9 @@ public interface FsCourseFinishTempMapper
             "<if test = ' maps.status !=null '> " +
             "and t.status = #{maps.status} " +
             "</if>" +
-            "            <if test=\"userIds != null and !userIds.isEmpty()\">\n" +
+            "            <if test=\" maps.userIds != null and !maps.userIds.isEmpty()\">\n" +
             "                AND create_by IN\n" +
-            "                <foreach collection='userIds' item='item' open='(' separator=',' close=')'>\n" +
+            "                <foreach collection=' maps.userIds' item='item' open='(' separator=',' close=')'>\n" +
             "                    #{item}\n" +
             "                </foreach>\n" +
             "            </if>" +

+ 1 - 0
fs-service/src/main/java/com/fs/course/mapper/FsCourseRedPacketLogMapper.java

@@ -116,6 +116,7 @@ public interface FsCourseRedPacketLogMapper
             "LEFT JOIN qw_user qu on qu.id= l.qw_user_id  \n" +
             "where 1=1   " +
             "<if test = ' maps.userId !=null '> and l.user_id = #{maps.userId} </if>" +
+            "<if test = ' maps.logId !=null '> and l.log_id = #{maps.logId} </if>" +
             "<if test = ' maps.watchLogId !=null '> and l.watch_log_id = #{maps.watchLogId} </if>" +
             "<if test = ' maps.companyId !=null '> and l.company_id = #{maps.companyId} </if>" +
             "<if test = ' maps.companyUserId !=null '> and l.company_user_id = #{maps.companyUserId} </if>" +

+ 6 - 0
fs-service/src/main/java/com/fs/course/mapper/FsUserCourseVideoMapper.java

@@ -265,4 +265,10 @@ public interface FsUserCourseVideoMapper extends BaseMapper<FsUserCourseVideo> {
     @Select("select video_id,is_first,course_sort,tg_id,watching_tg_id,watched_tg_id,watching_tag_id,watched_tag_id,tag_group_id from fs_user_course_video")
     @MapKey("videoId")
     Map<Long, FsUserCourseVideo> selectAllMap();
+
+    @Select("select * from fs_user_course_video where line_two is not null and job_id is null")
+    List<FsUserCourseVideo> selectVideoByHuaWei();
+
+    @Select("select * from fs_user_course_video where job_id is not null and vid is null")
+    List<FsUserCourseVideo> selectVideoByJobId();
 }

+ 2 - 0
fs-service/src/main/java/com/fs/course/param/FsCourseAnswerLogsParam.java

@@ -12,6 +12,8 @@ import java.util.Set;
 
 @Data
 public class FsCourseAnswerLogsParam  extends BaseEntity  {
+
+    private Long logId;
     private String phone;
     private String phoneMk;
     private String courseId;

+ 1 - 0
fs-service/src/main/java/com/fs/course/param/FsCourseRedPacketLogParam.java

@@ -11,6 +11,7 @@ import java.util.stream.Collectors;
 @Data
 public class FsCourseRedPacketLogParam implements Serializable {
 
+    private Long logId;
     private Long userId;
 
     private Long companyId;

+ 3 - 0
fs-service/src/main/java/com/fs/course/param/FsCourseWatchLogListParam.java

@@ -11,6 +11,8 @@ import java.util.stream.Collectors;
 @Data
 public class FsCourseWatchLogListParam implements Serializable {
 
+    private Long logId;
+
     private Long userId;
 
     private Long qwUserId;
@@ -112,6 +114,7 @@ public class FsCourseWatchLogListParam implements Serializable {
     private Integer pageNum;
     private Integer pageSize;
     private List<String> userIds;
+    private Integer watchType;
 
     public List<String> getUserIds() {
         if (userIds == null || userIds.isEmpty()) {

+ 22 - 0
fs-service/src/main/java/com/fs/course/param/LuckyBagActualRewardsParam.java

@@ -0,0 +1,22 @@
+package com.fs.course.param;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@NoArgsConstructor
+@Data
+public class LuckyBagActualRewardsParam {
+
+    @JsonProperty("type")
+    private Integer type;
+    @JsonProperty("name")
+    private String name;
+    @JsonProperty("amount")
+    private String amount;
+    @JsonProperty("probability")
+    private String probability;
+    @JsonProperty("code")
+    private String code;
+
+}

+ 12 - 0
fs-service/src/main/java/com/fs/course/service/IFsUserCourseVideoService.java

@@ -222,4 +222,16 @@ public interface IFsUserCourseVideoService extends IService<FsUserCourseVideo> {
     R sendAppReward(FsCourseSendRewardUParam param);
 
     R isSaveKf(FsUserCourseVideoAddKfUParam param);
+
+    /**
+     * 上传视频到火山云通过URL(把华为云的视频传到火山去并且存储JOBID)
+     * @return
+     */
+    R uploadVideoToHuoShanByUrl();
+
+    /**
+     * 通过jobId拿vid
+     * @return
+     */
+    R getVidByJob();
 }

+ 0 - 19
fs-service/src/main/java/com/fs/course/service/impl/FsCourseWatchLogServiceImpl.java

@@ -380,7 +380,6 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
         CourseConfig config = JSONUtil.toBean(json, CourseConfig.class);
 
         List<FsCourseWatchLog> logs = new ArrayList<>();
-        List<FsCourseWatchLog> finishedLogs = new ArrayList<>();
         for (String key : keys) {
             //取key中数据
             String[] parts = key.split(":");
@@ -419,17 +418,12 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
                     redisCache.deleteObject(heartbeatKey);
                     // 完课删除看课时长记录
                     redisCache.deleteObject(key);
-                    finishedLogs.add(watchLog);
                 }
             }
             //集合中增加
             logs.add(watchLog);
         }
         batchUpdateFsUserCourseWatchLog(logs,100);
-
-        if(CollectionUtils.isNotEmpty(finishedLogs)){
-            fsTagUpdateService.onCourseWatchFinishedBatch(finishedLogs);
-        }
     }
     public Long getFsUserVideoDuration(Long videoId){
         //将视频时长也存到redis
@@ -458,7 +452,6 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
         Collection<String> keys = redisCache.keys("h5wxuser:watch:heartbeat:*");
         LocalDateTime now = LocalDateTime.now();
         List<FsCourseWatchLog> logs = new ArrayList<>();
-        List<FsCourseWatchLog> watchingLogs = new ArrayList<>();
         for (String key : keys) {
             FsCourseWatchLog watchLog = new FsCourseWatchLog();
             String[] parts = key.split(":");
@@ -483,14 +476,10 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
                 redisCache.deleteObject(key);
             }else {
                 watchLog.setLogType(1);
-                watchingLogs.add(watchLog);
             }
             logs.add(watchLog);
         }
         batchUpdateFsUserCourseWatchLog(logs,100);
-        if(CollectionUtils.isNotEmpty(watchingLogs)){
-            fsTagUpdateService.onCourseWatchingBatch(watchingLogs);
-        }
     }
 
     @Override
@@ -513,7 +502,6 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
         CourseConfig config = JSONUtil.toBean(json, CourseConfig.class);
 
         List<FsCourseWatchLog> logs = new ArrayList<>();
-        List<FsCourseWatchLog> finishedLogs = new ArrayList<>();
         for (String key : keys) {
             //取key中数据
             Long videoId=null;
@@ -562,8 +550,6 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
                     redisCache.deleteObject(heartbeatKey);
                     // 完课删除看课时长记录
                     redisCache.deleteObject(key);
-
-                    finishedLogs.add(watchLog);
                 }
             }
             //集合中增加
@@ -571,11 +557,6 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
         }
 
         batchUpdateFsCourseWatchLogIsOpen(logs,100);
-
-        // 完课打标签
-        if(CollectionUtils.isNotEmpty(finishedLogs)){
-            fsTagUpdateService.onCourseWatchFinishedBatch(finishedLogs);
-        }
     }
 
     public void batchUpdateFsCourseWatchLogIsOpen(List<FsCourseWatchLog> logs, int batchSize) {

+ 213 - 6
fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java

@@ -62,6 +62,9 @@ import com.fs.his.utils.ConfigUtil;
 import com.fs.his.vo.OptionsVO;
 import com.fs.im.service.OpenIMService;
 import com.fs.qw.domain.*;
+import com.fs.qw.domain.QwCompany;
+import com.fs.qw.domain.QwExternalContact;
+import com.fs.qw.domain.QwUser;
 import com.fs.qw.mapper.QwExternalContactMapper;
 import com.fs.qw.mapper.QwGroupChatMapper;
 import com.fs.qw.mapper.QwGroupChatUserMapper;
@@ -79,6 +82,14 @@ import com.fs.system.mapper.SysDictDataMapper;
 import com.fs.system.service.ISysConfigService;
 import com.fs.voice.utils.StringUtil;
 import com.github.binarywang.wxpay.bean.transfer.TransferBillsResult;
+import com.volcengine.service.vod.IVodService;
+import com.volcengine.service.vod.model.business.VodUrlUploadURLSet;
+import com.volcengine.service.vod.model.request.VodGetMediaInfosRequest;
+import com.volcengine.service.vod.model.request.VodQueryUploadTaskInfoRequest;
+import com.volcengine.service.vod.model.request.VodUrlUploadRequest;
+import com.volcengine.service.vod.model.response.VodGetMediaInfosResponse;
+import com.volcengine.service.vod.model.response.VodQueryUploadTaskInfoResponse;
+import com.volcengine.service.vod.model.response.VodUrlUploadResponse;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.collections4.CollectionUtils;
 import org.redisson.api.RLock;
@@ -94,11 +105,12 @@ import org.springframework.transaction.annotation.Transactional;
 
 import java.math.BigDecimal;
 import java.math.RoundingMode;
+import java.text.SimpleDateFormat;
 import java.time.*;
 import java.time.format.DateTimeFormatter;
 import java.util.*;
-import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.TimeUnit;
+import java.util.concurrent.*;
+import java.util.concurrent.atomic.AtomicInteger;
 import java.util.concurrent.atomic.AtomicLong;
 import java.util.stream.Collectors;
 
@@ -999,6 +1011,7 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
         log.setQwUserId(Long.valueOf(param.getQwUserId()));
         log.setCreateTime(new Date());
         log.setLogType(3);
+        log.setWatchType(2);
         logger.info("【群聊生成看课记录】:{}", param);
         courseWatchLogMapper.insertFsCourseWatchLog(log);
     }
@@ -3012,7 +3025,7 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
         }
 
         //看课记录
-        addWatchLogIfNeeded(param.getVideoId(), param.getCourseId(), param.getFsUserId(), qwUser, param.getExternalUserId());
+        addWatchLogIfNeeded(param.getVideoId(), param.getCourseId(), param.getFsUserId(), qwUser, param.getExternalUserId(),2);
 
         //生成小程序链接
         String linkByMiniApp = createLinkByMiniApp(new Date(), param.getCourseId(), param.getVideoId(), qwUser, param.getExternalUserId(), 2, null, 0);
@@ -3055,7 +3068,7 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
             domainName = config.getRealLinkDomainName();
         }
 
-        addWatchLogIfNeeded(param.getVideoId(), param.getCourseId(), param.getFsUserId(), qwUser, param.getExternalUserId());
+        addWatchLogIfNeeded(param.getVideoId(), param.getCourseId(), param.getFsUserId(), qwUser, param.getExternalUserId(),2);
 
         String linkByCartLink = createLinkByMiniApp(new Date(), param.getCourseId(), param.getVideoId(), qwUser, param.getExternalUserId(), 1, domainName, 0);
 
@@ -3073,7 +3086,7 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
 
     //插入观看记录
     private void addWatchLogIfNeeded(Long videoId, Long courseId,
-                                     Long fsUserId, QwUser qwUser, Long externalId) {
+                                     Long fsUserId, QwUser qwUser, Long externalId,Integer watchType) {
 
         try {
 
@@ -3089,7 +3102,7 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
             watchLog.setCreateTime(new Date());
             watchLog.setUpdateTime(new Date());
             watchLog.setLogType(3);
-
+            watchLog.setWatchType(watchType);
             if (fsUserId == null) {
                 fsUserId = 0L;
             }
@@ -4030,5 +4043,199 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
     }
 
 
+    @Override
+    public R uploadVideoToHuoShanByUrl() {
+        List <FsUserCourseVideo> videos = fsUserCourseVideoMapper.selectVideoByHuaWei();
+        for (FsUserCourseVideo video : videos){
+            uploadVideoByUrl(video);
+        }
+        return R.ok();
+    }
+
+    @Autowired
+    private IVodService vodService;
+
+    //通过Url上传视频
+    public void uploadVideoByUrl(FsUserCourseVideo courseVideo) {
+
+        try {
+            VodUrlUploadRequest.Builder reqBuilder = VodUrlUploadRequest.newBuilder();
+            //空间名称
+            reqBuilder.setSpaceName(cloudHostProper.getSpaceName());
+            VodUrlUploadURLSet.Builder uRLSetsBuilder = VodUrlUploadURLSet.newBuilder();
+            //源文件 URL
+            uRLSetsBuilder.setSourceUrl(courseVideo.getLineTwo());//华为云
+            //存储类型。默认为 1。取值如下:
+            //1:标准存储。
+            //2:归档存储。
+            //3:低频存储。
+            uRLSetsBuilder.setStorageClass(1);
+            //文件后缀,即点播存储中文件的类型。
+            uRLSetsBuilder.setFileExtension(".mp4");
+            //用户额外信息,最大长度 512 字节。
+            uRLSetsBuilder.setCallbackArgs("");
+            // 火山云存储路径(文件上传后在火山云的路径)
+            String datePath = new SimpleDateFormat("yyyyMMdd").format(new Date());
+            String fileName = System.currentTimeMillis() + ".mp4";
+            String remoteFileName = "fs/" + datePath + "/" + fileName;
+            uRLSetsBuilder.setFileName(remoteFileName);
+            reqBuilder.addURLSets(uRLSetsBuilder);
+
+            VodUrlUploadResponse resp = vodService.uploadMediaByUrl(reqBuilder.build());
+
+            if (resp.getResponseMetadata().hasError()) {
+                log.info("上传返回异常:{}",resp.getResponseMetadata().getError());
+                System.exit(-1);
+            }else {
+                FsUserCourseVideo video = new FsUserCourseVideo();
+                video.setVideoId(courseVideo.getVideoId());
+                video.setJobId(resp.getResult().getData(0).getJobId());
+                //更新JobId
+                fsUserCourseVideoMapper.updateFsUserCourseVideo(video);
+            }
+            log.info("上传返回参数:{}",resp);
+        } catch (Exception e) {
+            throw new RuntimeException("视频上传失败: " + e.getMessage(), e);
+        }
+    }
+
+    @Override
+    public R getVidByJob() {
+        // 查询有JobId的视频
+        List<FsUserCourseVideo> list = fsUserCourseVideoMapper.selectVideoByJobId();
+        if (list.isEmpty()) {
+            log.info("没有待上传的视频任务");
+            return R.error();
+        }
+        // 按五百一批切割
+        List<List<FsUserCourseVideo>> batches = splitList(list, 500);
+        log.info("总任务 {} 条,分成 {} 批", list.size(), batches.size());
+
+        int batchIndex = 1;
+
+        // 批次顺序执行,每批内部多线程并发执行
+        for (List<FsUserCourseVideo> batch : batches) {
+
+            log.info("开始执行批次 {}/{},本批任务 {} 条", batchIndex, batches.size(), batch.size());
+
+            CountDownLatch latch = new CountDownLatch(batch.size());
+
+            for (FsUserCourseVideo video : batch) {
+                uploadExecutor.submit(() -> {
+                    try {
+                        uploadSingleTaskWithRetry(video);
+                    } finally {
+                        latch.countDown();
+                    }
+                });
+            }
+
+            // 等待这一批全部完成
+            try {
+                latch.await();
+            } catch (InterruptedException e) {
+                log.error("批次等待异常", e);
+            }
+
+            log.info("批次 {}/{} 执行完成", batchIndex, batches.size());
+            batchIndex++;
+        }
+
+        log.info("全部批次执行完成");
+        return R.ok();
+    }
+    private final ExecutorService uploadExecutor = new ThreadPoolExecutor(
+            8,  // core
+            16, // max
+            60L, TimeUnit.SECONDS,
+            new LinkedBlockingQueue<>(2000),
+            new ThreadFactory() {
+                private final AtomicInteger index = new AtomicInteger(1);
+
+                @Override
+                public Thread newThread(Runnable r) {
+                    return new Thread(r, "video-upload-" + index.getAndIncrement());
+                }
+            },
+            new ThreadPoolExecutor.CallerRunsPolicy()
+    );
+
+    public <T> List<List<T>> splitList(List<T> list, int batchSize) {
+        List<List<T>> result = new ArrayList<>();
+        int total = list.size();
+        for (int i = 0; i < total; i += batchSize) {
+            result.add(list.subList(i, Math.min(total, i + batchSize)));
+        }
+        return result;
+    }
+
+    //根据jobid查询上传视频的vid
+    public void getVidByJobId(FsUserCourseVideo courseVideo){
+        try {
+            VodQueryUploadTaskInfoRequest.Builder reqBuilder = VodQueryUploadTaskInfoRequest.newBuilder();
+            reqBuilder.setJobIds(courseVideo.getJobId());
+
+            VodQueryUploadTaskInfoResponse resp = vodService.queryUploadTaskInfo(reqBuilder.build());
+            if (resp.getResponseMetadata().hasError()) {
+                System.out.println(resp.getResponseMetadata().getError());
+                System.exit(-1);
+            }else {
+                FsUserCourseVideo video = new FsUserCourseVideo();
+                video.setVideoId(courseVideo.getVideoId());
+                video.setVid(resp.getResult().getData().getMediaInfoList(0).getVid());
+                fsUserCourseVideoMapper.updateFsUserCourseVideo(video);
+            }
+            System.out.println(resp);
+        } catch (Exception e) {
+            throw new RuntimeException("查询 URL 批量上传任务状态: " + e.getMessage(), e);
+        }
+    }
+
+
+    public void getVideoInfoByVid(FsUserCourseVideo courseVideo) {
+        try {
+            VodGetMediaInfosRequest.Builder reqBuilder = VodGetMediaInfosRequest.newBuilder();
+            reqBuilder.setVids(courseVideo.getVid());
+
+            VodGetMediaInfosResponse resp = vodService.getMediaInfos20230701(reqBuilder.build());
+            if (resp.getResponseMetadata().hasError()) {
+                System.out.println(resp.getResponseMetadata().getError());
+                System.exit(-1);
+            }else {
+                FsUserCourseVideo video = new FsUserCourseVideo();
+                video.setVideoId(courseVideo.getVideoId());
+                video.setLineTwo(resp.getResult().getMediaInfoList(0).getSourceInfo().getStoreUri());
+                fsUserCourseVideoMapper.updateFsUserCourseVideo(video);
+            }
+            System.out.println(resp);
+        } catch (Exception e) {
+            e.printStackTrace();
+        }
+    }
+
+
+    public void uploadSingleTaskWithRetry(FsUserCourseVideo courseVideo) {
+        int maxRetry = 3;
+        for (int i = 1; i <= maxRetry; i++) {
+            try {
+                //获取上传成功的视频vid,同步到数据库
+                getVidByJobId(courseVideo);
+                //查询需要上传的视频,上传后将视频任务id存到数据库
+//                uploadVideoByUrl(fsUserVideo);
+                //根据视频vid获取火山云的视频信息,并同步到数据库
+                /**
+                 * 下面这个方法调用前去润天his_java确认一下,是否一致的
+                 */
+                //getVideoInfoByVid(fsUserVideo);
+                return;
+            } catch (Exception e) {
+                log.error("视频 {} 上传失败,第 {} 次重试,原因:{}",
+                        courseVideo.getVideoId(), i, e.getMessage());
+                if (i == maxRetry) {
+                    log.error("视频 {} 上传最终失败!", courseVideo.getVideoId());
+                }
+            }
+        }
+    }
 }
 

+ 54 - 0
fs-service/src/main/java/com/fs/course/vo/FsPeriodCountExportVO.java

@@ -0,0 +1,54 @@
+package com.fs.course.vo;
+
+import com.fs.common.annotation.Excel;
+import com.fs.course.vo.newfs.FsCourseAnalysisCountVO;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+
+@Data
+@ApiModel
+public class FsPeriodCountExportVO {
+    @Excel(name = "课程名称")
+    private String title;
+
+    @Excel(name = "营期日期")
+    private LocalDate dayDate;
+
+    @Excel(name = "观看次数")
+    private int courseWatchTimes;
+
+    @Excel(name = "完播次数")
+    private int courseCompleteTimes;
+
+    @Excel(name = "观看人数")
+    private int courseWatchNum;
+
+    @Excel(name = "完播人数")
+    private int courseCompleteNum;
+
+    @Excel(name = "完播率")
+    private String completeRate;
+
+    @Excel(name = "答题次数")
+    private int answerTimes;
+
+    @Excel(name = "答题人数")
+    private int answerNum;
+
+    @Excel(name = "正确人数")
+    private int answerRightNum;
+
+    @Excel(name = "正确率")
+    private String answerRightRate;
+
+    @Excel(name = "答题红包个数")
+    private int redPacketNum;
+
+    @Excel(name = "答题红包金额(元)")
+    private BigDecimal redPacketAmount;
+
+}

+ 3 - 0
fs-service/src/main/java/com/fs/course/vo/FsUserCourseVideoQVO.java

@@ -102,4 +102,7 @@ public class FsUserCourseVideoQVO extends BaseEntity {
      * 课程关联的拍商品
      */
     private List<FsCourseProduct>  courseProducts;
+
+    // 是否上架 0:上架,1:下架
+    private Integer isOnPut;
 }

+ 3 - 0
fs-service/src/main/java/com/fs/course/vo/FsUserCourseVideoVO.java

@@ -68,4 +68,7 @@ public class FsUserCourseVideoVO extends BaseEntity {
     private String redPacketMoney;
 
     private String companyRedPacketMoney;
+
+    // 是否上架 0:上架,1:下架
+    private Integer isOnPut;
 }

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

@@ -849,7 +849,7 @@ public class JSTErpOrderServiceImpl implements IErpOrderService {
         log.info("订单号: {},发货状态: {},是否发货后: {}",fsStoreOrder.getOrderCode(),fsStoreOrder.getStatus(),ObjectUtils.equals(fsStoreOrder.getStatus(),2));
 
         // 发货后退款
-        if(ObjectUtils.equals(param.getOrderStatus(),2)){
+        if(ObjectUtils.equals(param.getOrderStatus(),2) || ObjectUtils.equals(param.getOrderStatus(),3)){
 
             FsJstAftersalePush fsJstAftersalePush = new FsJstAftersalePush();
             fsJstAftersalePush.setOrderId(fsStoreOrder.getOrderCode());

+ 4 - 4
fs-service/src/main/java/com/fs/fastGpt/service/impl/AiHookServiceImpl.java

@@ -1394,7 +1394,7 @@ public class AiHookServiceImpl implements AiHookService {
                         String.valueOf(user.getId()),
                         String.valueOf(user.getCompanyUserId()),
                         String.valueOf(user.getCompanyId()),
-                        String.valueOf(session.getQwExtId()));
+                        String.valueOf(session.getQwExtId()),2);
                 if (linkUrl != null && linkUrl.get("url") != null) {
                     String s = (String)linkUrl.get("url");
                     // sendWebSocketMsg(s,msgVo,user,session);
@@ -1425,7 +1425,7 @@ public class AiHookServiceImpl implements AiHookService {
                                 String.valueOf(user.getId()),
                                 String.valueOf(user.getCompanyUserId()),
                                 String.valueOf(user.getCompanyId()),
-                                String.valueOf(session.getQwExtId()));
+                                String.valueOf(session.getQwExtId()),2);
                         if (linkUrl != null && linkUrl.get("url") != null) {
                             String s = (String)linkUrl.get("url");
                             // sendWebSocketMsg(s,msgVo,user,session);
@@ -1935,7 +1935,7 @@ public class AiHookServiceImpl implements AiHookService {
                         String.valueOf(user.getId()),
                         String.valueOf(user.getCompanyUserId()),
                         String.valueOf(user.getCompanyId()),
-                        String.valueOf(session.getQwExtId()));
+                        String.valueOf(session.getQwExtId()),2);
                 if (linkUrl != null && linkUrl.get("url") != null) {
                     String s = (String)linkUrl.get("url");
                     sendWebTaskSocketMsg(s,sendId,user);
@@ -1966,7 +1966,7 @@ public class AiHookServiceImpl implements AiHookService {
                             String.valueOf(user.getId()),
                             String.valueOf(user.getCompanyUserId()),
                             String.valueOf(user.getCompanyId()),
-                            String.valueOf(session.getQwExtId()));
+                            String.valueOf(session.getQwExtId()),2);
                     if (linkUrl != null && linkUrl.get("url") != null) {
                         String s = (String)linkUrl.get("url");
                         sendWebTaskSocketMsg(s,sendId,user);

+ 4 - 4
fs-service/src/main/java/com/fs/fastGpt/service/impl/AiNewServiceImpl.java

@@ -552,7 +552,7 @@ public class AiNewServiceImpl implements AiNewService {
                         String.valueOf(user.getId()),
                         String.valueOf(user.getCompanyUserId()),
                         String.valueOf(user.getCompanyId()),
-                        String.valueOf(session.getQwExtId()));
+                        String.valueOf(session.getQwExtId()),2);
                 if (linkUrl != null && linkUrl.get("url") != null) {
                     String s = (String)linkUrl.get("url");
                     sendWebSocketMsg(s,msgVo,user,session);
@@ -583,7 +583,7 @@ public class AiNewServiceImpl implements AiNewService {
                                 String.valueOf(user.getId()),
                                 String.valueOf(user.getCompanyUserId()),
                                 String.valueOf(user.getCompanyId()),
-                                String.valueOf(session.getQwExtId()));
+                                String.valueOf(session.getQwExtId()),2);
                         if (linkUrl != null && linkUrl.get("url") != null) {
                             String s = (String)linkUrl.get("url");
                             sendWebSocketMsg(s,msgVo,user,session);
@@ -1070,7 +1070,7 @@ public class AiNewServiceImpl implements AiNewService {
                         String.valueOf(user.getId()),
                         String.valueOf(user.getCompanyUserId()),
                         String.valueOf(user.getCompanyId()),
-                        String.valueOf(session.getQwExtId()));
+                        String.valueOf(session.getQwExtId()),2);
                 if (linkUrl != null && linkUrl.get("url") != null) {
                     String s = (String)linkUrl.get("url");
                     sendWebTaskSocketMsg(s,sendId,user);
@@ -1101,7 +1101,7 @@ public class AiNewServiceImpl implements AiNewService {
                             String.valueOf(user.getId()),
                             String.valueOf(user.getCompanyUserId()),
                             String.valueOf(user.getCompanyId()),
-                            String.valueOf(session.getQwExtId()));
+                            String.valueOf(session.getQwExtId()),2);
                     if (linkUrl != null && linkUrl.get("url") != null) {
                         String s = (String)linkUrl.get("url");
                         sendWebTaskSocketMsg(s,sendId,user);

+ 4 - 4
fs-service/src/main/java/com/fs/fastGpt/service/impl/AiServiceImpl.java

@@ -530,7 +530,7 @@ public class AiServiceImpl implements AiService {
                         String.valueOf(user.getId()),
                         String.valueOf(user.getCompanyUserId()),
                         String.valueOf(user.getCompanyId()),
-                        String.valueOf(session.getQwExtId()));
+                        String.valueOf(session.getQwExtId()),2);
                 if (linkUrl != null && linkUrl.get("url") != null) {
                     String s = (String)linkUrl.get("url");
                     sendWebSocketMsg(s,msgVo,user,session);
@@ -561,7 +561,7 @@ public class AiServiceImpl implements AiService {
                                 String.valueOf(user.getId()),
                                 String.valueOf(user.getCompanyUserId()),
                                 String.valueOf(user.getCompanyId()),
-                                String.valueOf(session.getQwExtId()));
+                                String.valueOf(session.getQwExtId()),2);
                         if (linkUrl != null && linkUrl.get("url") != null) {
                             String s = (String)linkUrl.get("url");
                             sendWebSocketMsg(s,msgVo,user,session);
@@ -1048,7 +1048,7 @@ public class AiServiceImpl implements AiService {
                         String.valueOf(user.getId()),
                         String.valueOf(user.getCompanyUserId()),
                         String.valueOf(user.getCompanyId()),
-                        String.valueOf(session.getQwExtId()));
+                        String.valueOf(session.getQwExtId()),2);
                 if (linkUrl != null && linkUrl.get("url") != null) {
                     String s = (String)linkUrl.get("url");
                     sendWebTaskSocketMsg(s,sendId,user);
@@ -1079,7 +1079,7 @@ public class AiServiceImpl implements AiService {
                             String.valueOf(user.getId()),
                             String.valueOf(user.getCompanyUserId()),
                             String.valueOf(user.getCompanyId()),
-                            String.valueOf(session.getQwExtId()));
+                            String.valueOf(session.getQwExtId()),2);
                     if (linkUrl != null && linkUrl.get("url") != null) {
                         String s = (String)linkUrl.get("url");
                         sendWebTaskSocketMsg(s,sendId,user);

+ 8 - 0
fs-service/src/main/java/com/fs/his/domain/FsUserIntegralLogs.java

@@ -5,6 +5,8 @@ import com.fs.common.core.domain.BaseEntity;
 import io.swagger.models.auth.In;
 import lombok.Data;
 
+import java.math.BigDecimal;
+
 /**
  * 积分记录对象 fs_user_integral_logs
  *
@@ -42,4 +44,10 @@ public class FsUserIntegralLogs extends BaseEntity
     private Integer businessType;
 
     private Integer status;
+
+    private String nickName;
+
+    private String phone;
+
+    private BigDecimal commission;
 }

+ 2 - 0
fs-service/src/main/java/com/fs/his/mapper/FsUserInformationCollectionMapper.java

@@ -100,4 +100,6 @@ public interface FsUserInformationCollectionMapper extends BaseMapper<FsUserInfo
     List<FsUserInformationCollection>selectFsUserInformationCollectionByDoctorType2(@Param("maps") UserInformationDoctorType2Param userInformationDoctorType2Param);
     List<FsUserInformationCollection>selectFsUserInformationCollectionByDoctorType1(@Param("maps") UserInformationDoctorType2Param userInformationDoctorType2Param);
     FsUserInformationCollection selectFsUserInformationCollectionByOrderCode(String orderCode);
+
+    List<FsUserInformationCollection> selectListByIsPayAndConfirmStatus();
 }

+ 26 - 0
fs-service/src/main/java/com/fs/his/param/FsReceiveLuckyBagParam.java

@@ -0,0 +1,26 @@
+package com.fs.his.param;
+
+import lombok.Data;
+
+/**
+ * @description: TODO
+ * @author: Xgb
+ * @createDate: 2025/11/21
+ * @version: 1.0
+ */
+@Data
+public class FsReceiveLuckyBagParam {
+
+    // 记录id
+    private Long recordId;
+
+    // 用户id
+    private Long userId;
+
+    // 企微员工id
+    private String userName;
+
+    // 企微主体id
+    private String corpId;
+
+}

+ 1 - 0
fs-service/src/main/java/com/fs/his/service/impl/MerchantAppConfigServiceImpl.java

@@ -52,6 +52,7 @@ public class MerchantAppConfigServiceImpl extends ServiceImpl<MerchantAppConfigM
                 Thread.sleep(5000);
                 Integer count = baseMapper.checkTableExists();
                 if (ObjectUtil.isNotNull(count)&&count>0) {
+                    log.info("异步初始化商户配置表完成");
                     return;
                 }
                 // 1. 检查并创建表

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

@@ -140,4 +140,8 @@ public class FsStoreAfterSalesScrm extends BaseEntity
 
     private String remark;
 
+    /** 产品名称查询参数(用于搜索) */
+    @TableField(exist = false)
+    private String productName;
+
 }

+ 3 - 0
fs-service/src/main/java/com/fs/hisStore/mapper/FsStoreAfterSalesScrmMapper.java

@@ -140,6 +140,9 @@ public interface FsStoreAfterSalesScrmMapper
             "<if test = 'maps.consigneePhone != null and  maps.consigneePhone !=\"\"     '> " +
             "and o.user_phone like CONCAT('%',#{maps.consigneePhone},'%') " +
             "</if>" +
+            "<if test = 'maps.productName != null and  maps.productName != \"\" '> " +
+            "and EXISTS (SELECT 1 FROM fs_store_order_item_scrm oi WHERE oi.order_id = o.id AND JSON_UNQUOTE(JSON_EXTRACT(oi.json_info, '$.productName')) LIKE CONCAT('%', #{maps.productName}, '%')) " +
+            "</if>" +
             "<if test = 'maps.deptId != null    '> " +
             "  AND (o.dept_id = #{maps.deptId} OR o.dept_id IN ( SELECT t.dept_id FROM company_dept t WHERE find_in_set(#{maps.deptId}, ancestors) )) " +
             "</if>" +

+ 16 - 1
fs-service/src/main/java/com/fs/hisStore/mapper/FsStoreOrderItemScrmMapper.java

@@ -87,7 +87,17 @@ public interface FsStoreOrderItemScrmMapper
             " left join company_tcm_schedule cts on cts.id = o.schedule_id " +
             " left join fs_store_product_scrm psps on i.product_id=psps.product_id " +
             " left join fs_store_product_category_scrm fspcs on fspcs.cate_id=psps.cate_id " +
+            "            LEFT JOIN (\n" +
+            "            SELECT\n" +
+            "            sp.*,\n" +
+            "            ROW_NUMBER() OVER (PARTITION BY sp.business_code ORDER BY sp.create_time DESC) as rn\n" +
+            "            FROM fs_store_payment_scrm sp\n" +
+            "            WHERE sp.business_code IS NOT NULL\n" +
+            "            ) sp_latest ON sp_latest.business_code = o.order_code AND sp_latest.rn = 1\n" +
             " where 1=1 " +
+            "<if test=\"maps.bankTransactionId !=null and maps.bankTransactionId!=''\">" +
+            " and sp_latest.bank_transaction_id = #{maps.bankTransactionId} " +
+            "</if>" +
             "<if test = 'maps.orderCode != null and  maps.orderCode !=\"\"    '> " +
             "and o.order_code like CONCAT('%',#{maps.orderCode},'%') " +
             "</if>" +
@@ -162,7 +172,7 @@ public interface FsStoreOrderItemScrmMapper
             "left join company_user cu on cu.user_id=o.company_user_id " +
             "left join company_tcm_schedule cts on cts.id = o.schedule_id " +
             "LEFT JOIN fs_store_order_df df on df.order_id=o.id\n" +
-            "        <if test=\"maps.appId != null and maps.appId != ''\">\n" +
+            "        <if test=\"maps.bankTransactionId !=null and maps.bankTransactionId!=''\">\n" +
             "            LEFT JOIN (\n" +
             "            SELECT\n" +
             "            sp.*,\n" +
@@ -170,9 +180,14 @@ public interface FsStoreOrderItemScrmMapper
             "            FROM fs_store_payment_scrm sp\n" +
             "            WHERE sp.business_code IS NOT NULL\n" +
             "            ) sp_latest ON sp_latest.business_code = o.order_code AND sp_latest.rn = 1\n" +
+                        "<if test=\"maps.appId != null and maps.appId != ''\">" +
             "            LEFT JOIN fs_course_play_source_config csc ON csc.appid = sp_latest.app_id\n" +
+                        "</if>" +
             "        </if>" +
             "where 1=1 " +
+            "<if test=\"maps.bankTransactionId !=null and maps.bankTransactionId!=''\">" +
+            "and sp_latest.bank_transaction_id = #{maps.bankTransactionId}\n" +
+            "</if>" +
             "<if test=\"maps.appId != null and maps.appId != ''\">\n" +
             "   and csc.appid = #{maps.appId}\n" +
             " </if>\n" +

+ 17 - 2
fs-service/src/main/java/com/fs/hisStore/mapper/FsStoreOrderScrmMapper.java

@@ -648,9 +648,24 @@ public interface FsStoreOrderScrmMapper
 
     @Select({"<script> " +
             "select o.*,cts.name as scheduleName,u.nickname,u.phone,cc.push_code,cc.create_time as customer_create_time,cc.source,cc.customer_code, c.company_name ,cu.nick_name as company_user_nick_name ,cu.phonenumber as company_usere_phonenumber ,p.title as package_title ,CASE WHEN o.certificates IS NULL OR o.certificates = '' THEN 0 ELSE 1 END AS is_upload  " +
-            " from fs_store_order_scrm o  left JOIN fs_store_product_package_scrm p on o.package_id=p.package_id left join fs_user u on o.user_id=u.user_id  " +
-            " left join company c on c.company_id=o.company_id left join company_user cu on cu.user_id=o.company_user_id left join crm_customer cc on cc.customer_id=o.customer_id left join company_tcm_schedule cts on cts.id = o.schedule_id " +
+            " from fs_store_order_scrm o  " +
+            " left JOIN fs_store_product_package_scrm p on o.package_id=p.package_id " +
+            " left join fs_user u on o.user_id=u.user_id  " +
+            " left join company c on c.company_id=o.company_id " +
+            " left join company_user cu on cu.user_id=o.company_user_id " +
+            " left join crm_customer cc on cc.customer_id=o.customer_id " +
+            " left join company_tcm_schedule cts on cts.id = o.schedule_id " +
+            "            LEFT JOIN (\n" +
+            "            SELECT\n" +
+            "            sp.*,\n" +
+            "            ROW_NUMBER() OVER (PARTITION BY sp.business_code ORDER BY sp.create_time DESC) as rn\n" +
+            "            FROM fs_store_payment_scrm sp\n" +
+            "            WHERE sp.business_code IS NOT NULL\n" +
+            "            ) sp_latest ON sp_latest.business_code = o.order_code AND sp_latest.rn = 1\n" +
             "where 1=1 " +
+            "<if test=\"maps.bankTransactionId !=null and maps.bankTransactionId!=''\">" +
+            " and sp_latest.bank_transaction_id = #{maps.bankTransactionId} " +
+            "</if>" +
             "<if test = 'maps.orderCode != null and  maps.orderCode !=\"\"    '> " +
             "and o.order_code like CONCAT('%',#{maps.orderCode},'%') " +
             "</if>" +

+ 12 - 0
fs-service/src/main/java/com/fs/hisStore/mapper/FsStoreProductAttrValueScrmMapper.java

@@ -147,4 +147,16 @@ public interface FsStoreProductAttrValueScrmMapper
     void updateFsStoreProductAttrValuePrice(List<Long> ids, double v);
 
     List<FsStoreProductAttrValueScrm> getFsStoreProductAttrValueListInProductId(List<Long> productIds);
+
+    @Update({"<script> " +
+            " UPDATE fs_store_product_attr_value_scrm" +
+            " SET stock = stock + CAST(#{totalNum} AS SIGNED)" +
+            " WHERE product_id = #{productId}" +
+            " AND bar_code IN",
+            "<foreach collection='barCodeList' item='barCode' open='(' separator=',' close=')'>" +
+            "#{barCode}" +
+            "</foreach>" +
+            "</script>"
+    })
+    void incStock(@Param("productId") Long productId,@Param("barCodeList") List<String> barCodeList,@Param("totalNum") String totalNum);
 }

+ 10 - 0
fs-service/src/main/java/com/fs/hisStore/mapper/MergedOrderMapper.java

@@ -1,7 +1,9 @@
 package com.fs.hisStore.mapper;
 
 import com.fs.hisStore.param.FsMyStoreOrderQueryParam;
+import com.fs.hisStore.param.MergedAfterSalesQueryParam;
 import com.fs.hisStore.vo.FsMergedOrderListQueryVO;
+import com.fs.hisStore.vo.MergedAfterSalesVO;
 import com.fs.live.param.MergedOrderQueryParam;
 import com.fs.live.vo.MergedOrderVO;
 import org.apache.ibatis.annotations.Param;
@@ -25,6 +27,14 @@ public interface MergedOrderMapper
      */
     List<MergedOrderVO> selectMergedOrderList(@Param("maps") MergedOrderQueryParam param);
 
+    /**
+     * 查询合并的售后列表(商城售后+直播售后)
+     *
+     * @param param 查询参数
+     * @return 合并后的售后列表
+     */
+    List<MergedAfterSalesVO> selectMergedAfterSalesList(@Param("maps") MergedAfterSalesQueryParam param);
+
     /**
      * 查询合并的订单列表(商城订单+直播订单)
      *

+ 31 - 0
fs-service/src/main/java/com/fs/hisStore/param/MergedAfterSalesDeliveryParam.java

@@ -0,0 +1,31 @@
+package com.fs.hisStore.param;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+import java.io.Serializable;
+
+/**
+ * 合并售后物流参数
+ *
+ * @author fs
+ * @date 2025-01-XX
+ */
+@Data
+public class MergedAfterSalesDeliveryParam implements Serializable
+{
+    private Long userId;
+    
+    /** 售后ID */
+    private Long salesId;
+
+    @NotBlank(message = "物流单号不能为空")
+    private String deliverySn;
+
+    @NotBlank(message = "物流公司不能为空")
+    private String deliveryName;
+
+    /** 售后类型 1商城售后 2直播售后 */
+    private Integer afterSalesType;
+}
+

+ 43 - 0
fs-service/src/main/java/com/fs/hisStore/param/MergedAfterSalesParam.java

@@ -0,0 +1,43 @@
+package com.fs.hisStore.param;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 合并售后申请参数
+ *
+ * @author fs
+ * @date 2025-01-XX
+ */
+@Data
+public class MergedAfterSalesParam implements Serializable
+{
+    /** 订单号 */
+    @NotBlank
+    private String orderCode;
+
+    /** 服务类型 0仅退款1退货退款 */
+    @NotBlank
+    private Integer serviceType;
+
+    /** 申请原因 */
+    @NotBlank
+    private String reasons;
+
+    /** 申请说明 */
+    private String explains;
+
+    /** 申请说明图片 */
+    private String explainImg;
+
+    private BigDecimal refundAmount;
+
+    /** 商品数据 */
+    @NotBlank
+    private List<FsStoreAfterSalesProductParam> productList;
+}
+

+ 23 - 0
fs-service/src/main/java/com/fs/hisStore/param/MergedAfterSalesQueryParam.java

@@ -0,0 +1,23 @@
+package com.fs.hisStore.param;
+
+import com.fs.common.param.BaseQueryParam;
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 合并售后查询参数
+ *
+ * @author fs
+ * @date 2025-01-XX
+ */
+@Data
+public class MergedAfterSalesQueryParam extends BaseQueryParam implements Serializable
+{
+    /** 状态 1待处理 2已完成 */
+    private Integer status;
+    
+    /** 用户ID */
+    private Long userId;
+}
+

+ 22 - 0
fs-service/src/main/java/com/fs/hisStore/param/MergedAfterSalesRevokeParam.java

@@ -0,0 +1,22 @@
+package com.fs.hisStore.param;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 合并售后撤销参数
+ *
+ * @author fs
+ * @date 2025-01-XX
+ */
+@Data
+public class MergedAfterSalesRevokeParam implements Serializable
+{
+    /** 售后ID */
+    private Long salesId;
+
+    /** 售后类型 1商城售后 2直播售后 */
+    private Integer afterSalesType;
+}
+

+ 25 - 0
fs-service/src/main/java/com/fs/hisStore/param/MergedOrderDeleteParam.java

@@ -0,0 +1,25 @@
+package com.fs.hisStore.param;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotNull;
+import java.io.Serializable;
+
+/**
+ * 合并订单删除参数
+ *
+ * @author fs
+ * @date 2025-01-XX
+ */
+@Data
+public class MergedOrderDeleteParam implements Serializable
+{
+    /** 订单ID */
+    @NotNull(message = "订单ID不能为空")
+    private Long orderId;
+
+    /** 订单类型 1商城订单 2直播订单 */
+    @NotNull(message = "订单类型不能为空")
+    private Integer orderType;
+}
+

+ 56 - 1
fs-service/src/main/java/com/fs/hisStore/service/IMergedOrderService.java

@@ -1,10 +1,13 @@
 package com.fs.hisStore.service;
 
-import com.fs.hisStore.param.FsMyStoreOrderQueryParam;
+import com.fs.common.core.domain.R;
+import com.fs.hisStore.param.*;
 import com.fs.hisStore.vo.FsMergedOrderListQueryVO;
+import com.fs.hisStore.vo.MergedAfterSalesVO;
 import com.fs.live.param.MergedOrderQueryParam;
 import com.fs.live.vo.MergedOrderVO;
 
+import java.text.ParseException;
 import java.util.List;
 
 /**
@@ -24,5 +27,57 @@ public interface IMergedOrderService
     List<FsMergedOrderListQueryVO> selectMergedOrderListVO(FsMyStoreOrderQueryParam param);
 
     List<MergedOrderVO> selectMergedOrderList(MergedOrderQueryParam param);
+
+    /**
+     * 查询合并的售后列表(商城售后+直播售后)
+     *
+     * @param param 查询参数
+     * @return 合并后的售后列表
+     */
+    List<MergedAfterSalesVO> selectMergedAfterSalesList(MergedAfterSalesQueryParam param);
+
+    /**
+     * 申请售后
+     *
+     * @param userId 用户ID
+     * @param param 售后参数
+     * @return 结果
+     */
+    R applyForAfterSales(String userId, MergedAfterSalesParam param);
+
+    /**
+     * 撤销售后
+     *
+     * @param userId 用户ID
+     * @param param 撤销参数
+     * @return 结果
+     */
+    R revokeAfterSales(String userId, MergedAfterSalesRevokeParam param) throws ParseException;
+
+    /**
+     * 提交物流信息
+     *
+     * @param param 物流参数
+     * @return 结果
+     */
+    R addDelivery(MergedAfterSalesDeliveryParam param);
+
+    /**
+     * 查询售后详情
+     *
+     * @param salesId 售后ID
+     * @param afterSalesType 售后类型 1商城售后 2直播售后
+     * @return 售后详情
+     */
+    MergedAfterSalesVO selectMergedAfterSalesById(Long salesId, Integer afterSalesType);
+
+    /**
+     * 删除订单(逻辑删除)
+     *
+     * @param userId 用户ID
+     * @param param 删除参数
+     * @return 结果
+     */
+    R deleteOrder(String userId, MergedOrderDeleteParam param);
 }
 

+ 42 - 4
fs-service/src/main/java/com/fs/hisStore/service/impl/FsStoreOrderScrmServiceImpl.java

@@ -756,6 +756,11 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
         }
         FsStoreOrderPriceDTO priceGroup = this.getOrderPriceGroup(carts, userAddress);
         BigDecimal payPostage = priceGroup.getStorePostage();
+        BigDecimal badCode = BigDecimal.valueOf(-1);
+        // 检查运费计算结果,如果是 -1 表示偏远地区不可购买
+        if (payPostage.compareTo(badCode) == 0) {
+            throw new ServiceException("偏远地区暂不可购买");
+        }
         payPrice = NumberUtil.add(payPrice, payPostage);
 
         FsUserScrm user = userService.selectFsUserById(uid);
@@ -827,7 +832,16 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
         FsStoreOrderComputedParam computedParam = new FsStoreOrderComputedParam();
         BeanUtils.copyProperties(param, computedParam);
         //计算金额
-        FsStoreOrderComputeDTO dto = this.computedOrder(userId, computedParam);
+        FsStoreOrderComputeDTO dto;
+        try {
+            dto = this.computedOrder(userId, computedParam);
+        } catch (ServiceException e) {
+            // 捕获运费模板检查异常,直接返回错误
+            if ("偏远地区暂不可购买".equals(e.getMessage())) {
+                return R.error("偏远地区暂不可购买");
+            }
+            throw e;
+        }
         String cartIds = redisCache.getCacheObject("orderKey:" + param.getOrderKey());
         Integer payType = redisCache.getCacheObject("createOrderPayType:" + param.getCreateOrderKey());
         if (payType != null) {
@@ -841,7 +855,8 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
             //获取地址
             FsUserAddressScrm address = userAddressMapper.selectFsUserAddressById(param.getAddressId());
             //生成分布式唯一值
-            String orderSn = IdUtil.getSnowflake(0, 0).nextIdStr();
+
+            String orderSn = OrderCodeUtils.getOrderSn();
             //是否使用积分
             Boolean isIntegral = false;
             //组合数据
@@ -3072,6 +3087,7 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
      */
     private BigDecimal handlePostage(List<FsStoreCartQueryVO> cartInfo, FsUserAddressScrm userAddress) {
         BigDecimal storePostage = BigDecimal.ZERO;
+        BigDecimal badCode = BigDecimal.valueOf(-1);
         if (userAddress != null) {
             if (userAddress.getCityId() == null) {
                 return storePostage;
@@ -3083,14 +3099,26 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
             List<Long> tempIds = cartInfo
                     .stream()
                     .map(FsStoreCartQueryVO::getTempId)
+                    .filter(tempId -> tempId != null)
                     .collect(Collectors.toList());
 
+            if (tempIds.isEmpty()) {
+                return storePostage;
+            }
+
             //获取商品用到的运费模板
             List<FsShippingTemplatesScrm> shippingTemplatesList = shippingTemplatesService.selectFsShippingTemplatesByIds(StringUtils.join(tempIds, ","));
 
             //获取运费模板区域列表按照城市排序
             List<FsShippingTemplatesRegionScrm> shippingTemplatesRegionList = shippingTemplatesRegionService.selectFsShippingTemplatesRegionListByTempIdsAndCityIds(StringUtils.join(tempIds, ","), StringUtils.join(citys, ","));
 
+            // 有运费模板,但当前城市没有匹配的区域
+            if (shippingTemplatesList != null && !shippingTemplatesList.isEmpty()
+                    && (shippingTemplatesRegionList == null || shippingTemplatesRegionList.isEmpty())) {
+                logger.error("运费模板存在,但城市不在运费模板区域内,cityId: {}", cityId);
+                return badCode;
+            }
+
             //提取运费模板类型
             Map<Long, Integer> shippingTemplatesMap = shippingTemplatesList
                     .stream()
@@ -3107,6 +3135,18 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
             Map<Long, TemplateDTO> templateDTOMap = new HashMap<>();
             for (FsStoreCartQueryVO storeCartVO : cartInfo) {
                 Long tempId = storeCartVO.getTempId();
+                if (tempId == null) {
+                    continue;
+                }
+                // 检查该商品的运费模板是否有对应的区域配置
+                FsShippingTemplatesRegionScrm shippingTemplatesRegion = shippingTemplatesRegionMap.get(tempId);
+                // 如果商品有运费模板,但没有找到对应的区域配置,返回错误码
+                if (shippingTemplatesList != null && !shippingTemplatesList.isEmpty()
+                        && shippingTemplatesList.stream().anyMatch(t -> t.getId().equals(tempId))
+                        && shippingTemplatesRegion == null) {
+                    logger.error("商品运费模板存在,但城市不在运费模板区域内,tempId: {}, cityId: {}", tempId, cityId);
+                    return badCode;
+                }
                 //根据模板类型获取相应的数量
                 double num = 0d;
                 if (ShippingTempEnum.TYPE_1.getValue().equals(shippingTemplatesMap.get(tempId))) {
@@ -3118,8 +3158,6 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
                     num = NumberUtil.mul(storeCartVO.getCartNum(),
                             storeCartVO.getVolume()).doubleValue();
                 }
-
-                FsShippingTemplatesRegionScrm shippingTemplatesRegion = shippingTemplatesRegionMap.get(tempId);
                 BigDecimal price = NumberUtil.round(NumberUtil.mul(storeCartVO.getCartNum(),
                         storeCartVO.getPrice()), 2);
                 if (!templateDTOMap.containsKey(tempId)) {

+ 230 - 1
fs-service/src/main/java/com/fs/hisStore/service/impl/MergedOrderServiceImpl.java

@@ -2,20 +2,37 @@ package com.fs.hisStore.service.impl;
 
 import cn.hutool.json.JSONArray;
 import cn.hutool.json.JSONUtil;
+import com.fs.common.core.domain.R;
 import com.fs.common.utils.StringUtils;
+import com.fs.hisStore.domain.FsStoreAfterSalesItemScrm;
+import com.fs.hisStore.domain.FsStoreAfterSalesScrm;
 import com.fs.hisStore.enums.OrderInfoEnum;
 import com.fs.hisStore.mapper.MergedOrderMapper;
-import com.fs.hisStore.param.FsMyStoreOrderQueryParam;
+import com.fs.hisStore.param.*;
+import com.fs.hisStore.service.IFsStoreAfterSalesItemScrmService;
+import com.fs.hisStore.service.IFsStoreAfterSalesScrmService;
+import com.fs.hisStore.service.IFsStoreOrderScrmService;
 import com.fs.hisStore.service.IMergedOrderService;
 import com.fs.hisStore.vo.FsMergedOrderListQueryVO;
 import com.fs.hisStore.vo.FsStoreOrderItemVO;
+import com.fs.hisStore.vo.MergedAfterSalesVO;
+import com.fs.live.domain.LiveAfterSales;
+import com.fs.live.domain.LiveAfterSalesItem;
+import com.fs.live.param.LiveAfterSalesDeliveryParam;
+import com.fs.live.param.LiveAfterSalesParam;
+import com.fs.live.param.LiveAfterSalesRevokeParam;
 import com.fs.live.param.MergedOrderQueryParam;
+import com.fs.live.service.ILiveAfterSalesItemService;
+import com.fs.live.service.ILiveAfterSalesService;
+import com.fs.live.service.ILiveOrderService;
 import com.fs.live.vo.MergedOrderVO;
 import com.fs.store.config.StoreConfig;
 import com.fs.system.service.ISysConfigService;
+import org.springframework.beans.BeanUtils;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 
+import java.text.ParseException;
 import java.util.*;
 
 /**
@@ -65,6 +82,24 @@ public class MergedOrderServiceImpl implements IMergedOrderService
     @Autowired
     private ISysConfigService configService;
 
+    @Autowired
+    private IFsStoreAfterSalesScrmService storeAfterSalesService;
+
+    @Autowired
+    private ILiveAfterSalesService liveAfterSalesService;
+
+    @Autowired
+    private IFsStoreOrderScrmService storeOrderService;
+
+    @Autowired
+    private ILiveOrderService liveOrderService;
+
+    @Autowired
+    private IFsStoreAfterSalesItemScrmService storeAfterSalesItemService;
+
+    @Autowired
+    private ILiveAfterSalesItemService liveAfterSalesItemService;
+
     /*
      * 小程序合并
      * */
@@ -130,5 +165,199 @@ public class MergedOrderServiceImpl implements IMergedOrderService
 
         return list;
     }
+
+    @Override
+    public List<MergedAfterSalesVO> selectMergedAfterSalesList(MergedAfterSalesQueryParam param) {
+        List<MergedAfterSalesVO> list = mergedOrderMapper.selectMergedAfterSalesList(param);
+        
+        // 填充售后商品列表
+        for (MergedAfterSalesVO vo : list) {
+            if (vo.getAfterSalesType() != null && vo.getAfterSalesType() == 1) {
+                // 商城售后
+                FsStoreAfterSalesItemScrm itemParam = new FsStoreAfterSalesItemScrm();
+                itemParam.setStoreAfterSalesId(vo.getId());
+                List<FsStoreAfterSalesItemScrm> items = storeAfterSalesItemService.selectFsStoreAfterSalesItemList(itemParam);
+                vo.setItems(items);
+            } else if (vo.getAfterSalesType() != null && vo.getAfterSalesType() == 2) {
+                // 直播售后
+                List<LiveAfterSalesItem> items = liveAfterSalesItemService.selectLiveAfterSalesItemByAfterId(vo.getId());
+                vo.setItems(items);
+            }
+        }
+        
+        return list;
+    }
+
+    @Override
+    public R applyForAfterSales(String userId, MergedAfterSalesParam param) {
+        // 根据订单号判断是商城订单还是直播订单
+        try {
+            // 先尝试查询商城订单
+            com.fs.hisStore.domain.FsStoreOrderScrm storeOrder = storeOrderService.selectFsStoreOrderByOrderCode(param.getOrderCode());
+            if (storeOrder != null) {
+                // 商城订单,调用商城售后服务
+                FsStoreAfterSalesParam storeParam = new FsStoreAfterSalesParam();
+                BeanUtils.copyProperties(param, storeParam);
+                return storeAfterSalesService.applyForAfterSales(Long.parseLong(userId), storeParam);
+            }
+        } catch (Exception e) {
+            // 商城订单不存在,继续尝试直播订单
+        }
+        
+        // 尝试查询直播订单
+        com.fs.live.domain.LiveOrder liveOrder = liveOrderService.selectLiveOrderByOrderCode(param.getOrderCode());
+        if (liveOrder != null) {
+            // 直播订单,调用直播售后服务
+            LiveAfterSalesParam liveParam = new LiveAfterSalesParam();
+            liveParam.setOrderCode(param.getOrderCode());
+            liveParam.setServiceType(param.getServiceType());
+            liveParam.setReasons(param.getReasons());
+            liveParam.setExplains(param.getExplains());
+            liveParam.setExplainImg(param.getExplainImg());
+            liveParam.setRefundAmount(param.getRefundAmount());
+            // 转换商品列表
+            if (param.getProductList() != null) {
+                List<com.fs.live.param.LiveAfterSalesProductParam> liveProductList = new ArrayList<>();
+                for (FsStoreAfterSalesProductParam product : param.getProductList()) {
+                    com.fs.live.param.LiveAfterSalesProductParam liveProduct = new com.fs.live.param.LiveAfterSalesProductParam();
+                    liveProduct.setProductId(product.getProductId());
+                    liveProduct.setNum(product.getNum());
+                    liveProductList.add(liveProduct);
+                }
+                liveParam.setProductList(liveProductList);
+            }
+            return liveAfterSalesService.applyForAfterSales(userId, liveParam);
+        }
+        
+        return R.error("订单不存在");
+    }
+
+    @Override
+    public R revokeAfterSales(String userId, MergedAfterSalesRevokeParam param) throws ParseException {
+        if (param.getAfterSalesType() != null && param.getAfterSalesType() == 1) {
+            // 商城售后
+            return storeAfterSalesService.revoke(Long.parseLong(userId), param.getSalesId());
+        } else if (param.getAfterSalesType() != null && param.getAfterSalesType() == 2) {
+            // 直播售后
+            LiveAfterSalesRevokeParam liveParam = new LiveAfterSalesRevokeParam();
+            liveParam.setId(param.getSalesId());
+            return liveAfterSalesService.revoke(userId, liveParam);
+        }
+        return R.error("售后类型错误");
+    }
+
+    @Override
+    public R addDelivery(MergedAfterSalesDeliveryParam param) {
+        if (param.getAfterSalesType() != null && param.getAfterSalesType() == 1) {
+            // 商城售后
+            FsStoreAfterSalesDeliveryParam storeParam = new FsStoreAfterSalesDeliveryParam();
+            storeParam.setUserId(param.getUserId());
+            storeParam.setSalesId(param.getSalesId());
+            storeParam.setDeliverySn(param.getDeliverySn());
+            storeParam.setDeliveryName(param.getDeliveryName());
+            return storeAfterSalesService.addDelivery(storeParam);
+        } else if (param.getAfterSalesType() != null && param.getAfterSalesType() == 2) {
+            // 直播售后
+            LiveAfterSalesDeliveryParam liveParam = new LiveAfterSalesDeliveryParam();
+            liveParam.setUserId(param.getUserId());
+            liveParam.setId(param.getSalesId());
+            liveParam.setDeliverySn(param.getDeliverySn());
+            liveParam.setDeliveryName(param.getDeliveryName());
+            return liveAfterSalesService.addDelivery(liveParam);
+        }
+        return R.error("售后类型错误");
+    }
+
+    @Override
+    public MergedAfterSalesVO selectMergedAfterSalesById(Long salesId, Integer afterSalesType) {
+        MergedAfterSalesVO vo = new MergedAfterSalesVO();
+        
+        if (afterSalesType != null && afterSalesType == 1) {
+            // 商城售后
+            FsStoreAfterSalesScrm storeAfterSales = storeAfterSalesService.selectFsStoreAfterSalesById(salesId);
+            if (storeAfterSales != null) {
+                BeanUtils.copyProperties(storeAfterSales, vo);
+                vo.setAfterSalesType(1);
+                vo.setAfterSalesTypeName("商城售后");
+                vo.setOrderCode(storeAfterSales.getOrderCode());
+                
+                // 填充商品列表
+                FsStoreAfterSalesItemScrm itemParam = new FsStoreAfterSalesItemScrm();
+                itemParam.setStoreAfterSalesId(salesId);
+                List<FsStoreAfterSalesItemScrm> items = storeAfterSalesItemService.selectFsStoreAfterSalesItemList(itemParam);
+                vo.setItems(items);
+            }
+        } else if (afterSalesType != null && afterSalesType == 2) {
+            // 直播售后
+            LiveAfterSales liveAfterSales = liveAfterSalesService.selectLiveAfterSalesById(salesId);
+            if (liveAfterSales != null) {
+                BeanUtils.copyProperties(liveAfterSales, vo);
+                vo.setAfterSalesType(2);
+                vo.setAfterSalesTypeName("直播售后");
+                
+                // 查询订单号
+                com.fs.live.domain.LiveOrder liveOrder = liveOrderService.selectLiveOrderByOrderId(String.valueOf(liveAfterSales.getOrderId()));
+                if (liveOrder != null) {
+                    vo.setOrderCode(liveOrder.getOrderCode());
+                }
+                
+                // 填充商品列表
+                List<LiveAfterSalesItem> items = liveAfterSalesItemService.selectLiveAfterSalesItemByAfterId(salesId);
+                vo.setItems(items);
+            }
+        }
+        
+        return vo;
+    }
+
+    @Override
+    public R deleteOrder(String userId, MergedOrderDeleteParam param) {
+        Long orderId = param.getOrderId();
+        Integer orderType = param.getOrderType();
+        
+        if (orderType == null) {
+            return R.error("订单类型不能为空");
+        }
+        
+        if (orderType == 1) {
+            // 商城订单
+            com.fs.hisStore.domain.FsStoreOrderScrm storeOrder = storeOrderService.selectFsStoreOrderById(orderId);
+            if (storeOrder == null) {
+                return R.error("订单不存在");
+            }
+            // 检查订单是否属于当前用户
+            if (!storeOrder.getUserId().equals(Long.parseLong(userId))) {
+                return R.error("无权删除该订单");
+            }
+            // 逻辑删除:设置 isDel = 1
+            storeOrder.setIsDel(1);
+            int result = storeOrderService.updateFsStoreOrder(storeOrder);
+            if (result > 0) {
+                return R.ok("删除成功");
+            } else {
+                return R.error("删除失败");
+            }
+        } else if (orderType == 2) {
+            // 直播订单
+            com.fs.live.domain.LiveOrder liveOrder = liveOrderService.selectLiveOrderByOrderId(String.valueOf(orderId));
+            if (liveOrder == null) {
+                return R.error("订单不存在");
+            }
+            // 检查订单是否属于当前用户
+            if (!liveOrder.getUserId().equals(userId)) {
+                return R.error("无权删除该订单");
+            }
+            // 逻辑删除:设置 isDel = "1"
+            liveOrder.setIsDel("1");
+            int result = liveOrderService.updateLiveOrder(liveOrder);
+            if (result > 0) {
+                return R.ok("删除成功");
+            } else {
+                return R.error("删除失败");
+            }
+        } else {
+            return R.error("订单类型错误");
+        }
+    }
 }
 

+ 103 - 0
fs-service/src/main/java/com/fs/hisStore/vo/MergedAfterSalesVO.java

@@ -0,0 +1,103 @@
+package com.fs.hisStore.vo;
+
+import com.fs.common.annotation.Excel;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 合并售后VO
+ *
+ * @author fs
+ * @date 2025-01-XX
+ */
+@Data
+public class MergedAfterSalesVO implements Serializable
+{
+    /** 售后ID */
+    @Excel(name = "售后ID")
+    private Long id;
+
+    /** 订单号 */
+    @Excel(name = "订单号")
+    private String orderCode;
+
+    /** 退款金额 */
+    @Excel(name = "退款金额")
+    private BigDecimal refundAmount;
+
+    /** 服务类型0仅退款1退货退款 */
+    @Excel(name = "服务类型0仅退款1退货退款")
+    private Integer serviceType;
+
+    /** 申请原因 */
+    @Excel(name = "申请原因")
+    private String reasons;
+
+    /** 说明 */
+    @Excel(name = "说明")
+    private String explains;
+
+    /** 说明图片->多个用逗号分割 */
+    @Excel(name = "说明图片->多个用逗号分割")
+    private String explainImg;
+
+    /** 物流公司编码 */
+    @Excel(name = "物流公司编码")
+    private String shipperCode;
+
+    /** 物流单号 */
+    @Excel(name = "物流单号")
+    private String deliverySn;
+
+    /** 物流名称 */
+    @Excel(name = "物流名称")
+    private String deliveryName;
+
+    /** 状态 0已提交等待平台审核 1平台已审核 等待用户发货/退款 2 用户已发货 3退款成功 */
+    @Excel(name = "状态 0已提交等待平台审核 1平台已审核 等待用户发货/退款 2 用户已发货 3退款成功")
+    private Integer status;
+
+    /** 售后状态-0正常1用户取消2商家拒绝 */
+    @Excel(name = "售后状态-0正常1用户取消2商家拒绝")
+    private Integer salesStatus;
+
+    @Excel(name = "订单当前状态")
+    private Integer orderStatus;
+
+    /** 逻辑删除 */
+    @Excel(name = "逻辑删除")
+    private Integer isDel;
+
+    /** 用户id */
+    @Excel(name = "用户id")
+    private Long userId;
+
+    /** 商家收货人 */
+    @Excel(name = "商家收货人")
+    private String consignee;
+
+    /** 商家手机号 */
+    @Excel(name = "商家手机号")
+    private String phoneNumber;
+
+    /** 商家地址 */
+    @Excel(name = "商家地址")
+    private String address;
+
+    private Integer isPackage;
+
+    private String packageJson;
+
+    /** 售后类型 1商城售后 2直播售后 */
+    private Integer afterSalesType;
+
+    /** 售后类型名称 */
+    private String afterSalesTypeName;
+
+    /** 售后商品列表 */
+    private List<?> items;
+}
+

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

@@ -30,6 +30,8 @@ public class OpenImMsgDTO {
         private String data;
         private String description;
         private String extension;
+        private String sourceUrl;
+        private Integer duration;
     }
     @Data
     public static class ImData{

+ 6 - 0
fs-service/src/main/java/com/fs/live/domain/Live.java

@@ -3,14 +3,17 @@ package com.fs.live.domain;
 
 
 
+import com.baomidou.mybatisplus.annotation.TableField;
 import com.fasterxml.jackson.annotation.JsonFormat;
 import com.fs.common.annotation.Excel;
 import com.fs.common.core.domain.BaseEntity;
+import com.fs.live.vo.LiveTagItemVO;
 import lombok.Data;
 import lombok.EqualsAndHashCode;
 
 import java.time.LocalDateTime;
 import java.util.Date;
+import java.util.List;
 
 /**
  * 直播对象 live
@@ -127,4 +130,7 @@ public class   Live extends BaseEntity {
     private Long videoFileSize;
     private Long videoDuration;
     private Integer globalVisible;
+
+    @TableField(exist = false)
+    private List<LiveTagItemVO> liveTagList;
 }

+ 58 - 0
fs-service/src/main/java/com/fs/live/domain/LiveCompletionPointsRecord.java

@@ -0,0 +1,58 @@
+package com.fs.live.domain;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+/**
+ * 直播完课积分领取记录对象 live_completion_points_record
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class LiveCompletionPointsRecord extends BaseEntity {
+    
+    private static final long serialVersionUID = 1L;
+
+    /** 主键ID */
+    private Long id;
+
+    /** 直播ID */
+    private Long liveId;
+
+    /** 用户ID */
+    private Long userId;
+
+    /** 观看时长(秒) */
+    private Long watchDuration;
+
+    /** 视频总时长(秒) */
+    private Long videoDuration;
+
+    /** 完课比例(%) */
+    private BigDecimal completionRate;
+
+    /** 连续完课天数 */
+    private Integer continuousDays;
+
+    /** 获得积分 */
+    private Integer pointsAwarded;
+
+    /** 上次完课日期 */
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    private Date lastCompletionDate;
+
+    /** 本次完课日期 */
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    private Date currentCompletionDate;
+
+    /** 领取状态 0-未领取 1-已领取 */
+    private Integer receiveStatus;
+
+    /** 领取时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date receiveTime;
+}

+ 4 - 0
fs-service/src/main/java/com/fs/live/domain/LiveOrder.java

@@ -352,4 +352,8 @@ public class LiveOrder extends BaseEntity {
     @TableField(exist = false)
     private Long attrValueId;
 
+    /** 小程序AppId */
+    @Excel(name = "小程序AppId")
+    private String appId;
+
 }

+ 66 - 0
fs-service/src/main/java/com/fs/live/domain/LiveTagConfig.java

@@ -0,0 +1,66 @@
+package com.fs.live.domain;
+
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.fs.common.annotation.Excel;
+import lombok.Data;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 直播间标签配置对象 live_tag_config
+ *
+ * @author fs
+ * @date 2025-12-13
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class LiveTagConfig extends BaseEntity{
+
+    /** $column.columnComment */
+    private Long id;
+
+    /** 直播间id */
+    @Excel(name = "直播间id")
+    private Long liveId;
+
+    /** 企微主体id */
+    @Excel(name = "企微主体id")
+    private String corpId;
+
+    /** 公司id */
+    @Excel(name = "公司id")
+    private Long companyId;
+
+    /** 标记标签行为类型,数据字典live_mark_type */
+    @Excel(name = "标记标签行为类型,数据字典live_mark_type")
+    private Long markType;
+
+    /** 企微标签id */
+    @Excel(name = "企微标签id")
+    private Long qwTagId;
+
+    /** 企微标签真实id */
+    @Excel(name = "企微标签真实id")
+    private String qwTagRealId;
+
+    @Excel(name = "企微标签名称")
+    private String  qwTagName;
+
+    /** 创建人id */
+    @Excel(name = "创建人id")
+    private Long createUserId;
+
+    /** 创建人 */
+    @Excel(name = "创建人")
+    private String createUserName;
+
+    /** 更新人id */
+    @Excel(name = "更新人id")
+    private Long updateUserId;
+
+    /** 更新人 */
+    @Excel(name = "更新人")
+    private String updateUserName;
+
+
+}

+ 1 - 1
fs-service/src/main/java/com/fs/live/domain/LiveWatchConfig.java

@@ -27,7 +27,7 @@ public class LiveWatchConfig extends BaseEntity{
     private Boolean enabled;
 
     /** 参与条件 1达到指定观看时长 */
-    @Excel(name = "参与条件 1达到指定观看时长")
+    @Excel(name = "参与条件 1达到指定观看时长 2观看比例达到指定积分")
     private Long participateCondition;
 
     /** 观看时长 */

+ 89 - 0
fs-service/src/main/java/com/fs/live/domain/LiveWatchLog.java

@@ -0,0 +1,89 @@
+package com.fs.live.domain;
+
+import java.util.Date;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.fs.common.annotation.Excel;
+import lombok.Data;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 直播看课记录对象 live_watch_log
+ *
+ * @author fs
+ * @date 2025-12-12
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class LiveWatchLog extends BaseEntity{
+
+    /** 日志id */
+    private Long logId;
+
+    /** 用户userId */
+    @Excel(name = "用户userId")
+    private Long userId;
+
+    /** 直播间id */
+    @Excel(name = "直播间id")
+    private Long liveId;
+
+    /** 记录类型 1看课中 2完课 3待看课 4看课中断 */
+    @Excel(name = "记录类型 1看课中 2完课 3待看课 4看课中断")
+    private Integer logType;
+
+    /** 外部联系人id */
+    @Excel(name = "外部联系人id")
+    private Long externalContactId;
+
+    /** 销售id */
+    @Excel(name = "销售id")
+    private Long companyUserId;
+
+    /** 公司id */
+    @Excel(name = "公司id")
+    private Long companyId;
+
+    /** 完课时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    @Excel(name = "完课时间", width = 30, dateFormat = "yyyy-MM-dd")
+    private Date finishTime;
+
+    /** sop最后创建时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    @Excel(name = "sop最后创建时间", width = 30, dateFormat = "yyyy-MM-dd")
+    private Date sopCreateTime;
+
+    /** 发送小程序appid */
+    @Excel(name = "发送小程序appid")
+    private String sendAppId;
+
+    /** 日志创建来源:1、个人sop,2、群聊sop,3、一键群发 */
+    @Excel(name = "日志创建来源:1、个人sop,2、群聊sop,3、一键群发")
+    private Integer logSource;
+
+    /** 分享人企微id */
+    @Excel(name = "分享人企微id")
+    private String qwUserId;
+    /**
+     * 查看直播类型:1、直播,2、回放
+     */
+    private Integer watchType;
+
+    /**
+     * 企微主体id
+     */
+    private String corpId;
+
+    /**
+     * 直播购买
+     */
+    private Integer liveBuy;
+
+    /**
+     * 回放购买
+     */
+    private Integer replayBuy;
+
+}

+ 52 - 0
fs-service/src/main/java/com/fs/live/mapper/LiveCompletionPointsRecordMapper.java

@@ -0,0 +1,52 @@
+package com.fs.live.mapper;
+
+import com.fs.live.domain.LiveCompletionPointsRecord;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 直播完课积分记录Mapper接口
+ */
+public interface LiveCompletionPointsRecordMapper {
+
+    /**
+     * 插入完课积分记录
+     */
+    int insertRecord(LiveCompletionPointsRecord record);
+
+    /**
+     * 更新完课积分记录
+     */
+    int updateRecord(LiveCompletionPointsRecord record);
+
+    /**
+     * 查询用户某天的完课记录
+     */
+    LiveCompletionPointsRecord selectByUserAndDate(@Param("liveId") Long liveId, 
+                                                     @Param("userId") Long userId, 
+                                                     @Param("currentDate") Date currentDate);
+
+    /**
+     * 查询用户最近一次完课记录(不限直播间,用于计算连续天数)
+     */
+    LiveCompletionPointsRecord selectLatestByUser(@Param("userId") Long userId);
+
+    /**
+     * 查询用户未领取的完课记录列表
+     */
+    List<LiveCompletionPointsRecord> selectUnreceivedByUser(@Param("liveId") Long liveId, 
+                                                             @Param("userId") Long userId);
+
+    /**
+     * 查询用户的完课积分领取记录列表
+     */
+    List<LiveCompletionPointsRecord> selectRecordsByUser(@Param("liveId") Long liveId, 
+                                                          @Param("userId") Long userId);
+
+    /**
+     * 根据ID查询
+     */
+    LiveCompletionPointsRecord selectById(@Param("id") Long id);
+}

+ 67 - 0
fs-service/src/main/java/com/fs/live/mapper/LiveTagConfigMapper.java

@@ -0,0 +1,67 @@
+package com.fs.live.mapper;
+
+import java.util.List;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.live.domain.LiveTagConfig;
+import com.fs.live.vo.LiveTagItemVO;
+import org.apache.ibatis.annotations.Param;
+
+/**
+ * 直播间标签配置Mapper接口
+ * 
+ * @author fs
+ * @date 2025-12-13
+ */
+public interface LiveTagConfigMapper extends BaseMapper<LiveTagConfig>{
+    /**
+     * 查询直播间标签配置
+     * 
+     * @param id 直播间标签配置主键
+     * @return 直播间标签配置
+     */
+    LiveTagConfig selectLiveTagConfigById(Long id);
+
+    /**
+     * 查询直播间标签配置列表
+     * 
+     * @param liveTagConfig 直播间标签配置
+     * @return 直播间标签配置集合
+     */
+    List<LiveTagConfig> selectLiveTagConfigList(LiveTagConfig liveTagConfig);
+
+    /**
+     * 新增直播间标签配置
+     * 
+     * @param liveTagConfig 直播间标签配置
+     * @return 结果
+     */
+    int insertLiveTagConfig(LiveTagConfig liveTagConfig);
+
+    /**
+     * 修改直播间标签配置
+     * 
+     * @param liveTagConfig 直播间标签配置
+     * @return 结果
+     */
+    int updateLiveTagConfig(LiveTagConfig liveTagConfig);
+
+    /**
+     * 删除直播间标签配置
+     * 
+     * @param id 直播间标签配置主键
+     * @return 结果
+     */
+    int deleteLiveTagConfigById(Long id);
+
+    /**
+     * 批量删除直播间标签配置
+     * 
+     * @param ids 需要删除的数据主键集合
+     * @return 结果
+     */
+    int deleteLiveTagConfigByIds(Long[] ids);
+
+    int deleteByLiveId(@Param("liveId") Long liveId);
+
+    List<LiveTagItemVO> getLiveTagListByliveId(@Param("liveId") Long liveId);
+}

+ 1 - 1
fs-service/src/main/java/com/fs/live/mapper/LiveVideoMapper.java

@@ -92,6 +92,6 @@ public interface LiveVideoMapper
     List<LiveVideo> selectByAll();
 
 
-    @Update("update live_video set finish_status = 1 where video_url = #{fileName}")
+    @Update("update live_video set finish_status = 1 where video_url like concat('%',#{fileName})")
     void updateFinishStatus(@Param("fileName") String fileName);
 }

+ 74 - 0
fs-service/src/main/java/com/fs/live/mapper/LiveWatchLogMapper.java

@@ -0,0 +1,74 @@
+package com.fs.live.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.common.annotation.DataSource;
+import com.fs.common.enums.DataSourceType;
+import com.fs.live.domain.LiveWatchLog;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 直播间观看用户Mapper接口
+ * 
+ * @author fs
+ * @date 2025-01-18
+ */
+public interface LiveWatchLogMapper extends BaseMapper<LiveWatchLog> {
+    /**
+     * 查询直播看课记录
+     *
+     * @param logId 直播看课记录主键
+     * @return 直播看课记录
+     */
+    LiveWatchLog selectLiveWatchLogByLogId(Long logId);
+
+    /**
+     * 查询直播看课记录列表
+     *
+     * @param liveWatchLog 直播看课记录
+     * @return 直播看课记录集合
+     */
+    List<LiveWatchLog> selectLiveWatchLogList(LiveWatchLog liveWatchLog);
+
+    /**
+     * 新增直播看课记录
+     *
+     * @param liveWatchLog 直播看课记录
+     * @return 结果
+     */
+    int insertLiveWatchLog(LiveWatchLog liveWatchLog);
+
+    /**
+     * 修改直播看课记录
+     *
+     * @param liveWatchLog 直播看课记录
+     * @return 结果
+     */
+    int updateLiveWatchLog(LiveWatchLog liveWatchLog);
+
+    /**
+     * 删除直播看课记录
+     *
+     * @param logId 直播看课记录主键
+     * @return 结果
+     */
+    int deleteLiveWatchLogByLogId(Long logId);
+
+    /**
+     * 批量删除直播看课记录
+     *
+     * @param logIds 需要删除的数据主键集合
+     * @return 结果
+     */
+    int deleteLiveWatchLogByLogIds(Long[] logIds);
+
+    void insertLiveWatchLogBatch(@Param("liveWatchLogs")List<LiveWatchLog> liveWatchLogs);
+
+    int updateLiveWatchLogCondition(@Param("liveWatchLog") LiveWatchLog liveWatchLog);
+
+    LiveWatchLog selectOneLogByLiveIdAndQwUserIdAndExternalId(@Param("liveId")Long liveId,@Param("qwUserId")String qwUserId,@Param("externalContactId")Long externalContactId);
+
+    List<LiveWatchLog> selectLiveWatchLogByLiveId(@Param("liveId")Long liveId);
+
+}

+ 6 - 0
fs-service/src/main/java/com/fs/live/mapper/LiveWatchUserMapper.java

@@ -3,6 +3,7 @@ package com.fs.live.mapper;
 
 import com.fs.live.domain.LiveWatchUser;
 import com.fs.live.vo.LiveDashBoardDataVo;
+import com.fs.live.vo.LiveWatchUserEntry;
 import com.fs.live.vo.LiveWatchUserStatistics;
 import com.fs.live.vo.LiveWatchUserVO;
 import org.apache.ibatis.annotations.Param;
@@ -143,4 +144,9 @@ public interface LiveWatchUserMapper {
 
     @Select("select * from live_watch_user where live_id = #{liveId}")
     List<LiveWatchUser> selectLiveWatchUserListByLiveId(@Param("liveId") Long liveId);
+
+    @Select("select lufe.company_id,lufe.company_user_id,lwu.* from live_watch_user lwu" +
+            " left join live_user_first_entry lufe on lwu.live_id = lufe.live_id and lwu.user_id = lufe.user_id" +
+            " where lwu.live_id = #{liveId} and lwu.user_id = #{userId} and lwu.live_flag = #{liveFlag} and lwu.replay_flag = #{replayFlag} limit 1 ")
+    LiveWatchUserEntry selectLiveWatchAndCompanyUserByFlag(@Param("liveId") Long liveId,@Param("userId") Long userId,@Param("liveFlag") Integer liveFlag,@Param("replayFlag") Integer replayFlag);
 }

+ 59 - 0
fs-service/src/main/java/com/fs/live/param/LiveIsAddKfParam.java

@@ -0,0 +1,59 @@
+package com.fs.live.param;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotNull;
+import java.io.Serializable;
+
+/**
+ * @author MixLiu
+ * @date 2025/12/12 下午1:32)
+ */
+
+@Data
+public class LiveIsAddKfParam implements Serializable {
+
+    /**
+     * 企微员工 id
+     */
+    @NotNull(message = "企微userId")
+    private String qwUserId;
+
+    /**
+     * 直播id
+     */
+    @NotNull(message = "直播id")
+    private Long liveId;
+
+    /**
+     * 登录的小程序id
+     */
+    private Long userId;
+
+    /**
+     * 企微主体id
+     */
+    private String corpId;
+
+    /**
+     *   companyUserId
+     */
+    @NotNull(message = "客服参数不能为空")
+    private Long companyUserId;
+
+    /**
+     * 公司id
+     */
+    @NotNull(message = "经销商参数参数不能为空")
+    private Long companyId;
+
+    /**
+     * 外部联系人id
+     */
+    private Long qwExternalId;
+
+    /**
+     * 群聊id
+     */
+    private String chatId;
+}

+ 43 - 0
fs-service/src/main/java/com/fs/live/service/ILiveCompletionPointsRecordService.java

@@ -0,0 +1,43 @@
+package com.fs.live.service;
+
+import com.fs.live.domain.LiveCompletionPointsRecord;
+
+import java.util.List;
+
+/**
+ * 直播完课积分记录Service接口
+ */
+public interface ILiveCompletionPointsRecordService {
+
+    /**
+     * 检查并创建完课记录(定时任务调用)
+     * @param liveId 直播ID
+     * @param userId 用户ID
+     * @param watchDuration 观看时长(秒)
+     */
+    void checkAndCreateCompletionRecord(Long liveId, Long userId, Long watchDuration);
+
+    /**
+     * 用户领取完课积分
+     * @param recordId 完课记录ID
+     * @param userId 用户ID
+     * @return 领取结果
+     */
+    LiveCompletionPointsRecord receiveCompletionPoints(Long recordId, Long userId);
+
+    /**
+     * 获取用户完课状态
+     * @param liveId 直播ID
+     * @param userId 用户ID
+     * @return 未领取的完课记录列表
+     */
+    List<LiveCompletionPointsRecord> getUserUnreceivedRecords(Long liveId, Long userId);
+
+    /**
+     * 查询用户积分领取记录
+     * @param liveId 直播ID
+     * @param userId 用户ID
+     * @return 完课记录列表
+     */
+    List<LiveCompletionPointsRecord> getUserRecords(Long liveId, Long userId);
+}

+ 7 - 0
fs-service/src/main/java/com/fs/live/service/ILiveService.java

@@ -2,6 +2,7 @@ package com.fs.live.service;
 
 
 import com.fs.common.core.page.PageRequest;
+import com.fs.company.vo.CompanyVO;
 import com.fs.live.param.LiveNotifyParam;
 import com.fs.live.vo.LiveVo;
 import com.fs.common.core.domain.R;
@@ -197,4 +198,10 @@ public interface ILiveService
     List<Live> listToLiveNoEnd(Live live);
 
     Live selectLiveDbByLiveId(Long liveId);
+
+    /**
+     * 获取公司下拉列表
+     * @return
+     */
+    List<CompanyVO> getCompanyDropList();
 }

+ 61 - 0
fs-service/src/main/java/com/fs/live/service/ILiveWatchLogService.java

@@ -0,0 +1,61 @@
+package com.fs.live.service;
+
+import java.util.List;
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.fs.live.domain.LiveWatchLog;
+
+/**
+ * 直播看课记录Service接口
+ * 
+ * @author fs
+ * @date 2025-12-12
+ */
+public interface ILiveWatchLogService extends IService<LiveWatchLog>{
+    /**
+     * 查询直播看课记录
+     * 
+     * @param logId 直播看课记录主键
+     * @return 直播看课记录
+     */
+    LiveWatchLog selectLiveWatchLogByLogId(Long logId);
+
+    /**
+     * 查询直播看课记录列表
+     * 
+     * @param liveWatchLog 直播看课记录
+     * @return 直播看课记录集合
+     */
+    List<LiveWatchLog> selectLiveWatchLogList(LiveWatchLog liveWatchLog);
+
+    /**
+     * 新增直播看课记录
+     * 
+     * @param liveWatchLog 直播看课记录
+     * @return 结果
+     */
+    int insertLiveWatchLog(LiveWatchLog liveWatchLog);
+
+    /**
+     * 修改直播看课记录
+     * 
+     * @param liveWatchLog 直播看课记录
+     * @return 结果
+     */
+    int updateLiveWatchLog(LiveWatchLog liveWatchLog);
+
+    /**
+     * 批量删除直播看课记录
+     * 
+     * @param logIds 需要删除的直播看课记录主键集合
+     * @return 结果
+     */
+    int deleteLiveWatchLogByLogIds(Long[] logIds);
+
+    /**
+     * 删除直播看课记录信息
+     * 
+     * @param logId 直播看课记录主键
+     * @return 结果
+     */
+    int deleteLiveWatchLogByLogId(Long logId);
+}

+ 31 - 0
fs-service/src/main/java/com/fs/live/service/ILiveWatchUserService.java

@@ -4,6 +4,8 @@ package com.fs.live.service;
 import com.fs.common.core.domain.R;
 import com.fs.hisStore.domain.FsUserScrm;
 import com.fs.live.domain.LiveWatchUser;
+import com.fs.live.param.LiveIsAddKfParam;
+import com.fs.live.vo.LiveWatchUserEntry;
 import com.fs.live.vo.LiveWatchUserVO;
 
 import java.util.Date;
@@ -126,4 +128,33 @@ public interface ILiveWatchUserService {
     void updateSingleVisible(long liveId, Integer status,long userId);
 
     LiveWatchUser selectLiveWatchUserByFlag(Long liveId, Long userId, Integer liveFlag, Integer replayFlag);
+
+    R liveIsAddKf(LiveIsAddKfParam param);
+
+    LiveWatchUserEntry selectLiveWatchAndCompanyUserByFlag(Long liveId, Long userId, Integer liveFlag, Integer replayFlag);
+
+    void updateLiveWatchUserEntry(LiveWatchUserEntry liveWatchUser);
+
+    /**
+     * 根据用户直播看课记录来打标签
+     */
+    void qwTagMarkByLiveWatchLog(Long liveId);
+
+    /**
+     * 更新用户观看时长(心跳时调用)
+     * @param liveId 直播间ID
+     * @param userId 用户ID
+     * @param liveFlag 直播标记
+     * @param replayFlag 回放标记
+     * @param duration 观看时长(秒)
+     */
+    void updateWatchDuration(Long liveId, Long userId, Integer liveFlag, Integer replayFlag, Long duration);
+
+    /**
+     * 获取用户在某直播间的总观看时长(直播 + 回放)
+     * @param liveId 直播间ID
+     * @param userId 用户ID
+     * @return 总观看时长(秒)
+     */
+    Long getTotalWatchDuration(Long liveId, Long userId);
 }

+ 349 - 0
fs-service/src/main/java/com/fs/live/service/impl/LiveCompletionPointsRecordServiceImpl.java

@@ -0,0 +1,349 @@
+package com.fs.live.service.impl;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.fs.common.exception.base.BaseException;
+import com.fs.his.domain.FsUser;
+import com.fs.his.domain.FsUserIntegralLogs;
+import com.fs.his.mapper.FsUserIntegralLogsMapper;
+import com.fs.his.mapper.FsUserMapper;
+import com.fs.live.domain.Live;
+import com.fs.live.domain.LiveCompletionPointsRecord;
+import com.fs.live.mapper.LiveCompletionPointsRecordMapper;
+import com.fs.live.service.ILiveCompletionPointsRecordService;
+import com.fs.live.service.ILiveService;
+import com.fs.live.service.ILiveWatchUserService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.time.temporal.ChronoUnit;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 直播完课积分记录Service业务层处理
+ */
+@Slf4j
+@Service
+public class LiveCompletionPointsRecordServiceImpl implements ILiveCompletionPointsRecordService {
+
+    @Autowired
+    private LiveCompletionPointsRecordMapper recordMapper;
+
+    @Autowired
+    private ILiveService liveService;
+
+    @Autowired
+    private FsUserMapper fsUserMapper;
+
+    @Autowired
+    private FsUserIntegralLogsMapper fsUserIntegralLogsMapper;
+
+    @Autowired
+    private ILiveWatchUserService liveWatchUserService;
+
+
+    /**
+     * 检查并创建完课记录(由定时任务调用)
+     * @param liveId 直播间ID
+     * @param userId 用户ID
+     * @param watchDuration 观看时长(可为null,为null时从数据库自动累计直播+回放时长)
+     */
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void checkAndCreateCompletionRecord(Long liveId, Long userId, Long watchDuration) {
+        try {
+            // 1. 获取直播信息和配置
+            Live live = liveService.selectLiveByLiveId(liveId);
+            if (live == null) {
+                log.warn("直播间不存在, liveId={}", liveId);
+                return;
+            }
+
+            // 2. 从数据库获取完课积分配置
+            CompletionPointsConfig config = getCompletionPointsConfig(live);
+            
+            // 检查是否开启完课积分功能
+            if (!config.isEnabled()) {
+                log.debug("直播间未开启完课积分功能, liveId={}", liveId);
+                return;
+            }
+            
+            // 检查配置完整性
+            Integer completionRate = config.getCompletionRate();
+            int[] pointsConfig = config.getPointsConfig();
+            
+            if (completionRate == null || pointsConfig == null || pointsConfig.length == 0) {
+                log.warn("完课积分配置不完整, liveId={}, completionRate={}, pointsConfig={}", 
+                        liveId, completionRate, pointsConfig);
+                return;
+            }
+
+            // 3. 获取观看时长(如果为null,则从数据库累计直播+回放时长)
+            Long actualWatchDuration = watchDuration;
+            if (actualWatchDuration == null) {
+                // 自动累加直播和回放的观看时长
+                actualWatchDuration = liveWatchUserService.getTotalWatchDuration(liveId, userId);
+                log.debug("自动累计观看时长: liveId={}, userId={}, totalDuration={}",
+                        liveId, userId, actualWatchDuration);
+            }
+
+            if (actualWatchDuration == null || actualWatchDuration <= 0) {
+                log.debug("观看时长为0, liveId={}, userId={}", liveId, userId);
+                return;
+            }
+
+            // 4. 获取视频总时长(秒)
+            Long videoDuration = live.getDuration();
+            if (videoDuration == null || videoDuration <= 0) {
+                log.warn("直播间视频时长无效, liveId={}, duration={}", liveId, videoDuration);
+                return;
+            }
+
+            // 5. 计算完课比例
+            BigDecimal watchRate = BigDecimal.valueOf(actualWatchDuration)
+                    .multiply(BigDecimal.valueOf(100))
+                    .divide(BigDecimal.valueOf(videoDuration), 2, RoundingMode.HALF_UP);
+
+            // 6. 判断是否达到完课标准
+            if (watchRate.compareTo(BigDecimal.valueOf(completionRate)) < 0) {
+                log.debug("观看时长未达到完课标准, liveId={}, userId={}, watchDuration={}, videoDuration={}, watchRate={}%, required={}%",
+                        liveId, userId, actualWatchDuration, videoDuration, watchRate, completionRate);
+                return;
+            }
+
+            // 7. 检查今天是否已有完课记录
+            LocalDate today = LocalDate.now();
+            Date currentDate = Date.from(today.atStartOfDay(ZoneId.systemDefault()).toInstant());
+
+            LiveCompletionPointsRecord todayRecord = recordMapper.selectByUserAndDate(liveId, userId, currentDate);
+            if (todayRecord != null) {
+                log.debug("今天已有完课记录, liveId={}, userId={}", liveId, userId);
+                return;
+            }
+
+            // 7. 查询最近一次完课记录(不限直播间),计算连续天数
+            LiveCompletionPointsRecord latestRecord = recordMapper.selectLatestByUser(userId);
+            int continuousDays = 1;
+
+            if (latestRecord != null) {
+                LocalDate lastDate = latestRecord.getCurrentCompletionDate()
+                        .toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
+
+                long daysBetween = ChronoUnit.DAYS.between(lastDate, today);
+
+                if (daysBetween == 1) {
+                    // 昨天完课了,连续天数+1
+                    continuousDays = latestRecord.getContinuousDays() + 1;
+                } else if (daysBetween > 1) {
+                    // 中断了,重新开始
+                    continuousDays = 1;
+                } else {
+                    // daysBetween == 0 说明今天已经有记录了(理论上不会进入这里,因为前面已经检查过)
+                    log.warn("异常情况: 今天已有完课记录, liveId={}, userId={}", liveId, userId);
+                    return;
+                }
+            }
+
+            // 8. 计算积分
+            int points = calculatePoints(continuousDays, pointsConfig);
+
+            // 9. 创建完课记录
+            LiveCompletionPointsRecord record = new LiveCompletionPointsRecord();
+            record.setLiveId(liveId);
+            record.setUserId(userId);
+            record.setWatchDuration(actualWatchDuration);
+            record.setVideoDuration(videoDuration);
+            record.setCompletionRate(watchRate);
+            record.setContinuousDays(continuousDays);
+            record.setPointsAwarded(points);
+            record.setCurrentCompletionDate(currentDate);
+            record.setReceiveStatus(0); // 未领取
+
+            if (latestRecord != null) {
+                record.setLastCompletionDate(latestRecord.getCurrentCompletionDate());
+            }
+
+            recordMapper.insertRecord(record);
+
+            log.info("创建完课记录成功, liveId={}, userId={}, watchDuration={}, videoDuration={}, watchRate={}%, continuousDays={}, points={}",
+                    liveId, userId, actualWatchDuration, videoDuration, watchRate, continuousDays, points);
+
+        } catch (Exception e) {
+            log.error("检查并创建完课记录失败, liveId={}, userId={}", liveId, userId, e);
+            throw e;
+        }
+    }
+
+    /**
+     * 用户领取完课积分
+     */
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public LiveCompletionPointsRecord receiveCompletionPoints(Long recordId, Long userId) {
+        // 1. 查询完课记录
+        LiveCompletionPointsRecord record = recordMapper.selectById(recordId);
+        if (record == null) {
+            throw new BaseException("完课记录不存在");
+        }
+
+        // 2. 校验用户
+        if (!record.getUserId().equals(userId)) {
+            throw new BaseException("无权领取该完课积分");
+        }
+
+        // 3. 校验领取状态
+        if (record.getReceiveStatus() == 1) {
+            throw new BaseException("该完课积分已领取");
+        }
+
+        // 4. 更新用户积分
+        FsUser user = fsUserMapper.selectFsUserByUserId(userId);
+        if (user == null) {
+            throw new BaseException("用户不存在");
+        }
+
+        Long currentIntegral = user.getIntegral() != null ? user.getIntegral() : 0L;
+        Long newIntegral = currentIntegral + record.getPointsAwarded();
+
+        FsUser updateUser = new FsUser();
+        updateUser.setUserId(userId);
+        updateUser.setIntegral(newIntegral);
+        fsUserMapper.updateFsUser(updateUser);
+
+        // 5. 记录积分变动日志
+        FsUserIntegralLogs integralLog = new FsUserIntegralLogs();
+        integralLog.setUserId(userId);
+        integralLog.setIntegral(Long.valueOf(record.getPointsAwarded()));
+        integralLog.setBalance(newIntegral);
+        integralLog.setLogType(5); // 5-直播完课积分
+        integralLog.setBusinessId("live_completion_" + recordId); // 业务ID:直播完课记录ID
+        integralLog.setBusinessType(5); // 5-直播完课
+        integralLog.setStatus(1);
+        integralLog.setCreateTime(new Date());
+        fsUserIntegralLogsMapper.insertFsUserIntegralLogs(integralLog);
+
+        // 6. 更新完课记录状态
+        LiveCompletionPointsRecord updateRecord = new LiveCompletionPointsRecord();
+        updateRecord.setId(recordId);
+        updateRecord.setReceiveStatus(1);
+        updateRecord.setReceiveTime(new Date());
+        recordMapper.updateRecord(updateRecord);
+
+        // 7. 返回更新后的记录
+        record.setReceiveStatus(1);
+        record.setReceiveTime(new Date());
+
+        log.info("用户领取完课积分成功, userId={}, recordId={}, points={}", userId, recordId, record.getPointsAwarded());
+
+        return record;
+    }
+
+    /**
+     * 获取用户未领取的完课记录
+     */
+    @Override
+    public List<LiveCompletionPointsRecord> getUserUnreceivedRecords(Long liveId, Long userId) {
+        return recordMapper.selectUnreceivedByUser(liveId, userId);
+    }
+
+    /**
+     * 查询用户积分领取记录
+     */
+    @Override
+    public List<LiveCompletionPointsRecord> getUserRecords(Long liveId, Long userId) {
+        return recordMapper.selectRecordsByUser(liveId, userId);
+    }
+
+    /**
+     * 从直播配置中获取完课积分配置
+     */
+    private CompletionPointsConfig getCompletionPointsConfig(Live live) {
+        CompletionPointsConfig config = new CompletionPointsConfig();
+        config.setEnabled(false);
+        config.setCompletionRate(null);
+        config.setPointsConfig(null);
+        
+        String configJson = live.getConfigJson();
+        if (configJson == null || configJson.isEmpty()) {
+            return config;
+        }
+        
+        try {
+            JSONObject jsonConfig = JSON.parseObject(configJson);
+
+            config.setEnabled(jsonConfig.getBooleanValue("enabled"));
+
+            Integer rate = jsonConfig.getInteger("completionRate");
+            if (rate != null && rate > 0 && rate <= 100) {
+                config.setCompletionRate(rate);
+            }
+
+            List<Integer> pointsList = jsonConfig.getObject("pointsConfig", List.class);
+            if (pointsList != null && !pointsList.isEmpty()) {
+                config.setPointsConfig(pointsList.stream().mapToInt(Integer::intValue).toArray());
+            }
+        } catch (Exception e) {
+            log.warn("解析完课积分配置失败, liveId={}, 配置未生效", live.getLiveId(), e);
+        }
+        
+        return config;
+    }
+    
+    /**
+     * 计算积分
+     * 根据连续天数和积分配置计算应得积分
+     * @param continuousDays 连续完课天数
+     * @param pointsConfig 积分配置数组
+     * @return 应得积分
+     */
+    private int calculatePoints(int continuousDays, int[] pointsConfig) {
+        if (continuousDays <= 0) {
+            return pointsConfig[0];
+        }
+        if (continuousDays > pointsConfig.length) {
+            // 超过配置天数,使用最后一天的积分
+            return pointsConfig[pointsConfig.length - 1];
+        }
+        return pointsConfig[continuousDays - 1];
+    }
+    
+    /**
+     * 完课积分配置内部类
+     */
+    private static class CompletionPointsConfig {
+        private boolean enabled;
+        private Integer completionRate;
+        private int[] pointsConfig;
+        
+        public boolean isEnabled() {
+            return enabled;
+        }
+        
+        public void setEnabled(boolean enabled) {
+            this.enabled = enabled;
+        }
+        
+        public Integer getCompletionRate() {
+            return completionRate;
+        }
+        
+        public void setCompletionRate(Integer completionRate) {
+            this.completionRate = completionRate;
+        }
+        
+        public int[] getPointsConfig() {
+            return pointsConfig;
+        }
+        
+        public void setPointsConfig(int[] pointsConfig) {
+            this.pointsConfig = pointsConfig;
+        }
+    }
+}

+ 78 - 9
fs-service/src/main/java/com/fs/live/service/impl/LiveOrderServiceImpl.java

@@ -191,6 +191,9 @@ public class LiveOrderServiceImpl implements ILiveOrderService {
     @Autowired
     private FsStoreProductAttrValueScrmMapper fsStoreProductAttrValueMapper;
 
+    @Autowired
+    private FsStoreProductScrmMapper fsStoreProductScrmMapper;
+
     @Autowired
     private LiveUserLotteryRecordMapper liveUserLotteryRecordMapper;
 
@@ -749,6 +752,7 @@ public class LiveOrderServiceImpl implements ILiveOrderService {
             order.setIsPay("1");
             baseMapper.updateLiveOrder(order);
             try {
+                this.updateLiveWatchLog(order);
                 this.createOmsOrderCall(order);
             } catch (Exception e) {
                 log.error("推送erp失败:{}",e.getMessage());
@@ -767,6 +771,38 @@ public class LiveOrderServiceImpl implements ILiveOrderService {
         }
         return "SUCCESS";
     }
+    @Autowired
+    private ILiveWatchUserService liveWatchUserService;
+    @Autowired
+    private ILiveWatchLogService liveWatchLogService;
+
+    private void updateLiveWatchLog(LiveOrder order) {
+        if (order.getCompanyId() != null && order.getCompanyUserId() != null && order.getCompanyId() > 0 && order.getCompanyUserId() > 0) {
+            Map<String, Integer> liveFlagWithCache = liveWatchUserService.getLiveFlagWithCache(order.getLiveId());
+            if (liveFlagWithCache != null && liveFlagWithCache.containsKey("liveFlag") && 1 == liveFlagWithCache.get("liveFlag")) {
+                try {
+                    LiveWatchLog queryLog = new LiveWatchLog();
+                    queryLog.setLiveId(order.getLiveId());
+                    queryLog.setUserId(Long.valueOf(order.getUserId()));
+                    queryLog.setCompanyId(order.getCompanyId());
+                    queryLog.setCompanyUserId(order.getCompanyUserId());
+
+                    List<LiveWatchLog> logs = liveWatchLogService.selectLiveWatchLogList(queryLog);
+                    if (logs != null && !logs.isEmpty()) {
+                        for (LiveWatchLog log : logs) {
+                            if (log.getLogType() == null || log.getLogType() != 2) {
+                                log.setLiveBuy(1);
+                                liveWatchLogService.updateLiveWatchLog(log);
+                            }
+                        }
+                    }
+                } catch (Exception e) {
+                    log.error("更新 updateLiveWatchLog LiveWatchLog logType 异常(连接时):liveId={}, userId={}, error={}",
+                            order.getLiveId(), order.getUserId(), e.getMessage(), e);
+                }
+            }
+        }
+    }
 
     @Override
     @Transactional(rollbackFor = Throwable.class,propagation = Propagation.REQUIRED)
@@ -1916,12 +1952,23 @@ public class LiveOrderServiceImpl implements ILiveOrderService {
         FsStoreProductScrm fsStoreProduct = fsStoreProductService.selectFsStoreProductById(liveOrder.getProductId());
         LiveGoods goods = liveGoodsMapper.selectLiveGoodsByProductId(liveOrder.getLiveId(), liveOrder.getProductId());
         if(goods == null) return R.error("当前商品不存在");
+        FsStoreProductAttrValueScrm attrValue = null;
+        if (!Objects.isNull(liveOrder.getAttrValueId())) {
+            attrValue = fsStoreProductAttrValueMapper.selectFsStoreProductAttrValueById(liveOrder.getAttrValueId());
+        }
+        if (attrValue != null) {
+            attrValue.setStock(attrValue.getStock() - Integer.parseInt(liveOrder.getTotalNum()));
+            attrValue.setSales(attrValue.getSales() + Integer.parseInt(liveOrder.getTotalNum()));
+            fsStoreProductAttrValueMapper.updateFsStoreProductAttrValue(attrValue);
+        }
 
         // 更改店铺库存
         fsStoreProduct.setStock(fsStoreProduct.getStock()-Integer.parseInt(liveOrder.getTotalNum()));
-        fsStoreProductService.updateFsStoreProduct(fsStoreProduct);
+        fsStoreProduct.setSales(fsStoreProduct.getSales()+Integer.parseInt(liveOrder.getTotalNum()));
+        fsStoreProductScrmMapper.incStockDecSales(Long.valueOf("-" + liveOrder.getTotalNum()),fsStoreProduct.getProductId());
         // 更新直播间库存
         goods.setStock(goods.getStock()-Integer.parseInt(liveOrder.getTotalNum()));
+        goods.setSales(goods.getSales()+Integer.parseInt(liveOrder.getTotalNum()));
         liveGoodsMapper.updateLiveGoods(goods);
 
         //判断是否是三种特定产品
@@ -1979,7 +2026,7 @@ public class LiveOrderServiceImpl implements ILiveOrderService {
                 dto.setImage(fsStoreProduct.getImage());
                 dto.setSku(String.valueOf(fsStoreProduct.getStock()));
                 if (StringUtils.isEmpty(fsStoreProduct.getBarCode())) {
-                    FsStoreProductAttrValueScrm fsStoreProductAttrValue = fsStoreProductAttrValueMapper.selectFsStoreProductAttrValueByProductId(fsStoreProduct.getProductId()).stream().filter(attrValue -> StringUtils.isNotEmpty(attrValue.getBarCode())).findFirst().orElse(null);
+                    FsStoreProductAttrValueScrm fsStoreProductAttrValue = fsStoreProductAttrValueMapper.selectFsStoreProductAttrValueByProductId(fsStoreProduct.getProductId()).stream().filter(attr -> StringUtils.isNotEmpty(attr.getBarCode())).findFirst().orElse(null);
                     if (fsStoreProductAttrValue != null) {
                         dto.setBarCode(fsStoreProductAttrValue.getBarCode());
                     }
@@ -2902,12 +2949,18 @@ public class LiveOrderServiceImpl implements ILiveOrderService {
     public R payConfirmReward(LiveOrder liveOrder) {
         Long orderId = liveOrder.getOrderId();
         if(orderId==null) return R.error("订单ID不存在");
+        // 保存传入的appId
+        String appId = liveOrder.getAppId();
         Object savePoint = TransactionAspectSupport.currentTransactionStatus().createSavepoint();
         try {
             liveOrder = baseMapper.selectLiveOrderByOrderId(String.valueOf(orderId));
             if(liveOrder==null || !liveOrder.getStatus().equals(OrderInfoEnum.STATUS_0.getValue())){
                 throw new CustomException("当前订单未找到或者订单状态不为待支付! orderId:" + orderId);
             }
+            // 设置appId
+            if (StringUtils.isNotEmpty(appId)) {
+                liveOrder.setAppId(appId);
+            }
             FsUserScrm user = userMapper.selectFsUserById(Long.valueOf(liveOrder.getUserId()));
             if(user == null) return R.error("用户不存在");
 //            String json = configService.selectConfigByKey("store.pay");
@@ -3059,6 +3112,10 @@ public class LiveOrderServiceImpl implements ILiveOrderService {
                     order.setPayDelivery(order.getPayPrice().subtract(payMoney) );
 //                    order.setPayMoney(BigDecimal.ZERO);
                 }
+                // 保存appId到订单
+                if (StringUtils.isNotEmpty(param.getAppId())) {
+                    order.setAppId(param.getAppId());
+                }
                 baseMapper.updateLiveOrder(order);
             }
             String payCode = OrderCodeUtils.getOrderSn();
@@ -3516,12 +3573,11 @@ public class LiveOrderServiceImpl implements ILiveOrderService {
             attrValue.setStock(attrValue.getStock() - Integer.parseInt(liveOrder.getTotalNum()));
             attrValue.setSales(attrValue.getSales() + Integer.parseInt(liveOrder.getTotalNum()));
             fsStoreProductAttrValueMapper.updateFsStoreProductAttrValue(attrValue);
-        } else {
-            // 更改店铺库存
-            fsStoreProduct.setStock(fsStoreProduct.getStock()-Integer.parseInt(liveOrder.getTotalNum()));
-            fsStoreProduct.setSales(fsStoreProduct.getSales()+Integer.parseInt(liveOrder.getTotalNum()));
-            fsStoreProductService.updateFsStoreProduct(fsStoreProduct);
         }
+        // 更改店铺库存
+        fsStoreProduct.setStock(fsStoreProduct.getStock()-Integer.parseInt(liveOrder.getTotalNum()));
+        fsStoreProduct.setSales(fsStoreProduct.getSales()+Integer.parseInt(liveOrder.getTotalNum()));
+        fsStoreProductScrmMapper.incStockDecSales(Long.valueOf("-" + liveOrder.getTotalNum()),fsStoreProduct.getProductId());
 
         // 更新直播间库存
         goods.setStock(goods.getStock()-Integer.parseInt(liveOrder.getTotalNum()));
@@ -3771,8 +3827,21 @@ public class LiveOrderServiceImpl implements ILiveOrderService {
             FsStoreProductScrm fsStoreProduct = fsStoreProductService.selectFsStoreProductById(liveOrder.getProductId());
             LiveGoods goods = liveGoodsMapper.selectLiveGoodsByProductId(liveOrder.getLiveId(), liveOrder.getProductId());
             fsStoreProduct.setStock(fsStoreProduct.getStock()+Integer.parseInt(liveOrder.getTotalNum()));
+            List<LiveOrderItem> liveOrderItems = liveOrderItemMapper.selectCheckedByOrderId(order.getOrderId());
+            List<String> barCodeList = new ArrayList<>();
+            //更新规格库存
+            for (LiveOrderItem item : liveOrderItems) {
+                FsStoreProduct cartDTO = JSONUtil.toBean(item.getJsonInfo(), FsStoreProduct.class);
+                if (StringUtils.isNotEmpty(cartDTO.getBarCode())) {
+                    barCodeList.add(cartDTO.getBarCode());
+                }
+            }
+            if (!barCodeList.isEmpty()) {
+                attrValueScrmMapper.incStock(fsStoreProduct.getProductId(), barCodeList, liveOrder.getTotalNum());
+            }
+
             // 更新商品库存
-            fsStoreProductService.updateFsStoreProduct(fsStoreProduct);
+            fsStoreProductScrmMapper.incStockDecSales(Long.valueOf(liveOrder.getTotalNum()),fsStoreProduct.getProductId());
             goods.setStock(goods.getStock()+Long.parseLong(liveOrder.getTotalNum()));
             // 更新商品库存
             liveGoodsMapper.updateLiveGoods(goods);
@@ -3795,7 +3864,7 @@ public class LiveOrderServiceImpl implements ILiveOrderService {
 
     private void refundCoupon(LiveOrder order) {
         if(order.getCouponUserId()!=null){
-            LiveCouponUser couponUser=liveCouponUserService.selectLiveCouponUserById(order.getCouponUserId());
+            LiveCouponUser couponUser=liveCouponUserService.selectLiveCouponUserById(order.getUserCouponId());
             if(couponUser!=null){
                 couponUser.setStatus(0);
                 couponUser.setUseTime(null);

+ 51 - 1
fs-service/src/main/java/com/fs/live/service/impl/LiveServiceImpl.java

@@ -12,6 +12,7 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers;
 import com.fs.common.core.page.PageRequest;
 import com.fs.common.exception.base.BaseException;
 import com.fs.company.mapper.CompanyMapper;
+import com.fs.company.vo.CompanyVO;
 import com.fs.core.config.WxMaConfiguration;
 import com.fs.his.domain.FsStoreProduct;
 import com.fs.his.domain.FsUser;
@@ -122,6 +123,9 @@ public class LiveServiceImpl implements ILiveService
     private CompanyMapper companyMapper;
     @Autowired
     private LiveCouponMapper liveCouponMapper;
+    
+    @Autowired
+    LiveTagConfigMapper liveTagConfigMapper;
 
     private static String TOKEN_VALID_CODE = "40001";
 
@@ -146,10 +150,25 @@ public class LiveServiceImpl implements ILiveService
             byId.setVideoFileSize(liveVideo.getFileSize());
             byId.setVideoDuration(liveVideo.getDuration());
         }
+        List<LiveTagItemVO> list = liveTagConfigMapper.getLiveTagListByliveId(liveId);
+        if(null != list && !list.isEmpty()){
+            byId.setLiveTagList(list);
+        }else{
+            byId.setLiveTagList(new ArrayList<>());
+        }
 
         return byId;
     }
 
+    /**
+     * 获取公司下拉列表
+     * @return
+     */
+    @Override
+    public  List<CompanyVO> getCompanyDropList(){
+       return  companyMapper.getCompanyDropList();
+    }
+
 
     /**
      * 查询直播
@@ -282,7 +301,7 @@ public class LiveServiceImpl implements ILiveService
     @Override
     public R subNotifyLive(LiveNotifyParam param) {
         LiveMiniprogramSubNotifyTask notifyTask = new LiveMiniprogramSubNotifyTask();
-        notifyTask.setPage("/pages_course/living?liveId=" + param.getLiveId());
+        notifyTask.setPage("/pages_course/living.html?liveId=" + param.getLiveId());
         notifyTask.setTaskName("直播间预约提醒");
         notifyTask.setTemplateId(param.getTemplateId());
         Long userId = param.getUserId();
@@ -391,6 +410,7 @@ public class LiveServiceImpl implements ILiveService
      * @return 结果
      */
     @Override
+    @Transactional
     public int insertLive(Live live){
 
 
@@ -424,6 +444,12 @@ public class LiveServiceImpl implements ILiveService
         liveData.setFavouriteNum(0L);
         liveData.setFollowNum(0L);
         liveDataService.insertLiveData(liveData);
+
+        //处理直播间标签配置
+        if(null != live.getLiveTagList() && !live.getLiveTagList().isEmpty()){
+            insertLiveTagConfig(live.getLiveTagList(),live.getLiveId(),live.getCreateBy());
+        }
+
         return save > 0 ? 1 : 0;
     }
 
@@ -544,6 +570,7 @@ public class LiveServiceImpl implements ILiveService
      * @return 结果
      */
     @Override
+    @Transactional
     public int updateLive(Live live){
         Live exist = baseMapper.selectLiveByLiveId(live.getLiveId());
         if (live.getCompanyId() != null && exist.getCompanyId() != null && !Objects.equals(exist.getCompanyId(), live.getCompanyId())) {
@@ -585,9 +612,32 @@ public class LiveServiceImpl implements ILiveService
         // 清除缓存
         clearLiveCache(live.getLiveId());
 
+        //处理直播间标签配置
+        if(null != live.getLiveTagList() && !live.getLiveTagList().isEmpty()){
+            //删除当前直播间的所有
+            liveTagConfigMapper.deleteByLiveId(live.getLiveId());
+            insertLiveTagConfig(live.getLiveTagList(),live.getLiveId(),live.getCreateBy());
+        }
+        
         return result;
     }
 
+    public void insertLiveTagConfig(List<LiveTagItemVO> list,Long liveId,String createBy){
+        for (LiveTagItemVO liveTagItemVO : list) {
+            LiveTagConfig liveTagConfig = new LiveTagConfig();
+            liveTagConfig.setCompanyId(Long.valueOf(liveTagItemVO.getCompanyId()));
+            liveTagConfig.setLiveId(liveId);
+            liveTagConfig.setCorpId(liveTagItemVO.getCorpId());
+            liveTagConfig.setMarkType(Long.valueOf(liveTagItemVO.getMarkType()));
+            liveTagConfig.setQwTagId(liveTagItemVO.getQwTagId());
+            liveTagConfig.setQwTagName(liveTagItemVO.getQwTagName());
+            liveTagConfig.setQwTagRealId(liveTagItemVO.getQwTagRealId());
+            liveTagConfig.setCreateTime(new Date());
+            liveTagConfig.setCreateBy(createBy);
+            liveTagConfigMapper.insertLiveTagConfig(liveTagConfig);
+        }
+    }
+
     /**
      * 批量删除直播
      *

+ 94 - 0
fs-service/src/main/java/com/fs/live/service/impl/LiveWatchLogServiceImpl.java

@@ -0,0 +1,94 @@
+package com.fs.live.service.impl;
+
+import java.util.List;
+import com.fs.common.utils.DateUtils;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import com.fs.live.mapper.LiveWatchLogMapper;
+import com.fs.live.domain.LiveWatchLog;
+import com.fs.live.service.ILiveWatchLogService;
+
+/**
+ * 直播看课记录Service业务层处理
+ * 
+ * @author fs
+ * @date 2025-12-12
+ */
+@Service
+public class LiveWatchLogServiceImpl extends ServiceImpl<LiveWatchLogMapper, LiveWatchLog> implements ILiveWatchLogService {
+
+    /**
+     * 查询直播看课记录
+     * 
+     * @param logId 直播看课记录主键
+     * @return 直播看课记录
+     */
+    @Override
+    public LiveWatchLog selectLiveWatchLogByLogId(Long logId)
+    {
+        return baseMapper.selectLiveWatchLogByLogId(logId);
+    }
+
+    /**
+     * 查询直播看课记录列表
+     * 
+     * @param liveWatchLog 直播看课记录
+     * @return 直播看课记录
+     */
+    @Override
+    public List<LiveWatchLog> selectLiveWatchLogList(LiveWatchLog liveWatchLog)
+    {
+        return baseMapper.selectLiveWatchLogList(liveWatchLog);
+    }
+
+    /**
+     * 新增直播看课记录
+     * 
+     * @param liveWatchLog 直播看课记录
+     * @return 结果
+     */
+    @Override
+    public int insertLiveWatchLog(LiveWatchLog liveWatchLog)
+    {
+        liveWatchLog.setCreateTime(DateUtils.getNowDate());
+        return baseMapper.insertLiveWatchLog(liveWatchLog);
+    }
+
+    /**
+     * 修改直播看课记录
+     * 
+     * @param liveWatchLog 直播看课记录
+     * @return 结果
+     */
+    @Override
+    public int updateLiveWatchLog(LiveWatchLog liveWatchLog)
+    {
+        liveWatchLog.setUpdateTime(DateUtils.getNowDate());
+        return baseMapper.updateLiveWatchLog(liveWatchLog);
+    }
+
+    /**
+     * 批量删除直播看课记录
+     * 
+     * @param logIds 需要删除的直播看课记录主键
+     * @return 结果
+     */
+    @Override
+    public int deleteLiveWatchLogByLogIds(Long[] logIds)
+    {
+        return baseMapper.deleteLiveWatchLogByLogIds(logIds);
+    }
+
+    /**
+     * 删除直播看课记录信息
+     * 
+     * @param logId 直播看课记录主键
+     * @return 结果
+     */
+    @Override
+    public int deleteLiveWatchLogByLogId(Long logId)
+    {
+        return baseMapper.deleteLiveWatchLogByLogId(logId);
+    }
+}

+ 518 - 13
fs-service/src/main/java/com/fs/live/service/impl/LiveWatchUserServiceImpl.java

@@ -5,26 +5,43 @@ import cn.hutool.core.bean.BeanUtil;
 import cn.hutool.core.collection.CollUtil;
 import cn.hutool.core.thread.ThreadUtil;
 import com.alibaba.fastjson.JSON;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.fs.common.constant.LiveKeysConstant;
 import com.fs.common.core.domain.R;
 import com.fs.common.core.redis.RedisCache;
 import com.fs.common.utils.DateUtils;
 import com.fs.common.utils.StringUtils;
+import com.fs.course.domain.FsCourseLink;
+import com.fs.course.service.impl.FsUserCourseVideoServiceImpl;
 import com.fs.his.domain.FsUser;
+import com.fs.his.mapper.FsUserMapper;
 import com.fs.his.service.IFsUserService;
 import com.fs.hisStore.domain.FsUserScrm;
 import com.fs.hisStore.service.IFsUserScrmService;
 import com.fs.live.domain.Live;
 import com.fs.live.domain.LiveVideo;
+import com.fs.live.domain.LiveWatchLog;
 import com.fs.live.domain.LiveWatchUser;
-import com.fs.live.mapper.LiveWatchUserMapper;
-import com.fs.live.mapper.LiveMapper;
-import com.fs.live.mapper.LiveVideoMapper;
+import com.fs.live.mapper.*;
+import com.fs.live.param.LiveIsAddKfParam;
 import com.fs.live.service.ILiveWatchUserService;
-import com.fs.live.vo.LiveWatchUserStatistics;
-import com.fs.live.vo.LiveWatchUserVO;
+import com.fs.live.vo.*;
+import com.fs.qw.domain.QwExternalContact;
+import com.fs.qwApi.domain.QwResult;
+import com.fs.qwApi.param.QwEditUserTagParam;
+import com.fs.qwApi.service.QwApiService;
+import com.fs.qw.domain.QwGroupChat;
+import com.fs.qw.domain.QwGroupChatUser;
+import com.fs.qw.mapper.QwExternalContactMapper;
+import com.fs.qw.mapper.QwGroupChatMapper;
+import com.fs.qw.mapper.QwGroupChatUserMapper;
+import com.fs.sop.domain.SopUserLogsInfo;
+import com.fs.sop.service.ISopUserLogsInfoService;
 import lombok.extern.slf4j.Slf4j;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.annotation.Async;
 import org.springframework.stereotype.Service;
 
 import java.util.*;
@@ -52,8 +69,27 @@ public class LiveWatchUserServiceImpl implements ILiveWatchUserService {
     private LiveMapper liveMapper;
     @Autowired
     private LiveVideoMapper liveVideoMapper;
+    @Autowired
+    private FsUserMapper fsUserMapper;
+    @Autowired
+    private QwGroupChatMapper qwGroupChatMapper;
+    @Autowired
+    private QwGroupChatUserMapper qwGroupChatUserMapper;
+    @Autowired
+    private QwExternalContactMapper qwExternalContactMapper;
+    @Autowired
+    private LiveWatchLogMapper liveWatchLogMapper;
+    @Autowired
+    private ISopUserLogsInfoService iSopUserLogsInfoService;
+
+    @Autowired
+    LiveTagConfigMapper liveTagConfigMapper;
+    @Autowired
+    private com.fs.qwApi.service.QwApiService qwApiService;
 
 
+    private static final Logger logger = LoggerFactory.getLogger(LiveWatchUserServiceImpl.class);
+
     /**
      * 查询直播间观看用户
      *
@@ -309,14 +345,6 @@ public class LiveWatchUserServiceImpl implements ILiveWatchUserService {
 
         // 使用唯一索引查询:live_id, user_id, live_flag, replay_flag
         LiveWatchUser liveWatchUser = baseMapper.selectByUniqueIndex(liveId, userId, liveFlag, replayFlag);
-        // 设置在线时长
-        try {
-            Long onlineSeconds = liveWatchUser.getOnlineSeconds();
-            if(onlineSeconds == null) onlineSeconds = 0L;
-            liveWatchUser.setOnlineSeconds(onlineSeconds + (System.currentTimeMillis() - liveWatchUser.getUpdateTime().getTime()) / 1000);
-        } catch (Exception e) {
-            log.error("设置在线时长异常:{}", e.getMessage());
-        }
         liveWatchUser.setUpdateTime(DateUtils.getNowDate());
         liveWatchUser.setOnline(1);
         baseMapper.updateLiveWatchUser(liveWatchUser);
@@ -507,4 +535,481 @@ public class LiveWatchUserServiceImpl implements ILiveWatchUserService {
         return baseMapper.selectByUniqueIndex(liveId, userId, liveFlag, replayFlag);
     }
 
+    /**
+     * 直播链接打开判定是否添加客户
+     * @param param
+     * @return
+     */
+    @Override
+    public R liveIsAddKf(LiveIsAddKfParam param) {
+
+        logger.info("【直播判断添加客服】:{}", param);
+        //查询用户
+        FsUser fsUser = fsUserMapper.selectFsUserByUserId(param.getUserId());
+        //用户不存在唤起重新授权
+        if (fsUser == null) {
+            return R.error(401, "未授权");
+        }
+        if (fsUser.getStatus() == 0) {
+            return R.error("会员被停用,无权限,请联系客服!");
+        }
+        //未注册提示
+        String noRegisterMsg = "由于您还未完成注册,请联系伴学助手完成注册即可观看!";
+        //非独属链接提示
+        String noMemberMsg = "此链接已被绑定,请联系伴学助手领取您的专属链接,专属链接请勿分享哦!";
+
+        if (StringUtils.isNotBlank(param.getChatId())) {
+            return handleLiveChat(param,fsUser, noMemberMsg, noRegisterMsg);
+        } else if (null != param.getQwExternalId()) {
+            return handleLivePerson(param,fsUser, noMemberMsg, noRegisterMsg);
+        } else {
+            return R.error("直播参数错误");
+        }
+
+    }
+
+    @Override
+    public LiveWatchUserEntry selectLiveWatchAndCompanyUserByFlag(Long liveId, Long userId, Integer liveFlag, Integer replayFlag) {
+        return baseMapper.selectLiveWatchAndCompanyUserByFlag(liveId,userId,liveFlag,replayFlag);
+    }
+
+    @Override
+    public void updateLiveWatchUserEntry(LiveWatchUserEntry liveWatchUser) {
+        LiveWatchUser updateEntity = new LiveWatchUser();
+        BeanUtil.copyProperties(updateEntity, liveWatchUser);
+        baseMapper.updateLiveWatchUser(updateEntity);
+    }
+
+    /**
+     * 处理发送群聊逻辑
+     * @param param
+     * @param user
+     * @param noMemberMsg
+     * @param noRegisterMsg
+     * @return
+     */
+    public R handleLiveChat(LiveIsAddKfParam param,FsUser user, String noMemberMsg,String noRegisterMsg){
+
+        QwGroupChat qwGroupChat = qwGroupChatMapper.selectQwGroupChatByChatId(param.getChatId());
+        if (qwGroupChat == null) {
+            return R.error("直播群参数异常");
+        }
+        SopUserLogsInfo sopUserLogsInfo = new SopUserLogsInfo();
+        sopUserLogsInfo.setChatId(param.getChatId());
+        List<QwGroupChatUser> qwGroupChatUsers = qwGroupChatUserMapper.selectByChatId(sopUserLogsInfo);
+        if (qwGroupChatUsers == null || qwGroupChatUsers.isEmpty()) {
+            return R.error("直播群参数异常");
+        }
+
+        QwExternalContact qwExternalContact = null;
+        if (null != param.getUserId() && null == qwExternalContact) {
+            try {
+                qwExternalContact = qwExternalContactMapper.selectOne(new QueryWrapper<QwExternalContact>()
+                        .eq("user_id", qwGroupChat.getOwner())
+                        .eq("fs_user_id", param.getUserId())
+                        .eq("corp_id", param.getCorpId())
+                        .eq("status", 0));
+            } catch (Exception e) {
+                log.error("直播群聊用户id匹配异常,参数user_id:{},fs_user_id:{},corp_id:{}", qwGroupChat.getOwner(), param.getUserId(), param.getCorpId(), e);
+            }
+        }
+        if (StringUtils.isNotBlank(param.getChatId()) && null == qwExternalContact) {
+            List<QwExternalContact> groupChatUserByChatIdAndUserName = qwExternalContactMapper.getGroupChatUserByChatIdAndUserName(qwGroupChat.getOwner(), user.getNickName(), param.getCorpId(), param.getChatId());
+            log.info("直播群聊用户查询结果,参数user_id:{},name:{},corp_id:{},chatId:{},groupChatUserByChatIdAndUserName:{}", qwGroupChat.getOwner(), user.getNickName(), param.getCorpId(), param.getChatId(), groupChatUserByChatIdAndUserName);
+            //没找到用户 || 找到的用户数量大于1 使用userid查询匹配
+            if (null == groupChatUserByChatIdAndUserName || groupChatUserByChatIdAndUserName.isEmpty() || groupChatUserByChatIdAndUserName.size() > 1) {
+                log.error("直播群聊用户昵称匹配异常,参数user_id:{},name:{},corp_id:{},chatId:{}", qwGroupChat.getOwner(), user.getNickName(), param.getCorpId(), param.getChatId());
+            } else {
+                qwExternalContact = groupChatUserByChatIdAndUserName.get(0);
+            }
+        }
+        if(qwExternalContact==null){
+            return R.error(noRegisterMsg);
+        }
+        QwExternalContact finalQwExternalContact = qwExternalContact;
+        if (qwGroupChatUsers.stream().noneMatch(e -> e.getUserId().equals(finalQwExternalContact.getExternalUserId()))) {
+            log.error("直播客户不在群:{},里面:{}", qwGroupChat.getChatId(), qwExternalContact.getExternalUserId());
+            return R.error(noRegisterMsg);
+        }
+        Long qwExternalId = qwExternalContact.getId();
+
+        LiveWatchLog liveWatchLog = liveWatchLogMapper.selectOneLogByLiveIdAndQwUserIdAndExternalId(param.getLiveId(), param.getQwUserId(),qwExternalId);
+        if (liveWatchLog==null ){
+            return R.error(noRegisterMsg);
+        }
+        //判断外部联系人有没有绑定userId
+        if (qwExternalContact.getFsUserId() != null) {
+            //有客户有小程序id  但 登录的小程序id和根据外部联系人id查出来的小程序id不一致
+            if (!qwExternalContact.getFsUserId().equals(param.getUserId())) {
+                return R.error(noRegisterMsg);
+            }
+            List<QwExternalContact> qwExternalContacts = qwExternalContactMapper.selectQwExternalContactByMiniUserId(param.getUserId());
+            //匹配客户公司id
+            if (qwExternalContacts.stream().noneMatch(contact -> contact.getCorpId().equals(param.getCorpId()))){
+                return R.error(noRegisterMsg);
+            }
+
+            //看课记录中userId为0绑定userId
+            if (liveWatchLog.getUserId() == null || liveWatchLog.getUserId().equals(0L) || !liveWatchLog.getUserId().equals(param.getUserId())) {
+                liveWatchLog.setUserId(param.getUserId());
+            }
+
+            liveWatchLog.setUpdateTime(new Date());
+
+            liveWatchLogMapper.updateLiveWatchLog(liveWatchLog);
+            iSopUserLogsInfoService.updateSopUserInfoByExternalId(qwExternalId, param.getUserId());
+
+
+        } else {
+            //没绑定fsUser直接绑定fsUser
+            QwExternalContact contact = new QwExternalContact();
+            contact.setId(qwExternalId);
+            contact.setFsUserId(param.getUserId());
+            qwExternalContactMapper.updateQwExternalContact(contact);
+            iSopUserLogsInfoService.updateSopUserInfoByExternalId(qwExternalId, param.getUserId());
+
+            FsUser fsUser = new FsUser();
+            fsUser.setUserId(user.getUserId());
+            fsUser.setIsAddQw(1);
+            fsUserMapper.updateFsUser(fsUser);
+            //绑定上之后 更新观看记录
+            //看课记录中userId为0绑定userId
+            liveWatchLog.setUserId(param.getUserId());
+            liveWatchLog.setUpdateTime(new Date());
+            liveWatchLogMapper.updateLiveWatchLog(liveWatchLog);
+        }
+
+        return R.ok();
+    }
+
+    /**
+     * 处理发送个人逻辑
+     * @param param
+     * @param noMemberMsg
+     * @param noRegisterMsg
+     * @return
+     */
+    public R handleLivePerson(LiveIsAddKfParam param,FsUser user,String noMemberMsg,String noRegisterMsg){
+
+        Long qwExternalId = param.getQwExternalId();
+
+        LiveWatchLog liveWatchLog = liveWatchLogMapper.selectOneLogByLiveIdAndQwUserIdAndExternalId(param.getLiveId(), param.getQwUserId(),qwExternalId);
+
+        if (liveWatchLog==null ){
+            return R.error(noMemberMsg);
+        }
+        //查询是否有添加客服
+        QwExternalContact externalContact = qwExternalContactMapper.selectQwExternalContactById(qwExternalId);
+
+        //用小程序id查询外部联系人
+        List<QwExternalContact> qwExternalContacts = qwExternalContactMapper.selectQwExternalContactByMiniUserId(param.getUserId());
+
+        //判断外部联系人有没有绑定userId
+        if (externalContact.getFsUserId() != null) {
+            //有客户有小程序id  但 登录的小程序id和根据外部联系人id查出来的小程序id不一致
+            if (!externalContact.getFsUserId().equals(param.getUserId())) {
+                return R.error(noMemberMsg);
+            }
+            //匹配客户公司id
+            if (qwExternalContacts.stream().noneMatch(contact -> contact.getCorpId().equals(param.getCorpId()))){
+                return R.error(noMemberMsg);
+            }
+
+            //看课记录中userId为0绑定userId
+            if (liveWatchLog.getUserId() == null || liveWatchLog.getUserId().equals(0L) || !liveWatchLog.getUserId().equals(param.getUserId())) {
+                liveWatchLog.setUserId(param.getUserId());
+            }
+
+            liveWatchLog.setUpdateTime(new Date());
+
+            liveWatchLogMapper.updateLiveWatchLog(liveWatchLog);
+            iSopUserLogsInfoService.updateSopUserInfoByExternalId(qwExternalId, param.getUserId());
+
+        } else { //没绑定fsUser直接绑定fsUser
+            QwExternalContact contact = new QwExternalContact();
+            contact.setId(qwExternalId);
+            contact.setFsUserId(param.getUserId());
+            qwExternalContactMapper.updateQwExternalContact(contact);
+            iSopUserLogsInfoService.updateSopUserInfoByExternalId(qwExternalId, param.getUserId());
+
+            FsUser fsUser = new FsUser();
+            fsUser.setUserId(user.getUserId());
+            fsUser.setIsAddQw(1);
+            fsUserMapper.updateFsUser(fsUser);
+            //绑定上之后 更新观看记录
+            //看课记录中userId为0绑定userId
+            liveWatchLog.setUserId(param.getUserId());
+            liveWatchLog.setUpdateTime(new Date());
+            liveWatchLogMapper.updateLiveWatchLog(liveWatchLog);
+        }
+
+        return R.ok();
+
+    }
+
+    /**
+     * 根据用户直播看课记录来打标签
+     */
+    @Override
+    @Async
+    public void qwTagMarkByLiveWatchLog(Long liveId) {
+        //查询直播间的标签配置
+        List<LiveTagItemVO> liveTagConfig = liveTagConfigMapper.getLiveTagListByliveId(liveId);
+
+        /**
+         * 8	回放已下单
+         * 7	直播已下单
+         * 6	回放已完课
+         * 5	直播已完课
+         * 4	回放到课未完课
+         * 3	直播到课未完课
+         * 2	回放未到课
+         * 1	直播未到课
+         */
+        Map<Integer, LiveTagItemVO> liveTagMp = liveTagConfig.stream()
+                .collect(Collectors.toMap(
+                        LiveTagItemVO::getMarkType,
+                        Function.identity(),
+                        (existing, replacement) -> existing
+                ));
+        //查询直播间的看课记录
+        List<LiveWatchLog> liveWatchLogs = liveWatchLogMapper.selectLiveWatchLogByLiveId(liveId);
+
+        //根据配置给每位用户打上标签
+        List<HandleUserTagVO> handleUserTagVOS = new ArrayList<>();
+        liveWatchLogs.forEach(liveLog -> {
+            HandleUserTagVO addItem = new HandleUserTagVO();
+            addItem.setLiveId(liveId);
+            addItem.setExternalId(liveLog.getExternalContactId());
+            LiveTagItemVO liveTagItemVO = null;
+            List<String> tags = new ArrayList<>();
+            switch (liveLog.getLogType()) {
+                //1看课中
+                case 1:
+                    //打标签 直播到课未完课
+                    liveTagItemVO = liveTagMp.get(3);
+                    if (null != liveTagItemVO) {
+                        tags.add(liveTagItemVO.getQwTagRealId());
+                    }
+                    break;
+                //2完课
+                case 2:
+                    //打标签 直播已完课
+                    liveTagItemVO = liveTagMp.get(5);
+                    if (null != liveTagItemVO) {
+                        tags.add(liveTagItemVO.getQwTagRealId());
+                    }
+                    break;
+                //3待看课
+                case 3:
+
+                    //打标签 直播未到课
+                    liveTagItemVO = liveTagMp.get(1);
+                    if (null != liveTagItemVO) {
+                        tags.add(liveTagItemVO.getQwTagRealId());
+                    }
+                    break;
+                //4看课中断
+                case 4:
+                    //打标签 直播未到课
+                    liveTagItemVO = liveTagMp.get(3);
+                    if (null != liveTagItemVO) {
+                        tags.add(liveTagItemVO.getQwTagRealId());
+                    }
+                    break;
+                default:
+                    break;
+            }
+            if (null != liveLog.getLiveBuy() && liveLog.getLiveBuy().equals(1)) {
+                liveTagItemVO = liveTagMp.get(7);
+                if (null != liveTagItemVO) {
+                    tags.add(liveTagItemVO.getQwTagRealId());
+                }
+            }
+            handleUserTagVOS.add(addItem);
+        });
+        handleUserTags2Qw(handleUserTagVOS);
+    }
+
+    /**
+     * 对企微用户打标签
+     * @param userTagVOS 用户标签列表,包含外部联系人ID和要添加的标签列表
+     */
+    private void handleUserTags2Qw(List<HandleUserTagVO> userTagVOS) {
+        if (CollUtil.isEmpty(userTagVOS)) {
+            log.warn("用户标签列表为空,跳过打标签操作");
+            return;
+        }
+
+        int successCount = 0;
+        int failCount = 0;
+
+        for (HandleUserTagVO userTagVO : userTagVOS) {
+            try {
+                // 参数校验
+                if (userTagVO.getExternalId() == null) {
+                    log.warn("外部联系人ID为空,跳过该用户");
+                    failCount++;
+                    continue;
+                }
+
+                if (CollUtil.isEmpty(userTagVO.getTags())) {
+                    log.warn("标签列表为空,跳过该用户: externalId={}", userTagVO.getExternalId());
+                    failCount++;
+                    continue;
+                }
+
+                // 根据外部联系人ID查询企微外部联系人信息
+                QwExternalContact qwExternalContact = qwExternalContactMapper.selectQwExternalContactById(userTagVO.getExternalId());
+                if (qwExternalContact == null) {
+                    log.warn("未找到企微外部联系人: externalId={}", userTagVO.getExternalId());
+                    failCount++;
+                    continue;
+                }
+
+                // 校验必要字段
+                if (StringUtils.isEmpty(qwExternalContact.getUserId())
+                        || StringUtils.isEmpty(qwExternalContact.getExternalUserId())
+                        || StringUtils.isEmpty(qwExternalContact.getCorpId())) {
+                    log.warn("企微外部联系人信息不完整: externalId={}, userId={}, externalUserId={}, corpId={}",
+                            userTagVO.getExternalId(),
+                            qwExternalContact.getUserId(),
+                            qwExternalContact.getExternalUserId(),
+                            qwExternalContact.getCorpId());
+                    failCount++;
+                    continue;
+                }
+
+                // 构建打标签参数
+                QwEditUserTagParam qwEditUserTagParam = new QwEditUserTagParam();
+                qwEditUserTagParam.setUserid(qwExternalContact.getUserId());
+                qwEditUserTagParam.setExternal_userid(qwExternalContact.getExternalUserId());
+                qwEditUserTagParam.setAdd_tag(userTagVO.getTags());
+
+                // 调用企微API打标签
+                QwResult qwResult = qwApiService.editUserTag(qwEditUserTagParam, qwExternalContact.getCorpId());
+
+                if (qwResult != null && qwResult.getErrcode() == 0) {
+                    // 打标签成功,更新数据库中的标签信息
+                    String existingTagIds = qwExternalContact.getTagIds();
+                    Set<String> uniqueTagIds = new HashSet<>();
+
+                    // 合并现有标签
+                    if (StringUtils.isNotEmpty(existingTagIds)) {
+                        try {
+                            List<String> parsedTags = JSON.parseArray(existingTagIds, String.class);
+                            if (CollUtil.isNotEmpty(parsedTags)) {
+                                uniqueTagIds.addAll(parsedTags);
+                            }
+                        } catch (Exception e) {
+                            log.warn("解析现有标签失败: externalId={}, tagIds={}, error={}",
+                                    userTagVO.getExternalId(), existingTagIds, e.getMessage());
+                        }
+                    }
+
+                    // 添加新标签
+                    uniqueTagIds.addAll(userTagVO.getTags());
+
+                    // 更新数据库
+                    QwExternalContact updateContact = new QwExternalContact();
+                    updateContact.setId(qwExternalContact.getId());
+                    updateContact.setTagIds(JSON.toJSONString(new ArrayList<>(uniqueTagIds)));
+                    qwExternalContactMapper.updateQwExternalContact(updateContact);
+
+                    successCount++;
+                    log.info("成功为用户打标签: externalId={}, userId={}, externalUserId={}, tags={}",
+                            userTagVO.getExternalId(),
+                            qwExternalContact.getUserId(),
+                            qwExternalContact.getExternalUserId(),
+                            userTagVO.getTags());
+                } else {
+                    // 打标签失败
+                    failCount++;
+                    String errorMsg = qwResult != null ? qwResult.getErrmsg() : "未知错误";
+                    log.error("为用户打标签失败: externalId={}, userId={}, externalUserId={}, error={}",
+                            userTagVO.getExternalId(),
+                            qwExternalContact.getUserId(),
+                            qwExternalContact.getExternalUserId(),
+                            errorMsg);
+                }
+            } catch (Exception e) {
+                failCount++;
+                log.error("为用户打标签异常: externalId={}, error={}",
+                        userTagVO.getExternalId(), e.getMessage(), e);
+            }
+        }
+
+        log.info("打标签操作完成: 总数={}, 成功={}, 失败={}",
+                userTagVOS.size(), successCount, failCount);
+    }
+
+    /**
+     * 更新用户观看时长(心跳时调用)- 异步执行
+     * @param liveId 直播间ID
+     * @param userId 用户ID
+     * @param liveFlag 直播标记
+     * @param replayFlag 回放标记
+     * @param duration 观看时长(秒)
+     */
+    @Override
+    @Async
+    public void updateWatchDuration(Long liveId, Long userId, Integer liveFlag, Integer replayFlag, Long duration) {
+        try {
+
+            LiveWatchUser liveWatchUser = baseMapper.selectByUniqueIndex(liveId, userId, liveFlag, replayFlag);
+            
+            if (liveWatchUser != null) {
+                if (liveWatchUser.getOnlineSeconds() == null || duration > liveWatchUser.getOnlineSeconds()) {
+                    liveWatchUser.setOnlineSeconds(duration);
+                    liveWatchUser.setUpdateTime(DateUtils.getNowDate());
+                    baseMapper.updateLiveWatchUser(liveWatchUser);
+                    log.debug("更新观看时长成功: liveId={}, userId={}, liveFlag={}, replayFlag={}, duration={}",
+                            liveId, userId, liveFlag, replayFlag, duration);
+                }
+            } else {
+                log.warn("未找到观看记录,无法更新时长: liveId={}, userId={}, liveFlag={}, replayFlag={}",
+                        liveId, userId, liveFlag, replayFlag);
+            }
+        } catch (Exception e) {
+            log.error("更新观看时长失败: liveId={}, userId={}, liveFlag={}, replayFlag={}, duration={}",
+                    liveId, userId, liveFlag, replayFlag, duration, e);
+        }
+    }
+
+    /**
+     * 获取用户在某直播间的总观看时长(直播 + 回放)
+     * @param liveId 直播间ID
+     * @param userId 用户ID
+     * @return 总观看时长(秒)
+     */
+    @Override
+    public Long getTotalWatchDuration(Long liveId, Long userId) {
+        try {
+            long totalDuration = 0L;
+            
+            // 1. 查询直播观看记录(liveFlag=1, replayFlag=0)
+            LiveWatchUser liveRecord = baseMapper.selectByUniqueIndex(liveId, userId, 1, 0);
+            if (liveRecord != null && liveRecord.getOnlineSeconds() != null) {
+                totalDuration += liveRecord.getOnlineSeconds();
+            }
+            
+            // 2. 查询回放观看记录(liveFlag=0, replayFlag=1)
+            LiveWatchUser replayRecord = baseMapper.selectByUniqueIndex(liveId, userId, 0, 1);
+            if (replayRecord != null && replayRecord.getOnlineSeconds() != null) {
+                totalDuration += replayRecord.getOnlineSeconds();
+            }
+            
+            log.debug("查询总观看时长: liveId={}, userId={}, liveDuration={}, replayDuration={}, total={}",
+                    liveId, userId,
+                    liveRecord != null ? liveRecord.getOnlineSeconds() : 0,
+                    replayRecord != null ? replayRecord.getOnlineSeconds() : 0,
+                    totalDuration);
+            
+            return totalDuration;
+        } catch (Exception e) {
+            log.error("查询总观看时长失败: liveId={}, userId={}", liveId, userId, e);
+            return 0L;
+        }
+    }
+
 }

Some files were not shown because too many files changed in this diff