Forráskód Böngészése

1、新增留存表对运营自动化,中控台的操作进行留存
2、对领取进行记录关系表
3、修改收藏,点赞功能问题处理
4、新增答题,领取优惠卷接口

yys 1 hete
szülő
commit
ecc20c55b8
34 módosított fájl, 1124 hozzáadás és 165 törlés
  1. 33 0
      fs-admin/src/main/java/com/fs/live/controller/LiveConsoleOpLogController.java
  2. 36 3
      fs-live-app/src/main/java/com/fs/live/task/LiveCompletionPointsTask.java
  3. 71 23
      fs-live-app/src/main/java/com/fs/live/task/Task.java
  4. 7 0
      fs-live-app/src/main/java/com/fs/live/websocket/bean/SendMsgVo.java
  5. 136 4
      fs-live-app/src/main/java/com/fs/live/websocket/service/WebSocketServer.java
  6. 1 1
      fs-service/src/main/java/com/fs/course/mapper/FsUserVideoFavoriteMapper.java
  7. 1 24
      fs-service/src/main/java/com/fs/course/service/impl/FsUserVideoServiceImpl.java
  8. 81 0
      fs-service/src/main/java/com/fs/live/domain/LiveConsoleOpLog.java
  9. 46 0
      fs-service/src/main/java/com/fs/live/domain/LiveConsoleOpLogUser.java
  10. 20 0
      fs-service/src/main/java/com/fs/live/mapper/LiveConsoleOpLogMapper.java
  11. 18 0
      fs-service/src/main/java/com/fs/live/mapper/LiveConsoleOpLogUserMapper.java
  12. 3 0
      fs-service/src/main/java/com/fs/live/param/CouponPO.java
  13. 20 0
      fs-service/src/main/java/com/fs/live/param/LiveCompletionCouponAnswerItem.java
  14. 17 17
      fs-service/src/main/java/com/fs/live/param/LiveCompletionCouponAnswerParam.java
  15. 16 0
      fs-service/src/main/java/com/fs/live/param/LiveCompletionCouponClaimParam.java
  16. 2 0
      fs-service/src/main/java/com/fs/live/param/LotteryPO.java
  17. 2 0
      fs-service/src/main/java/com/fs/live/param/RedPO.java
  18. 9 24
      fs-service/src/main/java/com/fs/live/service/ILiveCompletionCouponService.java
  19. 1 1
      fs-service/src/main/java/com/fs/live/service/ILiveCompletionPointsRecordService.java
  20. 61 0
      fs-service/src/main/java/com/fs/live/service/ILiveConsoleOpLogService.java
  21. 2 1
      fs-service/src/main/java/com/fs/live/service/ILiveRedConfService.java
  22. 112 16
      fs-service/src/main/java/com/fs/live/service/impl/LiveCompletionCouponServiceImpl.java
  23. 9 9
      fs-service/src/main/java/com/fs/live/service/impl/LiveCompletionPointsRecordServiceImpl.java
  24. 132 0
      fs-service/src/main/java/com/fs/live/service/impl/LiveConsoleOpLogServiceImpl.java
  25. 8 0
      fs-service/src/main/java/com/fs/live/service/impl/LiveCouponServiceImpl.java
  26. 18 1
      fs-service/src/main/java/com/fs/live/service/impl/LiveLotteryConfServiceImpl.java
  27. 30 5
      fs-service/src/main/java/com/fs/live/service/impl/LiveRedConfServiceImpl.java
  28. 13 0
      fs-service/src/main/java/com/fs/live/vo/LiveCompletionCouponAnswerResult.java
  29. 33 27
      fs-service/src/main/java/com/fs/live/vo/LiveCompletionCouponStatusVO.java
  30. 66 0
      fs-service/src/main/java/com/fs/live/vo/LiveConsoleOpLogVo.java
  31. 68 0
      fs-service/src/main/resources/mapper/live/LiveConsoleOpLogMapper.xml
  32. 31 0
      fs-service/src/main/resources/mapper/live/LiveConsoleOpLogUserMapper.xml
  33. 17 9
      fs-user-app/src/main/java/com/fs/app/controller/live/LiveCompletionCouponController.java
  34. 4 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);
+    }
+}

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

@@ -4,8 +4,11 @@ import com.alibaba.fastjson.JSONObject;
 import com.fs.common.core.domain.R;
 import com.fs.common.core.redis.RedisCache;
 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.LiveCompletionCouponNotifyResult;
 import com.fs.live.websocket.bean.SendMsgVo;
@@ -42,6 +45,9 @@ public class LiveCompletionPointsTask {
     @Autowired
     private WebSocketServer webSocketServer;
 
+    @Autowired
+    private ILiveConsoleOpLogService liveConsoleOpLogService;
+
     /**
      * 定时检查观看时长并创建完课积分记录(兜底机制)
      * 每分钟执行一次
@@ -56,8 +62,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(),
+                            "完课积分" + record.getPointsAwarded() + "分"
+                    );
+                    liveConsoleOpLogService.bindOpLogUser(opLog.getId(), liveId, userId);
+                }
+            });
 
         } catch (Exception e) {
             log.error("检查完课积分定时任务执行失败", e);
@@ -84,7 +102,16 @@ public class LiveCompletionPointsTask {
                 if (notifyResult == null || !notifyResult.isShouldNotify()) {
                     return;
                 }
-                if (pushCompletionCouponQuestion(liveId, userId, notifyResult)) {
+                String bizName = notifyResult.getCoupon() != null ? notifyResult.getCoupon().getTitle() : "完课优惠券";
+                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 +145,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);

+ 71 - 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,13 @@ public class Task {
             sendMsgVo.setLiveId(liveLottery.getLiveId());
             sendMsgVo.setCmd("LotteryDetail");
             sendMsgVo.setData(JSON.toJSONString(lotteryVos));
+            WebSocketServer.attachOpLog(sendMsgVo, liveConsoleOpLogService.saveLog(
+                    liveLottery.getLiveId(),
+                    LiveConsoleOpLog.OP_LOTTERY_SETTLE,
+                    LiveConsoleOpLog.HANDLE_AUTO,
+                    liveLottery.getLotteryId(),
+                    liveLottery.getDesc()
+            ));
             webSocketServer.broadcastMessage(liveLottery.getLiveId(), JSONObject.toJSONString(R.ok().put("data", sendMsgVo)));
 
             liveService.asyncToCacheLiveConfig(liveLottery.getLiveId());
@@ -467,7 +480,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(),
+                            "观看奖励积分" + config.getScoreAmount() + "分,发放" + userIds.size() + "人"
+                    );
+                    userIds.forEach(userId -> webSocketServer.sendIntegralMessage(
+                            openRewardLive.getLiveId(), userId, config.getScoreAmount(), watchPointsOpLog));
                     break;
 
                 case 3: // 优惠券
@@ -478,7 +499,22 @@ 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()) {
+                        String couponTitle = watchRewardCoupon != null ? watchRewardCoupon.getTitle() : "观看奖励优惠券";
+                        LiveConsoleOpLog watchCouponOpLog = liveConsoleOpLogService.saveLog(
+                                openRewardLive.getLiveId(),
+                                LiveConsoleOpLog.OP_WATCH_REWARD_COUPON,
+                                LiveConsoleOpLog.HANDLE_AUTO,
+                                actionCouponId,
+                                couponTitle + ",发放" + couponRelations.size() + "人"
+                        );
+                        liveConsoleOpLogService.bindOpLogUsers(
+                                watchCouponOpLog.getId(), openRewardLive.getLiveId(), couponRelations);
+                        couponRelations.forEach(relation -> sendCouponRewardMessage(
+                                openRewardLive.getLiveId(), relation.getUserId(), watchRewardCoupon, watchCouponOpLog));
+                    }
                     break;
 
                 case 1: // 现金红包 - 暂不处理(现有逻辑)
@@ -490,35 +526,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 +591,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 +603,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 +647,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 +659,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 +679,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) {

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

+ 136 - 4
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);
@@ -659,6 +661,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 +694,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 +712,89 @@ 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);
         }
     }
 
+    /**
+     * 中控台红包操作留存(发放 / 结算)
+     */
+    private LiveConsoleOpLog saveConsoleRedOpLog(Long liveId, LiveRedConf liveRedConf, Integer status) {
+        if (liveRedConf == null || status == null) {
+            return null;
+        }
+        if (status == 1) {
+            return liveConsoleOpLogService.saveLog(
+                    liveId,
+                    LiveConsoleOpLog.OP_RED_SEND,
+                    LiveConsoleOpLog.HANDLE_CONSOLE,
+                    liveRedConf.getRedId(),
+                    liveRedConf.getDesc()
+            );
+        }
+        if (status == 2 || status == -1) {
+            return liveConsoleOpLogService.saveLog(
+                    liveId,
+                    LiveConsoleOpLog.OP_RED_SETTLE,
+                    LiveConsoleOpLog.HANDLE_CONSOLE,
+                    liveRedConf.getRedId(),
+                    liveRedConf.getDesc()
+            );
+        }
+        return null;
+    }
+
+    /**
+     * 中控台抽奖操作留存(发放 / 结算)
+     */
+    private LiveConsoleOpLog saveConsoleLotteryOpLog(Long liveId, LiveLotteryConf liveLotteryConf, Integer status) {
+        if (liveLotteryConf == null || status == null) {
+            return null;
+        }
+        if (status == 1) {
+            return liveConsoleOpLogService.saveLog(
+                    liveId,
+                    LiveConsoleOpLog.OP_LOTTERY_SEND,
+                    LiveConsoleOpLog.HANDLE_CONSOLE,
+                    liveLotteryConf.getLotteryId(),
+                    liveLotteryConf.getDesc()
+            );
+        }
+        if (status == 2 || status == -1) {
+            return liveConsoleOpLogService.saveLog(
+                    liveId,
+                    LiveConsoleOpLog.OP_LOTTERY_SETTLE,
+                    LiveConsoleOpLog.HANDLE_CONSOLE,
+                    liveLotteryConf.getLotteryId(),
+                    liveLotteryConf.getDesc()
+            );
+        }
+        return null;
+    }
+
+    /**
+     * 中控台优惠券展示留存
+     */
+    private LiveConsoleOpLog saveConsoleCouponOpLog(Long liveId, Long couponIssueId, Integer status) {
+        if (status == null || status != 1 || couponIssueId == null) {
+            return null;
+        }
+        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
+        );
+    }
+
     //错误时调用
     @OnError
     public void onError(Session session, Throwable throwable) {
@@ -906,7 +990,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 +1016,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)));
@@ -1276,6 +1374,13 @@ public class WebSocketServer {
                 liveRedConf.setUpdateTime( now);
                 msg.setData(JSON.toJSONString(liveRedConf));
                 liveRedConfService.updateLiveRedConf(liveRedConf);
+                attachOpLog(msg, liveConsoleOpLogService.saveLog(
+                        task.getLiveId(),
+                        LiveConsoleOpLog.OP_RED_SEND,
+                        LiveConsoleOpLog.HANDLE_AUTO,
+                        liveRedConf.getRedId(),
+                        liveRedConf.getDesc()
+                ));
                 liveService.asyncToCacheLiveConfig(task.getLiveId());
             }else if (task.getTaskType() == 4L) {
                 msg.setCmd("lottery");
@@ -1288,6 +1393,13 @@ public class WebSocketServer {
                 liveLotteryConf.setUpdateTime( now);
                 msg.setData(JSON.toJSONString(liveLotteryConf));
                 liveLotteryConfService.updateLiveLotteryConf(liveLotteryConf);
+                attachOpLog(msg, liveConsoleOpLogService.saveLog(
+                        task.getLiveId(),
+                        LiveConsoleOpLog.OP_LOTTERY_SEND,
+                        LiveConsoleOpLog.HANDLE_AUTO,
+                        liveLotteryConf.getLotteryId(),
+                        liveLotteryConf.getDesc()
+                ));
                 liveService.asyncToCacheLiveConfig(task.getLiveId());
             }else if (task.getTaskType() == 3L) {
                 msg.setCmd("sendMsg");
@@ -1329,6 +1441,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 +1740,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 +1752,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=')'>" +

+ 1 - 24
fs-service/src/main/java/com/fs/course/service/impl/FsUserVideoServiceImpl.java

@@ -382,16 +382,9 @@ 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();
+        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);
+}

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

@@ -0,0 +1,18 @@
+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);
+}

+ 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);
 
     /**
      * 用户领取完课积分

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

@@ -0,0 +1,61 @@
+package com.fs.live.service;
+
+import com.fs.live.domain.LiveConsoleOpLog;
+import com.fs.live.domain.LiveConsoleOpLogUser;
+import com.fs.live.domain.LiveCoupon;
+
+import java.util.List;
+
+/**
+ * 直播中控台操作留存 Service
+ */
+public interface ILiveConsoleOpLogService {
+
+    /**
+     * 查询留存记录列表
+     */
+    List<LiveConsoleOpLog> selectLiveConsoleOpLogList(LiveConsoleOpLog liveConsoleOpLog);
+
+    /**
+     * 保存操作留存
+     *
+     * @param liveId     直播间ID
+     * @param opType     操作类型,见 {@link LiveConsoleOpLog} 中 OP_* 常量
+     * @param handleType 触发类型,见 {@link LiveConsoleOpLog#HANDLE_CONSOLE} / {@link LiveConsoleOpLog#HANDLE_AUTO}
+     * @param bizId      业务ID
+     * @param bizName    业务名称
+     * @return 已入库的留存记录(含自增 id)
+     */
+    LiveConsoleOpLog saveLog(Long liveId, Integer opType, Integer handleType, Long bizId, String bizName);
+
+    /**
+     * 保存优惠券展示类留存(自动区分普通券 / 核销券)
+     *
+     * @param couponIssueId 优惠券发布记录ID
+     */
+    LiveConsoleOpLog saveCouponShowLog(Long liveId, Long couponIssueId, LiveCoupon coupon, Integer handleType);
+
+    /**
+     * 批量绑定留存记录与用户(定时自动发券等场景)
+     *
+     * @param opLogId   留存记录ID
+     * @param liveId    直播间ID
+     * @param relations 用户关联列表(opLogId 可为空,方法内会补全)
+     */
+    void bindOpLogUsers(Long opLogId, Long liveId, List<LiveConsoleOpLogUser> relations);
+
+    /**
+     * 根据留存记录ID查询关联用户
+     */
+    List<LiveConsoleOpLogUser> selectOpLogUsers(Long opLogId);
+
+    /**
+     * 绑定单个用户与留存记录(App 领取等场景)
+     */
+    void bindOpLogUser(Long opLogId, Long liveId, Long userId);
+
+    /**
+     * 绑定单个用户与留存记录,可附带用户优惠券记录 ID
+     */
+    void bindOpLogUser(Long opLogId, Long liveId, Long userId, Long couponUserId);
+}

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

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

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

@@ -0,0 +1,132 @@
+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.mapper.LiveConsoleOpLogMapper;
+import com.fs.live.mapper.LiveConsoleOpLogUserMapper;
+import com.fs.live.service.ILiveConsoleOpLogService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
+
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 直播中控台操作留存 Service 实现
+ */
+@Service
+public class LiveConsoleOpLogServiceImpl implements ILiveConsoleOpLogService {
+
+    /** 运营自动化默认操作人名称 */
+    private static final String AUTO_OPERATOR_NAME = "运营自动化";
+
+    @Autowired
+    private LiveConsoleOpLogMapper liveConsoleOpLogMapper;
+
+    @Autowired
+    private LiveConsoleOpLogUserMapper liveConsoleOpLogUserMapper;
+
+    @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) {
+            // 定时任务等非 Web 上下文无登录用户
+        }
+        if (LiveConsoleOpLog.HANDLE_AUTO == handleType && StringUtils.isEmpty(log.getOperatorName())) {
+            log.setOperatorName(AUTO_OPERATOR_NAME);
+        }
+        liveConsoleOpLogMapper.insertLiveConsoleOpLog(log);
+        return log;
+    }
+
+    @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)));
+    }
+
+    /**
+     * 判断是否为核销券 / 核销代金券类型
+     */
+    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("核销券"));
+    }
+}

+ 8 - 0
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;
 
     /**
      * 查询优惠券
@@ -239,6 +241,9 @@ public class LiveCouponServiceImpl implements ILiveCouponService
         }
 
         liveCouponMapper.updateShow(liveId, couponId, isShow ? 1 : 0);
+        if (isShow) {
+            liveConsoleOpLogService.saveCouponShowLog(liveId, couponId, liveCoupon, LiveConsoleOpLog.HANDLE_CONSOLE);
+        }
         return R.ok("操作成功");
     }
 
@@ -346,6 +351,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();

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

@@ -4,6 +4,7 @@ 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.live.domain.LiveConsoleOpLog;
 import com.fs.live.domain.LiveLotteryConf;
 import com.fs.live.domain.LiveLotteryRegistration;
 import com.fs.live.mapper.LiveLotteryConfMapper;
@@ -14,6 +15,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 +55,8 @@ public class LiveLotteryConfServiceImpl implements ILiveLotteryConfService {
     private LiveUserLotteryRecordMapper liveUserLotteryRecordMapper;
     @Autowired
     private ILiveAutoTaskService liveAutoTaskService;
+    @Autowired
+    private ILiveConsoleOpLogService liveConsoleOpLogService;
     /**
      * 查询直播抽奖配置
      *
@@ -114,7 +118,17 @@ 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())) {
+            liveConsoleOpLogService.saveLog(
+                    liveLotteryConf.getLiveId(),
+                    LiveConsoleOpLog.OP_LOTTERY_SETTLE,
+                    LiveConsoleOpLog.HANDLE_CONSOLE,
+                    liveLotteryConf.getLotteryId(),
+                    liveLotteryConf.getDesc()
+            );
+        }
+        return R.ok().put("data", rows);
     }
 
     /**
@@ -304,6 +318,9 @@ public class LiveLotteryConfServiceImpl implements ILiveLotteryConfService {
 
     @Override
     public void finishStatusByLotteryIds(List<Long> ids) {
+        if (ids == null || ids.isEmpty()) {
+            return;
+        }
         baseMapper.finishStatusByLotteryIds(ids);
     }
 

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

@@ -22,6 +22,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 +76,8 @@ public class LiveRedConfServiceImpl implements ILiveRedConfService {
 
     @Autowired
     private LiveRedConfMapper baseMapper;
+    @Autowired
+    private ILiveConsoleOpLogService liveConsoleOpLogService;
 
     /**
      * 查询直播红包记录配置
@@ -144,7 +147,17 @@ 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) {
+            liveConsoleOpLogService.saveLog(
+                    liveRedConf.getLiveId(),
+                    LiveConsoleOpLog.OP_RED_SETTLE,
+                    LiveConsoleOpLog.HANDLE_CONSOLE,
+                    liveRedConf.getRedId(),
+                    liveRedConf.getDesc()
+            );
+        }
+        return rows;
     }
 
     /**
@@ -368,8 +381,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 +417,29 @@ 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.saveLog(
+                            conf.getLiveId(),
+                            LiveConsoleOpLog.OP_RED_SETTLE,
+                            LiveConsoleOpLog.HANDLE_AUTO,
+                            conf.getRedId(),
+                            conf.getDesc()
+                    ));
+                }
+            }
             baseMapper.finishRedStatusBySetIds(range);
             redStatusUpdate(range.stream().map(Long::valueOf).collect(Collectors.toSet()));
             log.info("结束红包状态完成");
         }catch (Exception e){
             log.info("红包状态结束异常",e);
         }
-
+        return opLogs;
     }
 
     @Override

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

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

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

+ 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>

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

@@ -0,0 +1,31 @@
+<?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>
+</mapper>

+ 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 - 0
fs-user-app/src/main/java/com/fs/app/facade/impl/LiveFacadeServiceImpl.java

@@ -56,6 +56,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 +260,7 @@ 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("参与抽奖成功!请在直播间等待开奖");
     }
 }