Просмотр исходного кода

Merge remote-tracking branch 'origin/master' into Payment-Configuration

yfh 1 неделя назад
Родитель
Сommit
b1076b00cb
32 измененных файлов с 2466 добавлено и 73 удалено
  1. 1 1
      fs-admin/src/main/java/com/fs/hisStore/task/LiveTask.java
  2. 35 0
      fs-admin/src/main/java/com/fs/live/controller/LiveController.java
  3. 3 1
      fs-common/src/main/java/com/fs/common/constant/LiveKeysConstant.java
  4. 106 0
      fs-company/src/main/java/com/fs/company/controller/live/LiveWatchLogController.java
  5. 112 0
      fs-live-app/src/main/java/com/fs/live/task/Task.java
  6. 284 23
      fs-live-app/src/main/java/com/fs/live/websocket/service/WebSocketServer.java
  7. 13 1
      fs-qw-task/src/main/java/com/fs/app/task/UserCourseWatchCountTask.java
  8. 210 6
      fs-qw-task/src/main/java/com/fs/app/taskService/impl/SopLogsTaskServiceImpl.java
  9. 5 0
      fs-service/src/main/java/com/fs/company/mapper/CompanyMapper.java
  10. 12 0
      fs-service/src/main/java/com/fs/hisStore/mapper/FsStoreProductAttrValueScrmMapper.java
  11. 6 0
      fs-service/src/main/java/com/fs/live/domain/Live.java
  12. 66 0
      fs-service/src/main/java/com/fs/live/domain/LiveTagConfig.java
  13. 89 0
      fs-service/src/main/java/com/fs/live/domain/LiveWatchLog.java
  14. 67 0
      fs-service/src/main/java/com/fs/live/mapper/LiveTagConfigMapper.java
  15. 74 0
      fs-service/src/main/java/com/fs/live/mapper/LiveWatchLogMapper.java
  16. 6 0
      fs-service/src/main/java/com/fs/live/mapper/LiveWatchUserMapper.java
  17. 59 0
      fs-service/src/main/java/com/fs/live/param/LiveIsAddKfParam.java
  18. 7 0
      fs-service/src/main/java/com/fs/live/service/ILiveService.java
  19. 61 0
      fs-service/src/main/java/com/fs/live/service/ILiveWatchLogService.java
  20. 13 0
      fs-service/src/main/java/com/fs/live/service/ILiveWatchUserService.java
  21. 62 6
      fs-service/src/main/java/com/fs/live/service/impl/LiveOrderServiceImpl.java
  22. 51 1
      fs-service/src/main/java/com/fs/live/service/impl/LiveServiceImpl.java
  23. 94 0
      fs-service/src/main/java/com/fs/live/service/impl/LiveWatchLogServiceImpl.java
  24. 449 13
      fs-service/src/main/java/com/fs/live/service/impl/LiveWatchUserServiceImpl.java
  25. 29 0
      fs-service/src/main/java/com/fs/live/vo/HandleUserTagVO.java
  26. 25 0
      fs-service/src/main/java/com/fs/live/vo/LiveTagItemVO.java
  27. 72 0
      fs-service/src/main/java/com/fs/live/vo/LiveWatchUserEntry.java
  28. 102 16
      fs-service/src/main/java/com/fs/sop/service/impl/SopUserLogsInfoServiceImpl.java
  29. 4 4
      fs-service/src/main/resources/application-druid-bjzm-test.yml
  30. 124 0
      fs-service/src/main/resources/mapper/live/LiveTagConfigMapper.xml
  31. 213 0
      fs-service/src/main/resources/mapper/live/LiveWatchLogMapper.xml
  32. 12 1
      fs-user-app/src/main/java/com/fs/app/controller/live/LiveWatchUserController.java

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

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

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

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

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

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

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

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

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

@@ -71,6 +71,8 @@ public class Task {
     private ILiveRedConfService liveRedConfService;
     @Autowired
     private ILiveCouponIssueService liveCouponIssueService;
+    @Autowired
+    private ILiveVideoService liveVideoService;
 
     @Autowired
     public FsJstAftersalePushService fsJstAftersalePushService;
@@ -163,6 +165,34 @@ public class Task {
                         redisCache.redisTemplate.expire(key+live.getLiveId(), 1, TimeUnit.DAYS);
                     });
                 }
+                
+                // 将开启的直播间信息写入Redis缓存,用于打标签定时任务
+                try {
+                    // 获取视频时长
+                    Long videoDuration = 0L;
+                    List<LiveVideo> videos = liveVideoService.listByLiveId(live.getLiveId(), 1);
+                    if (CollUtil.isNotEmpty(videos)) {
+                        videoDuration = videos.stream()
+                                .filter(v -> v.getDuration() != null)
+                                .mapToLong(LiveVideo::getDuration)
+                                .sum();
+                    }
+                    
+                    // 如果视频时长大于0,将直播间信息存入Redis
+                    if (videoDuration > 0 && live.getStartTime() != null) {
+                        Map<String, Object> tagMarkInfo = new HashMap<>();
+                        tagMarkInfo.put("liveId", live.getLiveId());
+                        tagMarkInfo.put("startTime", live.getStartTime().atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli());
+                        tagMarkInfo.put("videoDuration", videoDuration);
+                        
+                        String tagMarkKey = String.format(LiveKeysConstant.LIVE_TAG_MARK_CACHE, live.getLiveId());
+                        redisCache.setCacheObject(tagMarkKey, JSON.toJSONString(tagMarkInfo), 24, TimeUnit.HOURS);
+                        log.info("直播间开启,已加入打标签缓存: liveId={}, startTime={}, videoDuration={}", 
+                                live.getLiveId(), live.getStartTime(), videoDuration);
+                    }
+                } catch (Exception e) {
+                    log.error("写入直播间打标签缓存失败: liveId={}, error={}", live.getLiveId(), e.getMessage(), e);
+                }
             }
             // 重新更新所有在直播的缓存
             liveService.asyncToCache();
@@ -183,6 +213,15 @@ public class Task {
                     });
                 }
                 webSocketServer.removeLikeCountCache(live.getLiveId());
+                
+                // 删除打标签缓存
+                try {
+                    String tagMarkKey = String.format(LiveKeysConstant.LIVE_TAG_MARK_CACHE, live.getLiveId());
+                    redisCache.deleteObject(tagMarkKey);
+                    log.info("直播间结束,已删除打标签缓存: liveId={}", live.getLiveId());
+                } catch (Exception e) {
+                    log.error("删除直播间打标签缓存失败: liveId={}, error={}", live.getLiveId(), e.getMessage(), e);
+                }
             }
             // 重新更新所有在直播的缓存
             liveService.asyncToCache();
@@ -579,4 +618,77 @@ public class Task {
     public void updateRedQuantityNum() {
         liveRedConfService.updateRedQuantityNum();
     }
+
+    /**
+     * 定时扫描开启的直播间,检查是否到了打标签的时间
+     * 每10秒执行一次
+     */
+    @Scheduled(cron = "0/10 * * * * ?")
+    @DistributeLock(key = "scanLiveTagMark", scene = "task")
+    public void scanLiveTagMark() {
+        try {
+            // 获取所有打标签缓存的key
+            String pattern = String.format(LiveKeysConstant.LIVE_TAG_MARK_CACHE, "*");
+            Set<String> keys = redisCache.redisTemplate.keys(pattern);
+            
+            if (keys == null || keys.isEmpty()) {
+                return;
+            }
+            
+            long currentTimeMillis = System.currentTimeMillis();
+            List<Long> processedLiveIds = new ArrayList<>();
+            
+            for (String key : keys) {
+                try {
+                    // 从Redis获取直播间信息
+                    Object cacheValue = redisCache.getCacheObject(key);
+                    if (cacheValue == null) {
+                        continue;
+                    }
+                    
+                    String jsonStr = cacheValue.toString();
+                    JSONObject tagMarkInfo = JSON.parseObject(jsonStr);
+                    Long liveId = tagMarkInfo.getLong("liveId");
+                    Long startTimeMillis = tagMarkInfo.getLong("startTime");
+                    Long videoDuration = tagMarkInfo.getLong("videoDuration");
+                    
+                    if (liveId == null || startTimeMillis == null || videoDuration == null || videoDuration <= 0) {
+                        log.warn("直播间打标签缓存信息不完整: key={}, liveId={}, startTime={}, videoDuration={}", 
+                                key, liveId, startTimeMillis, videoDuration);
+                        continue;
+                    }
+                    
+                    // 计算结束时间:开始时间 + 视频时长(秒转毫秒)
+                    long endTimeMillis = startTimeMillis + (videoDuration * 1000);
+                    
+                    // 如果当前时间已经超过了结束时间,执行打标签操作
+                    if (currentTimeMillis >= endTimeMillis) {
+                        log.info("直播间视频播放完成,开始打标签: liveId={}, startTime={}, videoDuration={}, endTime={}, currentTime={}", 
+                                liveId, startTimeMillis, videoDuration, endTimeMillis, currentTimeMillis);
+                        
+                        // 调用打标签方法
+                        liveWatchUserService.qwTagMarkByLiveWatchLog(liveId);
+                        
+                        // 标记为已处理,稍后删除缓存
+                        processedLiveIds.add(liveId);
+                    }
+                } catch (Exception e) {
+                    log.error("处理直播间打标签缓存异常: key={}, error={}", key, e.getMessage(), e);
+                }
+            }
+            
+            // 删除已处理的直播间缓存
+            for (Long liveId : processedLiveIds) {
+                try {
+                    String tagMarkKey = String.format(LiveKeysConstant.LIVE_TAG_MARK_CACHE, liveId);
+                    redisCache.deleteObject(tagMarkKey);
+                    log.info("已删除已处理的直播间打标签缓存: liveId={}", liveId);
+                } catch (Exception e) {
+                    log.error("删除直播间打标签缓存失败: liveId={}, error={}", liveId, e.getMessage(), e);
+                }
+            }
+        } catch (Exception e) {
+            log.error("扫描直播间打标签任务异常: error={}", e.getMessage(), e);
+        }
+    }
 }

+ 284 - 23
fs-live-app/src/main/java/com/fs/live/websocket/service/WebSocketServer.java

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

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

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

+ 210 - 6
fs-qw-task/src/main/java/com/fs/app/taskService/impl/SopLogsTaskServiceImpl.java

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -4,6 +4,8 @@ package com.fs.live.service;
 import com.fs.common.core.domain.R;
 import com.fs.hisStore.domain.FsUserScrm;
 import com.fs.live.domain.LiveWatchUser;
+import com.fs.live.param.LiveIsAddKfParam;
+import com.fs.live.vo.LiveWatchUserEntry;
 import com.fs.live.vo.LiveWatchUserVO;
 
 import java.util.Date;
@@ -126,4 +128,15 @@ public interface ILiveWatchUserService {
     void updateSingleVisible(long liveId, Integer status,long userId);
 
     LiveWatchUser selectLiveWatchUserByFlag(Long liveId, Long userId, Integer liveFlag, Integer replayFlag);
+
+    R liveIsAddKf(LiveIsAddKfParam param);
+
+    LiveWatchUserEntry selectLiveWatchAndCompanyUserByFlag(Long liveId, Long userId, Integer liveFlag, Integer replayFlag);
+
+    void updateLiveWatchUserEntry(LiveWatchUserEntry liveWatchUser);
+
+    /**
+     * 根据用户直播看课记录来打标签
+     */
+    void qwTagMarkByLiveWatchLog(Long liveId);
 }

+ 62 - 6
fs-service/src/main/java/com/fs/live/service/impl/LiveOrderServiceImpl.java

@@ -749,6 +749,7 @@ public class LiveOrderServiceImpl implements ILiveOrderService {
             order.setIsPay("1");
             baseMapper.updateLiveOrder(order);
             try {
+                this.updateLiveWatchLog(order);
                 this.createOmsOrderCall(order);
             } catch (Exception e) {
                 log.error("推送erp失败:{}",e.getMessage());
@@ -767,6 +768,38 @@ public class LiveOrderServiceImpl implements ILiveOrderService {
         }
         return "SUCCESS";
     }
+    @Autowired
+    private ILiveWatchUserService liveWatchUserService;
+    @Autowired
+    private ILiveWatchLogService liveWatchLogService;
+
+    private void updateLiveWatchLog(LiveOrder order) {
+        if (order.getCompanyId() != null && order.getCompanyUserId() != null && order.getCompanyId() > 0 && order.getCompanyUserId() > 0) {
+            Map<String, Integer> liveFlagWithCache = liveWatchUserService.getLiveFlagWithCache(order.getLiveId());
+            if (liveFlagWithCache != null && liveFlagWithCache.containsKey("liveFlag") && 1 == liveFlagWithCache.get("liveFlag")) {
+                try {
+                    LiveWatchLog queryLog = new LiveWatchLog();
+                    queryLog.setLiveId(order.getLiveId());
+                    queryLog.setUserId(Long.valueOf(order.getUserId()));
+                    queryLog.setCompanyId(order.getCompanyId());
+                    queryLog.setCompanyUserId(order.getCompanyUserId());
+
+                    List<LiveWatchLog> logs = liveWatchLogService.selectLiveWatchLogList(queryLog);
+                    if (logs != null && !logs.isEmpty()) {
+                        for (LiveWatchLog log : logs) {
+                            if (log.getLogType() == null || log.getLogType() != 2) {
+                                log.setLiveBuy(1);
+                                liveWatchLogService.updateLiveWatchLog(log);
+                            }
+                        }
+                    }
+                } catch (Exception e) {
+                    log.error("更新 updateLiveWatchLog LiveWatchLog logType 异常(连接时):liveId={}, userId={}, error={}",
+                            order.getLiveId(), order.getUserId(), e.getMessage(), e);
+                }
+            }
+        }
+    }
 
     @Override
     @Transactional(rollbackFor = Throwable.class,propagation = Propagation.REQUIRED)
@@ -1916,12 +1949,23 @@ public class LiveOrderServiceImpl implements ILiveOrderService {
         FsStoreProductScrm fsStoreProduct = fsStoreProductService.selectFsStoreProductById(liveOrder.getProductId());
         LiveGoods goods = liveGoodsMapper.selectLiveGoodsByProductId(liveOrder.getLiveId(), liveOrder.getProductId());
         if(goods == null) return R.error("当前商品不存在");
+        FsStoreProductAttrValueScrm attrValue = null;
+        if (!Objects.isNull(liveOrder.getAttrValueId())) {
+            attrValue = fsStoreProductAttrValueMapper.selectFsStoreProductAttrValueById(liveOrder.getAttrValueId());
+        }
+        if (attrValue != null) {
+            attrValue.setStock(attrValue.getStock() - Integer.parseInt(liveOrder.getTotalNum()));
+            attrValue.setSales(attrValue.getSales() + Integer.parseInt(liveOrder.getTotalNum()));
+            fsStoreProductAttrValueMapper.updateFsStoreProductAttrValue(attrValue);
+        }
 
         // 更改店铺库存
         fsStoreProduct.setStock(fsStoreProduct.getStock()-Integer.parseInt(liveOrder.getTotalNum()));
+        fsStoreProduct.setSales(fsStoreProduct.getSales()+Integer.parseInt(liveOrder.getTotalNum()));
         fsStoreProductService.updateFsStoreProduct(fsStoreProduct);
         // 更新直播间库存
         goods.setStock(goods.getStock()-Integer.parseInt(liveOrder.getTotalNum()));
+        goods.setSales(goods.getSales()+Integer.parseInt(liveOrder.getTotalNum()));
         liveGoodsMapper.updateLiveGoods(goods);
 
         //判断是否是三种特定产品
@@ -1979,7 +2023,7 @@ public class LiveOrderServiceImpl implements ILiveOrderService {
                 dto.setImage(fsStoreProduct.getImage());
                 dto.setSku(String.valueOf(fsStoreProduct.getStock()));
                 if (StringUtils.isEmpty(fsStoreProduct.getBarCode())) {
-                    FsStoreProductAttrValueScrm fsStoreProductAttrValue = fsStoreProductAttrValueMapper.selectFsStoreProductAttrValueByProductId(fsStoreProduct.getProductId()).stream().filter(attrValue -> StringUtils.isNotEmpty(attrValue.getBarCode())).findFirst().orElse(null);
+                    FsStoreProductAttrValueScrm fsStoreProductAttrValue = fsStoreProductAttrValueMapper.selectFsStoreProductAttrValueByProductId(fsStoreProduct.getProductId()).stream().filter(attr -> StringUtils.isNotEmpty(attr.getBarCode())).findFirst().orElse(null);
                     if (fsStoreProductAttrValue != null) {
                         dto.setBarCode(fsStoreProductAttrValue.getBarCode());
                     }
@@ -3520,12 +3564,11 @@ public class LiveOrderServiceImpl implements ILiveOrderService {
             attrValue.setStock(attrValue.getStock() - Integer.parseInt(liveOrder.getTotalNum()));
             attrValue.setSales(attrValue.getSales() + Integer.parseInt(liveOrder.getTotalNum()));
             fsStoreProductAttrValueMapper.updateFsStoreProductAttrValue(attrValue);
-        } else {
-            // 更改店铺库存
-            fsStoreProduct.setStock(fsStoreProduct.getStock()-Integer.parseInt(liveOrder.getTotalNum()));
-            fsStoreProduct.setSales(fsStoreProduct.getSales()+Integer.parseInt(liveOrder.getTotalNum()));
-            fsStoreProductService.updateFsStoreProduct(fsStoreProduct);
         }
+        // 更改店铺库存
+        fsStoreProduct.setStock(fsStoreProduct.getStock()-Integer.parseInt(liveOrder.getTotalNum()));
+        fsStoreProduct.setSales(fsStoreProduct.getSales()+Integer.parseInt(liveOrder.getTotalNum()));
+        fsStoreProductService.updateFsStoreProduct(fsStoreProduct);
 
         // 更新直播间库存
         goods.setStock(goods.getStock()-Integer.parseInt(liveOrder.getTotalNum()));
@@ -3775,6 +3818,19 @@ public class LiveOrderServiceImpl implements ILiveOrderService {
             FsStoreProductScrm fsStoreProduct = fsStoreProductService.selectFsStoreProductById(liveOrder.getProductId());
             LiveGoods goods = liveGoodsMapper.selectLiveGoodsByProductId(liveOrder.getLiveId(), liveOrder.getProductId());
             fsStoreProduct.setStock(fsStoreProduct.getStock()+Integer.parseInt(liveOrder.getTotalNum()));
+            List<LiveOrderItem> liveOrderItems = liveOrderItemMapper.selectCheckedByOrderId(order.getOrderId());
+            List<String> barCodeList = new ArrayList<>();
+            //更新规格库存
+            for (LiveOrderItem item : liveOrderItems) {
+                FsStoreProduct cartDTO = JSONUtil.toBean(item.getJsonInfo(), FsStoreProduct.class);
+                if (StringUtils.isNotEmpty(cartDTO.getBarCode())) {
+                    barCodeList.add(cartDTO.getBarCode());
+                }
+            }
+            if (!barCodeList.isEmpty()) {
+                attrValueScrmMapper.incStock(fsStoreProduct.getProductId(), barCodeList, liveOrder.getTotalNum());
+            }
+
             // 更新商品库存
             fsStoreProductService.updateFsStoreProduct(fsStoreProduct);
             goods.setStock(goods.getStock()+Long.parseLong(liveOrder.getTotalNum()));

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

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

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

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

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

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

+ 29 - 0
fs-service/src/main/java/com/fs/live/vo/HandleUserTagVO.java

@@ -0,0 +1,29 @@
+package com.fs.live.vo;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * @author MixLiu
+ * @date 2025/12/13 下午6:40)
+ */
+
+@Data
+public class HandleUserTagVO {
+
+    /**
+     * 直播间id
+     */
+    private Long liveId;
+
+    /**
+     * 外部联系人id
+     */
+    private Long externalId;
+
+    /**
+     * 打标签列表
+     */
+    private List<String> tags;
+}

+ 25 - 0
fs-service/src/main/java/com/fs/live/vo/LiveTagItemVO.java

@@ -0,0 +1,25 @@
+package com.fs.live.vo;
+
+import lombok.Data;
+
+/**
+ * @author MixLiu
+ * @date 2025/12/13 下午4:43)
+ */
+@Data
+public class LiveTagItemVO {
+
+   private Long id;
+    //公司id
+   private Integer companyId;
+   //主体id
+   private String  corpId;
+   //标记类型
+   private Integer markType;
+   //标签id
+   private Long qwTagId;
+   //企微标签id
+   private String qwTagRealId;
+   //标签名称
+   private String qwTagName;
+}

+ 72 - 0
fs-service/src/main/java/com/fs/live/vo/LiveWatchUserEntry.java

@@ -0,0 +1,72 @@
+package com.fs.live.vo;
+
+
+import com.fs.common.annotation.Excel;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 直播间观看用户对象 live_watch_user
+ *
+ * @author fs
+ * @date 2025-01-18
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class LiveWatchUserEntry extends BaseEntity {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long id;
+
+    /** 直播ID */
+    @Excel(name = "直播ID")
+    private Long liveId;
+
+    /** 用户ID */
+    @Excel(name = "用户ID")
+    private Long userId;
+
+    @Excel(name = "用户头像")
+    private String avatar;
+
+    /** 消息状态;0正常1禁言 */
+    @Excel(name = "消息状态;0正常1禁言")
+    private Integer msgStatus;
+
+    /** 在线状态;0在线1离线 */
+    @Excel(name = "在线状态;0在线1离线")
+    private Integer online = 0;
+    /** 全局用户自见 */
+    private Integer globalVisible = 0;
+    /** 用户自见 */
+    private Integer singleVisible = 0;
+
+    private Long onlineSeconds;
+
+    /** 用户名字 */
+
+    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 Long companyId;
+    private Long companyUserId;
+
+}

+ 102 - 16
fs-service/src/main/java/com/fs/sop/service/impl/SopUserLogsInfoServiceImpl.java

@@ -30,6 +30,8 @@ import com.fs.course.service.IFsCourseLinkService;
 import com.fs.course.service.IFsUserCourseVideoService;
 import com.fs.fastGpt.domain.FastGptChatReplaceWords;
 import com.fs.fastGpt.mapper.FastGptChatReplaceWordsMapper;
+import com.fs.live.domain.LiveWatchLog;
+import com.fs.live.mapper.LiveWatchLogMapper;
 import com.fs.qw.domain.*;
 import com.fs.qw.mapper.*;
 import com.fs.qw.param.QwExtCourseSopWatchLog;
@@ -170,6 +172,9 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
     @Autowired
     private IQwSopTempVoiceService sopTempVoiceService;
 
+    @Autowired
+    LiveWatchLogMapper liveWatchLogMapper;
+
 
 
     @Override
@@ -511,20 +516,25 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                     e.setUserList(userMap.getOrDefault(e.getUserId(), Collections.emptyList()));
                 });
             }
-            try {
-                groupList.forEach(groupChat -> {
-                    QwUser qwUser = qwUserMapper.selectQwUserByIdByWeComeText2(groupChat.getOwner(), groupChat.getCorpId());
-                    groupChat.getChatUserList().stream().filter(e -> e.getUserList() != null && !e.getUserList().isEmpty()).forEach(e -> {
-                        Map<String, GroupUserExternalVo> userMap = PubFun.listToMapByGroupObject(e.getUserList(), GroupUserExternalVo::getUserId);
-                        GroupUserExternalVo vo = userMap.get(groupChat.getOwner());
-                        if (vo != null && vo.getId() != null) {
-                            addWatchLogIfNeeded(param.getSopId(), param.getVideoId(), param.getCourseId(), vo.getFsUserId(), qwUser.getId().toString(), qwUser.getCompanyUserId().toString(), qwUser.getCompanyId().toString(), vo.getId(), param.getStartTime(), createTime);
-                        }
+
+            //没有传值课程和课节 是直播的数据
+            if(null != param.getCourseId() && null !=param.getVideoId()){
+                try {
+                    groupList.forEach(groupChat -> {
+                        QwUser qwUser = qwUserMapper.selectQwUserByIdByWeComeText2(groupChat.getOwner(), groupChat.getCorpId());
+                        groupChat.getChatUserList().stream().filter(e -> e.getUserList() != null && !e.getUserList().isEmpty()).forEach(e -> {
+                            Map<String, GroupUserExternalVo> userMap = PubFun.listToMapByGroupObject(e.getUserList(), GroupUserExternalVo::getUserId);
+                            GroupUserExternalVo vo = userMap.get(groupChat.getOwner());
+                            if (vo != null && vo.getId() != null) {
+                                addWatchLogIfNeeded(param.getSopId(), param.getVideoId(), param.getCourseId(), vo.getFsUserId(), qwUser.getId().toString(), qwUser.getCompanyUserId().toString(), qwUser.getCompanyId().toString(), vo.getId(), param.getStartTime(), createTime);
+                            }
+                        });
                     });
-                });
-            } catch (Exception e) {
-                log.error("群聊创建看课记录失败!", e);
+                } catch (Exception e) {
+                    log.error("群聊创建看课记录失败!", e);
+                }
             }
+
             if (param.getSendType() != null && param.getSendType() == 2) {
                 sopLogsList = groupUserList.stream().map(groupUser -> {
                     QwGroupChat qwGroupChat = groupMap.get(groupUser.getChatId());
@@ -666,10 +676,19 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                 break;
                             //直播小程序单独
                             case "12":
-                                String sortLiveLink = "/pages_course/living?companyId=" + qwUser.getCompanyUserId() + "&companyUserId=" + companyUserId + "&liveId=" + st.getLiveId();
+                                String sortLiveLink = "/pages_course/living.html?companyId=" + qwUser.getCompanyUserId() + "&companyUserId=" + companyUserId + "&liveId=" + st.getLiveId() + "&corpId=" + param.getCorpId()+"&qwUserId=" + qwUser.getId() +"&externalId=" + vo.getId().toString();
                                 st.setContentType("4");
                                 String js = configService.selectConfigByKey("his.config");
                                 FSSysConfig sysConfig= JSON.parseObject(js,FSSysConfig.class);
+                                //todo 发个人看课记录处理
+                                try {
+                                    if (vo != null && vo.getId() != null) {
+                                        createLiveWatchLogAndInsert(qwUser.getCompanyId().toString(), qwUser.getCompanyUserId().toString(),vo.getId().toString(),Long.valueOf(st.getLiveId()),sysConfig.getAppId(),2, qwUser.getId().toString(),param.getCorpId());
+                                    }
+                                } catch (Exception e) {
+                                    log.error("群聊创建直播看课记录失败!", e);
+                                }
+//                                createLiveWatchLogAndInsert();
                                 st.setMiniprogramAppid(sysConfig.getAppId());
                                 st.setMiniprogramPage(sortLiveLink);
                                 break;
@@ -809,10 +828,25 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                 break;
                             //直播小程序单独
                             case "12":
-                                String sortLiveLink = "/pages_course/living?companyId=" + qwUser.getCompanyUserId() + "&companyUserId=" + qwUser.getCompanyUserId() + "&liveId=" + st.getLiveId();
+                                String sortLiveLink = "/pages_course/living.html?companyId=" + qwUser.getCompanyUserId() + "&companyUserId=" + qwUser.getCompanyUserId() + "&liveId=" + st.getLiveId() + "&corpId=" +param.getCorpId()+"&qwUserId=" + qwUser.getId() + "&chatId=" + groupChat.getChatId();
                                 st.setContentType("4");
                                 String js = configService.selectConfigByKey("his.config");
                                 FSSysConfig sysConfig= JSON.parseObject(js,FSSysConfig.class);
+                                //发群处理看课记录
+//                                createLiveWatchLogAndInsert();
+                                try {
+                                    groupList.forEach(gc -> {
+                                        gc.getChatUserList().stream().filter(e -> e.getUserList() != null && !e.getUserList().isEmpty()).forEach(e -> {
+                                            Map<String, GroupUserExternalVo> userMap = PubFun.listToMapByGroupObject(e.getUserList(), GroupUserExternalVo::getUserId);
+                                            GroupUserExternalVo vo = userMap.get(groupChat.getOwner());
+                                            if (vo != null && vo.getId() != null) {
+                                                createLiveWatchLogAndInsert(qwUser.getCompanyId().toString(), qwUser.getCompanyUserId().toString(),vo.getId().toString(),Long.valueOf(st.getLiveId()),sysConfig.getAppId(),2, qwUser.getId().toString(),param.getCorpId());
+                                            }
+                                        });
+                                    });
+                                } catch (Exception e) {
+                                    log.error("群聊创建直播看课记录失败!", e);
+                                }
                                 st.setMiniprogramAppid(sysConfig.getAppId());
                                 st.setMiniprogramPage(sortLiveLink);
                                 break;
@@ -1013,10 +1047,17 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                             break;
                         //直播小程序单独
                         case "12":
-                            String sortLiveLink = "/pages_course/living?companyId=" + qwUser.getCompanyUserId() + "&companyUserId=" + qwUser.getCompanyUserId() + "&liveId=" + st.getLiveId();
+                            String sortLiveLink = "/pages_course/living.html?companyId=" + qwUser.getCompanyUserId() + "&companyUserId=" + qwUser.getCompanyUserId() + "&liveId=" + st.getLiveId() + "&corpId=" + param.getCorpId()+"&qwUserId=" + qwUserId +"&externalId=" + item.getExternalId().toString();
                             st.setContentType("4");
                             String js = configService.selectConfigByKey("his.config");
                             FSSysConfig sysConfig= JSON.parseObject(js,FSSysConfig.class);
+                            //todo 发个人看课记录处理
+                            try {
+                                    createLiveWatchLogAndInsert(qwUser.getCompanyId().toString(), qwUser.getCompanyUserId().toString(),item.getExternalId().toString(),Long.valueOf(st.getLiveId()),sysConfig.getAppId(),2, qwUserId,param.getCorpId());
+
+                            } catch (Exception e) {
+                                log.error("群聊创建直播看课记录失败!", e);
+                            }
                             st.setMiniprogramAppid(sysConfig.getAppId());
                             st.setMiniprogramPage(sortLiveLink);
                             break;
@@ -1474,14 +1515,21 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                 //直播小程序单独
                 case "12":
                     String sortLiveLink;
-                    sortLiveLink = "/pages_course/living?companyId=" + companyId + "&companyUserId=" + companyUserId + "&liveId=" + st.getLiveId();
+                    sortLiveLink = "/pages_course/living.html?companyId=" + companyId + "&companyUserId=" + companyUserId + "&liveId=" + st.getLiveId() + "&corpId=" + param.getCorpId()+"&qwUserId=" + qwUser.getId() +"&externalId=" + item.getExternalId().toString();
 
 
                     String miniprogramLiveTitle = st.getMiniprogramTitle();
                     int maxLiveLength = 17;
                     st.setMiniprogramTitle(miniprogramLiveTitle.length() > maxLiveLength ? miniprogramLiveTitle.substring(0, maxLiveLength) + "..." : miniprogramLiveTitle);
+
                     String json = configService.selectConfigByKey("his.config");
                     FSSysConfig sysConfig= JSON.parseObject(json,FSSysConfig.class);
+                    //todo 发个人看课记录处理
+                    try {
+                        createLiveWatchLogAndInsert(qwUser.getCompanyId().toString(), qwUser.getCompanyUserId().toString(),item.getExternalId().toString(),Long.valueOf(st.getLiveId()),sysConfig.getAppId(),2, String.valueOf(qwUser.getId()),param.getCorpId());
+                    } catch (Exception e) {
+                        log.error("群聊创建直播看课记录失败!", e);
+                    }
                     st.setMiniprogramAppid(sysConfig.getAppId());
                     st.setMiniprogramPage(sortLiveLink);
                     st.setContentType("4");
@@ -1741,5 +1789,43 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
         return Date.from(localDateTime.atZone(ZoneId.systemDefault()).toInstant());
     }
 
+    /**
+     * 直播看课记录处理
+     * @param companyId
+     * @param companyUserId
+     * @param externalId
+     * @param liveId
+     * @param appId
+     * @param logSource
+     * @param qwUserId
+     * @param corpId
+     */
+    public void createLiveWatchLogAndInsert(String companyId,String companyUserId,String externalId,Long liveId,String appId,Integer logSource,String qwUserId,String corpId){
+        try{
+            // 写入对应数据源的记录表
+            LiveWatchLog itemLiveWatchLog = new LiveWatchLog();
+            itemLiveWatchLog.setLiveId(liveId);
+            itemLiveWatchLog.setLogType(3);
+            itemLiveWatchLog.setSopCreateTime(new Date());
+            itemLiveWatchLog.setCompanyId(Long.valueOf(companyId));
+            itemLiveWatchLog.setCompanyUserId(Long.valueOf(companyUserId));
+            itemLiveWatchLog.setSendAppId(appId);
+            itemLiveWatchLog.setLogSource(logSource);
+            itemLiveWatchLog.setQwUserId(qwUserId);
+            itemLiveWatchLog.setExternalContactId(Long.valueOf(externalId));
+            itemLiveWatchLog.setCorpId(corpId);
+            if(liveWatchLogMapper.updateLiveWatchLogCondition(itemLiveWatchLog) > 0){
+
+            }else{
+                List<LiveWatchLog> handleList= new ArrayList<>();
+                handleList.add(itemLiveWatchLog);
+                liveWatchLogMapper.insertLiveWatchLogBatch(handleList);
+            }
+        }catch(Exception e){
+            log.error("创建直播看课记录失败:{}",e.getMessage());
+        }
+
+
+    }
 
 }

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

@@ -45,10 +45,10 @@ spring:
                 # 从库数据源
                 slave:
                     # 从数据源开关/默认关闭
-                    enabled: false
-                    url:
-                    username:
-                    password:
+                    enabled: true
+                    url: jdbc:mysql://gz-cdb-ofgnuz1n.sql.tencentcdb.com:26872/fs_his?allowMultiQueries=true&useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
+                    username: root
+                    password: Ylrz_1q2w3e4r5t6y
                 # 初始连接数
                 initialSize: 5
                 # 最小连接池数量

+ 124 - 0
fs-service/src/main/resources/mapper/live/LiveTagConfigMapper.xml

@@ -0,0 +1,124 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.fs.live.mapper.LiveTagConfigMapper">
+    
+    <resultMap type="LiveTagConfig" id="LiveTagConfigResult">
+        <result property="id"    column="id"    />
+        <result property="liveId"    column="live_id"    />
+        <result property="corpId"    column="corp_id"    />
+        <result property="companyId"    column="company_id"    />
+        <result property="markType"    column="mark_type"    />
+        <result property="qwTagId"    column="qw_tag_id"    />
+        <result property="qwTagName"    column="qw_tag_name"    />
+        <result property="qwTagRealId"    column="qw_tag_real_id"    />
+        <result property="createTime"    column="create_time"    />
+        <result property="updateTime"    column="update_time"    />
+        <result property="createUserId"    column="create_user_id"    />
+        <result property="createUserName"    column="create_user_name"    />
+        <result property="updateUserId"    column="update_user_id"    />
+        <result property="updateUserName"    column="update_user_name"    />
+    </resultMap>
+
+    <sql id="selectLiveTagConfigVo">
+        select id, live_id, corp_id, company_id, mark_type, qw_tag_id, qw_tag_name, qw_tag_real_id, create_time, update_time, create_user_id, create_user_name, update_user_id, update_user_name from live_tag_config
+    </sql>
+
+    <select id="selectLiveTagConfigList" parameterType="LiveTagConfig" resultMap="LiveTagConfigResult">
+        <include refid="selectLiveTagConfigVo"/>
+        <where>  
+            <if test="liveId != null "> and live_id = #{liveId}</if>
+            <if test="corpId != null  and corpId != ''"> and corp_id = #{corpId}</if>
+            <if test="companyId != null "> and company_id = #{companyId}</if>
+            <if test="markType != null "> and mark_type = #{markType}</if>
+            <if test="qwTagId != null "> and qw_tag_id = #{qwTagId}</if>
+            <if test="qwTagName != null "> and qw_tag_name = #{qwTagName}</if>
+            <if test="qwTagRealId != null "> and qw_tag_real_id = #{qwTagRealId}</if>
+            <if test="createUserId != null "> and create_user_id = #{createUserId}</if>
+            <if test="createUserName != null  and createUserName != ''"> and create_user_name like concat('%', #{createUserName}, '%')</if>
+            <if test="updateUserId != null "> and update_user_id = #{updateUserId}</if>
+            <if test="updateUserName != null  and updateUserName != ''"> and update_user_name like concat('%', #{updateUserName}, '%')</if>
+        </where>
+    </select>
+    
+    <select id="selectLiveTagConfigById" parameterType="Long" resultMap="LiveTagConfigResult">
+        <include refid="selectLiveTagConfigVo"/>
+        where id = #{id}
+    </select>
+        
+    <insert id="insertLiveTagConfig" parameterType="LiveTagConfig">
+        insert into live_tag_config
+        <trim prefix="(" suffix=")" suffixOverrides=",">
+            <if test="id != null">id,</if>
+            <if test="liveId != null">live_id,</if>
+            <if test="corpId != null">corp_id,</if>
+            <if test="companyId != null">company_id,</if>
+            <if test="markType != null">mark_type,</if>
+            <if test="qwTagId != null">qw_tag_id,</if>
+            <if test="qwTagName != null">qw_tag_name,</if>
+            <if test="qwTagRealId != null">qw_tag_real_id,</if>
+            <if test="createTime != null">create_time,</if>
+            <if test="updateTime != null">update_time,</if>
+            <if test="createUserId != null">create_user_id,</if>
+            <if test="createUserName != null">create_user_name,</if>
+            <if test="updateUserId != null">update_user_id,</if>
+            <if test="updateUserName != null">update_user_name,</if>
+         </trim>
+        <trim prefix="values (" suffix=")" suffixOverrides=",">
+            <if test="id != null">#{id},</if>
+            <if test="liveId != null">#{liveId},</if>
+            <if test="corpId != null">#{corpId},</if>
+            <if test="companyId != null">#{companyId},</if>
+            <if test="markType != null">#{markType},</if>
+            <if test="qwTagId != null">#{qwTagId},</if>
+            <if test="qwTagName != null">#{qwTagName},</if>
+            <if test="qwTagRealId != null">#{qwTagRealId},</if>
+            <if test="createTime != null">#{createTime},</if>
+            <if test="updateTime != null">#{updateTime},</if>
+            <if test="createUserId != null">#{createUserId},</if>
+            <if test="createUserName != null">#{createUserName},</if>
+            <if test="updateUserId != null">#{updateUserId},</if>
+            <if test="updateUserName != null">#{updateUserName},</if>
+         </trim>
+    </insert>
+
+    <update id="updateLiveTagConfig" parameterType="LiveTagConfig">
+        update live_tag_config
+        <trim prefix="SET" suffixOverrides=",">
+            <if test="liveId != null">live_id = #{liveId},</if>
+            <if test="corpId != null">corp_id = #{corpId},</if>
+            <if test="companyId != null">company_id = #{companyId},</if>
+            <if test="markType != null">mark_type = #{markType},</if>
+            <if test="qwTagId != null">qw_tag_id = #{qwTagId},</if>
+            <if test="qwTagName != null">qw_tag_name = #{qwTagName},</if>
+            <if test="qwTagRealId != null">qw_tag_real_id = #{qwTagRealId},</if>
+            <if test="createTime != null">create_time = #{createTime},</if>
+            <if test="updateTime != null">update_time = #{updateTime},</if>
+            <if test="createUserId != null">create_user_id = #{createUserId},</if>
+            <if test="createUserName != null">create_user_name = #{createUserName},</if>
+            <if test="updateUserId != null">update_user_id = #{updateUserId},</if>
+            <if test="updateUserName != null">update_user_name = #{updateUserName},</if>
+        </trim>
+        where id = #{id}
+    </update>
+
+    <delete id="deleteLiveTagConfigById" parameterType="Long">
+        delete from live_tag_config where id = #{id}
+    </delete>
+
+    <delete id="deleteLiveTagConfigByIds" parameterType="String">
+        delete from live_tag_config where id in 
+        <foreach item="id" collection="array" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </delete>
+
+    <delete id="deleteByLiveId" parameterType="Long">
+        delete from live_tag_config where live_id = #{liveId}
+    </delete>
+
+    <select id="getLiveTagListByliveId" parameterType="Long" resultType="com.fs.live.vo.LiveTagItemVO">
+        select * from live_tag_config where live_id = #{liveId}
+    </select>
+</mapper>

+ 213 - 0
fs-service/src/main/resources/mapper/live/LiveWatchLogMapper.xml

@@ -0,0 +1,213 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.fs.live.mapper.LiveWatchLogMapper">
+
+
+    <resultMap type="LiveWatchLog" id="LiveWatchLogResult">
+        <result property="logId"    column="log_id"    />
+        <result property="userId"    column="user_id"    />
+        <result property="liveId"    column="live_id"    />
+        <result property="logType"    column="log_type"    />
+        <result property="createTime"    column="create_time"    />
+        <result property="updateTime"    column="update_time"    />
+        <result property="externalContactId"    column="external_contact_id"    />
+        <result property="companyUserId"    column="company_user_id"    />
+        <result property="companyId"    column="company_id"    />
+        <result property="finishTime"    column="finish_time"    />
+        <result property="createBy"    column="create_by"    />
+        <result property="sopCreateTime"    column="sop_create_time"    />
+        <result property="sendAppId"    column="send_app_id"    />
+        <result property="logSource"    column="log_source"    />
+        <result property="qwUserId"    column="qw_user_id"    />
+        <result property="watchType"    column="watch_type"    />
+        <result property="corpId"    column="corp_id"    />
+        <result property="liveBuy"    column="live_buy"    />
+        <result property="replayBuy"    column="replay_buy"    />
+    </resultMap>
+
+    <sql id="selectLiveWatchLogVo">
+        select log_id, user_id, live_id, log_type, create_time, update_time, external_contact_id, company_user_id, company_id, finish_time, create_by, sop_create_time,live_buy,replay_buy,
+               send_app_id, log_source, qw_user_id,watch_type,corp_id from live_watch_log
+    </sql>
+
+    <select id="selectLiveWatchLogList" parameterType="LiveWatchLog" resultMap="LiveWatchLogResult">
+        <include refid="selectLiveWatchLogVo"/>
+        <where>
+            <if test="userId != null "> and user_id = #{userId}</if>
+            <if test="liveId != null "> and live_id = #{liveId}</if>
+            <if test="logType != null "> and log_type = #{logType}</if>
+            <if test="externalContactId != null "> and external_contact_id = #{externalContactId}</if>
+            <if test="companyUserId != null "> and company_user_id = #{companyUserId}</if>
+            <if test="companyId != null "> and company_id = #{companyId}</if>
+            <if test="finishTime != null "> and finish_time = #{finishTime}</if>
+            <if test="sopCreateTime != null "> and sop_create_time = #{sopCreateTime}</if>
+            <if test="sendAppId != null  and sendAppId != ''"> and send_app_id = #{sendAppId}</if>
+            <if test="logSource != null "> and log_source = #{logSource}</if>
+            <if test="qwUserId != null  and qwUserId != ''"> and qw_user_id = #{qwUserId}</if>
+            <if test="watchType != null">and watch_type = #{watchType} </if>
+            <if test="corpId != null">and corp_id = #{corpId} </if>
+            <if test="liveBuy != null">and live_buy = #{liveBuy} </if>
+            <if test="replayBuy != null">and replay_buy = #{replayBuy} </if>
+        </where>
+    </select>
+
+    <select id="selectLiveWatchLogByLogId" parameterType="Long" resultMap="LiveWatchLogResult">
+        <include refid="selectLiveWatchLogVo"/>
+        where log_id = #{logId}
+    </select>
+
+    <insert id="insertLiveWatchLog" parameterType="LiveWatchLog" useGeneratedKeys="true" keyProperty="logId">
+        insert into live_watch_log
+        <trim prefix="(" suffix=")" suffixOverrides=",">
+            <if test="userId != null">user_id,</if>
+            <if test="liveId != null">live_id,</if>
+            <if test="logType != null">log_type,</if>
+            <if test="createTime != null">create_time,</if>
+            <if test="updateTime != null">update_time,</if>
+            <if test="externalContactId != null">external_contact_id,</if>
+            <if test="companyUserId != null">company_user_id,</if>
+            <if test="companyId != null">company_id,</if>
+            <if test="finishTime != null">finish_time,</if>
+            <if test="createBy != null">create_by,</if>
+            <if test="sopCreateTime != null">sop_create_time,</if>
+            <if test="sendAppId != null">send_app_id,</if>
+            <if test="logSource != null">log_source,</if>
+            <if test="qwUserId != null">qw_user_id,</if>
+            <if test="watchType != null">watch_type,</if>
+            <if test="corpId != null">corp_id,</if>
+            <if test="liveBuy != null">live_buy,</if>
+            <if test="replayBuy != null">replay_buy,</if>
+        </trim>
+        <trim prefix="values (" suffix=")" suffixOverrides=",">
+            <if test="userId != null">#{userId},</if>
+            <if test="liveId != null">#{liveId},</if>
+            <if test="logType != null">#{logType},</if>
+            <if test="createTime != null">#{createTime},</if>
+            <if test="updateTime != null">#{updateTime},</if>
+            <if test="externalContactId != null">#{externalContactId},</if>
+            <if test="companyUserId != null">#{companyUserId},</if>
+            <if test="companyId != null">#{companyId},</if>
+            <if test="finishTime != null">#{finishTime},</if>
+            <if test="createBy != null">#{createBy},</if>
+            <if test="sopCreateTime != null">#{sopCreateTime},</if>
+            <if test="sendAppId != null">#{sendAppId},</if>
+            <if test="logSource != null">#{logSource},</if>
+            <if test="qwUserId != null">#{qwUserId},</if>
+            <if test="watchType != null">#{watchType},</if>
+            <if test="corpId != null">#{corpId},</if>
+            <if test="liveBuy != null">#{liveBuy},</if>
+            <if test="replayBuy != null">#{replayBuy},</if>
+        </trim>
+    </insert>
+
+    <update id="updateLiveWatchLog" parameterType="LiveWatchLog">
+        update live_watch_log
+        <trim prefix="SET" suffixOverrides=",">
+            <if test="userId != null">user_id = #{userId},</if>
+            <if test="liveId != null">live_id = #{liveId},</if>
+            <if test="logType != null">log_type = #{logType},</if>
+            <if test="createTime != null">create_time = #{createTime},</if>
+            <if test="updateTime != null">update_time = #{updateTime},</if>
+            <if test="externalContactId != null">external_contact_id = #{externalContactId},</if>
+            <if test="companyUserId != null">company_user_id = #{companyUserId},</if>
+            <if test="companyId != null">company_id = #{companyId},</if>
+            <if test="finishTime != null">finish_time = #{finishTime},</if>
+            <if test="createBy != null">create_by = #{createBy},</if>
+            <if test="sopCreateTime != null">sop_create_time = #{sopCreateTime},</if>
+            <if test="sendAppId != null">send_app_id = #{sendAppId},</if>
+            <if test="logSource != null">log_source = #{logSource},</if>
+            <if test="qwUserId != null">qw_user_id = #{qwUserId},</if>
+            <if test="watchType != null">watch_type = #{watchType},</if>
+            <if test="corpId != null">corp_id = #{corpId},</if>
+            <if test="liveBuy != null">live_buy = #{liveBuy},</if>
+            <if test="replayBuy != null">replay_buy = #{replayBuy},</if>
+        </trim>
+        where log_id = #{logId}
+    </update>
+
+    <delete id="deleteLiveWatchLogByLogId" parameterType="Long">
+        delete from live_watch_log where log_id = #{logId}
+    </delete>
+
+    <delete id="deleteLiveWatchLogByLogIds" parameterType="String">
+        delete from live_watch_log where log_id in
+        <foreach item="logId" collection="array" open="(" separator="," close=")">
+            #{logId}
+        </foreach>
+    </delete>
+
+    <insert id="insertLiveWatchLogBatch" parameterType="java.util.List" useGeneratedKeys="true" keyProperty="logId">
+        INSERT INTO live_watch_log
+        <trim prefix="(" suffix=")" suffixOverrides=",">
+            <if test="liveWatchLogs != null and liveWatchLogs.size() &gt; 0">
+                <foreach collection="liveWatchLogs" item="item" index="index" open="" close="" separator="">
+                    <if test="index == 0">
+                        <if test="item.userId != null">user_id,</if>
+                        <if test="item.liveId != null">live_id,</if>
+                        <if test="item.logType != null">log_type,</if>
+                        <if test="item.createTime != null">create_time,</if>
+                        <if test="item.updateTime != null">update_time,</if>
+                        <if test="item.externalContactId != null">external_contact_id,</if>
+                        <if test="item.companyUserId != null">company_user_id,</if>
+                        <if test="item.companyId != null">company_id,</if>
+                        <if test="item.finishTime != null">finish_time,</if>
+                        <if test="item.createBy != null">create_by,</if>
+                        <if test="item.sopCreateTime != null">sop_create_time,</if>
+                        <if test="item.sendAppId != null">send_app_id,</if>
+                        <if test="item.logSource != null">log_source,</if>
+                        <if test="item.qwUserId != null">qw_user_id,</if>
+                        <if test="item.watchType != null">watch_type,</if>
+                        <if test="item.corpId != null">corp_id,</if>
+                        <if test="item.liveBuy != null">live_buy,</if>
+                        <if test="item.replayBuy != null">replay_buy,</if>
+                    </if>
+                </foreach>
+            </if>
+        </trim>
+        <trim prefix="VALUES">
+            <foreach collection="liveWatchLogs" item="item" separator=",">
+                (<trim suffixOverrides=",">
+                <if test="item.userId != null">#{item.userId},</if>
+                <if test="item.liveId != null">#{item.liveId},</if>
+                <if test="item.logType != null">#{item.logType},</if>
+                <if test="item.createTime != null">#{item.createTime},</if>
+                <if test="item.updateTime != null">#{item.updateTime},</if>
+                <if test="item.externalContactId != null">#{item.externalContactId},</if>
+                <if test="item.companyUserId != null">#{item.companyUserId},</if>
+                <if test="item.companyId != null">#{item.companyId},</if>
+                <if test="item.finishTime != null">#{item.finishTime},</if>
+                <if test="item.createBy != null">#{item.createBy},</if>
+                <if test="item.sopCreateTime != null">#{item.sopCreateTime},</if>
+                <if test="item.sendAppId != null">#{item.sendAppId},</if>
+                <if test="item.logSource != null">#{item.logSource},</if>
+                <if test="item.qwUserId != null">#{item.qwUserId},</if>
+                <if test="item.watchType != null">#{item.watchType},</if>
+                <if test="item.corpId != null">#{item.corpId},</if>
+                <if test="item.liveBuy != null">#{item.liveBuy},</if>
+                <if test="item.replayBuy != null">#{item.replayBuy},</if>
+            </trim>)
+            </foreach>
+        </trim>
+    </insert>
+
+    <update id="updateLiveWatchLogCondition" parameterType="com.fs.live.domain.LiveWatchLog" >
+        update live_watch_log
+        set update_time = NOW(),
+            sop_create_time = NOW(),
+            send_app_id = #{liveWatchLog.sendAppId},
+            log_source = #{liveWatchLog.logSource}
+        where external_contact_id = #{liveWatchLog.externalContactId}
+            and live_id = #{liveWatchLog.liveId}
+            and qw_user_id = #{liveWatchLog.qwUserId}
+    </update>
+
+    <select id="selectOneLogByLiveIdAndQwUserIdAndExternalId"  resultType="com.fs.live.domain.LiveWatchLog">
+        select * from live_watch_log where live_id = #{liveId} and qw_user_id = #{qwUserId} and external_contact_id = #{externalContactId}
+    </select>
+<!--    todo lmx-->
+    <select id="selectLiveWatchLogByLiveId" resultType="com.fs.live.domain.LiveWatchLog">
+        select * from live_watch_log where live_id = #{liveId}
+    </select>
+</mapper>

+ 12 - 1
fs-user-app/src/main/java/com/fs/app/controller/live/LiveWatchUserController.java

@@ -1,7 +1,10 @@
 package com.fs.app.controller.live;
 
 
+import com.fs.app.annotation.Login;
+import com.fs.app.controller.AppBaseController;
 import com.fs.app.facade.LiveFacadeService;
+import com.fs.live.param.LiveIsAddKfParam;
 import com.fs.common.annotation.Log;
 import com.fs.common.core.controller.BaseController;
 import com.fs.common.core.domain.AjaxResult;
@@ -21,7 +24,7 @@ import org.springframework.web.bind.annotation.*;
  */
 @RestController
 @RequestMapping("/app/live/liveWatchUser")
-public class LiveWatchUserController extends BaseController
+public class LiveWatchUserController extends AppBaseController
 {
     @Autowired
     private ILiveWatchUserService liveWatchUserService;
@@ -87,4 +90,12 @@ public class LiveWatchUserController extends BaseController
         return toAjax(liveWatchUserService.changeUserState(liveId, userId));
     }
 
+    @Login
+    @PostMapping("/liveIsAddKf")
+    public R liveIsAddKf(@RequestBody LiveIsAddKfParam param){
+        String userId = getUserId();
+        param.setUserId(Long.valueOf(userId));
+        return liveWatchUserService.liveIsAddKf(param);
+    }
+
 }