Parcourir la source

1、兼容链接websocket参数问题
2、观看奖励可多选和设置数量、直播题库 可多选关联
签到、观看奖励、答题奖励:都需要关联奖励(选择奖励类型(可多选):红包、积分、核销卷(可多选、数量可设置)、优惠劵(可多选、数量可设置))

yys il y a 1 semaine
Parent
commit
3e9b3e0935

+ 56 - 24
fs-live-app/src/main/java/com/fs/live/task/LiveCompletionPointsTask.java

@@ -12,6 +12,7 @@ import com.fs.live.service.ILiveCompletionPointsRecordService;
 import com.fs.live.service.ILiveConsoleOpLogService;
 import com.fs.live.service.ILiveService;
 import com.fs.live.service.ILiveWatchUserService;
+import com.fs.live.utils.LiveCompletionConfigUtils;
 import com.fs.live.vo.LiveCompletionCouponInfoVO;
 import com.fs.live.vo.LiveCompletionCouponNotifyResult;
 import com.fs.live.websocket.bean.SendMsgVo;
@@ -27,6 +28,16 @@ import java.util.Set;
 
 /**
  * 直播完课奖励定时任务(积分 / 优惠券)
+ * <p>
+ * 与 {@code Task#autoUpdateWatchReward}(观看时长奖励)职责分离。
+ * 完课奖励类型 {@code completionTypes} 支持多选:
+ * <ul>
+ *   <li>完课积分:{@link #checkCompletionStatus()},留存 opType=完课积分</li>
+ *   <li>完课优惠券:{@link #checkCompletionCouponStatus()},留存 opType=完课优惠券</li>
+ * </ul>
+ * 两种类型各自独立判定、独立发放、独立写留存;同时勾选时会产生两条留存记录。
+ * 旧配置(condition=2 仅积分 / condition=3 仅优惠券)发放逻辑保持不变。
+ * </p>
  */
 @Slf4j
 @Component
@@ -52,7 +63,7 @@ public class LiveCompletionPointsTask {
 
     /**
      * 定时检查观看时长并创建完课积分记录(兜底机制)
-     * 每分钟执行一次
+     * 每分钟执行一次;仅处理 completionTypes 含积分(及旧 condition=2)的直播间
      */
     @Scheduled(cron = "0 */1 * * * ?")
     public void checkCompletionStatus() {
@@ -64,20 +75,7 @@ public class LiveCompletionPointsTask {
                 return;
             }
 
-            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);
-                }
-            });
+            processCompletionByWatchDuration(activeLives, this::handleCompletionPointsReward);
 
         } catch (Exception e) {
             log.error("检查完课积分定时任务执行失败", e);
@@ -85,8 +83,9 @@ public class LiveCompletionPointsTask {
     }
 
     /**
-     * 定时检查观看时长并推送完课优惠券「今日问题」弹窗(兜底,仅完课优惠券业务)
-     * 每分钟执行一次;未配置课题时静默跳过,不影响其他奖励逻辑
+     * 定时检查观看时长并推送完课优惠券「今日问题」弹窗(兜底)
+     * 每分钟执行一次;仅处理 completionTypes 含优惠券(及旧 condition=3)的直播间
+     * 未配置课题时静默跳过,不影响完课积分等其他奖励逻辑
      */
     @Scheduled(cron = "0 */1 * * * ?")
     public void checkCompletionCouponStatus() {
@@ -98,14 +97,42 @@ public class LiveCompletionPointsTask {
                 return;
             }
 
-            processCompletionByWatchDuration(activeLives, (liveId, userId, duration) ->
-                    dispatchCompletionCouponNotify(liveId, userId, duration, false));
+            processCompletionByWatchDuration(activeLives, this::handleCompletionCouponReward);
 
         } catch (Exception e) {
             log.error("检查完课优惠券定时任务执行失败", e);
         }
     }
 
+    /**
+     * 完课积分发放(原有业务逻辑,独立写完课积分留存)
+     */
+    private void handleCompletionPointsReward(Long liveId, Long userId, Long duration) {
+        LiveCompletionPointsRecord record =
+                completionPointsRecordService.checkAndCreateCompletionRecord(liveId, userId, duration);
+        if (record == null) {
+            return;
+        }
+        // 每种完课类型独立一条留存:此处固定写「完课积分」
+        LiveConsoleOpLog opLog = liveConsoleOpLogService.saveLog(
+                liveId,
+                LiveConsoleOpLog.OP_COMPLETION_POINTS,
+                LiveConsoleOpLog.HANDLE_AUTO,
+                record.getId(),
+                resolveCompletionPointsBizName(record)
+        );
+        liveConsoleOpLogService.bindOpLogUser(opLog.getId(), liveId, userId);
+        log.info("[完课积分] 创建记录并写入留存, liveId={}, userId={}, recordId={}, opLogId={}, points={}",
+                liveId, userId, record.getId(), opLog.getId(), record.getPointsAwarded());
+    }
+
+    /**
+     * 完课优惠券推送(原有业务逻辑,独立写完课优惠券留存)
+     */
+    private void handleCompletionCouponReward(Long liveId, Long userId, Long duration) {
+        dispatchCompletionCouponNotify(liveId, userId, duration, false);
+    }
+
     /**
      * 手动触发完课优惠券「今日问题」弹窗(单用户,供接口调用)
      */
@@ -132,9 +159,6 @@ public class LiveCompletionPointsTask {
         }
     }
 
-    /**
-     * 完课优惠券弹窗:与定时任务一致,先写留存再推送 WebSocket
-     */
     /**
      * 尝试推送完课优惠券弹窗(定时任务 / WebSocket 心跳共用)
      * @return 是否推送成功
@@ -162,10 +186,14 @@ public class LiveCompletionPointsTask {
         return opLog;
     }
 
+    /**
+     * 完课优惠券弹窗:先写「完课优惠券」留存,再推送 WebSocket(与积分留存互不影响)
+     */
     private LiveConsoleOpLog saveAndPushCompletionCouponNotify(Long liveId, Long userId,
                                                                  LiveCompletionCouponNotifyResult notifyResult) {
         String bizName = resolveCompletionCouponBizName(notifyResult);
         Long bizId = notifyResult.getCoupon() != null ? notifyResult.getCoupon().getCouponId() : null;
+        // 每种完课类型独立一条留存:此处固定写「完课优惠券」
         LiveConsoleOpLog opLog = liveConsoleOpLogService.saveLog(
                 liveId,
                 LiveConsoleOpLog.OP_COMPLETION_COUPON,
@@ -175,6 +203,8 @@ public class LiveCompletionPointsTask {
         );
         if (pushCompletionCouponQuestion(liveId, userId, notifyResult, opLog)) {
             completionCouponService.markCompletionCouponNotified(liveId, userId);
+            log.info("[完课优惠券] 写入留存并推送成功, liveId={}, userId={}, opLogId={}, couponId={}",
+                    liveId, userId, opLog.getId(), bizId);
             return opLog;
         }
         return null;
@@ -215,8 +245,10 @@ public class LiveCompletionPointsTask {
                     }
                 }
 
-                log.info("[完课定时] 直播liveId={} 共有{}个观看用户, 去重后{}个",
-                        liveId, watchUsers.size(), userIds.size());
+                boolean pointsEnabled = LiveCompletionConfigUtils.isCompletionPointsMode(live.getConfigJson());
+                boolean couponEnabled = LiveCompletionConfigUtils.isCompletionCouponMode(live.getConfigJson());
+                log.info("[完课定时] 直播liveId={} 共有{}个观看用户, 去重后{}个, 完课积分={}, 完课优惠券={}",
+                        liveId, watchUsers.size(), userIds.size(), pointsEnabled, couponEnabled);
 
                 for (Long userId : userIds) {
                     try {

+ 121 - 75
fs-live-app/src/main/java/com/fs/live/task/Task.java

@@ -52,6 +52,13 @@ public class Task {
     private static final Logger log = LoggerFactory.getLogger(Task.class);
     private static final String LOG_PREFIX = "[LiveScheduled]";
 
+    /** 观看奖励参与条件:达到指定观看时长(完课奖励走 LiveCompletionPointsTask,勿混用) */
+    private static final long WATCH_REWARD_CONDITION_DURATION = 1L;
+    /** 观看奖励实施动作:积分 */
+    private static final long WATCH_REWARD_ACTION_POINTS = 2L;
+    /** 观看奖励实施动作:优惠券 */
+    private static final long WATCH_REWARD_ACTION_COUPON = 3L;
+
 
     private void logTaskFinish(String taskName,  String summary) {
         log.info("{} {} 完成, {}", LOG_PREFIX, taskName, summary);
@@ -502,16 +509,22 @@ public class Task {
             }
             String configJson = live.getConfigJson();
             LiveWatchConfig config = JSON.parseObject(configJson, LiveWatchConfig.class);
-            if (!config.getEnabled() || config.getParticipateCondition() == null || config.getAction() == null) {
+            if (config == null || !Boolean.TRUE.equals(config.getEnabled())
+                    || config.getParticipateCondition() == null) {
                 log.info("{} autoUpdateWatchReward 配置未启用或缺失: liveId={}", LOG_PREFIX, live.getLiveId());
                 continue;
             }
-            // 只处理 "达到指定观看时长" 的参与条件
-            if (1 != config.getParticipateCondition()) {
+            // 只处理「达到指定观看时长」;完课积分/优惠券由 LiveCompletionPointsTask 负责
+            if (WATCH_REWARD_CONDITION_DURATION != config.getParticipateCondition()) {
                 log.info("{} autoUpdateWatchReward 参与条件非观看时长: liveId={}, condition={}",
                         LOG_PREFIX, live.getLiveId(), config.getParticipateCondition());
                 continue;
             }
+            List<Long> actions = config.resolveActions();
+            if (actions.isEmpty()) {
+                log.info("{} autoUpdateWatchReward 未配置实施动作: liveId={}", LOG_PREFIX, live.getLiveId());
+                continue;
+            }
             if (live.getStartTime() != null && live.getStartTime().isAfter(LocalDateTime.now())) {
                 log.info("{} autoUpdateWatchReward 直播未开始,跳过: liveId={}, startTime={}",
                         LOG_PREFIX, live.getLiveId(), live.getStartTime());
@@ -542,8 +555,8 @@ public class Task {
                 continue;
             }
 
-            log.info("{} autoUpdateWatchReward 准备发放: liveId={}, action={}, 用户数={}, required={}s",
-                    LOG_PREFIX, live.getLiveId(), config.getAction(), userIds.size(), requiredWatchSeconds);
+            log.info("{} autoUpdateWatchReward 准备发放: liveId={}, actions={}, 用户数={}, required={}s",
+                    LOG_PREFIX, live.getLiveId(), actions, userIds.size(), requiredWatchSeconds);
 
             int granted = grantWatchRewardToUsers(live, config, userIds);
             if (granted > 0) {
@@ -576,8 +589,11 @@ public class Task {
                 return;
             }
             LiveWatchConfig config = JSON.parseObject(live.getConfigJson(), LiveWatchConfig.class);
-            if (!config.getEnabled() || config.getParticipateCondition() == null || config.getParticipateCondition() != 1
-                    || config.getAction() == null || config.getWatchDuration() == null || config.getWatchDuration() <= 0) {
+            if (config == null || !Boolean.TRUE.equals(config.getEnabled())
+                    || config.getParticipateCondition() == null
+                    || WATCH_REWARD_CONDITION_DURATION != config.getParticipateCondition()
+                    || config.resolveActions().isEmpty()
+                    || config.getWatchDuration() == null || config.getWatchDuration() <= 0) {
                 return;
             }
             if (live.getStartTime() != null && live.getStartTime().isAfter(LocalDateTime.now())) {
@@ -602,85 +618,115 @@ public class Task {
     }
 
     /**
-     * 向达标用户发放观看奖励(积分 / 优惠券)
+     * 向达标用户发放观看奖励(支持积分 / 优惠券多选同时发放
      *
-     * @return 成功发放的用户数
+     * @return 成功发放的用户数(任一奖励发放成功即计入)
      */
     private int grantWatchRewardToUsers(Live live, LiveWatchConfig config, List<Long> userIds) {
         if (live == null || config == null || userIds == null || userIds.isEmpty()) {
             return 0;
         }
-        Long action = config.getAction();
-        if (action == null) {
+        List<Long> actions = config.resolveActions();
+        if (actions.isEmpty()) {
             return 0;
         }
         Long liveId = live.getLiveId();
-        switch (action.intValue()) {
-            case 2:
-                saveUserRewardRecord(live, userIds, BigDecimal.valueOf(config.getScoreAmount()), 2);
-                LiveConsoleOpLog watchPointsOpLog = liveConsoleOpLogService.saveLog(
-                        liveId,
-                        LiveConsoleOpLog.OP_WATCH_REWARD_POINTS,
-                        LiveConsoleOpLog.HANDLE_AUTO,
-                        liveId,
-                        resolveWatchRewardPointsBizName(config, userIds.size())
-                );
-                int pointsSuccessCount = 0;
-                for (Long uid : userIds) {
-                    if (grantWatchRewardIntegral(liveId, uid, config.getScoreAmount())) {
-                        liveConsoleOpLogService.bindOpLogUser(watchPointsOpLog.getId(), liveId, uid);
-                        webSocketServer.sendIntegralMessage(liveId, uid, config.getScoreAmount(), watchPointsOpLog);
-                        pointsSuccessCount++;
-                    }
-                }
-                if (pointsSuccessCount > 0) {
-                    log.info("{} 观看奖励积分发放完成: liveId={}, 成功用户数={}/{}",
-                            LOG_PREFIX, liveId, pointsSuccessCount, userIds.size());
-                } else {
-                    log.warn("{} 观看奖励积分发放全部失败: liveId={}, 用户数={}",
-                            LOG_PREFIX, liveId, userIds.size());
-                }
-                return pointsSuccessCount;
+        Set<Long> grantedUserIds = new HashSet<>();
 
-            case 3:
-                String actionCouponIdStr = config.getActionCouponId();
-                if (StringUtils.isBlank(actionCouponIdStr)) {
-                    log.warn("直播间观看奖励配置为优惠券,但未配置优惠券ID,liveId={}", liveId);
-                    return 0;
-                }
-                Long actionCouponId = Long.parseLong(actionCouponIdStr);
-                int actionCouponCount = resolveActionCouponCount(config);
-                LiveCoupon watchRewardCoupon = liveCouponService.selectLiveCouponById(actionCouponId);
-                List<LiveConsoleOpLogUser> couponRelations = bindCouponToUsers(live, userIds, actionCouponId, false, actionCouponCount);
-                if (!couponRelations.isEmpty()) {
-                    LiveConsoleOpLog watchCouponOpLog = liveConsoleOpLogService.saveLog(
-                            liveId,
-                            LiveConsoleOpLog.OP_WATCH_REWARD_COUPON,
-                            LiveConsoleOpLog.HANDLE_AUTO,
-                            actionCouponId,
-                            resolveWatchRewardCouponBizName(watchRewardCoupon, actionCouponId, couponRelations.size())
-                    );
-                    liveConsoleOpLogService.bindOpLogUsers(watchCouponOpLog.getId(), liveId, couponRelations);
-                    Set<Long> notifiedUserIds = new HashSet<>();
-                    couponRelations.forEach(relation -> {
-                        if (relation.getUserId() != null && notifiedUserIds.add(relation.getUserId())) {
-                            sendCouponRewardMessage(liveId, relation.getUserId(), watchRewardCoupon, watchCouponOpLog);
-                        }
-                    });
-                    log.info("{} 观看奖励优惠券发放完成: liveId={}, 用户数={}, 每人张数={}",
-                            LOG_PREFIX, liveId, notifiedUserIds.size(), actionCouponCount);
-                    return notifiedUserIds.size();
+        if (config.hasAction(WATCH_REWARD_ACTION_POINTS)) {
+            grantedUserIds.addAll(grantWatchRewardPoints(live, config, userIds));
+        }
+
+        if (config.hasAction(WATCH_REWARD_ACTION_COUPON)) {
+            grantedUserIds.addAll(grantWatchRewardCoupons(live, config, userIds));
+        }
+
+        boolean hasUnhandled = actions.stream().anyMatch(a ->
+                a != WATCH_REWARD_ACTION_POINTS && a != WATCH_REWARD_ACTION_COUPON);
+        if (hasUnhandled) {
+            log.info("{} 观看奖励含暂不处理的动作类型: actions={}, liveId={}",
+                    LOG_PREFIX, actions, liveId);
+        }
+
+        return grantedUserIds.size();
+    }
+
+    /**
+     * 发放观看奖励积分
+     *
+     * @return 成功发放的用户ID集合
+     */
+    private Set<Long> grantWatchRewardPoints(Live live, LiveWatchConfig config, List<Long> userIds) {
+        Set<Long> successUserIds = new HashSet<>();
+        if (config.getScoreAmount() == null || config.getScoreAmount() <= 0) {
+            log.warn("{} 观看奖励积分配额无效: liveId={}, scoreAmount={}",
+                    LOG_PREFIX, live.getLiveId(), config.getScoreAmount());
+            return successUserIds;
+        }
+        Long liveId = live.getLiveId();
+        saveUserRewardRecord(live, userIds, BigDecimal.valueOf(config.getScoreAmount()), 2);
+        LiveConsoleOpLog watchPointsOpLog = liveConsoleOpLogService.saveLog(
+                liveId,
+                LiveConsoleOpLog.OP_WATCH_REWARD_POINTS,
+                LiveConsoleOpLog.HANDLE_AUTO,
+                liveId,
+                resolveWatchRewardPointsBizName(config, userIds.size())
+        );
+        for (Long uid : userIds) {
+            if (grantWatchRewardIntegral(liveId, uid, config.getScoreAmount())) {
+                liveConsoleOpLogService.bindOpLogUser(watchPointsOpLog.getId(), liveId, uid);
+                webSocketServer.sendIntegralMessage(liveId, uid, config.getScoreAmount(), watchPointsOpLog);
+                successUserIds.add(uid);
+            }
+        }
+        if (!successUserIds.isEmpty()) {
+            log.info("{} 观看奖励积分发放完成: liveId={}, 成功用户数={}/{}",
+                    LOG_PREFIX, liveId, successUserIds.size(), userIds.size());
+        } else {
+            log.warn("{} 观看奖励积分发放全部失败: liveId={}, 用户数={}",
+                    LOG_PREFIX, liveId, userIds.size());
+        }
+        return successUserIds;
+    }
+
+    /**
+     * 发放观看奖励优惠券
+     *
+     * @return 成功发放的用户ID集合
+     */
+    private Set<Long> grantWatchRewardCoupons(Live live, LiveWatchConfig config, List<Long> userIds) {
+        Set<Long> notifiedUserIds = new HashSet<>();
+        Long liveId = live.getLiveId();
+        String actionCouponIdStr = config.getActionCouponId();
+        if (StringUtils.isBlank(actionCouponIdStr)) {
+            log.warn("直播间观看奖励配置为优惠券,但未配置优惠券ID,liveId={}", liveId);
+            return notifiedUserIds;
+        }
+        Long actionCouponId = Long.parseLong(actionCouponIdStr);
+        int actionCouponCount = resolveActionCouponCount(config);
+        LiveCoupon watchRewardCoupon = liveCouponService.selectLiveCouponById(actionCouponId);
+        List<LiveConsoleOpLogUser> couponRelations = bindCouponToUsers(live, userIds, actionCouponId, false, actionCouponCount);
+        if (!couponRelations.isEmpty()) {
+            LiveConsoleOpLog watchCouponOpLog = liveConsoleOpLogService.saveLog(
+                    liveId,
+                    LiveConsoleOpLog.OP_WATCH_REWARD_COUPON,
+                    LiveConsoleOpLog.HANDLE_AUTO,
+                    actionCouponId,
+                    resolveWatchRewardCouponBizName(watchRewardCoupon, actionCouponId, couponRelations.size())
+            );
+            liveConsoleOpLogService.bindOpLogUsers(watchCouponOpLog.getId(), liveId, couponRelations);
+            couponRelations.forEach(relation -> {
+                if (relation.getUserId() != null && notifiedUserIds.add(relation.getUserId())) {
+                    sendCouponRewardMessage(liveId, relation.getUserId(), watchRewardCoupon, watchCouponOpLog);
                 }
-                log.warn("{} 观看奖励优惠券发放无成功用户: liveId={}, couponId={}",
-                        LOG_PREFIX, liveId, actionCouponId);
-                return 0;
-
-            case 1:
-            default:
-                log.info("{} 观看奖励类型暂不处理: action={}, liveId={}",
-                        LOG_PREFIX, action, liveId);
-                return 0;
+            });
+            log.info("{} 观看奖励优惠券发放完成: liveId={}, 用户数={}, 每人张数={}",
+                    LOG_PREFIX, liveId, notifiedUserIds.size(), actionCouponCount);
+            return notifiedUserIds;
         }
+        log.warn("{} 观看奖励优惠券发放无成功用户: liveId={}, couponId={}",
+                LOG_PREFIX, liveId, actionCouponId);
+        return notifiedUserIds;
     }
 
     /**
@@ -814,7 +860,7 @@ public class Task {
                 if (!Boolean.TRUE.equals(config.getEnabled())) {
                     return R.error("观看奖励未开启");
                 }
-                if (config.getAction() == null || config.getAction().intValue() != 3) {
+                if (!config.hasAction(WATCH_REWARD_ACTION_COUPON)) {
                     return R.error("观看奖励类型不是优惠券,请传入 couponId");
                 }
                 String actionCouponIdStr = config.getActionCouponId();

+ 17 - 4
fs-live-app/src/main/java/com/fs/live/websocket/auth/WebSocketConfigurator.java

@@ -13,6 +13,8 @@ import javax.websocket.server.HandshakeRequest;
 import javax.websocket.server.ServerEndpointConfig;
 import java.util.List;
 import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
 @Slf4j
 public class WebSocketConfigurator extends ServerEndpointConfig.Configurator {
@@ -129,16 +131,27 @@ public class WebSocketConfigurator extends ServerEndpointConfig.Configurator {
     }
 
     /**
-     * 安全地解析Long类型参数
+     * 安全地解析Long类型参数。兼容误传 "liveId=110" 等情况,失败时仅提取数字。
+     * 验签仍使用原始 query 参数,不受此处影响。
      */
     private Long safeParseLong(String value, String paramName) {
         if (value == null || value.trim().isEmpty() ||
-                "undefined".equals(value) || "null".equals(value)) {
+                "undefined".equals(value.trim()) || "null".equals(value.trim())) {
             throw new BaseException("参数 " + paramName + " 的值无效: " + value);
         }
+        String trimmed = value.trim();
         try {
-            return Long.valueOf(value);
-        } catch (NumberFormatException e) {
+            return Long.valueOf(trimmed);
+        } catch (NumberFormatException ignored) {
+            // 从字符串中取第一段连续数字,如 "liveId=110" -> 110
+            Matcher matcher = Pattern.compile("\\d+").matcher(trimmed);
+            if (matcher.find()) {
+                try {
+                    return Long.valueOf(matcher.group());
+                } catch (NumberFormatException e) {
+                    // fall through
+                }
+            }
             throw new BaseException("参数 " + paramName + " 格式错误: " + value);
         }
     }

+ 10 - 6
fs-live-app/src/main/java/com/fs/live/websocket/service/WebSocketServer.java

@@ -1832,6 +1832,7 @@ public class WebSocketServer {
 
     /**
      * 心跳节流检查完课积分 / 完课优惠券(每 60 秒最多一次)
+     * 两种类型可同时开启:各自独立检查与推送,各自独立写留存
      */
     private void checkCompletionRewardsOnHeartbeat(long liveId, long userId) {
         String throttleKey = "live:completion:heartbeat:check:" + liveId + ":" + userId;
@@ -1844,11 +1845,16 @@ public class WebSocketServer {
                 return;
             }
             Live live = liveService.selectLiveByLiveId(liveId);
-            if (live != null && com.fs.live.utils.LiveCompletionConfigUtils.isCompletionPointsMode(live.getConfigJson())) {
+            String configJson = live != null ? live.getConfigJson() : null;
+            // 完课积分:独立判定,写完课积分留存
+            if (com.fs.live.utils.LiveCompletionConfigUtils.isCompletionPointsMode(configJson)) {
                 checkAndSendCompletionPointsInRealTime(liveId, userId, duration);
             }
-            SpringUtils.getBean(LiveCompletionPointsTask.class)
-                    .tryDispatchCompletionCouponNotify(liveId, userId, duration);
+            // 完课优惠券:独立判定,写完课优惠券留存(与积分互不影响)
+            if (com.fs.live.utils.LiveCompletionConfigUtils.isCompletionCouponMode(configJson)) {
+                SpringUtils.getBean(LiveCompletionPointsTask.class)
+                        .tryDispatchCompletionCouponNotify(liveId, userId, duration);
+            }
         } catch (Exception e) {
             log.error("[完课心跳检查] 失败, liveId={}, userId={}", liveId, userId, e);
         }
@@ -1942,9 +1948,7 @@ public class WebSocketServer {
             }
 
             JSONObject jsonConfig = JSON.parseObject(configJson);
-            if (com.fs.live.utils.LiveCompletionConfigUtils.isCompletionCouponMode(configJson)) {
-                return;
-            }
+            // 完课类型可多选:只要开启了完课积分就推送倒计时配置,不再因同时开启优惠券而跳过
             if (!com.fs.live.utils.LiveCompletionConfigUtils.isCompletionPointsMode(configJson)) {
                 return;
             }

+ 68 - 8
fs-service/src/main/java/com/fs/live/domain/LiveWatchConfig.java

@@ -2,10 +2,12 @@ package com.fs.live.domain;
 
 import com.fs.common.annotation.Excel;
 import com.fs.common.core.domain.BaseEntity;
+import com.fs.live.utils.LiveCompletionConfigUtils;
 import lombok.Data;
 import lombok.EqualsAndHashCode;
 
 import java.math.BigDecimal;
+import java.util.List;
 
 /**
  * 直播观看奖励设置对象 live_watch_config
@@ -26,18 +28,30 @@ public class LiveWatchConfig extends BaseEntity{
 
     private Boolean enabled;
 
-    /** 参与条件 1达到指定观看时长 */
-    @Excel(name = "参与条件 1达到指定观看时长 2观看比例达到指定积分")
+    /** 参与条件 1达到指定观看时长 2完课奖励(积分/优惠券由completionTypes区分) 3完课优惠券(旧) */
+    @Excel(name = "参与条件 1达到指定观看时长 2完课奖励 3完课优惠券(旧)")
     private Long participateCondition;
 
     /** 观看时长 */
     @Excel(name = "观看时长")
     private Long watchDuration;
 
-    /** 实施动作 1现金红包 2积分红包 */
-    @Excel(name = "实施动作 1现金红包 2积分红包")
+    /** 实施动作 1现金红包 2积分红包 3优惠券(旧单选字段,兼容用) */
+    @Excel(name = "实施动作 1现金红包 2积分红包 3优惠券")
     private Long action;
 
+    /**
+     * 实施动作多选(观看时长):2积分 3优惠券
+     * 支持 JSON 数组 / 逗号串,解析请用 {@link #resolveActions()}
+     */
+    private Object actions;
+
+    /**
+     * 完课奖励类型多选:2完课积分 3完课优惠券
+     * 支持 JSON 数组 / 逗号串,解析请用 {@link #resolveCompletionTypes()}
+     */
+    private Object completionTypes;
+
     /** 领取提示语 */
     @Excel(name = "领取提示语")
     private String receivePrompt;
@@ -110,11 +124,11 @@ public class LiveWatchConfig extends BaseEntity{
     @Excel(name = "优惠券引导语")
     private String couponGuideText;
 
-    /** 完课优惠券ID(participateCondition=3时使用) */
+    /** 完课优惠券ID(完课类型含优惠券时使用) */
     @Excel(name = "完课优惠券ID")
     private String finishCouponId;
 
-    /** 完课优惠券领取数量(participateCondition=3时使用,每人领取张数) */
+    /** 完课优惠券领取数量(完课类型含优惠券时使用,每人领取张数) */
     @Excel(name = "完课优惠券领取数量")
     private Long finishCouponCount;
 
@@ -122,7 +136,53 @@ public class LiveWatchConfig extends BaseEntity{
     @Excel(name = "配置json")
     private String configJson;
 
-
-
+    /**
+     * 解析观看时长实施动作列表(兼容旧字段 action)
+     */
+    public List<Long> resolveActions() {
+        List<Long> list = LiveCompletionConfigUtils.parseLongList(this.actions);
+        if (!list.isEmpty()) {
+            return list;
+        }
+        if (this.action != null) {
+            return java.util.Collections.singletonList(this.action);
+        }
+        return java.util.Collections.emptyList();
+    }
+
+    /**
+     * 是否包含指定实施动作
+     */
+    public boolean hasAction(long actionType) {
+        return resolveActions().contains(actionType);
+    }
+
+    /**
+     * 解析完课奖励类型列表(兼容旧 participateCondition=2/3)
+     */
+    public List<Long> resolveCompletionTypes() {
+        Long condition = this.participateCondition;
+        if (condition == null) {
+            return java.util.Collections.emptyList();
+        }
+        if (condition == LiveCompletionConfigUtils.PARTICIPATE_CONDITION_COMPLETION_COUPON) {
+            return java.util.Collections.singletonList(LiveCompletionConfigUtils.TYPE_COUPON);
+        }
+        if (condition != LiveCompletionConfigUtils.PARTICIPATE_CONDITION_COMPLETION) {
+            return java.util.Collections.emptyList();
+        }
+        List<Long> list = LiveCompletionConfigUtils.parseLongList(this.completionTypes);
+        if (!list.isEmpty()) {
+            return list;
+        }
+        return java.util.Collections.singletonList(LiveCompletionConfigUtils.TYPE_POINTS);
+    }
+
+    /**
+     * 是否包含指定完课类型
+     */
+    public boolean hasCompletionType(long type) {
+        return resolveCompletionTypes().contains(type);
+    }
 
 }

+ 22 - 5
fs-service/src/main/java/com/fs/live/mapper/LiveMapper.java

@@ -123,27 +123,44 @@ public interface LiveMapper
 
     /**
      * 查询开启了完课积分配置的直播间(用于完课积分定时任务)
+     * 新:participateCondition=2 且 completionTypes 含 2(或无 types 的旧完课积分)
+     * 旧:participateCondition=2 且无 completionTypes
      * @return 直播列表
      */
     @Select("select * from live where status != 3 and live_type in (2,3) and is_audit = 1 " +
             "and config_json is not null " +
             "and JSON_EXTRACT(config_json, '$.enabled') = true " +
-            "and (JSON_EXTRACT(config_json, '$.participateCondition') is null " +
-            "     or CAST(JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.participateCondition')) AS UNSIGNED) != 3) " +
             "and JSON_EXTRACT(config_json, '$.pointsConfig') is not null " +
-            "and JSON_LENGTH(JSON_EXTRACT(config_json, '$.pointsConfig')) > 0")
+            "and JSON_LENGTH(JSON_EXTRACT(config_json, '$.pointsConfig')) > 0 " +
+            "and CAST(JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.participateCondition')) AS UNSIGNED) = 2 " +
+            "and (" +
+            "  JSON_EXTRACT(config_json, '$.completionTypes') is null " +
+            "  or JSON_CONTAINS(JSON_EXTRACT(config_json, '$.completionTypes'), '2') " +
+            "  or JSON_CONTAINS(JSON_EXTRACT(config_json, '$.completionTypes'), '\"2\"')" +
+            ")")
     List<Live> selectLiveListWithCompletionPointsEnabled();
 
     /**
      * 查询开启了完课优惠券配置的直播间(用于完课优惠券定时任务)
+     * 新:participateCondition=2 且 completionTypes 含 3
+     * 旧:participateCondition=3
      * @return 直播列表
      */
     @Select("select * from live where status != 3 and live_type in (2,3) and is_audit = 1 " +
             "and config_json is not null " +
             "and JSON_EXTRACT(config_json, '$.enabled') = true " +
-            "and CAST(JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.participateCondition')) AS UNSIGNED) = 3 " +
             "and JSON_EXTRACT(config_json, '$.finishCouponId') is not null " +
-            "and JSON_EXTRACT(config_json, '$.finishCouponId') != ''")
+            "and JSON_EXTRACT(config_json, '$.finishCouponId') != '' " +
+            "and (" +
+            "  CAST(JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.participateCondition')) AS UNSIGNED) = 3 " +
+            "  or (" +
+            "    CAST(JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.participateCondition')) AS UNSIGNED) = 2 " +
+            "    and (" +
+            "      JSON_CONTAINS(JSON_EXTRACT(config_json, '$.completionTypes'), '3') " +
+            "      or JSON_CONTAINS(JSON_EXTRACT(config_json, '$.completionTypes'), '\"3\"')" +
+            "    )" +
+            "  )" +
+            ")")
     List<Live> selectLiveListWithCompletionCouponEnabled();
 
     void updateStatusAndTimeBatchById(@Param("liveList") List<Live> list);

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

@@ -11,6 +11,15 @@ public class LiveCompletionCouponClaimParam {
     /** 直播间 ID */
     private Long liveId;
 
-    /** 中控台操作留存 ID(WebSocket opLog.id) */
+    /**
+     * 中控台操作留存 ID(WebSocket 完课优惠券弹窗下发的 opLog.id)
+     * <p>完课类型多选时会同时存在完课积分/完课优惠券两条留存,此处必须传优惠券那条(opType=8)</p>
+     */
     private Long opLogId;
+
+    /**
+     * 答题记录 ID(/answer 接口返回的 recordId)
+     * <p>优先用库表 live_completion_answer_record 校验答题正确;不传则回退查当日最新答题记录</p>
+     */
+    private Long answerRecordId;
 }

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

@@ -1,5 +1,6 @@
 package com.fs.live.service;
 
+import com.fs.live.domain.LiveCompletionAnswerRecord;
 import com.fs.live.param.LiveCompletionAnswerRecordParam;
 import com.fs.live.param.LiveCompletionCouponAnswerItem;
 import com.fs.live.vo.LiveCompletionAnswerDetailVO;
@@ -22,6 +23,16 @@ public interface ILiveCompletionAnswerRecordService {
      */
     LiveCompletionAnswerRecordListVO selectLiveCompletionAnswerRecordById(Long id);
 
+    /**
+     * 按主键查询实体
+     */
+    LiveCompletionAnswerRecord selectById(Long id);
+
+    /**
+     * 查询用户当日在该直播间的最新答题记录
+     */
+    LiveCompletionAnswerRecord selectTodayLatest(Long liveId, Long userId);
+
     /**
      * 保存完课答题记录(/app/live/completion/coupon/answer 提交时调用)
      *
@@ -30,4 +41,4 @@ public interface ILiveCompletionAnswerRecordService {
     Long saveAnswerRecord(Long liveId, Long userId, boolean allCorrect,
                           List<LiveCompletionCouponAnswerItem> answers,
                           List<LiveCompletionAnswerDetailVO> questionDetails);
-}
+}

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

@@ -1,6 +1,7 @@
 package com.fs.live.service.impl;
 
 import com.alibaba.fastjson.JSON;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.fs.common.utils.DateUtils;
 import com.fs.common.utils.StringUtils;
 import com.fs.his.domain.FsUser;
@@ -15,6 +16,9 @@ import com.fs.live.vo.LiveCompletionAnswerRecordListVO;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.util.Date;
 import java.util.List;
 
 /**
@@ -39,6 +43,32 @@ public class LiveCompletionAnswerRecordServiceImpl implements ILiveCompletionAns
         return liveCompletionAnswerRecordMapper.selectLiveCompletionAnswerRecordById(id);
     }
 
+    @Override
+    public LiveCompletionAnswerRecord selectById(Long id) {
+        if (id == null) {
+            return null;
+        }
+        return liveCompletionAnswerRecordMapper.selectById(id);
+    }
+
+    @Override
+    public LiveCompletionAnswerRecord selectTodayLatest(Long liveId, Long userId) {
+        if (liveId == null || userId == null) {
+            return null;
+        }
+        LocalDate today = LocalDate.now();
+        Date dayStart = Date.from(today.atStartOfDay(ZoneId.systemDefault()).toInstant());
+        Date dayEnd = Date.from(today.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant());
+        LambdaQueryWrapper<LiveCompletionAnswerRecord> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(LiveCompletionAnswerRecord::getLiveId, liveId)
+                .eq(LiveCompletionAnswerRecord::getUserId, userId)
+                .ge(LiveCompletionAnswerRecord::getCreateTime, dayStart)
+                .lt(LiveCompletionAnswerRecord::getCreateTime, dayEnd)
+                .orderByDesc(LiveCompletionAnswerRecord::getCreateTime)
+                .last("LIMIT 1");
+        return liveCompletionAnswerRecordMapper.selectOne(wrapper);
+    }
+
     @Override
     public Long saveAnswerRecord(Long liveId, Long userId, boolean allCorrect,
                                  List<LiveCompletionCouponAnswerItem> answers,
@@ -68,4 +98,4 @@ public class LiveCompletionAnswerRecordServiceImpl implements ILiveCompletionAns
         }
         return user.getPhone();
     }
-}
+}

+ 85 - 13
fs-service/src/main/java/com/fs/live/service/impl/LiveCompletionCouponServiceImpl.java

@@ -11,6 +11,7 @@ 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.utils.LiveCompletionConfigUtils;
 import com.fs.live.vo.LiveCompletionAnswerDetailVO;
 import com.fs.live.vo.LiveCompletionCouponAnswerResult;
 import com.fs.live.vo.LiveCompletionCouponConfigVO;
@@ -182,9 +183,16 @@ 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());
+
+        LiveCompletionAnswerRecord dbAnswer = liveCompletionAnswerRecordService.selectTodayLatest(liveId, userId);
+        if (dbAnswer != null) {
+            status.setAnsweredToday(true);
+            status.setAllCorrect(dbAnswer.getIsRight() != null && dbAnswer.getIsRight() == 1);
+        } else {
+            AnswerRecord answerRecord = getAnswerRecordToday(liveId, userId);
+            status.setAnsweredToday(answerRecord != null);
+            status.setAllCorrect(answerRecord != null && answerRecord.isAllCorrect());
+        }
         return status;
     }
 
@@ -253,13 +261,12 @@ public class LiveCompletionCouponServiceImpl implements ILiveCompletionCouponSer
         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_completion_answer_record 为准)
+        assertAnswerPassedForClaim(param, liveId, userId);
+
+        // 完课类型多选时会有积分/优惠券两条留存,领取必须绑定「完课优惠券」那条
+        LiveConsoleOpLog opLog = validateCompletionCouponOpLog(param.getOpLogId(), liveId);
 
         Live live = liveService.selectLiveByLiveId(liveId);
         if (live == null) {
@@ -273,10 +280,76 @@ public class LiveCompletionCouponServiceImpl implements ILiveCompletionCouponSer
         for (LiveCouponUser couponUser : couponUsers) {
             relations.add(new LiveConsoleOpLogUser(null, userId, liveId, couponUser.getId()));
         }
-        liveConsoleOpLogService.bindOpLogUsers(param.getOpLogId(), liveId, relations);
+        liveConsoleOpLogService.bindOpLogUsers(opLog.getId(), liveId, relations);
         return couponUsers.get(0);
     }
 
+    /**
+     * 校验领取所需答题结果:answerRecordId > 当日最新库表记录 > Redis(兼容旧客户端)
+     */
+    private void assertAnswerPassedForClaim(LiveCompletionCouponClaimParam param, Long liveId, Long userId) {
+        LiveCompletionAnswerRecord dbRecord = null;
+        if (param.getAnswerRecordId() != null) {
+            dbRecord = liveCompletionAnswerRecordService.selectById(param.getAnswerRecordId());
+            if (dbRecord == null) {
+                throw new BaseException("答题记录不存在,请重新作答");
+            }
+            if (!liveId.equals(dbRecord.getLiveId()) || !userId.equals(dbRecord.getUserId())) {
+                throw new BaseException("答题记录与当前用户不匹配");
+            }
+            if (!isSameDay(dbRecord.getCreateTime(), new Date())) {
+                throw new BaseException("答题记录已过期,请重新作答");
+            }
+        } else {
+            dbRecord = liveCompletionAnswerRecordService.selectTodayLatest(liveId, userId);
+        }
+
+        if (dbRecord != null) {
+            if (dbRecord.getIsRight() == null || dbRecord.getIsRight() != 1) {
+                throw new BaseException("回答错误,请重新作答后再领取");
+            }
+            return;
+        }
+
+        // 兼容:旧链路仅写 Redis
+        AnswerRecord answerRecord = getAnswerRecordToday(liveId, userId);
+        if (answerRecord == null) {
+            throw new BaseException("请先完成今日问题");
+        }
+        if (!answerRecord.isAllCorrect()) {
+            throw new BaseException("回答错误,请重新作答后再领取");
+        }
+    }
+
+    /**
+     * 校验留存为当前直播间的「完课优惠券」记录(避免多选时误绑完课积分留存)
+     */
+    private LiveConsoleOpLog validateCompletionCouponOpLog(Long opLogId, Long liveId) {
+        if (opLogId == null) {
+            throw new BaseException("缺少留存标识,请重新完成今日问题后再领取");
+        }
+        LiveConsoleOpLog opLog = liveConsoleOpLogService.selectLiveConsoleOpLogById(opLogId);
+        if (opLog == null) {
+            throw new BaseException("留存记录不存在,请重新完成今日问题后再领取");
+        }
+        if (!liveId.equals(opLog.getLiveId())) {
+            throw new BaseException("留存记录与当前直播间不匹配");
+        }
+        if (opLog.getOpType() == null || opLog.getOpType() != LiveConsoleOpLog.OP_COMPLETION_COUPON) {
+            throw new BaseException("留存类型错误,请使用完课优惠券弹窗领取");
+        }
+        return opLog;
+    }
+
+    private boolean isSameDay(Date date, Date other) {
+        if (date == null || other == null) {
+            return false;
+        }
+        LocalDate d1 = date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
+        LocalDate d2 = other.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
+        return d1.equals(d2);
+    }
+
     /**
      * 将 App 扁平选项格式归一为每题一条作答记录。
      * <p>App 可能按「每个选项一条」提交(含 answerIndex / answerName / isAnswer),
@@ -650,8 +723,7 @@ public class LiveCompletionCouponServiceImpl implements ILiveCompletionCouponSer
                 return config;
             }
 
-            Long participateCondition = jsonConfig.getLong("participateCondition");
-            if (participateCondition == null || participateCondition != 3L) {
+            if (!LiveCompletionConfigUtils.isCompletionCouponMode(configJson)) {
                 return config;
             }
 

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

@@ -13,6 +13,7 @@ import com.fs.live.mapper.LiveCompletionPointsRecordMapper;
 import com.fs.live.service.ILiveCompletionPointsRecordService;
 import com.fs.live.service.ILiveService;
 import com.fs.live.service.ILiveWatchUserService;
+import com.fs.live.utils.LiveCompletionConfigUtils;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
@@ -383,8 +384,7 @@ public class LiveCompletionPointsRecordServiceImpl implements ILiveCompletionPoi
         try {
             JSONObject jsonConfig = JSON.parseObject(configJson);
 
-            Long participateCondition = jsonConfig.getLong("participateCondition");
-            if (participateCondition != null && participateCondition == 3L) {
+            if (!LiveCompletionConfigUtils.isCompletionPointsMode(configJson)) {
                 return config;
             }
 

+ 6 - 2
fs-service/src/main/java/com/fs/live/service/impl/LiveConsoleOpLogServiceImpl.java

@@ -315,14 +315,18 @@ public class LiveConsoleOpLogServiceImpl implements ILiveConsoleOpLogService {
 
         Date now = DateUtils.getNowDate();
         Live live = liveMapper.selectLiveByLiveId(liveId);
-        boolean completionCouponMode = live != null && LiveCompletionConfigUtils.isCompletionCouponMode(live.getConfigJson());
+        String configJson = live != null ? live.getConfigJson() : null;
+        boolean completionCouponMode = LiveCompletionConfigUtils.isCompletionCouponMode(configJson);
+        boolean completionPointsMode = LiveCompletionConfigUtils.isCompletionPointsMode(configJson);
         Map<Long, Long> couponTypeMap = resolveCouponTypeMap(opLogs);
         List<LiveConsoleOpLogRecordVo> result = new ArrayList<>(opLogs.size());
         for (LiveConsoleOpLog opLog : opLogs) {
             if (isInternalSettleOpLog(opLog)) {
                 continue;
             }
-            if (completionCouponMode && opLog.getOpType() != null
+            // 旧互斥:仅「纯完课优惠券」时隐藏完课积分留存;多选积分+优惠券时两条留存都展示
+            if (completionCouponMode && !completionPointsMode
+                    && opLog.getOpType() != null
                     && opLog.getOpType() == LiveConsoleOpLog.OP_COMPLETION_POINTS) {
                 continue;
             }

+ 166 - 8
fs-service/src/main/java/com/fs/live/utils/LiveCompletionConfigUtils.java

@@ -1,18 +1,36 @@
 package com.fs.live.utils;
 
+import com.alibaba.fastjson.JSONArray;
 import com.alibaba.fastjson.JSONObject;
 import com.fs.common.utils.StringUtils;
 
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashSet;
 import java.util.List;
+import java.util.Set;
 
 /**
- * 直播完课奖励 config_json 解析(积分 / 优惠券互斥)
+ * 直播完课 / 观看奖励 config_json 解析
+ * <p>
+ * 参与条件:1=达到指定观看时长,2=完课奖励(积分/优惠券合并,由 completionTypes 区分)
+ * 兼容旧值:participateCondition=3 视为完课优惠券
+ * </p>
  */
 public final class LiveCompletionConfigUtils {
 
-    /** 参与条件:完课领取优惠券 */
+    /** 参与条件:达到指定观看时长 */
+    public static final long PARTICIPATE_CONDITION_WATCH_DURATION = 1L;
+    /** 参与条件:完课奖励(积分/优惠券合并) */
+    public static final long PARTICIPATE_CONDITION_COMPLETION = 2L;
+    /** 参与条件(旧):完课领取优惠券 */
     public static final long PARTICIPATE_CONDITION_COMPLETION_COUPON = 3L;
 
+    /** 类型/实施动作:积分 */
+    public static final long TYPE_POINTS = 2L;
+    /** 类型/实施动作:优惠券 */
+    public static final long TYPE_COUPON = 3L;
+
     private LiveCompletionConfigUtils() {
     }
 
@@ -28,33 +46,173 @@ public final class LiveCompletionConfigUtils {
     }
 
     /**
-     * 完课优惠券模式:enabled + participateCondition=3 + finishCouponId
+     * 是否为完课参与条件(含旧值 3)
+     */
+    public static boolean isCompletionParticipate(JSONObject json) {
+        if (json == null) {
+            return false;
+        }
+        Long participateCondition = json.getLong("participateCondition");
+        return participateCondition != null
+                && (participateCondition == PARTICIPATE_CONDITION_COMPLETION
+                || participateCondition == PARTICIPATE_CONDITION_COMPLETION_COUPON);
+    }
+
+    /**
+     * 解析完课奖励类型多选:2=完课积分,3=完课优惠券
+     * <p>兼容:旧 condition=3 → [3];旧 condition=2 且无 completionTypes → [2]</p>
+     */
+    public static List<Long> resolveCompletionTypes(JSONObject json) {
+        if (json == null) {
+            return Collections.emptyList();
+        }
+        Long participateCondition = json.getLong("participateCondition");
+        if (participateCondition == null) {
+            return Collections.emptyList();
+        }
+        if (participateCondition == PARTICIPATE_CONDITION_COMPLETION_COUPON) {
+            return Collections.singletonList(TYPE_COUPON);
+        }
+        if (participateCondition != PARTICIPATE_CONDITION_COMPLETION) {
+            return Collections.emptyList();
+        }
+        List<Long> types = parseLongList(json.get("completionTypes"));
+        if (!types.isEmpty()) {
+            return types;
+        }
+        // 旧数据:condition=2 仅表示完课积分
+        return Collections.singletonList(TYPE_POINTS);
+    }
+
+    public static boolean hasCompletionType(JSONObject json, long type) {
+        return resolveCompletionTypes(json).contains(type);
+    }
+
+    /**
+     * 解析观看时长实施动作多选:2=积分,3=优惠券
+     * <p>兼容旧字段 action(单值)</p>
+     */
+    public static List<Long> resolveWatchActions(JSONObject json) {
+        if (json == null) {
+            return Collections.emptyList();
+        }
+        List<Long> actions = parseLongList(json.get("actions"));
+        if (!actions.isEmpty()) {
+            return actions;
+        }
+        Long action = json.getLong("action");
+        if (action != null) {
+            return Collections.singletonList(action);
+        }
+        return Collections.emptyList();
+    }
+
+    public static boolean hasWatchAction(JSONObject json, long action) {
+        return resolveWatchActions(json).contains(action);
+    }
+
+    /**
+     * 完课优惠券模式:enabled + 完课条件 + 类型含优惠券 + finishCouponId
      */
     public static boolean isCompletionCouponMode(String configJson) {
         JSONObject json = parseConfig(configJson);
         if (json == null || !json.getBooleanValue("enabled")) {
             return false;
         }
-        Long participateCondition = json.getLong("participateCondition");
-        if (participateCondition == null || participateCondition != PARTICIPATE_CONDITION_COMPLETION_COUPON) {
+        if (!isCompletionParticipate(json) || !hasCompletionType(json, TYPE_COUPON)) {
             return false;
         }
         return StringUtils.isNotEmpty(json.getString("finishCouponId"));
     }
 
     /**
-     * 完课积分模式:enabled + 非优惠券模式 + 配置了 pointsConfig
+     * 完课积分模式:enabled + 完课条件 + 类型含积分 + 配置了 pointsConfig
      */
     public static boolean isCompletionPointsMode(String configJson) {
         JSONObject json = parseConfig(configJson);
         if (json == null || !json.getBooleanValue("enabled")) {
             return false;
         }
-        Long participateCondition = json.getLong("participateCondition");
-        if (participateCondition != null && participateCondition == PARTICIPATE_CONDITION_COMPLETION_COUPON) {
+        if (!isCompletionParticipate(json) || !hasCompletionType(json, TYPE_POINTS)) {
             return false;
         }
         List<?> pointsConfig = json.getObject("pointsConfig", List.class);
         return pointsConfig != null && !pointsConfig.isEmpty();
     }
+
+    /**
+     * 观看时长奖励是否开启(enabled + condition=1 + 至少一项实施动作)
+     */
+    public static boolean isWatchDurationRewardMode(String configJson) {
+        JSONObject json = parseConfig(configJson);
+        if (json == null || !json.getBooleanValue("enabled")) {
+            return false;
+        }
+        Long participateCondition = json.getLong("participateCondition");
+        if (participateCondition == null || participateCondition != PARTICIPATE_CONDITION_WATCH_DURATION) {
+            return false;
+        }
+        return !resolveWatchActions(json).isEmpty();
+    }
+
+    /**
+     * 将 actions / completionTypes 等字段统一解析为 Long 列表(支持数组、逗号串、单值)
+     */
+    public static List<Long> parseLongList(Object raw) {
+        if (raw == null) {
+            return Collections.emptyList();
+        }
+        Set<Long> result = new LinkedHashSet<>();
+        if (raw instanceof JSONArray) {
+            JSONArray arr = (JSONArray) raw;
+            for (int i = 0; i < arr.size(); i++) {
+                addLong(result, arr.get(i));
+            }
+        } else if (raw instanceof List) {
+            for (Object item : (List<?>) raw) {
+                addLong(result, item);
+            }
+        } else if (raw instanceof Number) {
+            addLong(result, raw);
+        } else {
+            String text = String.valueOf(raw).trim();
+            if (StringUtils.isEmpty(text) || "null".equalsIgnoreCase(text)) {
+                return Collections.emptyList();
+            }
+            // 兼容 JSON 数组字符串
+            if (text.startsWith("[") && text.endsWith("]")) {
+                try {
+                    return parseLongList(JSONArray.parseArray(text));
+                } catch (Exception ignored) {
+                    // fall through
+                }
+            }
+            for (String part : text.split(",")) {
+                addLong(result, part.trim());
+            }
+        }
+        return new ArrayList<>(result);
+    }
+
+    private static void addLong(Set<Long> result, Object value) {
+        if (value == null) {
+            return;
+        }
+        try {
+            if (value instanceof Number) {
+                result.add(((Number) value).longValue());
+                return;
+            }
+            String text = String.valueOf(value).trim();
+            if (StringUtils.isEmpty(text) || "null".equalsIgnoreCase(text)
+                    || "\"".equals(text) || text.startsWith("\"")) {
+                text = text.replace("\"", "").trim();
+            }
+            if (StringUtils.isNotEmpty(text)) {
+                result.add(Long.parseLong(text));
+            }
+        } catch (NumberFormatException ignored) {
+            // skip invalid
+        }
+    }
 }

+ 6 - 1
fs-user-app/src/main/java/com/fs/app/controller/live/LiveCompletionCouponController.java

@@ -36,6 +36,7 @@ public class LiveCompletionCouponController extends AppBaseController {
 
     /**
      * 提交今日问题(校验答题结果并写入 live_completion_answer_record,不发券)
+     * <p>答对后返回 recordId,领取时建议一并回传 answerRecordId</p>
      */
     @PostMapping("/answer")
     @RepeatSubmit
@@ -49,7 +50,11 @@ public class LiveCompletionCouponController extends AppBaseController {
     }
 
     /**
-     * 领取福利券(需先答题全部正确,回传 WebSocket 下发的 opLogId)
+     * 领取福利券
+     * <p>
+     * 需先答题全部正确。完课类型多选时留存有两条,请传完课优惠券弹窗的 opLogId(opType=8),
+     * 并建议回传 /answer 返回的 answerRecordId(recordId)。
+     * </p>
      */
     @PostMapping("/claim")
     @RepeatSubmit