Browse Source

直播数据,在线观看,聊天室优化,

yuhongqi 2 days ago
parent
commit
b65ac0458d
52 changed files with 3167 additions and 170 deletions
  1. 2 1
      fs-admin/src/main/java/com/fs/core/web/service/UserDetailsServiceImpl.java
  2. 1 1
      fs-admin/src/main/java/com/fs/live/controller/LiveController.java
  3. 85 5
      fs-admin/src/main/java/com/fs/live/controller/LiveDataController.java
  4. 7 0
      fs-common/src/main/java/com/fs/common/constant/LiveKeysConstant.java
  5. 15 0
      fs-common/src/main/java/com/fs/common/core/redis/RedisCache.java
  6. 3 0
      fs-common/src/main/java/com/fs/common/vo/LiveVo.java
  7. 77 0
      fs-company/src/main/java/com/fs/company/controller/live/LiveDataController.java
  8. 0 1
      fs-live-socket/src/main/java/com/fs/core/aspectj/LiveWatchUserAspect.java
  9. 51 19
      fs-live-socket/src/main/java/com/fs/live/task/Task.java
  10. 6 12
      fs-live-socket/src/main/java/com/fs/live/websocket/handle/LiveChatHandler.java
  11. 202 47
      fs-live-socket/src/main/java/com/fs/live/websocket/service/WebSocketServer.java
  12. 2 0
      fs-service-system/src/main/java/com/fs/live/domain/Live.java
  13. 9 0
      fs-service-system/src/main/java/com/fs/live/domain/LiveData.java
  14. 11 1
      fs-service-system/src/main/java/com/fs/live/domain/LiveMsg.java
  15. 20 0
      fs-service-system/src/main/java/com/fs/live/domain/LiveWatchUser.java
  16. 68 0
      fs-service-system/src/main/java/com/fs/live/dto/TemplateMessageSendRequestDTO.java
  17. 3 0
      fs-service-system/src/main/java/com/fs/live/mapper/LiveCouponMapper.java
  18. 31 2
      fs-service-system/src/main/java/com/fs/live/mapper/LiveDataMapper.java
  19. 41 0
      fs-service-system/src/main/java/com/fs/live/mapper/LiveMapper.java
  20. 3 0
      fs-service-system/src/main/java/com/fs/live/mapper/LiveOrderMapper.java
  21. 20 0
      fs-service-system/src/main/java/com/fs/live/mapper/LiveWatchUserMapper.java
  22. 99 0
      fs-service-system/src/main/java/com/fs/live/param/LiveDataParam.java
  23. 7 0
      fs-service-system/src/main/java/com/fs/live/service/ILiveAutoTaskService.java
  24. 40 0
      fs-service-system/src/main/java/com/fs/live/service/ILiveDataService.java
  25. 3 0
      fs-service-system/src/main/java/com/fs/live/service/ILiveService.java
  26. 4 2
      fs-service-system/src/main/java/com/fs/live/service/ILiveWatchUserService.java
  27. 236 4
      fs-service-system/src/main/java/com/fs/live/service/impl/LiveAutoTaskServiceImpl.java
  28. 32 0
      fs-service-system/src/main/java/com/fs/live/service/impl/LiveCouponIssueServiceImpl.java
  29. 59 4
      fs-service-system/src/main/java/com/fs/live/service/impl/LiveCouponServiceImpl.java
  30. 487 9
      fs-service-system/src/main/java/com/fs/live/service/impl/LiveDataServiceImpl.java
  31. 38 3
      fs-service-system/src/main/java/com/fs/live/service/impl/LiveGoodsServiceImpl.java
  32. 30 1
      fs-service-system/src/main/java/com/fs/live/service/impl/LiveLotteryConfServiceImpl.java
  33. 30 3
      fs-service-system/src/main/java/com/fs/live/service/impl/LiveRedConfServiceImpl.java
  34. 122 8
      fs-service-system/src/main/java/com/fs/live/service/impl/LiveServiceImpl.java
  35. 108 23
      fs-service-system/src/main/java/com/fs/live/service/impl/LiveWatchUserServiceImpl.java
  36. 59 0
      fs-service-system/src/main/java/com/fs/live/vo/FsMyLiveOrderListQueryVO.java
  37. 98 0
      fs-service-system/src/main/java/com/fs/live/vo/LiveDataDetailVo.java
  38. 81 0
      fs-service-system/src/main/java/com/fs/live/vo/LiveDataListVo.java
  39. 62 0
      fs-service-system/src/main/java/com/fs/live/vo/LiveDataStatisticsVo.java
  40. 56 0
      fs-service-system/src/main/java/com/fs/live/vo/LiveUserDetailExportVO.java
  41. 40 0
      fs-service-system/src/main/java/com/fs/live/vo/LiveUserDetailVo.java
  42. 28 0
      fs-service-system/src/main/java/com/fs/live/vo/ProductSalesVo.java
  43. 278 2
      fs-service-system/src/main/java/com/fs/store/service/impl/FsStoreProductServiceImpl.java
  44. 274 0
      fs-service-system/src/main/resources/mapper/live/LiveDataMapper.xml
  45. 15 0
      fs-service-system/src/main/resources/mapper/live/LiveMapper.xml
  46. 4 0
      fs-service-system/src/main/resources/mapper/live/LiveMsgMapper.xml
  47. 90 8
      fs-service-system/src/main/resources/mapper/live/LiveWatchUserMapper.xml
  48. 78 1
      fs-user-app/src/main/java/com/fs/app/controller/LiveGoodsController.java
  49. 10 0
      fs-user-app/src/main/java/com/fs/app/controller/LiveOrderController.java
  50. 33 1
      fs-user-app/src/main/java/com/fs/app/facade/impl/LiveFacadeServiceImpl.java
  51. 9 11
      fs-user-app/src/main/java/com/fs/app/websocket/handle/LiveChatHandler.java
  52. 0 1
      fs-user-app/src/main/java/com/fs/core/aspectj/LiveWatchUserAspect.java

+ 2 - 1
fs-admin/src/main/java/com/fs/core/web/service/UserDetailsServiceImpl.java

@@ -1,6 +1,7 @@
 package com.fs.core.web.service;
 
 import com.fs.core.security.LoginUser;
+import com.fs.core.security.SecurityUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -17,7 +18,7 @@ import com.fs.system.service.ISysUserService;
 /**
  * 用户验证处理
  *
- 
+
  */
 @Service
 public class UserDetailsServiceImpl implements UserDetailsService

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

@@ -64,7 +64,7 @@ public class LiveController extends BaseController {
     @PreAuthorize("@ss.hasPermi('live:live:query')")
     @GetMapping(value = "/{liveId}")
     public AjaxResult getInfo(@PathVariable("liveId") Long liveId) {
-        return AjaxResult.success(liveService.selectLiveByLiveId(liveId));
+        return AjaxResult.success(liveService.selectLiveDbByLiveId(liveId));
     }
 
     /**

+ 85 - 5
fs-admin/src/main/java/com/fs/live/controller/LiveDataController.java

@@ -1,22 +1,27 @@
 package com.fs.live.controller;
 
+import com.fs.common.annotation.Log;
+import com.fs.common.core.domain.AjaxResult;
 import com.fs.common.core.domain.R;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.poi.ExcelUtil;
 import com.fs.core.security.SecurityUtils;
 import com.fs.live.domain.LiveData;
+import com.fs.live.param.LiveDataParam;
 import com.fs.live.service.ILiveDataService;
+import com.fs.live.vo.LiveUserDetailExportVO;
+import com.github.pagehelper.PageHelper;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.security.access.prepost.PreAuthorize;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.PathVariable;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.bind.annotation.*;
 
+import javax.servlet.http.HttpServletRequest;
 import java.util.List;
 import java.util.Map;
 
 
 @RestController
-@RequestMapping("/live/liveData")
+@RequestMapping("/liveData/liveData")
 public class LiveDataController {
 
     @Autowired
@@ -77,5 +82,80 @@ public class LiveDataController {
         return R.ok(liveViewData);
     }
 
+    /**
+     * 查询新直播数据列表
+     */
+    @PreAuthorize("@ss.hasPermi('liveData:liveData:list')")
+    @PostMapping("/listLiveData")
+    public R listLiveData(@RequestBody LiveDataParam param, HttpServletRequest request)
+    {
+        PageHelper.startPage(param.getPageNum(), param.getPageSize());
+        return liveDataService.listLiveData(param);
+    }
+
+    /**
+     * 查询直播间详情数据(SQL方式)
+     * @param liveId 直播间ID
+     * @return 详情数据
+     */
+    @PreAuthorize("@ss.hasPermi('liveData:liveData:query')")
+    @GetMapping("/getLiveDataDetailBySql")
+    public R getLiveDataDetailBySql(@RequestParam Long liveId) {
+        return liveDataService.getLiveDataDetailBySql(liveId);
+    }
+
+    /**
+     * 查询直播间用户详情列表(SQL方式)
+     * @param liveId 直播间ID
+     * @return 用户详情列表
+     */
+    @PreAuthorize("@ss.hasPermi('liveData:liveData:query')")
+    @GetMapping("/getLiveUserDetailListBySql")
+    public R getLiveUserDetailListBySql(@RequestParam Long liveId) {
+        return liveDataService.getLiveUserDetailListBySql(liveId);
+    }
+
+    /**
+     * 查询直播间详情数据(查询数据服务器处理方式)
+     * @param liveId 直播间ID
+     * @return 详情数据
+     */
+    @PreAuthorize("@ss.hasPermi('liveData:liveData:query')")
+    @GetMapping("/getLiveDataDetailByServer")
+    public R getLiveDataDetailByServer(@RequestParam Long liveId) {
+        return liveDataService.getLiveDataDetailByServer(liveId);
+    }
+
+    /**
+     * 查询直播间用户详情列表(查询数据服务器处理方式)
+     * @param liveId 直播间ID
+     * @return 用户详情列表
+     */
+    @PreAuthorize("@ss.hasPermi('liveData:liveData:query')")
+    @GetMapping("/getLiveUserDetailListByServer")
+    public R getLiveUserDetailListByServer(@RequestParam Long liveId) {
+        return liveDataService.getLiveUserDetailListByServer(liveId);
+    }
+
+
+    /**
+     * 导出直播间用户详情数据
+     * @param liveId 直播间ID
+     * @return Excel文件
+     */
+    @PreAuthorize("@ss.hasPermi('liveData:liveData:export')")
+    @Log(title = "直播间用户详情", businessType = BusinessType.EXPORT)
+    @GetMapping("/exportLiveUserDetail")
+    public AjaxResult exportLiveUserDetail(@RequestParam Long liveId) {
+        List<LiveUserDetailExportVO> list = liveDataService.exportLiveUserDetail(liveId);
+        if (list == null || list.isEmpty()) {
+            return AjaxResult.error("未找到用户详情数据");
+        }
+
+        ExcelUtil<LiveUserDetailExportVO> util = new ExcelUtil<>(LiveUserDetailExportVO.class);
+        return util.exportExcel(list, "直播间用户详情数据");
+    }
+
+
 
 }

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

@@ -26,6 +26,13 @@ public class LiveKeysConstant {
     public static final String LIVE_HOME_PAGE_CONFIG_DRAW = "live:config:%s:draw:%s"; //抽奖记录
     public static final String TOP_MSG = "topMsg"; //抽奖记录
 
+    public static final String LIVE_FLAG_CACHE = "live:flag:%s"; //直播间直播/回放状态缓存
+    public static final Integer LIVE_FLAG_CACHE_EXPIRE = 300; //直播间状态缓存过期时间(秒)
 
+    public static final String LIVE_DATA_CACHE = "live:data:%s"; //直播间数据缓存
+    public static final Integer LIVE_DATA_CACHE_EXPIRE = 300; //直播间数据缓存过期时间(秒)
+
+    public static final String PRODUCT_DETAIL_CACHE = "product:detail:%s"; //商品详情缓存
+    public static final Integer PRODUCT_DETAIL_CACHE_EXPIRE = 300; //商品详情缓存过期时间(秒)
 
 }

+ 15 - 0
fs-common/src/main/java/com/fs/common/core/redis/RedisCache.java

@@ -271,4 +271,19 @@ public class RedisCache
     public boolean setIfAbsent(final String key, final Object value, long timeout, TimeUnit unit) {
         return Boolean.TRUE.equals(redisTemplate.opsForValue().setIfAbsent(key, value, timeout, unit));
     }
+
+    /**
+     * 向哈希表中添加键值对
+     *
+     * @param key     哈希表键
+     * @param hashKey 哈希键
+     * @param value   哈希值
+     */
+    public void hashPut(String key, String hashKey, Object value) {
+        redisTemplate.opsForHash().put(key, hashKey, value);
+    }
+
+    public Long incr(final String key,final long delta) {
+        return redisTemplate.opsForValue().increment(key, delta);
+    }
 }

+ 3 - 0
fs-common/src/main/java/com/fs/common/vo/LiveVo.java

@@ -21,6 +21,9 @@ public class LiveVo {
 
     private Long anchorId;
 
+    private Long videoFileSize;
+    private Long videoDuration;
+
     private Integer liveType;
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
 

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

@@ -3,6 +3,7 @@ package com.fs.company.controller.live;
 import com.fs.common.annotation.Log;
 import com.fs.common.core.controller.BaseController;
 import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.domain.R;
 import com.fs.common.core.page.TableDataInfo;
 import com.fs.common.enums.BusinessType;
 import com.fs.common.utils.poi.ExcelUtil;
@@ -10,8 +11,11 @@ import com.fs.core.security.LoginUser;
 import com.fs.core.security.SecurityUtils;
 import com.fs.core.web.service.TokenService;
 import com.fs.live.domain.LiveData;
+import com.fs.live.param.LiveDataParam;
 import com.fs.live.service.ILiveDataService;
 import com.fs.live.vo.ColumnsConfigVo;
+import com.fs.live.vo.LiveUserDetailExportVO;
+import com.github.pagehelper.PageHelper;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
@@ -35,6 +39,79 @@ public class LiveDataController extends BaseController
     @Autowired
     private TokenService tokenService;
 
+    /**
+     * 查询新直播数据列表
+     */
+    @PreAuthorize("@ss.hasPermi('liveData:liveData:list')")
+    @PostMapping("/listLiveData")
+    public R listLiveData(@RequestBody LiveDataParam param, HttpServletRequest request)
+    {
+        param.setCompanyId(tokenService.getLoginUser(request).getUser().getCompanyId());
+        PageHelper.startPage(param.getPageNum(), param.getPageSize());
+        return liveDataService.listLiveData(param);
+    }
+    /**
+     * 查询直播间详情数据(SQL方式)
+     * @param liveId 直播间ID
+     * @return 详情数据
+     */
+    @PreAuthorize("@ss.hasPermi('liveData:liveData:query')")
+    @GetMapping("/getLiveDataDetailBySql")
+    public R getLiveDataDetailBySql(@RequestParam Long liveId) {
+        return liveDataService.getLiveDataDetailBySql(liveId);
+    }
+
+    /**
+     * 查询直播间用户详情列表(SQL方式)
+     * @param liveId 直播间ID
+     * @return 用户详情列表
+     */
+    @PreAuthorize("@ss.hasPermi('liveData:liveData:query')")
+    @GetMapping("/getLiveUserDetailListBySql")
+    public R getLiveUserDetailListBySql(@RequestParam Long liveId) {
+        return liveDataService.getLiveUserDetailListBySql(liveId);
+    }
+
+    /**
+     * 查询直播间详情数据(查询数据服务器处理方式)
+     * @param liveId 直播间ID
+     * @return 详情数据
+     */
+    @PreAuthorize("@ss.hasPermi('liveData:liveData:query')")
+    @GetMapping("/getLiveDataDetailByServer")
+    public R getLiveDataDetailByServer(@RequestParam Long liveId) {
+        return liveDataService.getLiveDataDetailByServer(liveId);
+    }
+
+    /**
+     * 查询直播间用户详情列表(查询数据服务器处理方式)
+     * @param liveId 直播间ID
+     * @return 用户详情列表
+     */
+    @PreAuthorize("@ss.hasPermi('liveData:liveData:query')")
+    @GetMapping("/getLiveUserDetailListByServer")
+    public R getLiveUserDetailListByServer(@RequestParam Long liveId) {
+        return liveDataService.getLiveUserDetailListByServer(liveId);
+    }
+
+
+    /**
+     * 导出直播间用户详情数据
+     * @param liveId 直播间ID
+     * @return Excel文件
+     */
+    @PreAuthorize("@ss.hasPermi('liveData:liveData:export')")
+    @Log(title = "直播间用户详情", businessType = BusinessType.EXPORT)
+    @GetMapping("/exportLiveUserDetail")
+    public AjaxResult exportLiveUserDetail(@RequestParam Long liveId) {
+        List<LiveUserDetailExportVO> list = liveDataService.exportLiveUserDetail(liveId);
+        if (list == null || list.isEmpty()) {
+            return AjaxResult.error("未找到用户详情数据");
+        }
+
+        ExcelUtil<LiveUserDetailExportVO> util = new ExcelUtil<>(LiveUserDetailExportVO.class);
+        return util.exportExcel(list, "直播间用户详情数据");
+    }
     /**
      * 查询直播数据列表
      */

+ 0 - 1
fs-live-socket/src/main/java/com/fs/core/aspectj/LiveWatchUserAspect.java

@@ -35,7 +35,6 @@ public class LiveWatchUserAspect {
         try {
             String methodName = joinPoint.getSignature().getName();
             Object[] args = joinPoint.getArgs();
-            log.info("直播观看用户数据发生变化,方法: {}, 参数: {}", methodName, Arrays.toString(args));
             // 提取liveId并处理缓存更新
             Set<Long> liveIds = extractLiveIds(methodName, args);
             for (Long liveId : liveIds) {

+ 51 - 19
fs-live-socket/src/main/java/com/fs/live/task/Task.java

@@ -491,39 +491,71 @@ public class Task {
             return;
         liveDatas.forEach(liveData ->{
 
-            Long resultLikeCount = getAsLong(redisCache, "live:like:" + liveData.getLiveId());
-            resultLikeCount = resultLikeCount > 0L ? resultLikeCount : liveData.getLikes();
-            redisCache.setCacheObject("live:like:" + liveData.getLiveId(), resultLikeCount.intValue());
-            liveData.setLikes(
-                    resultLikeCount
-            );
+            Map<String, Integer> flagMap = liveWatchUserService.getLiveFlagWithCache(liveData.getLiveId());
+            Integer liveFlag = flagMap.get("liveFlag");
+
+            // 判断是直播还是回放
+            if (liveFlag != null && liveFlag == 1) {
+                // 直播:更新 likes 和 totalViews
+                Long resultLikeCount = getAsLong(redisCache, "live:like:" + liveData.getLiveId());
+                resultLikeCount = resultLikeCount > 0L ? resultLikeCount : liveData.getLikes();
+                redisCache.setCacheObject("live:like:" + liveData.getLiveId(), resultLikeCount.intValue());
+                liveData.setLikes(resultLikeCount);
+
+                // 从 redis 获取数据,并提供默认值,避免 NPE
+                liveData.setPageViews(
+                        Math.max( liveData.getPageViews(), Optional.ofNullable(redisCache.incr(PAGE_VIEWS_KEY + liveData.getLiveId(),0)).orElse(0L))
+                );
+                liveData.setTotalViews(
+                        Math.max( liveData.getTotalViews(), Optional.ofNullable(redisCache.incr(TOTAL_VIEWS_KEY + liveData.getLiveId(),0)).orElse(0L))
+                );
+            } else {
+                // 回放:使用 Redis 中的数据减去直播的数据,得到回放的数据
+                String likeKey = "live:like:" + liveData.getLiveId();
+                String totalViewsKey = TOTAL_VIEWS_KEY + liveData.getLiveId();
 
-       /* for (Long liveId : liveIds) {
-            LiveData liveData = liveDataService.selectLiveDataByLiveId(liveId);
-            if (liveData == null) {
-                continue; // 防止空指针异常
-            }*/
+                // 从 Redis 获取总数据(直播+回放)
+                Long totalLikeCount = getAsLong(redisCache, likeKey);
+                Long totalViewCount = getAsLong(redisCache, totalViewsKey);
 
+                // 获取数据库中直播的数据
+                Long liveLikeCount = liveData.getLikes() != null ? liveData.getLikes() : 0L;
+                Long liveViewCount = liveData.getTotalViews() != null ? liveData.getTotalViews() : 0L;
 
-            liveData.setPageViews(
-                    Math.max( liveData.getPageViews(), Optional.ofNullable(redisCache.increment(PAGE_VIEWS_KEY + liveData.getLiveId(),0)).orElse(0L))
-            );
-            liveData.setTotalViews(
-                    Math.max( liveData.getTotalViews(), Optional.ofNullable(redisCache.increment(TOTAL_VIEWS_KEY + liveData.getLiveId(),0)).orElse(0L))
-            );
+                // 回放数据 = Redis总数据 - 直播数据
+                Long replayLikeNum = totalLikeCount - liveLikeCount;
+                Long replayViewNum = totalViewCount - liveViewCount;
+
+                // 确保回放数据不为负数
+                if (replayLikeNum < 0L) {
+                    replayLikeNum = 0L;
+                }
+                if (replayViewNum < 0L) {
+                    replayViewNum = 0L;
+                }
+
+                // 更新回放数据
+                liveData.setReplayLikeNum(replayLikeNum);
+                liveData.setReplayViewNum(replayViewNum);
+
+                // 从 redis 获取数据,并提供默认值,避免 NPE
+                liveData.setPageViews(
+                        Math.max( liveData.getPageViews(), Optional.ofNullable(redisCache.incr(PAGE_VIEWS_KEY + liveData.getLiveId(),0)).orElse(0L))
+                );
+            }
             liveData.setUniqueVisitors(
                     /*Optional.ofNullable(redisCache.getCacheSet(UNIQUE_VISITORS_KEY + liveId))
                             .map(Set::size)  // 获取集合大小
                             .map(Long::valueOf)  // 转换为 Long 类型
                             .orElse(0L)*/
-                    Math.max( liveData.getUniqueVisitors(), Optional.ofNullable(redisCache.increment(UNIQUE_VISITORS_KEY + liveData.getLiveId(),0)).orElse(0L))
+                    Math.max( liveData.getUniqueVisitors(), Optional.ofNullable(redisCache.incr(UNIQUE_VISITORS_KEY + liveData.getLiveId(),0)).orElse(0L))
             );
             liveData.setUniqueViewers(
                     /*Optional.ofNullable(redisCache.getCacheSet(UNIQUE_VIEWERS_KEY + liveId))
                             .map(Set::size)  // 获取集合大小
                             .map(Long::valueOf)  // 转换为 Long 类型
                             .orElse(0L)*/
-                    Math.max( liveData.getUniqueViewers(), Optional.ofNullable(redisCache.increment(UNIQUE_VIEWERS_KEY + liveData.getLiveId(),0)).orElse(0L))
+                    Math.max( liveData.getUniqueViewers(), Optional.ofNullable(redisCache.incr(UNIQUE_VIEWERS_KEY + liveData.getLiveId(),0)).orElse(0L))
             );
             // 使用Set大小来获取最大同时在线人数
             String onlineUsersSetKey = ONLINE_USERS_SET_KEY + liveData.getLiveId();

+ 6 - 12
fs-live-socket/src/main/java/com/fs/live/websocket/handle/LiveChatHandler.java

@@ -51,7 +51,6 @@ public class LiveChatHandler extends SimpleChannelInboundHandler<TextWebSocketFr
      */
     @Override
     public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
-        log.debug("事件");
         // 处理 WebSocket 握手完成事件
         if (evt instanceof WebSocketServerProtocolHandler.HandshakeComplete) {
             Long userId = ctx.channel().attr(AttrConstant.ATTR_USER_ID).get();
@@ -69,15 +68,15 @@ public class LiveChatHandler extends SimpleChannelInboundHandler<TextWebSocketFr
             roomGroup.add(ctx.channel());
 
             if (userType == 0) {
-                // 加入房间
-                liveWatchUserService.join(liveId, userId);
-                room.put(userId, ctx.channel());
+
 
                 FsUser fsUser = fsUserService.selectFsUserByUserId(userId);
                 if (Objects.isNull(fsUser)) {
                     ctx.channel().writeAndFlush(new TextWebSocketFrame("Error: 用户信息错误")).addListener(ChannelFutureListener.CLOSE);
                     return;
-                }
+                } // 加入房间
+                liveWatchUserService.join(fsUser,liveId, userId,"");
+                room.put(userId, ctx.channel());
 
                 LiveWatchUserVO liveWatchUserVO = liveWatchUserService.selectWatchUserByLiveIdAndUserId(liveId, userId);
 
@@ -97,7 +96,6 @@ public class LiveChatHandler extends SimpleChannelInboundHandler<TextWebSocketFr
                 adminRoom.add(ctx.channel());
             }
 
-            log.debug("加入webSocket liveId: {}, userId: {}, 直播间人数: {}", liveId, userId, room.size());
         }
     }
 
@@ -154,7 +152,6 @@ public class LiveChatHandler extends SimpleChannelInboundHandler<TextWebSocketFr
      */
     @Override
     protected void channelRead0(ChannelHandlerContext channelHandlerContext, TextWebSocketFrame textWebSocketFrame) throws Exception {
-        log.debug("接收到消息 data: {}", textWebSocketFrame.text());
         Long liveId = channelHandlerContext.channel().attr(AttrConstant.ATTR_LIVE_ID).get();
         Long userType = channelHandlerContext.channel().attr(AttrConstant.ATTR_USER_TYPE).get();
 
@@ -175,8 +172,8 @@ public class LiveChatHandler extends SimpleChannelInboundHandler<TextWebSocketFr
                     liveMsg.setCreateTime(new Date());
 
                     if (userType == 0) {
-                        LiveWatchUser liveWatchUser = liveWatchUserService.getByLiveIdAndUserId(msg.getLiveId(), msg.getUserId());
-                        if(liveWatchUser.getMsgStatus() == 1){
+                        List<LiveWatchUser> liveWatchUser = liveWatchUserService.getByLiveIdAndUserId(msg.getLiveId(), msg.getUserId());
+                        if(!liveWatchUser.isEmpty() && liveWatchUser.get(0).getMsgStatus() == 1){
                             sendMessage(channelHandlerContext.channel(), JSONObject.toJSONString(R.error("你以被禁言")));
                             return;
                         }
@@ -203,7 +200,6 @@ public class LiveChatHandler extends SimpleChannelInboundHandler<TextWebSocketFr
      */
     @Override
     public void channelInactive(ChannelHandlerContext ctx) throws Exception {
-        log.debug("断开连接");
         Long userId = ctx.channel().attr(AttrConstant.ATTR_USER_ID).get();
         Long liveId = ctx.channel().attr(AttrConstant.ATTR_LIVE_ID).get();
         Long userType = ctx.channel().attr(AttrConstant.ATTR_USER_TYPE).get();
@@ -250,7 +246,6 @@ public class LiveChatHandler extends SimpleChannelInboundHandler<TextWebSocketFr
             roomGroups.remove(liveId);
         }
 
-        log.debug("断开webSocket liveId: {}, userId: {}, 直播间人数: {}", liveId, userId, room.size());
 
     }
 
@@ -262,7 +257,6 @@ public class LiveChatHandler extends SimpleChannelInboundHandler<TextWebSocketFr
      */
     @Override
     public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
-        log.error("连接异常 msg: {}", cause.getMessage(), cause);
         ctx.close();
     }
 }

+ 202 - 47
fs-live-socket/src/main/java/com/fs/live/websocket/service/WebSocketServer.java

@@ -28,10 +28,9 @@ import javax.websocket.server.ServerEndpoint;
 import java.io.EOFException;
 import java.io.IOException;
 import java.util.*;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.CopyOnWriteArrayList;
-import java.util.concurrent.ThreadLocalRandom;
-import java.util.concurrent.TimeUnit;
+import java.util.concurrent.*;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
 
 import static com.fs.common.constant.LiveKeysConstant.*;
 import static com.fs.common.constant.LiveKeysConstant.TOP_MSG;
@@ -47,6 +46,16 @@ public class WebSocketServer {
     private final static ConcurrentHashMap<Long, ConcurrentHashMap<Long, Session>> rooms = new ConcurrentHashMap<>();
     // 管理端连接
     private final static ConcurrentHashMap<Long, CopyOnWriteArrayList<Session>> adminRooms = new ConcurrentHashMap<>();
+
+    // Session发送锁,避免同一会话并发发送消息
+    private final static ConcurrentHashMap<String, Lock> sessionLocks = new ConcurrentHashMap<>();
+    // 心跳超时缓存:key=sessionId,value=最后心跳时间戳
+    private final static ConcurrentHashMap<String, Long> heartbeatCache = new ConcurrentHashMap<>();
+    // 心跳超时时间(毫秒):3分钟无心跳则认为超时
+    private final static long HEARTBEAT_TIMEOUT = 3 * 60 * 1000;
+    // admin房间消息发送线程池(单线程,保证串行化)
+    private final static ConcurrentHashMap<Long, ExecutorService> adminExecutors = new ConcurrentHashMap<>();
+
     private final RedisCache redisCache = SpringUtils.getBean(RedisCache.class);
     private final ILiveMsgService liveMsgService = SpringUtils.getBean(ILiveMsgService.class);
     private final ILiveService liveService = SpringUtils.getBean(ILiveService.class);
@@ -60,6 +69,7 @@ public class WebSocketServer {
     private final ILiveUserFirstEntryService liveUserFirstEntryService =  SpringUtils.getBean(ILiveUserFirstEntryService.class);
     private final ILiveCouponIssueService liveCouponIssueService =  SpringUtils.getBean(ILiveCouponIssueService.class);
     private final LiveCouponMapper liveCouponMapper = SpringUtils.getBean(LiveCouponMapper.class);
+    private static Random random = new Random();
 
     // 直播间在线用户缓存
 //    private static final ConcurrentHashMap<Long, Integer> liveOnlineUsers = new ConcurrentHashMap<>();
@@ -73,6 +83,7 @@ public class WebSocketServer {
         long liveId = (long) userProperties.get("liveId");
         long userId = (long) userProperties.get("userId");
         long userType = (long) userProperties.get("userType");
+        String location = (String) userProperties.get("location");
         Live live = liveService.selectLiveByLiveId(liveId);
         if (live == null) {
             throw new BaseException("未找到直播间");
@@ -97,7 +108,7 @@ public class WebSocketServer {
                 throw new BaseException("用户信息错误");
             }
 
-            LiveWatchUser liveWatchUserVO = liveWatchUserService.join(liveId, userId);
+            LiveWatchUser liveWatchUserVO = liveWatchUserService.join(fsUser,liveId, userId, location);
             room.put(userId, session);
             // 直播间浏览量 +1
             redisCache.increment(PAGE_VIEWS_KEY + liveId, 1);
@@ -133,17 +144,19 @@ public class WebSocketServer {
             }
 
             liveWatchUserVO.setMsgStatus(liveWatchUserVO.getMsgStatus());
-            SendMsgVo sendMsgVo = new SendMsgVo();
-            sendMsgVo.setLiveId(liveId);
-            sendMsgVo.setUserId(userId);
-            sendMsgVo.setUserType(userType);
-            sendMsgVo.setCmd("entry");
-            sendMsgVo.setMsg("用户进入");
-            sendMsgVo.setData(JSONObject.toJSONString(liveWatchUserVO));
-            sendMsgVo.setNickName(fsUser.getNickname());
-            sendMsgVo.setAvatar(fsUser.getAvatar());
-            // 广播连接消息
-            broadcastMessage(liveId, JSONObject.toJSONString(R.ok().put("data", sendMsgVo)));
+            if (1 == random.nextInt(10)) {
+                SendMsgVo sendMsgVo = new SendMsgVo();
+                sendMsgVo.setLiveId(liveId);
+                sendMsgVo.setUserId(userId);
+                sendMsgVo.setUserType(userType);
+                sendMsgVo.setCmd("entry");
+                sendMsgVo.setMsg("用户进入");
+                sendMsgVo.setData(JSONObject.toJSONString(liveWatchUserVO));
+                sendMsgVo.setNickName(fsUser.getNickname());
+                sendMsgVo.setAvatar(fsUser.getAvatar());
+                // 广播连接消息
+                broadcastMessage(liveId, JSONObject.toJSONString(R.ok().put("data", sendMsgVo)));
+            }
 
             LiveUserFirstEntry liveUserFirstEntry = liveUserFirstEntryService.selectEntityByLiveIdUserId(liveId, userId);
             if (liveUserFirstEntry != null) {
@@ -175,9 +188,14 @@ public class WebSocketServer {
 
         } else {
             adminRoom.add(session);
+            // 为admin房间创建单线程执行器,保证串行化发送
+            adminExecutors.computeIfAbsent(liveId, k -> Executors.newSingleThreadExecutor());
         }
 
-        log.debug("加入webSocket liveId: {}, userId: {}, 直播间人数: {}, 管理端人数: {}", liveId, userId, room.size(), adminRoom.size());
+        // 初始化Session锁
+        sessionLocks.putIfAbsent(session.getId(), new ReentrantLock());
+        // 初始化心跳时间
+        heartbeatCache.put(session.getId(), System.currentTimeMillis());
     }
 
     //关闭连接时调用
@@ -210,23 +228,34 @@ public class WebSocketServer {
             // 从在线用户Set中移除用户ID
             String onlineUsersSetKey = ONLINE_USERS_SET_KEY + liveId;
             redisCache.redisTemplate.opsForSet().remove(onlineUsersSetKey, String.valueOf(userId));
-            SendMsgVo sendMsgVo = new SendMsgVo();
-            sendMsgVo.setLiveId(liveId);
-            sendMsgVo.setUserId(userId);
-            sendMsgVo.setUserType(userType);
-            sendMsgVo.setCmd("out");
-            sendMsgVo.setMsg("用户离开");
-            sendMsgVo.setData(JSONObject.toJSONString(liveWatchUserVO));
-            sendMsgVo.setNickName(fsUser.getNickname());
-            sendMsgVo.setAvatar(fsUser.getAvatar());
-
-            // 广播离开消息
-            broadcastMessage(liveId, JSONObject.toJSONString(R.ok().put("data", sendMsgVo)));
+            // 广播离开消息 添加一个概率问题 摇塞子,1-4 当为1的时候广播消息
+            if (1 == new Random().nextInt(10)) {
+                SendMsgVo sendMsgVo = new SendMsgVo();
+                sendMsgVo.setLiveId(liveId);
+                sendMsgVo.setUserId(userId);
+                sendMsgVo.setUserType(userType);
+                sendMsgVo.setCmd("out");
+                sendMsgVo.setMsg("用户离开");
+                sendMsgVo.setData(JSONObject.toJSONString(liveWatchUserVO));
+                sendMsgVo.setNickName(fsUser.getNickname());
+                sendMsgVo.setAvatar(fsUser.getAvatar());
+                broadcastMessage(liveId, JSONObject.toJSONString(R.ok().put("data", sendMsgVo)));
+            }
         } else {
             adminRoom.remove(session);
+            // 如果admin房间为空,关闭并清理执行器
+            if (adminRoom.isEmpty()) {
+                ExecutorService executor = adminExecutors.remove(liveId);
+                if (executor != null) {
+                    executor.shutdown();
+                }
+                adminRooms.remove(liveId);
+            }
         }
+        // 清理Session相关资源
+        heartbeatCache.remove(session.getId());
+        sessionLocks.remove(session.getId());
 
-        log.debug("离开webSocket liveId: {}, userId: {}, 直播间人数: {}, 管理端人数: {}", liveId, userId, room.size(), adminRoom.size());
     }
 
     //收到客户端信息
@@ -243,6 +272,8 @@ public class WebSocketServer {
         try {
             switch (msg.getCmd()) {
                 case "heartbeat":
+                    // 更新心跳时间
+                    heartbeatCache.put(session.getId(), System.currentTimeMillis());
                     sendMessage(session, JSONObject.toJSONString(R.ok().put("data", msg)));
                     break;
                 case "sendMsg":
@@ -257,12 +288,16 @@ public class WebSocketServer {
                     liveMsg.setCreateTime(new Date());
 
                     if (userType == 0) {
-                        LiveWatchUser liveWatchUser = liveWatchUserService.getByLiveIdAndUserId(msg.getLiveId(), msg.getUserId());
-                        if(liveWatchUser.getMsgStatus() == 1){
+                        List<LiveWatchUser> liveWatchUser = liveWatchUserService.getByLiveIdAndUserId(msg.getLiveId(), msg.getUserId());
+                        if(!liveWatchUser.isEmpty() && liveWatchUser.get(0).getMsgStatus() == 1){
                             sendMessage(session, JSONObject.toJSONString(R.error("你已被禁言")));
                             return;
                         }
-
+                        Map<String, Integer> flagMap = liveWatchUserService.getLiveFlagWithCache(liveId);
+                        Integer liveFlag = flagMap.get("liveFlag");
+                        Integer replayFlag = flagMap.get("replayFlag");
+                        liveMsg.setLiveFlag(liveFlag);
+                        liveMsg.setReplayFlag(replayFlag);
                         liveMsgService.insertLiveMsg(liveMsg);
                     }
 
@@ -404,12 +439,7 @@ public class WebSocketServer {
         try {
             this.onClose(session);
         } catch (Exception e) {
-            log.error("webSocket 错误 onError", e);
-        }
-        if (throwable instanceof EOFException) {
-            log.info("WebSocket连接被客户端正常关闭(EOF),sessionId: {}", session.getId());
-        } else {
-            log.error("WebSocket连接错误", throwable);
+            log.error("webSocket 错误处理失败", e);
         }
     }
 
@@ -433,7 +463,26 @@ public class WebSocketServer {
 
     //发送消息
     public void sendMessage(Session session, String message) throws IOException {
-        session.getAsyncRemote().sendText(message);
+        if (session == null || !session.isOpen()) {
+            return;
+        }
+
+        // 获取Session锁
+        Lock lock = sessionLocks.get(session.getId());
+        if (lock == null) {
+            // 如果锁不存在,创建一个新锁
+            lock = sessionLocks.computeIfAbsent(session.getId(), k -> new ReentrantLock());
+        }
+
+        // 使用锁保证同一Session的消息串行发送
+        lock.lock();
+        try {
+            if (session.isOpen()) {
+                session.getAsyncRemote().sendText(message);
+            }
+        } finally {
+            lock.unlock();
+        }
     }
 
     public void sendIntegralMessage(Long liveId, Long userId,Long scoreAmount) {
@@ -473,16 +522,33 @@ public class WebSocketServer {
         ConcurrentHashMap<Long, Session> room = getRoom(liveId);
         List<Session> adminRoom = getAdminRoom(liveId);
 
+        // 普通用户房间:并行发送
         room.forEach((k, v) -> {
             if (v.isOpen()) {
-                sendWithRetry(v,message,7);
+                sendWithRetry(v,message,1);
             }
         });
-        adminRoom.forEach(v -> {
-            if (v.isOpen()) {
-                sendWithRetry(v,message,7);
+
+        // admin房间:串行发送,使用单线程执行器
+        if (!adminRoom.isEmpty()) {
+            ExecutorService executor = adminExecutors.get(liveId);
+            if (executor != null && !executor.isShutdown()) {
+                executor.submit(() -> {
+                    for (Session session : adminRoom) {
+                        if (session.isOpen()) {
+                            sendWithRetry(session, message, 1);
+                        }
+                    }
+                });
+            } else {
+                // 如果执行器不存在或已关闭,直接发送
+                adminRoom.forEach(v -> {
+                    if (v.isOpen()) {
+                        sendWithRetry(v, message, 1);
+                    }
+                });
             }
-        });
+        }
     }
 
     /**
@@ -494,7 +560,7 @@ public class WebSocketServer {
         ConcurrentHashMap<Long, Session> room = getRoom(liveId);
         room.forEach((k, v) -> {
             if (v.isOpen()) {
-                sendWithRetry(v,message,7);
+                sendWithRetry(v,message,1);
             }
         });
     }
@@ -629,6 +695,27 @@ public class WebSocketServer {
         lastLikeCountCache.remove(liveId);
     }
 
+    @Scheduled(fixedRate = 2000)// 每2秒执行一次
+    public void broadcastUserNumMessage() {
+        // 遍历每个直播间
+        for (Map.Entry<Long, ConcurrentHashMap<Long, Session>> entry : rooms.entrySet()) {
+            Long liveId = entry.getKey();
+            ConcurrentHashMap<Long, Session> room = entry.getValue();
+
+            // 统计当前直播间的在线人数
+            int onlineCount = room.size();
+
+            // 构造消息
+            SendMsgVo sendMsgVo = new SendMsgVo();
+            sendMsgVo.setLiveId(liveId);
+            sendMsgVo.setCmd("userCount");
+            sendMsgVo.setData(String.valueOf(onlineCount));
+
+            // 广播当前直播间的在线人数
+            broadcastMessage(liveId, JSONObject.toJSONString(R.ok().put("data", sendMsgVo)));
+        }
+    }
+
 
     @Scheduled(fixedRate = 300)// 每0.3分钟执行一次
     public void broadcastLikeMessage() {
@@ -644,7 +731,6 @@ public class WebSocketServer {
                 String valueStr = cacheObject.toString().trim();
                 current = Integer.parseInt(valueStr);
             } catch (NumberFormatException e) {
-                log.error("点赞数格式错误,liveId: {}, value: {}", liveId, cacheObject, e);
                 continue;
             }
             Integer last = lastLikeCountCache.getOrDefault(liveId, 0);
@@ -659,4 +745,73 @@ public class WebSocketServer {
         }
         lastLikeCountCache.keySet().removeIf(liveId -> !activeLiveIds.contains(liveId));
     }
+
+    /**
+     * 定时清理无效会话(每分钟执行一次)
+     * 检查心跳超时的会话并关闭
+     */
+    @Scheduled(fixedRate = 60000) // 每分钟执行一次
+    public void cleanInactiveSessions() {
+        long currentTime = System.currentTimeMillis();
+        int cleanedCount = 0;
+
+        // 遍历所有直播间
+        for (Map.Entry<Long, ConcurrentHashMap<Long, Session>> roomEntry : rooms.entrySet()) {
+            Long liveId = roomEntry.getKey();
+            ConcurrentHashMap<Long, Session> room = roomEntry.getValue();
+
+            // 检查普通用户会话
+            List<Long> toRemove = new ArrayList<>();
+            room.forEach((userId, session) -> {
+                Long lastHeartbeat = heartbeatCache.get(session.getId());
+                if (lastHeartbeat != null && (currentTime - lastHeartbeat) > HEARTBEAT_TIMEOUT) {
+                    toRemove.add(userId);
+                    try {
+                        if (session.isOpen()) {
+                            session.close(new CloseReason(CloseReason.CloseCodes.NORMAL_CLOSURE, "心跳超时"));
+                        }
+                    } catch (Exception e) {
+                        log.error("关闭超时会话失败: sessionId={}, liveId={}, userId={}",
+                                session.getId(), liveId, userId, e);
+                    }
+                }
+            });
+
+            // 移除超时的会话
+            toRemove.forEach(room::remove);
+            cleanedCount += toRemove.size();
+        }
+
+        // 检查admin房间
+        for (Map.Entry<Long, CopyOnWriteArrayList<Session>> adminEntry : adminRooms.entrySet()) {
+            Long liveId = adminEntry.getKey();
+            CopyOnWriteArrayList<Session> adminRoom = adminEntry.getValue();
+
+            List<Session> toRemoveAdmin = new ArrayList<>();
+            for (Session session : adminRoom) {
+                Long lastHeartbeat = heartbeatCache.get(session.getId());
+                if (lastHeartbeat != null && (currentTime - lastHeartbeat) > HEARTBEAT_TIMEOUT) {
+                    toRemoveAdmin.add(session);
+                    try {
+                        if (session.isOpen()) {
+                            session.close(new CloseReason(CloseReason.CloseCodes.NORMAL_CLOSURE, "心跳超时"));
+                        }
+                    } catch (Exception e) {
+                        log.error("关闭admin超时会话失败: sessionId={}, liveId={}",
+                                session.getId(), liveId, e);
+                    }
+                }
+            }
+
+            // 移除超时的admin会话
+            toRemoveAdmin.forEach(adminRoom::remove);
+            cleanedCount += toRemoveAdmin.size();
+        }
+
+        if (cleanedCount > 0) {
+            if (random.nextInt(10) == 1) {
+                log.info("已清理 {} 个无效会话", cleanedCount);
+            }
+        }
+    }
 }

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

@@ -124,4 +124,6 @@ public class   Live extends BaseEntity {
     private Date createTime;
     private String companyName;
     private Long fileSize;
+    private Long videoFileSize;
+    private Long videoDuration;
 }

+ 9 - 0
fs-service-system/src/main/java/com/fs/live/domain/LiveData.java

@@ -77,4 +77,13 @@ public class LiveData{
     private Long followNum;
 
 
+    /** 回放观看人次 */
+    @Excel(name = "回放观看人次")
+    private Long replayViewNum;
+
+    /** 回放点赞数 */
+    @Excel(name = "回放点赞数")
+    private Long replayLikeNum;
+
+
 }

+ 11 - 1
fs-service-system/src/main/java/com/fs/live/domain/LiveMsg.java

@@ -18,7 +18,7 @@ import lombok.EqualsAndHashCode;
 public class LiveMsg extends BaseEntity {
 
     /** id */
-    
+
     private Long msgId;
 
     /** 直播ID */
@@ -40,4 +40,14 @@ public class LiveMsg extends BaseEntity {
     /** 消息 */
     @Excel(name = "消息")
     private String msg;
+
+    private Integer singleVisible;
+
+    /** 直播消息标记:0-否 1-是 */
+    @Excel(name = "直播消息标记")
+    private Integer liveFlag = 0;
+
+    /** 回放消息标记:0-否 1-是 */
+    @Excel(name = "回放消息标记")
+    private Integer replayFlag = 0;
 }

+ 20 - 0
fs-service-system/src/main/java/com/fs/live/domain/LiveWatchUser.java

@@ -46,4 +46,24 @@ public class LiveWatchUser extends BaseEntity {
     private String nickName;
     private String tabName;
 
+    /** 直播进入标记:0-否 1-是 */
+    @Excel(name = "直播进入标记")
+    private Integer liveFlag = 0;
+
+    /** 回放进入标记:0-否 1-是 */
+    @Excel(name = "回放进入标记")
+    private Integer replayFlag = 0;
+
+    /** 用户所在位置 */
+    @Excel(name = "用户所在位置")
+    private String location;
+
+    private Integer pageNum;
+    private Integer pageSize;
+
+    /** 全局用户自见 */
+    private Integer globalVisible = 0;
+    /** 用户自见 */
+    private Integer singleVisible = 0;
+
 }

+ 68 - 0
fs-service-system/src/main/java/com/fs/live/dto/TemplateMessageSendRequestDTO.java

@@ -0,0 +1,68 @@
+package com.fs.live.dto;
+
+import lombok.Data;
+
+import java.util.Map;
+
+/**
+ * 模板消息发送请求DTO
+ * <p>
+ * 用于构建发送模板消息的请求体。
+ * </p>
+ *
+ * @author xdd
+ * @version 1.0
+ * @since 2025-02-27
+ */
+@Data
+public class TemplateMessageSendRequestDTO {
+
+    /**
+     * 接收者openid
+     * <p>
+     * 用户的唯一标识符。
+     * </p>
+     */
+    private String touser;
+
+    /**
+     * 模板ID
+     * <p>
+     * 所需下发的模板消息的id。
+     * </p>
+     */
+    private String template_id;
+
+    /**
+     * 跳转页面
+     * <p>
+     * 点击模板消息后跳转的页面,可以为空。
+     * </p>
+     */
+    private String page;
+
+    /**
+     * 模板数据
+     * <p>
+     * 模板内容,键值对形式,键名为模板中的变量名,值为要替换的内容。
+     * </p>
+     */
+    private Map<String, TemplateDataValue> data;
+
+    /**
+     * 模板数据值对象
+     * <p>
+     * 内部类,用于表示模板数据中的单个值。
+     * </p>
+     */
+    @Data
+    public static class TemplateDataValue {
+        /**
+         * 模板变量值
+         * <p>
+         * 要替换模板变量的具体内容。
+         * </p>
+         */
+        private String value;
+    }
+}

+ 3 - 0
fs-service-system/src/main/java/com/fs/live/mapper/LiveCouponMapper.java

@@ -136,4 +136,7 @@ public interface LiveCouponMapper
 
     @Select("select * from live_coupon_issue_relation where live_id = #{liveId}")
     List<LiveCouponIssueRelation> selectCouponRelationByLiveId(@Param("liveId") Long liveId);
+
+    @Select("select * from live_coupon_issue_relation where coupon_issue_id = #{couponIssueId}")
+    List<LiveCouponIssueRelation> selectCouponRelationByCouponIssueId(@Param("couponIssueId") Long couponIssueId);
 }

+ 31 - 2
fs-service-system/src/main/java/com/fs/live/mapper/LiveDataMapper.java

@@ -2,8 +2,7 @@ package com.fs.live.mapper;
 
 
 import com.fs.live.domain.LiveData;
-import com.fs.live.vo.RecentLiveDataVo;
-import com.fs.live.vo.TrendDataVO;
+import com.fs.live.vo.*;
 import org.apache.ibatis.annotations.Param;
 import org.springframework.stereotype.Repository;
 
@@ -124,4 +123,34 @@ public interface LiveDataMapper {
     TrendDataVO getCompanyPreviousData(@Param("prevStartDate") String start,@Param("prevEndDate") String end,@Param("companyId") Long companyId);
 
     List<Map<String, Object>> getCompanyChartData(@Param("chartStartDate") String chartStartDate,@Param("chartEndDate") String chartEndDate, @Param("format") String format,@Param("category") String category,@Param("companyId") Long companyId);
+
+
+    /**
+     * 查询直播间详情数据(SQL方式)
+     * @param liveId 直播间ID
+     * @return 详情数据
+     */
+    LiveDataDetailVo selectLiveDataDetailBySql(@Param("liveId") Long liveId);
+
+    /**
+     * 查询直播间用户详情列表(SQL方式)
+     * @param liveId 直播间ID
+     * @return 用户详情列表
+     */
+    List<LiveUserDetailVo> selectLiveUserDetailListBySql(@Param("liveId") Long liveId);
+
+    /**
+     * 查询直播间统计数据
+     * @param liveIds 直播间ID列表
+     * @return 统计数据
+     */
+    LiveDataStatisticsVo selectLiveDataStatistics(@Param("liveIds") List<Long> liveIds);
+
+    /**
+     * 查询直播间列表数据
+     * @param liveIds 直播间ID列表
+     * @return 列表数据
+     */
+    List<LiveDataListVo> selectLiveDataListByLiveIds(@Param("liveIds") List<Long> liveIds);
+
 }

+ 41 - 0
fs-service-system/src/main/java/com/fs/live/mapper/LiveMapper.java

@@ -2,6 +2,7 @@ package com.fs.live.mapper;
 
 
 import com.fs.live.domain.Live;
+import com.fs.live.param.LiveDataParam;
 import com.fs.live.vo.LiveListVo;
 import org.apache.ibatis.annotations.Param;
 import org.apache.ibatis.annotations.Select;
@@ -134,4 +135,44 @@ public interface LiveMapper
     List<Live> liveShowList(@Param("companyIds") List<Long> companyIds);
 
     List<Live> selectLiveShowReadyStartLiveList(@Param("companyIds") List<Long> companyIds);
+
+    @Select("select * from live where is_audit = 1 and is_del = 0 and status in (1,2,4) and live_type in (2,3) order by create_time desc")
+    List<Live> liveListAll();
+
+    @Select({"<script>" +
+            "select * from live where 1=1 " +
+            " <if test='param.companyId!=null' > and (company_id is null or company_id = #{param.companyId}) </if> and live_type IN (1,2, 3) AND status IN (3, 4) AND is_del = 0 and is_audit=1 " +
+            " <if test='param.liveName!=null' > and live_name like concat('%' ,#{param.liveName},'%') </if>" +
+            "  order by create_time desc" +
+            " </script>"})
+    List<Live> listLiveData(@Param("param") LiveDataParam param);
+
+    @Select({"<script>" +
+            "select count(1) from live where 1=1 " +
+            " <if test='param.companyId!=null' > and company_id = #{param.companyId} </if> and live_type IN (1,2, 3) AND status IN (3, 4) AND is_del = 0 and is_audit=1" +
+            " <if test='param.liveName!=null' > and live_name like concat('%' ,#{param.liveName},'%') </if>" +
+            "  order by create_time desc" +
+            " </script>"})
+    int listLiveDataCount(@Param("param") LiveDataParam param);
+
+    /**
+     * 查询直播间是直播还是回放状态
+     * 判断标准:当前直播间开始时间 + 视频类型为1的视频时长,如果大于当前时间,返回1,否则返回0
+     * @param liveId 直播间ID
+     * @return 1表示直播中,0表示回放中
+     */
+    @Select("SELECT CASE " +
+            "WHEN l.start_time IS NOT NULL AND " +
+            "     (l.start_time + INTERVAL COALESCE(SUM(CASE WHEN lv.video_type = 1 THEN lv.duration ELSE 0 END), 0) SECOND) > NOW() " +
+            "THEN 1 " +
+            "ELSE 0 " +
+            "END AS liveFlag " +
+            "FROM live l " +
+            "LEFT JOIN live_video lv ON l.live_id = lv.live_id AND lv.video_type = 1 " +
+            "WHERE l.live_id = #{liveId} " +
+            "GROUP BY l.live_id, l.start_time")
+    Integer selectLiveFlagByLiveId(@Param("liveId") Long liveId);
+
+    @Update("update live set global_visible = #{status} where live_id = #{liveId}")
+    void updateGlobalVisible(@Param("liveId")Long liveId,@Param("status") Integer status);
 }

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

@@ -118,4 +118,7 @@ public interface LiveOrderMapper {
     LiveOrder selectLiveOrderByOrderCode(@Param("orderCode") String orderCode);
 
     List<LiveOrder> selectFsOutDateOrder();
+
+    @Select("SELECT * FROM live_order WHERE live_id= #{liveId}")
+    List<LiveOrder> selectOrderByLiveId(@Param("liveId") Long liveId);
 }

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

@@ -6,6 +6,7 @@ import com.fs.live.vo.LiveWatchUserStatistics;
 import com.fs.live.vo.LiveWatchUserVO;
 import org.apache.ibatis.annotations.Param;
 import org.apache.ibatis.annotations.Select;
+import org.apache.ibatis.annotations.Update;
 
 import java.util.Date;
 import java.util.List;
@@ -111,4 +112,23 @@ public interface LiveWatchUserMapper {
             "FROM " +
             "    live_watch_user where live_id=#{liveId}")
     LiveWatchUserStatistics liveUserTotals(LiveWatchUser liveWatchUser);
+
+    @Update("update live_watch_user set global_visible = #{status} where live_id = #{liveId}")
+    void updateGlobalVisible(@Param("liveId") long liveId,@Param("status") int status);
+
+    @Update("update live_watch_user set single_visible = #{status} where live_id = #{liveId} and user_id=#{userId}")
+    void updateSingleVisible(@Param("liveId") long liveId,@Param("status") int status,@Param("userId") long userId);
+
+    @Select("select * from live_watch_user where live_id = #{liveId}")
+    List<LiveWatchUser> selectLiveWatchUserListByLiveId(@Param("liveId") Long liveId);
+
+    /**
+     * 根据唯一索引查询:live_id, user_id, live_flag, replay_flag
+     */
+    LiveWatchUser selectByUniqueIndex(@Param("liveId") Long liveId, @Param("userId") Long userId,
+                                      @Param("liveFlag") Integer liveFlag, @Param("replayFlag") Integer replayFlag);
+    /**
+     * 根据唯一索引更新或插入(ON DUPLICATE KEY UPDATE)
+     */
+    int insertOrUpdateByUniqueIndex(LiveWatchUser liveWatchUser);
 }

+ 99 - 0
fs-service-system/src/main/java/com/fs/live/param/LiveDataParam.java

@@ -0,0 +1,99 @@
+package com.fs.live.param;
+
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fs.common.annotation.Excel;
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 直播数据对象 live_data
+ *
+ * @author fs
+ * @date 2025-03-05
+ */
+@Data
+public class LiveDataParam {
+
+    /** 直播id */
+
+    private Long liveId;
+
+
+    @Excel(name = "直播名称")
+    private String liveName;
+
+
+    @Excel(name = "直播封面")
+    private String liveImgUrl;
+
+
+    @Excel(name = "1待直播 2直播中 3已结束")
+    private Integer status;
+
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    @Excel(name = "开始时间", width = 30, dateFormat = "yyyy-MM-dd")
+    private Date startTime;
+
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    @Excel(name = "结束时间", width = 30, dateFormat = "yyyy-MM-dd")
+    private Date finishTime;
+
+
+    @Excel(name = "直播地址")
+    private String rtmpUrl;
+
+    /** 浏览量 */
+    @Excel(name = "浏览量")
+    private Long pageViews;
+
+    /** 访客数 */
+    @Excel(name = "访客数")
+    private Long uniqueVisitors;
+
+    /** 累计观看人次 */
+    @Excel(name = "累计观看人次")
+    private Long totalViews;
+
+    /** 累计观看人数 */
+    @Excel(name = "累计观看人数")
+    private Long uniqueViewers;
+
+    /** 最高在线人数 */
+    @Excel(name = "最高在线人数")
+    private Long peakConcurrentViewers;
+
+
+    /** 点赞数 */
+    @Excel(name = "点赞数")
+    private Long likes;
+
+    /** 收藏数*/
+    @Excel(name = "收藏数")
+    private Long favouriteNum;
+
+    /** 关注数*/
+    @Excel(name = "关注数")
+    private Long followNum;
+
+    private Long companyId;
+    /** 完课状态 */
+    private Integer completeStatus;
+
+    /** 观看时长 */
+    private Integer watchDuration;
+    /** 结束时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    @Excel(name = "结束时间", width = 30, dateFormat = "yyyy-MM-dd")
+    private Date endTime;
+
+
+    private Integer pageNum;
+    private Integer pageSize;
+    private String watchType;
+
+
+}

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

@@ -85,4 +85,11 @@ public interface ILiveAutoTaskService {
     void directInsertLiveAutoTask(LiveAutoTask liveAutoTask);
 
     void batchInsertLiveAutoTask(List<LiveAutoTask> addList);
+
+    void deleteAutoTasksByCouponId(Long couponId, Long liveId);
+    void deleteAutoTasksByRedId(Long redId, Long liveId);
+    void deleteAutoTasksByLotteryId(Long lotteryId, Long liveId);
+    void deleteAutoTasksByGoodsId(Long goodsId, Long liveId);
+    void deleteLotteryCache(Long lotteryId, Long liveId);
+    void deleteRedCache(Long redId, Long liveId);
 }

+ 40 - 0
fs-service-system/src/main/java/com/fs/live/service/ILiveDataService.java

@@ -3,7 +3,9 @@ package com.fs.live.service;
 
 import com.fs.common.core.domain.R;
 import com.fs.live.domain.LiveData;
+import com.fs.live.param.LiveDataParam;
 import com.fs.live.vo.ColumnsConfigVo;
+import com.fs.live.vo.LiveUserDetailExportVO;
 import com.fs.live.vo.RecentLiveDataVo;
 import com.fs.live.vo.TrendDataVO;
 
@@ -127,4 +129,42 @@ public interface ILiveDataService {
     List<RecentLiveDataVo> getCompanyLiveTop(String rankType,Long companyId);
 
     TrendDataVO getCompanyTrendData(String type, String date, String category, Long companyId);
+
+
+    /**
+     * 查询直播间详情数据(SQL方式)
+     * @param liveId 直播间ID
+     * @return 详情数据
+     */
+    R getLiveDataDetailBySql(Long liveId);
+
+    /**
+     * 查询直播间用户详情列表(SQL方式)
+     * @param liveId 直播间ID
+     * @return 用户详情列表
+     */
+    R getLiveUserDetailListBySql(Long liveId);
+
+    /**
+     * 查询直播间详情数据(查询数据服务器处理方式)
+     * @param liveId 直播间ID
+     * @return 详情数据
+     */
+    R getLiveDataDetailByServer(Long liveId);
+
+    /**
+     * 查询直播间用户详情列表(查询数据服务器处理方式)
+     * @param liveId 直播间ID
+     * @return 用户详情列表
+     */
+    R getLiveUserDetailListByServer(Long liveId);
+
+    /**
+     * 导出直播间用户详情数据
+     * @param liveId 直播间ID
+     * @return 导出VO列表
+     */
+    List<LiveUserDetailExportVO> exportLiveUserDetail(Long liveId);
+
+    R listLiveData(LiveDataParam param);
 }

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

@@ -186,4 +186,7 @@ public interface ILiveService
 
     int updateLiveEntity(Live live);
 
+    void updateGlobalVisible(long liveId, Integer status);
+
+    Live selectLiveDbByLiveId(Long liveId);
 }

+ 4 - 2
fs-service-system/src/main/java/com/fs/live/service/ILiveWatchUserService.java

@@ -4,6 +4,7 @@ package com.fs.live.service;
 import com.fs.common.core.domain.R;
 import com.fs.live.domain.LiveWatchUser;
 import com.fs.live.vo.LiveWatchUserVO;
+import com.fs.store.domain.FsUser;
 
 import java.util.Date;
 import java.util.List;
@@ -73,9 +74,10 @@ public interface ILiveWatchUserService {
      */
     int deleteLiveWatchUserById(Long id);
 
-    LiveWatchUser getByLiveIdAndUserId(long liveId, long userId);
+    List<LiveWatchUser> getByLiveIdAndUserId(long liveId, long userId);
 
-    LiveWatchUser join(long liveId, long userId);
+    Map<String, Integer> getLiveFlagWithCache(Long liveId);
+    LiveWatchUser join(FsUser fsUser, long liveId, long userId, String location);
     LiveWatchUser close(long liveId, long userId);
 
     /**

+ 236 - 4
fs-service-system/src/main/java/com/fs/live/service/impl/LiveAutoTaskServiceImpl.java

@@ -3,10 +3,7 @@ package com.fs.live.service.impl;
 import java.time.LocalDateTime;
 import java.time.LocalTime;
 import java.time.ZoneId;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Date;
-import java.util.List;
+import java.util.*;
 
 import cn.hutool.core.util.ObjectUtil;
 import com.alibaba.fastjson.JSON;
@@ -335,6 +332,13 @@ public class LiveAutoTaskServiceImpl implements ILiveAutoTaskService {
     @Override
     public int deleteLiveAutoTaskByIds(Long[] ids)
     {
+        // 先从缓存中删除所有任务
+        for (Long id : ids) {
+            LiveAutoTask task = baseMapper.selectLiveAutoTaskById(id);
+            if (task != null) {
+                removeTaskFromCache(task);
+            }
+        }
         return baseMapper.deleteLiveAutoTaskByIds(ids);
     }
 
@@ -406,4 +410,232 @@ public class LiveAutoTaskServiceImpl implements ILiveAutoTaskService {
     public void batchInsertLiveAutoTask(List<LiveAutoTask> addList) {
         baseMapper.batchInsertLiveAutoTask(addList);
     }
+
+    /**
+     * 删除与优惠券相关的自动化任务(taskType=5)
+     * @param couponId 优惠券ID
+     * @param liveId 直播间ID(可为null,null表示所有直播间)
+     */
+    @Override
+    public void deleteAutoTasksByCouponId(Long couponId, Long liveId) {
+        try {
+            List<LiveAutoTask> allTasks;
+            if (liveId != null) {
+                allTasks = baseMapper.selectLiveAutoTaskByLiveId(liveId);
+            } else {
+                // 查询所有直播间的任务
+                LiveAutoTask queryTask = new LiveAutoTask();
+                queryTask.setTaskType(5L); // 优惠券任务
+                allTasks = baseMapper.selectLiveAutoTaskList(queryTask);
+            }
+
+            List<Long> taskIdsToDelete = new ArrayList<>();
+            for (LiveAutoTask task : allTasks) {
+                if (task.getTaskType() != null && task.getTaskType() == 5L) {
+                    try {
+                        LiveCoupon liveCoupon = JSON.parseObject(task.getContent(), LiveCoupon.class);
+                        if (liveCoupon != null && couponId.equals(liveCoupon.getCouponId())) {
+                            taskIdsToDelete.add(task.getId());
+                            // 从缓存中删除
+                            removeTaskFromCache(task);
+                        }
+                    } catch (Exception e) {
+                        log.warn("解析自动化任务content失败,taskId: {}, error: {}", task.getId(), e.getMessage());
+                    }
+                }
+            }
+
+            if (!taskIdsToDelete.isEmpty()) {
+                Long[] ids = taskIdsToDelete.toArray(new Long[0]);
+                baseMapper.deleteLiveAutoTaskByIds(ids);
+                log.info("删除与优惠券ID {} 相关的自动化任务 {} 个", couponId, taskIdsToDelete.size());
+            }
+        } catch (Exception e) {
+            log.error("删除优惠券相关自动化任务失败,couponId: {}", couponId, e);
+        }
+    }
+
+    /**
+     * 删除与红包相关的自动化任务(taskType=2)
+     * @param redId 红包ID
+     * @param liveId 直播间ID
+     */
+    @Override
+    public void deleteAutoTasksByRedId(Long redId, Long liveId) {
+        try {
+            List<LiveAutoTask> allTasks = baseMapper.selectLiveAutoTaskByLiveId(liveId);
+            List<Long> taskIdsToDelete = new ArrayList<>();
+
+            for (LiveAutoTask task : allTasks) {
+                if (task.getTaskType() != null && task.getTaskType() == 2L) {
+                    try {
+                        LiveRedConf liveRedConf = JSON.parseObject(task.getContent(), LiveRedConf.class);
+                        if (liveRedConf != null && redId.equals(liveRedConf.getRedId())) {
+                            taskIdsToDelete.add(task.getId());
+                            // 从缓存中删除
+                            removeTaskFromCache(task);
+                        }
+                    } catch (Exception e) {
+                        log.warn("解析自动化任务content失败,taskId: {}, error: {}", task.getId(), e.getMessage());
+                    }
+                }
+            }
+
+            if (!taskIdsToDelete.isEmpty()) {
+                Long[] ids = taskIdsToDelete.toArray(new Long[0]);
+                baseMapper.deleteLiveAutoTaskByIds(ids);
+                log.info("删除与红包ID {} 相关的自动化任务 {} 个", redId, taskIdsToDelete.size());
+            }
+        } catch (Exception e) {
+            log.error("删除红包相关自动化任务失败,redId: {}, liveId: {}", redId, liveId, e);
+        }
+    }
+
+    /**
+     * 删除与抽奖相关的自动化任务(taskType=4)
+     * @param lotteryId 抽奖ID
+     * @param liveId 直播间ID
+     */
+    @Override
+    public void deleteAutoTasksByLotteryId(Long lotteryId, Long liveId) {
+        try {
+            List<LiveAutoTask> allTasks = baseMapper.selectLiveAutoTaskByLiveId(liveId);
+            List<Long> taskIdsToDelete = new ArrayList<>();
+
+            for (LiveAutoTask task : allTasks) {
+                if (task.getTaskType() != null && task.getTaskType() == 4L) {
+                    try {
+                        LiveLotteryConf liveLotteryConf = JSON.parseObject(task.getContent(), LiveLotteryConf.class);
+                        if (liveLotteryConf != null && lotteryId.equals(liveLotteryConf.getLotteryId())) {
+                            taskIdsToDelete.add(task.getId());
+                            // 从缓存中删除
+                            removeTaskFromCache(task);
+                        }
+                    } catch (Exception e) {
+                        log.warn("解析自动化任务content失败,taskId: {}, error: {}", task.getId(), e.getMessage());
+                    }
+                }
+            }
+
+            if (!taskIdsToDelete.isEmpty()) {
+                Long[] ids = taskIdsToDelete.toArray(new Long[0]);
+                baseMapper.deleteLiveAutoTaskByIds(ids);
+                log.info("删除与抽奖ID {} 相关的自动化任务 {} 个", lotteryId, taskIdsToDelete.size());
+            }
+        } catch (Exception e) {
+            log.error("删除抽奖相关自动化任务失败,lotteryId: {}, liveId: {}", lotteryId, liveId, e);
+        }
+    }
+
+    /**
+     * 删除与商品相关的自动化任务(taskType=1或6)
+     * @param goodsId 商品ID
+     * @param liveId 直播间ID
+     */
+    @Override
+    public void deleteAutoTasksByGoodsId(Long goodsId, Long liveId) {
+        try {
+            List<LiveAutoTask> allTasks = baseMapper.selectLiveAutoTaskByLiveId(liveId);
+            List<Long> taskIdsToDelete = new ArrayList<>();
+
+            for (LiveAutoTask task : allTasks) {
+                if (task.getTaskType() != null && (task.getTaskType() == 1L || task.getTaskType() == 6L)) {
+                    try {
+                        if (task.getTaskType() == 1L) {
+                            // 商品推荐任务
+                            LiveGoodsVo liveGoodsVo = JSON.parseObject(task.getContent(), LiveGoodsVo.class);
+                            if (liveGoodsVo != null && goodsId.equals(liveGoodsVo.getGoodsId())) {
+                                taskIdsToDelete.add(task.getId());
+                                removeTaskFromCache(task);
+                            }
+                        } else if (task.getTaskType() == 6L) {
+                            // 商品上下架任务
+                            com.alibaba.fastjson.JSONObject jsonObject = JSON.parseObject(task.getContent());
+                            Long taskGoodsId = jsonObject.getLong("goodsId");
+                            if (taskGoodsId != null && goodsId.equals(taskGoodsId)) {
+                                taskIdsToDelete.add(task.getId());
+                                removeTaskFromCache(task);
+                            }
+                        }
+                    } catch (Exception e) {
+                        log.warn("解析自动化任务content失败,taskId: {}, error: {}", task.getId(), e.getMessage());
+                    }
+                }
+            }
+
+            if (!taskIdsToDelete.isEmpty()) {
+                Long[] ids = taskIdsToDelete.toArray(new Long[0]);
+                baseMapper.deleteLiveAutoTaskByIds(ids);
+                log.info("删除与商品ID {} 相关的自动化任务 {} 个", goodsId, taskIdsToDelete.size());
+            }
+        } catch (Exception e) {
+            log.error("删除商品相关自动化任务失败,goodsId: {}, liveId: {}", goodsId, liveId, e);
+        }
+    }
+
+    /**
+     * 从缓存中删除自动化任务
+     * @param task 自动化任务
+     */
+    private void removeTaskFromCache(LiveAutoTask task) {
+        try {
+            if (task.getLiveId() != null && task.getId() != null) {
+                String cacheKey = "live:auto_task:" + task.getLiveId();
+                // 获取ZSet中的所有任务
+                Set<String> allTasks = redisCache.redisTemplate.opsForZSet().range(cacheKey, 0, -1);
+                if (allTasks != null && !allTasks.isEmpty()) {
+                    // 遍历找到匹配的任务ID
+                    for (String taskJson : allTasks) {
+                        try {
+                            LiveAutoTask cachedTask = JSON.parseObject(taskJson, LiveAutoTask.class);
+                            if (cachedTask != null && task.getId().equals(cachedTask.getId())) {
+                                // 找到匹配的任务,从ZSet中删除
+                                redisCache.redisTemplate.opsForZSet().remove(cacheKey, taskJson);
+                                log.debug("从缓存中删除自动化任务,liveId: {}, taskId: {}", task.getLiveId(), task.getId());
+                                break;
+                            }
+                        } catch (Exception e) {
+                            log.warn("解析缓存中的任务失败,taskJson: {}, error: {}", taskJson, e.getMessage());
+                        }
+                    }
+                }
+            }
+        } catch (Exception e) {
+            log.warn("从缓存删除自动化任务失败,taskId: {}, error: {}", task.getId(), e.getMessage());
+        }
+    }
+
+    /**
+     * 删除抽奖缓存
+     * @param lotteryId 抽奖ID
+     * @param liveId 直播间ID
+     */
+    public void deleteLotteryCache(Long lotteryId, Long liveId) {
+        try {
+            String cacheKey = "live:lottery_task:" + liveId;
+            // 从ZSet中删除指定的抽奖ID
+            redisCache.redisTemplate.opsForZSet().remove(cacheKey, String.valueOf(lotteryId));
+            log.info("删除抽奖缓存,lotteryId: {}, liveId: {}", lotteryId, liveId);
+        } catch (Exception e) {
+            log.error("删除抽奖缓存失败,lotteryId: {}, liveId: {}", lotteryId, liveId, e);
+        }
+    }
+
+    /**
+     * 删除红包缓存
+     * @param redId 红包ID
+     * @param liveId 直播间ID
+     */
+    public void deleteRedCache(Long redId, Long liveId) {
+        try {
+            String cacheKey = "live:red_task:" + liveId;
+            // 从ZSet中删除指定的红包ID
+            redisCache.redisTemplate.opsForZSet().remove(cacheKey, String.valueOf(redId));
+            // 同时删除剩余红包数的缓存
+            redisCache.deleteObject("live:red:remainingLots:" + redId);
+            log.info("删除红包缓存,redId: {}, liveId: {}", redId, liveId);
+        } catch (Exception e) {
+            log.error("删除红包缓存失败,redId: {}, liveId: {}", redId, liveId, e);
+        }
+    }
 }

+ 32 - 0
fs-service-system/src/main/java/com/fs/live/service/impl/LiveCouponIssueServiceImpl.java

@@ -3,12 +3,17 @@ package com.fs.live.service.impl;
 import java.util.Collections;
 import java.util.List;
 import com.fs.common.utils.DateUtils;
+import com.fs.live.domain.LiveCouponIssueRelation;
+import com.fs.live.mapper.LiveCouponMapper;
 import com.fs.live.param.CouponPO;
+import com.fs.live.service.ILiveAutoTaskService;
+import com.fs.live.vo.LiveCouponListVo;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import com.fs.live.mapper.LiveCouponIssueMapper;
 import com.fs.live.domain.LiveCouponIssue;
 import com.fs.live.service.ILiveCouponIssueService;
+import org.springframework.transaction.annotation.Transactional;
 
 /**
  * 优惠券领取Service业务层处理
@@ -21,6 +26,10 @@ public class LiveCouponIssueServiceImpl implements ILiveCouponIssueService
 {
     @Autowired
     private LiveCouponIssueMapper liveCouponIssueMapper;
+    @Autowired
+    private LiveCouponMapper liveCouponMapper;
+    @Autowired
+    private ILiveAutoTaskService liveAutoTaskService;
 
     /**
      * 查询优惠券领取
@@ -79,8 +88,31 @@ public class LiveCouponIssueServiceImpl implements ILiveCouponIssueService
      * @return 结果
      */
     @Override
+    @Transactional
     public int deleteLiveCouponIssueByIds(Long[] ids)
     {
+        // 联动删除:删除所有直播间存在的优惠券、定时任务和缓存
+        for (Long couponIssueId : ids) {
+            // 查询优惠券发布信息,获取couponId
+            LiveCouponIssue issue = liveCouponIssueMapper.selectLiveCouponIssueById(couponIssueId);
+            if (issue != null && issue.getCouponId() != null) {
+                // 删除所有直播间相关的自动化任务(liveId为null表示所有直播间)
+                liveAutoTaskService
+                        .deleteAutoTasksByCouponId(issue.getCouponId(), null);
+
+                // 查询所有使用该优惠券发布的直播间关联关系
+                List<LiveCouponIssueRelation> relations =
+                        liveCouponMapper.selectCouponRelationByCouponIssueId(couponIssueId);
+
+                // 删除所有直播间的关联关系
+                for (LiveCouponIssueRelation relation : relations) {
+                    LiveCouponListVo listVo = new LiveCouponListVo();
+                    listVo.setCouponIds(java.util.Arrays.asList(couponIssueId));
+                    listVo.setLiveId(relation.getLiveId().intValue());
+                    liveCouponMapper.handleDeleteSelectedAdmin(listVo);
+                }
+            }
+        }
         return liveCouponIssueMapper.deleteLiveCouponIssueByIds(ids);
     }
 

+ 59 - 4
fs-service-system/src/main/java/com/fs/live/service/impl/LiveCouponServiceImpl.java

@@ -11,19 +11,17 @@ import com.fs.common.core.redis.RedisUtil;
 import com.fs.common.utils.DateUtils;
 import com.fs.common.utils.StringUtils;
 import com.fs.live.domain.*;
+import com.fs.live.mapper.LiveCouponIssueMapper;
 import com.fs.live.mapper.LiveGoodsMapper;
 import com.fs.live.mapper.LiveMapper;
 import com.fs.live.param.CouponPO;
-import com.fs.live.service.ILiveCouponIssueService;
-import com.fs.live.service.ILiveCouponIssueUserService;
-import com.fs.live.service.ILiveCouponUserService;
+import com.fs.live.service.*;
 import com.fs.live.vo.LiveCouponListVo;
 import com.fs.store.domain.FsStoreCoupon;
 import org.checkerframework.checker.units.qual.A;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import com.fs.live.mapper.LiveCouponMapper;
-import com.fs.live.service.ILiveCouponService;
 import org.springframework.transaction.annotation.Transactional;
 
 /**
@@ -51,6 +49,10 @@ public class LiveCouponServiceImpl implements ILiveCouponService
     private ILiveCouponIssueService liveCouponIssueService;
     @Autowired
     private LiveGoodsMapper liveGoodsMapper;
+    @Autowired
+    private ILiveAutoTaskService liveAutoTaskService;
+    @Autowired
+    private LiveCouponIssueMapper liveCouponIssueMapper;
 
     /**
      * 查询优惠券
@@ -111,6 +113,31 @@ public class LiveCouponServiceImpl implements ILiveCouponService
     @Override
     public int deleteLiveCouponByIds(Long[] couponIds)
     {
+        // 联动删除:删除所有直播间存在的优惠券、定时任务和缓存
+        for (Long couponId : couponIds) {
+            // 删除所有直播间相关的自动化任务(liveId为null表示所有直播间)
+            liveAutoTaskService
+                    .deleteAutoTasksByCouponId(couponId, null);
+
+            // 查询所有使用该优惠券的优惠券发布记录
+            LiveCouponIssue queryIssue = new LiveCouponIssue();
+            queryIssue.setCouponId(couponId);
+            List<LiveCouponIssue> couponIssues = liveCouponIssueMapper.selectLiveCouponIssueList(queryIssue);
+
+            // 删除所有直播间的关联关系
+            for (LiveCouponIssue issue : couponIssues) {
+                if (issue.getId() != null) {
+                    // 查询所有使用该优惠券发布的直播间关联关系
+                    List<LiveCouponIssueRelation> relations = liveCouponMapper.selectCouponRelationByCouponIssueId(issue.getId());
+                    for (LiveCouponIssueRelation relation : relations) {
+                        LiveCouponListVo listVo = new LiveCouponListVo();
+                        listVo.setCouponIds(java.util.Arrays.asList(issue.getId()));
+                        listVo.setLiveId(relation.getLiveId().intValue());
+                        liveCouponMapper.handleDeleteSelectedAdmin(listVo);
+                    }
+                }
+            }
+        }
         return liveCouponMapper.deleteLiveCouponByIds(couponIds);
     }
 
@@ -176,13 +203,41 @@ public class LiveCouponServiceImpl implements ILiveCouponService
     }
 
     @Override
+    @Transactional
     public R handleDeleteSelectedAdmin(LiveCouponListVo listVo) {
+        // 在删除前,查询关联关系,获取couponId和liveId,用于联动删除
+        if (listVo.getCouponIds() != null && !listVo.getCouponIds().isEmpty() && listVo.getLiveId() != null) {
+            Long liveId = Long.valueOf(listVo.getLiveId());
+            for (Long couponIssueId : listVo.getCouponIds()) {
+                // 查询优惠券发布信息
+                LiveCouponIssue liveCouponIssue = liveCouponIssueMapper.selectLiveCouponIssueById(couponIssueId);
+                if (liveCouponIssue != null && liveCouponIssue.getCouponId() != null) {
+                    // 删除相关的自动化任务
+                    liveAutoTaskService
+                            .deleteAutoTasksByCouponId(liveCouponIssue.getCouponId(), liveId);
+                }
+            }
+        }
         liveCouponMapper.handleDeleteSelectedAdmin(listVo);
         return R.ok();
     }
 
     @Override
+    @Transactional
     public int delLiveCoupon(LiveCouponListVo vo) {
+        // 在删除前,查询关联关系,获取couponId和liveId,用于联动删除
+        if (vo.getCouponIds() != null && !vo.getCouponIds().isEmpty() && vo.getLiveId() != null) {
+            Long liveId = Long.valueOf(vo.getLiveId());
+            for (Long couponIssueId : vo.getCouponIds()) {
+                // 查询优惠券发布信息
+                LiveCouponIssue liveCouponIssue = liveCouponIssueMapper.selectLiveCouponIssueById(couponIssueId);
+                if (liveCouponIssue != null && liveCouponIssue.getCouponId() != null) {
+                    // 删除相关的自动化任务
+                    liveAutoTaskService
+                            .deleteAutoTasksByCouponId(liveCouponIssue.getCouponId(), liveId);
+                }
+            }
+        }
         liveCouponMapper.handleDeleteSelectedAdmin(vo);
         return 1;
     }

+ 487 - 9
fs-service-system/src/main/java/com/fs/live/service/impl/LiveDataServiceImpl.java

@@ -5,19 +5,22 @@ 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.spring.SpringUtils;
-import com.fs.live.domain.LiveData;
-import com.fs.live.domain.LiveUserFavorite;
-import com.fs.live.domain.LiveUserFollow;
-import com.fs.live.domain.LiveUserLike;
-import com.fs.live.mapper.LiveDataMapper;
+import com.fs.company.domain.Company;
+import com.fs.company.domain.CompanyUser;
+import com.fs.company.mapper.CompanyMapper;
+import com.fs.company.mapper.CompanyUserMapper;
+import com.fs.live.domain.*;
+import com.fs.live.mapper.*;
+import com.fs.live.param.LiveDataParam;
 import com.fs.live.service.ILiveDataService;
 import com.fs.live.service.ILiveUserFavoriteService;
 import com.fs.live.service.ILiveUserFollowService;
 import com.fs.live.service.ILiveUserLikeService;
-import com.fs.live.vo.ColumnsConfigVo;
-import com.fs.live.vo.DateRange;
-import com.fs.live.vo.RecentLiveDataVo;
-import com.fs.live.vo.TrendDataVO;
+import com.fs.live.vo.*;
+import com.fs.store.domain.FsStoreProduct;
+import com.fs.store.domain.FsUser;
+import com.fs.store.mapper.FsStoreProductMapper;
+import com.fs.store.mapper.FsUserMapper;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -31,6 +34,7 @@ import java.time.LocalDate;
 import java.time.format.DateTimeFormatter;
 import java.util.*;
 import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
 
 import static com.fs.common.constant.LiveKeysConstant.*;
 
@@ -62,6 +66,18 @@ public class LiveDataServiceImpl implements ILiveDataService {
     private ILiveUserFavoriteService liveUserFavoriteService;
     @Autowired
     private LiveDataMapper baseMapper;
+    @Autowired
+    private FsUserMapper fsUserMapper;
+    @Autowired
+    private LiveWatchUserMapper liveWatchUserMapper;
+    @Autowired
+    private CompanyMapper companyMapper;
+    @Autowired
+    private CompanyUserMapper companyUserMapper;
+    @Autowired
+    private LiveOrderMapper liveOrderMapper;
+    @Autowired
+    private LiveMapper liveMapper;
     /**
      * 查询直播数据
      *
@@ -554,4 +570,466 @@ public class LiveDataServiceImpl implements ILiveDataService {
         return column;
     }
 
+    @Override
+    public R getLiveDataDetailBySql(Long liveId) {
+        LiveDataDetailVo detailVo = liveDataMapper.selectLiveDataDetailBySql(liveId);
+        if (detailVo == null) {
+            detailVo = new LiveDataDetailVo();
+        }
+        // 查询单品销量统计
+        List<ProductSalesVo> productSalesList = getProductSalesList(liveId);
+        detailVo.setProductSalesList(productSalesList);
+        return R.ok().put("data", detailVo);
+    }
+
+    @Override
+    public R getLiveUserDetailListBySql(Long liveId) {
+        List<LiveUserDetailVo> userDetailList = liveDataMapper.selectLiveUserDetailListBySql(liveId);
+        return R.ok().put("data", userDetailList);
+    }
+
+    @Override
+    public R getLiveDataDetailByServer(Long liveId) {
+        // 查询数据服务器处理方式:通过查询各个表的数据,在内存中计算统计
+        LiveDataDetailVo detailVo = calculateLiveDataDetailByServer(liveId);
+        // 查询单品销量统计
+        List<ProductSalesVo> productSalesList = getProductSalesList(liveId);
+        detailVo.setProductSalesList(productSalesList);
+        return R.ok().put("data", detailVo);
+    }
+
+    @Override
+    public R getLiveUserDetailListByServer(Long liveId) {
+        // 查询数据服务器处理方式:通过查询各个表的数据,在内存中计算
+        List<LiveUserDetailVo> userDetailList = calculateLiveUserDetailListByServer(liveId);
+        return R.ok().put("data", userDetailList);
+    }
+
+    /**
+     * 通过查询数据服务器处理方式计算直播间详情数据
+     */
+    private LiveDataDetailVo calculateLiveDataDetailByServer(Long liveId) {
+        LiveDataDetailVo detailVo = new LiveDataDetailVo();
+
+        // 查询视频时长
+        LiveVideoMapper liveVideoMapper = SpringUtils.getBean(LiveVideoMapper.class);
+        List<LiveVideo> videos = liveVideoMapper.selectByLiveId(liveId);
+        Long videoDuration = videos.stream()
+                .filter(v -> v.getVideoType() != null && (v.getVideoType() == 1 || v.getVideoType() == 2))
+                .mapToLong(v -> v.getDuration() != null ? v.getDuration() : 0L)
+                .sum();
+        detailVo.setVideoDuration(videoDuration);
+
+        // 查询观看用户数据
+        LiveWatchUser queryParam = new LiveWatchUser();
+        queryParam.setLiveId(liveId);
+        List<LiveWatchUser> watchUsers = liveWatchUserMapper.selectLiveWatchUserList(queryParam);
+
+        // 累计观看人数
+        long totalViewers = watchUsers.stream().map(LiveWatchUser::getUserId).distinct().count();
+        detailVo.setTotalViewers(totalViewers);
+
+        // 直播观看人数
+        long liveViewers = watchUsers.stream()
+                .filter(w -> w.getLiveFlag() != null && w.getLiveFlag() == 1 && (w.getReplayFlag() == null || w.getReplayFlag() == 0))
+                .map(LiveWatchUser::getUserId)
+                .distinct()
+                .count();
+        detailVo.setLiveViewers(liveViewers);
+
+        // 回放观看人数
+        long playbackViewers = watchUsers.stream()
+                .filter(w -> w.getReplayFlag() != null && w.getReplayFlag() == 1 && (w.getLiveFlag() == null || w.getLiveFlag() == 0))
+                .map(LiveWatchUser::getUserId)
+                .distinct()
+                .count();
+        detailVo.setPlaybackViewers(playbackViewers);
+
+        // 累计完课人数(观看时长 >= 视频时长)
+        long totalCompletedCourses = watchUsers.stream()
+                .filter(w -> w.getOnlineSeconds() != null && videoDuration > 0 && w.getOnlineSeconds() >= videoDuration)
+                .map(LiveWatchUser::getUserId)
+                .distinct()
+                .count();
+        detailVo.setTotalCompletedCourses(totalCompletedCourses);
+
+        // 计算到课完课率
+        if (totalViewers > 0) {
+            detailVo.setTotalCompletionRate(BigDecimal.valueOf(totalCompletedCourses * 100.0 / totalViewers).setScale(2, RoundingMode.HALF_UP));
+        }
+
+        // 直播相关统计
+        List<LiveWatchUser> liveWatchUsers = watchUsers.stream()
+                .filter(w -> w.getLiveFlag() != null && w.getLiveFlag() == 1 && (w.getReplayFlag() == null || w.getReplayFlag() == 0))
+                .collect(Collectors.toList());
+
+        long liveOver20Minutes = liveWatchUsers.stream()
+                .filter(w -> w.getOnlineSeconds() != null && w.getOnlineSeconds() >= 1200)
+                .map(LiveWatchUser::getUserId)
+                .distinct()
+                .count();
+        detailVo.setLiveOver20Minutes(liveOver20Minutes);
+
+        long liveOver30Minutes = liveWatchUsers.stream()
+                .filter(w -> w.getOnlineSeconds() != null && w.getOnlineSeconds() >= 1800)
+                .map(LiveWatchUser::getUserId)
+                .distinct()
+                .count();
+        detailVo.setLiveOver30Minutes(liveOver30Minutes);
+
+        if (liveViewers > 0) {
+            detailVo.setLiveCompletionRate20(BigDecimal.valueOf(liveOver20Minutes * 100.0 / liveViewers).setScale(2, RoundingMode.HALF_UP));
+            detailVo.setLiveCompletionRate30(BigDecimal.valueOf(liveOver30Minutes * 100.0 / liveViewers).setScale(2, RoundingMode.HALF_UP));
+        }
+
+        // 直播平均时长
+        double liveAvgDuration = liveWatchUsers.stream()
+                .filter(w -> w.getOnlineSeconds() != null)
+                .mapToLong(LiveWatchUser::getOnlineSeconds)
+                .average()
+                .orElse(0.0);
+        detailVo.setLiveAvgDuration((long) liveAvgDuration);
+
+        // 回放相关统计
+        List<LiveWatchUser> playbackWatchUsers = watchUsers.stream()
+                .filter(w -> w.getReplayFlag() != null && w.getReplayFlag() == 1 && (w.getLiveFlag() == null || w.getLiveFlag() == 0))
+                .collect(Collectors.toList());
+
+        long playbackOver20Minutes = playbackWatchUsers.stream()
+                .filter(w -> w.getOnlineSeconds() != null && w.getOnlineSeconds() >= 1200)
+                .map(LiveWatchUser::getUserId)
+                .distinct()
+                .count();
+        detailVo.setPlaybackOver20Minutes(playbackOver20Minutes);
+
+        long playbackOver30Minutes = playbackWatchUsers.stream()
+                .filter(w -> w.getOnlineSeconds() != null && w.getOnlineSeconds() >= 1800)
+                .map(LiveWatchUser::getUserId)
+                .distinct()
+                .count();
+        detailVo.setPlaybackOver30Minutes(playbackOver30Minutes);
+
+        if (playbackViewers > 0) {
+            detailVo.setPlaybackCompletionRate20(BigDecimal.valueOf(playbackOver20Minutes * 100.0 / playbackViewers).setScale(2, RoundingMode.HALF_UP));
+            detailVo.setPlaybackCompletionRate30(BigDecimal.valueOf(playbackOver30Minutes * 100.0 / playbackViewers).setScale(2, RoundingMode.HALF_UP));
+        }
+
+        // 回放平均时长
+        double playbackAvgDuration = playbackWatchUsers.stream()
+                .filter(w -> w.getOnlineSeconds() != null)
+                .mapToLong(LiveWatchUser::getOnlineSeconds)
+                .average()
+                .orElse(0.0);
+        detailVo.setPlaybackAvgDuration((long) playbackAvgDuration);
+
+        // 回放完播率
+        if (videoDuration > 0) {
+            detailVo.setPlaybackFinishRate(BigDecimal.valueOf(playbackAvgDuration * 100.0 / videoDuration).setScale(2, RoundingMode.HALF_UP));
+        }
+
+        // 查询直播峰值
+        LiveData liveData = liveDataMapper.selectLiveDataByLiveId(liveId);
+        if (liveData != null && liveData.getPeakConcurrentViewers() != null) {
+            detailVo.setLivePeak(liveData.getPeakConcurrentViewers());
+        }
+
+        // 查询订单数据
+        LiveOrderMapper liveOrderMapper = SpringUtils.getBean(LiveOrderMapper.class);
+        LiveOrder orderQuery = new LiveOrder();
+        orderQuery.setLiveId(liveId);
+        List<LiveOrder> orders = liveOrderMapper.selectLiveOrderList(orderQuery);
+
+        BigDecimal gmv = orders.stream()
+                .filter(o -> "1".equals(o.getIsPay()))
+                .map(LiveOrder::getPayPrice)
+                .filter(Objects::nonNull)
+                .reduce(BigDecimal.ZERO, BigDecimal::add);
+        detailVo.setGmv(gmv);
+
+        long paidUsers = orders.stream()
+                .filter(o -> "1".equals(o.getIsPay()))
+                .filter(o -> o.getUserId() != null && !o.getUserId().isEmpty())
+                .map(o -> {
+                    try {
+                        return Long.parseLong(o.getUserId());
+                    } catch (NumberFormatException e) {
+                        return null;
+                    }
+                })
+                .filter(Objects::nonNull)
+                .distinct()
+                .count();
+        detailVo.setPaidUsers(paidUsers);
+
+        long paidOrders = orders.stream()
+                .filter(o -> "1".equals(o.getIsPay()))
+                .count();
+        detailVo.setPaidOrders(paidOrders);
+
+        // 计算转化率
+        if (detailVo.getLivePeak() > 0) {
+            detailVo.setPeakConversionRate(BigDecimal.valueOf(paidUsers * 100.0 / detailVo.getLivePeak()).setScale(2, RoundingMode.HALF_UP));
+            detailVo.setPeakRValue(gmv.divide(BigDecimal.valueOf(detailVo.getLivePeak()), 2, RoundingMode.HALF_UP));
+        }
+
+        if (totalViewers > 0) {
+            detailVo.setTotalViewerConversionRate(BigDecimal.valueOf(paidUsers * 100.0 / totalViewers).setScale(2, RoundingMode.HALF_UP));
+        }
+
+        if (liveOver30Minutes > 0) {
+            detailVo.setCompletion30MinConversionRate(BigDecimal.valueOf(paidUsers * 100.0 / liveOver30Minutes).setScale(2, RoundingMode.HALF_UP));
+        }
+
+        if (totalCompletedCourses > 0) {
+            detailVo.setCompletionRValue(gmv.divide(BigDecimal.valueOf(totalCompletedCourses), 2, RoundingMode.HALF_UP));
+        }
+
+        return detailVo;
+    }
+
+    /**
+     * 通过查询数据服务器处理方式计算用户详情列表
+     */
+    private List<LiveUserDetailVo> calculateLiveUserDetailListByServer(Long liveId) {
+        // 查询观看用户
+        List<LiveWatchUser> watchUsers = liveWatchUserMapper.selectLiveWatchUserListByLiveId(liveId);
+
+        LiveOrder orderQuery = new LiveOrder();
+        orderQuery.setLiveId(liveId);
+        List<LiveOrder> orders = liveOrderMapper.selectLiveOrderList(orderQuery);
+
+
+        // 按用户ID分组统计
+        Map<Long, LiveUserDetailVo> userDetailMap = new HashMap<>();
+
+        for (LiveWatchUser watchUser : watchUsers) {
+            Long userId = watchUser.getUserId();
+            if (userId == null) continue;
+
+            LiveUserDetailVo userDetail = userDetailMap.computeIfAbsent(userId, k -> {
+                LiveUserDetailVo vo = new LiveUserDetailVo();
+                vo.setUserId(userId);
+                // 查询用户信息
+                FsUser user = fsUserMapper.selectFsUserByUserId(userId);
+                if (user != null) {
+                    vo.setUserName(user.getNickname() != null ? user.getNickname() : (user.getNickname() != null ? user.getNickname() : "未知用户"));
+                } else {
+                    vo.setUserName("未知用户");
+                }
+                return vo;
+            });
+
+            // 累加观看时长
+            if (watchUser.getOnlineSeconds() != null) {
+                if (watchUser.getLiveFlag() != null && watchUser.getLiveFlag() == 1 && (watchUser.getReplayFlag() == null || watchUser.getReplayFlag() == 0)) {
+                    userDetail.setLiveWatchDuration(userDetail.getLiveWatchDuration() + watchUser.getOnlineSeconds());
+                } else if (watchUser.getReplayFlag() != null && watchUser.getReplayFlag() == 1 && (watchUser.getLiveFlag() == null || watchUser.getLiveFlag() == 0)) {
+                    userDetail.setPlaybackWatchDuration(userDetail.getPlaybackWatchDuration() + watchUser.getOnlineSeconds());
+                }
+            }
+        }
+
+        // 统计订单数据
+        Map<Long, List<LiveOrder>> userOrdersMap = orders.stream()
+                .filter(o -> o.getUserId() != null && !o.getUserId().isEmpty())
+                .filter(o -> {
+                    try {
+                        Long.parseLong(o.getUserId());
+                        return true;
+                    } catch (NumberFormatException e) {
+                        return false;
+                    }
+                })
+                .collect(Collectors.groupingBy(o -> {
+                    try {
+                        return Long.parseLong(o.getUserId());
+                    } catch (NumberFormatException e) {
+                        return 0L;
+                    }
+                }));
+
+        for (Map.Entry<Long, List<LiveOrder>> entry : userOrdersMap.entrySet()) {
+            Long userId = entry.getKey();
+            List<LiveOrder> userOrders = entry.getValue();
+
+            LiveUserDetailVo userDetail = userDetailMap.computeIfAbsent(userId, k -> {
+                LiveUserDetailVo vo = new LiveUserDetailVo();
+                vo.setUserId(userId);
+                FsUser user = fsUserMapper.selectFsUserByUserId(userId);
+                if (user != null) {
+                    vo.setUserName(user.getNickname() != null ? user.getNickname() : (user.getNickname() != null ? user.getNickname() : "未知用户"));
+                } else {
+                    vo.setUserName("未知用户");
+                }
+                return vo;
+            });
+
+            userDetail.setOrderCount((long) userOrders.size());
+            BigDecimal orderAmount = userOrders.stream()
+                    .filter(o -> "1".equals(o.getIsPay()))
+                    .map(LiveOrder::getPayPrice)
+                    .filter(Objects::nonNull)
+                    .reduce(BigDecimal.ZERO, BigDecimal::add);
+            userDetail.setOrderAmount(orderAmount);
+        }
+
+        // 转换为列表并排序
+        List<LiveUserDetailVo> result = new ArrayList<>(userDetailMap.values());
+        result.sort((a, b) -> {
+            int compare = b.getOrderAmount().compareTo(a.getOrderAmount());
+            if (compare != 0) return compare;
+            return Long.compare(b.getLiveWatchDuration(), a.getLiveWatchDuration());
+        });
+
+        return result;
+    }
+
+    /**
+     * 查询单品销量统计
+     */
+    private List<ProductSalesVo> getProductSalesList(Long liveId) {
+
+        List<LiveOrder> orders = liveOrderMapper.selectOrderByLiveId(liveId);
+
+        // 按商品ID分组统计
+        Map<Long, ProductSalesVo> productSalesMap = new HashMap<>();
+
+        for (LiveOrder order : orders) {
+            if (!"1".equals(order.getIsPay()) || order.getProductId() == null) {
+                continue;
+            }
+
+            ProductSalesVo productSales = productSalesMap.computeIfAbsent(order.getProductId(), k -> {
+                ProductSalesVo vo = new ProductSalesVo();
+                vo.setProductId(order.getProductId());
+                // 查询商品名称
+                FsStoreProductMapper productMapper = SpringUtils.getBean(FsStoreProductMapper.class);
+                FsStoreProduct product = productMapper.selectFsStoreProductById(order.getProductId());
+                if (product != null) {
+                    vo.setProductName(product.getProductName());
+                } else {
+                    vo.setProductName("未知商品");
+                }
+                return vo;
+            });
+
+            productSales.setSalesCount(productSales.getSalesCount() + 1);
+            if (order.getPayPrice() != null) {
+                productSales.setSalesAmount(productSales.getSalesAmount().add(order.getPayPrice()));
+            }
+        }
+
+        List<ProductSalesVo> result = new ArrayList<>(productSalesMap.values());
+        result.sort((a, b) -> b.getSalesAmount().compareTo(a.getSalesAmount()));
+
+        return result;
+    }
+    /**
+     * 导出直播间用户详情数据
+     * @param liveId 直播间ID
+     * @return 导出VO列表
+     */
+    @Override
+    public List<LiveUserDetailExportVO> exportLiveUserDetail(Long liveId) {
+        // 查询用户详情列表
+        List<LiveUserDetailVo> userDetailList = liveDataMapper.selectLiveUserDetailListBySql(liveId);
+        if (userDetailList == null || userDetailList.isEmpty()) {
+            return new ArrayList<>();
+        }
+
+        // 转换为导出VO列表
+        List<LiveUserDetailExportVO> exportList = new ArrayList<>();
+        for (LiveUserDetailVo userDetail : userDetailList) {
+            LiveUserDetailExportVO exportVO = new LiveUserDetailExportVO();
+
+            // 用户基本信息
+            exportVO.setUserId(userDetail.getUserId());
+            exportVO.setUserName(userDetail.getUserName());
+
+            // 观看时长(秒转分钟)
+            exportVO.setLiveWatchDuration(formatSecondsToMinutes(userDetail.getLiveWatchDuration()));
+            exportVO.setPlaybackWatchDuration(formatSecondsToMinutes(userDetail.getPlaybackWatchDuration()));
+
+            // 计算总观看时长
+            Long totalSeconds = (userDetail.getLiveWatchDuration() != null ? userDetail.getLiveWatchDuration() : 0L) +
+                    (userDetail.getPlaybackWatchDuration() != null ? userDetail.getPlaybackWatchDuration() : 0L);
+//            exportVO.setTotalWatchDuration(formatSecondsToMinutes(totalSeconds));
+
+            // 订单信息
+            exportVO.setOrderCount(Math.toIntExact(userDetail.getOrderCount()));
+            exportVO.setOrderAmount(formatMoney(userDetail.getOrderAmount()));
+
+            // 公司和销售信息
+            exportVO.setCompanyName(userDetail.getCompanyName());
+            exportVO.setSalesName(userDetail.getSalesName());
+
+            // 是否完课(根据观看时长判断,假设30分钟以上为完课)
+            if (totalSeconds >= 1800) {
+                exportVO.setIsCompleted("是");
+            } else {
+                exportVO.setIsCompleted("否");
+            }
+
+            exportList.add(exportVO);
+        }
+
+        return exportList;
+    }
+
+    @Override
+    public R listLiveData(LiveDataParam param) {
+        // 第一步:查询当前公司的直播间数据
+        // 直播类型 只展示已结束和直播回放的数据 录播展示直播中和已结束的直播数据
+        List<Live> lives = liveMapper.listLiveData(param);
+        int total = liveMapper.listLiveDataCount(param);
+
+        if (lives == null || lives.isEmpty()) {
+            LiveDataStatisticsVo statistics = new LiveDataStatisticsVo();
+            return R.ok().put("list", Collections.emptyList()).put("data", statistics);
+        }
+
+        // 获取直播间ID列表
+        List<Long> liveIds = lives.stream()
+                .map(Live::getLiveId)
+                .collect(Collectors.toList());
+
+        // 查询统计数据(根据live_watch_user表查询用户的在线时长,计算平均时长
+        // 根据live_video的文件时长,判断用户的完课情况
+        // 根据live_order查询直播间的销量额和订单数)
+        LiveDataStatisticsVo statistics = baseMapper.selectLiveDataStatistics(liveIds);
+        if (statistics == null) {
+            statistics = new LiveDataStatisticsVo();
+        }
+
+        // 查询列表数据(每个直播间的详细统计数据)
+        List<LiveDataListVo> liveDataList = baseMapper.selectLiveDataListByLiveIds(liveIds);
+        if (liveDataList == null) {
+            liveDataList = Collections.emptyList();
+        }
+
+        return R.ok().put("list", liveDataList).put("data", statistics).put("total", total);
+    }
+
+    /**
+     * 格式化秒数为分钟(保留2位小数)
+     */
+    private String formatSecondsToMinutes(Long seconds) {
+        if (seconds == null || seconds == 0) {
+            return "0.00";
+        }
+        BigDecimal minutes = new BigDecimal(seconds).divide(new BigDecimal(60), 2, RoundingMode.HALF_UP);
+        return minutes.toString();
+    }
+
+    /**
+     * 格式化金额
+     */
+    private String formatMoney(BigDecimal value) {
+        if (value == null) {
+            return "0.00";
+        }
+        return value.setScale(2, RoundingMode.HALF_UP).toString();
+    }
+
+
 }

+ 38 - 3
fs-service-system/src/main/java/com/fs/live/service/impl/LiveGoodsServiceImpl.java

@@ -20,6 +20,7 @@ import cn.hutool.json.JSONUtil;
 import cn.hutool.json.JSONObject;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
 
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -116,7 +117,11 @@ public class LiveGoodsServiceImpl  implements ILiveGoodsService {
     {
         // 删除前检查live_auto_task,删除task_type=1和6的定时任务
         for (Long goodsId : goodsIds) {
-            deleteRelatedAutoTasksForGoods(goodsId);
+            LiveGoods goods = baseMapper.selectLiveGoodsByGoodsId(goodsId);
+            if (goods != null && goods.getLiveId() != null) {
+                liveAutoTaskService
+                        .deleteAutoTasksByGoodsId(goodsId, goods.getLiveId());
+            }
         }
         return baseMapper.deleteLiveGoodsByGoodsIds(goodsIds);
     }
@@ -128,7 +133,7 @@ public class LiveGoodsServiceImpl  implements ILiveGoodsService {
         LiveAutoTask queryTask = new LiveAutoTask();
         List<LiveAutoTask> tasks = liveAutoTaskService.selectLiveAutoTaskList(queryTask);
         List<Long> taskIdsToDelete = new ArrayList<>();
-        
+
         for (LiveAutoTask task : tasks) {
             if (task.getTaskType() != null && (task.getTaskType() == 1L || task.getTaskType() == 6L)) {
                 if (task.getContent() != null && !task.getContent().isEmpty()) {
@@ -144,7 +149,7 @@ public class LiveGoodsServiceImpl  implements ILiveGoodsService {
                 }
             }
         }
-        
+
         if (!taskIdsToDelete.isEmpty()) {
             liveAutoTaskService.deleteLiveAutoTaskByIds(taskIdsToDelete.toArray(new Long[0]));
         }
@@ -157,8 +162,16 @@ public class LiveGoodsServiceImpl  implements ILiveGoodsService {
      * @return 结果
      */
     @Override
+    @Transactional
     public int deleteLiveGoodsByGoodsId(Long goodsId)
     {
+        // 先查询商品信息,获取liveId
+        LiveGoods goods = baseMapper.selectLiveGoodsByGoodsId(goodsId);
+        if (goods != null && goods.getLiveId() != null) {
+            // 删除相关的自动化任务
+            liveAutoTaskService
+                    .deleteAutoTasksByGoodsId(goodsId, goods.getLiveId());
+        }
         return baseMapper.deleteLiveGoodsByGoodsId(goodsId);
     }
 
@@ -315,6 +328,16 @@ public class LiveGoodsServiceImpl  implements ILiveGoodsService {
 
     @Override
     public R handleDeleteSelected(LiveGoodsListVo listVo) {
+        // 联动删除:删除相关的自动化任务
+        if (listVo.getGoodsIds() != null && !listVo.getGoodsIds().isEmpty()) {
+            Long liveId = listVo.getLiveId() != null ? Long.valueOf(listVo.getLiveId()) : null;
+            for (Long goodsId : listVo.getGoodsIds()) {
+                if (liveId != null) {
+                    liveAutoTaskService
+                            .deleteAutoTasksByGoodsId(goodsId, liveId);
+                }
+            }
+        }
         int deleteCount = baseMapper.deleteBatchList(listVo);
         if (deleteCount > 0) {
             return R.ok("操作成功");
@@ -344,7 +367,19 @@ public class LiveGoodsServiceImpl  implements ILiveGoodsService {
     }
 
     @Override
+    @Transactional
     public R handleDeleteSelectedAdmin(LiveGoodsListVo listVo) {
+        // 联动删除:删除相关的自动化任务
+        if (listVo.getGoodsIds() != null && !listVo.getGoodsIds().isEmpty()) {
+            // 需要查询每个商品的liveId
+            for (Long goodsId : listVo.getGoodsIds()) {
+                LiveGoods goods = baseMapper.selectLiveGoodsByGoodsId(goodsId);
+                if (goods != null && goods.getLiveId() != null) {
+                    liveAutoTaskService
+                            .deleteAutoTasksByGoodsId(goodsId, goods.getLiveId());
+                }
+            }
+        }
         baseMapper.handleDeleteSelectedAdmin(listVo);
         return R.ok();
     }

+ 30 - 1
fs-service-system/src/main/java/com/fs/live/service/impl/LiveLotteryConfServiceImpl.java

@@ -125,11 +125,20 @@ public class LiveLotteryConfServiceImpl implements ILiveLotteryConfService {
      * @return 结果
      */
     @Override
+    @Transactional
     public int deleteLiveLotteryConfByLotteryIds(Long[] lotteryIds)
     {
         // 删除前检查live_auto_task,删除task_type=4的定时任务
         for (Long lotteryId : lotteryIds) {
-            deleteRelatedAutoTasksForLottery(lotteryId);
+            LiveLotteryConf conf = baseMapper.selectLiveLotteryConfByLotteryId(lotteryId);
+            if (conf != null && conf.getLiveId() != null) {
+                // 删除相关的自动化任务
+                liveAutoTaskService
+                        .deleteAutoTasksByLotteryId(lotteryId, conf.getLiveId());
+                // 删除抽奖缓存
+                liveAutoTaskService
+                        .deleteLotteryCache(lotteryId, conf.getLiveId());
+            }
         }
         return baseMapper.deleteLiveLotteryConfByLotteryIds(lotteryIds);
     }
@@ -173,6 +182,16 @@ public class LiveLotteryConfServiceImpl implements ILiveLotteryConfService {
     @Transactional
     public int deleteLiveLotteryConfByLotteryId(Long lotteryId)
     {
+        // 先查询抽奖配置,获取liveId
+        LiveLotteryConf conf = baseMapper.selectLiveLotteryConfByLotteryId(lotteryId);
+        if (conf != null && conf.getLiveId() != null) {
+            // 删除相关的自动化任务
+            liveAutoTaskService
+                    .deleteAutoTasksByLotteryId(lotteryId, conf.getLiveId());
+            // 删除抽奖缓存
+            liveAutoTaskService
+                    .deleteLotteryCache(lotteryId, conf.getLiveId());
+        }
         return baseMapper.deleteLiveLotteryConfByLotteryId(lotteryId);
     }
 
@@ -203,6 +222,16 @@ public class LiveLotteryConfServiceImpl implements ILiveLotteryConfService {
     @Override
     @Transactional
     public void delete(Long lotteryId) {
+        // 先查询抽奖配置,获取liveId
+        LiveLotteryConf conf = mapper.selectById(lotteryId);
+        if (conf != null && conf.getLiveId() != null) {
+            // 删除相关的自动化任务
+            liveAutoTaskService
+                    .deleteAutoTasksByLotteryId(lotteryId, conf.getLiveId());
+            // 删除抽奖缓存
+            liveAutoTaskService
+                    .deleteLotteryCache(lotteryId, conf.getLiveId());
+        }
         mapper.deleteById(lotteryId);
     }
 

+ 30 - 3
fs-service-system/src/main/java/com/fs/live/service/impl/LiveRedConfServiceImpl.java

@@ -148,7 +148,14 @@ public class LiveRedConfServiceImpl implements ILiveRedConfService {
     {
         // 删除前检查live_auto_task,删除task_type=2的定时任务
         for (Long redId : redIds) {
-            deleteRelatedAutoTasksForRed(redId);
+            LiveRedConf conf = baseMapper.selectLiveRedConfByRedId(redId);
+            if (conf != null && conf.getLiveId() != null) {
+                // 删除相关的自动化任务
+                liveAutoTaskService.deleteAutoTasksByRedId(redId, conf.getLiveId());
+                // 删除红包缓存
+                liveAutoTaskService
+                        .deleteRedCache(redId, conf.getLiveId());
+            }
         }
         return baseMapper.deleteLiveRedConfByRedIds(redIds);
     }
@@ -160,7 +167,7 @@ public class LiveRedConfServiceImpl implements ILiveRedConfService {
         LiveAutoTask queryTask = new LiveAutoTask();
         List<LiveAutoTask> tasks = liveAutoTaskService.selectLiveAutoTaskList(queryTask);
         List<Long> taskIdsToDelete = new ArrayList<>();
-        
+
         for (LiveAutoTask task : tasks) {
             if (task.getTaskType() != null && task.getTaskType() == 2L) {
                 if (task.getContent() != null && !task.getContent().isEmpty()) {
@@ -176,7 +183,7 @@ public class LiveRedConfServiceImpl implements ILiveRedConfService {
                 }
             }
         }
-        
+
         if (!taskIdsToDelete.isEmpty()) {
             liveAutoTaskService.deleteLiveAutoTaskByIds(taskIdsToDelete.toArray(new Long[0]));
         }
@@ -191,6 +198,16 @@ public class LiveRedConfServiceImpl implements ILiveRedConfService {
     @Override
     public int deleteLiveRedConfByRedId(Long redId)
     {
+        // 先查询红包配置,获取liveId
+        LiveRedConf conf = baseMapper.selectLiveRedConfByRedId(redId);
+        if (conf != null && conf.getLiveId() != null) {
+            // 删除相关的自动化任务
+            liveAutoTaskService
+                    .deleteAutoTasksByRedId(redId, conf.getLiveId());
+            // 删除红包缓存
+            liveAutoTaskService
+                    .deleteRedCache(redId, conf.getLiveId());
+        }
         return baseMapper.deleteLiveRedConfByRedId(redId);
     }
 
@@ -221,6 +238,16 @@ public class LiveRedConfServiceImpl implements ILiveRedConfService {
     @Override
     @Transactional
     public void delete(Long redId) {
+        // 先查询红包配置,获取liveId
+        LiveRedConf conf = baseMapper.selectById(redId);
+        if (conf != null && conf.getLiveId() != null) {
+            // 删除相关的自动化任务
+            liveAutoTaskService
+                    .deleteAutoTasksByRedId(redId, conf.getLiveId());
+            // 删除红包缓存
+            liveAutoTaskService
+                    .deleteRedCache(redId, conf.getLiveId());
+        }
         baseMapper.deleteById(redId);
     }
 

+ 122 - 8
fs-service-system/src/main/java/com/fs/live/service/impl/LiveServiceImpl.java

@@ -117,7 +117,19 @@ public class LiveServiceImpl implements ILiveService
      */
     @Override
     public Live selectLiveByLiveId(Long liveId){
+        // 先从缓存中获取
+        String cacheKey = String.format(LiveKeysConstant.LIVE_DATA_CACHE, liveId);
+        Live cachedLive = redisCache.getCacheObject(cacheKey);
+        if (cachedLive != null) {
+            return cachedLive;
+        }
+
+        // 缓存中没有,从数据库查询
         Live byId = baseMapper.selectLiveByLiveId(liveId);
+        if (byId == null) {
+            return null;
+        }
+
         List<LiveVideo> videos = liveVideoService.listByLiveId(liveId, 1);
         if(!videos.isEmpty()){
             LiveVideo liveVideo = videos.get(0);
@@ -125,7 +137,13 @@ public class LiveServiceImpl implements ILiveService
             byId.setDuration(liveVideo.getDuration());
             byId.setVideoId(liveVideo.getVideoId());
             byId.setVideoType(liveVideo.getVideoType());
+            byId.setVideoFileSize(liveVideo.getFileSize());
+            byId.setVideoDuration(liveVideo.getDuration());
         }
+
+        // 将结果存入缓存
+        redisCache.setCacheObject(cacheKey, byId, LiveKeysConstant.LIVE_DATA_CACHE_EXPIRE, TimeUnit.SECONDS);
+
         return byId;
     }
 
@@ -163,7 +181,7 @@ public class LiveServiceImpl implements ILiveService
     public List<Live> asyncToCache() {
         // 同步直播间数据到缓存
         List<Live> list = liveList();
-        log.info("开始同步直播间数据到缓存,共{}条数据", list.size());
+
         ThreadUtil.execute(()->{
             // 清空原有的 ZSet 数据
             redisUtil.delete(LiveKeysConstant.LIVE_HOME_PAGE_LIST);
@@ -171,7 +189,7 @@ public class LiveServiceImpl implements ILiveService
                 redisUtil.zSetAdd(LiveKeysConstant.LIVE_HOME_PAGE_LIST, JSON.toJSONString(live), live.getCreateTime().getTime());
                 redisUtil.expire(LiveKeysConstant.LIVE_HOME_PAGE_LIST, LiveKeysConstant.LIVE_HOME_PAGE_LIST_EXPIRE, TimeUnit.SECONDS);
             }
-            log.info("直播间数据同步到缓存完成");
+
         });
         return list;
     }
@@ -199,10 +217,10 @@ public class LiveServiceImpl implements ILiveService
 			liveVo.setNowDuration(seconds);
 		}
         ThreadUtil.execute(()->{
-            log.info("同步直播间详情数据到缓存{}", id);
+
             redisUtil.delete(String.format(LiveKeysConstant.LIVE_HOME_PAGE_DETAIL, live.getLiveId()));
             redisUtil.set(String.format(LiveKeysConstant.LIVE_HOME_PAGE_DETAIL, live.getLiveId()), liveVo,LiveKeysConstant.LIVE_HOME_PAGE_DETAIL_EXPIRE, TimeUnit.SECONDS);
-            log.info("直播间数据同步到缓存完成");
+
         });
 
         return liveVo;
@@ -212,10 +230,10 @@ public class LiveServiceImpl implements ILiveService
     public LiveConfigVo asyncToCacheLiveConfig(Long liveId) {
         LiveConfigVo liveConfigVo = currentActivities(liveId);
         ThreadUtil.execute(()->{
-            log.info("同步配置信息到缓存{}", liveConfigVo);
+
             redisUtil.delete(String.format(LiveKeysConstant.LIVE_HOME_PAGE_CONFIG, liveId,liveId));
             redisUtil.set(String.format(LiveKeysConstant.LIVE_HOME_PAGE_DETAIL, liveId), liveConfigVo,LiveKeysConstant.LIVE_HOME_PAGE_CONFIG_EXPIRE, TimeUnit.SECONDS);
-            log.info("直播间数据同步到缓存完成");
+
         });
         return liveConfigVo;
     }
@@ -356,7 +374,39 @@ public class LiveServiceImpl implements ILiveService
     @Override
     @Transactional
     public int updateLiveEntity(Live live) {
-        return baseMapper.updateLive( live);
+        int result = baseMapper.updateLive( live);
+        // 清除缓存
+        clearLiveCache(live.getLiveId());
+        return result;
+    }
+
+    @Override
+    public void updateGlobalVisible(long liveId, Integer status) {
+        baseMapper.updateGlobalVisible(liveId, status);
+        // 清除缓存
+        clearLiveCache(liveId);
+    }
+
+    @Override
+    public Live selectLiveDbByLiveId(Long liveId) {
+        // 缓存中没有,从数据库查询
+        Live byId = baseMapper.selectLiveByLiveId(liveId);
+        if (byId == null) {
+            return null;
+        }
+
+        List<LiveVideo> videos = liveVideoService.listByLiveId(liveId, 1);
+        if(!videos.isEmpty()){
+            LiveVideo liveVideo = videos.get(0);
+            byId.setVideoUrl(liveVideo.getVideoUrl());
+            byId.setDuration(liveVideo.getDuration());
+            byId.setVideoId(liveVideo.getVideoId());
+            byId.setVideoType(liveVideo.getVideoType());
+            byId.setVideoFileSize(liveVideo.getFileSize());
+            byId.setVideoDuration(liveVideo.getDuration());
+        }
+
+        return byId;
     }
 
 
@@ -399,6 +449,10 @@ public class LiveServiceImpl implements ILiveService
         int result = baseMapper.updateLive(live);
         liveAutoTaskService.recalcLiveAutoTask(live);
 
+
+        // 清除缓存
+        clearLiveCache(live.getLiveId());
+
         return result;
     }
 
@@ -410,7 +464,10 @@ public class LiveServiceImpl implements ILiveService
      */
     @Override
     public int deleteLiveByLiveIds(Long[] liveIds){
-        return baseMapper.deleteLiveByLiveIds(liveIds);
+        int result = baseMapper.deleteLiveByLiveIds(liveIds);
+        // 清除缓存
+        clearLiveCache(liveIds);
+        return result;
     }
 
     /**
@@ -467,6 +524,8 @@ public class LiveServiceImpl implements ILiveService
         live.setStatus(2);
         live.setLiveType(1);
         this.updateLive(live);
+        // 清除缓存
+        clearLiveCache(liveId);
         LiveData liveData = new LiveData();
         liveData.setLiveId(liveId);
         liveData.setPageViews(0L);
@@ -532,6 +591,8 @@ public class LiveServiceImpl implements ILiveService
         live.setFinishTime(LocalDateTime.now());
         live.setLiveType(2);
         baseMapper.updateLive(live);
+        // 清除缓存
+        clearLiveCache(live.getLiveId());
         return R.ok();
     }
     /**
@@ -568,6 +629,8 @@ public class LiveServiceImpl implements ILiveService
 
 
             baseMapper.updateLive(curLive);
+            // 清除缓存
+            clearLiveCache(live.getLiveId());
 
             return R.ok();
         } catch (Exception e) {
@@ -595,6 +658,8 @@ public class LiveServiceImpl implements ILiveService
             curLive.setStatus(2);
             curLive.setUpdateTime(new Date());
             baseMapper.updateLive(curLive);
+            // 清除缓存
+            clearLiveCache(live.getLiveId());
 
             return R.ok();
         } catch (Exception e) {
@@ -608,6 +673,8 @@ public class LiveServiceImpl implements ILiveService
         if (updatedCount > 0) {
             return R.ok("操作成功");
         }
+        // 清除缓存
+        clearLiveCache(listVo.getLiveIds());
         return R.error();
     }
 
@@ -617,6 +684,8 @@ public class LiveServiceImpl implements ILiveService
         if (deleteCount > 0) {
             return R.ok("操作成功");
         }
+        // 清除缓存
+        clearLiveCache(listVo.getLiveIds());
         return R.error();
     }
 
@@ -635,6 +704,8 @@ public class LiveServiceImpl implements ILiveService
         exist.setUpdateTime(new Date());
 
         baseMapper.updateLive(exist);
+        // 清除缓存
+        clearLiveCache(live.getLiveId());
 
         return R.ok();
     }
@@ -666,6 +737,8 @@ public class LiveServiceImpl implements ILiveService
         exist.setFinishTime( null);
         exist.setStartTime(LocalDateTime.now());
         baseMapper.updateLive(exist);
+        // 清除缓存
+        clearLiveCache(live.getLiveId());
         List<LiveAutoTask> liveAutoTasks = liveAutoTaskService.selectNoActivedByLiveId(exist.getLiveId(), new Date());
         liveAutoTasks.forEach(liveAutoTask -> {
             liveAutoTask.setCreateTime(null);
@@ -680,12 +753,16 @@ public class LiveServiceImpl implements ILiveService
     @Override
     public R handleShelfOrUnAdmin(LiveListVo listVo) {
         baseMapper.handleShelfOrUnAdmin(listVo);
+        // 清除缓存
+        clearLiveCache(listVo.getLiveIds());
         return R.ok();
     }
 
     @Override
     public R handleDeleteSelectedAdmin(LiveListVo listVo) {
         baseMapper.handleDeleteSelectedAdmin(listVo);
+        // 清除缓存
+        clearLiveCache(listVo.getLiveIds());
         return R.ok();
     }
 
@@ -1081,6 +1158,8 @@ public class LiveServiceImpl implements ILiveService
             if (200 == jsonObject.getIntValue("code") && jsonObject.getBooleanValue("success")) {
                 live.setIdCardUrl(payload.get("idCardUrl"));
                 baseMapper.updateLive(live);
+                // 清除缓存
+                clearLiveCache(live.getLiveId());
                 return R.ok();
             }
         } catch (Exception e) {
@@ -1161,4 +1240,39 @@ public class LiveServiceImpl implements ILiveService
         String hexString = Long.toHexString(aLong);
         return hexString.toUpperCase();
     }
+    /**
+     * 清除直播间数据缓存
+     * @param liveId 直播间ID
+     */
+    private void clearLiveCache(Long liveId) {
+        if (liveId != null) {
+            String cacheKey = String.format(LiveKeysConstant.LIVE_DATA_CACHE, liveId);
+            redisCache.deleteObject(cacheKey);
+            log.debug("清除直播间缓存: liveId={}", liveId);
+        }
+    }
+
+    /**
+     * 批量清除直播间数据缓存
+     * @param liveIds 直播间ID数组
+     */
+    private void clearLiveCache(Long[] liveIds) {
+        if (liveIds != null && liveIds.length > 0) {
+            for (Long liveId : liveIds) {
+                clearLiveCache(liveId);
+            }
+        }
+    }
+
+    /**
+     * 批量清除直播间数据缓存
+     * @param liveIds 直播间ID列表
+     */
+    private void clearLiveCache(List<Long> liveIds) {
+        if (liveIds != null && !liveIds.isEmpty()) {
+            for (Long liveId : liveIds) {
+                clearLiveCache(liveId);
+            }
+        }
+    }
 }

+ 108 - 23
fs-service-system/src/main/java/com/fs/live/service/impl/LiveWatchUserServiceImpl.java

@@ -10,8 +10,10 @@ import com.fs.common.core.domain.R;
 import com.fs.common.core.redis.RedisCache;
 import com.fs.common.core.redis.RedisUtil;
 import com.fs.common.utils.DateUtils;
+import com.fs.common.utils.StringUtils;
 import com.fs.live.domain.Live;
 import com.fs.live.domain.LiveWatchUser;
+import com.fs.live.mapper.LiveMapper;
 import com.fs.live.mapper.LiveWatchUserMapper;
 import com.fs.live.service.ILiveWatchUserService;
 import com.fs.live.vo.LiveWatchUserStatistics;
@@ -47,6 +49,8 @@ public class LiveWatchUserServiceImpl implements ILiveWatchUserService {
 
     @Autowired
     private RedisUtil redisUtil;
+    @Autowired
+    private LiveMapper liveMapper;
     /**
      * 查询直播间观看用户
      *
@@ -142,43 +146,117 @@ public class LiveWatchUserServiceImpl implements ILiveWatchUserService {
     }
 
     @Override
-    public LiveWatchUser getByLiveIdAndUserId(long liveId, long userId) {
-        return baseMapper.selectUserByLiveIdAndUserId(liveId, userId);
+    public List<LiveWatchUser> getByLiveIdAndUserId(long liveId, long userId) {
+        // 先查缓存,获取liveFlag和replayFlag
+        Map<String, Integer> flagMap = getLiveFlagWithCache(liveId);
+        LiveWatchUser liveWatchUser = new LiveWatchUser();
+        liveWatchUser.setLiveId(liveId);
+        liveWatchUser.setUserId(userId);
+        liveWatchUser.setLiveFlag(flagMap.get("liveFlag"));
+        liveWatchUser.setReplayFlag(flagMap.get("replayFlag"));
+        return baseMapper.selectLiveWatchUserList(liveWatchUser);
+    }
+
+    /**
+     * 获取直播间的直播/回放状态(带缓存)
+     * @param liveId 直播间ID
+     * @return Map包含liveFlag和replayFlag
+     */
+    @Override
+    public Map<String, Integer> getLiveFlagWithCache(Long liveId) {
+        String cacheKey = String.format(LiveKeysConstant.LIVE_FLAG_CACHE, liveId);
+
+        // 先查缓存
+        Map<String, Integer> cached = redisCache.getCacheObject(cacheKey);
+        if (cached != null && cached.containsKey("liveFlag") && cached.containsKey("replayFlag")) {
+            return cached;
+        }
+
+        // 缓存不存在,查询数据库
+        Integer liveFlag = liveMapper.selectLiveFlagByLiveId(liveId);
+        if (liveFlag == null) {
+            liveFlag = 0;
+        }
+        Integer replayFlag = 1 - liveFlag; // 反数
+
+        // 构建结果
+        Map<String, Integer> result = new HashMap<>();
+        result.put("liveFlag", liveFlag);
+        result.put("replayFlag", replayFlag);
+
+        // 缓存结果,过期时间1分钟
+        redisCache.setCacheObject(cacheKey, result, LiveKeysConstant.LIVE_FLAG_CACHE_EXPIRE, TimeUnit.SECONDS);
+
+        return result;
     }
 
     @Override
-    public LiveWatchUser join(long liveId, long userId) {
-        LiveWatchUser liveWatchUser = getByLiveIdAndUserId(liveId, userId);
-        FsUserVO fsUserVO = fsUserService.selectFsUserByUserId(userId);
-        if(liveWatchUser != null) {
-            liveWatchUser.setUpdateTime(DateUtils.getNowDate());
+    public LiveWatchUser join(FsUser fsUser,long liveId, long userId, String location) {
+        // 查询直播间信息
+        Live live = liveMapper.selectLiveByLiveId(liveId);
+        if (live == null) {
+            throw new RuntimeException("直播间不存在");
+        }
+        Date now = DateUtils.getNowDate();
+
+        // 获取直播/回放状态(带缓存)
+        Map<String, Integer> flagMap = getLiveFlagWithCache(liveId);
+        Integer liveFlag = flagMap.get("liveFlag");
+        Integer replayFlag = flagMap.get("replayFlag");
+
+        // 使用唯一索引查询:live_id, user_id, live_flag, replay_flag
+        LiveWatchUser liveWatchUser = baseMapper.selectByUniqueIndex(liveId, userId, liveFlag, replayFlag);
+
+        if (liveWatchUser != null) {
+            // 存在则更新
+            liveWatchUser.setUpdateTime(now);
             liveWatchUser.setOnline(0);
+            if (StringUtils.isNotEmpty(location)) {
+                liveWatchUser.setLocation(location);
+            }
             baseMapper.updateLiveWatchUser(liveWatchUser);
-        }else{
+        } else {
+            // 不存在则插入
             liveWatchUser = new LiveWatchUser();
             liveWatchUser.setLiveId(liveId);
             liveWatchUser.setUserId(userId);
-            liveWatchUser.setAvatar(fsUserVO.getAvatar());
+            liveWatchUser.setAvatar(fsUser.getAvatar());
             liveWatchUser.setMsgStatus(0);
             liveWatchUser.setOnline(0);
-            liveWatchUser.setCreateTime(DateUtils.getNowDate());
-            liveWatchUser.setUpdateTime(DateUtils.getNowDate());
+            liveWatchUser.setLocation(location);
+            liveWatchUser.setLiveFlag(liveFlag);
+            liveWatchUser.setReplayFlag(replayFlag);
+            liveWatchUser.setCreateTime(now);
+            liveWatchUser.setUpdateTime(now);
             baseMapper.insertLiveWatchUser(liveWatchUser);
         }
-        liveWatchUser.setAvatar(fsUserVO.getAvatar());
-        liveWatchUser.setNickName(fsUserVO.getNickname());
-        String hashKey  = String.format(LiveKeysConstant.LIVE_WATCH_USERS, liveId);
-        redisUtil.hashPut(hashKey, String.valueOf(userId), JSON.toJSONString(liveWatchUser));
+
+        liveWatchUser.setAvatar(fsUser.getAvatar());
+        liveWatchUser.setNickName(fsUser.getNickname());
+        String hashKey = String.format(LiveKeysConstant.LIVE_WATCH_USERS, liveId);
+        redisCache.hashPut(hashKey, String.valueOf(userId), JSON.toJSONString(liveWatchUser));
         return liveWatchUser;
     }
     @Override
     public LiveWatchUser close(long liveId, long userId) {
-        LiveWatchUser liveWatchUser = getByLiveIdAndUserId(liveId, userId);
+        // 查询直播间信息
+        Live live = liveMapper.selectLiveByLiveId(liveId);
+        if (live == null) {
+            throw new RuntimeException("直播间不存在");
+        }
+
+        // 获取直播/回放状态(带缓存)
+        Map<String, Integer> flagMap = getLiveFlagWithCache(liveId);
+        Integer liveFlag = flagMap.get("liveFlag");
+        Integer replayFlag = flagMap.get("replayFlag");
+
+        // 使用唯一索引查询:live_id, user_id, live_flag, replay_flag
+        LiveWatchUser liveWatchUser = baseMapper.selectByUniqueIndex(liveId, userId, liveFlag, replayFlag);
         // 设置在线时长
         try {
             Long onlineSeconds = liveWatchUser.getOnlineSeconds();
             if(onlineSeconds == null) onlineSeconds = 0L;
-            liveWatchUser.setOnlineSeconds(onlineSeconds + (System.currentTimeMillis() - liveWatchUser.getUpdateTime().getTime()));
+            liveWatchUser.setOnlineSeconds(onlineSeconds + (System.currentTimeMillis() - liveWatchUser.getUpdateTime().getTime()) / 1000);
         } catch (Exception e) {
             log.error("设置在线时长异常:{}", e.getMessage());
         }
@@ -208,11 +286,14 @@ public class LiveWatchUserServiceImpl implements ILiveWatchUserService {
      */
     @Override
     public int changeUserState(Long liveId, Long userId) {
-        LiveWatchUser liveWatchUser = getByLiveIdAndUserId(liveId, userId);
+        List<LiveWatchUser> liveWatchUser = getByLiveIdAndUserId(liveId, userId);
         if (Objects.nonNull(liveWatchUser)) {
-            liveWatchUser.setMsgStatus(Math.abs(1 - liveWatchUser.getMsgStatus()));
-            liveWatchUser.setUpdateTime(DateUtils.getNowDate());
-            return baseMapper.updateLiveWatchUser(liveWatchUser);
+            for (LiveWatchUser watchUser : liveWatchUser) {
+                watchUser.setMsgStatus(Math.abs(1 - watchUser.getMsgStatus()));
+                watchUser.setUpdateTime(DateUtils.getNowDate());
+                baseMapper.updateLiveWatchUser(watchUser);
+            }
+            return 1;
         }
         return 0;
     }
@@ -230,6 +311,12 @@ public class LiveWatchUserServiceImpl implements ILiveWatchUserService {
 
     @Override
     public List<LiveWatchUserVO> selectOnlineUserList(LiveWatchUser param) {
+        // 先查缓存,获取liveFlag和replayFlag
+        if (param != null && param.getLiveId() != null) {
+            Map<String, Integer> flagMap = getLiveFlagWithCache(param.getLiveId());
+            param.setLiveFlag(flagMap.get("liveFlag"));
+            param.setReplayFlag(flagMap.get("replayFlag"));
+        }
         return baseMapper.selectOnlineUserList(param);
     }
 
@@ -257,7 +344,6 @@ public class LiveWatchUserServiceImpl implements ILiveWatchUserService {
         liveWatchUser.setLiveId(liveId);
         List<LiveWatchUserVO> liveWatchUserVOS = selectOnlineUserList(liveWatchUser);
 
-        log.info("开始同步直播在线人数到缓存,共{}条数据", liveWatchUserVOS.size());
         if (CollUtil.isNotEmpty(liveWatchUserVOS)){
             ThreadUtil.execute(()->{
                 String hashKey  = String.format(LiveKeysConstant.LIVE_WATCH_USERS, liveId);
@@ -269,7 +355,6 @@ public class LiveWatchUserServiceImpl implements ILiveWatchUserService {
                                 JSON::toJSONString
                         ));
                 redisUtil.hashPut(hashKey,collect);
-                log.info("同步直播在线人数到缓存完成");
             });
         }
         return liveWatchUserVOS;

+ 59 - 0
fs-service-system/src/main/java/com/fs/live/vo/FsMyLiveOrderListQueryVO.java

@@ -0,0 +1,59 @@
+package com.fs.live.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fs.common.annotation.Excel;
+import com.fs.live.domain.LiveOrderItem;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 订单对象 fs_store_order
+ *
+ * @author fs
+ * @date 2022-03-15
+ */
+@Data
+public class FsMyLiveOrderListQueryVO implements Serializable
+{
+    private static final long serialVersionUID = 1L;
+
+    /** 订单ID */
+    private Long id;
+    private Long orderId;
+    private Long liveId;
+
+    /** 订单号 */
+    private String orderCode;
+
+    /** 实际支付金额 */
+    @Excel(name = "实际支付金额")
+    private BigDecimal payPrice;
+
+    private Integer status;
+
+    private Integer isPackage;
+    private Integer totalNum;
+
+    private String  packageJson;
+
+    private String  itemJson;
+
+    private String deliveryId;
+
+    private Integer isAfterSales;
+    private Integer discountMoney;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date finishTime;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+
+    private List<LiveOrderItem> items;
+
+
+}

+ 98 - 0
fs-service-system/src/main/java/com/fs/live/vo/LiveDataDetailVo.java

@@ -0,0 +1,98 @@
+package com.fs.live.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 直播数据详情VO
+ *
+ * @author fs
+ * @date 2025-01-18
+ */
+@Data
+public class LiveDataDetailVo {
+    /** 视频时长(秒) */
+    private Long videoDuration = 0L;
+
+    /** 累计观看人数 */
+    private Long totalViewers = 0L;
+
+    /** 累计完课人数 */
+    private Long totalCompletedCourses = 0L;
+
+    /** 到课完课率(累计完课人数/累计观看人数) */
+    private BigDecimal totalCompletionRate = BigDecimal.ZERO;
+
+    /** 直播观看人数 */
+    private Long liveViewers = 0L;
+
+    /** >20分钟人数(直播) */
+    private Long liveOver20Minutes = 0L;
+
+    /** >30分钟人数(直播) */
+    private Long liveOver30Minutes = 0L;
+
+    /** 到课完课率直播(>20分钟人数(直播)/直播观看人数) */
+    private BigDecimal liveCompletionRate20 = BigDecimal.ZERO;
+
+    /** 到课完课率直播(>30分钟人数(直播)/直播观看人数) */
+    private BigDecimal liveCompletionRate30 = BigDecimal.ZERO;
+
+    /** 回放观看人数 */
+    private Long playbackViewers = 0L;
+
+    /** >20分钟人数(回放) */
+    private Long playbackOver20Minutes = 0L;
+
+    /** >30分钟人数(回放) */
+    private Long playbackOver30Minutes = 0L;
+
+    /** 到课完课率回放(>20分钟人数(回放)/回放观看人数) */
+    private BigDecimal playbackCompletionRate20 = BigDecimal.ZERO;
+
+    /** 到课完课率回放(>30分钟人数(回放)/回放观看人数) */
+    private BigDecimal playbackCompletionRate30 = BigDecimal.ZERO;
+
+    /** 直播峰值 */
+    private Long livePeak = 0L;
+
+    /** 直播平均时长(秒) */
+    private Long liveAvgDuration = 0L;
+
+    /** 回放平均时长(秒) */
+    private Long playbackAvgDuration = 0L;
+
+    /** 回放完播率(回放平均时长/视频时长) */
+    private BigDecimal playbackFinishRate = BigDecimal.ZERO;
+
+    /** GMV */
+    private BigDecimal gmv = BigDecimal.ZERO;
+
+    /** 付费人数 */
+    private Long paidUsers = 0L;
+
+    /** 付费单数 */
+    private Long paidOrders = 0L;
+
+    /** 峰值转化率 */
+    private BigDecimal peakConversionRate = BigDecimal.ZERO;
+
+    /** 总到课转化率 */
+    private BigDecimal totalViewerConversionRate = BigDecimal.ZERO;
+
+    /** 30min完课转化率 */
+    private BigDecimal completion30MinConversionRate = BigDecimal.ZERO;
+
+    /** 峰值R值 */
+    private BigDecimal peakRValue = BigDecimal.ZERO;
+
+    /** 完课R值 */
+    private BigDecimal completionRValue = BigDecimal.ZERO;
+
+    /** 单品销量统计 */
+    private List<ProductSalesVo> productSalesList;
+}
+
+

+ 81 - 0
fs-service-system/src/main/java/com/fs/live/vo/LiveDataListVo.java

@@ -0,0 +1,81 @@
+package com.fs.live.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+/**
+ * 直播数据列表VO
+ *
+ * @author fs
+ * @date 2025-01-18
+ */
+@Data
+public class LiveDataListVo {
+    /** 直播ID */
+    private Long liveId;
+
+    /** 直播名称 */
+    private String liveName;
+
+    /** 直播类型 1直播,2录播,3直播回放 */
+    private Integer liveType;
+
+    /** 直播状态 1未开播 2直播中 3已结束 4直播回放中 */
+    private Integer status;
+
+    /** 开始时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date startTime;
+
+    /** 结束时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date finishTime;
+
+    /** 累计观看人数 */
+    private Long totalViewers = 0L;
+
+    /** 直播观看人数 */
+    private Long liveViewers = 0L;
+
+    /** 回放观看人数 */
+    private Long playbackViewers = 0L;
+
+    /** 直播平均时长(秒) */
+    private Long liveAvgDuration = 0L;
+
+    /** 回放平均时长(秒) */
+    private Long playbackAvgDuration = 0L;
+
+    /** 累计完课人数 */
+    private Long totalCompletedCourses = 0L;
+
+    /** 直播完课人数 */
+    private Long liveCompletedCourses = 0L;
+
+    /** 回放完课人数 */
+    private Long playbackCompletedCourses = 0L;
+
+    /** GMV(总销售额) */
+    private BigDecimal gmv = BigDecimal.ZERO;
+
+    /** 付费人数 */
+    private Long paidUsers = 0L;
+
+    /** 付费单数 */
+    private Long paidOrders = 0L;
+
+    /** 销量统计 */
+    private Long salesCount = 0L;
+}
+
+
+
+
+
+
+
+
+

+ 62 - 0
fs-service-system/src/main/java/com/fs/live/vo/LiveDataStatisticsVo.java

@@ -0,0 +1,62 @@
+package com.fs.live.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * 直播数据统计VO
+ *
+ * @author fs
+ * @date 2025-01-18
+ */
+@Data
+public class LiveDataStatisticsVo {
+    /** 累计观看人数 */
+    private Long totalViewers = 0L;
+
+    /** 直播观看人数 */
+    private Long liveViewers = 0L;
+
+    /** 直播峰值 */
+    private Long livePeak = 0L;
+
+    /** 直播平均时长(秒) */
+    private Long liveAvgDuration = 0L;
+
+    /** 回放观看人数 */
+    private Long playbackViewers = 0L;
+
+    /** 回放平均时长(秒) */
+    private Long playbackAvgDuration = 0L;
+
+    /** 累计完课人数 */
+    private Long totalCompletedCourses = 0L;
+
+    /** 直播完课人数 */
+    private Long liveCompletedCourses = 0L;
+
+    /** 回放完课人数 */
+    private Long playbackCompletedCourses = 0L;
+
+    /** GMV(总销售额) */
+    private BigDecimal gmv = BigDecimal.ZERO;
+
+    /** 付费人数 */
+    private Long paidUsers = 0L;
+
+    /** 付费单数 */
+    private Long paidOrders = 0L;
+
+    /** 销量统计 */
+    private Long salesCount = 0L;
+}
+
+
+
+
+
+
+
+
+

+ 56 - 0
fs-service-system/src/main/java/com/fs/live/vo/LiveUserDetailExportVO.java

@@ -0,0 +1,56 @@
+package com.fs.live.vo;
+
+import com.fs.common.annotation.Excel;
+import lombok.Data;
+
+/**
+ * 直播间用户详情导出VO
+ *
+ * @author fs
+ * @date 2025-12-02
+ */
+@Data
+public class LiveUserDetailExportVO {
+
+    /** 用户ID */
+    @Excel(name = "用户ID")
+    private Long userId;
+
+    /** 用户名称 */
+    @Excel(name = "用户名称")
+    private String userName;
+
+    /** 直播观看时长(分钟) */
+    @Excel(name = "直播观看时长(分钟)")
+    private String liveWatchDuration;
+
+    /** 回放观看时长(分钟) */
+    @Excel(name = "回放观看时长(分钟)")
+    private String playbackWatchDuration;
+
+//    /** 总观看时长(分钟) */
+//    @Excel(name = "总观看时长(分钟)")
+//    private String totalWatchDuration;
+
+    /** 订单数 */
+    @Excel(name = "订单数")
+    private Integer orderCount;
+
+    /** 订单金额(元) */
+    @Excel(name = "订单金额(元)")
+    private String orderAmount;
+
+    /** 分公司 */
+    @Excel(name = "分公司")
+    private String companyName;
+
+    /** 销售 */
+    @Excel(name = "销售")
+    private String salesName;
+
+    /** 是否完课 */
+    @Excel(name = "是否完课")
+    private String isCompleted;
+
+}
+

+ 40 - 0
fs-service-system/src/main/java/com/fs/live/vo/LiveUserDetailVo.java

@@ -0,0 +1,40 @@
+package com.fs.live.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * 直播间用户详情VO
+ *
+ * @author fs
+ * @date 2025-01-18
+ */
+@Data
+public class LiveUserDetailVo {
+    /** 用户ID */
+    private Long userId;
+
+    /** 用户名称 */
+    private String userName;
+
+    /** 今天看了直播多长时间(秒) */
+    private Long liveWatchDuration = 0L;
+
+    /** 看了回放多长时间(秒) */
+    private Long playbackWatchDuration = 0L;
+
+    /** 下了几笔单 */
+    private Long orderCount = 0L;
+
+    /** 金额是多少 */
+    private BigDecimal orderAmount = BigDecimal.ZERO;
+
+    /** 对应的分公司 */
+    private String companyName;
+
+    /** 分公司的销售是谁 */
+    private String salesName;
+}
+
+

+ 28 - 0
fs-service-system/src/main/java/com/fs/live/vo/ProductSalesVo.java

@@ -0,0 +1,28 @@
+package com.fs.live.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * 单品销量统计VO
+ *
+ * @author fs
+ * @date 2025-01-18
+ */
+@Data
+public class ProductSalesVo {
+    /** 商品ID */
+    private Long productId;
+
+    /** 商品名称 */
+    private String productName;
+
+    /** 销量 */
+    private Long salesCount = 0L;
+
+    /** 销售额 */
+    private BigDecimal salesAmount = BigDecimal.ZERO;
+}
+
+

+ 278 - 2
fs-service-system/src/main/java/com/fs/store/service/impl/FsStoreProductServiceImpl.java

@@ -15,7 +15,14 @@ import com.fs.common.BeanCopyUtils;
 import com.fs.common.core.domain.R;
 import com.fs.common.exception.CustomException;
 import com.fs.common.utils.DateUtils;
-import com.fs.live.domain.LiveGoods;
+import com.fs.live.domain.*;
+import com.fs.live.mapper.LiveLotteryProductConfMapper;
+import com.fs.live.mapper.LiveMapper;
+import com.fs.live.service.ILiveAutoTaskService;
+import com.fs.live.service.ILiveGoodsService;
+import com.fs.live.service.ILiveLotteryConfService;
+import com.fs.live.service.ILiveService;
+import com.fs.live.vo.LiveGoodsVo;
 import com.fs.store.domain.*;
 import com.fs.store.dto.ProductArrtDTO;
 import com.fs.store.dto.ProductAttrCountDto;
@@ -24,9 +31,11 @@ import com.fs.store.param.FsStoreProductAddEditParam;
 import com.fs.store.param.FsStoreProductQueryParam;
 import com.fs.store.service.IFsStoreProductAttrValueService;
 import com.fs.store.vo.*;
+import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.lang3.StringUtils;
 import org.springframework.beans.BeanUtils;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.annotation.Async;
 import org.springframework.stereotype.Service;
 import com.fs.store.service.IFsStoreProductService;
 import org.springframework.transaction.annotation.Propagation;
@@ -40,6 +49,7 @@ import org.springframework.transaction.annotation.Transactional;
  */
 @Service
 @Transactional(propagation = Propagation.REQUIRED, rollbackFor = Exception.class)
+@Slf4j
 public class FsStoreProductServiceImpl implements IFsStoreProductService
 {
     @Autowired
@@ -56,6 +66,24 @@ public class FsStoreProductServiceImpl implements IFsStoreProductService
     private FsStoreProductPackageMapper fsStoreProductPackageMapper;
     @Autowired
     private FsStoreProductGroupMapper fsStoreProductGroupMapper;
+    @Autowired
+    private ILiveService liveService;
+
+    @Autowired
+    private LiveMapper liveMapper;
+
+    @Autowired
+    private ILiveGoodsService liveGoodsService;
+
+    @Autowired
+    private ILiveAutoTaskService liveAutoTaskService;
+
+    @Autowired
+    private ILiveLotteryConfService liveLotteryConfService;
+
+    @Autowired
+    private LiveLotteryProductConfMapper liveLotteryProductConfMapper;
+
     /**
      * 查询商品
      *
@@ -115,7 +143,255 @@ public class FsStoreProductServiceImpl implements IFsStoreProductService
     @Override
     public int deleteFsStoreProductByIds(Long[] productIds)
     {
-        return fsStoreProductMapper.deleteFsStoreProductByIds(productIds);
+        int result = fsStoreProductMapper.deleteFsStoreProductByIds(productIds);
+        // 异步处理商品删除联动逻辑
+        if (result > 0) {
+            try {
+                log.info("批量删除商品:{}", productIds);
+                handleProductDeleteAsync(productIds);
+            } catch (Exception e) {
+                log.error("商品删除异步处理失败:{}", e.getMessage());
+            }
+        }
+        return result;
+    }
+
+    /**
+     * 异步处理商品删除联动逻辑
+     * 删除所有未直播、直播中和直播回放的直播间中关联的商品、定时任务、抽奖等
+     *
+     * @param productIds 被删除的商品ID数组
+     */
+    @Async
+    public void handleProductDeleteAsync(Long[] productIds) {
+        try {
+            log.info("开始异步处理商品删除联动,商品IDs: {}", Arrays.toString(productIds));
+
+            // 查询所有未直播(1)、直播中(2)和直播回放(4)的直播间
+            // 使用 LiveMapper 查询状态为1,2,4的直播间(包括所有类型)
+            List<Live> allLiveList = liveMapper.liveListAll();
+            // 同时查询其他类型的直播间(如果liveListAll只查询类型2,3,我们需要补充查询类型1的)
+            // 为了确保完整性,我们使用 selectLiveList 方法查询所有状态为1,2,4的直播间
+            Live queryLive = new Live();
+            List<Live> allLiveList2 = liveService.selectLiveList(queryLive);
+            // 合并并去重,过滤出状态为1,2,4的直播间
+            Set<Long> liveIdSet = new HashSet<>();
+            List<Live> targetLiveList = new ArrayList<>();
+            for (Live live : allLiveList) {
+                if (live.getLiveId() != null && !liveIdSet.contains(live.getLiveId())) {
+                    liveIdSet.add(live.getLiveId());
+                    targetLiveList.add(live);
+                }
+            }
+            for (Live live : allLiveList2) {
+                if (live.getStatus() != null && (live.getStatus() == 1 || live.getStatus() == 2 || live.getStatus() == 4)) {
+                    if (live.getLiveId() != null && !liveIdSet.contains(live.getLiveId())) {
+                        liveIdSet.add(live.getLiveId());
+                        targetLiveList.add(live);
+                    }
+                }
+            }
+
+            if (targetLiveList.isEmpty()) {
+                log.info("没有找到需要处理的直播间");
+                return;
+            }
+
+            log.info("找到 {} 个需要处理的直播间", targetLiveList.size());
+
+            // 遍历每个被删除的商品
+            for (Long productId : productIds) {
+                processProductDeleteForLives(productId, targetLiveList);
+            }
+
+            log.info("商品删除联动处理完成");
+        } catch (Exception e) {
+            log.error("异步处理商品删除联动失败", e);
+        }
+    }
+
+    /**
+     * 处理单个商品在指定直播间列表中的删除联动
+     *
+     * @param productId 商品ID
+     * @param liveList 直播间列表
+     */
+    private void processProductDeleteForLives(Long productId, List<Live> liveList) {
+        for (Live live : liveList) {
+            try {
+                Long liveId = live.getLiveId();
+                if (liveId == null) {
+                    continue;
+                }
+
+                // 1. 删除直播商品
+                deleteLiveGoodsByProductId(productId, liveId);
+
+                // 2. 删除直播定时任务(直播上下架、直播卡片)
+                deleteLiveAutoTasksByProductId(productId, liveId);
+
+                // 3. 删除直播抽奖(产品关联被删除的商品)
+                deleteLiveLotteryProductConfByProductId(productId, liveId);
+
+                // 4. 删除直播定时任务(抽奖,里面关联了这个商品的)
+                deleteLiveAutoTasksByLotteryProductId(productId, liveId);
+
+            } catch (Exception e) {
+                log.error("处理直播间 {} 的商品 {} 删除联动失败", live.getLiveId(), productId, e);
+            }
+        }
+    }
+
+    /**
+     * 删除直播商品
+     *
+     * @param productId 商品ID
+     * @param liveId 直播间ID
+     */
+    private void deleteLiveGoodsByProductId(Long productId, Long liveId) {
+        try {
+            LiveGoods queryGoods = new LiveGoods();
+            queryGoods.setProductId(productId);
+            queryGoods.setLiveId(liveId);
+            List<LiveGoods> goodsList = liveGoodsService.selectLiveGoodsList(queryGoods);
+
+            if (!goodsList.isEmpty()) {
+                Long[] goodsIds = goodsList.stream()
+                        .map(LiveGoods::getGoodsId)
+                        .toArray(Long[]::new);
+                liveGoodsService.deleteLiveGoodsByGoodsIds(goodsIds);
+                log.info("删除直播间 {} 的商品 {} 相关的直播商品 {} 个", liveId, productId, goodsIds.length);
+            }
+        } catch (Exception e) {
+            log.error("删除直播商品失败,productId: {}, liveId: {}", productId, liveId, e);
+        }
+    }
+
+    /**
+     * 删除直播定时任务(直播上下架、直播卡片)
+     *
+     * @param productId 商品ID
+     * @param liveId 直播间ID
+     */
+    private void deleteLiveAutoTasksByProductId(Long productId, Long liveId) {
+        try {
+            LiveAutoTask queryTask = new LiveAutoTask();
+            queryTask.setLiveId(liveId);
+            List<LiveAutoTask> taskList = liveAutoTaskService.selectLiveAutoTaskList(queryTask);
+
+            List<Long> taskIdsToDelete = new ArrayList<>();
+
+            for (LiveAutoTask task : taskList) {
+                // 任务类型:1-定时推送卡片商品 6-自动上下架
+                if (task.getTaskType() != null && (task.getTaskType() == 1L || task.getTaskType() == 6L)) {
+                    try {
+                        if (task.getTaskType() == 1L) {
+                            // 商品推荐任务
+                            LiveGoodsVo liveGoodsVo = JSON.parseObject(task.getContent(), LiveGoodsVo.class);
+                            if (liveGoodsVo != null && productId.equals(liveGoodsVo.getProductId())) {
+                                taskIdsToDelete.add(task.getId());
+                            }
+                        } else if (task.getTaskType() == 6L) {
+                            // 商品上下架任务
+                            JSONObject jsonObject = JSON.parseObject(task.getContent());
+                            Long taskProductId = jsonObject.getLong("productId");
+                            if (taskProductId != null && productId.equals(taskProductId)) {
+                                taskIdsToDelete.add(task.getId());
+                            }
+                        }
+                    } catch (Exception e) {
+                        log.warn("解析自动化任务content失败,taskId: {}, error: {}", task.getId(), e.getMessage());
+                    }
+                }
+            }
+
+            if (!taskIdsToDelete.isEmpty()) {
+                Long[] ids = taskIdsToDelete.toArray(new Long[0]);
+                liveAutoTaskService.deleteLiveAutoTaskByIds(ids);
+                log.info("删除直播间 {} 的商品 {} 相关的定时任务 {} 个", liveId, productId, taskIdsToDelete.size());
+            }
+        } catch (Exception e) {
+            log.error("删除直播定时任务失败,productId: {}, liveId: {}", productId, liveId, e);
+        }
+    }
+
+    /**
+     * 删除直播抽奖(产品关联被删除的商品)
+     *
+     * @param productId 商品ID
+     * @param liveId 直播间ID
+     */
+    private void deleteLiveLotteryProductConfByProductId(Long productId, Long liveId) {
+        try {
+            LiveLotteryProductConf queryConf = new LiveLotteryProductConf();
+            queryConf.setProductId(productId);
+            queryConf.setLiveId(liveId);
+            List<LiveLotteryProductConf> confList = liveLotteryProductConfMapper.selectLiveLotteryProductConfList(queryConf);
+
+            if (!confList.isEmpty()) {
+                Long[] ids = confList.stream()
+                        .map(LiveLotteryProductConf::getId)
+                        .toArray(Long[]::new);
+                liveLotteryProductConfMapper.deleteLiveLotteryProductConfByIds(ids);
+                log.info("删除直播间 {} 的商品 {} 相关的抽奖商品配置 {} 个", liveId, productId, ids.length);
+            }
+        } catch (Exception e) {
+            log.error("删除直播抽奖商品配置失败,productId: {}, liveId: {}", productId, liveId, e);
+        }
+    }
+
+    /**
+     * 删除直播定时任务(抽奖,里面关联了这个商品的)
+     *
+     * @param productId 商品ID
+     * @param liveId 直播间ID
+     */
+    private void deleteLiveAutoTasksByLotteryProductId(Long productId, Long liveId) {
+        try {
+            // 查询该直播间下所有包含该商品的抽奖配置
+            LiveLotteryProductConf queryConf = new LiveLotteryProductConf();
+            queryConf.setProductId(productId);
+            queryConf.setLiveId(liveId);
+            List<LiveLotteryProductConf> confList = liveLotteryProductConfMapper.selectLiveLotteryProductConfList(queryConf);
+
+            if (confList.isEmpty()) {
+                return;
+            }
+
+            // 获取所有相关的抽奖ID
+            Set<Long> lotteryIds = confList.stream()
+                    .map(LiveLotteryProductConf::getLotteryId)
+                    .collect(Collectors.toSet());
+
+            // 查询该直播间的所有定时任务
+            LiveAutoTask queryTask = new LiveAutoTask();
+            queryTask.setLiveId(liveId);
+            List<LiveAutoTask> taskList = liveAutoTaskService.selectLiveAutoTaskList(queryTask);
+
+            List<Long> taskIdsToDelete = new ArrayList<>();
+
+            for (LiveAutoTask task : taskList) {
+                // 任务类型:4-抽奖
+                if (task.getTaskType() != null && task.getTaskType() == 4L) {
+                    try {
+                        LiveLotteryConf liveLotteryConf = JSON.parseObject(task.getContent(), LiveLotteryConf.class);
+                        if (liveLotteryConf != null && lotteryIds.contains(liveLotteryConf.getLotteryId())) {
+                            taskIdsToDelete.add(task.getId());
+                        }
+                    } catch (Exception e) {
+                        log.warn("解析自动化任务content失败,taskId: {}, error: {}", task.getId(), e.getMessage());
+                    }
+                }
+            }
+
+            if (!taskIdsToDelete.isEmpty()) {
+                Long[] ids = taskIdsToDelete.toArray(new Long[0]);
+                liveAutoTaskService.deleteLiveAutoTaskByIds(ids);
+                log.info("删除直播间 {} 的商品 {} 相关的抽奖定时任务 {} 个", liveId, productId, taskIdsToDelete.size());
+            }
+        } catch (Exception e) {
+            log.error("删除抽奖定时任务失败,productId: {}, liveId: {}", productId, liveId, e);
+        }
     }
 
     /**

+ 274 - 0
fs-service-system/src/main/resources/mapper/live/LiveDataMapper.xml

@@ -248,6 +248,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="likes != null">likes = #{likes},</if>
             <if test="favouriteNum != null">favourite_num = #{favouriteNum},</if>
             <if test="followNum != null">follow_num = #{followNum},</if>
+            <if test="replayViewNum != null">replay_view_num = #{replayViewNum},</if>
+            <if test="replayLikeNum != null">replay_like_num = #{replayLikeNum},</if>
         </trim>
         where live_id = #{liveId}
     </update>
@@ -279,4 +281,276 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             WHERE live_id = #{liveData.liveId}
         </foreach>
     </update>
+
+    <!-- 查询直播间统计数据 -->
+    <select id="selectLiveDataStatistics" resultType="com.fs.live.vo.LiveDataStatisticsVo">
+        SELECT
+        COUNT( lwu.user_id) AS totalViewers,
+        COUNT( CASE WHEN lwu.live_flag = 1 and lwu.replay_flag = 0 THEN lwu.user_id END) AS liveViewers,
+        COUNT( CASE WHEN lwu.live_flag = 0 and lwu.replay_flag = 1 THEN lwu.user_id END) AS playbackViewers,
+        COALESCE(AVG(CASE WHEN lwu.live_flag = 1 and lwu.replay_flag = 0 THEN lwu.online_seconds END), 0) AS liveAvgDuration,
+        COALESCE(AVG(CASE WHEN lwu.live_flag = 0 and lwu.replay_flag = 1 THEN lwu.online_seconds END), 0) AS playbackAvgDuration,
+        COUNT( CASE
+        WHEN lwu.online_seconds >= COALESCE(video_duration.total_duration, 0) AND video_duration.total_duration > 0
+        THEN lwu.user_id
+        END) AS totalCompletedCourses,
+        COUNT( CASE
+        WHEN lwu.live_flag = 1 and lwu.replay_flag = 0 AND lwu.online_seconds >= COALESCE(video_duration.total_duration, 0) AND video_duration.total_duration > 0
+        THEN lwu.user_id
+        END) AS liveCompletedCourses,
+        COUNT( CASE
+        WHEN lwu.live_flag = 0 and lwu.replay_flag = 1 AND lwu.online_seconds >= COALESCE(video_duration.total_duration, 0) AND video_duration.total_duration > 0
+        THEN lwu.user_id
+        END) AS playbackCompletedCourses,
+        COALESCE((
+        SELECT SUM( pay_price)
+        FROM live_order
+        WHERE live_id IN
+        <foreach collection="liveIds" item="liveId" open="(" separator="," close=")">
+            #{liveId}
+        </foreach>
+        AND is_pay = '1'
+        ), 0) AS gmv,
+        COALESCE((
+        select sum(acs.paid) from (SELECT COUNT(DISTINCT user_id) as paid
+        FROM live_order
+        WHERE live_id IN
+        <foreach collection="liveIds" item="liveId" open="(" separator="," close=")">
+            #{liveId}
+        </foreach>
+        AND is_pay = '1'
+        group by live_id
+        ) acs
+        ), 0) AS paidUsers,
+        COALESCE((
+        SELECT COUNT(DISTINCT order_id)
+        FROM live_order
+        WHERE live_id IN
+        <foreach collection="liveIds" item="liveId" open="(" separator="," close=")">
+            #{liveId}
+        </foreach>
+        AND is_pay = '1'
+        ), 0) AS paidOrders,
+        COALESCE((
+        SELECT COUNT(DISTINCT order_id)
+        FROM live_order
+        WHERE live_id IN
+        <foreach collection="liveIds" item="liveId" open="(" separator="," close=")">
+            #{liveId}
+        </foreach>
+        ), 0) AS salesCount
+        FROM live l
+        LEFT JOIN live_watch_user lwu ON l.live_id = lwu.live_id
+        LEFT JOIN (
+        SELECT live_id, SUM(COALESCE(duration, 0)) AS total_duration
+        FROM live_video
+        WHERE video_type IN (1, 2)
+        GROUP BY live_id
+        ) video_duration ON l.live_id = video_duration.live_id
+        WHERE l.live_id IN
+        <foreach collection="liveIds" item="liveId" open="(" separator="," close=")">
+            #{liveId}
+        </foreach>
+    </select>
+
+    <!-- 查询直播间列表数据 -->
+    <select id="selectLiveDataListByLiveIds" resultType="com.fs.live.vo.LiveDataListVo">
+        SELECT
+        l.live_id AS liveId,
+        l.live_name AS liveName,
+        l.live_type AS liveType,
+        l.status AS status,
+        l.start_time AS startTime,
+        l.finish_time AS finishTime,
+        COUNT(1) AS totalViewers,
+        COUNT(CASE WHEN lwu.live_flag = 1 and lwu.replay_flag = 0 THEN lwu.user_id END) AS liveViewers,
+        COUNT(CASE WHEN lwu.live_flag = 0 and lwu.replay_flag = 1 THEN lwu.user_id END) AS playbackViewers,
+        COALESCE(AVG(CASE WHEN lwu.live_flag = 1 and lwu.replay_flag = 0 THEN lwu.online_seconds END), 0) AS liveAvgDuration,
+        COALESCE(AVG(CASE WHEN lwu.live_flag = 0 and lwu.replay_flag = 1 THEN lwu.online_seconds END), 0) AS playbackAvgDuration,
+        COUNT(CASE
+        WHEN lwu.online_seconds >= COALESCE(video_duration.total_duration, 0) AND video_duration.total_duration > 0
+        THEN lwu.user_id
+        END) AS totalCompletedCourses,
+        COUNT(CASE
+        WHEN lwu.live_flag = 1  and lwu.replay_flag = 0 AND lwu.online_seconds >= COALESCE(video_duration.total_duration, 0) AND video_duration.total_duration > 0
+        THEN lwu.user_id
+        END) AS liveCompletedCourses,
+        COUNT(CASE
+        WHEN lwu.live_flag = 0  and lwu.replay_flag = 1 AND lwu.online_seconds >= COALESCE(video_duration.total_duration, 0) AND video_duration.total_duration > 0
+        THEN lwu.user_id
+        END) AS playbackCompletedCourses,
+        COALESCE(order_stats.gmv, 0) AS gmv,
+        COALESCE(order_stats.paidUsers, 0) AS paidUsers,
+        COALESCE(order_stats.paidOrders, 0) AS paidOrders,
+        COALESCE(order_stats.salesCount, 0) AS salesCount
+        FROM live l
+        LEFT JOIN live_watch_user lwu ON l.live_id = lwu.live_id
+        LEFT JOIN (
+        SELECT live_id, SUM(COALESCE(duration, 0)) AS total_duration
+        FROM live_video
+        WHERE video_type IN (1, 2)
+        GROUP BY live_id
+        ) video_duration ON l.live_id = video_duration.live_id
+        LEFT JOIN (
+        SELECT
+        live_id,
+        SUM( case when is_pay = '1' then pay_price else 0 end) AS gmv,
+        COUNT(distinct CASE WHEN is_pay = '1' THEN user_id END) AS paidUsers,
+        sum(CASE WHEN is_pay = '1' THEN 1 else 0 END) AS paidOrders,
+        COUNT(DISTINCT order_id) AS salesCount
+        FROM live_order
+        GROUP BY live_id
+        ) order_stats ON l.live_id = order_stats.live_id
+        WHERE l.live_id IN
+        <foreach collection="liveIds" item="liveId" open="(" separator="," close=")">
+            #{liveId}
+        </foreach>
+        GROUP BY l.live_id, l.live_name, l.live_type, l.status, l.start_time, l.finish_time,
+        order_stats.gmv, order_stats.paidUsers, order_stats.paidOrders, order_stats.salesCount
+        ORDER BY l.start_time DESC
+    </select>
+
+    <!-- 查询直播间详情数据(SQL方式) -->
+    <select id="selectLiveDataDetailBySql" resultType="com.fs.live.vo.LiveDataDetailVo">
+        SELECT
+            COALESCE(video_duration.total_duration, 0) AS videoDuration,
+            (COUNT(DISTINCT CASE WHEN lwu.live_flag = 1 AND lwu.replay_flag = 0 THEN lwu.user_id END) +  COUNT(DISTINCT CASE WHEN lwu.live_flag = 0 AND lwu.replay_flag = 1 THEN lwu.user_id END)) AS totalViewers,
+            COUNT(DISTINCT CASE
+                               WHEN lwu.online_seconds >= COALESCE(video_duration.total_duration, 0) AND video_duration.total_duration > 0
+                                   THEN lwu.user_id
+                END) AS totalCompletedCourses,
+            CASE
+                WHEN COUNT(DISTINCT lwu.user_id) > 0 THEN
+                    ROUND(COUNT(DISTINCT CASE
+                                             WHEN lwu.online_seconds >= COALESCE(video_duration.total_duration, 0) AND video_duration.total_duration > 0
+                                                 THEN lwu.user_id
+                        END) * 100.0 / COUNT(DISTINCT lwu.user_id), 2)
+                ELSE 0
+                END AS totalCompletionRate,
+            COUNT(DISTINCT CASE WHEN lwu.live_flag = 1 AND lwu.replay_flag = 0 THEN lwu.user_id END) AS liveViewers,
+            COUNT(DISTINCT CASE WHEN lwu.live_flag = 1 AND lwu.replay_flag = 0 AND lwu.online_seconds >= 1200 THEN lwu.user_id END) AS liveOver20Minutes,
+            COUNT(DISTINCT CASE WHEN lwu.live_flag = 1 AND lwu.replay_flag = 0 AND lwu.online_seconds >= 1800 THEN lwu.user_id END) AS liveOver30Minutes,
+            CASE
+                WHEN COUNT(DISTINCT CASE WHEN lwu.live_flag = 1 AND lwu.replay_flag = 0 THEN lwu.user_id END) > 0 THEN
+                    ROUND(COUNT(DISTINCT CASE WHEN lwu.live_flag = 1 AND lwu.replay_flag = 0 AND lwu.online_seconds >= 1200 THEN lwu.user_id END) * 100.0 / COUNT(DISTINCT CASE WHEN lwu.live_flag = 1 AND lwu.replay_flag = 0 THEN lwu.user_id END), 2)
+                ELSE 0
+                END AS liveCompletionRate20,
+            CASE
+                WHEN COUNT(DISTINCT CASE WHEN lwu.live_flag = 1 AND lwu.replay_flag = 0 THEN lwu.user_id END) > 0 THEN
+                    ROUND(COUNT(DISTINCT CASE WHEN lwu.live_flag = 1 AND lwu.replay_flag = 0 AND lwu.online_seconds >= 1800 THEN lwu.user_id END) * 100.0 / COUNT(DISTINCT CASE WHEN lwu.live_flag = 1 AND lwu.replay_flag = 0 THEN lwu.user_id END), 2)
+                ELSE 0
+                END AS liveCompletionRate30,
+            COUNT(DISTINCT CASE WHEN lwu.live_flag = 0 AND lwu.replay_flag = 1 THEN lwu.user_id END) AS playbackViewers,
+            COUNT(DISTINCT CASE WHEN lwu.live_flag = 0 AND lwu.replay_flag = 1 AND lwu.online_seconds >= 1200 THEN lwu.user_id END) AS playbackOver20Minutes,
+            COUNT(DISTINCT CASE WHEN lwu.live_flag = 0 AND lwu.replay_flag = 1 AND lwu.online_seconds >= 1800 THEN lwu.user_id END) AS playbackOver30Minutes,
+            CASE
+                WHEN COUNT(DISTINCT CASE WHEN lwu.live_flag = 0 AND lwu.replay_flag = 1 THEN lwu.user_id END) > 0 THEN
+                    ROUND(COUNT(DISTINCT CASE WHEN lwu.live_flag = 0 AND lwu.replay_flag = 1 AND lwu.online_seconds >= 1200 THEN lwu.user_id END) * 100.0 / COUNT(DISTINCT CASE WHEN lwu.live_flag = 0 AND lwu.replay_flag = 1 THEN lwu.user_id END), 2)
+                ELSE 0
+                END AS playbackCompletionRate20,
+            CASE
+                WHEN COUNT(DISTINCT CASE WHEN lwu.live_flag = 0 AND lwu.replay_flag = 1 THEN lwu.user_id END) > 0 THEN
+                    ROUND(COUNT(DISTINCT CASE WHEN lwu.live_flag = 0 AND lwu.replay_flag = 1 AND lwu.online_seconds >= 1800 THEN lwu.user_id END) * 100.0 / COUNT(DISTINCT CASE WHEN lwu.live_flag = 0 AND lwu.replay_flag = 1 THEN lwu.user_id END), 2)
+                ELSE 0
+                END AS playbackCompletionRate30,
+            COALESCE(ld.peak_concurrent_viewers, 0) AS livePeak,
+            COALESCE(AVG(CASE WHEN lwu.live_flag = 1 AND lwu.replay_flag = 0 THEN lwu.online_seconds END), 0) AS liveAvgDuration,
+            COALESCE(AVG(CASE WHEN lwu.live_flag = 0 AND lwu.replay_flag = 1 THEN lwu.online_seconds END), 0) AS playbackAvgDuration,
+            CASE
+                WHEN COALESCE(video_duration.total_duration, 0) > 0 THEN
+                    ROUND(COALESCE(AVG(CASE WHEN lwu.live_flag = 0 AND lwu.replay_flag = 1 THEN lwu.online_seconds END), 0) * 100.0 / video_duration.total_duration, 2)
+                ELSE 0
+                END AS playbackFinishRate,
+            COALESCE(order_stats.gmv, 0) AS gmv,
+            COALESCE(order_stats.paidUsers, 0) AS paidUsers,
+            COALESCE(order_stats.paidOrders, 0) AS paidOrders,
+            CASE
+                WHEN COALESCE(ld.peak_concurrent_viewers, 0) > 0 THEN
+                    ROUND(order_stats.paidUsers * 100.0 / ld.peak_concurrent_viewers, 2)
+                ELSE 0
+                END AS peakConversionRate,
+            CASE
+                WHEN COUNT(DISTINCT lwu.user_id) > 0 THEN
+                    ROUND(order_stats.paidUsers * 100.0 / COUNT(DISTINCT lwu.user_id), 2)
+                ELSE 0
+                END AS totalViewerConversionRate,
+            CASE
+                WHEN COUNT(DISTINCT CASE WHEN lwu.live_flag = 1 AND lwu.replay_flag = 0 AND lwu.online_seconds >= 1800 THEN lwu.user_id END) > 0 THEN
+                    ROUND(order_stats.paidUsers * 100.0 / COUNT(DISTINCT CASE WHEN lwu.live_flag = 1 AND lwu.replay_flag = 0 AND lwu.online_seconds >= 1800 THEN lwu.user_id END), 2)
+                ELSE 0
+                END AS completion30MinConversionRate,
+            CASE
+                WHEN COALESCE(ld.peak_concurrent_viewers, 0) > 0 THEN
+                    ROUND(order_stats.gmv / ld.peak_concurrent_viewers, 2)
+                ELSE 0
+                END AS peakRValue,
+            CASE
+                WHEN COUNT(DISTINCT CASE
+                                        WHEN lwu.online_seconds >= COALESCE(video_duration.total_duration, 0) AND video_duration.total_duration > 0
+                                            THEN lwu.user_id
+                    END) > 0 THEN
+                    ROUND(order_stats.gmv / COUNT(DISTINCT CASE
+                                                               WHEN lwu.online_seconds >= COALESCE(video_duration.total_duration, 0) AND video_duration.total_duration > 0
+                                                                   THEN lwu.user_id
+                        END), 2)
+                ELSE 0
+                END AS completionRValue
+        FROM live l
+                 LEFT JOIN live_data ld ON l.live_id = ld.live_id
+                 LEFT JOIN live_watch_user lwu ON l.live_id = lwu.live_id
+                 LEFT JOIN (
+            SELECT live_id, SUM(COALESCE(duration, 0)) AS total_duration
+            FROM live_video
+            WHERE video_type IN (1, 2)
+            GROUP BY live_id
+        ) video_duration ON l.live_id = video_duration.live_id
+                 LEFT JOIN (
+            SELECT
+                live_id,
+                SUM(CASE WHEN is_pay = '1' THEN pay_price ELSE 0 END) AS gmv,
+                COUNT(DISTINCT CASE WHEN is_pay = '1' THEN user_id END) AS paidUsers,
+                SUM(CASE WHEN is_pay = '1' THEN 1 ELSE 0 END) AS paidOrders
+            FROM live_order
+            GROUP BY live_id
+        ) order_stats ON l.live_id = order_stats.live_id
+        WHERE l.live_id = #{liveId}
+
+        GROUP BY
+            l.live_id,
+            ld.peak_concurrent_viewers,
+            video_duration.total_duration,
+            order_stats.gmv,
+            order_stats.paidUsers,
+            order_stats.paidOrders;
+    </select>
+
+    <!-- 查询直播间用户详情列表(SQL方式) -->
+    <select id="selectLiveUserDetailListBySql" resultType="com.fs.live.vo.LiveUserDetailVo">
+        SELECT
+            u.user_id AS userId,
+            COALESCE(u.nickname, '未知用户') AS userName,
+            COALESCE(SUM(CASE WHEN lwu.live_flag = 1 AND lwu.replay_flag = 0 THEN lwu.online_seconds ELSE 0 END), 0) AS liveWatchDuration,
+            COALESCE(SUM(CASE WHEN lwu.live_flag = 0 AND lwu.replay_flag = 1 THEN lwu.online_seconds ELSE 0 END), 0) AS playbackWatchDuration,
+            COALESCE(order_info.orderCount, 0) AS orderCount,
+            COALESCE(order_info.orderAmount, 0) AS orderAmount,
+            COALESCE(c.company_name, '') AS companyName,
+            COALESCE(cu.user_name, '') AS salesName
+        FROM live_watch_user lwu
+                 LEFT JOIN fs_user u ON lwu.user_id = u.user_id
+                 LEFT JOIN (
+            SELECT
+                CAST(user_id AS UNSIGNED) AS user_id,
+                COUNT(DISTINCT order_id) AS orderCount,
+                SUM(CASE WHEN is_pay = '1' THEN pay_price ELSE 0 END) AS orderAmount
+            FROM live_order
+            WHERE live_id = #{liveId} AND user_id IS NOT NULL AND user_id != ''
+            GROUP BY user_id
+        ) order_info ON lwu.user_id = order_info.user_id
+                 left join live_user_first_entry lufe on lufe.live_id = lwu.live_id and lufe.user_id = lwu.user_id
+                 LEFT JOIN company c ON lufe.company_id = c.company_id
+                 LEFT JOIN company_user cu ON lufe.company_user_id = cu.user_id
+        WHERE lwu.live_id = #{liveId}
+        GROUP BY u.user_id, u.nickname, order_info.orderCount, order_info.orderAmount, c.company_name, cu.user_name
+        ORDER BY order_info.orderAmount DESC, liveWatchDuration DESC
+    </select>
 </mapper>

+ 15 - 0
fs-service-system/src/main/resources/mapper/live/LiveMapper.xml

@@ -231,6 +231,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <foreach item="liveId" collection="array" open="(" separator="," close=")">
             #{liveId}
         </foreach>
+        <if test="live != null">
+            <if test="live.companyId != null"> and company_id = #{live.companyId}</if>
+        </if>
     </delete>
 
     <update id="updateBatchLiveList" parameterType="com.fs.live.vo.LiveListVo">
@@ -238,6 +241,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <foreach item="liveId" collection="liveVo.liveIds" open="(" separator="," close=")">
             #{liveId}
         </foreach>
+        <if test="liveVo != null">
+            <if test="liveVo.companyId != null"> and company_id = #{liveVo.companyId}</if>
+        </if>
     </update>
 
     <update id="deleteBatchLiveList" parameterType="com.fs.live.vo.LiveListVo">
@@ -246,6 +252,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <foreach item="liveId" collection="liveVo.liveIds" open="(" separator="," close=")">
             #{liveId}
         </foreach>
+        <if test="liveVo.companyId != null">
+            <if test="liveVo.companyId != null"> and company_id = #{liveVo.companyId}</if>
+        </if>
     </update>
 
     <update id="handleShelfOrUnAdmin" parameterType="com.fs.live.vo.LiveListVo">
@@ -253,6 +262,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <foreach item="liveId" collection="liveVo.liveIds" open="(" separator="," close=")">
             #{liveId}
         </foreach>
+        <if test="liveVo.companyId != null">
+            <if test="liveVo.companyId != null"> and company_id = #{liveVo.companyId}</if>
+        </if>
     </update>
 
     <update id="handleDeleteSelectedAdmin" parameterType="com.fs.live.vo.LiveListVo">
@@ -261,6 +273,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <foreach item="liveId" collection="liveVo.liveIds" open="(" separator="," close=")">
             #{liveId}
         </foreach>
+        <if test="liveVo.companyId != null">
+            <if test="liveVo.companyId != null"> and company_id = #{liveVo.companyId}</if>
+        </if>
     </update>
 
     <update id="updateLiveList">

+ 4 - 0
fs-service-system/src/main/resources/mapper/live/LiveMsgMapper.xml

@@ -55,6 +55,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="updateBy != null">update_by,</if>
             <if test="updateTime != null">update_time,</if>
             <if test="remark != null">remark,</if>
+            <if test="liveFlag != null">live_flag,</if>
+            <if test="replayFlag != null">replay_flag,</if>
          </trim>
         <trim prefix="values (" suffix=")" suffixOverrides=",">
             <if test="liveId != null">#{liveId},</if>
@@ -66,6 +68,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="updateBy != null">#{updateBy},</if>
             <if test="updateTime != null">#{updateTime},</if>
             <if test="remark != null">#{remark},</if>
+            <if test="liveFlag != null">#{liveFlag},</if>
+            <if test="replayFlag != null">#{replayFlag},</if>
          </trim>
     </insert>
 

+ 90 - 8
fs-service-system/src/main/resources/mapper/live/LiveWatchUserMapper.xml

@@ -16,10 +16,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="updateTime"    column="update_time"    />
         <result property="remark"    column="remark"    />
         <result property="onlineSeconds"    column="online_seconds"    />
+        <result property="globalVisible"    column="global_visible"    />
+        <result property="singleVisible"    column="single_visible"    />
+        <result property="liveFlag"    column="live_flag"    />
+        <result property="replayFlag"    column="replay_flag"    />
+        <result property="location"    column="location"    />
     </resultMap>
 
     <sql id="selectLiveWatchUserVo">
-        select id, live_id,user_id, msg_status, online, create_time, create_by, update_by, update_time, remark,online_seconds from live_watch_user
+        select id, live_id,user_id, msg_status, online, create_time, create_by, update_by, update_time, remark,online_seconds,global_visible,single_visible,live_flag,replay_flag,location from live_watch_user
     </sql>
 
     <select id="selectLiveWatchUserList" parameterType="LiveWatchUser" resultMap="LiveWatchUserResult">
@@ -27,6 +32,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <where>
             <if test="liveId != null "> and live_id = #{liveId}</if>
             <if test="userId != null "> and user_id = #{userId}</if>
+            <if test="liveFlag != null "> and live_flag = #{liveFlag}</if>
+            <if test="replayFlag != null "> and replay_flag = #{replayFlag}</if>
             <if test="msgStatus != null "> and msg_status = #{msgStatus}</if>
             <if test="online != null "> and online = #{online}</if>
             <if test="onlineSeconds != null "> and online_seconds = #{onlineSeconds}</if>
@@ -38,19 +45,45 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         where id = #{id}
     </select>
 
+
+    <select id="selectWatchUserListAllByLiveId" resultType="com.fs.live.vo.LiveWatchUserVO">
+        select
+        lwu.live_id     liveId,
+        lwu.user_id     userId,
+        lwu.msg_status  msgStatus,
+        lwu.online      online,
+        fu.nick_name    nickName,
+        fu.avatar       avatar,
+        lwu.global_visible  globalVisible,
+        lwu.single_visible  singleVisible
+        from live_watch_user lwu
+        left join fs_user fu on lwu.user_id = fu.user_id
+        where lwu.live_id = #{params.liveId} and fu.status = 1
+        <if test="params.liveFlag != null "> and lwu.live_flag = #{params.liveFlag}</if>
+        <if test="params.replayFlag != null "> and lwu.replay_flag = #{params.replayFlag}</if>
+        <if test="params.userName != null and params.userName != ''"> and fu.nick_name like concat('%',#{params.userName},'%')</if>
+        order by lwu.create_time desc
+
+    </select>
+
     <select id="selectWatchUserListByLiveId" resultType="com.fs.live.vo.LiveWatchUserVO">
         select
-            lwu.live_id     liveId,
-            lwu.user_id     userId,
-            lwu.msg_status  msgStatus,
-            lwu.online      online,
-            fu.nickname    nickName,
-            fu.avatar       avatar
+        lwu.live_id     liveId,
+        lwu.user_id     userId,
+        lwu.msg_status  msgStatus,
+        lwu.online      online,
+        fu.nick_name    nickName,
+        fu.avatar       avatar,
+        lwu.global_visible  globalVisible,
+        lwu.single_visible  singleVisible
         from live_watch_user lwu
         left join fs_user fu on lwu.user_id = fu.user_id
-        where lwu.live_id = #{params.liveId}
+        where lwu.live_id = #{params.liveId} and fu.status = 1
+        <if test="params.liveFlag != null "> and lwu.live_flag = #{params.liveFlag}</if>
+        <if test="params.replayFlag != null "> and lwu.replay_flag = #{params.replayFlag}</if>
         <if test="params.msgStatus != null "> and msg_status = #{params.msgStatus}</if>
         <if test="params.online != null "> and online = #{params.online}</if>
+        <if test="params.userName != null and params.userName != ''"> and fu.nick_name like concat('%',#{params.userName},'%')</if>
         order by lwu.create_time desc
 
     </select>
@@ -68,9 +101,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             lwu.online      online,
             fu.nickname    nickName,
             fu.avatar       avatar
+            lwu.global_visible  globalVisible,
+                lwu.single_visible  singleVisible
         from live_watch_user lwu
         left join fs_user fu on lwu.user_id = fu.user_id
         where lwu.live_id = #{liveId} and lwu.user_id = #{userId}
+        <if test="liveFlag != null "> and lwu.live_flag = #{liveFlag}</if>
+        <if test="replayFlag != null "> and lwu.replay_flag = #{replayFlag}</if>
     </select>
 
     <select id="selectOnlineUserList" parameterType="LiveWatchUser" resultType="com.fs.live.vo.LiveWatchUserVO">
@@ -82,6 +119,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         left join fs_user fu on lwu.user_id = fu.user_id
         <where>
         <if test="liveId != null "> and live_id = #{liveId}</if>
+            <if test="liveFlag != null "> and live_flag = #{liveFlag}</if>
+            <if test="replayFlag != null "> and replay_flag = #{replayFlag}</if>
         <if test="online != null "> and online = #{online}</if>
         </where>
     </select>
@@ -99,6 +138,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="updateTime != null">update_time,</if>
             <if test="remark != null">remark,</if>
             <if test="onlineSeconds != null">online_seconds,</if>
+            <if test="globalVisible != null">global_visible,</if>
+            <if test="singleVisible != null">single_visible,</if>
+            <if test="liveFlag != null">live_flag,</if>
+            <if test="replayFlag != null">replay_flag,</if>
+            <if test="location != null">location,</if>
          </trim>
         <trim prefix="values (" suffix=")" suffixOverrides=",">
             <if test="liveId != null">#{liveId},</if>
@@ -111,6 +155,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="updateTime != null">#{updateTime},</if>
             <if test="remark != null">#{remark},</if>
             <if test="onlineSeconds != null">#{onlineSeconds},</if>
+            <if test="globalVisible != null">#{globalVisible},</if>
+            <if test="singleVisible != null">#{singleVisible},</if>
+            <if test="liveFlag != null">#{liveFlag},</if>
+            <if test="replayFlag != null">#{replayFlag},</if>
+            <if test="location != null">#{location},</if>
          </trim>
     </insert>
 
@@ -127,6 +176,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="updateTime != null">update_time = #{updateTime},</if>
             <if test="remark != null">remark = #{remark},</if>
             <if test="onlineSeconds != null">online_seconds = #{onlineSeconds},</if>
+            <if test="globalVisible != null">global_visible = #{globalVisible},</if>
+            <if test="singleVisible != null">single_visible = #{singleVisible},</if>
+            <if test="liveFlag != null">live_flag = #{liveFlag},</if>
+            <if test="replayFlag != null">replay_flag = #{replayFlag},</if>
+            <if test="location != null">location = #{location},</if>
         </trim>
         where id = #{id}
     </update>
@@ -156,4 +210,32 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
               AND DATE(lrr.create_time) = DATE(#{now})
         )
     </select>
+    <!-- 根据唯一索引查询:live_id, user_id, live_flag, replay_flag -->
+    <select id="selectByUniqueIndex" resultMap="LiveWatchUserResult">
+        <include refid="selectLiveWatchUserVo"/>
+        WHERE live_id = #{liveId}
+        AND user_id = #{userId}
+        AND live_flag = #{liveFlag}
+        AND replay_flag = #{replayFlag}
+        LIMIT 1
+    </select>
+
+    <!-- 根据唯一索引插入或更新(ON DUPLICATE KEY UPDATE) -->
+    <insert id="insertOrUpdateByUniqueIndex" parameterType="LiveWatchUser">
+        INSERT INTO live_watch_user (
+            live_id, user_id, live_flag, replay_flag,
+            msg_status, online, location,
+            create_time, update_time
+        ) VALUES (
+                     #{liveId}, #{userId}, #{liveFlag}, #{replayFlag},
+                     #{msgStatus}, #{online}, #{location},
+                     #{createTime}, #{updateTime}
+                 )
+            ON DUPLICATE KEY UPDATE
+
+                                 msg_status = VALUES(msg_status),
+                                 online = VALUES(online),
+                                 location = VALUES(location),
+                                 update_time = VALUES(update_time)
+    </insert>
 </mapper>

+ 78 - 1
fs-user-app/src/main/java/com/fs/app/controller/LiveGoodsController.java

@@ -1,6 +1,7 @@
 package com.fs.app.controller;
 
 import com.fs.common.annotation.Log;
+import com.fs.common.constant.LiveKeysConstant;
 import com.fs.common.core.controller.BaseController;
 import com.fs.common.core.domain.AjaxResult;
 import com.fs.common.core.domain.R;
@@ -11,6 +12,13 @@ import com.fs.common.utils.ServletUtils;
 import com.fs.common.utils.poi.ExcelUtil;
 import com.fs.live.domain.LiveGoods;
 import com.fs.live.service.ILiveGoodsService;
+import com.fs.store.domain.FsStoreProduct;
+import com.fs.store.domain.FsStoreProductAttr;
+import com.fs.store.domain.FsStoreProductAttrValue;
+import com.fs.store.domain.FsStoreProductRelation;
+import com.fs.store.service.IFsStoreProductAttrService;
+import com.fs.store.service.IFsStoreProductAttrValueService;
+import com.fs.store.service.IFsStoreProductRelationService;
 import com.fs.store.service.IFsStoreProductService;
 import com.fs.store.vo.FsStoreProductListQueryVO;
 import com.github.pagehelper.PageHelper;
@@ -18,7 +26,11 @@ import com.github.pagehelper.PageInfo;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.*;
 
+import java.util.Date;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
 
 import static com.fs.app.constant.CommonConstant.REDIS_KEY_COMPANY_USER_TOKEN;
 
@@ -41,6 +53,14 @@ public class LiveGoodsController extends AppBaseController
     @Autowired
     private RedisCache redisCache;
 
+    @Autowired
+    private IFsStoreProductAttrService attrService;
+    @Autowired
+    private IFsStoreProductRelationService productRelationService;
+
+    @Autowired
+    private IFsStoreProductAttrValueService attrValueService;
+
     /**
      * 查询直播商品列表
      */
@@ -122,7 +142,64 @@ public class LiveGoodsController extends AppBaseController
     @GetMapping("/liveGoodsDetail/{productId}")
     public R liveGoodsDetail(@PathVariable Long productId)
     {
-        return R.ok().put("data",fsStoreProductService.selectFsStoreProductById(productId));
+        // 先从缓存中获取商品详情
+        String cacheKey = String.format(LiveKeysConstant.PRODUCT_DETAIL_CACHE, productId);
+        Map<String, Object> cachedData = redisCache.getCacheObject(cacheKey);
+
+        FsStoreProduct product;
+        List<FsStoreProductAttr> productAttr;
+        List<FsStoreProductAttrValue> productValues;
+
+        if (cachedData != null) {
+            // 从缓存中获取数据
+            product = (FsStoreProduct) cachedData.get("product");
+            productAttr = (List<FsStoreProductAttr>) cachedData.get("productAttr");
+            productValues = (List<FsStoreProductAttrValue>) cachedData.get("productValues");
+        } else {
+            // 缓存中没有,从数据库查询
+            product = fsStoreProductService.selectFsStoreProductById(productId);
+            if(product==null){
+                return R.error("商品不存在或已下架");
+            }
+            productAttr = attrService.selectFsStoreProductAttrByProductId(productId);
+            productValues = attrValueService.selectFsStoreProductAttrValueByProductId(productId);
+
+            // 将数据存入缓存
+            Map<String, Object> cacheData = new HashMap<>();
+            cacheData.put("product", product);
+            cacheData.put("productAttr", productAttr);
+            cacheData.put("productValues", productValues);
+            redisCache.setCacheObject(cacheKey, cacheData, LiveKeysConstant.PRODUCT_DETAIL_CACHE_EXPIRE, TimeUnit.SECONDS);
+        }
+
+        // 获取用户的TOKEN写入足迹
+        String userId=getUserId();
+        if(userId!=null){
+            FsStoreProductRelation productRelation=new FsStoreProductRelation();
+            productRelation.setIsDel(0);
+            productRelation.setUserId(Long.parseLong(userId));
+            productRelation.setProductId(product.getProductId());
+            productRelation.setType("foot");
+            List<FsStoreProductRelation> productRelations=productRelationService.selectFsStoreProductRelationList(productRelation);
+            if(productRelations!=null&&productRelations.size()>0){
+                FsStoreProductRelation relation=productRelations.get(0);
+                relation.setUpdateTime(new Date());
+                productRelationService.updateFsStoreProductRelation(relation);
+            }
+            else{
+                FsStoreProductRelation relation=new FsStoreProductRelation();
+                relation.setUserId(Long.parseLong(userId));
+                relation.setIsDel(0);
+                relation.setProductId(product.getProductId());
+                relation.setUpdateTime(new Date());
+                relation.setType("foot");
+                relation.setCreateTime(new Date());
+                relation.setUpdateTime(new Date());
+                productRelationService.insertFsStoreProductRelation(relation);
+            }
+        }
+        return R.ok().put("product",product).put("productAttr",productAttr).put("productValues",productValues);
+
     }
 
     /**

+ 10 - 0
fs-user-app/src/main/java/com/fs/app/controller/LiveOrderController.java

@@ -37,6 +37,7 @@ import io.swagger.annotations.ApiOperation;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.transaction.annotation.Transactional;
 import org.springframework.validation.annotation.Validated;
 import org.springframework.web.bind.annotation.*;
 
@@ -101,6 +102,15 @@ public class LiveOrderController extends AppBaseController
         return liveOrderService.getExpress(order);
     }
 
+    @Login
+    @ApiOperation("修改支付类型")
+    @PostMapping("/clearPayType")
+    @Transactional
+    public R clearPayType(HttpServletRequest request, @Validated @RequestBody FsStoreOrderPayParam param) {
+        redisCache.deleteObject("isPaying:"+param.getOrderId());
+        return R.ok();
+    }
+
 
     /**
      * 查询订单列表

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

@@ -97,6 +97,20 @@ public class LiveFacadeServiceImpl extends BaseController implements LiveFacadeS
 
     @Override
     public TableDataInfo watchUserList(LiveWatchUser param) {
+        // 从参数中提取分页条件
+        Integer pageNum = param.getPageNum();
+        Integer pageSize = param.getPageSize();
+
+        // 如果分页参数为空或无效,强制设置为默认值:第1页,每页10条
+        if (pageNum == null || pageNum <= 0) {
+            pageNum = 1;
+            param.setPageNum(pageNum);
+        }
+        if (pageSize == null || pageSize <= 0) {
+            pageSize = 10;
+            param.setPageSize(pageSize);
+        }
+
         List<LiveWatchUserVO> liveWatchUserVOS;
         String setKey = String.format(LiveKeysConstant.LIVE_WATCH_USERS, param.getLiveId());
         Map<Object, Object> hashEntries = redisUtil.hashEntries(setKey);
@@ -115,7 +129,25 @@ public class LiveFacadeServiceImpl extends BaseController implements LiveFacadeS
                     .filter(Objects::nonNull)
                     .collect(Collectors.toList());
         }
-        return getDataTable(liveWatchUserVOS);
+        // 手动分页处理
+        int total = liveWatchUserVOS.size();
+        int start = (pageNum - 1) * pageSize;
+        int end = Math.min(start + pageSize, total);
+
+        List<LiveWatchUserVO> pageList;
+        if (start >= total) {
+            pageList = new ArrayList<>();
+        } else {
+            pageList = liveWatchUserVOS.subList(start, end);
+        }
+
+        // 创建分页结果
+        TableDataInfo tableDataInfo = new TableDataInfo();
+        tableDataInfo.setCode(200);
+        tableDataInfo.setMsg("查询成功");
+        tableDataInfo.setRows(pageList);
+        tableDataInfo.setTotal(total);
+        return tableDataInfo;
     }
 
     @Override

+ 9 - 11
fs-user-app/src/main/java/com/fs/app/websocket/handle/LiveChatHandler.java

@@ -51,7 +51,7 @@ public class LiveChatHandler extends SimpleChannelInboundHandler<TextWebSocketFr
      */
     @Override
     public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
-        log.debug("事件");
+
         // 处理 WebSocket 握手完成事件
         if (evt instanceof WebSocketServerProtocolHandler.HandshakeComplete) {
             Long userId = ctx.channel().attr(AttrConstant.ATTR_USER_ID).get();
@@ -69,16 +69,16 @@ public class LiveChatHandler extends SimpleChannelInboundHandler<TextWebSocketFr
             roomGroup.add(ctx.channel());
 
             if (userType == 0) {
-                // 加入房间
-                liveWatchUserService.join(liveId, userId);
-                room.put(userId, ctx.channel());
+
 
                 FsUser fsUser = fsUserService.selectFsUserByUserId(userId);
                 if (Objects.isNull(fsUser)) {
                     ctx.channel().writeAndFlush(new TextWebSocketFrame("Error: 用户信息错误")).addListener(ChannelFutureListener.CLOSE);
                     return;
                 }
-
+                // 加入房间
+                liveWatchUserService.join(fsUser,liveId, userId,"");
+                room.put(userId, ctx.channel());
                 LiveWatchUserVO liveWatchUserVO = liveWatchUserService.selectWatchUserByLiveIdAndUserId(liveId, userId);
 
                 SendMsgVo sendMsgVo = new SendMsgVo();
@@ -154,7 +154,7 @@ public class LiveChatHandler extends SimpleChannelInboundHandler<TextWebSocketFr
      */
     @Override
     protected void channelRead0(ChannelHandlerContext channelHandlerContext, TextWebSocketFrame textWebSocketFrame) throws Exception {
-        log.debug("接收到消息 data: {}", textWebSocketFrame.text());
+
         Long liveId = channelHandlerContext.channel().attr(AttrConstant.ATTR_LIVE_ID).get();
         Long userType = channelHandlerContext.channel().attr(AttrConstant.ATTR_USER_TYPE).get();
 
@@ -175,8 +175,8 @@ public class LiveChatHandler extends SimpleChannelInboundHandler<TextWebSocketFr
                     liveMsg.setCreateTime(new Date());
 
                     if (userType == 0) {
-                        LiveWatchUser liveWatchUser = liveWatchUserService.getByLiveIdAndUserId(msg.getLiveId(), msg.getUserId());
-                        if(liveWatchUser.getMsgStatus() == 1){
+                        List<LiveWatchUser> liveWatchUser = liveWatchUserService.getByLiveIdAndUserId(msg.getLiveId(), msg.getUserId());
+                        if(!liveWatchUser.isEmpty() && liveWatchUser.get(0).getMsgStatus() == 1){
                             sendMessage(channelHandlerContext.channel(), JSONObject.toJSONString(R.error("你以被禁言")));
                             return;
                         }
@@ -203,7 +203,7 @@ public class LiveChatHandler extends SimpleChannelInboundHandler<TextWebSocketFr
      */
     @Override
     public void channelInactive(ChannelHandlerContext ctx) throws Exception {
-        log.debug("断开连接");
+
         Long userId = ctx.channel().attr(AttrConstant.ATTR_USER_ID).get();
         Long liveId = ctx.channel().attr(AttrConstant.ATTR_LIVE_ID).get();
         Long userType = ctx.channel().attr(AttrConstant.ATTR_USER_TYPE).get();
@@ -250,7 +250,6 @@ public class LiveChatHandler extends SimpleChannelInboundHandler<TextWebSocketFr
             roomGroups.remove(liveId);
         }
 
-        log.debug("断开webSocket liveId: {}, userId: {}, 直播间人数: {}", liveId, userId, room.size());
 
     }
 
@@ -262,7 +261,6 @@ public class LiveChatHandler extends SimpleChannelInboundHandler<TextWebSocketFr
      */
     @Override
     public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
-        log.error("连接异常 msg: {}", cause.getMessage(), cause);
         ctx.close();
     }
 }

+ 0 - 1
fs-user-app/src/main/java/com/fs/core/aspectj/LiveWatchUserAspect.java

@@ -35,7 +35,6 @@ public class LiveWatchUserAspect {
         try {
             String methodName = joinPoint.getSignature().getName();
             Object[] args = joinPoint.getArgs();
-            log.info("直播观看用户数据发生变化,方法: {}, 参数: {}", methodName, Arrays.toString(args));
             // 提取liveId并处理缓存更新
             Set<Long> liveIds = extractLiveIds(methodName, args);
             for (Long liveId : liveIds) {