Преглед изворни кода

Merge remote-tracking branch 'origin/红德堂' into 红德堂-test

# Conflicts:
#	fs-live-app/src/main/java/com/fs/live/websocket/service/WebSocketServer.java
#	fs-service/src/main/java/com/fs/live/service/impl/LiveOrderServiceImpl.java
#	fs-service/src/main/java/com/fs/live/service/impl/LiveWatchUserServiceImpl.java
wangxy пре 3 дана
родитељ
комит
37a344b56d
55 измењених фајлова са 919 додато и 163 уклоњено
  1. 50 5
      fs-admin/src/main/java/com/fs/his/controller/FsUserController.java
  2. 1 1
      fs-admin/src/main/java/com/fs/live/controller/LiveAfterSalesController.java
  3. 7 4
      fs-admin/src/main/java/com/fs/live/controller/OrderController.java
  4. 2 0
      fs-common/src/main/java/com/fs/common/constant/LiveKeysConstant.java
  5. 6 3
      fs-company/src/main/java/com/fs/company/controller/live/OrderController.java
  6. 5 0
      fs-live-app/src/main/java/com/fs/framework/aspectj/LiveWatchUserAspect.java
  7. 70 2
      fs-live-app/src/main/java/com/fs/live/task/Task.java
  8. 155 56
      fs-live-app/src/main/java/com/fs/live/websocket/service/WebSocketServer.java
  9. 13 1
      fs-qw-task/src/main/java/com/fs/app/task/UserCourseWatchCountTask.java
  10. 4 2
      fs-qw-task/src/main/java/com/fs/app/taskService/impl/SopLogsTaskServiceImpl.java
  11. 1 1
      fs-service/src/main/java/com/fs/course/mapper/FsUserCourseMapper.java
  12. 1 1
      fs-service/src/main/java/com/fs/course/mapper/FsUserCourseVideoMapper.java
  13. 2 1
      fs-service/src/main/java/com/fs/course/param/FsUserCourseVideoAddKfUParam.java
  14. 2 2
      fs-service/src/main/java/com/fs/course/param/newfs/FsUserCourseAddCompanyUserParam.java
  15. 5 0
      fs-service/src/main/java/com/fs/course/param/newfs/FsUserCourseListParam.java
  16. 10 10
      fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java
  17. 19 1
      fs-service/src/main/java/com/fs/course/vo/FsUserCourseComplaintRecordPageListVO.java
  18. 2 2
      fs-service/src/main/java/com/fs/his/mapper/FsUserMapper.java
  19. 1 1
      fs-service/src/main/java/com/fs/his/service/IFsUserService.java
  20. 3 3
      fs-service/src/main/java/com/fs/his/service/impl/FsUserServiceImpl.java
  21. 3 0
      fs-service/src/main/java/com/fs/his/vo/FsUserVO.java
  22. 1 0
      fs-service/src/main/java/com/fs/his/vo/UserOpenIdVO.java
  23. 12 0
      fs-service/src/main/java/com/fs/huifuPay/sdk/opps/core/request/V2TradePaymentScanpayQueryRequest.java
  24. 1 1
      fs-service/src/main/java/com/fs/live/domain/LiveCouponUser.java
  25. 1 1
      fs-service/src/main/java/com/fs/live/domain/LiveOrder.java
  26. 17 0
      fs-service/src/main/java/com/fs/live/domain/LiveWatchLog.java
  27. 2 0
      fs-service/src/main/java/com/fs/live/mapper/LiveAfterSalesMapper.java
  28. 6 0
      fs-service/src/main/java/com/fs/live/mapper/LiveCompletionPointsRecordMapper.java
  29. 21 0
      fs-service/src/main/java/com/fs/live/mapper/LiveOrderMapper.java
  30. 1 1
      fs-service/src/main/java/com/fs/live/param/LiveOrderSearchParam.java
  31. 2 0
      fs-service/src/main/java/com/fs/live/param/MergedOrderQueryParam.java
  32. 2 0
      fs-service/src/main/java/com/fs/live/service/ILiveAfterSalesService.java
  33. 10 0
      fs-service/src/main/java/com/fs/live/service/ILiveCompletionPointsRecordService.java
  34. 54 0
      fs-service/src/main/java/com/fs/live/service/impl/LiveAfterSalesServiceImpl.java
  35. 8 0
      fs-service/src/main/java/com/fs/live/service/impl/LiveCompletionPointsRecordServiceImpl.java
  36. 1 1
      fs-service/src/main/java/com/fs/live/service/impl/LiveCouponServiceImpl.java
  37. 7 4
      fs-service/src/main/java/com/fs/live/service/impl/LiveOrderServiceImpl.java
  38. 49 12
      fs-service/src/main/java/com/fs/live/service/impl/LiveServiceImpl.java
  39. 8 5
      fs-service/src/main/java/com/fs/live/service/impl/LiveWatchUserServiceImpl.java
  40. 1 1
      fs-service/src/main/java/com/fs/live/vo/LiveOrderListVo.java
  41. 1 1
      fs-service/src/main/java/com/fs/live/vo/LiveOrderVoZm.java
  42. 5 1
      fs-service/src/main/java/com/fs/live/vo/MergedOrderExportVO.java
  43. 5 0
      fs-service/src/main/java/com/fs/qw/vo/QwExternalContactComplaintVO.java
  44. 16 1
      fs-service/src/main/java/com/fs/store/mapper/FsUserCourseCountMapper.java
  45. 103 15
      fs-service/src/main/java/com/fs/store/service/impl/FsUserCourseCountServiceImpl.java
  46. 7 1
      fs-service/src/main/resources/mapper/course/FsUserCourseComplaintRecordMapper.xml
  47. 1 1
      fs-service/src/main/resources/mapper/course/FsUserCourseVideoMapper.xml
  48. 36 18
      fs-service/src/main/resources/mapper/his/FsUserMapper.xml
  49. 53 0
      fs-service/src/main/resources/mapper/live/LiveAfterSalesMapper.xml
  50. 9 0
      fs-service/src/main/resources/mapper/live/LiveCompletionPointsRecordMapper.xml
  51. 2 0
      fs-service/src/main/resources/mapper/live/LiveUserRedRecordMapper.xml
  52. 2 1
      fs-service/src/main/resources/mapper/qw/QwExternalContactMapper.xml
  53. 63 1
      fs-service/src/main/resources/mapper/store/FsUserCourseCountMapper.xml
  54. 48 0
      fs-user-app/src/main/java/com/fs/app/controller/live/LiveRedController.java
  55. 2 1
      fs-user-app/src/main/java/com/fs/app/facade/impl/LiveFacadeServiceImpl.java

+ 50 - 5
fs-admin/src/main/java/com/fs/his/controller/FsUserController.java

@@ -1,5 +1,6 @@
 package com.fs.his.controller;
 
+import java.io.IOException;
 import java.util.*;
 
 import com.alibaba.fastjson.JSON;
@@ -33,6 +34,10 @@ import com.google.common.collect.Lists;
 import io.swagger.annotations.ApiOperation;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.ibatis.session.SqlSessionFactory;
+import org.apache.poi.ss.usermodel.Row;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.ss.usermodel.Workbook;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
 import org.springframework.beans.BeanUtils;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -55,6 +60,8 @@ import com.fs.common.utils.poi.ExcelUtil;
 import com.fs.common.core.page.TableDataInfo;
 import org.springframework.web.multipart.MultipartFile;
 
+import javax.servlet.http.HttpServletResponse;
+
 import static com.fs.his.utils.PhoneUtil.*;
 
 /**
@@ -157,11 +164,49 @@ public class FsUserController extends BaseController
     }
 
     @PreAuthorize("@ss.hasPermi('his:user:exportOpenId')")
-    @GetMapping("/exportOpenId")
-    public  AjaxResult exportOpenIdList(){
-        List<UserOpenIdVO> list = fsUserService.selectOpenIdList();
-        ExcelUtil<UserOpenIdVO> util = new ExcelUtil<UserOpenIdVO>(UserOpenIdVO.class);
-        return util.exportExcel(list, "用户openId数据");
+    @PostMapping("/exportOpenId")
+    public  void exportOpenIdList(HttpServletResponse response) throws IOException {
+        int batchSize = 10000; // 每批处理1000条
+        long lastId = 0; // 从0开始
+        boolean hasMore = true;
+
+        // 设置响应头
+        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+        response.setHeader("Content-Disposition", "attachment; filename=用户openId数据.xlsx");
+
+        try (Workbook workbook = new XSSFWorkbook()) {
+            Sheet sheet = workbook.createSheet("用户openId数据");
+
+            // 创建表头
+            Row headerRow = sheet.createRow(0);
+            headerRow.createCell(0).setCellValue("OpenID");
+
+            int rowNum = 1;
+
+            while (hasMore) {
+                List<UserOpenIdVO> batchList = fsUserService.selectOpenIdList(lastId, batchSize);
+
+                if (batchList.isEmpty()) {
+                    hasMore = false;
+                    break;
+                }
+
+                //创建行和单元格
+                for (UserOpenIdVO item : batchList) {
+                    Row row = sheet.createRow(rowNum++);
+                    row.createCell(0).setCellValue(item.getOpenId());
+
+                    // 更新lastId用于下一次查询
+                    lastId = item.getUserId();
+                }
+
+                //当前批次数据
+                if (batchList.size() < batchSize) {
+                    hasMore = false;
+                }
+            }
+            workbook.write(response.getOutputStream());
+        }
     }
 
 

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

@@ -114,7 +114,7 @@ public class LiveAfterSalesController extends BaseController
     {
         PageHelper.clearPage();
         PageHelper.startPage(1, 10000, "");
-        List<LiveAfterSalesVo> list = liveAfterSalesService.selectLiveAfterSalesVoList(liveAfterSales);
+        List<LiveAfterSalesVo> list = liveAfterSalesService.selectLiveAfterSalesVoListExport(liveAfterSales);
         if("北京卓美".equals(signProjectName)){
             List<FsStoreOrderItemExportRefundZMVO> zmvoList = list.stream()
                     .map(vo -> {

+ 7 - 4
fs-admin/src/main/java/com/fs/live/controller/OrderController.java

@@ -51,7 +51,7 @@ public class OrderController extends BaseController
     @Autowired
     private IMergedOrderService mergedOrderService;
     // 设置最大导出数量限制为20000条
-    private static final int maxExportCount = 20000;
+    private static final int maxExportCount = 50000;
 
 
     @Autowired
@@ -88,6 +88,7 @@ public class OrderController extends BaseController
         // 先查询数据,限制查询20001条,用于判断是否超过限制
         PageHelper.startPage(1, maxExportCount + 1);
         List<MergedOrderVO> list = mergedOrderService.selectMergedOrderList(param);
+        list = list.stream().filter(item -> StringUtils.isNotEmpty(item.getBankTransactionId())).collect(Collectors.toList());
         
         // 如果查询结果超过20000条,返回错误提示
         if (list != null && list.size() > maxExportCount) {
@@ -126,6 +127,7 @@ public class OrderController extends BaseController
         // 先查询数据,限制查询20001条,用于判断是否超过限制
         PageHelper.startPage(1, maxExportCount + 1);
         List<MergedOrderVO> list = mergedOrderService.selectMergedOrderList(param);
+        list = list.stream().filter(item -> StringUtils.isNotEmpty(item.getBankTransactionId())).collect(Collectors.toList());
 
         // 如果查询结果超过20000条,返回错误提示
         if (list != null && list.size() > maxExportCount) {
@@ -235,20 +237,21 @@ public class OrderController extends BaseController
             MergedOrderExportVO exportVO = new MergedOrderExportVO();
             
             // 订单基本信息(参考 FsStoreOrderItemExportVO 的顺序)
+            exportVO.setOrderTypeName(vo.getOrderTypeName());
             exportVO.setOrderCode(vo.getOrderCode());
             exportVO.setStatus(vo.getStatus() != null ? String.valueOf(vo.getStatus()) : null);
             exportVO.setUserId(vo.getUserId());
             
             // 产品信息
-            exportVO.setProductName(vo.getProductName());
+            exportVO.setProductName(StringUtils.isEmpty(vo.getProductName()) ? "产品被删除" : vo.getProductName());
             exportVO.setBarCode(vo.getBarCode());
             exportVO.setProductSpec(StringUtils.isEmpty(vo.getProductSpec()) ? "默认" : vo.getProductSpec());
             exportVO.setTotalNum(vo.getTotalNum());
             exportVO.setPrice(vo.getTotalPrice()); // 产品价格使用订单总价
-            exportVO.setCost(vo.getCost());
+            exportVO.setCost(vo.getCost() != null ? vo.getCost() : BigDecimal.ZERO);
             exportVO.setFPrice(vo.getCost() != null ? vo.getCost().multiply(BigDecimal.valueOf(vo.getTotalNum())) : BigDecimal.ZERO); // 结算价,合并订单暂无此字段
             exportVO.setPayPostage(vo.getPayDelivery());
-            exportVO.setCateName(vo.getCateName());
+            exportVO.setCateName(StringUtils.isEmpty(vo.getCateName()) ? "产品被删除" : vo.getCateName());
             // 收货信息
             exportVO.setRealName(vo.getRealName());
             if (isPlainText) {

+ 2 - 0
fs-common/src/main/java/com/fs/common/constant/LiveKeysConstant.java

@@ -37,6 +37,8 @@ public class LiveKeysConstant {
     public static final Integer PRODUCT_DETAIL_CACHE_EXPIRE = 300; //商品详情缓存过期时间(秒)
 
     public static final String LIVE_TAG_MARK_CACHE = "live:tag:mark:%s"; //直播间打标签缓存,存储直播间ID、开始时间和视频时长
+    //记录用户观看直播间信息 直播间id、用户id、外部联系人id、qwUserId
+    public static final String LIVE_USER_WATCH_LOG_CACHE = "live:user:watch:log:%s:%s:%s:%s";
 
 
 }

+ 6 - 3
fs-company/src/main/java/com/fs/company/controller/live/OrderController.java

@@ -52,7 +52,7 @@ public class OrderController extends BaseController
     @Autowired
     private IMergedOrderService mergedOrderService;
     // 设置最大导出数量限制为20000条
-    private static final int maxExportCount = 20000;
+    private static final int maxExportCount = 50000;
 
 
 
@@ -96,6 +96,7 @@ public class OrderController extends BaseController
         param.setCompanyId(user.getCompanyId());
         PageHelper.startPage(1, maxExportCount + 1);
         List<MergedOrderVO> list = mergedOrderService.selectMergedOrderList(param);
+        list = list.stream().filter(item -> StringUtils.isNotEmpty(item.getBankTransactionId())).collect(Collectors.toList());
 
         // 如果查询结果超过20000条,返回错误提示
         if (list != null && list.size() > maxExportCount) {
@@ -138,6 +139,7 @@ public class OrderController extends BaseController
         param.setCompanyId(user.getCompanyId());
         PageHelper.startPage(1, maxExportCount + 1);
         List<MergedOrderVO> list = mergedOrderService.selectMergedOrderList(param);
+        list = list.stream().filter(item -> StringUtils.isNotEmpty(item.getBankTransactionId())).collect(Collectors.toList());
 
         // 如果查询结果超过20000条,返回错误提示
         if (list != null && list.size() > maxExportCount) {
@@ -239,12 +241,13 @@ public class OrderController extends BaseController
             MergedOrderExportVO exportVO = new MergedOrderExportVO();
 
             // 订单基本信息(参考 FsStoreOrderItemExportVO 的顺序)
+            exportVO.setOrderTypeName(vo.getOrderTypeName());
             exportVO.setOrderCode(vo.getOrderCode());
             exportVO.setStatus(vo.getStatus() != null ? String.valueOf(vo.getStatus()) : null);
             exportVO.setUserId(vo.getUserId());
 
             // 产品信息
-            exportVO.setProductName(vo.getProductName());
+            exportVO.setProductName(StringUtils.isEmpty(vo.getProductName()) ? "产品被删除" : vo.getProductName());
             exportVO.setBarCode(vo.getBarCode());
             exportVO.setProductSpec(StringUtils.isEmpty(vo.getProductSpec()) ? "默认" : vo.getProductSpec());
             exportVO.setTotalNum(vo.getTotalNum());
@@ -252,7 +255,7 @@ public class OrderController extends BaseController
             exportVO.setCost(BigDecimal.ZERO);
             exportVO.setFPrice(BigDecimal.ZERO); // 结算价,合并订单暂无此字段
             exportVO.setPayPostage(vo.getPayDelivery());
-            exportVO.setCateName(vo.getCateName());
+            exportVO.setCateName(StringUtils.isEmpty(vo.getCateName()) ? "产品被删除" : vo.getCateName());
 
             // 收货信息
             exportVO.setRealName(vo.getRealName());

+ 5 - 0
fs-live-app/src/main/java/com/fs/framework/aspectj/LiveWatchUserAspect.java

@@ -8,6 +8,9 @@ import org.aspectj.lang.JoinPoint;
 import org.aspectj.lang.annotation.AfterReturning;
 import org.aspectj.lang.annotation.Aspect;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
 import org.springframework.stereotype.Component;
 
 import java.util.Arrays;
@@ -17,11 +20,13 @@ import java.util.Set;
 @Aspect
 @Component
 @Slf4j
+@Order(Ordered.LOWEST_PRECEDENCE - 1)  // 调整切面优先级
 public class LiveWatchUserAspect {
 
 
 
     @Autowired
+    @Lazy
     private ILiveWatchUserService liveWatchUserService;
 
     @AfterReturning(pointcut = "execution(* com.fs.live.service.impl.LiveWatchUserServiceImpl.insertLiveWatchUser(..)) || " +

+ 70 - 2
fs-live-app/src/main/java/com/fs/live/task/Task.java

@@ -33,6 +33,7 @@ import javax.annotation.PostConstruct;
 import java.math.BigDecimal;
 import java.time.Instant;
 import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
 import java.util.*;
 import java.util.concurrent.TimeUnit;
 import java.util.stream.Collectors;
@@ -812,6 +813,7 @@ public class Task {
     @DistributeLock(key = "scanLiveWatchUserStatus", scene = "task")
     public void scanLiveWatchUserStatus() {
         try {
+            DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
             // 查询所有正在直播的直播间
             List<Live> activeLives = liveService.selectNoEndLiveList();
             if (activeLives == null || activeLives.isEmpty()) {
@@ -864,7 +866,7 @@ public class Task {
                             if (onlineSeconds == null || onlineSeconds <= 0) {
                                 continue;
                             }
-                            
+
                             // 获取用户的 companyId 和 companyUserId
                             LiveUserFirstEntry liveUserFirstEntry =
                                     liveUserFirstEntryService.selectEntityByLiveIdUserIdWithCache(liveId, userId);
@@ -878,7 +880,10 @@ public class Task {
                             if (qwUserId == null || qwUserId <= 0 || externalContactId == null || externalContactId <= 0) {
                                 continue;
                             }
-
+                            //更新最新用户活跃时间
+                            String liveUserWatchLogKey = String.format(LIVE_USER_WATCH_LOG_CACHE, liveId, userId,externalContactId,qwUserId);
+                            LocalDateTime now = LocalDateTime.now();
+                            redisCache.setCacheObject(liveUserWatchLogKey,formatter.format(now),5,TimeUnit.MINUTES);
                             // 使用 updateLiveWatchLogTypeByDuration 的逻辑更新观看记录状态
                             updateLiveWatchLogTypeByDuration(liveId, userId, qwUserId, externalContactId,
                                     onlineSeconds, totalVideoDuration, updateLog);
@@ -972,6 +977,69 @@ public class Task {
         }
     }
 
+    /**
+     * 每分钟扫描一次用户在线状态用于更新用户观看记录值
+     */
+    @Scheduled(cron = "0 0/1 * * * ?")
+    @DistributeLock(key = "updateLiveWatchUserStatus", scene = "task")
+    public void updateLiveWatchUserStatus() {
+        try {
+            Set<String> keys = redisCache.redisTemplate.keys("live:user:watch:log:*");
+            LocalDateTime now = LocalDateTime.now();
+            DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+            List<LiveWatchLog> updateLog = new ArrayList<>();
+            if (keys != null && !keys.isEmpty()) {
+                for (String key : keys) {
+                    String[] split = key.split(":");
+                    String cacheTime = redisCache.getCacheObject(key);
+                    //判断缓存的值是否已经距离现在超过一分钟
+                    if (StringUtils.isNotBlank(cacheTime)) {
+                        try {
+                            LocalDateTime cachedDateTime = LocalDateTime.parse(cacheTime, formatter);
+                            // 比较时间,判断是否超过1分钟(60秒)
+                            long secondsBetween = java.time.Duration.between(cachedDateTime, now).getSeconds();
+                            if (secondsBetween >= 60) {
+                                // 距离上次记录已超过1分钟,更新状态为看课中断
+                                // 查询 LiveWatchLog
+                                LiveWatchLog queryLog = new LiveWatchLog();
+                                queryLog.setLiveId(Long.valueOf(split[4]));
+                                queryLog.setQwUserId(String.valueOf(split[7]));
+                                queryLog.setExternalContactId(Long.valueOf(split[6]));
+                                queryLog.setLogType(1);
+                                List<LiveWatchLog> logs = liveWatchLogService.selectLiveWatchLogList(queryLog);
+                                if (logs != null && !logs.isEmpty()) {
+                                    for (LiveWatchLog log : logs) {
+                                        if (log.getLogType() != null && log.getLogType() == 2) {
+                                            continue;
+                                        }
+                                        log.setLogType(4);
+                                        updateLog.add(log);
+                                    }
+                                }
+                            }
+                        } catch (Exception e) {
+                            log.error("解析缓存时间失败: cacheTime={}, error={}", cacheTime, e.getMessage());
+                        }
+                    }
+                }
+                // 批量插入回放用户数据
+                if (!updateLog.isEmpty()) {
+                    int batchSize = 500;
+                    for (int i = 0; i < updateLog.size(); i += batchSize) {
+                        int end = Math.min(i + batchSize, updateLog.size());
+                        List<LiveWatchLog> batch = updateLog.subList(i, end);
+                        liveWatchLogService.batchUpdateLiveWatchLog(batch);
+                    }
+                    for (LiveWatchLog liveWatchLog : updateLog) {
+                        redisCache.setCacheObject("live:watch:log:cache:" + liveWatchLog.getLogId(), liveWatchLog, 1, TimeUnit.HOURS);
+                    }
+                }
+            }
+        } catch (Exception ex) {
+            log.error("每分钟扫描一次用户在线状态用于更新用户观看记录值: error={}", ex.getMessage(), ex);
+        }
+    }
+
     /**
      * 批量同步Redis中的观看时长到数据库
      * 每2分钟执行一次,减少数据库压力

+ 155 - 56
fs-live-app/src/main/java/com/fs/live/websocket/service/WebSocketServer.java

@@ -36,7 +36,10 @@ import javax.websocket.*;
 import javax.websocket.server.ServerEndpoint;
 import java.io.EOFException;
 import java.io.IOException;
+import java.time.LocalDate;
 import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
 import java.util.*;
 import java.util.concurrent.*;
 import java.util.concurrent.locks.Lock;
@@ -83,7 +86,7 @@ public class WebSocketServer {
     private final ILiveVideoService liveVideoService = SpringUtils.getBean(ILiveVideoService.class);
     private final ILiveCompletionPointsRecordService completionPointsRecordService = SpringUtils.getBean(ILiveCompletionPointsRecordService.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
 
@@ -135,7 +138,7 @@ 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);
@@ -145,7 +148,7 @@ public class WebSocketServer {
                 redisCache.setCacheObject(entryTimeKey, System.currentTimeMillis(), 24, TimeUnit.HOURS);
             }
             // 如果是重连,不覆盖进入时间,保持原来的进入时间以便正确计算总时长
-            
+
             // 直播间浏览量 +1
             redisCache.incr(PAGE_VIEWS_KEY + liveId, 1);
 
@@ -246,6 +249,9 @@ public class WebSocketServer {
             }
             redisCache.setCacheObject( "live:user:first:entry:" + liveId + ":" + userId, liveUserFirstEntry,1, TimeUnit.HOURS);
 
+            // 推送完课积分倒计时配置信息给前端
+            sendCompletionPointsConfigToUser(session, liveId, userId, live);
+
 
         } else {
             adminRoom.add(session);
@@ -353,7 +359,7 @@ public class WebSocketServer {
                     long watchUserId = (long) userProperties.get("userId");
 
 
-                    
+
                     if (msg.getData() != null && !msg.getData().isEmpty()) {
                         try {
                             Long currentDuration = Long.parseLong(msg.getData());
@@ -362,7 +368,8 @@ public class WebSocketServer {
                             if (currentLive == null) {
                                 break;
                             }
-                            
+
+
                             // 判断直播是否已开始:status=2(直播中) 或 当前时间 >= 开播时间
                             boolean isLiveStarted = false;
                             if (currentLive.getStatus() != null && currentLive.getStatus() == 2) {
@@ -370,22 +377,21 @@ public class WebSocketServer {
                                 isLiveStarted = true;
                             } else if (currentLive.getStartTime() != null) {
                                 // 判断当前时间是否已超过开播时间
-                                LocalDateTime now = java.time.LocalDateTime.now();
+                                LocalDateTime now = LocalDateTime.now();
                                 isLiveStarted = now.isAfter(currentLive.getStartTime()) || now.isEqual(currentLive.getStartTime());
                             }
-                            
-                            if (!isLiveStarted) {
-                                log.debug("[心跳-观看时长] 直播未开始(开播倒计时中),不统计观看时长, liveId={}, status={}, startTime={}", 
-                                        liveId, currentLive.getStatus(), currentLive.getStartTime());
-                                break;
-                            }
-                            
-                            log.debug("[心跳-观看时长] 直播已开始,统计观看时长, liveId={}, userId={}, duration={}秒", 
-                                    liveId, watchUserId, currentDuration);
-                            
+
                             // 使用Hash结构存储:一个直播间一个Hash,包含所有用户的时长
                             String hashKey = "live:watch:duration:hash:" + liveId;
                             String userIdField = String.valueOf(watchUserId);
+
+                            if (!isLiveStarted) {
+                                redisCache.hashDelete(hashKey, userIdField);
+                                log.debug("[心跳-观看时长] 直播未开始,清除预播时长, liveId={}, userId={}", liveId, watchUserId);
+                                break;
+                            }
+
+                            // 直播已开始,记录观看时长
                             // 获取现有时长
                             Object existingDuration = redisCache.hashGet(hashKey, userIdField);
                             // 只有当新的时长更大时才更新
@@ -399,11 +405,11 @@ public class WebSocketServer {
 
                             }
                         } catch (Exception e) {
-                            log.error("[心跳-观看时长] 更新失败, liveId={}, userId={}, data={}", 
+                            log.error("[心跳-观看时长] 更新失败, liveId={}, userId={}, data={}",
                                     liveId, watchUserId, msg.getData(), e);
                         }
                     }
-                    
+
                     sendMessage(session, JSONObject.toJSONString(R.ok().put("data", msg)));
                     break;
                 case "sendMsg":
@@ -744,7 +750,7 @@ public class WebSocketServer {
      */
     public void broadcastWebMessage(Long liveId, String message) {
         ConcurrentHashMap<Long, Session> room = getRoom(liveId);
-        
+
         if (room.isEmpty()) {
             return;
         }
@@ -870,7 +876,7 @@ public class WebSocketServer {
         for (Map.Entry<Long, ConcurrentHashMap<Long, Session>> roomEntry : rooms.entrySet()) {
             Long liveId = roomEntry.getKey();
             ConcurrentHashMap<Long, Session> room = roomEntry.getValue();
-            
+
             // 如果房间为空,跳过
             if (room.isEmpty()) {
                 continue;
@@ -882,12 +888,12 @@ public class WebSocketServer {
             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);
@@ -957,11 +963,11 @@ public class WebSocketServer {
      */
     public void broadcastLikeMessage(Long liveId, String message) {
         ConcurrentHashMap<Long, Session> room = getRoom(liveId);
-        
+
         if (room.isEmpty()) {
             return;
         }
-        
+
         // 使用快照遍历,避免并发修改
         for (Map.Entry<Long, Session> entry : room.entrySet()) {
             Session session = entry.getValue();
@@ -1122,31 +1128,31 @@ public class WebSocketServer {
             // 从 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();
@@ -1155,7 +1161,7 @@ public class WebSocketServer {
                 }
                 liveWatchUser.setOnlineSeconds(onlineSeconds + durationSeconds);
                 liveWatchUser.setUpdateTime(now);
-                
+
                 // 更新数据库
                 liveWatchUserService.updateLiveWatchUserEntry(liveWatchUser);
                 // 如果 LiveWatchUserEntry 存在,并且当前是直播状态(liveFlag = 1),更新 LiveWatchLog
@@ -1167,15 +1173,15 @@ public class WebSocketServer {
 //                            liveWatchUser.getOnlineSeconds());
 //                }
             }
-            
+
             // 删除 Redis 中的进入时间记录
             redisCache.deleteObject(entryTimeKey);
         } catch (Exception e) {
-            log.error("更新用户在线时长异常:liveId={}, userId={}, error={}", 
+            log.error("更新用户在线时长异常:liveId={}, userId={}, error={}",
                     liveId, userId, e.getMessage(), e);
         }
     }
-    
+
     /**
      * 在连接时更新 LiveWatchLog 的 logType
      * 如果 logType 类型不是 2,修改 logType 类型为 1(看课中)
@@ -1186,7 +1192,7 @@ public class WebSocketServer {
             queryLog.setLiveId(liveId);
             queryLog.setQwUserId(String.valueOf(qwUserId));
             queryLog.setExternalContactId(externalContactId);
-            
+            DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
             List<LiveWatchLog> logs = liveWatchLogService.selectLiveWatchLogList(queryLog);
             if (logs != null && !logs.isEmpty()) {
                 for (LiveWatchLog log : logs) {
@@ -1194,15 +1200,18 @@ public class WebSocketServer {
                     if (log.getLogType() == null || log.getLogType() != 2) {
                         log.setLogType(1);
                         liveWatchLogService.updateLiveWatchLog(log);
+                        String liveUserWatchLogKey = String.format(LIVE_USER_WATCH_LOG_CACHE, liveId, userId,externalContactId,qwUserId);
+                        LocalDateTime now = LocalDateTime.now();
+                        redisCache.setCacheObject(liveUserWatchLogKey,formatter.format(now),5,TimeUnit.MINUTES);
                     }
                 }
             }
         } catch (Exception e) {
-            log.error("更新 LiveWatchLog logType 异常(连接时):liveId={}, userId={}, error={}", 
+            log.error("更新 LiveWatchLog logType 异常(连接时):liveId={}, userId={}, error={}",
                     liveId, userId, e.getMessage(), e);
         }
     }
-    
+
     /**
      * 实时更新用户看课状态(在心跳时调用)
      * 在直播期间实时更新用户的看课状态,而不是等到关闭 WebSocket 或清理无效会话时才更新
@@ -1215,36 +1224,36 @@ public class WebSocketServer {
             // 获取当前直播/回放状态
             Map<String, Integer> flagMap = liveWatchUserService.getLiveFlagWithCache(liveId);
             Integer currentLiveFlag = flagMap.get("liveFlag");
-            
+
             // 只在直播状态(liveFlag = 1)时更新
             if (currentLiveFlag == null || currentLiveFlag != 1) {
                 return;
             }
-            
+
             // 获取用户的 companyId 和 companyUserId(使用带缓存的查询方法)
             LiveUserFirstEntry liveUserFirstEntry = liveUserFirstEntryService.selectEntityByLiveIdUserIdWithCache(liveId, userId);
             if (liveUserFirstEntry == null) {
                 return;
             }
-            
+
             Long companyId = liveUserFirstEntry.getCompanyId();
             Long companyUserId = liveUserFirstEntry.getCompanyUserId();
-            
+
             // 如果 companyId 和 companyUserId 有效,则更新看课状态
             if (companyId != null && companyId > 0 && companyUserId != null && companyUserId > 0) {
                 // 检查是否达到关键观看时长节点,在这些节点实时更新
                 // 关键节点:3分钟(180秒)、20分钟(1200秒)、30分钟(1800秒)
                 boolean isKeyDuration = (watchDuration == 180 || watchDuration == 1200 || watchDuration == 1800) ||
                                        (watchDuration > 180 && watchDuration % 60 == 0); // 每分钟更新一次
-                
+
                 // 使用 Redis 缓存控制更新频率,避免频繁更新数据库
                 // 策略:在关键节点立即更新,其他时候每60秒更新一次
                 String updateLockKey = "live:watch:log:update:lock:" + liveId + ":" + userId;
                 String lastUpdateKey = "live:watch:log:last:duration:" + liveId + ":" + userId;
-                
+
                 // 获取上次更新的时长
                 Long lastUpdateDuration = redisCache.getCacheObject(lastUpdateKey);
-                
+
                 // 如果达到关键节点,或者距离上次更新已超过60秒,则更新
                 boolean shouldUpdate = false;
                 if (isKeyDuration) {
@@ -1254,11 +1263,11 @@ public class WebSocketServer {
                     // 每60秒更新一次
                     shouldUpdate = true;
                 }
-                
+
                 if (shouldUpdate) {
                     // 使用分布式锁,避免并发更新(锁超时时间10秒)
                     Boolean canUpdate = redisCache.setIfAbsent(updateLockKey, "1", 10, TimeUnit.SECONDS);
-                    
+
                     if (Boolean.TRUE.equals(canUpdate)) {
                         // 异步更新,避免阻塞心跳处理
                         CompletableFuture.runAsync(() -> {
@@ -1267,7 +1276,7 @@ public class WebSocketServer {
                                 // 更新上次更新的时长
                                 redisCache.setCacheObject(lastUpdateKey, watchDuration, 2, TimeUnit.HOURS);
                             } catch (Exception e) {
-                                log.error("实时更新看课状态异常:liveId={}, userId={}, error={}", 
+                                log.error("实时更新看课状态异常:liveId={}, userId={}, error={}",
                                         liveId, userId, e.getMessage(), e);
                             } finally {
                                 // 释放锁
@@ -1278,11 +1287,11 @@ public class WebSocketServer {
                 }
             }
         } catch (Exception e) {
-            log.error("实时更新看课状态异常:liveId={}, userId={}, error={}", 
+            log.error("实时更新看课状态异常:liveId={}, userId={}, error={}",
                     liveId, userId, e.getMessage(), e);
         }
     }
-    
+
     /**
      * 根据在线时长更新 LiveWatchLog 的 logType
      * @param liveId 直播间ID
@@ -1291,7 +1300,7 @@ public class WebSocketServer {
      * @param companyUserId 销售ID
      * @param onlineSeconds 在线时长(秒)
      */
-    private void updateLiveWatchLogTypeByDuration(Long liveId, Long userId, Long companyId, 
+    private void updateLiveWatchLogTypeByDuration(Long liveId, Long userId, Long companyId,
                                                    Long companyUserId, Long onlineSeconds) {
         try {
             // 获取直播视频总时长(videoType = 1 的视频,使用带缓存的查询方法)
@@ -1303,13 +1312,13 @@ public class WebSocketServer {
                         .mapToLong(LiveVideo::getDuration)
                         .sum();
             }
-            
+
             // 查询 LiveWatchLog
             LiveWatchLog queryLog = new LiveWatchLog();
             queryLog.setLiveId(liveId);
             queryLog.setCompanyId(companyId);
             queryLog.setCompanyUserId(companyUserId);
-            
+
             List<LiveWatchLog> logs = liveWatchLogService.selectLiveWatchLogList(queryLog);
             if (logs == null || logs.isEmpty()) {
                 return;
@@ -1318,7 +1327,7 @@ public class WebSocketServer {
             for (LiveWatchLog log : logs) {
                 boolean needUpdate = false;
                 Integer newLogType = log.getLogType();
-                
+
                 // ① 如果在线时长 <= 3分钟,修改 logType 为 4(看课中断)
                 if (onlineSeconds <= 180) { // 3分钟 = 180秒
                     newLogType = 4;
@@ -1336,7 +1345,7 @@ public class WebSocketServer {
                     log.setFinishTime(now);
                     needUpdate = true;
                 }
-                
+
                 // 如果 logType 已经是 2(完课),不再更新
                 if (needUpdate && (log.getLogType() == null || log.getLogType() != 2)) {
                     log.setLogType(newLogType);
@@ -1344,7 +1353,7 @@ public class WebSocketServer {
                 }
             }
         } catch (Exception e) {
-            log.error("根据在线时长更新 LiveWatchLog logType 异常:liveId={}, userId={}, error={}", 
+            log.error("根据在线时长更新 LiveWatchLog logType 异常:liveId={}, userId={}, error={}",
                     liveId, userId, e.getMessage(), e);
         }
     }
@@ -1392,5 +1401,95 @@ public class WebSocketServer {
         }
     }
 
+    /**
+     * 向用户推送完课积分倒计时配置信息
+     * 在用户连接WebSocket时调用,让前端能够显示倒计时
+     * @param session WebSocket会话
+     * @param liveId 直播间ID
+     * @param userId 用户ID
+     * @param live 直播信息
+     */
+    private void sendCompletionPointsConfigToUser(Session session, Long liveId, Long userId, Live live) {
+        try {
+
+            boolean isLiveStarted = false;
+            if (live.getStatus() != null && live.getStatus() == 2) {
+                isLiveStarted = true;
+            } else if (live.getStartTime() != null) {
+                LocalDateTime now = LocalDateTime.now();
+                isLiveStarted = now.isAfter(live.getStartTime()) || now.isEqual(live.getStartTime());
+            }
+
+            if (!isLiveStarted) {
+                // 直播未开始,不推送完课配置
+                log.debug("[完课配置推送] 直播未开始,跳过推送, liveId={}, userId={}", liveId, userId);
+                return;
+            }
+
+            String configJson = live.getConfigJson();
+            if (configJson == null || configJson.isEmpty()) {
+                return;
+            }
+
+            JSONObject jsonConfig = JSON.parseObject(configJson);
+            boolean enabled = jsonConfig.getBooleanValue("enabled");
+            if (!enabled) {
+                return;
+            }
+
+            Integer completionRate = jsonConfig.getInteger("completionRate");
+            if (completionRate == null || completionRate <= 0 || completionRate > 100) {
+                return;
+            }
+
+            // 3. 计算完课所需观看时长
+            Long videoDuration = live.getDuration();
+            if (videoDuration == null || videoDuration <= 0) {
+                return;
+            }
+
+            // 完课所需时长(秒) = 视频总时长 × 完课比例 / 100
+            long requiredDuration = (long) Math.ceil(videoDuration * completionRate / 100.0);
+
+            // 4. 获取用户当前观看时长
+            String hashKey = "live:watch:duration:hash:" + liveId;
+            String userIdField = String.valueOf(userId);
+            Object existingDuration = redisCache.hashGet(hashKey, userIdField);
+            long currentDuration = existingDuration != null ? Long.parseLong(existingDuration.toString()) : 0L;
+
+            // 5. 检查今天是否已有完课记录
+            LocalDate today = LocalDate.now();
+            Date currentDate = Date.from(today.atStartOfDay(ZoneId.systemDefault()).toInstant());
+            LiveCompletionPointsRecord todayRecord = completionPointsRecordService.selectByUserAndDate(liveId, userId, currentDate);
+
+            boolean hasCompletedToday = (todayRecord != null);
+
+            // 6. 构建配置信息
+            JSONObject configData = new JSONObject();
+            configData.put("videoDuration", videoDuration);  // 视频总时长(秒)
+            configData.put("completionRate", completionRate);  // 完课比例(%)
+            configData.put("requiredDuration", requiredDuration);  // 完课所需时长(秒)
+            configData.put("currentDuration", currentDuration);  // 当前观看时长(秒)
+            configData.put("remainingDuration", Math.max(0, requiredDuration - currentDuration));  // 剩余时长(秒)
+            configData.put("hasCompletedToday", hasCompletedToday);  // 今天是否已完课
+
+            // 7. 推送配置消息
+            SendMsgVo sendMsgVo = new SendMsgVo();
+            sendMsgVo.setLiveId(liveId);
+            sendMsgVo.setUserId(userId);
+            sendMsgVo.setCmd("completionPointsConfig");
+            sendMsgVo.setMsg("完课积分配置");
+            sendMsgVo.setData(configData.toJSONString());
+
+            sendMessage(session, JSONObject.toJSONString(R.ok().put("data", sendMsgVo)));
+
+            log.debug("[完课配置推送] 推送成功, liveId={}, userId={}, 所需时长={}秒, 当前时长={}秒, 剩余={}秒",
+                    liveId, userId, requiredDuration, currentDuration, Math.max(0, requiredDuration - currentDuration));
+
+        } catch (Exception e) {
+            log.error("[完课配置推送] 推送失败, liveId={}, userId={}", liveId, userId, e);
+        }
+    }
+
 }
 

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

+ 4 - 2
fs-qw-task/src/main/java/com/fs/app/taskService/impl/SopLogsTaskServiceImpl.java

@@ -40,6 +40,7 @@ import com.fs.sop.vo.QwCreateLinkByAppVO;
 import com.fs.sop.vo.SopUserLogsVo;
 import com.fs.system.service.ISysConfigService;
 import com.fs.voice.utils.StringUtil;
+import io.swagger.models.auth.In;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.BeanUtils;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -2060,7 +2061,7 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                 //小程序单独
                 case "4":
                     addWatchLogIfNeeded(sopLogs.getSopId(), st.getVideoId().intValue(), st.getCourseId().intValue(), sopLogs.getFsUserId(),  String.valueOf(qwUser.getId()),qwUser.getCompanyUserId().toString(), qwUser.getCompanyId().toString(),
-                            sopLogs.getExternalId(), newTimeString.substring(0, 10), dataTime);
+                            sopLogs.getExternalId(), newTimeString.substring(0, 10), dataTime, 2);
 
                     String linkByMiniApp = createLinkByMiniApp(st, sopLogs.getCorpId(), dataTime, finishTemp.getCourseId().intValue(), Integer.valueOf(st.getVideoId().toString()),
                             String.valueOf(qwUser.getId()), qwUser.getCompanyUserId().toString(), qwUser.getCompanyId().toString(), sopLogs.getExternalId(), config);
@@ -2237,7 +2238,7 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
      */
     private Long addWatchLogIfNeeded(String sopId, Integer videoId, Integer courseId,
                                      Long fsUserId, String qwUserId, String companyUserId,
-                                     String companyId, Long externalId, String startTime, Date createTime) {
+                                     String companyId, Long externalId, String startTime, Date createTime, Integer watchType) {
 
         try {
             FsCourseWatchLog watchLog = new FsCourseWatchLog();
@@ -2255,6 +2256,7 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
             watchLog.setLogType(3);
             watchLog.setUserId(fsUserId);
             watchLog.setCampPeriodTime(convertStringToDate(startTime, "yyyy-MM-dd"));
+            watchLog.setWatchType(watchType);
 
             //存看课记录
             int i = fsCourseWatchLogMapper.insertOrUpdateFsCourseWatchLog(watchLog);

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

@@ -252,7 +252,7 @@ public interface FsUserCourseMapper
             "        LEFT JOIN fs_user_course_period_days fcpd ON fcpd.period_id = fcp.period_id\n" +
             "        LEFT JOIN fs_user_course c ON c.course_id = fcpd.course_id\n" +
             "        WHERE\n" +
-            "        c.is_del = 0 and fcp.del_flag = '0'\n" +
+            "        c.is_del = 0 and fcp.del_flag = '0' and fcp.period_end_time >= #{currentDate}\n" +
             "        AND FIND_IN_SET(#{companyId}, fcp.company_id)\n" +
             "        <if test=\"keyword != null and keyword !='' \">\n" +
             "            AND fcp.period_name LIKE concat('%',#{keyword},'%'\n" +

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

@@ -147,7 +147,7 @@ public interface FsUserCourseVideoMapper
     Long selectFsUserCourseVideoByCourseSort(@Param("courseId")Long courseId, @Param("courseSort")Long courseSort);
 
 
-    @Select("select video_id dict_value, title dict_label  from fs_user_course_video where course_id=#{id} and is_del = 0 order by course_sort")
+    @Select("select video_id dict_value, title dict_label from fs_user_course_video where course_id=#{id} and is_del = 0 order by course_sort")
     List<OptionsVO> selectFsUserCourseVodeAllList(Long id);
 
     @Select({"<script> " +

+ 2 - 1
fs-service/src/main/java/com/fs/course/param/FsUserCourseVideoAddKfUParam.java

@@ -69,6 +69,7 @@ public class FsUserCourseVideoAddKfUParam implements Serializable {
     private String nickName;
 
     private Integer isOpenCourse;
-    private Integer typeFlag; //0 小程序 1 app
+
+    private Integer typeFlag =2; //2 小程序 1 app 默认小程序
 
 }

+ 2 - 2
fs-service/src/main/java/com/fs/course/param/newfs/FsUserCourseAddCompanyUserParam.java

@@ -45,7 +45,7 @@ public class FsUserCourseAddCompanyUserParam implements Serializable {
     private Long id;
 
     /**
-     * 来源标识 0小程序 1 app
+     * 来源标识 2小程序 1 app
      */
-    private Integer typeFlag=0;
+    private Integer typeFlag=2;
 }

+ 5 - 0
fs-service/src/main/java/com/fs/course/param/newfs/FsUserCourseListParam.java

@@ -3,6 +3,8 @@ package com.fs.course.param.newfs;
 import io.swagger.annotations.ApiModelProperty;
 import lombok.Data;
 
+import java.time.LocalDate;
+
 @Data
 public class FsUserCourseListParam {
 
@@ -22,5 +24,8 @@ public class FsUserCourseListParam {
 
     private String qwUserId;
 
+    @ApiModelProperty(value = "当前日期")
+    private LocalDate currentDate = LocalDate.now();
+
 
 }

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

@@ -1997,9 +1997,9 @@ public class FsUserCourseVideoServiceImpl implements IFsUserCourseVideoService {
                 fsCourseWatchLog.setDuration(0L);
                 fsCourseWatchLog.setCreateTime(new Date());
                 fsCourseWatchLog.setLogType(1);
-//                if (param.getTypeFlag() != null) {
-//                    fsCourseWatchLog.setTypeFlag(param.getTypeFlag());
-//                }
+                if (param.getTypeFlag() != null) {
+                    fsCourseWatchLog.setWatchType(param.getTypeFlag());
+                }
                 courseWatchLogMapper.insertFsCourseWatchLog(fsCourseWatchLog);
                 String redisKey = "h5wxuser:watch:heartbeat:" + param.getUserId() + ":" + param.getVideoId() + ":" + 0;
                 redisCache.setCacheObject(redisKey, LocalDateTime.now().toString());
@@ -2013,7 +2013,7 @@ public class FsUserCourseVideoServiceImpl implements IFsUserCourseVideoService {
         if (companyUser == null) {
             return ResponseResult.fail(405, "当前销售不存在");
         }
-        if(param.getTypeFlag()==0){
+        if(param.getTypeFlag()==2){
             //小程序看课需要判断是否注册
             //营期公司的开关状态
             List<Integer> selected = periodCompanyMapper.selectRegistrationSwitchByPeriodId(param.getPeriodId());
@@ -2114,9 +2114,9 @@ public class FsUserCourseVideoServiceImpl implements IFsUserCourseVideoService {
             fsCourseWatchLog.setCreateTime(new Date());
             fsCourseWatchLog.setLogType(1);
             fsCourseWatchLog.setProject(courseProject);
-//            if (param.getTypeFlag() != null) {
-//                fsCourseWatchLog.setTypeFlag(param.getTypeFlag());
-//            }
+            if (param.getTypeFlag() != null) {
+                fsCourseWatchLog.setWatchType(param.getTypeFlag());
+            }
             courseWatchLogMapper.insertFsCourseWatchLog(fsCourseWatchLog);
 
             String redisKey = "h5wxuser:watch:heartbeat:" + param.getUserId() + ":" + param.getVideoId() + ":" + param.getCompanyUserId();
@@ -3182,9 +3182,9 @@ public class FsUserCourseVideoServiceImpl implements IFsUserCourseVideoService {
             log.setCreateTime(new Date());
             log.setLogType(3);
             logger.info("【群聊生成看课记录】:{}", param);
-//            if(param.getTypeFlag()!=null){
-//                log.setTypeFlag(param.getTypeFlag());
-//            }
+            if(param.getTypeFlag()!=null){
+                log.setWatchType(param.getTypeFlag());
+            }
             courseWatchLogMapper.insertFsCourseWatchLog(log);
         } catch (BeansException e) {
             return R.error("群聊生成看课记录失败!");

+ 19 - 1
fs-service/src/main/java/com/fs/course/vo/FsUserCourseComplaintRecordPageListVO.java

@@ -27,7 +27,7 @@ public class FsUserCourseComplaintRecordPageListVO extends BaseEntity{
     @Excel(name = "投诉类型")
     private String complaintTypeName;
 
-//    @Excel(name = "投诉内容")
+    @Excel(name = "投诉内容")
     private String complaintContent;
 
     @Excel(name = "投诉上传图片")
@@ -46,4 +46,22 @@ public class FsUserCourseComplaintRecordPageListVO extends BaseEntity{
     @Excel(name = "看课状态")
     private String status;
 
+    /**
+     * 销售账号
+     */
+    @Excel(name = "销售账号")
+    private String userName;
+
+    /**
+     * 销售昵称
+     */
+    @Excel(name = "销售昵称")
+    private String saleNickName;
+
+    /**
+     * 销售公司
+     */
+    @Excel(name = "销售公司")
+    private  String company;
+
 }

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

@@ -260,8 +260,8 @@ public interface FsUserMapper
             "</script>"})
     Long selectFsUserExportListVOCount(FsUserParam fsUser);
 
-    @Select("SELECT mp_open_id as openId FROM fs_user WHERE mp_open_id is not null")
-    List<UserOpenIdVO> selectOpenIdList();
+    @Select("SELECT user_id as userId, mp_open_id as openId FROM fs_user WHERE user_id > #{lastId} AND mp_open_id IS NOT NULL ORDER BY user_id LIMIT #{limit}")
+    List<UserOpenIdVO> selectOpenIdList(@Param("lastId") long lastId, @Param("limit") int limit);
 
     @Select("select * from fs_user where phone=#{phone}")
     FsUser selectFsUserByMpOpenId(@Param("phone") String phone);

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

@@ -143,7 +143,7 @@ public interface IFsUserService
      * 获取所有用户openId
      * @return
      */
-    List<UserOpenIdVO> selectOpenIdList();
+    List<UserOpenIdVO> selectOpenIdList(long lastId, int limit);
 
     FsUser selectFsUserByMpOpenId(String openId);
 

+ 3 - 3
fs-service/src/main/java/com/fs/his/service/impl/FsUserServiceImpl.java

@@ -570,8 +570,8 @@ public class FsUserServiceImpl implements IFsUserService {
     }
 
     @Override
-    public List<UserOpenIdVO> selectOpenIdList() {
-        return fsUserMapper.selectOpenIdList();
+    public List<UserOpenIdVO> selectOpenIdList(long lastId, int limit) {
+        return fsUserMapper.selectOpenIdList(lastId, limit);
     }
 
 
@@ -878,7 +878,7 @@ public class FsUserServiceImpl implements IFsUserService {
         Integer blackNum = map.getOrDefault("2", 0);
 
         // 黑名单人数加上重粉的数量,正常人数去掉重粉数量
-        int repeatUserNumber = fsUserMapper.getRepeatUserNumber(userId);
+        int repeatUserNumber = 0; //fsUserMapper.getRepeatUserNumber(userId);
         pageVO.setNumber(normalNum - repeatUserNumber);
         pageVO.setBlackNum(blackNum + repeatUserNumber);
         pageVO.setSmallBlackNum(smallBlackNum + repeatUserNumber);

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

@@ -165,4 +165,7 @@ public class FsUserVO extends FsUser implements Serializable
     @ApiModelProperty(value = "绑定时间")
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
     private Date bindTime;
+
+    @ApiModelProperty(value = "绑定关系表主键ID")
+    private Long userCompanyUserId;
 }

+ 1 - 0
fs-service/src/main/java/com/fs/his/vo/UserOpenIdVO.java

@@ -8,4 +8,5 @@ public class UserOpenIdVO {
 
     @Excel(name = "openId")
     private String openId;
+    private  Long userId;
 }

+ 12 - 0
fs-service/src/main/java/com/fs/huifuPay/sdk/opps/core/request/V2TradePaymentScanpayQueryRequest.java

@@ -47,6 +47,10 @@ public class V2TradePaymentScanpayQueryRequest extends BaseRequest {
     @JSONField(name = "party_order_id")
     private String partyOrderId;
 
+
+
+    String appId; //多小程序支付
+
     @Override
     public FunctionCodeEnum getFunctionCode() {
         return FunctionCodeEnum.V2_TRADE_PAYMENT_SCANPAY_QUERY;
@@ -121,4 +125,12 @@ public class V2TradePaymentScanpayQueryRequest extends BaseRequest {
         this.partyOrderId = partyOrderId;
     }
 
+    public String getAppId() {
+        return appId;
+    }
+
+    public void setAppId(String appId) {
+        this.appId = appId;
+    }
+
 }

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

@@ -29,7 +29,7 @@ public class LiveCouponUser extends BaseEntity
 
     /** 优惠券所属用户 */
     @Excel(name = "优惠券所属用户")
-    private Integer userId;
+    private Long userId;
 
     /** 优惠券名称 */
     @Excel(name = "优惠券名称")

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

@@ -145,7 +145,7 @@ public class LiveOrder extends BaseEntity {
     private String isDel;
 
     /** 成本价 */
-    @Excel(name = "成本价")
+    @Excel(name = "成本价")
     private BigDecimal costPrice;
 
     /** 核销码 */

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

@@ -1,6 +1,8 @@
 package com.fs.live.domain;
 
 import java.util.Date;
+import java.util.Objects;
+
 import com.fasterxml.jackson.annotation.JsonFormat;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.fs.common.annotation.Excel;
@@ -86,4 +88,19 @@ public class LiveWatchLog extends BaseEntity{
      */
     private Integer replayBuy;
 
+    @Override
+    public boolean equals(Object obj) {
+        if (this == obj) return true;
+        if (obj == null || getClass() != obj.getClass()) return false;
+        LiveWatchLog that = (LiveWatchLog) obj;
+        return Objects.equals(liveId, that.liveId) &&
+                Objects.equals(externalContactId, that.externalContactId) &&
+                Objects.equals(qwUserId, that.qwUserId);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(liveId, externalContactId, qwUserId);
+    }
+
 }

+ 2 - 0
fs-service/src/main/java/com/fs/live/mapper/LiveAfterSalesMapper.java

@@ -133,4 +133,6 @@ public interface LiveAfterSalesMapper {
 
     @Select(" select  * from  live_after_sales where order_id = #{orderId} and sales_status = 0 ")
     LiveAfterSales getLiveAfterSalesByOrderId(@Param("orderId") Long orderId);
+
+    List<LiveAfterSalesVo> selectLiveAfterSalesVoListExport(LiveAfterSalesVo liveAfterSales);
 }

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

@@ -33,6 +33,12 @@ public interface LiveCompletionPointsRecordMapper {
      */
     LiveCompletionPointsRecord selectLatestByUser(@Param("userId") Long userId);
 
+    /**
+     * 查询用户在某直播间最近一次完课记录(不限制日期)
+     */
+    LiveCompletionPointsRecord selectLatestByUserAndLiveId(@Param("liveId") Long liveId, 
+                                                            @Param("userId") Long userId);
+
     /**
      * 查询用户未领取的完课记录列表
      */

+ 21 - 0
fs-service/src/main/java/com/fs/live/mapper/LiveOrderMapper.java

@@ -126,6 +126,27 @@ public interface LiveOrderMapper {
     @Select("select * from live_order where order_code=#{orderCode} limit 1")
     LiveOrder selectLiveOrderByOrderCode(@Param("orderCode") String orderCode);
 
+    /**
+     * 查询状态为6(被拆分)的订单
+     */
+    @Select("select * from live_order where status = 6")
+    List<LiveOrder> selectSplitOrders();
+
+    /**
+     * 根据父订单号查询子订单(拆分订单)
+     * 通过用户ID、直播ID、公司ID等关联查找可能的拆分订单
+     */
+    @Select({"<script> " +
+            "select * from live_order " +
+            "where status != 6 " +
+            "and user_id = (select user_id from live_order where order_code = #{parentOrderCode} limit 1) " +
+            "and live_id = (select live_id from live_order where order_code = #{parentOrderCode} limit 1) " +
+            "and create_time >= (select create_time from live_order where order_code = #{parentOrderCode} limit 1) " +
+            "and order_code != #{parentOrderCode} " +
+            "order by create_time asc " +
+            "</script>"})
+    List<LiveOrder> selectChildOrdersByParentOrderCode(@Param("parentOrderCode") String parentOrderCode);
+
     List<LiveOrder> selectFsOutDateOrder();
 
 

+ 1 - 1
fs-service/src/main/java/com/fs/live/param/LiveOrderSearchParam.java

@@ -131,7 +131,7 @@ public class LiveOrderSearchParam extends BaseEntity {
     private String isDel;
 
     /** 成本价 */
-    @Excel(name = "成本价")
+    @Excel(name = "成本价")
     private BigDecimal costPrice;
 
     /** 核销码 */

+ 2 - 0
fs-service/src/main/java/com/fs/live/param/MergedOrderQueryParam.java

@@ -118,5 +118,7 @@ public class MergedOrderQueryParam extends BaseQueryParam implements Serializabl
 
     /** ERP电话 */
     private String erpPhoneNumber;
+    /** 汇付商户订单号 */
+    private String hfshh;
 }
 

+ 2 - 0
fs-service/src/main/java/com/fs/live/service/ILiveAfterSalesService.java

@@ -93,4 +93,6 @@ public interface ILiveAfterSalesService {
     Integer selectLiveAfterSalesCount(long l, int i);
 
     R handleImmediatelyRefund(Long orderId);
+
+    List<LiveAfterSalesVo> selectLiveAfterSalesVoListExport(LiveAfterSalesVo liveAfterSales);
 }

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

@@ -2,6 +2,7 @@ package com.fs.live.service;
 
 import com.fs.live.domain.LiveCompletionPointsRecord;
 
+import java.util.Date;
 import java.util.List;
 
 /**
@@ -40,4 +41,13 @@ public interface ILiveCompletionPointsRecordService {
      * @return 完课记录列表
      */
     List<LiveCompletionPointsRecord> getUserRecords(Long liveId, Long userId);
+
+    /**
+     * 根据用户和日期查询完课记录
+     * @param liveId 直播ID
+     * @param userId 用户ID
+     * @param date 日期
+     * @return 完课记录
+     */
+    LiveCompletionPointsRecord selectByUserAndDate(Long liveId, Long userId, Date date);
 }

+ 54 - 0
fs-service/src/main/java/com/fs/live/service/impl/LiveAfterSalesServiceImpl.java

@@ -192,6 +192,59 @@ public class LiveAfterSalesServiceImpl implements ILiveAfterSalesService {
 
 //    @Autowired
 //    private FsStoreDeliversService fsStoreDeliversService;
+
+
+    @Override
+    public List<LiveAfterSalesVo> selectLiveAfterSalesVoListExport(LiveAfterSalesVo liveAfterSales) {
+        List<LiveAfterSalesVo> liveAfterSalesVos = baseMapper.selectLiveAfterSalesVoListExport(liveAfterSales);
+        List<Long> orderIds = new ArrayList<>();
+        Map<Long, List<LiveOrderItemListUVO>> orderItemMap = new HashMap<>();
+        if(null != liveAfterSalesVos && !liveAfterSalesVos.isEmpty()){
+            orderIds = liveAfterSalesVos.stream().map(e -> e.getOrderId()).collect(Collectors.toList());
+            if(null != orderIds && !orderIds.isEmpty()){
+                List<LiveOrderItemListUVO> liveOrderItemListUVOS = liveOrderItemMapper.selectLiveOrderItemListUVOByOrderIds(orderIds);
+                orderItemMap = liveOrderItemListUVOS.stream()
+                        .collect(Collectors.groupingBy(LiveOrderItemListUVO::getOrderId));
+            }
+        }
+        boolean mapEmpty = orderItemMap.isEmpty();
+        for (LiveAfterSalesVo item : liveAfterSalesVos) {
+            if(ObjectUtil.isNotNull(item.getUserId())) {
+                FsUser fsUser = fsUserCacheService.selectFsUserById(item.getUserId());
+                if(ObjectUtil.isNotNull(fsUser)) {
+                    item.setUserName(String.format("%s_%s",fsUser.getUserId(),fsUser.getNickname()));
+                }
+            }
+
+            if(ObjectUtil.isNull(item.getCompanyUserNickName())) {
+                item.setCompanyUserNickName("-");
+            }
+
+            if(ObjectUtil.isNull(item.getCompanyName())){
+                item.setCompanyName("-");
+            }
+
+            if(!mapEmpty && orderItemMap.containsKey(item.getOrderId())){
+                List<LiveOrderItemListUVO> liveOrderItemListUVOS = orderItemMap.get(item.getOrderId());
+                for (LiveOrderItemListUVO liveOrderItemListUVO : liveOrderItemListUVOS) {
+                    try {
+                        JSONObject jsO = JSONObject.parseObject(liveOrderItemListUVO.getJsonInfo());
+                        item.setProductName(StringUtils.isNotBlank(item.getProductName()) ? item.getProductName() + "," + jsO.getString("productName") : jsO.getString("productName"));
+                        item.setProductBarCode(StringUtils.isNotBlank(item.getProductBarCode()) ? item.getProductBarCode() + "," + jsO.getString("barCode") : jsO.getString("barCode"));
+                        item.setSku(StringUtils.isNotBlank(item.getSku()) ? item.getSku() + "," + jsO.getString("sku") : jsO.getString("sku"));
+                        item.setNum(StringUtils.isNotBlank(item.getNum()) ? item.getNum() + "," + jsO.getString("num") : jsO.getString("num"));
+                        item.setPrice(StringUtils.isNotBlank(item.getPrice()) ? item.getPrice() + "," + jsO.getString("price") : jsO.getString("price"));
+                        item.setCost(StringUtils.isNotBlank(item.getCost()) ? item.getCost() + "," + liveOrderItemListUVO.getCost() : liveOrderItemListUVO.getCost());
+                        item.setCateName(StringUtils.isNotBlank(item.getCateName()) ? item.getCateName() + "," + liveOrderItemListUVO.getCateName() : liveOrderItemListUVO.getCateName());
+                    } catch (Exception ex) {
+                        log.error("售后订单商品信息转换异常",ex);
+                    }
+                }
+            }
+
+        }
+        return liveAfterSalesVos;
+    }
     /**
      * 查询售后记录列表
      *
@@ -1124,4 +1177,5 @@ public class LiveAfterSalesServiceImpl implements ILiveAfterSalesService {
 
         return R.ok();
     }
+
 }

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

@@ -261,6 +261,14 @@ public class LiveCompletionPointsRecordServiceImpl implements ILiveCompletionPoi
         return recordMapper.selectRecordsByUser(liveId, userId);
     }
 
+    /**
+     * 根据用户和日期查询完课记录
+     */
+    @Override
+    public LiveCompletionPointsRecord selectByUserAndDate(Long liveId, Long userId, Date date) {
+        return recordMapper.selectByUserAndDate(liveId, userId, date);
+    }
+
     /**
      * 从直播配置中获取完课积分配置
      */

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

@@ -337,7 +337,7 @@ public class LiveCouponServiceImpl implements ILiveCouponService
 
         LiveCouponUser userRecord = new LiveCouponUser();
         userRecord.setCouponId(liveCoupon.getCouponId());
-        userRecord.setUserId(Math.toIntExact(coupon.getUserId()));
+        userRecord.setUserId(coupon.getUserId());
         userRecord.setCouponTitle(liveCoupon.getTitle());
         userRecord.setCouponPrice(liveCoupon.getCouponPrice());
         userRecord.setUseMinPrice(liveCoupon.getUseMinPrice());

+ 7 - 4
fs-service/src/main/java/com/fs/live/service/impl/LiveOrderServiceImpl.java

@@ -3193,7 +3193,7 @@ public class LiveOrderServiceImpl implements ILiveOrderService {
                         });
                         String s = (String) resultMap.get("package");
                         resultMap.put("packageValue", s);
-                        return R.ok().put("payType", param.getPayType()).put("result", resultMap);
+                        return R.ok().put("payType", param.getPayType()).put("result", resultMap).put("type", "hf");
                     } else {
                         return R.error(result.getResp_desc());
                     }
@@ -3532,6 +3532,7 @@ public class LiveOrderServiceImpl implements ILiveOrderService {
                     V2TradePaymentScanpayQueryRequest request = new V2TradePaymentScanpayQueryRequest();
                     request.setOrgReqDate(new SimpleDateFormat("yyyyMMdd").format(payment.getCreateTime()));
                     request.setOrgHfSeqId(payment.getTradeNo());
+                    request.setAppId(payment.getAppId());
                     HuiFuQueryOrderResult queryOrderResult = null;
                     try {
                         queryOrderResult = huiFuService.queryOrder(request);
@@ -3624,9 +3625,11 @@ public class LiveOrderServiceImpl implements ILiveOrderService {
         }
 
         LiveUserFirstEntry liveUserFirstEntry = liveUserFirstEntryService.selectEntityByLiveIdUserId(liveOrder.getLiveId(), Long.parseLong(liveOrder.getUserId()));
-        liveOrder.setCompanyId(liveUserFirstEntry.getCompanyId());
-        liveOrder.setCompanyUserId(liveUserFirstEntry.getCompanyUserId());
-        liveOrder.setTuiUserId(liveUserFirstEntry.getCompanyUserId());
+        if (ObjectUtil.isNotEmpty(liveUserFirstEntry)) {
+            liveOrder.setCompanyId(liveUserFirstEntry.getCompanyId());
+            liveOrder.setCompanyUserId(liveUserFirstEntry.getCompanyUserId());
+            liveOrder.setTuiUserId(liveUserFirstEntry.getCompanyUserId());
+        }
         String orderSn = OrderCodeUtils.getOrderSn();
 //        String orderSn = "123"; // todo yhq
         log.info("订单生成:"+orderSn);

+ 49 - 12
fs-service/src/main/java/com/fs/live/service/impl/LiveServiceImpl.java

@@ -1,6 +1,7 @@
 package com.fs.live.service.impl;
 
 import cn.binarywang.wx.miniapp.api.WxMaService;
+import cn.hutool.core.collection.CollUtil;
 import cn.hutool.core.thread.ThreadUtil;
 import cn.hutool.core.util.ObjectUtil;
 import com.alibaba.fastjson.JSON;
@@ -319,6 +320,9 @@ public class LiveServiceImpl implements ILiveService
 
     @Override
     public R subNotifyLive(LiveNotifyParam param) {
+        if (StringUtils.isEmpty(param.getAppId())) {
+            return R.error("小程序订阅失败:appId为空!");
+        }
         LiveMiniprogramSubNotifyTask notifyTask = new LiveMiniprogramSubNotifyTask();
         notifyTask.setPage("pages_course/living?liveId=" + param.getLiveId());
         notifyTask.setTaskName("直播间预约提醒");
@@ -326,7 +330,7 @@ public class LiveServiceImpl implements ILiveService
         Long userId = param.getUserId();
         Wrapper<FsUserWx> queryWrapper = Wrappers.<FsUserWx>lambdaQuery()
                 .eq(FsUserWx::getFsUserId, userId)
-                .eq(FsUserWx::getAppId, StringUtils.isEmpty(param.getAppId()) ? "wx44beed5640bcb1ba" : param.getAppId()); // 卓美小程序
+                .eq(FsUserWx::getAppId, StringUtils.isEmpty(param.getAppId()) ? "wxd791d5933ed42218" : param.getAppId()); // 卓美小程序
         FsUserWx fsUserWx = fsUserWxMapper.selectOne(queryWrapper);
         String maOpenId = "";
         if (fsUserWx == null) {
@@ -972,7 +976,38 @@ public class LiveServiceImpl implements ILiveService
             redisCache.redisTemplate.opsForZSet().add("live:auto_task:" + live.getLiveId(), JSON.toJSONString(liveAutoTask),liveAutoTask.getAbsValue().getTime());
             redisCache.redisTemplate.expire("live:auto_task:"+live.getLiveId(), 1, TimeUnit.DAYS);
         });
+        String cacheKey = String.format(LiveKeysConstant.LIVE_DATA_CACHE, live.getLiveId());
+        redisCache.deleteObject(cacheKey);
+        String cacheKey2 = String.format(LiveKeysConstant.LIVE_FLAG_CACHE, live.getLiveId());
+        redisCache.deleteObject(cacheKey2);
+
+        // 将开启的直播间信息写入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);
+        }
 
         return R.ok();
     }
@@ -1223,9 +1258,9 @@ public class LiveServiceImpl implements ILiveService
         Map<Long, LiveAutoTask> goodsTaskMap = goodsTasks.stream()
                 .collect(Collectors.toMap(task -> parseIdFromContent(task.getContent(), "goodsId"),
                         Function.identity(), (existing, replacement) -> existing));
-        Map<Long, LiveAutoTask> shelfTaskMap = shelfTasks.stream()
-                .collect(Collectors.toMap(task -> parseIdFromContent(task.getContent(), "goodsId"),
-                        Function.identity(), (existing, replacement) -> existing));
+        // 使用 groupingBy 支持同一个商品有多个上下架任务(不同时间点的上架/下架操作)
+        Map<Long, List<LiveAutoTask>> shelfTaskMap = shelfTasks.stream()
+                .collect(Collectors.groupingBy(task -> parseIdFromContent(task.getContent(), "goodsId")));
 
         LiveGoods queryParam = new LiveGoods();
         queryParam.setLiveId(existLiveId);
@@ -1265,14 +1300,16 @@ public class LiveServiceImpl implements ILiveService
                     liveAutoTaskService.directInsertLiveAutoTask(newTask);
                 }
 
-                // 复制上下架任务(taskType=6)
-                LiveAutoTask shelfTask = shelfTaskMap.get(liveGoods.getGoodsId());
-                if (shelfTask != null) {
-                    JSONObject contentJson = JSON.parseObject(shelfTask.getContent());
-                    contentJson.put("goodsId", newGoods.getGoodsId());
-                    LiveAutoTask newTask = createAutoTaskEntity(shelfTask, newLiveId, now,
-                            contentJson.toJSONString());
-                    liveAutoTaskService.directInsertLiveAutoTask(newTask);
+                // 复制上下架任务(taskType=6)- 支持同一个商品有多个上下架任务
+                List<LiveAutoTask> shelfTaskList = shelfTaskMap.get(liveGoods.getGoodsId());
+                if (shelfTaskList != null && !shelfTaskList.isEmpty()) {
+                    for (LiveAutoTask shelfTask : shelfTaskList) {
+                        JSONObject contentJson = JSON.parseObject(shelfTask.getContent());
+                        contentJson.put("goodsId", newGoods.getGoodsId());
+                        LiveAutoTask newTask = createAutoTaskEntity(shelfTask, newLiveId, now,
+                                contentJson.toJSONString());
+                        liveAutoTaskService.directInsertLiveAutoTask(newTask);
+                    }
                 }
             }
         }

+ 8 - 5
fs-service/src/main/java/com/fs/live/service/impl/LiveWatchUserServiceImpl.java

@@ -11,15 +11,11 @@ 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.*;
@@ -863,6 +859,9 @@ public class LiveWatchUserServiceImpl implements ILiveWatchUserService {
         //查询直播间的标签配置
         List<LiveTagItemVO> liveTagConfig = liveTagConfigMapper.getLiveTagListByliveId(liveId);
         log.info("处理直播间打标签: liveTagConfig={}", liveTagConfig);
+        if(null == liveTagConfig || liveTagConfig.isEmpty()){
+            return;
+        }
         /**
          * 8	回放已下单
          * 7	直播已下单
@@ -881,7 +880,10 @@ public class LiveWatchUserServiceImpl implements ILiveWatchUserService {
                 ));
         //查询直播间的看课记录
         List<LiveWatchLog> liveWatchLogs = liveWatchLogMapper.selectLiveWatchLogByLiveId(liveId);
-
+        log.info("处理直播间打标签: liveWatchLogs={}", liveWatchLogs);
+        if(null == liveWatchLogs || liveWatchLogs.isEmpty()){
+            return;
+        }
         //根据配置给每位用户打上标签
         List<HandleUserTagVO> handleUserTagVOS = new ArrayList<>();
         liveWatchLogs.forEach(liveLog -> {
@@ -936,6 +938,7 @@ public class LiveWatchUserServiceImpl implements ILiveWatchUserService {
             }
             handleUserTagVOS.add(addItem);
         });
+        log.info("处理直播间打标签最终打标签:{}",handleUserTagVOS);
         handleUserTags2Qw(handleUserTagVOS);
     }
 

+ 1 - 1
fs-service/src/main/java/com/fs/live/vo/LiveOrderListVo.java

@@ -136,7 +136,7 @@ public class LiveOrderListVo extends BaseEntity {
     private String isDel;
 
     /** 成本价 */
-    @Excel(name = "成本价")
+    @Excel(name = "成本价")
     private BigDecimal costPrice;
 
     /** 核销码 */

+ 1 - 1
fs-service/src/main/java/com/fs/live/vo/LiveOrderVoZm.java

@@ -118,7 +118,7 @@ public class LiveOrderVoZm{
     @Excel(name = "销售价格")
     private BigDecimal price;
 
-    @Excel(name = "成本价")
+    @Excel(name = "成本价")
     private BigDecimal cost;
 
     @Excel(name = "结算价格")

+ 5 - 1
fs-service/src/main/java/com/fs/live/vo/MergedOrderExportVO.java

@@ -19,6 +19,10 @@ public class MergedOrderExportVO implements Serializable
 {
     private static final long serialVersionUID = 1L;
 
+    /** 订单类型 */
+    @Excel(name = "订单类型")
+    private String orderTypeName;
+
     /** 订单号 */
     @Excel(name = "订单号")
     private String orderCode;
@@ -52,7 +56,7 @@ public class MergedOrderExportVO implements Serializable
     private BigDecimal price;
 
     /** 成本价 */
-    @Excel(name = "成本价")
+    @Excel(name = "成本价")
     private BigDecimal cost;
 
     /** 商品金额 */

+ 5 - 0
fs-service/src/main/java/com/fs/qw/vo/QwExternalContactComplaintVO.java

@@ -35,6 +35,11 @@ public class QwExternalContactComplaintVO extends BaseEntity {
     */
     private String nickName;
 
+    /**
+     * 销售公司
+     */
+    private  String company;
+
 
     /**
     * 客户小程序id

+ 16 - 1
fs-service/src/main/java/com/fs/store/mapper/FsUserCourseCountMapper.java

@@ -68,8 +68,17 @@ public interface FsUserCourseCountMapper
     /**
      * 获取看课统计表结果
      * @return list
+     * @param userIds 用户id列表
      */
-    List<FsUserCourseCount> getCountResult();
+    List<FsUserCourseCount> getCountResult(@Param("userIds") List<Long> userIds);
+
+    /**
+     *
+     * @param offset 从第几条开始查询
+     * @param pageSize 每页数量
+     * @return
+     */
+    List<Long> getUsersByPage(@Param("offset") int offset, @Param("pageSize") int pageSize);
 
     /**
      * 获取最近七天每天最大心跳时间的看课记录数据
@@ -82,6 +91,12 @@ public interface FsUserCourseCountMapper
      */
     void insertFsUserCourseCountTask(FsUserCourseCount fsUserCourseCount);
 
+    /**
+     * 插入/更新看课统计表
+     * @param list
+     */
+    void batchInsertOrUpdate(@Param("list") List<FsUserCourseCount> list);
+
     /**
      * 查询会员最新的看课状态和心跳时间
      * @return

+ 103 - 15
fs-service/src/main/java/com/fs/store/service/impl/FsUserCourseCountServiceImpl.java

@@ -2,16 +2,21 @@ package com.fs.store.service.impl;
 
 import com.fs.common.utils.DateUtils;
 import com.fs.course.mapper.FsCourseWatchLogMapper;
+import com.fs.his.mapper.FsUserMapper;
+import com.fs.his.service.impl.FsUserServiceImpl;
 import com.fs.store.domain.FsUserCourseCount;
 import com.fs.store.mapper.FsUserCourseCountMapper;
 import com.fs.store.service.IFsUserCourseCountService;
 import com.google.common.collect.Lists;
+import lombok.extern.slf4j.Slf4j;
 import org.apache.ibatis.session.ExecutorType;
 import org.apache.ibatis.session.SqlSession;
 import org.apache.ibatis.session.SqlSessionFactory;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
 
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.stream.Collectors;
@@ -23,6 +28,7 @@ import java.util.stream.Collectors;
  * @date 2025-04-02
  */
 @Service
+@Slf4j
 public class FsUserCourseCountServiceImpl implements IFsUserCourseCountService
 {
     @Autowired
@@ -115,29 +121,111 @@ public class FsUserCourseCountServiceImpl implements IFsUserCourseCountService
 
     @Override
     public void insertFsUserCourseCountTask() {
-        // 1、获取统计结果
-        List<FsUserCourseCount> countResult = fsUserCourseCountMapper.getCountResult();
+        // 总处理量-执行中
+        int totalProcessed = 0;
+        long startTime = System.currentTimeMillis();
+
+        log.info("开始处理~~~~~~~~~~~~~~~~~");
+        // 1、分页分批次查询并处理数据
+        int page = 1;
+        int pageSize = 1000;
+        while (true) {
+            List<Long> userIds = fsUserCourseCountMapper.getUsersByPage((page - 1) * pageSize, pageSize);
+            if (userIds.isEmpty()) {
+                break;
+            }
+            log.info("处理第 {} 页,用户数: {}", page, userIds.size());
+
+            // 2、查询当前页用户的统计结果
+            List<FsUserCourseCount> countResult = Collections.emptyList();
+            if (!userIds.isEmpty()) {
+                countResult = fsUserCourseCountMapper.getCountResult(userIds);
+
+                // 3、分批插入数据
+                this.batchInsertOrUpdateNew(countResult);
+            }
+
+            totalProcessed += countResult.size();
+            // 每处理10页记录一次进度
+            if (page % 10 == 0) {
+                log.info("处理进度: 第{}页,已处理 {} 条记录", page, totalProcessed);
+            }
+            page++;
+        }
+
+        long endTime = System.currentTimeMillis();
+        log.info("处理完成!总共处理 {} 条记录,总耗时: {} 毫秒", totalProcessed, endTime - startTime);
+
+        // 获取统计结果
+//        List<FsUserCourseCount> countResult = fsUserCourseCountMapper.getCountResult();
 
         // 查询用户-每天的最新的看课状态,和最后的心跳时间
-        List<FsUserCourseCount> userStatusAndLastWatchDate = fsUserCourseCountMapper.getUserStatusAndLastWatchDate();
-        Map<String, FsUserCourseCount> map = userStatusAndLastWatchDate.stream()
-                .collect(Collectors.toMap(k -> String.format("%s-%s-%s", k.getUserId(),k.getProjectId(), k.getLastDate()), v -> v));
-
-        for (FsUserCourseCount data : countResult) {
-            String key = String.format("%s-%s-%s", data.getUserId(),data.getProjectId(), data.getLastDate());
-            FsUserCourseCount fsUserCourseCount = map.get(key);
-            if(fsUserCourseCount != null){
-                data.setLastWatchDate(fsUserCourseCount.getLastWatchDate());
-                data.setStatus(fsUserCourseCount.getStatus());
+//        List<FsUserCourseCount> userStatusAndLastWatchDate = fsUserCourseCountMapper.getUserStatusAndLastWatchDate();
+//        Map<String, FsUserCourseCount> map = userStatusAndLastWatchDate.stream()
+//                .collect(Collectors.toMap(k -> String.format("%s-%s-%s", k.getUserId(),k.getProjectId(), k.getLastDate()), v -> v));
+
+//        for (FsUserCourseCount data : countResult) {
+//            String key = String.format("%s-%s-%s", data.getUserId(),data.getProjectId(), data.getLastDate());
+//            FsUserCourseCount fsUserCourseCount = map.get(key);
+//            if(fsUserCourseCount != null){
+//                data.setLastWatchDate(fsUserCourseCount.getLastWatchDate());
+//                data.setStatus(fsUserCourseCount.getStatus());
 //                data.setStopWatchDays(fsUserCourseCount.getStopWatchDays());
-            }
+//            }
+//        }
+    }
+
+    /**
+     * 分批次插入数据
+     * @author Caolq
+     * @param list 数据列表
+     */
+    private void batchInsertOrUpdateNew(List<FsUserCourseCount> list){
+        if (CollectionUtils.isEmpty(list)) {
+            return;
         }
 
-        // 2、分批插入数据
-        this.batchInsert(countResult);
+        // 分批次处理,一次提交500条
+        int insertBatchSize = 500;
+        try (SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH, false)) {
+            FsUserCourseCountMapper mapper = sqlSession.getMapper(FsUserCourseCountMapper.class);
+
+            long totalStartTime = System.currentTimeMillis();
+            int totalInserted = 0;
+
+            // 将数据分割
+            List<List<FsUserCourseCount>> batches = Lists.partition(list, insertBatchSize);
+
+            for (int i = 0; i < batches.size(); i++) {
+                List<FsUserCourseCount> batch = batches.get(i);
+                try {
+                    // 批量插入
+                    mapper.batchInsertOrUpdate(batch);
+
+                    // 定期提交事务,避免事务过大
+                    if ((i + 1) % 10 == 0) {
+                        sqlSession.commit();
+                        log.debug("已提交第 {} 到 {} 批次", i - 9, i + 1);
+                    }
+                    totalInserted += batch.size();
+                } catch (Exception e) {
+                    log.error("批次 {} 插入/更新失败,大小:{}", i + 1, batch.size(), e);
+                    sqlSession.rollback();
+                }
+            }
+
+            // 提交剩余未提交的数据,避免提交漏
+            sqlSession.commit();
 
+            long totalEndTime = System.currentTimeMillis();
+            log.info("当前页批量插入/更新完成,总记录数:{},总耗时:{} 毫秒", totalInserted, totalEndTime - totalStartTime);
+        } catch (Exception e) {
+            log.error("批量插入/更新过程中发生错误", e);
+            throw new RuntimeException("批量插入/更新失败", e);
+        }
     }
 
+
     private void batchInsert(List<FsUserCourseCount> list) {
         // 分批次处理,一次提交500条
         List<List<FsUserCourseCount>> batches = Lists.partition(list, 500);

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

@@ -36,8 +36,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         ct.complaint_type_name,
         uc.course_name,
         ucv.title,
-        fs_user.nick_name
+        fs_user.nick_name,
 --         if(ec.comment_status = 1,'已拉黑','正常') as status
+        cu.user_name userName,
+        cu.nick_name saleNickName,
+        c.company_name as company
         FROM
         fs_user_course_complaint_record cr
         LEFT JOIN fs_user_course_complaint_type ct ON ct.complaint_type_id = cr.complaint_type_id
@@ -45,6 +48,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         LEFT JOIN fs_user_course_video ucv ON ucv.video_id = cr.video_id
         LEFT JOIN fs_user ON fs_user.user_id = cr.user_id
 --         left join qw_external_contact ec on cr.user_id = ec.fs_user_id
+        left join  fs_user_company_user fucu on fs_user.user_id = fucu.user_id
+        left join  company_user cu on cu.user_id = fucu.company_user_id
+        left join company c on c.company_id = cu.company_id
         <where>
             <if test="userId != null">
                 and cr.user_id like concat('%', #{userId}, '%')

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

@@ -301,7 +301,7 @@
         </if>
         <!-- 营销提前查看天数逻辑 -->
         AND DATE_SUB(fcpd.day_date, INTERVAL fcp.max_view_num DAY) &lt;= now()
-        order by video.course_sort
+        order by fcpd.day_date, video.course_sort
     </select>
 
     <select id="selectVideoListByMap" resultType="com.fs.his.vo.OptionsVO">

+ 36 - 18
fs-service/src/main/resources/mapper/his/FsUserMapper.xml

@@ -791,21 +791,38 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
 
     <select id="getUserNumber" resultType="com.fs.store.vo.h5.UserListCountVO">
         SELECT
-        fs_user_company_user.`status` as status,
-        count( DISTINCT fs_user.user_id ) AS num
-        FROM
-        fs_user
-        LEFT JOIN fs_user_company_user ON fs_user_company_user.user_id = fs_user.user_id
-        LEFT JOIN company_user ON company_user.user_id = fs_user_company_user.company_user_id
-        WHERE fs_user.is_del = 0 and fs_user_company_user.is_repeat_fans is not null
-        <if test="userId != null and userId != 0 ">
-            and (fs_user_company_user.company_user_id = #{userId} OR company_user.parent_id = #{userId} )
-        </if>
-        <if test="companyId != null ">
-            and fs_user_company_user.company_id = #{companyId}
-        </if>
-        GROUP BY
-        fs_user_company_user.`status`,fs_user_company_user.project_id
+            t.status,
+            COUNT(DISTINCT t.user_id) AS num
+        FROM (
+            SELECT
+                fcu.status,
+                fu.user_id
+            FROM fs_user fu
+            INNER JOIN fs_user_company_user fcu ON fcu.user_id = fu.user_id
+            WHERE fu.is_del = 0 AND fcu.is_repeat_fans IS NOT NULL
+            <if test="userId != null and userId != 0">
+                AND fcu.company_user_id = #{userId}
+            </if>
+            <if test="companyId != null ">
+                and fcu.company_id = #{companyId}
+            </if>
+
+            <if test="userId != null and userId != 0 ">
+            UNION ALL
+            SELECT
+                fcu.status,
+                fu.user_id
+            FROM fs_user fu
+            INNER JOIN fs_user_company_user fcu ON fcu.user_id = fu.user_id
+            INNER JOIN company_user cu ON cu.user_id = fcu.company_user_id
+            WHERE fu.is_del = 0 AND fcu.is_repeat_fans IS NOT NULL
+            AND cu.parent_id = #{userId}
+            <if test="companyId != null ">
+                and fcu.company_id = #{companyId}
+            </if>
+            </if>
+        ) t
+        GROUP BY t.status
     </select>
 
     <select id="getRepeatUserNumber" resultType="int">
@@ -1944,7 +1961,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             u.user_id, u.nick_name, u.avatar, u.phone, u.integral, u.now_money,
             ucu.project_id,ucu.company_user_id as companyUserId,ucu.update_time as bindTime,ucu.status,
             company.company_name,
-            cu.nick_name   companyUserNickName
+            cu.nick_name   companyUserNickName,
+            ucu.id userCompanyUserId
         FROM
             fs_user_company_user ucu
         left join
@@ -1966,11 +1984,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
                 AND u.phone LIKE CONCAT("%",#{maps.phone},"%")
             </if >
             <if test = "maps.startCreateTime != null" >
-                AND ucu.update_time >= #{registerStartTime}
+                AND ucu.update_time >= #{maps.startCreateTime}
             </if >
             <if test = "maps.endCreateTime != null" >
             <![CDATA[
-                AND ucu.update_time < date_add(#{endCreateTime}, interval 1 day)
+                AND ucu.update_time < date_add(#{maps.endCreateTime}, interval 1 day)
             ]]>
             </if >
             <if test = "maps.registerCode != null  and  maps.registerCode !=''  " >

+ 53 - 0
fs-service/src/main/resources/mapper/live/LiveAfterSalesMapper.xml

@@ -115,6 +115,59 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         </if>
         order by las.create_time desc
     </select>
+    <select id="selectLiveAfterSalesVoListExport" parameterType="com.fs.live.vo.LiveAfterSalesVo" resultType="com.fs.live.vo.LiveAfterSalesVo">
+        select las.id, las.live_id, las.store_id, las.refund_amount,
+        las.refund_type, las.reasons, las.explains, las.explain_img, las.delivery_code, las.delivery_sn, las.delivery_name, las.status, las.sales_status,
+        las.order_status, las.create_time, las.is_del, las.user_id, las.consignee, las.phone_number, las.address, las.company_id, las.company_user_id, las.dept_id,
+        cu.nick_name as company_user_nick_name, c.company_name,lo.order_id,lo.order_code,lo.user_phone,lo.item_json,lo.pay_time as orderPayTime,
+        lo.user_address,lo.user_name,lo.pay_price,lo.total_postage,lop.bank_serial_no,lo.delivery_sn as orderDeliveryId,lo.delivery_name as orderDeliveryName,
+        lo.delivery_code as orderDeliverySn,lo.status as orderStatus,lop.bank_transaction_id,lo.pay_money,lop.pay_code as payCode
+        from live_after_sales las
+        left join live_order lo on lo.order_id = las.order_id
+        left join company_user cu on cu.user_id = las.company_user_id
+        left join company c on c.company_id = cu.company_id
+        left join live_order_payment lop on lop.business_id = lo.order_id and lop.status in (1,-1)
+        <if test="productName != null and productName != ''">
+        left join live_order_item loi on loi.order_id = lo.order_id
+        </if>
+
+        where 1=1 and las.status =4 and lop.bank_transaction_id is not null
+            <if test="hfOrderCode != null and hfOrderCode != ''"> and lop.pay_code = #{hfOrderCode}</if>
+            <if test="liveId != null and liveId != ''"> and las.live_id = #{liveId}</if>
+            <if test="companyUserNickName != null and companyUserNickName != ''"> and cu.nick_name like concat(#{companyUserNickName},'%')</if>
+            <if test="storeId != null and storeId != ''"> and las.store_id = #{storeId}</if>
+            <if test="orderCode != null and orderCode != ''"> and lo.order_code = #{orderCode}</if>
+            <if test="productNameQuery != null and productNameQuery != ''"> and JSON_UNQUOTE(JSON_EXTRACT(loi.json_info, '$.productName')) like concat('%', #{productNameQuery}, '%')</if>
+            <if test="refundAmount != null "> and las.refund_amount = #{refundAmount}</if>
+            <if test="refundType != null "> and las.refund_type = #{refundType}</if>
+            <if test="status != null "> and las.status = #{status}</if>
+            <if test="salesStatus != null "> and las.sales_status = #{salesStatus}</if>
+            <if test="orderStatus != null "> and las.order_status = #{orderStatus}</if>
+            <if test="reasons != null  and reasons != ''"> and las.reasons = #{reasons}</if>
+            <if test="explains != null  and explains != ''"> and las.explains = #{explains}</if>
+            <if test="explainImg != null  and explainImg != ''"> and las.explain_img = #{explainImg}</if>
+            <if test="deliveryCode != null  and deliveryCode != ''"> and las.delivery_code = #{deliveryCode}</if>
+            <if test="deliverySn != null  and deliverySn != ''"> and las.delivery_sn = #{deliverySn}</if>
+            <if test="deliveryName != null  and deliveryName != ''"> and las.delivery_name like concat('%', #{deliveryName}, '%')</if>
+            <if test="status != null "> and las.status = #{status}</if>
+            <if test="salesStatus != null "> and las.sales_status = #{salesStatus}</if>
+            <if test="orderStatus != null "> and las.order_status = #{orderStatus}</if>
+            <if test="deliveryStatus != null and deliveryStatus!= ''"> and las.order_status = #{deliveryStatus}</if>
+            <if test="isDel != null  and isDel != ''"> and las.is_del = #{isDel}</if>
+            <if test="userId != null "> and las.user_id = #{userId}</if>
+            <if test="consignee != null  and consignee != ''"> and las.consignee = #{consignee}</if>
+            <if test="phoneNumber != null  and phoneNumber != ''"> and las.phone_number = #{phoneNumber}</if>
+            <if test="address != null  and address != ''"> and las.address = #{address}</if>
+            <if test="companyId != null "> and las.company_id = #{companyId}</if>
+            <if test="companyUserId != null "> and las.company_user_id = #{companyUserId}</if>
+            <if test="deptId != null "> and cu.dept_id = #{deptId}</if>
+            <if test="userPhone != null "> and lo.user_phone like concat(#{userPhone},'%')</if>
+
+        <if test="productName != null and productName != ''">
+        group by las.id
+        </if>
+        order by las.create_time desc
+    </select>
 
     <select id="selectLiveAfterSalesById" parameterType="Long" resultMap="LiveAfterSalesResult">
         <include refid="selectLiveAfterSalesVo"/>

+ 9 - 0
fs-service/src/main/resources/mapper/live/LiveCompletionPointsRecordMapper.xml

@@ -89,6 +89,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         LIMIT 1
     </select>
 
+    <!-- 查询用户在某直播间最近一次完课记录(不限制日期) -->
+    <select id="selectLatestByUserAndLiveId" resultMap="LiveCompletionPointsRecordResult">
+        SELECT * FROM live_completion_points_record
+        WHERE live_id = #{liveId}
+          AND user_id = #{userId}
+        ORDER BY current_completion_date DESC, id DESC
+        LIMIT 1
+    </select>
+
     <!-- 查询用户未领取的完课记录列表 -->
     <select id="selectUnreceivedByUser" resultMap="LiveCompletionPointsRecordResult">
         SELECT * FROM live_completion_points_record

+ 2 - 0
fs-service/src/main/resources/mapper/live/LiveUserRedRecordMapper.xml

@@ -27,6 +27,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="userId != null "> and user_id = #{userId}</if>
             <if test="createTime != null "> and create_time = #{createTime}</if>
         </where>
+
+        order by create_time desc
     </select>
 
     <select id="selectLiveUserRedRecordById" parameterType="Long" resultMap="LiveUserRedRecordResult">

+ 2 - 1
fs-service/src/main/resources/mapper/qw/QwExternalContactMapper.xml

@@ -619,10 +619,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
     </select>
     <select id="selectFsUserCourseComplaintRecordByUserId" resultType="com.fs.qw.vo.QwExternalContactComplaintVO">
         select qec.id,qec.qw_user_id,qec.status,qec.user_id,qec.name,qec.avatar,qec.remark,qec.description,qec.fs_user_id,
-               qu.qw_user_name,cu.user_name ,cu.nick_name
+               qu.qw_user_name,cu.user_name ,cu.nick_name,c.company_name as company
         from qw_external_contact qec
                left join qw_user qu on qu.id=qec.qw_user_id
                left join company_user cu on cu.user_id=qu.company_user_id
+               left join company c on c.company_id=cu.company_id
         where fs_user_id = #{userId}
     </select>
 

+ 63 - 1
fs-service/src/main/resources/mapper/store/FsUserCourseCountMapper.xml

@@ -165,9 +165,23 @@
             NOW() AS updateTime,
             DATE_FORMAT(fwl.create_time,'%Y-%m-%d') AS create_date,
             DATE (fwl.create_time ) AS lastDate
+            ,Max( fwl.last_heartbeat_time ) AS lastWatchDate,
+            CASE
+            WHEN fwl.log_type = 1
+            OR fwl.log_type = 2 THEN
+            1
+            WHEN fwl.log_type = 4 THEN
+            2
+            WHEN fwl.log_type = 3 THEN
+            3
+        END AS STATUS
         FROM fs_course_watch_log fwl
         left join fs_user_company_user ucu on ucu.user_id = fwl.user_id
-        where fwl.send_type = 1 and fwl.create_time &gt;= DATE_SUB(CURDATE(), INTERVAL 15 DAY) and fwl.project = ucu.project_id
+        where fwl.user_id in
+        <foreach item="userId" collection="userIds" open="(" separator="," close=")">
+            #{userId}
+        </foreach>
+            and fwl.send_type = 1 and fwl.create_time >= DATE_SUB(CURDATE(), INTERVAL 15 DAY) and fwl.project = ucu.project_id
         GROUP BY
             fwl.user_id, date(fwl.create_time),ucu.project_id
     </select>
@@ -199,6 +213,16 @@
             fs_course_watch_log.user_id, date(fs_course_watch_log.create_time),ucu.project_id
     </select>
 
+    <select id="getUsersByPage" resultType="Long">
+        SELECT DISTINCT
+            fs_user.user_id
+        FROM
+            fs_user
+                left join fs_user_company_user ucu on ucu.user_id = fs_user.user_id
+        where ucu.company_user_id is not null
+            LIMIT #{offset}, #{pageSize}
+    </select>
+
 
     <insert id="insertFsUserCourseCountTask" parameterType="FsUserCourseCount" useGeneratedKeys="true" keyProperty="id">
         insert into fs_user_course_count
@@ -256,6 +280,44 @@
         </trim>
     </insert>
 
+
+    <insert id="batchInsertOrUpdate">
+        INSERT INTO fs_user_course_count
+            ( user_id,
+            watch_course_count,
+            miss_course_count,
+            miss_course_status,
+            course_ids,
+            part_course_count,
+            last_watch_date,
+            status,
+            create_time,
+            update_time,
+            complete_watch_date,
+            complete_watch_count,
+            watch_times,
+            create_date,
+            project_id )
+        VALUES
+        <foreach collection="list" item="item" separator=",">
+            (#{item.userId}, #{item.watchCourseCount}, #{item.missCourseCount}, #{item.missCourseStatus}, #{item.courseIds},
+             #{item.partCourseCount}, #{item.lastWatchDate}, #{item.status},#{item.createTime},#{item.updateTime},#{item.completeWatchDate}
+            ,#{item.completeWatchCount},#{item.watchTimes},#{item.createDate},#{item.projectId})
+        </foreach>
+        on duplicate key update
+        watch_course_count = VALUES(watch_course_count),
+        miss_course_count = VALUES(miss_course_count),
+        miss_course_status = VALUES(miss_course_status),
+        course_ids = VALUES(course_ids),
+        part_course_count = VALUES(part_course_count),
+        last_watch_date = VALUES(last_watch_date),
+        status = VALUES(status),
+        complete_watch_date = VALUES(complete_watch_date),
+        complete_watch_count = VALUES(complete_watch_count),
+        watch_times = VALUES(watch_times),
+        update_time = NOW()
+    </insert>
+
     <select id="selectUserLastCount" resultType="com.fs.store.vo.FsUserLastCount">
         SELECT
         fs_user_course_count.user_id,

+ 48 - 0
fs-user-app/src/main/java/com/fs/app/controller/live/LiveRedController.java

@@ -4,13 +4,24 @@ import com.fs.app.annotation.Login;
 import com.fs.app.controller.AppBaseController;
 import com.fs.app.facade.LiveFacadeService;
 import com.fs.common.core.domain.R;
+import com.fs.common.utils.ServletUtils;
+import com.fs.live.domain.LiveUserRedRecord;
 import com.fs.live.param.RedPO;
+import com.fs.live.service.ILiveUserRedRecordService;
+import com.github.pagehelper.PageHelper;
+import com.github.pagehelper.PageInfo;
+import lombok.extern.slf4j.Slf4j;
 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.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
 
+import java.util.List;
+
+@Slf4j
 @RestController
 @RequestMapping("/app/live/liveRed")
 public class LiveRedController extends AppBaseController {
@@ -18,6 +29,9 @@ public class LiveRedController extends AppBaseController {
     @Autowired
     private LiveFacadeService liveFacadeService;
 
+    @Autowired
+    private ILiveUserRedRecordService liveUserRedRecordService;
+
     /**
      * 领取红包
      */
@@ -28,4 +42,38 @@ public class LiveRedController extends AppBaseController {
         return liveFacadeService.redClaim(red);
     }
 
+    /**
+     * 查询用户自己领取直播间的红包记录
+     * @return 红包记录列表
+     */
+    @Login
+    @GetMapping("/list")
+    public R list() {
+        try {
+            String userId = getUserId();
+            if (userId == null || userId.isEmpty()) {
+                return R.error("用户未登录");
+            }
+            // 分页参数
+            String pageNum = ServletUtils.getParameter("pageNum");
+            String pageSize = ServletUtils.getParameter("pageSize");
+            int page = pageNum != null && !pageNum.isEmpty() ? Integer.parseInt(pageNum) : 1;
+            int size = pageSize != null && !pageSize.isEmpty() ? Integer.parseInt(pageSize) : 10;
+
+            // 构建查询条件
+            LiveUserRedRecord query = new LiveUserRedRecord();
+            query.setUserId(Long.parseLong(userId));
+
+            // 分页查询
+            PageHelper.startPage(page, size);
+            List<LiveUserRedRecord> list = liveUserRedRecordService.selectLiveUserRedRecordList(query);
+            PageInfo<LiveUserRedRecord> pageInfo = new PageInfo<>(list);
+
+            return R.ok().put("data", pageInfo);
+        } catch (Exception e) {
+            log.error("查询用户红包记录失败", e);
+            return R.error("查询失败:" + e.getMessage());
+        }
+    }
+
 }

+ 2 - 1
fs-user-app/src/main/java/com/fs/app/facade/impl/LiveFacadeServiceImpl.java

@@ -233,7 +233,8 @@ public class LiveFacadeServiceImpl extends BaseController implements LiveFacadeS
     }
 
     @Override
-    @DistributeLock(keyExpression = "#coupon.couponIssueId +'_'+#coupon.userId", scene = "coupon_claim", waitTime = 1000, errorMsg = "优惠券领取失败")
+    @DistributeLock(keyExpression = "(#coupon?.couponIssueId?:'default') + '_' + (#coupon?.userId?:'default')"
+, scene = "coupon_claim", waitTime = 1000, errorMsg = "优惠券领取失败")
     public R couponClaim(CouponPO coupon) {
         return iLiveCouponService.claimCoupon(coupon);
     }