5 Incheckningar c9d690a09c ... 6c565f9a85

Upphovsman SHA1 Meddelande Datum
  yys 6c565f9a85 1、调整是否购物车隐藏暂时发送消息 5 dagar sedan
  yys d0234fa997 1、调整留存记录,视频排序记录 5 dagar sedan
  yys 90a318316a 1、调整发送红包,抽奖问题 1 vecka sedan
  yys dbe5cfb0ec 1、调整记录 1 vecka sedan
  yys ecc20c55b8 1、新增留存表对运营自动化,中控台的操作进行留存 1 vecka sedan
51 ändrade filer med 1753 tillägg och 260 borttagningar
  1. 33 0
      fs-admin/src/main/java/com/fs/live/controller/LiveConsoleOpLogController.java
  2. 3 0
      fs-common/src/main/java/com/fs/common/constant/LiveKeysConstant.java
  3. 7 0
      fs-common/src/main/java/com/fs/common/core/redis/RedisCache.java
  4. 26 0
      fs-live-app/src/main/java/com/fs/live/redis/LiveRedisPubSubConfig.java
  5. 67 0
      fs-live-app/src/main/java/com/fs/live/redis/LiveWsBroadcastSubscriber.java
  6. 60 3
      fs-live-app/src/main/java/com/fs/live/task/LiveCompletionPointsTask.java
  7. 105 23
      fs-live-app/src/main/java/com/fs/live/task/Task.java
  8. 7 0
      fs-live-app/src/main/java/com/fs/live/websocket/bean/SendMsgVo.java
  9. 192 5
      fs-live-app/src/main/java/com/fs/live/websocket/service/WebSocketServer.java
  10. 1 1
      fs-service/src/main/java/com/fs/course/mapper/FsUserVideoFavoriteMapper.java
  11. 7 3
      fs-service/src/main/java/com/fs/course/mapper/FsUserVideoMapper.java
  12. 2 25
      fs-service/src/main/java/com/fs/course/service/impl/FsUserVideoServiceImpl.java
  13. 81 0
      fs-service/src/main/java/com/fs/live/domain/LiveConsoleOpLog.java
  14. 46 0
      fs-service/src/main/java/com/fs/live/domain/LiveConsoleOpLogUser.java
  15. 20 0
      fs-service/src/main/java/com/fs/live/mapper/LiveConsoleOpLogMapper.java
  16. 21 0
      fs-service/src/main/java/com/fs/live/mapper/LiveConsoleOpLogUserMapper.java
  17. 3 0
      fs-service/src/main/java/com/fs/live/param/CouponPO.java
  18. 20 0
      fs-service/src/main/java/com/fs/live/param/LiveCompletionCouponAnswerItem.java
  19. 17 17
      fs-service/src/main/java/com/fs/live/param/LiveCompletionCouponAnswerParam.java
  20. 16 0
      fs-service/src/main/java/com/fs/live/param/LiveCompletionCouponClaimParam.java
  21. 2 0
      fs-service/src/main/java/com/fs/live/param/LotteryPO.java
  22. 2 0
      fs-service/src/main/java/com/fs/live/param/RedPO.java
  23. 9 24
      fs-service/src/main/java/com/fs/live/service/ILiveCompletionCouponService.java
  24. 1 1
      fs-service/src/main/java/com/fs/live/service/ILiveCompletionPointsRecordService.java
  25. 43 0
      fs-service/src/main/java/com/fs/live/service/ILiveConsoleOpLogService.java
  26. 2 1
      fs-service/src/main/java/com/fs/live/service/ILiveRedConfService.java
  27. 5 0
      fs-service/src/main/java/com/fs/live/service/ILiveService.java
  28. 5 0
      fs-service/src/main/java/com/fs/live/service/ILiveWatchUserService.java
  29. 112 16
      fs-service/src/main/java/com/fs/live/service/impl/LiveCompletionCouponServiceImpl.java
  30. 9 9
      fs-service/src/main/java/com/fs/live/service/impl/LiveCompletionPointsRecordServiceImpl.java
  31. 331 0
      fs-service/src/main/java/com/fs/live/service/impl/LiveConsoleOpLogServiceImpl.java
  32. 22 8
      fs-service/src/main/java/com/fs/live/service/impl/LiveCouponServiceImpl.java
  33. 26 1
      fs-service/src/main/java/com/fs/live/service/impl/LiveLotteryConfServiceImpl.java
  34. 37 5
      fs-service/src/main/java/com/fs/live/service/impl/LiveRedConfServiceImpl.java
  35. 22 1
      fs-service/src/main/java/com/fs/live/service/impl/LiveServiceImpl.java
  36. 17 0
      fs-service/src/main/java/com/fs/live/service/impl/LiveWatchUserServiceImpl.java
  37. 13 0
      fs-service/src/main/java/com/fs/live/vo/LiveCompletionCouponAnswerResult.java
  38. 1 1
      fs-service/src/main/java/com/fs/live/vo/LiveCompletionCouponInfoVO.java
  39. 33 27
      fs-service/src/main/java/com/fs/live/vo/LiveCompletionCouponStatusVO.java
  40. 67 0
      fs-service/src/main/java/com/fs/live/vo/LiveConsoleOpLogRecordVo.java
  41. 66 0
      fs-service/src/main/java/com/fs/live/vo/LiveConsoleOpLogVo.java
  42. 21 0
      fs-service/src/main/java/com/fs/live/vo/LiveUserRewardRecordsVo.java
  43. 2 2
      fs-service/src/main/resources/application-config-druid-tyt.yml
  44. 1 1
      fs-service/src/main/resources/application-druid-tyt.yml
  45. 68 0
      fs-service/src/main/resources/mapper/live/LiveConsoleOpLogMapper.xml
  46. 38 0
      fs-service/src/main/resources/mapper/live/LiveConsoleOpLogUserMapper.xml
  47. 0 8
      fs-user-app/src/main/java/com/fs/app/controller/VideoController.java
  48. 17 9
      fs-user-app/src/main/java/com/fs/app/controller/live/LiveCompletionCouponController.java
  49. 4 69
      fs-user-app/src/main/java/com/fs/app/controller/live/LiveController.java
  50. 6 0
      fs-user-app/src/main/java/com/fs/app/facade/LiveFacadeService.java
  51. 37 0
      fs-user-app/src/main/java/com/fs/app/facade/impl/LiveFacadeServiceImpl.java

+ 33 - 0
fs-admin/src/main/java/com/fs/live/controller/LiveConsoleOpLogController.java

@@ -0,0 +1,33 @@
+package com.fs.live.controller;
+
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.live.domain.LiveConsoleOpLog;
+import com.fs.live.service.ILiveConsoleOpLogService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/**
+ * 直播中控台操作留存 Controller
+ */
+@RestController
+@RequestMapping("/live/consoleOpLog")
+public class LiveConsoleOpLogController extends BaseController {
+
+    @Autowired
+    private ILiveConsoleOpLogService liveConsoleOpLogService;
+
+    /**
+     * 分页查询中控台操作留存列表
+     */
+    @GetMapping("/list")
+    public TableDataInfo list(LiveConsoleOpLog liveConsoleOpLog) {
+        startPage();
+        List<LiveConsoleOpLog> list = liveConsoleOpLogService.selectLiveConsoleOpLogList(liveConsoleOpLog);
+        return getDataTable(list);
+    }
+}

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

@@ -42,4 +42,7 @@ public class LiveKeysConstant {
 
     public static final String LIVE_ROOM_PASSWORD_CACHE = "live:room:password:%s";
 
+    /** 直播 WebSocket 跨服务广播频道(admin 等服务发布,live-app 订阅后推送给 App) */
+    public static final String LIVE_WS_BROADCAST_CHANNEL = "live:ws:broadcast";
+
 }

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

@@ -541,4 +541,11 @@ public class RedisCache
         redisTemplate.opsForSet().add(key, value);
     }
 
+    /**
+     * 发布 Redis 频道消息
+     */
+    public void publish(final String channel, final Object message) {
+        redisTemplate.convertAndSend(channel, message);
+    }
+
 }

+ 26 - 0
fs-live-app/src/main/java/com/fs/live/redis/LiveRedisPubSubConfig.java

@@ -0,0 +1,26 @@
+package com.fs.live.redis;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+import org.springframework.data.redis.listener.ChannelTopic;
+import org.springframework.data.redis.listener.RedisMessageListenerContainer;
+
+/**
+ * ??? WebSocket Redis ????????????
+ */
+@Configuration
+public class LiveRedisPubSubConfig {
+
+    @Bean
+    public RedisMessageListenerContainer liveWsRedisMessageListenerContainer(
+            RedisConnectionFactory redisConnectionFactory,
+            @Lazy LiveWsBroadcastSubscriber liveWsBroadcastSubscriber) {
+        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
+        container.setConnectionFactory(redisConnectionFactory);
+        container.addMessageListener(liveWsBroadcastSubscriber,
+                new ChannelTopic(LiveWsBroadcastSubscriber.channel()));
+        return container;
+    }
+}

+ 67 - 0
fs-live-app/src/main/java/com/fs/live/redis/LiveWsBroadcastSubscriber.java

@@ -0,0 +1,67 @@
+package com.fs.live.redis;
+
+import com.alibaba.fastjson.JSONObject;
+import com.fs.common.constant.LiveKeysConstant;
+import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.spring.SpringUtils;
+import com.fs.live.websocket.bean.SendMsgVo;
+import com.fs.live.websocket.service.WebSocketServer;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.connection.Message;
+import org.springframework.data.redis.connection.MessageListener;
+import org.springframework.stereotype.Component;
+
+import java.nio.charset.StandardCharsets;
+
+/**
+ * 订阅 admin 等服务发布的直播 WebSocket 广播,推送给 App 端
+ */
+@Slf4j
+@Component
+public class LiveWsBroadcastSubscriber implements MessageListener {
+
+    @Override
+    public void onMessage(Message message, byte[] pattern) {
+        if (message == null || message.getBody() == null) {
+            return;
+        }
+        String body = new String(message.getBody(), StandardCharsets.UTF_8);
+        if (StringUtils.isEmpty(body)) {
+            return;
+        }
+        try {
+            JSONObject payload = JSONObject.parseObject(body);
+            Long liveId = payload.getLong("liveId");
+            String cmd = payload.getString("cmd");
+            if (liveId == null || StringUtils.isEmpty(cmd)) {
+                log.warn("[LiveWsBroadcast] 忽略无效消息: {}", body);
+                return;
+            }
+            SendMsgVo sendMsgVo = new SendMsgVo();
+            sendMsgVo.setLiveId(liveId);
+            sendMsgVo.setCmd(cmd);
+            if (payload.containsKey("status")) {
+                sendMsgVo.setStatus(payload.getInteger("status"));
+            }
+            if (payload.containsKey("userId")) {
+                sendMsgVo.setUserId(payload.getLong("userId"));
+            }
+            if (payload.containsKey("msg")) {
+                sendMsgVo.setMsg(payload.getString("msg"));
+            }
+            if (payload.containsKey("data")) {
+                sendMsgVo.setData(payload.getString("data"));
+            }
+            WebSocketServer webSocketServer = SpringUtils.getBean(WebSocketServer.class);
+            webSocketServer.broadcastLiveCmd(sendMsgVo);
+            log.info("[LiveWsBroadcast] 已推送指令, liveId={}, cmd={}, status={}",
+                    liveId, cmd, sendMsgVo.getStatus());
+        } catch (Exception e) {
+            log.error("[LiveWsBroadcast] 处理广播消息失败: {}", body, e);
+        }
+    }
+
+    public static String channel() {
+        return LiveKeysConstant.LIVE_WS_BROADCAST_CHANNEL;
+    }
+}

+ 60 - 3
fs-live-app/src/main/java/com/fs/live/task/LiveCompletionPointsTask.java

@@ -3,10 +3,15 @@ package com.fs.live.task;
 import com.alibaba.fastjson.JSONObject;
 import com.fs.common.core.domain.R;
 import com.fs.common.core.redis.RedisCache;
+import com.fs.common.utils.StringUtils;
 import com.fs.live.domain.Live;
+import com.fs.live.domain.LiveCompletionPointsRecord;
+import com.fs.live.domain.LiveConsoleOpLog;
 import com.fs.live.service.ILiveCompletionCouponService;
 import com.fs.live.service.ILiveCompletionPointsRecordService;
+import com.fs.live.service.ILiveConsoleOpLogService;
 import com.fs.live.service.ILiveService;
+import com.fs.live.vo.LiveCompletionCouponInfoVO;
 import com.fs.live.vo.LiveCompletionCouponNotifyResult;
 import com.fs.live.websocket.bean.SendMsgVo;
 import com.fs.live.websocket.service.WebSocketServer;
@@ -42,6 +47,9 @@ public class LiveCompletionPointsTask {
     @Autowired
     private WebSocketServer webSocketServer;
 
+    @Autowired
+    private ILiveConsoleOpLogService liveConsoleOpLogService;
+
     /**
      * 定时检查观看时长并创建完课积分记录(兜底机制)
      * 每分钟执行一次
@@ -56,8 +64,20 @@ public class LiveCompletionPointsTask {
                 return;
             }
 
-            processCompletionByWatchDuration(activeLives, (liveId, userId, duration) ->
-                    completionPointsRecordService.checkAndCreateCompletionRecord(liveId, userId, duration));
+            processCompletionByWatchDuration(activeLives, (liveId, userId, duration) -> {
+                LiveCompletionPointsRecord record =
+                        completionPointsRecordService.checkAndCreateCompletionRecord(liveId, userId, duration);
+                if (record != null) {
+                    LiveConsoleOpLog opLog = liveConsoleOpLogService.saveLog(
+                            liveId,
+                            LiveConsoleOpLog.OP_COMPLETION_POINTS,
+                            LiveConsoleOpLog.HANDLE_AUTO,
+                            record.getId(),
+                            resolveCompletionPointsBizName(record)
+                    );
+                    liveConsoleOpLogService.bindOpLogUser(opLog.getId(), liveId, userId);
+                }
+            });
 
         } catch (Exception e) {
             log.error("检查完课积分定时任务执行失败", e);
@@ -84,7 +104,16 @@ public class LiveCompletionPointsTask {
                 if (notifyResult == null || !notifyResult.isShouldNotify()) {
                     return;
                 }
-                if (pushCompletionCouponQuestion(liveId, userId, notifyResult)) {
+                String bizName = resolveCompletionCouponBizName(notifyResult);
+                Long bizId = notifyResult.getCoupon() != null ? notifyResult.getCoupon().getCouponId() : null;
+                LiveConsoleOpLog opLog = liveConsoleOpLogService.saveLog(
+                        liveId,
+                        LiveConsoleOpLog.OP_COMPLETION_COUPON,
+                        LiveConsoleOpLog.HANDLE_AUTO,
+                        bizId,
+                        bizName
+                );
+                if (pushCompletionCouponQuestion(liveId, userId, notifyResult, opLog)) {
                     completionCouponService.markCompletionCouponNotified(liveId, userId);
                 }
             });
@@ -118,12 +147,18 @@ public class LiveCompletionPointsTask {
     }
 
     private boolean pushCompletionCouponQuestion(Long liveId, Long userId, LiveCompletionCouponNotifyResult notifyResult) {
+        return pushCompletionCouponQuestion(liveId, userId, notifyResult, null);
+    }
+
+    private boolean pushCompletionCouponQuestion(Long liveId, Long userId, LiveCompletionCouponNotifyResult notifyResult,
+                                                 LiveConsoleOpLog opLog) {
         SendMsgVo sendMsgVo = new SendMsgVo();
         sendMsgVo.setLiveId(liveId);
         sendMsgVo.setUserId(userId);
         sendMsgVo.setCmd("completionCouponQuestion");
         sendMsgVo.setMsg("今日问题");
         sendMsgVo.setData(JSONObject.toJSONString(notifyResult));
+        WebSocketServer.attachOpLog(sendMsgVo, opLog);
         boolean pushed = webSocketServer.sendCompletionCouponQuestionMessage(liveId, userId, sendMsgVo);
         if (pushed) {
             log.info("[完课优惠券] 推送今日问题弹窗, liveId={}, userId={}", liveId, userId);
@@ -161,4 +196,26 @@ public class LiveCompletionPointsTask {
     private interface CompletionHandler {
         void handle(Long liveId, Long userId, Long duration);
     }
+
+    private String resolveCompletionPointsBizName(LiveCompletionPointsRecord record) {
+        if (record == null) {
+            return "完课积分";
+        }
+        Integer points = record.getPointsAwarded();
+        if (points != null) {
+            return "完课积分" + points + "分";
+        }
+        return record.getId() != null ? "完课积分 #" + record.getId() : "完课积分";
+    }
+
+    private String resolveCompletionCouponBizName(LiveCompletionCouponNotifyResult notifyResult) {
+        if (notifyResult == null || notifyResult.getCoupon() == null) {
+            return "完课优惠券";
+        }
+        LiveCompletionCouponInfoVO coupon = notifyResult.getCoupon();
+        if (StringUtils.isNotEmpty(coupon.getTitle())) {
+            return coupon.getTitle();
+        }
+        return coupon.getCouponId() != null ? "完课优惠券 #" + coupon.getCouponId() : "完课优惠券";
+    }
 }

+ 105 - 23
fs-live-app/src/main/java/com/fs/live/task/Task.java

@@ -84,6 +84,9 @@ public class Task {
     @Autowired
     private ILiveUserFirstEntryService liveUserFirstEntryService;
 
+    @Autowired
+    private ILiveConsoleOpLogService liveConsoleOpLogService;
+
     @Autowired
     public FsJstAftersalePushService fsJstAftersalePushService;
 
@@ -272,26 +275,29 @@ public class Task {
                 continue;
             }
 
-            updateRedStatus(range);
+            List<LiveConsoleOpLog> opLogs = updateRedStatus(range);
             redisCache.redisTemplate.opsForZSet()
                     .removeRangeByScore(liveKey, 0, currentTime);
             try {
+                Long liveId = Long.parseLong(liveKey.substring("live:red_task:".length()));
                 // 广播红包关闭消息
                 SendMsgVo sendMsgVo = new SendMsgVo();
-                sendMsgVo.setLiveId(Long.valueOf(liveKey));
+                sendMsgVo.setLiveId(liveId);
                 sendMsgVo.setCmd("red");
                 sendMsgVo.setStatus(-1);
-                liveService.asyncToCacheLiveConfig(Long.parseLong(liveKey));
-                webSocketServer.broadcastMessage(Long.valueOf(liveKey), JSONObject.toJSONString(R.ok().put("data", sendMsgVo)));
+                liveService.asyncToCacheLiveConfig(liveId);
+                if (!opLogs.isEmpty()) {
+                    WebSocketServer.attachOpLog(sendMsgVo, opLogs.get(opLogs.size() - 1));
+                }
+                webSocketServer.broadcastMessage(liveId, JSONObject.toJSONString(R.ok().put("data", sendMsgVo)));
             } catch (Exception e) {
                 log.error("更新红包状态异常", e);
             }
         }
     }
 
-    private void updateRedStatus(Set<String> range) {
-
-        liveRedConfService.finishRedStatusBySetIds(range);
+    private List<LiveConsoleOpLog> updateRedStatus(Set<String> range) {
+        return liveRedConfService.finishRedStatusBySetIds(range);
     }
 
     private void processLotteryTask(Set<String> range) {
@@ -364,6 +370,12 @@ public class Task {
             sendMsgVo.setLiveId(liveLottery.getLiveId());
             sendMsgVo.setCmd("LotteryDetail");
             sendMsgVo.setData(JSON.toJSONString(lotteryVos));
+            WebSocketServer.attachOpLog(sendMsgVo, liveConsoleOpLogService.saveLotterySettleLog(
+                    liveLottery.getLiveId(),
+                    LiveConsoleOpLog.HANDLE_AUTO,
+                    liveLottery.getLotteryId(),
+                    resolveLotteryBizName(liveLottery.getLotteryId(), liveLottery.getDesc())
+            ));
             webSocketServer.broadcastMessage(liveLottery.getLiveId(), JSONObject.toJSONString(R.ok().put("data", sendMsgVo)));
 
             liveService.asyncToCacheLiveConfig(liveLottery.getLiveId());
@@ -467,7 +479,15 @@ public class Task {
                     // 5.更新用户积分
                     fsUserService.increaseIntegral(userIds, config.getScoreAmount());
                     // 6.发送websocket事件消息 通知用户自动领取成功
-                    userIds.forEach(userId -> webSocketServer.sendIntegralMessage(openRewardLive.getLiveId(), userId, config.getScoreAmount()));
+                    LiveConsoleOpLog watchPointsOpLog = liveConsoleOpLogService.saveLog(
+                            openRewardLive.getLiveId(),
+                            LiveConsoleOpLog.OP_WATCH_REWARD_POINTS,
+                            LiveConsoleOpLog.HANDLE_AUTO,
+                            openRewardLive.getLiveId(),
+                            resolveWatchRewardPointsBizName(config, userIds.size())
+                    );
+                    userIds.forEach(userId -> webSocketServer.sendIntegralMessage(
+                            openRewardLive.getLiveId(), userId, config.getScoreAmount(), watchPointsOpLog));
                     break;
 
                 case 3: // 优惠券
@@ -478,7 +498,21 @@ public class Task {
                         continue;
                     }
                     Long actionCouponId = Long.parseLong(actionCouponIdStr);
-                    bindCouponToUsers(openRewardLive, userIds, actionCouponId);
+                    LiveCoupon watchRewardCoupon = liveCouponService.selectLiveCouponById(actionCouponId);
+                    List<LiveConsoleOpLogUser> couponRelations = bindCouponToUsers(openRewardLive, userIds, actionCouponId, false);
+                    if (!couponRelations.isEmpty()) {
+                        LiveConsoleOpLog watchCouponOpLog = liveConsoleOpLogService.saveLog(
+                                openRewardLive.getLiveId(),
+                                LiveConsoleOpLog.OP_WATCH_REWARD_COUPON,
+                                LiveConsoleOpLog.HANDLE_AUTO,
+                                actionCouponId,
+                                resolveWatchRewardCouponBizName(watchRewardCoupon, actionCouponId, couponRelations.size())
+                        );
+                        liveConsoleOpLogService.bindOpLogUsers(
+                                watchCouponOpLog.getId(), openRewardLive.getLiveId(), couponRelations);
+                        couponRelations.forEach(relation -> sendCouponRewardMessage(
+                                openRewardLive.getLiveId(), relation.getUserId(), watchRewardCoupon, watchCouponOpLog));
+                    }
                     break;
 
                 case 1: // 现金红包 - 暂不处理(现有逻辑)
@@ -490,35 +524,38 @@ public class Task {
     }
 
     /**
-     * 将优惠券绑定到用户
-     * @param live 直播间
-     * @param userIds 用户ID列表
-     * @param couponId 优惠券ID
+     * 将优惠券绑定到用户,并收集留存关联信息
+     *
+     * @param live          直播间
+     * @param userIds       待发放用户ID列表
+     * @param couponId      优惠券ID
+     * @param sendWebSocket 是否立即发送 WebSocket(定时任务由外层统一发送并附带 opLog)
+     * @return 发放成功的用户关联列表(含 userId、couponUserId,供写入 live_console_op_log_user)
      */
-    private void bindCouponToUsers(Live live, List<Long> userIds, Long couponId) {
+    private List<LiveConsoleOpLogUser> bindCouponToUsers(Live live, List<Long> userIds, Long couponId, boolean sendWebSocket) {
+        List<LiveConsoleOpLogUser> relations = new ArrayList<>();
         try {
             // 查询优惠券信息
             LiveCoupon coupon = liveCouponService.selectLiveCouponById(couponId);
             if (coupon == null) {
                 log.error("优惠券不存在,couponId={}", couponId);
-                return;
+                return relations;
             }
 
             // 查询当前直播间关联的优惠券领取信息
             LiveCouponIssue couponIssue = liveCouponIssueService.selectIssueByLiveIdAndCouponId(live.getLiveId(), couponId);
             if (couponIssue == null) {
                 log.error("优惠券领取信息不存在或未关联到直播间,liveId={}, couponId={}", live.getLiveId(), couponId);
-                return;
+                return relations;
             }
 
             // 检查优惠券状态
             if ((couponIssue.getStatus() == null || couponIssue.getStatus() != 1)&&couponIssue.getCouponType()!=3) {
                 log.error("优惠券状态不正常,couponId={}, status={}", couponId, couponIssue.getStatus());
-                return;
+                return relations;
             }
 
             Date now = new Date();
-            int successCount = 0;
 
             for (Long userId : userIds) {
                 try {
@@ -552,10 +589,11 @@ public class Task {
                     // 保存奖励记录 - 优惠券类型的sourceId为couponId
                     saveUserRewardRecord(live, Collections.singletonList(userId), coupon.getCouponPrice(), 3, couponId);
 
-                    successCount++;
+                    relations.add(new LiveConsoleOpLogUser(null, userId, live.getLiveId(), couponUser.getId()));
 
-                    // 发送WebSocket消息通知用户
-                    sendCouponRewardMessage(live.getLiveId(), userId, coupon);
+                    if (sendWebSocket) {
+                        sendCouponRewardMessage(live.getLiveId(), userId, coupon);
+                    }
 
                 } catch (Exception e) {
                     log.error("绑定优惠券到用户失败,userId={}, couponId={}", userId, couponId, e);
@@ -563,11 +601,14 @@ public class Task {
             }
 
             log.info("直播间观看奖励-优惠券发放完成,liveId={}, couponId={}, 成功发放 {} 个用户",
-                    live.getLiveId(), couponId, successCount);
+                    live.getLiveId(), couponId, relations.size());
+
+            return relations;
 
         } catch (Exception e) {
             log.error("绑定优惠券到用户异常,liveId={}, couponId={}", live.getLiveId(), couponId, e);
         }
+        return relations;
     }
 
     /**
@@ -604,7 +645,7 @@ public class Task {
                 targetCouponId = Long.parseLong(actionCouponIdStr);
             }
 
-            bindCouponToUsers(live, Collections.singletonList(userId), targetCouponId);
+            bindCouponToUsers(live, Collections.singletonList(userId), targetCouponId, true);
             return R.ok("观看奖励优惠券发放完成").put("couponId", targetCouponId);
         } catch (Exception e) {
             log.error("手动触发观看奖励优惠券失败, liveId={}, userId={}", liveId, userId, e);
@@ -616,6 +657,10 @@ public class Task {
      * 发送优惠券奖励消息给前端
      */
     private void sendCouponRewardMessage(Long liveId, Long userId, LiveCoupon coupon) {
+        sendCouponRewardMessage(liveId, userId, coupon, null);
+    }
+
+    private void sendCouponRewardMessage(Long liveId, Long userId, LiveCoupon coupon, LiveConsoleOpLog opLog) {
         try {
             SendMsgVo sendMsgVo = new SendMsgVo();
             sendMsgVo.setLiveId(liveId);
@@ -632,6 +677,7 @@ public class Task {
             couponData.put("useMinPrice", coupon.getUseMinPrice());
             couponData.put("couponTime", coupon.getCouponTime());
             sendMsgVo.setData(couponData.toJSONString());
+            WebSocketServer.attachOpLog(sendMsgVo, opLog);
 
             webSocketServer.sendCompletionPointsMessage(liveId, userId, sendMsgVo);
         } catch (Exception e) {
@@ -1323,4 +1369,40 @@ public class Task {
 //            log.error("批量同步观看时长任务异常", e);
 //        }
 //    }
+
+    private String resolveLotteryBizName(Long lotteryId, String desc) {
+        if (StringUtils.isNotEmpty(desc)) {
+            return desc;
+        }
+        if (lotteryId != null) {
+            LiveLotteryConf conf = liveLotteryConfService.selectLiveLotteryConfByLotteryId(lotteryId);
+            if (conf != null && StringUtils.isNotEmpty(conf.getDesc())) {
+                return conf.getDesc();
+            }
+            return "抽奖 #" + lotteryId;
+        }
+        return "抽奖";
+    }
+
+    private String resolveWatchRewardPointsBizName(LiveWatchConfig config, int userCount) {
+        Long amount = config != null ? config.getScoreAmount() : null;
+        String amountText = amount != null ? String.valueOf(amount) : "0";
+        return "观看奖励积分" + amountText + "分,发放" + userCount + "人";
+    }
+
+    private String resolveWatchRewardCouponBizName(LiveCoupon coupon, Long couponId, int userCount) {
+        String title = null;
+        if (coupon != null && StringUtils.isNotEmpty(coupon.getTitle())) {
+            title = coupon.getTitle();
+        } else if (couponId != null) {
+            LiveCoupon loaded = liveCouponService.selectLiveCouponById(couponId);
+            if (loaded != null && StringUtils.isNotEmpty(loaded.getTitle())) {
+                title = loaded.getTitle();
+            }
+        }
+        if (StringUtils.isEmpty(title)) {
+            title = couponId != null ? "观看奖励优惠券 #" + couponId : "观看奖励优惠券";
+        }
+        return title + ",发放" + userCount + "人";
+    }
 }

+ 7 - 0
fs-live-app/src/main/java/com/fs/live/websocket/bean/SendMsgVo.java

@@ -1,11 +1,15 @@
 package com.fs.live.websocket.bean;
 
+import com.fs.live.vo.LiveConsoleOpLogVo;
 import io.swagger.annotations.ApiModelProperty;
 import lombok.AllArgsConstructor;
 import lombok.Builder;
 import lombok.Data;
 import lombok.NoArgsConstructor;
 
+/**
+ * WebSocket 下行消息体
+ */
 @Data
 @Builder
 @AllArgsConstructor
@@ -35,4 +39,7 @@ public class SendMsgVo {
     private Integer status;
     private Integer duration;
 
+    @ApiModelProperty("中控台操作留存(红包/优惠券/抽奖等运营动作下发给 App)")
+    private LiveConsoleOpLogVo opLog;
+
 }

+ 192 - 5
fs-live-app/src/main/java/com/fs/live/websocket/service/WebSocketServer.java

@@ -24,6 +24,7 @@ import com.fs.common.utils.StringUtils;
 import com.fs.common.utils.spring.SpringUtils;
 import com.fs.live.domain.*;
 import com.fs.live.service.*;
+import com.fs.live.vo.LiveConsoleOpLogVo;
 import com.fs.live.vo.LiveGoodsVo;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.lang3.time.DateUtils;
@@ -102,6 +103,7 @@ public class WebSocketServer {
     private final ILiveUserFirstEntryService liveUserFirstEntryService =  SpringUtils.getBean(ILiveUserFirstEntryService.class);
     private final ILiveCouponIssueService liveCouponIssueService =  SpringUtils.getBean(ILiveCouponIssueService.class);
     private final LiveCouponMapper liveCouponMapper = SpringUtils.getBean(LiveCouponMapper.class);
+    private final ILiveConsoleOpLogService liveConsoleOpLogService = SpringUtils.getBean(ILiveConsoleOpLogService.class);
     private final ILiveWatchLogService liveWatchLogService = SpringUtils.getBean(ILiveWatchLogService.class);
     private final ILiveVideoService liveVideoService = SpringUtils.getBean(ILiveVideoService.class);
     private final ILiveCompletionPointsRecordService completionPointsRecordService = SpringUtils.getBean(ILiveCompletionPointsRecordService.class);
@@ -485,6 +487,9 @@ public class WebSocketServer {
                                 sendMessage(session, JSONObject.toJSONString(R.error("你已被禁言")));
                                 return;
                             }
+                            if (!liveWatchUser.isEmpty() && liveWatchUser.get(0).getSingleVisible() != null) {
+                                liveMsg.setSingleVisible(liveWatchUser.get(0).getSingleVisible());
+                            }
                         } catch (Exception e) {
                             log.error("[WebSocket-sendMsg] 检查禁言状态失败, liveId={}, userId={}, error={}",
                                     liveId, msg.getUserId(), e.getMessage(), e);
@@ -594,10 +599,16 @@ public class WebSocketServer {
                     break;
                 case "showCart":
                     msg.setOn(true);
+                    if (msg.getStatus() != null) {
+                        liveService.updateShowCartWithoutBroadcast(liveId, msg.getStatus());
+                    }
                     enqueueMessage(liveId, JSONObject.toJSONString(R.ok().put("data", msg)), true);
                     break;
                 case "singleVisible":
-                    liveWatchUserService.updateSingleVisible(liveId, msg.getStatus(),msg.getUserId());
+                    msg.setOn(true);
+                    if (msg.getStatus() != null && msg.getUserId() != null) {
+                        liveWatchUserService.updateSingleVisible(liveId, msg.getStatus(), msg.getUserId());
+                    }
                     // 管理员消息插队
                     enqueueMessage(liveId, JSONObject.toJSONString(R.ok().put("data", msg)), true);
                     break;
@@ -659,6 +670,8 @@ public class WebSocketServer {
         } else {
             redisCache.deleteObject(String.format(LiveKeysConstant.LIVE_COUPON_NUM , couponIssueId));
         }
+        LiveConsoleOpLog opLog = saveConsoleCouponOpLog(liveId, couponIssueId, status);
+        attachOpLog(msg, opLog);
         // 管理员消息插队
         enqueueMessage(liveId, JSONObject.toJSONString(R.ok().put("data", msg)), true);
     }
@@ -690,6 +703,8 @@ public class WebSocketServer {
         if (Objects.nonNull(liveRedConf)) {
             liveService.asyncToCacheLiveConfig(liveId);
             msg.setData(JSONObject.toJSONString(liveRedConf));
+            LiveConsoleOpLog opLog = saveConsoleRedOpLog(liveId, liveRedConf, status);
+            attachOpLog(msg, opLog);
             // 管理员消息插队
             enqueueMessage(liveId, JSONObject.toJSONString(R.ok().put("data", msg)), true);
         }
@@ -706,11 +721,138 @@ public class WebSocketServer {
         if (Objects.nonNull(liveLotteryConf)) {
             liveService.asyncToCacheLiveConfig(liveId);
             msg.setData(JSONObject.toJSONString(liveLotteryConf));
+            LiveConsoleOpLog opLog = saveConsoleLotteryOpLog(liveId, liveLotteryConf, status);
+            attachOpLog(msg, opLog);
             // 管理员消息插队
             enqueueMessage(liveId, JSONObject.toJSONString(R.ok().put("data", msg)), true);
         }
     }
 
+    /**
+     * 中控台红包:仅结算时挂载 REST/定时任务已写入的留存,不在 WebSocket 侧新建记录
+     */
+    private LiveConsoleOpLog saveConsoleRedOpLog(Long liveId, LiveRedConf liveRedConf, Integer status) {
+        if (liveRedConf == null || status == null) {
+            return null;
+        }
+        if (status == 2 || status == -1) {
+            return findRecentRedSettleOpLog(liveId, liveRedConf.getRedId());
+        }
+        return null;
+    }
+
+    /**
+     * 查询 REST 结算接口刚写入的红包结算留存,供 WebSocket 挂载 opLog
+     */
+    private LiveConsoleOpLog findRecentRedSettleOpLog(Long liveId, Long redId) {
+        LiveConsoleOpLog query = new LiveConsoleOpLog();
+        query.setLiveId(liveId);
+        query.setBizId(redId);
+        List<LiveConsoleOpLog> list = liveConsoleOpLogService.selectLiveConsoleOpLogList(query);
+        if (list == null || list.isEmpty()) {
+            return null;
+        }
+        long now = System.currentTimeMillis();
+        for (LiveConsoleOpLog item : list) {
+            if (item.getCreateTime() == null
+                    || !Objects.equals(item.getOpType(), LiveConsoleOpLog.OP_RED_SETTLE)) {
+                continue;
+            }
+            if (now - item.getCreateTime().getTime() <= 30000) {
+                return item;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * 中控台抽奖:仅结算时挂载 REST 已写入的留存,不在 WebSocket 侧新建记录
+     */
+    private LiveConsoleOpLog saveConsoleLotteryOpLog(Long liveId, LiveLotteryConf liveLotteryConf, Integer status) {
+        if (liveLotteryConf == null || status == null) {
+            return null;
+        }
+        if (status == 2 || status == -1) {
+            return findRecentLotterySettleOpLog(liveId, liveLotteryConf.getLotteryId());
+        }
+        return null;
+    }
+
+    /**
+     * 查询 REST 结算接口刚写入的抽奖结算留存,供 WebSocket 挂载 opLog
+     */
+    private LiveConsoleOpLog findRecentLotterySettleOpLog(Long liveId, Long lotteryId) {
+        LiveConsoleOpLog query = new LiveConsoleOpLog();
+        query.setLiveId(liveId);
+        query.setBizId(lotteryId);
+        List<LiveConsoleOpLog> list = liveConsoleOpLogService.selectLiveConsoleOpLogList(query);
+        if (list == null || list.isEmpty()) {
+            return null;
+        }
+        long now = System.currentTimeMillis();
+        for (LiveConsoleOpLog item : list) {
+            if (item.getCreateTime() == null
+                    || !Objects.equals(item.getOpType(), LiveConsoleOpLog.OP_LOTTERY_SETTLE)) {
+                continue;
+            }
+            if (now - item.getCreateTime().getTime() <= 30000) {
+                return item;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * 中控台优惠券展示留存
+     */
+    private LiveConsoleOpLog saveConsoleCouponOpLog(Long liveId, Long couponIssueId, Integer status) {
+        if (status == null || status != 1 || couponIssueId == null) {
+            return null;
+        }
+        LiveConsoleOpLog recent = findRecentCouponShowOpLog(liveId, couponIssueId);
+        if (recent != null) {
+            return recent;
+        }
+        LiveCouponIssue liveCouponIssue = liveCouponIssueService.selectLiveCouponIssueById(couponIssueId);
+        if (liveCouponIssue == null || liveCouponIssue.getCouponId() == null) {
+            return null;
+        }
+        LiveCoupon liveCoupon = liveCouponMapper.selectLiveCouponById(liveCouponIssue.getCouponId());
+        return liveConsoleOpLogService.saveCouponShowLog(
+                liveId,
+                couponIssueId,
+                liveCoupon,
+                LiveConsoleOpLog.HANDLE_CONSOLE
+        );
+    }
+
+    /**
+     * 中控台展示券时 REST 已写入留存,WebSocket 复用近期记录避免重复插入
+     */
+    private LiveConsoleOpLog findRecentCouponShowOpLog(Long liveId, Long couponIssueId) {
+        LiveConsoleOpLog query = new LiveConsoleOpLog();
+        query.setLiveId(liveId);
+        query.setBizId(couponIssueId);
+        List<LiveConsoleOpLog> list = liveConsoleOpLogService.selectLiveConsoleOpLogList(query);
+        if (list == null || list.isEmpty()) {
+            return null;
+        }
+        LiveConsoleOpLog latest = list.get(0);
+        if (latest.getCreateTime() == null) {
+            return null;
+        }
+        long ageMs = System.currentTimeMillis() - latest.getCreateTime().getTime();
+        if (ageMs > 5000) {
+            return null;
+        }
+        Integer opType = latest.getOpType();
+        if (opType != null
+                && (opType == LiveConsoleOpLog.OP_COUPON_SHOW || opType == LiveConsoleOpLog.OP_VERIFY_COUPON_SHOW)) {
+            return latest;
+        }
+        return null;
+    }
+
     //错误时调用
     @OnError
     public void onError(Session session, Throwable throwable) {
@@ -906,7 +1048,20 @@ public class WebSocketServer {
         }
     }
 
-    public void sendIntegralMessage(Long liveId, Long userId,Long scoreAmount) {
+    /**
+     * 将中控台操作留存挂载到 WebSocket 消息体,供 App 端展示
+     */
+    public static void attachOpLog(SendMsgVo msg, LiveConsoleOpLog opLog) {
+        if (msg != null && opLog != null) {
+            msg.setOpLog(LiveConsoleOpLogVo.from(opLog));
+        }
+    }
+
+    public void sendIntegralMessage(Long liveId, Long userId, Long scoreAmount) {
+        sendIntegralMessage(liveId, userId, scoreAmount, null);
+    }
+
+    public void sendIntegralMessage(Long liveId, Long userId, Long scoreAmount, LiveConsoleOpLog opLog) {
         ConcurrentHashMap<Long, Session> room = getRoom(liveId);
         Session session = room.get(userId);
         if (session == null || !session.isOpen()) {
@@ -919,6 +1074,7 @@ public class WebSocketServer {
         sendMsgVo.setCmd("Integral");
         sendMsgVo.setMsg("恭喜你成功获得观看奖励:" + scoreAmount + "积分");
         sendMsgVo.setData(String.valueOf(scoreAmount));
+        attachOpLog(sendMsgVo, opLog);
 
         if(Objects.isNull(session)) return;
         sendMessage(session, JSONObject.toJSONString(R.ok().put("data", sendMsgVo)));
@@ -1002,6 +1158,17 @@ public class WebSocketServer {
         }
     }
 
+    /**
+     * 广播中控台指令到 App 端(管理员消息插队)
+     */
+    public void broadcastLiveCmd(SendMsgVo msg) {
+        if (msg == null || msg.getLiveId() == null || StringUtils.isEmpty(msg.getCmd())) {
+            return;
+        }
+        msg.setOn(true);
+        enqueueMessage(msg.getLiveId(), JSONObject.toJSONString(R.ok().put("data", msg)), true);
+    }
+
     /**
      * 广播消息
      * @param liveId   直播间ID
@@ -1329,6 +1496,12 @@ public class WebSocketServer {
                 data.put("couponTime", liveCoupon.getCouponTime());
                 msg.setData(JSON.toJSONString(data));
                 liveCouponMapper.updateChangeShow(task.getLiveId(), liveCouponIssue.getId());
+                attachOpLog(msg, liveConsoleOpLogService.saveCouponShowLog(
+                        task.getLiveId(),
+                        liveCouponIssue.getId(),
+                        liveCoupon,
+                        LiveConsoleOpLog.HANDLE_AUTO
+                ));
             } else if (task.getTaskType() == 6L) {
                 // 上架/下架商品
                 msg.setCmd("goods");
@@ -1622,7 +1795,8 @@ public class WebSocketServer {
             log.debug("[实时完课检查] liveId={}, userId={}, duration={}秒", liveId, userId, duration);
 
             // 1. 调用完课记录服务检查并创建完课记录
-            completionPointsRecordService.checkAndCreateCompletionRecord(liveId, userId, duration);
+            LiveCompletionPointsRecord createdRecord =
+                    completionPointsRecordService.checkAndCreateCompletionRecord(liveId, userId, duration);
 
             // 2. 查询是否有新的未领取完课记录
             List<LiveCompletionPointsRecord> unreceivedRecords =
@@ -1633,19 +1807,32 @@ public class WebSocketServer {
                 return;
             }
 
+            LiveCompletionPointsRecord notifyRecord = unreceivedRecords.get(0);
+
             // 3. 构建推送消息
             SendMsgVo sendMsgVo = new SendMsgVo();
             sendMsgVo.setLiveId(liveId);
             sendMsgVo.setUserId(userId);
             sendMsgVo.setCmd("completionPoints");
             sendMsgVo.setMsg("完成任务!");
-            sendMsgVo.setData(JSONObject.toJSONString(unreceivedRecords.get(0)));
+            sendMsgVo.setData(JSONObject.toJSONString(notifyRecord));
+            if (createdRecord != null) {
+                LiveConsoleOpLog opLog = liveConsoleOpLogService.saveLog(
+                        liveId,
+                        LiveConsoleOpLog.OP_COMPLETION_POINTS,
+                        LiveConsoleOpLog.HANDLE_AUTO,
+                        createdRecord.getId(),
+                        "完课积分" + createdRecord.getPointsAwarded() + "分"
+                );
+                liveConsoleOpLogService.bindOpLogUser(opLog.getId(), liveId, userId);
+                attachOpLog(sendMsgVo, opLog);
+            }
 
             // 4. 实时推送完课积分弹窗
             sendCompletionPointsMessage(liveId, userId, sendMsgVo);
 
             log.info("[实时完课推送] 发送完课积分弹窗通知, liveId={}, userId={}, points={}, duration={}秒",
-                    liveId, userId, unreceivedRecords.get(0).getPointsAwarded(), duration);
+                    liveId, userId, notifyRecord.getPointsAwarded(), duration);
 
         } catch (Exception e) {
             log.error("[实时完课推送] 实时检查完课积分失败, liveId={}, userId={}, duration={}",

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

@@ -76,7 +76,7 @@ public interface FsUserVideoFavoriteMapper
 
 
     @Select("<script>" +
-            "select video_id, ifnull(count(1), 0) > 0 as favorited " +
+            "select video_id, ifnull(count(1), 0) > 0 as favorite " +
             "from fs_user_video_favorite " +
             "where video_id in " +
             "<foreach item='videoId' collection='videoIds' open='(' separator=',' close=')'>" +

+ 7 - 3
fs-service/src/main/java/com/fs/course/mapper/FsUserVideoMapper.java

@@ -146,7 +146,7 @@ public interface FsUserVideoMapper
             "<if test = ' maps.keyword!=null and maps.keyword != \"\" '> " +
             "and v.title like CONCAT('%',#{maps.keyword},'%') " +
             "</if>" +
-            " order by RAND() "+
+            " order by v.create_time desc "+
             "</script>"})
     List<FsUserVideoListUVO> selectFsUserVideoListUVO(@Param("maps") FsUserVideoListUParam param);
 
@@ -264,7 +264,11 @@ public interface FsUserVideoMapper
     @Select({"<script> " +
             "select v.video_id as id,v.talent_id as talentId,t.user_id as userId,v.title,v.description as msg,t.nick_name as username,t.avatar as headImg, " +
             "v.thumbnail as cover,v.url as src,v.likes as likeNum,v.comments as smsNum,v.favorite_num as favoriteNum," +
-            "v.create_time,v.views as playNumber,v.product_id,p.img_url,p.package_name,v.upload_type,v.shares,v.add_num,v.is_audit,v.fail_reason,v.status from fs_user_video v " +
+            "v.create_time,v.views as playNumber,v.product_id,p.img_url,p.package_name,v.upload_type,v.shares,v.add_num,v.is_audit,v.fail_reason,v.status" +
+            "<if test = 'userId != null'> " +
+            ",CASE WHEN EXISTS (SELECT 1 FROM fs_user_talent_follow tf WHERE tf.talent_id = v.talent_id AND tf.user_id = #{userId}) THEN '1' ELSE '0' END as isFollow " +
+            "</if> " +
+            "from fs_user_video v " +
             "left join fs_user_talent t on t.talent_id = v.talent_id " +
             " left join fs_package p on p.package_id = v.product_id " +
             "where v.is_del = 0 and (" +
@@ -276,7 +280,7 @@ public interface FsUserVideoMapper
             " order by v.create_time" +
             "</if>" +
             "</script>"})
-    List<FsUserVideoListUVO> selectFsUserVideoListUVOByUser(@Param("talentId") Long talentId, @Param("oneSelf") boolean oneSelf);
+    List<FsUserVideoListUVO> selectFsUserVideoListUVOByUser(@Param("talentId") Long talentId, @Param("oneSelf") boolean oneSelf, @Param("userId") Long userId);
 
     // 评论数加一
     int addCommentCount(Long videoId);

+ 2 - 25
fs-service/src/main/java/com/fs/course/service/impl/FsUserVideoServiceImpl.java

@@ -381,17 +381,10 @@ public class FsUserVideoServiceImpl implements IFsUserVideoService {
 
     @Override
     public List<FsUserVideoListUVO> selectFsUserVideoListUVOByUser(Long talentId, boolean oneSelf, Long userId) {
-        List<FsUserVideoListUVO> list = fsUserVideoMapper.selectFsUserVideoListUVOByUser(talentId, oneSelf);
-        /*if (param != null && param.getUserId() != null) {
-            Long userId = param.getUserId();
+        List<FsUserVideoListUVO> list = fsUserVideoMapper.selectFsUserVideoListUVOByUser(talentId, oneSelf, userId);
+        if (list != null && !list.isEmpty() && userId != null) {
             list = selectLikesAndFavorites(userId, list);
-        }*/
-        // 当前视频是否被自己喜欢或收藏
-        if (list.size() > 0) {
-            selectLikesAndFavoritesByMyself(list,userId);
         }
-
-
         return list;
     }
 
@@ -432,22 +425,6 @@ public class FsUserVideoServiceImpl implements IFsUserVideoService {
         return R.ok();
     }
 
-    private void selectLikesAndFavoritesByMyself(List<FsUserVideoListUVO> list, long userId) {
-        List<Long> videoIds = list.stream().map(vo -> Long.parseLong(vo.getId())).collect(Collectors.toList());
-        Map<Long, VideoLikeStatusDTO> likeMaps = fsUserVideoLikeMapper.checkLikes(videoIds, userId);
-        Map<Long, VideoFavoriteStatusDTO> FavoriteMaps = fsUserVideoFavoriteMapper.checkFavorites(videoIds, userId);
-        long videoId;
-        for (FsUserVideoListUVO entity : list) {
-            videoId = Long.parseLong(entity.getId());
-            if (likeMaps.containsKey(videoId)) {
-                entity.setLike(1);
-            }
-            if (FavoriteMaps.containsKey(videoId)) {
-                entity.setFavorite(1);
-            }
-        }
-    }
-
     public static String updateUrlPrefix(String url) {
         final String oldPrefix = "https://obs.ylrztop.com";
         final String newPrefix = "https://rtobs.ylrztop.com";

+ 81 - 0
fs-service/src/main/java/com/fs/live/domain/LiveConsoleOpLog.java

@@ -0,0 +1,81 @@
+package com.fs.live.domain;
+
+import com.fs.common.annotation.Excel;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 直播中控台操作留存记录
+ * <p>对应表:live_console_op_log</p>
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class LiveConsoleOpLog extends BaseEntity {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 操作类型:优惠券展示 */
+    public static final int OP_COUPON_SHOW = 1;
+    /** 操作类型:核销券展示 */
+    public static final int OP_VERIFY_COUPON_SHOW = 2;
+    /** 操作类型:红包结算 */
+    public static final int OP_RED_SETTLE = 3;
+    /** 操作类型:抽奖结算 */
+    public static final int OP_LOTTERY_SETTLE = 4;
+    /** 操作类型:红包发放 */
+    public static final int OP_RED_SEND = 5;
+    /** 操作类型:抽奖发放 */
+    public static final int OP_LOTTERY_SEND = 6;
+    /** 操作类型:完课积分 */
+    public static final int OP_COMPLETION_POINTS = 7;
+    /** 操作类型:完课优惠券 */
+    public static final int OP_COMPLETION_COUPON = 8;
+    /** 操作类型:观看奖励积分 */
+    public static final int OP_WATCH_REWARD_POINTS = 9;
+    /** 操作类型:观看奖励优惠券 */
+    public static final int OP_WATCH_REWARD_COUPON = 10;
+
+    /** 触发类型:中控台人工操作 */
+    public static final int HANDLE_CONSOLE = 1;
+    /** 触发类型:运营自动化(定时任务等) */
+    public static final int HANDLE_AUTO = 2;
+
+    /** 主键ID */
+    private Long id;
+
+    /** 直播间ID */
+    @Excel(name = "直播间ID")
+    private Long liveId;
+
+    /**
+     * 操作类型
+     * @see #OP_COUPON_SHOW
+     */
+    @Excel(name = "操作类型", readConverterExp = "1=优惠券展示,2=核销券展示,3=红包结算,4=抽奖结算,5=红包发放,6=抽奖发放,7=完课积分,8=完课优惠券,9=观看奖励积分,10=观看奖励优惠券")
+    private Integer opType;
+
+    /**
+     * 触发类型
+     * @see #HANDLE_CONSOLE
+     * @see #HANDLE_AUTO
+     */
+    @Excel(name = "触发类型", readConverterExp = "1=中控台,2=运营自动化")
+    private Integer handleType;
+
+    /** 业务ID(如优惠券ID、红包ID、抽奖ID、完课记录ID等) */
+    @Excel(name = "业务ID")
+    private Long bizId;
+
+    /** 业务名称(展示用,如优惠券标题、红包名称等) */
+    @Excel(name = "业务名称")
+    private String bizName;
+
+    /** 操作人用户ID(自动化场景可为空) */
+    @Excel(name = "操作人ID")
+    private Long operatorId;
+
+    /** 操作人名称(自动化场景默认为「运营自动化」) */
+    @Excel(name = "操作人名称")
+    private String operatorName;
+}

+ 46 - 0
fs-service/src/main/java/com/fs/live/domain/LiveConsoleOpLogUser.java

@@ -0,0 +1,46 @@
+package com.fs.live.domain;
+
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+
+/**
+ * 中控台操作留存与用户关联
+ * <p>对应表:live_console_op_log_user,用于记录某条留存涉及的用户(如定时自动发券)</p>
+ */
+@Data
+@NoArgsConstructor
+@EqualsAndHashCode(callSuper = true)
+public class LiveConsoleOpLogUser extends BaseEntity {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 主键ID */
+    private Long id;
+
+    /** 留存记录ID,关联 live_console_op_log.id */
+    private Long opLogId;
+
+    /** 用户ID */
+    private Long userId;
+
+    /** 直播间ID */
+    private Long liveId;
+
+    /** 用户优惠券记录ID(关联 live_coupon_user.id,发券场景可选) */
+    private Long couponUserId;
+
+    public LiveConsoleOpLogUser(Long opLogId, Long userId, Long liveId) {
+        this.opLogId = opLogId;
+        this.userId = userId;
+        this.liveId = liveId;
+    }
+
+    public LiveConsoleOpLogUser(Long opLogId, Long userId, Long liveId, Long couponUserId) {
+        this.opLogId = opLogId;
+        this.userId = userId;
+        this.liveId = liveId;
+        this.couponUserId = couponUserId;
+    }
+}

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

@@ -0,0 +1,20 @@
+package com.fs.live.mapper;
+
+import com.fs.live.domain.LiveConsoleOpLog;
+
+import java.util.List;
+
+/**
+ * 直播中控台操作留存 Mapper
+ */
+public interface LiveConsoleOpLogMapper {
+
+    /** 按主键查询 */
+    LiveConsoleOpLog selectLiveConsoleOpLogById(Long id);
+
+    /** 条件查询列表 */
+    List<LiveConsoleOpLog> selectLiveConsoleOpLogList(LiveConsoleOpLog liveConsoleOpLog);
+
+    /** 新增留存记录 */
+    int insertLiveConsoleOpLog(LiveConsoleOpLog liveConsoleOpLog);
+}

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

@@ -0,0 +1,21 @@
+package com.fs.live.mapper;
+
+import com.fs.live.domain.LiveConsoleOpLogUser;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 中控台操作留存与用户关联 Mapper
+ */
+public interface LiveConsoleOpLogUserMapper {
+
+    /** 批量插入用户关联 */
+    int batchInsertLiveConsoleOpLogUser(@Param("list") List<LiveConsoleOpLogUser> list);
+
+    /** 根据留存记录ID查询关联用户 */
+    List<LiveConsoleOpLogUser> selectByOpLogId(@Param("opLogId") Long opLogId);
+
+    /** 查询用户在指定直播间的留存关联 */
+    List<LiveConsoleOpLogUser> selectByLiveIdAndUserId(@Param("liveId") Long liveId, @Param("userId") Long userId);
+}

+ 3 - 0
fs-service/src/main/java/com/fs/live/param/CouponPO.java

@@ -34,4 +34,7 @@ public class CouponPO extends BaseQueryParam {
     /** 状态筛选(0:未核销,1:已核销,2:已过期) */
     private Integer status;
 
+    /** 中控台操作留存 ID(App 领取时回传) */
+    private Long opLogId;
+
 }

+ 20 - 0
fs-service/src/main/java/com/fs/live/param/LiveCompletionCouponAnswerItem.java

@@ -0,0 +1,20 @@
+package com.fs.live.param;
+
+import lombok.Data;
+
+/**
+ * 完课优惠券单题作答
+ */
+@Data
+public class LiveCompletionCouponAnswerItem {
+
+    /** 题目 ID */
+    private Long questionId;
+
+    /**
+     * 用户答案
+     * 单选:如 A
+     * 多选:如 A,B 或 JSON 数组字符串 ["A","B"]
+     */
+    private String answer;
+}

+ 17 - 17
fs-service/src/main/java/com/fs/live/param/LiveCompletionCouponAnswerParam.java

@@ -1,17 +1,17 @@
-package com.fs.live.param;

-

-import com.fs.live.domain.LiveQuestionBank;

-import lombok.Data;

-

-import java.util.List;

-

-/**

- * 直播完课优惠券答题参数

- */

-@Data

-public class LiveCompletionCouponAnswerParam {

-

-    private Long liveId;

-

-    private List<LiveQuestionBank> questions;

-}

+package com.fs.live.param;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 直播完课优惠券答题参数
+ */
+@Data
+public class LiveCompletionCouponAnswerParam {
+
+    private Long liveId;
+
+    /** 用户作答列表 */
+    private List<LiveCompletionCouponAnswerItem> answers;
+}

+ 16 - 0
fs-service/src/main/java/com/fs/live/param/LiveCompletionCouponClaimParam.java

@@ -0,0 +1,16 @@
+package com.fs.live.param;
+
+import lombok.Data;
+
+/**
+ * 完课优惠券领取参数
+ */
+@Data
+public class LiveCompletionCouponClaimParam {
+
+    /** 直播间 ID */
+    private Long liveId;
+
+    /** 中控台操作留存 ID(WebSocket opLog.id) */
+    private Long opLogId;
+}

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

@@ -21,5 +21,7 @@ public class LotteryPO {
      * */
     private Long userId;
 
+    /** 中控台操作留存 ID(App 领取时回传) */
+    private Long opLogId;
 
 }

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

@@ -25,5 +25,7 @@ public class RedPO {
      * */
     private Long userId;
 
+    /** 中控台操作留存 ID(App 领取时回传) */
+    private Long opLogId;
 
 }

+ 9 - 24
fs-service/src/main/java/com/fs/live/service/ILiveCompletionCouponService.java

@@ -1,7 +1,10 @@
 package com.fs.live.service;
 
 import com.fs.live.domain.Live;
+import com.fs.live.domain.LiveCouponUser;
 import com.fs.live.param.LiveCompletionCouponAnswerParam;
+import com.fs.live.param.LiveCompletionCouponClaimParam;
+import com.fs.live.vo.LiveCompletionCouponAnswerResult;
 import com.fs.live.vo.LiveCompletionCouponConfigVO;
 import com.fs.live.vo.LiveCompletionCouponNotifyResult;
 import com.fs.live.vo.LiveCompletionCouponStatusVO;
@@ -14,45 +17,27 @@ import java.util.List;
  */
 public interface ILiveCompletionCouponService {
 
-    /**
-     * 解析完课优惠券配置
-     */
     LiveCompletionCouponConfigVO parseCompletionCouponConfig(Live live);
 
-    /**
-     * 预检查完课弹窗(不标记已通知,供定时任务/WebSocket在推送成功后标记)
-     */
     LiveCompletionCouponNotifyResult prepareCompletionCouponNotify(Long liveId, Long userId, Long watchDuration);
 
-    /**
-     * 预检查完课弹窗
-     *
-     * @param forcePush true 时跳过「今日已推送」校验,供手动触发接口强制 WebSocket 推送
-     */
     LiveCompletionCouponNotifyResult prepareCompletionCouponNotify(Long liveId, Long userId, Long watchDuration, boolean forcePush);
 
-    /**
-     * 标记今日已推送完课优惠券弹窗
-     */
     void markCompletionCouponNotified(Long liveId, Long userId);
 
-    /**
-     * @deprecated 请使用 {@link #prepareCompletionCouponNotify} + {@link #markCompletionCouponNotified}
-     */
     LiveCompletionCouponNotifyResult checkAndNotifyCompletionCoupon(Long liveId, Long userId, Long watchDuration);
 
-    /**
-     * 查询完课优惠券状态及今日问题
-     */
     LiveCompletionCouponStatusVO getCompletionCouponStatus(Long liveId, Long userId, Long watchDuration);
 
+    List<LiveCompletionQuestionVO> getCompletionQuestions(Long liveId);
+
     /**
-     * 获取今日问题列表(不含答案)
+     * 提交今日问题作答(仅记录答题结果,不发券
      */
-    List<LiveCompletionQuestionVO> getCompletionQuestions(Long liveId);
+    LiveCompletionCouponAnswerResult submitAnswers(LiveCompletionCouponAnswerParam param, Long userId);
 
     /**
-     * 提交答题,全部答对后发放优惠券
+     * 领取完课福利券(需先答题全部正确,点击领取后发券并写入留存关联)
      */
-    void submitAnswerAndIssueCoupon(LiveCompletionCouponAnswerParam param, Long userId);
+    LiveCouponUser claimCompletionCoupon(LiveCompletionCouponClaimParam param, Long userId);
 }

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

@@ -16,7 +16,7 @@ public interface ILiveCompletionPointsRecordService {
      * @param userId 用户ID
      * @param watchDuration 观看时长(秒)
      */
-    void checkAndCreateCompletionRecord(Long liveId, Long userId, Long watchDuration);
+    LiveCompletionPointsRecord checkAndCreateCompletionRecord(Long liveId, Long userId, Long watchDuration);
 
     /**
      * 用户领取完课积分

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

@@ -0,0 +1,43 @@
+package com.fs.live.service;
+
+import com.fs.live.domain.LiveConsoleOpLog;
+import com.fs.live.domain.LiveConsoleOpLogUser;
+import com.fs.live.domain.LiveCoupon;
+import com.fs.live.vo.LiveConsoleOpLogRecordVo;
+
+import java.util.List;
+
+/**
+ * 直播中控台操作留存 Service
+ */
+public interface ILiveConsoleOpLogService {
+
+    List<LiveConsoleOpLog> selectLiveConsoleOpLogList(LiveConsoleOpLog liveConsoleOpLog);
+
+    LiveConsoleOpLog saveLog(Long liveId, Integer opType, Integer handleType, Long bizId, String bizName);
+
+    /**
+     * 抽奖结算留存:同一抽奖仅写入一条,重复调用返回已有记录
+     */
+    LiveConsoleOpLog saveLotterySettleLog(Long liveId, Integer handleType, Long lotteryId, String bizName);
+
+    /**
+     * 红包结算留存:同一红包仅写入一条,重复调用返回已有记录
+     */
+    LiveConsoleOpLog saveRedSettleLog(Long liveId, Integer handleType, Long redId, String bizName);
+
+    LiveConsoleOpLog saveCouponShowLog(Long liveId, Long couponIssueId, LiveCoupon coupon, Integer handleType);
+
+    void bindOpLogUsers(Long opLogId, Long liveId, List<LiveConsoleOpLogUser> relations);
+
+    List<LiveConsoleOpLogUser> selectOpLogUsers(Long opLogId);
+
+    void bindOpLogUser(Long opLogId, Long liveId, Long userId);
+
+    void bindOpLogUser(Long opLogId, Long liveId, Long userId, Long couponUserId);
+
+    /**
+     * 查询用户在指定直播间的留存记录(含领取/结束状态)
+     */
+    List<LiveConsoleOpLogRecordVo> listUserOpLogRecords(Long liveId, Long userId);
+}

+ 2 - 1
fs-service/src/main/java/com/fs/live/service/ILiveRedConfService.java

@@ -2,6 +2,7 @@ package com.fs.live.service;
 
 
 import com.fs.common.core.domain.R;
+import com.fs.live.domain.LiveConsoleOpLog;
 import com.fs.live.domain.LiveRedConf;
 import com.fs.live.param.RedPO;
 
@@ -82,7 +83,7 @@ public interface ILiveRedConfService {
     List<LiveRedConf> selectActivedRed(Long liveId);
 
     // 结算掉红包
-    void finishRedStatusBySetIds(Set<String> range);
+    List<LiveConsoleOpLog> finishRedStatusBySetIds(Set<String> range);
 
     // 更新红包数量
     void redStatusUpdate(Set<Long> redIds);

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

@@ -213,6 +213,11 @@ public interface ILiveService
 
     R updateShowCart(Long liveId, Integer showCart);
 
+    /**
+     * 仅更新购物车显示状态(不触发 Redis 广播,供 WebSocket 中控台指令使用)
+     */
+    void updateShowCartWithoutBroadcast(Long liveId, Integer showCart);
+
     String getGotoWxAppLiveLink(String linkStr, String appid);
 
     R liveListAll(PageRequest pageRequest);

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

@@ -158,6 +158,11 @@ public interface ILiveWatchUserService {
      */
     Long getTotalWatchDuration(Long liveId, Long userId);
 
+    /**
+     * 获取用户观看时长(秒),优先取 DB 与 Redis 较大值
+     */
+    Long getUserWatchDuration(Long liveId, Long userId);
+
     /**
      * 批量更新直播间观看用户
      * @param liveWatchUsers 需要更新的观看用户列表

+ 112 - 16
fs-service/src/main/java/com/fs/live/service/impl/LiveCompletionCouponServiceImpl.java

@@ -7,8 +7,11 @@ import com.fs.common.exception.base.BaseException;
 import com.fs.common.utils.StringUtils;
 import com.fs.live.domain.*;
 import com.fs.live.mapper.LiveQuestionBankMapper;
+import com.fs.live.param.LiveCompletionCouponAnswerItem;
 import com.fs.live.param.LiveCompletionCouponAnswerParam;
+import com.fs.live.param.LiveCompletionCouponClaimParam;
 import com.fs.live.service.*;
+import com.fs.live.vo.LiveCompletionCouponAnswerResult;
 import com.fs.live.vo.LiveCompletionCouponConfigVO;
 import com.fs.live.vo.LiveCompletionCouponInfoVO;
 import com.fs.live.vo.LiveCompletionCouponNotifyResult;
@@ -40,6 +43,7 @@ public class LiveCompletionCouponServiceImpl implements ILiveCompletionCouponSer
     private static final String COMPLETION_COUPON_TYPE = "4";
 
     private static final String NOTIFY_REDIS_KEY_PREFIX = "live:completion:coupon:notify:";
+    private static final String ANSWER_RECORD_REDIS_KEY_PREFIX = "live:completion:coupon:answer:record:";
 
     @Autowired
     private ILiveService liveService;
@@ -65,6 +69,9 @@ public class LiveCompletionCouponServiceImpl implements ILiveCompletionCouponSer
     @Autowired
     private RedisCache redisCache;
 
+    @Autowired
+    private ILiveConsoleOpLogService liveConsoleOpLogService;
+
     @Override
     public LiveCompletionCouponConfigVO parseCompletionCouponConfig(Live live) {
         LiveCompletionCouponConfigVO vo = new LiveCompletionCouponConfigVO();
@@ -163,6 +170,9 @@ public class LiveCompletionCouponServiceImpl implements ILiveCompletionCouponSer
         status.setQuestions(questions);
         status.setEligible(isWatchRateEligible(liveId, userId, watchDuration, config));
         status.setReceivedToday(hasIssuedToday(liveId, userId, config.getCouponId()));
+        AnswerRecord answerRecord = getAnswerRecordToday(liveId, userId);
+        status.setAnsweredToday(answerRecord != null);
+        status.setAllCorrect(answerRecord != null && answerRecord.isAllCorrect());
         return status;
     }
 
@@ -177,11 +187,11 @@ public class LiveCompletionCouponServiceImpl implements ILiveCompletionCouponSer
 
     @Override
     @Transactional(rollbackFor = Exception.class)
-    public void submitAnswerAndIssueCoupon(LiveCompletionCouponAnswerParam param, Long userId) {
+    public LiveCompletionCouponAnswerResult submitAnswers(LiveCompletionCouponAnswerParam param, Long userId) {
         if (param == null || param.getLiveId() == null) {
             throw new BaseException("参数错误");
         }
-        if (param.getQuestions() == null || param.getQuestions().isEmpty()) {
+        if (param.getAnswers() == null || param.getAnswers().isEmpty()) {
             throw new BaseException("请完成今日问题");
         }
 
@@ -190,11 +200,9 @@ public class LiveCompletionCouponServiceImpl implements ILiveCompletionCouponSer
         if (!config.isEnabled() || config.getCouponId() == null) {
             throw new BaseException("完课优惠券未开启");
         }
-
         if (!isWatchRateEligible(liveId, userId, null, config)) {
             throw new BaseException("未达到完课要求");
         }
-
         if (hasIssuedToday(liveId, userId, config.getCouponId())) {
             throw new BaseException("今日福利券已领取");
         }
@@ -204,18 +212,55 @@ public class LiveCompletionCouponServiceImpl implements ILiveCompletionCouponSer
             throw new BaseException("未配置今日问题");
         }
 
-        validateAnswers(param.getQuestions(), configuredQuestionIds);
+        boolean allCorrect = evaluateAnswers(param.getAnswers(), configuredQuestionIds);
+        saveAnswerRecordToday(liveId, userId, allCorrect);
+
+        LiveCompletionCouponAnswerResult result = new LiveCompletionCouponAnswerResult();
+        result.setAllCorrect(allCorrect);
+        return result;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public LiveCouponUser claimCompletionCoupon(LiveCompletionCouponClaimParam param, Long userId) {
+        if (param == null || param.getLiveId() == null) {
+            throw new BaseException("参数错误");
+        }
+        Long liveId = param.getLiveId();
+        CompletionCouponConfig config = resolveConfig(liveId);
+        if (!config.isEnabled() || config.getCouponId() == null) {
+            throw new BaseException("完课优惠券未开启");
+        }
+        if (!isWatchRateEligible(liveId, userId, null, config)) {
+            throw new BaseException("未达到完课要求");
+        }
+        if (hasIssuedToday(liveId, userId, config.getCouponId())) {
+            throw new BaseException("今日福利券已领取");
+        }
+        AnswerRecord answerRecord = getAnswerRecordToday(liveId, userId);
+        if (answerRecord == null) {
+            throw new BaseException("请先完成今日问题");
+        }
+        if (!answerRecord.isAllCorrect()) {
+            throw new BaseException("回答错误,请重新作答后再领取");
+        }
 
         Live live = liveService.selectLiveByLiveId(liveId);
         if (live == null) {
             throw new BaseException("直播不存在");
         }
-        issueCoupon(live, userId, config.getCouponId());
+        LiveCouponUser couponUser = issueCoupon(live, userId, config.getCouponId());
+        liveConsoleOpLogService.bindOpLogUser(
+                param.getOpLogId(), liveId, userId, couponUser.getId());
+        return couponUser;
     }
 
-    private void validateAnswers(List<LiveQuestionBank> userAnswers, List<Long> configuredQuestionIds) {
+    /**
+     * 校验是否答完全部题目,并返回是否全部答对(答错不阻断记录)
+     */
+    private boolean evaluateAnswers(List<LiveCompletionCouponAnswerItem> userAnswers, List<Long> configuredQuestionIds) {
         Set<Long> submittedIds = userAnswers.stream()
-                .map(LiveQuestionBank::getId)
+                .map(LiveCompletionCouponAnswerItem::getQuestionId)
                 .filter(Objects::nonNull)
                 .collect(Collectors.toSet());
         if (submittedIds.size() != configuredQuestionIds.size()
@@ -228,28 +273,44 @@ public class LiveCompletionCouponServiceImpl implements ILiveCompletionCouponSer
                 .stream()
                 .collect(Collectors.toMap(LiveQuestionBank::getId, q -> q));
 
-        for (LiveQuestionBank userAnswer : userAnswers) {
-            LiveQuestionBank correctAnswer = correctAnswersMap.get(userAnswer.getId());
+        boolean allCorrect = true;
+        for (LiveCompletionCouponAnswerItem userAnswer : userAnswers) {
+            LiveQuestionBank correctAnswer = correctAnswersMap.get(userAnswer.getQuestionId());
             if (correctAnswer == null || correctAnswer.getStatus() == null || correctAnswer.getStatus() == 0) {
                 throw new BaseException("题目不存在或已停用");
             }
-            if (!isAnswerCorrect(userAnswer, correctAnswer)) {
-                throw new BaseException("回答错误,请重新作答");
+            if (!isAnswerCorrect(userAnswer.getAnswer(), correctAnswer)) {
+                allCorrect = false;
             }
         }
+        return allCorrect;
     }
 
-    private boolean isAnswerCorrect(LiveQuestionBank userAnswer, LiveQuestionBank correctAnswer) {
+    private boolean isAnswerCorrect(String userAnswerValue, LiveQuestionBank correctAnswer) {
         if (correctAnswer.getType() == null || correctAnswer.getType() == 1L) {
-            return Objects.equals(userAnswer.getAnswer(), correctAnswer.getAnswer());
+            return Objects.equals(normalizeSingleAnswer(userAnswerValue), normalizeSingleAnswer(correctAnswer.getAnswer()));
         }
-        String[] userAnswers = parseAnswerArray(userAnswer.getAnswer());
+        String[] userAnswers = parseAnswerArray(userAnswerValue);
         String[] correctAnswers = parseAnswerArray(correctAnswer.getAnswer());
         Arrays.sort(userAnswers);
         Arrays.sort(correctAnswers);
         return Arrays.equals(userAnswers, correctAnswers);
     }
 
+    private String normalizeSingleAnswer(String answer) {
+        if (StringUtils.isEmpty(answer)) {
+            return answer;
+        }
+        String trimmed = answer.trim();
+        if (trimmed.startsWith("[")) {
+            List<String> list = JSON.parseArray(trimmed, String.class);
+            if (list != null && !list.isEmpty()) {
+                return list.get(0);
+            }
+        }
+        return trimmed;
+    }
+
     private String[] parseAnswerArray(String answer) {
         if (StringUtils.isEmpty(answer)) {
             return new String[0];
@@ -298,7 +359,7 @@ public class LiveCompletionCouponServiceImpl implements ILiveCompletionCouponSer
         return getCompletionCouponConfig(live);
     }
 
-    private void issueCoupon(Live live, Long userId, Long couponId) {
+    private LiveCouponUser issueCoupon(Live live, Long userId, Long couponId) {
         LiveCoupon coupon = liveCouponService.selectLiveCouponById(couponId);
         if (coupon == null) {
             throw new BaseException("优惠券不存在");
@@ -346,6 +407,7 @@ public class LiveCompletionCouponServiceImpl implements ILiveCompletionCouponSer
         liveRewardRecordService.insertLiveRewardRecord(record);
 
         log.info("完课优惠券发放成功, liveId={}, userId={}, couponId={}", live.getLiveId(), userId, couponId);
+        return couponUser;
     }
 
     private boolean hasIssuedToday(Long liveId, Long userId, Long couponId) {
@@ -478,6 +540,28 @@ public class LiveCompletionCouponServiceImpl implements ILiveCompletionCouponSer
         redisCache.setCacheObject(buildNotifyRedisKey(liveId, userId), Boolean.TRUE, 1, TimeUnit.DAYS);
     }
 
+    private void saveAnswerRecordToday(Long liveId, Long userId, boolean allCorrect) {
+        AnswerRecord record = new AnswerRecord();
+        record.setAllCorrect(allCorrect);
+        redisCache.setCacheObject(buildAnswerRecordRedisKey(liveId, userId), record, 1, TimeUnit.DAYS);
+    }
+
+    private AnswerRecord getAnswerRecordToday(Long liveId, Long userId) {
+        Object cache = redisCache.getCacheObject(buildAnswerRecordRedisKey(liveId, userId));
+        if (cache == null) {
+            return null;
+        }
+        if (cache instanceof AnswerRecord) {
+            return (AnswerRecord) cache;
+        }
+        return JSON.parseObject(JSON.toJSONString(cache), AnswerRecord.class);
+    }
+
+    private String buildAnswerRecordRedisKey(Long liveId, Long userId) {
+        String today = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
+        return ANSWER_RECORD_REDIS_KEY_PREFIX + liveId + ":" + userId + ":" + today;
+    }
+
     private String buildNotifyRedisKey(Long liveId, Long userId) {
         String today = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
         return NOTIFY_REDIS_KEY_PREFIX + liveId + ":" + userId + ":" + today;
@@ -526,4 +610,16 @@ public class LiveCompletionCouponServiceImpl implements ILiveCompletionCouponSer
             this.finishQuestionIds = finishQuestionIds;
         }
     }
+
+    private static class AnswerRecord {
+        private boolean allCorrect;
+
+        public boolean isAllCorrect() {
+            return allCorrect;
+        }
+
+        public void setAllCorrect(boolean allCorrect) {
+            this.allCorrect = allCorrect;
+        }
+    }
 }

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

@@ -57,13 +57,13 @@ public class LiveCompletionPointsRecordServiceImpl implements ILiveCompletionPoi
      */
     @Override
     @Transactional(rollbackFor = Exception.class)
-    public void checkAndCreateCompletionRecord(Long liveId, Long userId, Long watchDuration) {
+    public LiveCompletionPointsRecord checkAndCreateCompletionRecord(Long liveId, Long userId, Long watchDuration) {
         try {
             // 1. 获取直播信息和配置
             Live live = liveService.selectLiveByLiveId(liveId);
             if (live == null) {
 
-                return;
+                return null;
             }
 
             // 2. 从数据库获取完课积分配置
@@ -72,7 +72,7 @@ public class LiveCompletionPointsRecordServiceImpl implements ILiveCompletionPoi
             // 检查是否开启完课积分功能
             if (!config.isEnabled()) {
 
-                return;
+                return null;
             }
             
             // 检查配置完整性
@@ -81,7 +81,7 @@ public class LiveCompletionPointsRecordServiceImpl implements ILiveCompletionPoi
             
             if (completionRate == null || pointsConfig == null || pointsConfig.length == 0) {
 
-                return;
+                return null;
             }
 
             // 3. 获取观看时长(如果为null,则从数据库累计直播+回放时长)
@@ -94,14 +94,14 @@ public class LiveCompletionPointsRecordServiceImpl implements ILiveCompletionPoi
 
             if (actualWatchDuration == null || actualWatchDuration <= 0) {
 
-                return;
+                return null;
             }
 
             // 4. 获取视频总时长(秒)
             Long videoDuration = live.getDuration();
             if (videoDuration == null || videoDuration <= 0) {
 
-                return;
+                return null;
             }
 
             // 5. 计算完课比例
@@ -117,7 +117,7 @@ public class LiveCompletionPointsRecordServiceImpl implements ILiveCompletionPoi
             // 6. 判断是否达到完课标准
             if (watchRate.compareTo(BigDecimal.valueOf(completionRate)) < 0) {
 
-                return;
+                return null;
             }
 
             // 7. 检查今天是否已有完课记录
@@ -127,7 +127,7 @@ public class LiveCompletionPointsRecordServiceImpl implements ILiveCompletionPoi
             LiveCompletionPointsRecord todayRecord = recordMapper.selectByUserAndDate(liveId, userId, currentDate);
             if (todayRecord != null) {
 
-                return;
+                return null;
             }
 
             // 7. 查询最近一次完课记录(不限直播间),计算连续天数
@@ -173,7 +173,7 @@ public class LiveCompletionPointsRecordServiceImpl implements ILiveCompletionPoi
 
             recordMapper.insertRecord(record);
 
-
+            return record;
 
         } catch (Exception e) {
             log.error("检查并创建完课记录失败, liveId={}, userId={}", liveId, userId, e);

+ 331 - 0
fs-service/src/main/java/com/fs/live/service/impl/LiveConsoleOpLogServiceImpl.java

@@ -0,0 +1,331 @@
+package com.fs.live.service.impl;
+
+import com.fs.common.core.domain.model.LoginUser;
+import com.fs.common.utils.DateUtils;
+import com.fs.common.utils.DictUtils;
+import com.fs.common.utils.SecurityUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.live.domain.LiveConsoleOpLog;
+import com.fs.live.domain.LiveConsoleOpLogUser;
+import com.fs.live.domain.LiveCoupon;
+import com.fs.live.domain.LiveCouponIssue;
+import com.fs.live.domain.LiveCouponIssueRelation;
+import com.fs.live.domain.LiveLotteryConf;
+import com.fs.live.domain.LiveRedConf;
+import com.fs.live.mapper.LiveConsoleOpLogMapper;
+import com.fs.live.mapper.LiveConsoleOpLogUserMapper;
+import com.fs.live.mapper.LiveCouponMapper;
+import com.fs.live.service.ILiveConsoleOpLogService;
+import com.fs.live.service.ILiveCouponIssueService;
+import com.fs.live.service.ILiveLotteryConfService;
+import com.fs.live.service.ILiveRedConfService;
+import com.fs.live.vo.LiveConsoleOpLogRecordVo;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
+
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * 直播中控台操作留存 Service 实现
+ */
+@Service
+public class LiveConsoleOpLogServiceImpl implements ILiveConsoleOpLogService {
+
+    /** 运营自动化场景默认操作人 */
+    private static final String AUTO_OPERATOR_NAME = "运营自动化";
+    /** 中控台 WebSocket 无登录上下文时的默认操作人 */
+    private static final String CONSOLE_OPERATOR_NAME = "中控台";
+
+    @Autowired
+    private LiveConsoleOpLogMapper liveConsoleOpLogMapper;
+
+    @Autowired
+    private LiveConsoleOpLogUserMapper liveConsoleOpLogUserMapper;
+
+    @Autowired
+    private ILiveRedConfService liveRedConfService;
+
+    @Autowired
+    private ILiveLotteryConfService liveLotteryConfService;
+
+    @Autowired
+    private ILiveCouponIssueService liveCouponIssueService;
+
+    @Autowired
+    private LiveCouponMapper liveCouponMapper;
+
+    @Override
+    public List<LiveConsoleOpLog> selectLiveConsoleOpLogList(LiveConsoleOpLog liveConsoleOpLog) {
+        return liveConsoleOpLogMapper.selectLiveConsoleOpLogList(liveConsoleOpLog);
+    }
+
+    @Override
+    public LiveConsoleOpLog saveLog(Long liveId, Integer opType, Integer handleType, Long bizId, String bizName) {
+        LiveConsoleOpLog log = new LiveConsoleOpLog();
+        log.setLiveId(liveId);
+        log.setOpType(opType);
+        log.setHandleType(handleType);
+        log.setBizId(bizId);
+        log.setBizName(bizName);
+        log.setCreateTime(DateUtils.getNowDate());
+        try {
+            LoginUser loginUser = SecurityUtils.getLoginUser();
+            if (loginUser != null && loginUser.getUser() != null) {
+                log.setOperatorId(loginUser.getUser().getUserId());
+                log.setOperatorName(loginUser.getUser().getNickName());
+            }
+        } catch (Exception ignored) {
+            // 中控台 WebSocket 等场景可能无登录上下文
+        }
+        if (LiveConsoleOpLog.HANDLE_AUTO == handleType && StringUtils.isEmpty(log.getOperatorName())) {
+            log.setOperatorName(AUTO_OPERATOR_NAME);
+        }
+        if (LiveConsoleOpLog.HANDLE_CONSOLE == handleType && StringUtils.isEmpty(log.getOperatorName())) {
+            log.setOperatorName(CONSOLE_OPERATOR_NAME);
+        }
+        liveConsoleOpLogMapper.insertLiveConsoleOpLog(log);
+        return log;
+    }
+
+    @Override
+    public LiveConsoleOpLog saveLotterySettleLog(Long liveId, Integer handleType, Long lotteryId, String bizName) {
+        if (liveId == null || lotteryId == null) {
+            return null;
+        }
+        LiveConsoleOpLog query = new LiveConsoleOpLog();
+        query.setLiveId(liveId);
+        query.setBizId(lotteryId);
+        query.setOpType(LiveConsoleOpLog.OP_LOTTERY_SETTLE);
+        List<LiveConsoleOpLog> existing = liveConsoleOpLogMapper.selectLiveConsoleOpLogList(query);
+        if (!CollectionUtils.isEmpty(existing)) {
+            return existing.get(0);
+        }
+        return saveLog(liveId, LiveConsoleOpLog.OP_LOTTERY_SETTLE, handleType, lotteryId, bizName);
+    }
+
+    @Override
+    public LiveConsoleOpLog saveRedSettleLog(Long liveId, Integer handleType, Long redId, String bizName) {
+        if (liveId == null || redId == null) {
+            return null;
+        }
+        LiveConsoleOpLog query = new LiveConsoleOpLog();
+        query.setLiveId(liveId);
+        query.setBizId(redId);
+        query.setOpType(LiveConsoleOpLog.OP_RED_SETTLE);
+        List<LiveConsoleOpLog> existing = liveConsoleOpLogMapper.selectLiveConsoleOpLogList(query);
+        if (!CollectionUtils.isEmpty(existing)) {
+            return existing.get(0);
+        }
+        return saveLog(liveId, LiveConsoleOpLog.OP_RED_SETTLE, handleType, redId, bizName);
+    }
+
+    @Override
+    public LiveConsoleOpLog saveCouponShowLog(Long liveId, Long couponIssueId, LiveCoupon coupon, Integer handleType) {
+        int opType = isVerifyCouponType(coupon)
+                ? LiveConsoleOpLog.OP_VERIFY_COUPON_SHOW
+                : LiveConsoleOpLog.OP_COUPON_SHOW;
+        String bizName = coupon != null ? coupon.getTitle() : null;
+        return saveLog(liveId, opType, handleType, couponIssueId, bizName);
+    }
+
+    @Override
+    public void bindOpLogUsers(Long opLogId, Long liveId, List<LiveConsoleOpLogUser> relations) {
+        if (opLogId == null || CollectionUtils.isEmpty(relations)) {
+            return;
+        }
+        Date now = DateUtils.getNowDate();
+        for (LiveConsoleOpLogUser relation : relations) {
+            relation.setOpLogId(opLogId);
+            if (relation.getLiveId() == null) {
+                relation.setLiveId(liveId);
+            }
+            relation.setCreateTime(now);
+        }
+        liveConsoleOpLogUserMapper.batchInsertLiveConsoleOpLogUser(relations);
+    }
+
+    @Override
+    public List<LiveConsoleOpLogUser> selectOpLogUsers(Long opLogId) {
+        if (opLogId == null) {
+            return Collections.emptyList();
+        }
+        return liveConsoleOpLogUserMapper.selectByOpLogId(opLogId);
+    }
+
+    @Override
+    public void bindOpLogUser(Long opLogId, Long liveId, Long userId) {
+        bindOpLogUser(opLogId, liveId, userId, null);
+    }
+
+    @Override
+    public void bindOpLogUser(Long opLogId, Long liveId, Long userId, Long couponUserId) {
+        if (opLogId == null || userId == null) {
+            return;
+        }
+        List<LiveConsoleOpLogUser> existing = liveConsoleOpLogUserMapper.selectByOpLogId(opLogId);
+        if (existing != null && existing.stream().anyMatch(r -> userId.equals(r.getUserId()))) {
+            return;
+        }
+        bindOpLogUsers(opLogId, liveId,
+                Collections.singletonList(new LiveConsoleOpLogUser(opLogId, userId, liveId, couponUserId)));
+    }
+
+    @Override
+    public List<LiveConsoleOpLogRecordVo> listUserOpLogRecords(Long liveId, Long userId) {
+        if (liveId == null || userId == null) {
+            return Collections.emptyList();
+        }
+        LiveConsoleOpLog query = new LiveConsoleOpLog();
+        query.setLiveId(liveId);
+        List<LiveConsoleOpLog> opLogs = liveConsoleOpLogMapper.selectLiveConsoleOpLogList(query);
+        if (opLogs == null || opLogs.isEmpty()) {
+            return Collections.emptyList();
+        }
+
+        Set<Long> claimedOpLogIds = new HashSet<>();
+        List<LiveConsoleOpLogUser> relations = liveConsoleOpLogUserMapper.selectByLiveIdAndUserId(liveId, userId);
+        if (relations != null) {
+            claimedOpLogIds = relations.stream()
+                    .map(LiveConsoleOpLogUser::getOpLogId)
+                    .filter(Objects::nonNull)
+                    .collect(Collectors.toSet());
+        }
+
+        Date now = DateUtils.getNowDate();
+        List<LiveConsoleOpLogRecordVo> result = new ArrayList<>(opLogs.size());
+        for (LiveConsoleOpLog opLog : opLogs) {
+            boolean claimed = opLog.getId() != null && claimedOpLogIds.contains(opLog.getId());
+            int status = resolveOpLogStatus(opLog, claimed, now);
+            result.add(LiveConsoleOpLogRecordVo.from(opLog, status));
+        }
+        return result;
+    }
+
+    /**
+     * 状态优先级:已领取 > 已结束 > 待领取
+     */
+    private int resolveOpLogStatus(LiveConsoleOpLog opLog, boolean claimed, Date now) {
+        if (claimed) {
+            return LiveConsoleOpLogRecordVo.STATUS_CLAIMED;
+        }
+        if (isOpLogEnded(opLog, now)) {
+            return LiveConsoleOpLogRecordVo.STATUS_ENDED;
+        }
+        return LiveConsoleOpLogRecordVo.STATUS_PENDING;
+    }
+
+    private boolean isOpLogEnded(LiveConsoleOpLog opLog, Date now) {
+        Date expireTime = resolveExpireTime(opLog);
+        if (expireTime != null && !now.before(expireTime)) {
+            return true;
+        }
+        return isBizEnded(opLog);
+    }
+
+    private Date resolveExpireTime(LiveConsoleOpLog opLog) {
+        if (opLog == null || opLog.getCreateTime() == null || opLog.getOpType() == null) {
+            return null;
+        }
+        Integer opType = opLog.getOpType();
+        Long bizId = opLog.getBizId();
+        if (bizId == null) {
+            return null;
+        }
+
+        Long durationMinutes = null;
+        Date limitTime = null;
+
+        switch (opType) {
+            case LiveConsoleOpLog.OP_COUPON_SHOW:
+            case LiveConsoleOpLog.OP_VERIFY_COUPON_SHOW:
+                LiveCouponIssue issue = liveCouponIssueService.selectLiveCouponIssueById(bizId);
+                if (issue != null) {
+                    limitTime = issue.getLimitTime();
+                }
+                break;
+            case LiveConsoleOpLog.OP_RED_SETTLE:
+            case LiveConsoleOpLog.OP_RED_SEND:
+                LiveRedConf redConf = liveRedConfService.selectLiveRedConfByRedId(bizId);
+                if (redConf != null) {
+                    durationMinutes = redConf.getDuration();
+                }
+                break;
+            case LiveConsoleOpLog.OP_LOTTERY_SETTLE:
+            case LiveConsoleOpLog.OP_LOTTERY_SEND:
+                LiveLotteryConf lotteryConf = liveLotteryConfService.selectLiveLotteryConfByLotteryId(bizId);
+                if (lotteryConf != null) {
+                    durationMinutes = lotteryConf.getDuration();
+                }
+                break;
+            default:
+                break;
+        }
+
+        if (limitTime != null) {
+            return limitTime;
+        }
+        if (durationMinutes != null && durationMinutes > 0) {
+            Calendar calendar = Calendar.getInstance();
+            calendar.setTime(opLog.getCreateTime());
+            calendar.add(Calendar.MINUTE, durationMinutes.intValue());
+            return calendar.getTime();
+        }
+        return null;
+    }
+
+    private boolean isBizEnded(LiveConsoleOpLog opLog) {
+        if (opLog == null || opLog.getOpType() == null || opLog.getBizId() == null) {
+            return false;
+        }
+        Integer opType = opLog.getOpType();
+        Long bizId = opLog.getBizId();
+
+        switch (opType) {
+            case LiveConsoleOpLog.OP_COUPON_SHOW:
+            case LiveConsoleOpLog.OP_VERIFY_COUPON_SHOW:
+                LiveCouponIssueRelation showRelation = liveCouponMapper.selectRelation(opLog.getLiveId(), bizId);
+                if (showRelation == null || showRelation.getIsShow() == null || showRelation.getIsShow() != 1) {
+                    return true;
+                }
+                LiveCouponIssue issue = liveCouponIssueService.selectLiveCouponIssueById(bizId);
+                return issue == null
+                        || issue.getStatus() == null
+                        || issue.getStatus() != 1
+                        || (issue.getRemainCount() != null && issue.getRemainCount() <= 0);
+            case LiveConsoleOpLog.OP_RED_SETTLE:
+            case LiveConsoleOpLog.OP_RED_SEND:
+                LiveRedConf redConf = liveRedConfService.selectLiveRedConfByRedId(bizId);
+                return redConf != null && redConf.getRedStatus() != null && redConf.getRedStatus() == 2L;
+            case LiveConsoleOpLog.OP_LOTTERY_SETTLE:
+            case LiveConsoleOpLog.OP_LOTTERY_SEND:
+                LiveLotteryConf lotteryConf = liveLotteryConfService.selectLiveLotteryConfByLotteryId(bizId);
+                return lotteryConf != null && "2".equals(lotteryConf.getLotteryStatus());
+            default:
+                return false;
+        }
+    }
+
+    /**
+     * 判断是否为核销券 / 核销优惠券类型
+     */
+    private boolean isVerifyCouponType(LiveCoupon coupon) {
+        if (coupon == null || coupon.getType() == null) {
+            return false;
+        }
+        if (coupon.getType() == 3L) {
+            return true;
+        }
+        String typeLabel = DictUtils.getDictLabel("store_coupon_type", String.valueOf(coupon.getType()));
+        return StringUtils.isNotEmpty(typeLabel)
+                && (typeLabel.contains("核销") || typeLabel.contains("核销券"));
+    }
+}

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

@@ -58,6 +58,8 @@ public class LiveCouponServiceImpl implements ILiveCouponService
     private ILiveAutoTaskService liveAutoTaskService;
     @Autowired
     private LiveCouponIssueMapper liveCouponIssueMapper;
+    @Autowired
+    private ILiveConsoleOpLogService liveConsoleOpLogService;
 
     /**
      * 查询优惠券
@@ -223,22 +225,31 @@ public class LiveCouponServiceImpl implements ILiveCouponService
         Live live = liveMapper.selectLiveByLiveId(liveId);
         if(live == null) return R.error("直播间不存在");
         boolean isShow = Boolean.parseBoolean(payload.get("isShow").toString());
-        if (isShow) {
-            int i = liveCouponMapper.selectShowByLiveId(liveId);
-            if (i > 0) return R.error("直播间已存在优惠券");
+        Long couponIssueId = Long.valueOf(payload.get("couponId").toString());
+        LiveCouponIssueRelation liveCouponIssueRelation = liveCouponMapper.selectRelation(liveId, couponIssueId);
+        if (liveCouponIssueRelation == null) {
+            return R.error("优惠券未关联到直播间");
         }
-        Long couponId = Long.valueOf(payload.get("couponId").toString());
-        LiveCouponIssueRelation liveCouponIssueRelation = liveCouponMapper.selectRelation(liveId,couponId);
 
-        // 查询优惠券类型
-        LiveCoupon liveCoupon = liveCouponMapper.selectLiveCouponById(couponId);
+        LiveCouponIssue couponIssue = liveCouponIssueMapper.selectLiveCouponIssueById(couponIssueId);
+        if (couponIssue == null || couponIssue.getCouponId() == null) {
+            return R.error("优惠券不存在");
+        }
+        LiveCoupon liveCoupon = liveCouponMapper.selectLiveCouponById(couponIssue.getCouponId());
 
         // 如果不是核销券/无门槛券,需要检查是否绑定了商品
         if (!isVerifyCouponType(liveCoupon) && ObjectUtil.isEmpty(liveCouponIssueRelation.getGoodsId())) {
             return R.error("未绑定商品,无法发布!");
         }
 
-        liveCouponMapper.updateShow(liveId, couponId, isShow ? 1 : 0);
+        if (isShow) {
+            // updateChangeShow 会自动收起其它券,仅保留当前展示的一张
+            liveCouponMapper.updateChangeShow(liveId, couponIssueId);
+            liveConsoleOpLogService.saveCouponShowLog(
+                    liveId, couponIssueId, liveCoupon, LiveConsoleOpLog.HANDLE_CONSOLE);
+        } else {
+            liveCouponMapper.updateShow(liveId, couponIssueId, 0);
+        }
         return R.ok("操作成功");
     }
 
@@ -346,6 +357,9 @@ public class LiveCouponServiceImpl implements ILiveCouponService
         liveCouponUserService.insertLiveCouponUser(userRecord);
         liveCouponIssueUserService.insertLiveCouponIssueUser(record);
 
+        liveConsoleOpLogService.bindOpLogUser(
+                coupon.getOpLogId(), coupon.getLiveId(), coupon.getUserId(), userRecord.getId());
+
         // 更新优惠卷数量
         if (issue.getRemainCount() > 0) {
             LiveCouponIssue liveCouponIssue = new LiveCouponIssue();

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

@@ -4,6 +4,8 @@ package com.fs.live.service.impl;
 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.live.domain.LiveConsoleOpLog;
 import com.fs.live.domain.LiveLotteryConf;
 import com.fs.live.domain.LiveLotteryRegistration;
 import com.fs.live.mapper.LiveLotteryConfMapper;
@@ -14,6 +16,7 @@ import com.fs.live.param.LiveLotteryProduct;
 import com.fs.live.param.LiveLotteryProductSaveParam;
 import com.fs.live.param.LotteryPO;
 import com.fs.live.service.ILiveAutoTaskService;
+import com.fs.live.service.ILiveConsoleOpLogService;
 import com.fs.live.service.ILiveLotteryConfService;
 import com.fs.live.vo.LiveLotteryConfVo;
 import com.fs.live.vo.LiveLotteryProductListVo;
@@ -53,6 +56,8 @@ public class LiveLotteryConfServiceImpl implements ILiveLotteryConfService {
     private LiveUserLotteryRecordMapper liveUserLotteryRecordMapper;
     @Autowired
     private ILiveAutoTaskService liveAutoTaskService;
+    @Autowired
+    private ILiveConsoleOpLogService liveConsoleOpLogService;
     /**
      * 查询直播抽奖配置
      *
@@ -114,7 +119,24 @@ public class LiveLotteryConfServiceImpl implements ILiveLotteryConfService {
         } else {
             redisCache.deleteObject(cacheKey);
         }
-        return R.ok().put("data",baseMapper.updateLiveLotteryConf(liveLotteryConf));
+        int rows = baseMapper.updateLiveLotteryConf(liveLotteryConf);
+        if ("2".equals(liveLotteryConf.getLotteryStatus())) {
+            LiveLotteryConf persisted = baseMapper.selectLiveLotteryConfByLotteryId(liveLotteryConf.getLotteryId());
+            liveConsoleOpLogService.saveLotterySettleLog(
+                    liveLotteryConf.getLiveId(),
+                    LiveConsoleOpLog.HANDLE_CONSOLE,
+                    liveLotteryConf.getLotteryId(),
+                    resolveLotteryBizName(persisted)
+            );
+        }
+        return R.ok().put("data", rows);
+    }
+
+    private String resolveLotteryBizName(LiveLotteryConf conf) {
+        if (conf != null && StringUtils.isNotEmpty(conf.getDesc())) {
+            return conf.getDesc();
+        }
+        return conf != null && conf.getLotteryId() != null ? "抽奖 #" + conf.getLotteryId() : "抽奖";
     }
 
     /**
@@ -304,6 +326,9 @@ public class LiveLotteryConfServiceImpl implements ILiveLotteryConfService {
 
     @Override
     public void finishStatusByLotteryIds(List<Long> ids) {
+        if (ids == null || ids.isEmpty()) {
+            return;
+        }
         baseMapper.finishStatusByLotteryIds(ids);
     }
 

+ 37 - 5
fs-service/src/main/java/com/fs/live/service/impl/LiveRedConfServiceImpl.java

@@ -8,6 +8,7 @@ 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.his.domain.FsUserIntegralLogs;
 import com.fs.his.enums.FsUserIntegralLogTypeEnum;
 import com.fs.his.mapper.FsUserIntegralLogsMapper;
@@ -22,6 +23,7 @@ import com.fs.live.mapper.LiveRewardRecordMapper;
 import com.fs.live.mapper.LiveUserRedRecordMapper;
 import com.fs.live.param.RedPO;
 import com.fs.live.service.ILiveAutoTaskService;
+import com.fs.live.service.ILiveConsoleOpLogService;
 import com.fs.live.service.ILiveRedConfService;
 
 import org.slf4j.Logger;
@@ -75,6 +77,8 @@ public class LiveRedConfServiceImpl implements ILiveRedConfService {
 
     @Autowired
     private LiveRedConfMapper baseMapper;
+    @Autowired
+    private ILiveConsoleOpLogService liveConsoleOpLogService;
 
     /**
      * 查询直播红包记录配置
@@ -144,7 +148,24 @@ public class LiveRedConfServiceImpl implements ILiveRedConfService {
             redisCache.deleteObject(REDPACKET_CONF_CACHE_KEY + liveRedConf.getRedId());
             redStatusUpdate(CollUtil.newHashSet(liveRedConf.getRedId()));
         }
-        return baseMapper.updateLiveRedConf(liveRedConf);
+        int rows = baseMapper.updateLiveRedConf(liveRedConf);
+        if (liveRedConf.getRedStatus() != null && liveRedConf.getRedStatus() == 2L) {
+            LiveRedConf persisted = baseMapper.selectLiveRedConfByRedId(liveRedConf.getRedId());
+            liveConsoleOpLogService.saveRedSettleLog(
+                    liveRedConf.getLiveId(),
+                    LiveConsoleOpLog.HANDLE_CONSOLE,
+                    liveRedConf.getRedId(),
+                    resolveRedBizName(persisted)
+            );
+        }
+        return rows;
+    }
+
+    private String resolveRedBizName(LiveRedConf conf) {
+        if (conf != null && StringUtils.isNotEmpty(conf.getDesc())) {
+            return conf.getDesc();
+        }
+        return conf != null && conf.getRedId() != null ? "红包 #" + conf.getRedId() : "红包";
     }
 
     /**
@@ -368,8 +389,7 @@ public class LiveRedConfServiceImpl implements ILiveRedConfService {
             log.error("异步更新数据库失败,userId: {}, redId: {}, integral: {}", red.getUserId(), red.getRedId(), finalIntegral, e);
         }
 
-
-
+            liveConsoleOpLogService.bindOpLogUser(red.getOpLogId(), red.getLiveId(), red.getUserId());
 
             return R.ok("恭喜您成功抢到" + integral + "福币");
 
@@ -405,16 +425,28 @@ public class LiveRedConfServiceImpl implements ILiveRedConfService {
     }
 
     @Override
-    public void finishRedStatusBySetIds(Set<String> range) {
+    public List<LiveConsoleOpLog> finishRedStatusBySetIds(Set<String> range) {
+        List<LiveConsoleOpLog> opLogs = new ArrayList<>();
         try {
             log.info("开始结束红包状态:{}",range);
+            for (String redIdStr : range) {
+                LiveRedConf conf = baseMapper.selectLiveRedConfByRedId(Long.valueOf(redIdStr));
+                if (conf != null) {
+                    opLogs.add(liveConsoleOpLogService.saveRedSettleLog(
+                            conf.getLiveId(),
+                            LiveConsoleOpLog.HANDLE_AUTO,
+                            conf.getRedId(),
+                            resolveRedBizName(conf)
+                    ));
+                }
+            }
             baseMapper.finishRedStatusBySetIds(range);
             redStatusUpdate(range.stream().map(Long::valueOf).collect(Collectors.toSet()));
             log.info("结束红包状态完成");
         }catch (Exception e){
             log.info("红包状态结束异常",e);
         }
-
+        return opLogs;
     }
 
     @Override

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

@@ -605,9 +605,30 @@ public class LiveServiceImpl implements ILiveService
         if (liveId == null || showCart == null) {
             return R.error("参数错误");
         }
+        updateShowCartWithoutBroadcast(liveId, showCart);
+        publishShowCartWsMessage(liveId, showCart);
+        return R.ok();
+    }
+
+    @Override
+    public void updateShowCartWithoutBroadcast(Long liveId, Integer showCart) {
+        if (liveId == null || showCart == null) {
+            return;
+        }
         baseMapper.updateShowCart(liveId, showCart);
         clearLiveCache(liveId);
-        return R.ok();
+    }
+
+    private void publishShowCartWsMessage(Long liveId, Integer showCart) {
+        try {
+            JSONObject payload = new JSONObject();
+            payload.put("liveId", liveId);
+            payload.put("cmd", "showCart");
+            payload.put("status", showCart);
+            redisCache.publish(LiveKeysConstant.LIVE_WS_BROADCAST_CHANNEL, payload.toJSONString());
+        } catch (Exception e) {
+            log.warn("发布购物车显示状态 WebSocket 广播失败, liveId={}, showCart={}", liveId, showCart, e);
+        }
     }
 
     @Override

+ 17 - 0
fs-service/src/main/java/com/fs/live/service/impl/LiveWatchUserServiceImpl.java

@@ -1320,4 +1320,21 @@ public class LiveWatchUserServiceImpl implements ILiveWatchUserService {
         }
     }
 
+    @Override
+    public Long getUserWatchDuration(Long liveId, Long userId) {
+        Long total = getTotalWatchDuration(liveId, userId);
+        long dbDuration = total != null ? total : 0L;
+        try {
+            String hashKey = "live:watch:duration:hash:" + liveId;
+            Object redisValue = redisCache.hashGet(hashKey, String.valueOf(userId));
+            if (redisValue != null) {
+                long redisDuration = Long.parseLong(redisValue.toString());
+                return Math.max(dbDuration, redisDuration);
+            }
+        } catch (Exception e) {
+            log.debug("读取 Redis 观看时长失败: liveId={}, userId={}", liveId, userId, e);
+        }
+        return dbDuration;
+    }
+
 }

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

@@ -0,0 +1,13 @@
+package com.fs.live.vo;
+
+import lombok.Data;
+
+/**
+ * 完课优惠券答题结果
+ */
+@Data
+public class LiveCompletionCouponAnswerResult {
+
+    /** 是否全部答对 */
+    private boolean allCorrect;
+}

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

@@ -5,7 +5,7 @@ import lombok.Data;
 import java.math.BigDecimal;
 
 /**
- * Íê¿ÎÓÅ»ÝȯÐÅÏ¢
+ * 完课优惠券信�
  */
 @Data
 public class LiveCompletionCouponInfoVO {

+ 33 - 27
fs-service/src/main/java/com/fs/live/vo/LiveCompletionCouponStatusVO.java

@@ -1,27 +1,33 @@
-package com.fs.live.vo;

-

-import lombok.Data;

-

-import java.util.List;

-

-/**

- * 直播完课优惠券状态

- */

-@Data

-public class LiveCompletionCouponStatusVO {

-

-    /** 是否已开启完课优惠券 */

-    private boolean enabled;

-

-    /** 是否达到完课条件 */

-    private boolean eligible;

-

-    /** 今天是否已领取优惠券 */

-    private boolean receivedToday;

-

-    /** 是否已配置今日问题 */

-    private boolean hasQuestions;

-

-    /** 今日问题列表(不含答案) */

-    private List<LiveCompletionQuestionVO> questions;

-}

+package com.fs.live.vo;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 直播完课优惠券状态
+ */
+@Data
+public class LiveCompletionCouponStatusVO {
+
+    /** 是否已开启完课优惠券 */
+    private boolean enabled;
+
+    /** 是否达到完课条件 */
+    private boolean eligible;
+
+    /** 今天是否已领取优惠券 */
+    private boolean receivedToday;
+
+    /** 今天是否已提交答题 */
+    private boolean answeredToday;
+
+    /** 今日答题是否全部正确(未答题时为 false) */
+    private boolean allCorrect;
+
+    /** 是否已配置今日问题 */
+    private boolean hasQuestions;
+
+    /** 今日问题列表(不含答案) */
+    private List<LiveCompletionQuestionVO> questions;
+}

+ 67 - 0
fs-service/src/main/java/com/fs/live/vo/LiveConsoleOpLogRecordVo.java

@@ -0,0 +1,67 @@
+package com.fs.live.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fs.live.domain.LiveConsoleOpLog;
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * App 端直播间留存记录(含领取状态)
+ */
+@Data
+public class LiveConsoleOpLogRecordVo {
+
+    /** 状态:0待领取 1已领取 2已结束 */
+    public static final int STATUS_PENDING = 0;
+    public static final int STATUS_CLAIMED = 1;
+    public static final int STATUS_ENDED = 2;
+
+    private Long id;
+    private Long liveId;
+    private Integer opType;
+    private Integer handleType;
+    private Long bizId;
+    private String bizName;
+    private Long operatorId;
+    private String operatorName;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+
+    /** 状态:0待领取 1已领取 2已结束 */
+    private Integer status;
+
+    /** 状态名称 */
+    private String statusName;
+
+    public static LiveConsoleOpLogRecordVo from(LiveConsoleOpLog log, int status) {
+        LiveConsoleOpLogRecordVo vo = new LiveConsoleOpLogRecordVo();
+        if (log == null) {
+            return vo;
+        }
+        vo.setId(log.getId());
+        vo.setLiveId(log.getLiveId());
+        vo.setOpType(log.getOpType());
+        vo.setHandleType(log.getHandleType());
+        vo.setBizId(log.getBizId());
+        vo.setBizName(log.getBizName());
+        vo.setOperatorId(log.getOperatorId());
+        vo.setOperatorName(log.getOperatorName());
+        vo.setCreateTime(log.getCreateTime());
+        vo.setStatus(status);
+        vo.setStatusName(resolveStatusName(status));
+        return vo;
+    }
+
+    public static String resolveStatusName(int status) {
+        switch (status) {
+            case STATUS_CLAIMED:
+                return "已领取";
+            case STATUS_ENDED:
+                return "已结束";
+            default:
+                return "待领取";
+        }
+    }
+}

+ 66 - 0
fs-service/src/main/java/com/fs/live/vo/LiveConsoleOpLogVo.java

@@ -0,0 +1,66 @@
+package com.fs.live.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fs.live.domain.LiveConsoleOpLog;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 中控台操作留存 VO
+ * <p>通过 WebSocket 下发至 App 端,挂载于 SendMsgVo.opLog 字段</p>
+ */
+@Data
+@ApiModel("中控台操作留存")
+public class LiveConsoleOpLogVo {
+
+    @ApiModelProperty("留存记录ID")
+    private Long id;
+
+    @ApiModelProperty("直播间ID")
+    private Long liveId;
+
+    @ApiModelProperty("操作类型:1优惠券展示 2核销券展示 3红包结算 4抽奖结算 5红包发放 6抽奖发放 7完课积分 8完课优惠券 9观看奖励积分 10观看奖励优惠券")
+    private Integer opType;
+
+    @ApiModelProperty("触发类型:1中控台 2运营自动化")
+    private Integer handleType;
+
+    @ApiModelProperty("业务ID")
+    private Long bizId;
+
+    @ApiModelProperty("业务名称")
+    private String bizName;
+
+    @ApiModelProperty("操作人ID")
+    private Long operatorId;
+
+    @ApiModelProperty("操作人名称")
+    private String operatorName;
+
+    @ApiModelProperty("创建时间")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+
+    /**
+     * 将留存实体转换为 VO
+     */
+    public static LiveConsoleOpLogVo from(LiveConsoleOpLog log) {
+        if (log == null) {
+            return null;
+        }
+        LiveConsoleOpLogVo vo = new LiveConsoleOpLogVo();
+        vo.setId(log.getId());
+        vo.setLiveId(log.getLiveId());
+        vo.setOpType(log.getOpType());
+        vo.setHandleType(log.getHandleType());
+        vo.setBizId(log.getBizId());
+        vo.setBizName(log.getBizName());
+        vo.setOperatorId(log.getOperatorId());
+        vo.setOperatorName(log.getOperatorName());
+        vo.setCreateTime(log.getCreateTime());
+        return vo;
+    }
+}

+ 21 - 0
fs-service/src/main/java/com/fs/live/vo/LiveUserRewardRecordsVo.java

@@ -0,0 +1,21 @@
+package com.fs.live.vo;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * App 端用户直播间奖品留存查询结果
+ */
+@Data
+public class LiveUserRewardRecordsVo {
+
+    /** 留存记录列表 */
+    private List<LiveConsoleOpLogRecordVo> records;
+
+    /** 开播总时长(秒) */
+    private Long liveDuration;
+
+    /** 用户观看时长(秒,直播+回放,取 DB 与 Redis 较大值) */
+    private Long watchDuration;
+}

+ 2 - 2
fs-service/src/main/resources/application-config-druid-tyt.yml

@@ -37,8 +37,8 @@ wx:
       port: 6379
       timeout: 2000
     configs:
-      - appId: wx6ee517a8d8743f88  # 第一个公众号的appid
-        secret: 1fac75465a61f9259a0fe19795d9e80d # 公众号的appsecret
+      - appId: wx21ba7ccef1b72fe5  # 第一个公众号的appid
+        secret: a6b16c72b26081990b9f77905d7f4038 # 公众号的appsecret
         token: PPKOdAlCoMO # 接口配置里的Token值
         aesKey: Eswa6VjwtVMCcw03qZy6fWllgrv5aytIA1SZPEU0kU2 # 接口配置里的EncodingAESKey值
   open:

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

@@ -228,7 +228,7 @@ rocketmq:
 openIM:
     secret: openIM123
     userID: imAdmin
-    url: https://web.tytim.ylrzfs.com/api
+    url: https://tytwebim.ylrzcloud.com/api
 #是否使用新im
 im:
     type: OPENIM

+ 68 - 0
fs-service/src/main/resources/mapper/live/LiveConsoleOpLogMapper.xml

@@ -0,0 +1,68 @@
+<?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 -->
+<mapper namespace="com.fs.live.mapper.LiveConsoleOpLogMapper">
+
+    <resultMap type="LiveConsoleOpLog" id="LiveConsoleOpLogResult">
+        <result property="id" column="id"/>
+        <result property="liveId" column="live_id"/>
+        <result property="opType" column="op_type"/>
+        <result property="handleType" column="handle_type"/>
+        <result property="bizId" column="biz_id"/>
+        <result property="bizName" column="biz_name"/>
+        <result property="operatorId" column="operator_id"/>
+        <result property="operatorName" column="operator_name"/>
+        <result property="createTime" column="create_time"/>
+        <result property="remark" column="remark"/>
+    </resultMap>
+
+    <sql id="selectLiveConsoleOpLogVo">
+        select id, live_id, op_type, handle_type, biz_id, biz_name, operator_id, operator_name, create_time, remark
+        from live_console_op_log
+    </sql>
+
+    <select id="selectLiveConsoleOpLogList" parameterType="LiveConsoleOpLog" resultMap="LiveConsoleOpLogResult">
+        <include refid="selectLiveConsoleOpLogVo"/>
+        <where>
+            <if test="liveId != null">and live_id = #{liveId}</if>
+            <if test="opType != null">and op_type = #{opType}</if>
+            <if test="handleType != null">and handle_type = #{handleType}</if>
+            <if test="bizId != null">and biz_id = #{bizId}</if>
+            <if test="operatorId != null">and operator_id = #{operatorId}</if>
+        </where>
+        order by create_time desc, id desc
+    </select>
+
+    <select id="selectLiveConsoleOpLogById" parameterType="Long" resultMap="LiveConsoleOpLogResult">
+        <include refid="selectLiveConsoleOpLogVo"/>
+        where id = #{id}
+    </select>
+
+    <insert id="insertLiveConsoleOpLog" parameterType="LiveConsoleOpLog" useGeneratedKeys="true" keyProperty="id">
+        insert into live_console_op_log
+        <trim prefix="(" suffix=")" suffixOverrides=",">
+            <if test="liveId != null">live_id,</if>
+            <if test="opType != null">op_type,</if>
+            <if test="handleType != null">handle_type,</if>
+            <if test="bizId != null">biz_id,</if>
+            <if test="bizName != null">biz_name,</if>
+            <if test="operatorId != null">operator_id,</if>
+            <if test="operatorName != null">operator_name,</if>
+            <if test="createTime != null">create_time,</if>
+            <if test="remark != null">remark,</if>
+        </trim>
+        <trim prefix="values (" suffix=")" suffixOverrides=",">
+            <if test="liveId != null">#{liveId},</if>
+            <if test="opType != null">#{opType},</if>
+            <if test="handleType != null">#{handleType},</if>
+            <if test="bizId != null">#{bizId},</if>
+            <if test="bizName != null">#{bizName},</if>
+            <if test="operatorId != null">#{operatorId},</if>
+            <if test="operatorName != null">#{operatorName},</if>
+            <if test="createTime != null">#{createTime},</if>
+            <if test="remark != null">#{remark},</if>
+        </trim>
+    </insert>
+</mapper>

+ 38 - 0
fs-service/src/main/resources/mapper/live/LiveConsoleOpLogUserMapper.xml

@@ -0,0 +1,38 @@
+<?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 -->
+<mapper namespace="com.fs.live.mapper.LiveConsoleOpLogUserMapper">
+
+    <resultMap type="LiveConsoleOpLogUser" id="LiveConsoleOpLogUserResult">
+        <result property="id" column="id"/>
+        <result property="opLogId" column="op_log_id"/>
+        <result property="userId" column="user_id"/>
+        <result property="liveId" column="live_id"/>
+        <result property="couponUserId" column="coupon_user_id"/>
+        <result property="createTime" column="create_time"/>
+    </resultMap>
+
+    <insert id="batchInsertLiveConsoleOpLogUser">
+        insert into live_console_op_log_user (op_log_id, user_id, live_id, coupon_user_id, create_time)
+        values
+        <foreach collection="list" item="item" separator=",">
+            (#{item.opLogId}, #{item.userId}, #{item.liveId}, #{item.couponUserId}, #{item.createTime})
+        </foreach>
+    </insert>
+
+    <select id="selectByOpLogId" resultMap="LiveConsoleOpLogUserResult">
+        select id, op_log_id, user_id, live_id, coupon_user_id, create_time
+        from live_console_op_log_user
+        where op_log_id = #{opLogId}
+        order by id asc
+    </select>
+
+    <select id="selectByLiveIdAndUserId" resultMap="LiveConsoleOpLogUserResult">
+        select id, op_log_id, user_id, live_id, coupon_user_id, create_time
+        from live_console_op_log_user
+        where live_id = #{liveId} and user_id = #{userId}
+        order by id desc
+    </select>
+</mapper>

+ 0 - 8
fs-user-app/src/main/java/com/fs/app/controller/VideoController.java

@@ -40,8 +40,6 @@ public class VideoController extends  AppBaseController{
     @Autowired
     private IFsUserVideoViewService videoViewService;
 
-    @Autowired
-    private IRecommendationService recommendationService;
     @Autowired
     private IFsUserVideoTagsService fsUserVideoTagsService;
 
@@ -57,12 +55,6 @@ public class VideoController extends  AppBaseController{
         //添加假数据
 //        list = videoService.addNum(list);
         PageInfo<FsUserVideoListUVO> listPageInfo=new PageInfo<>(list);
-        if (param.getUserId() != null) {
-            // 对分页后的数据进行推荐排序
-            list = recommendationService.recommendVideos(param.getUserId(), list);
-             //更新PageInfo中的列表
-            listPageInfo.setList(list);
-        }
         if (param.getVideoId() != null && param.getPageNum()==1) {
             // 获取该 videoId 对应的视频数据
             FsUserVideoListUVO video = videoService.getVideoById(param.getVideoId());

+ 17 - 9
fs-user-app/src/main/java/com/fs/app/controller/live/LiveCompletionCouponController.java

@@ -4,7 +4,9 @@ import com.fs.app.controller.AppBaseController;
 import com.fs.common.annotation.RepeatSubmit;
 import com.fs.common.core.domain.R;
 import com.fs.live.param.LiveCompletionCouponAnswerParam;
+import com.fs.live.param.LiveCompletionCouponClaimParam;
 import com.fs.live.service.ILiveCompletionCouponService;
+import com.fs.live.vo.LiveCompletionCouponAnswerResult;
 import com.fs.live.vo.LiveCompletionCouponStatusVO;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.*;
@@ -19,9 +21,6 @@ public class LiveCompletionCouponController extends AppBaseController {
     @Autowired
     private ILiveCompletionCouponService completionCouponService;
 
-    /**
-     * 查询完课优惠券状态及今日问题(观看完视频后前端轮询或主动调用)
-     */
     @GetMapping("/status")
     public R status(@RequestParam Long liveId, @RequestParam(required = false) Long watchDuration) {
         Long userId = Long.parseLong(getUserId());
@@ -29,22 +28,31 @@ public class LiveCompletionCouponController extends AppBaseController {
         return R.ok().put("data", status);
     }
 
-    /**
-     * 获取今日问题列表
-     */
     @GetMapping("/questions")
     public R questions(@RequestParam Long liveId) {
         return R.ok().put("data", completionCouponService.getCompletionQuestions(liveId));
     }
 
     /**
-     * 提交今日问题,答对后发放福利券
+     * 提交今日问题(仅记录答题结果,不发券)
      */
     @PostMapping("/answer")
     @RepeatSubmit
     public R answer(@RequestBody LiveCompletionCouponAnswerParam param) {
         Long userId = Long.parseLong(getUserId());
-        completionCouponService.submitAnswerAndIssueCoupon(param, userId);
-        return R.ok("恭喜您,福利券已到账");
+        LiveCompletionCouponAnswerResult result = completionCouponService.submitAnswers(param, userId);
+        String msg = result.isAllCorrect() ? "答题正确,请点击领取福利券" : "回答错误,请重新作答";
+        return R.ok(msg).put("data", result);
+    }
+
+    /**
+     * 领取福利券(需先答题全部正确,回传 WebSocket 下发的 opLogId)
+     */
+    @PostMapping("/claim")
+    @RepeatSubmit
+    public R claim(@RequestBody LiveCompletionCouponClaimParam param) {
+        Long userId = Long.parseLong(getUserId());
+        return R.ok("恭喜您,福利券已到账")
+                .put("data", completionCouponService.claimCompletionCoupon(param, userId));
     }
 }

+ 4 - 69
fs-user-app/src/main/java/com/fs/app/controller/live/LiveController.java

@@ -98,7 +98,6 @@ public class LiveController extends AppBaseController {
     private ILiveCouponUserService liveCouponUserService;
     @Autowired
     private ILiveUserRedRecordService liveUserRedRecordService;
-
 	/**
 	 * 查询未结束直播间(销售专用)
 	 */
@@ -408,9 +407,9 @@ public class LiveController extends AppBaseController {
 	}
 
 	/**
-	 * 查询当前用户在当前直播间获得的奖品记录
+	 * 查询当前用户在当前直播间的奖品留存记录
 	 * @param liveId 直播间ID
-	 * @return 奖品记录列表(积分、优惠券、红包等
+	 * @return 留存记录及开播/观看时长(秒
 	 */
 	@Login
 	@ApiOperation("查询我的直播间奖品记录")
@@ -418,73 +417,9 @@ public class LiveController extends AppBaseController {
 	public R myRewardRecords(@RequestParam Long liveId) {
 		try {
 			Long userId = Long.valueOf(getUserId());
-
-			// 查询奖励记录(积分、现金、优惠券等)
-			LiveRewardRecord rewardQuery = new LiveRewardRecord();
-			rewardQuery.setLiveId(liveId);
-			rewardQuery.setUserId(userId);
-			rewardQuery.setIncomeType(1L); // 收入类型
-			List<LiveRewardRecord> rewardRecords = liveRewardRecordService.selectLiveRewardRecordList(rewardQuery);
-
-			// 从奖励记录中提取优惠券ID(rewardType=3时,sourceId为couponId)
-			List<Long> couponIds = rewardRecords.stream()
-					.filter(r -> r.getRewardType() != null && r.getRewardType() == 3)
-					.map(LiveRewardRecord::getSourceId)
-					.filter(Objects::nonNull)
-					.distinct()
-					.collect(Collectors.toList());
-
-			// 查询优惠券详情
-			List<LiveCouponUser> liveCoupons = new ArrayList<>();
-			if (!couponIds.isEmpty()) {
-				LiveCouponUser couponQuery = new LiveCouponUser();
-				couponQuery.setUserId(userId.intValue());
-				List<LiveCouponUser> allUserCoupons = liveCouponUserService.selectLiveCouponUserList(couponQuery);
-				// 过滤出当前直播间获得的优惠券
-				liveCoupons = allUserCoupons.stream()
-						.filter(c -> c.getCouponId() != null && couponIds.contains(c.getCouponId()))
-						.collect(Collectors.toList());
-			}
-
-			// 查询红包记录
-			LiveUserRedRecord redQuery = new LiveUserRedRecord();
-			redQuery.setLiveId(liveId);
-			redQuery.setUserId(userId);
-			List<LiveUserRedRecord> redRecords = liveUserRedRecordService.selectLiveUserRedRecordList(redQuery);
-
-			// 整合结果
-			Map<String, Object> result = new HashMap<>();
-			result.put("rewardRecords", rewardRecords);  // 积分、现金奖励记录
-			result.put("couponRecords", liveCoupons);     // 优惠券记录
-			result.put("redRecords", redRecords);          // 红包记录
-
-			// 统计
-			Map<String, Object> statistics = new HashMap<>();
-			statistics.put("totalRewards", rewardRecords.size());
-			statistics.put("totalCoupons", liveCoupons.size());
-			statistics.put("totalReds", redRecords.size());
-
-			// 计算总积分
-			BigDecimal totalPoints = rewardRecords.stream()
-					.filter(r -> r.getRewardType() != null && r.getRewardType() == 2)
-					.map(LiveRewardRecord::getNum)
-					.filter(Objects::nonNull)
-					.reduce(BigDecimal.ZERO, BigDecimal::add);
-			statistics.put("totalPoints", totalPoints);
-
-			// 计算总现金
-			BigDecimal totalCash = rewardRecords.stream()
-					.filter(r -> r.getRewardType() != null && r.getRewardType() == 1)
-					.map(LiveRewardRecord::getNum)
-					.filter(Objects::nonNull)
-					.reduce(BigDecimal.ZERO, BigDecimal::add);
-			statistics.put("totalCash", totalCash);
-
-			result.put("statistics", statistics);
-
-			return R.ok().put("data", result);
+			return R.ok().put("data", liveFacadeService.getUserRewardRecords(liveId, userId));
 		} catch (Exception e) {
-			log.error("查询奖品记录失败, liveId={}, userId={}", liveId, getUserId(), e);
+			log.error("查询奖品留存记录失败, liveId={}, userId={}", liveId, getUserId(), e);
 			return R.error("查询奖品记录失败: " + e.getMessage());
 		}
 	}

+ 6 - 0
fs-user-app/src/main/java/com/fs/app/facade/LiveFacadeService.java

@@ -7,6 +7,7 @@ import com.fs.live.domain.LiveWatchUser;
 import com.fs.live.param.CouponPO;
 import com.fs.live.param.LotteryPO;
 import com.fs.live.param.RedPO;
+import com.fs.live.vo.LiveUserRewardRecordsVo;
 
 public interface LiveFacadeService {
     R liveList(PageRequest pageRequest);
@@ -23,4 +24,9 @@ public interface LiveFacadeService {
 
     R couponClaim(CouponPO coupon);
 
+    /**
+     * 查询用户在指定直播间的奖品留存记录(含开播/观看时长)
+     */
+    LiveUserRewardRecordsVo getUserRewardRecords(Long liveId, Long userId);
+
 }

+ 37 - 0
fs-user-app/src/main/java/com/fs/app/facade/impl/LiveFacadeServiceImpl.java

@@ -23,6 +23,8 @@ import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 
+import java.time.LocalDateTime;
+import java.time.temporal.ChronoUnit;
 import java.util.*;
 import java.util.stream.Collectors;
 
@@ -56,6 +58,9 @@ public class LiveFacadeServiceImpl extends BaseController implements LiveFacadeS
     @Autowired
     private ILiveCompletionPointsRecordService completionPointsRecordService;
 
+    @Autowired
+    private ILiveConsoleOpLogService liveConsoleOpLogService;
+
     @Override
     public R liveList(PageRequest pageRequest) {
         int start = (pageRequest.getCurrentPage() - 1) * pageRequest.getPageSize();
@@ -257,6 +262,38 @@ public class LiveFacadeServiceImpl extends BaseController implements LiveFacadeS
         redisCache.hashPut(String.format(LiveKeysConstant.LIVE_HOME_PAGE_CONFIG_DRAW, lottery.getLiveId(), lottery.getLotteryId()), String.valueOf(lottery.getUserId()), JSONUtil.toJsonStr(registration));
         // 通过定时任务入库
         // liveLotteryRegistrationService.insertLiveLotteryRegistration(registration);
+        liveConsoleOpLogService.bindOpLogUser(lottery.getOpLogId(), lottery.getLiveId(), lottery.getUserId());
         return R.ok("参与抽奖成功!请在直播间等待开奖");
     }
+
+    @Override
+    public LiveUserRewardRecordsVo getUserRewardRecords(Long liveId, Long userId) {
+        LiveUserRewardRecordsVo vo = new LiveUserRewardRecordsVo();
+        vo.setRecords(liveConsoleOpLogService.listUserOpLogRecords(liveId, userId));
+        vo.setLiveDuration(resolveLiveDuration(liveId));
+        vo.setWatchDuration(liveWatchUserService.getUserWatchDuration(liveId, userId));
+        return vo;
+    }
+
+    private Long resolveLiveDuration(Long liveId) {
+        if (liveId == null) {
+            return 0L;
+        }
+        Live live = liveService.selectLiveByLiveId(liveId);
+        if (live == null) {
+            return 0L;
+        }
+        if (live.getDuration() != null && live.getDuration() > 0) {
+            return live.getDuration();
+        }
+        if (live.getVideoDuration() != null && live.getVideoDuration() > 0) {
+            return live.getVideoDuration();
+        }
+        if (live.getStartTime() != null) {
+            LocalDateTime endTime = live.getFinishTime() != null ? live.getFinishTime() : LocalDateTime.now();
+            long seconds = ChronoUnit.SECONDS.between(live.getStartTime(), endTime);
+            return Math.max(seconds, 0L);
+        }
+        return 0L;
+    }
 }