ソースを参照

1、完课奖励调整到导航外部,领取奖励调整
2、观看奖励,完课奖励定时任务进行处理

yys 1 週間 前
コミット
aff3246859

+ 76 - 12
fs-live-app/src/main/java/com/fs/live/task/LiveCompletionPointsTask.java

@@ -22,6 +22,8 @@ import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
 
+import java.util.ArrayList;
+import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
@@ -35,7 +37,9 @@ import java.util.Set;
  *   <li>完课积分:{@link #checkCompletionStatus()},留存 opType=完课积分</li>
  *   <li>完课优惠券:{@link #checkCompletionCouponStatus()},留存 opType=完课优惠券</li>
  * </ul>
- * 两种类型各自独立判定、独立发放、独立写留存;同时勾选时会产生两条留存记录。
+ * 完课优惠券支持 {@code finishCoupons} 多选,每项含 couponId + count(领取张数);
+ * 定时任务仅推送「今日问题」并写一条完课优惠券留存,真正按多券×张数发放在领取接口完成。
+ * 两种类型各自独立判定、独立写留存;同时勾选时会产生两条留存记录。
  * 旧配置(condition=2 仅积分 / condition=3 仅优惠券)发放逻辑保持不变。
  * </p>
  */
@@ -187,12 +191,22 @@ public class LiveCompletionPointsTask {
     }
 
     /**
-     * 完课优惠券弹窗:先写「完课优惠券」留存,再推送 WebSocket(与积分留存互不影响)
+     * 完课优惠券弹窗:先写「完课优惠券」留存,再推送 WebSocket(与积分留存互不影响)。
+     * <p>
+     * 多选优惠券仍只写一条留存;领取时按 finishCoupons 逐券、按 count 发多张并全部绑定到该留存。
+     * </p>
      */
     private LiveConsoleOpLog saveAndPushCompletionCouponNotify(Long liveId, Long userId,
                                                                  LiveCompletionCouponNotifyResult notifyResult) {
-        String bizName = resolveCompletionCouponBizName(notifyResult);
-        Long bizId = notifyResult.getCoupon() != null ? notifyResult.getCoupon().getCouponId() : null;
+        List<LiveCompletionCouponInfoVO> coupons = resolveNotifyCoupons(notifyResult);
+        if (coupons.isEmpty()) {
+            log.warn("[完课优惠券] 推送数据无有效优惠券,跳过, liveId={}, userId={}", liveId, userId);
+            return null;
+        }
+        String bizName = resolveCompletionCouponBizName(coupons);
+        // 单券写 bizId;多券 bizId 取首张(兼容旧兜底),张数明细落在 bizName / WS coupons
+        Long bizId = coupons.get(0).getCouponId();
+        int totalSheets = sumCouponSheets(coupons);
         // 每种完课类型独立一条留存:此处固定写「完课优惠券」
         LiveConsoleOpLog opLog = liveConsoleOpLogService.saveLog(
                 liveId,
@@ -203,8 +217,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);
+            log.info("[完课优惠券] 写入留存并推送成功, liveId={}, userId={}, opLogId={}, couponKinds={}, totalSheets={}, bizName={}",
+                    liveId, userId, opLog.getId(), coupons.size(), totalSheets, bizName);
             return opLog;
         }
         return null;
@@ -293,14 +307,64 @@ public class LiveCompletionPointsTask {
         return record.getId() != null ? "完课积分 #" + record.getId() : "完课积分";
     }
 
-    private String resolveCompletionCouponBizName(LiveCompletionCouponNotifyResult notifyResult) {
-        if (notifyResult == null || notifyResult.getCoupon() == null) {
+    /** 优先 coupons 多选列表,兼容旧字段 coupon */
+    private List<LiveCompletionCouponInfoVO> resolveNotifyCoupons(LiveCompletionCouponNotifyResult notifyResult) {
+        if (notifyResult == null) {
+            return Collections.emptyList();
+        }
+        if (notifyResult.getCoupons() != null && !notifyResult.getCoupons().isEmpty()) {
+            return notifyResult.getCoupons();
+        }
+        if (notifyResult.getCoupon() != null) {
+            return Collections.singletonList(notifyResult.getCoupon());
+        }
+        return Collections.emptyList();
+    }
+
+    private int sumCouponSheets(List<LiveCompletionCouponInfoVO> coupons) {
+        int total = 0;
+        if (coupons == null) {
+            return 0;
+        }
+        for (LiveCompletionCouponInfoVO coupon : coupons) {
+            if (coupon == null) {
+                continue;
+            }
+            Integer count = coupon.getCount();
+            total += (count != null && count > 0) ? count : 1;
+        }
+        return total;
+    }
+
+    /**
+     * 留存展示名:单券「标题」或「标题×N」;多券「完课优惠券:A×2、B×1」
+     */
+    private String resolveCompletionCouponBizName(List<LiveCompletionCouponInfoVO> coupons) {
+        if (coupons == null || coupons.isEmpty()) {
+            return "完课优惠券";
+        }
+        List<String> parts = new ArrayList<>();
+        for (LiveCompletionCouponInfoVO coupon : coupons) {
+            if (coupon == null) {
+                continue;
+            }
+            String title;
+            if (StringUtils.isNotEmpty(coupon.getTitle())) {
+                title = coupon.getTitle();
+            } else if (coupon.getCouponId() != null) {
+                title = "优惠券#" + coupon.getCouponId();
+            } else {
+                continue;
+            }
+            int count = coupon.getCount() != null && coupon.getCount() > 0 ? coupon.getCount() : 1;
+            parts.add(count > 1 ? title + "×" + count : title);
+        }
+        if (parts.isEmpty()) {
             return "完课优惠券";
         }
-        LiveCompletionCouponInfoVO coupon = notifyResult.getCoupon();
-        if (StringUtils.isNotEmpty(coupon.getTitle())) {
-            return coupon.getTitle();
+        if (parts.size() == 1) {
+            return parts.get(0);
         }
-        return coupon.getCouponId() != null ? "完课优惠券 #" + coupon.getCouponId() : "完课优惠券";
+        return "完课优惠券:" + String.join("、", parts);
     }
 }

+ 62 - 40
fs-live-app/src/main/java/com/fs/live/task/Task.java

@@ -18,6 +18,7 @@ import com.fs.live.domain.*;
 import com.fs.live.mapper.LiveLotteryRegistrationMapper;
 import com.fs.live.param.LiveReplayParam;
 import com.fs.live.service.*;
+import com.fs.live.utils.LiveCompletionConfigUtils;
 import com.fs.live.vo.LiveLotteryConfVo;
 import com.fs.live.vo.LiveLotteryProductListVo;
 import com.fs.live.vo.LotteryVo;
@@ -509,15 +510,13 @@ public class Task {
             }
             String configJson = live.getConfigJson();
             LiveWatchConfig config = JSON.parseObject(configJson, LiveWatchConfig.class);
-            if (config == null || !Boolean.TRUE.equals(config.getEnabled())
-                    || config.getParticipateCondition() == null) {
+            if (config == null || !Boolean.TRUE.equals(config.getEnabled())) {
                 log.info("{} autoUpdateWatchReward 配置未启用或缺失: liveId={}", LOG_PREFIX, live.getLiveId());
                 continue;
             }
-            // 只处理「达到指定观看时长」;完课积分/优惠券由 LiveCompletionPointsTask 负责
-            if (WATCH_REWARD_CONDITION_DURATION != config.getParticipateCondition()) {
-                log.info("{} autoUpdateWatchReward 参与条件非观看时长: liveId={}, condition={}",
-                        LOG_PREFIX, live.getLiveId(), config.getParticipateCondition());
+            // 观看时长奖励:按 watchDuration + actions 判定(可与完课配置并存)
+            if (!LiveCompletionConfigUtils.isWatchDurationRewardMode(configJson)) {
+                log.info("{} autoUpdateWatchReward 未开启观看时长奖励: liveId={}", LOG_PREFIX, live.getLiveId());
                 continue;
             }
             List<Long> actions = config.resolveActions();
@@ -590,8 +589,7 @@ public class Task {
             }
             LiveWatchConfig config = JSON.parseObject(live.getConfigJson(), LiveWatchConfig.class);
             if (config == null || !Boolean.TRUE.equals(config.getEnabled())
-                    || config.getParticipateCondition() == null
-                    || WATCH_REWARD_CONDITION_DURATION != config.getParticipateCondition()
+                    || !LiveCompletionConfigUtils.isWatchDurationRewardMode(live.getConfigJson())
                     || config.resolveActions().isEmpty()
                     || config.getWatchDuration() == null || config.getWatchDuration() <= 0) {
                 return;
@@ -721,7 +719,13 @@ public class Task {
                     LiveConsoleOpLog.OP_WATCH_REWARD_COUPON,
                     LiveConsoleOpLog.HANDLE_AUTO,
                     actionCouponId,
-                    resolveWatchRewardCouponBizName(watchRewardCoupon, actionCouponId, couponRelations.size())
+                    resolveWatchRewardCouponBizName(watchRewardCoupon, actionCouponId,
+                            (int) couponRelations.stream()
+                                    .map(LiveConsoleOpLogUser::getUserId)
+                                    .filter(java.util.Objects::nonNull)
+                                    .distinct()
+                                    .count(),
+                            actionCouponCount)
             );
             liveConsoleOpLogService.bindOpLogUsers(watchCouponOpLog.getId(), liveId, couponRelations);
             Set<Long> notifiedForCoupon = new HashSet<>();
@@ -775,37 +779,51 @@ public class Task {
 
             for (Long userId : userIds) {
                 try {
+                    // 每人按配置张数预扣总库存,不足则跳过该用户
+                    Long remain = liveCouponIssueService.tryDeductStock(couponIssue, grantCount);
+                    if (remain == null) {
+                        log.warn("观看奖励优惠券库存不足,停止后续发放: liveId={}, couponId={}, grantCount={}, 已成功={}人",
+                                live.getLiveId(), couponId, grantCount, relations.stream()
+                                        .map(LiveConsoleOpLogUser::getUserId).filter(java.util.Objects::nonNull).distinct().count());
+                        break;
+                    }
+
                     boolean userGranted = false;
-                    for (int i = 0; i < grantCount; i++) {
-                        // 创建用户优惠券记录
-                        LiveCouponUser couponUser = new LiveCouponUser();
-                        couponUser.setCouponId(couponId);
-                        couponUser.setUserId(userId.intValue());
-                        couponUser.setCouponTitle(coupon.getTitle());
-                        couponUser.setCouponPrice(coupon.getCouponPrice());
-                        couponUser.setUseMinPrice(coupon.getUseMinPrice());
-
-                        // 计算优惠券过期时间
-                        if (couponIssue.getLimitTime() != null) {
-                            couponUser.setLimitTime(couponIssue.getLimitTime());
-                        } else if (coupon.getCouponTime() != null) {
-                            // 如果没有设置领取结束时间,使用优惠券有效期限计算
-                            java.util.Calendar cal = java.util.Calendar.getInstance();
-                            cal.setTime(now);
-                            cal.add(java.util.Calendar.DATE, coupon.getCouponTime().intValue());
-                            couponUser.setLimitTime(cal.getTime());
-                        }
+                    try {
+                        for (int i = 0; i < grantCount; i++) {
+                            // 创建用户优惠券记录
+                            LiveCouponUser couponUser = new LiveCouponUser();
+                            couponUser.setCouponId(couponId);
+                            couponUser.setUserId(userId.intValue());
+                            couponUser.setCouponTitle(coupon.getTitle());
+                            couponUser.setCouponPrice(coupon.getCouponPrice());
+                            couponUser.setUseMinPrice(coupon.getUseMinPrice());
+
+                            // 计算优惠券过期时间
+                            if (couponIssue.getLimitTime() != null) {
+                                couponUser.setLimitTime(couponIssue.getLimitTime());
+                            } else if (coupon.getCouponTime() != null) {
+                                // 如果没有设置领取结束时间,使用优惠券有效期限计算
+                                java.util.Calendar cal = java.util.Calendar.getInstance();
+                                cal.setTime(now);
+                                cal.add(java.util.Calendar.DATE, coupon.getCouponTime().intValue());
+                                couponUser.setLimitTime(cal.getTime());
+                            }
 
-                        couponUser.setType("3"); // 获取方式:3-观看奖励
-                        couponUser.setStatus(0); // 状态:0-未核销
-                        couponUser.setIsFail(0); // 是否有效:0-有效
-                        couponUser.setIsDel(0);
-                        couponUser.setCreateTime(now);
+                            couponUser.setType("3"); // 获取方式:3-观看奖励
+                            couponUser.setStatus(0); // 状态:0-未核销
+                            couponUser.setIsFail(0); // 是否有效:0-有效
+                            couponUser.setIsDel(0);
+                            couponUser.setCreateTime(now);
 
-                        liveCouponUserService.insertLiveCouponUser(couponUser);
+                            liveCouponUserService.insertLiveCouponUser(couponUser);
 
-                        relations.add(new LiveConsoleOpLogUser(null, userId, live.getLiveId(), couponUser.getId()));
-                        userGranted = true;
+                            relations.add(new LiveConsoleOpLogUser(null, userId, live.getLiveId(), couponUser.getId()));
+                            userGranted = true;
+                        }
+                    } catch (Exception insertEx) {
+                        liveCouponIssueService.restoreStock(couponIssue, grantCount);
+                        throw insertEx;
                     }
 
                     if (userGranted) {
@@ -822,8 +840,8 @@ public class Task {
                 }
             }
 
-            log.info("直播间观看奖励-优惠券发放完成,liveId={}, couponId={}, 每人张数={}, 成功发放 {} 条记录",
-                    live.getLiveId(), couponId, grantCount, relations.size());
+            log.info("直播间观看奖励-优惠券发放完成,liveId={}, couponId={}, 每人张数={}, 成功发放 {} 条记录, remainCount={}",
+                    live.getLiveId(), couponId, grantCount, relations.size(), couponIssue.getRemainCount());
 
             return relations;
 
@@ -910,7 +928,7 @@ public class Task {
                     LiveConsoleOpLog.OP_WATCH_REWARD_COUPON,
                     LiveConsoleOpLog.HANDLE_AUTO,
                     targetCouponId,
-                    resolveWatchRewardCouponBizName(watchRewardCoupon, targetCouponId, couponRelations.size())
+                    resolveWatchRewardCouponBizName(watchRewardCoupon, targetCouponId, 1, actionCouponCount)
             );
             liveConsoleOpLogService.bindOpLogUsers(watchCouponOpLog.getId(), liveId, couponRelations);
             sendCouponRewardMessage(liveId, userId, watchRewardCoupon, watchCouponOpLog);
@@ -1711,7 +1729,7 @@ public class Task {
         return "观看奖励积分" + amountText + "分,发放" + userCount + "人";
     }
 
-    private String resolveWatchRewardCouponBizName(LiveCoupon coupon, Long couponId, int userCount) {
+    private String resolveWatchRewardCouponBizName(LiveCoupon coupon, Long couponId, int userCount, int perUserSheets) {
         String title = null;
         if (coupon != null && StringUtils.isNotEmpty(coupon.getTitle())) {
             title = coupon.getTitle();
@@ -1724,6 +1742,10 @@ public class Task {
         if (StringUtils.isEmpty(title)) {
             title = couponId != null ? "观看奖励优惠券 #" + couponId : "观看奖励优惠券";
         }
+        int sheets = perUserSheets > 0 ? perUserSheets : 1;
+        if (sheets > 1) {
+            title = title + "×" + sheets;
+        }
         return title + ",发放" + userCount + "人";
     }
 

+ 10 - 15
fs-service/src/main/java/com/fs/live/mapper/LiveMapper.java

@@ -132,19 +132,19 @@ public interface LiveMapper
             "and JSON_EXTRACT(config_json, '$.enabled') = true " +
             "and JSON_EXTRACT(config_json, '$.pointsConfig') is not null " +
             "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\"')" +
+            "  JSON_CONTAINS(JSON_EXTRACT(config_json, '$.completionTypes'), '2') " +
+            "  or JSON_CONTAINS(JSON_EXTRACT(config_json, '$.completionTypes'), '\"2\"') " +
+            "  or (" +
+            "    JSON_EXTRACT(config_json, '$.completionTypes') is null " +
+            "    and CAST(JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.participateCondition')) AS UNSIGNED) = 2" +
+            "  )" +
             ")")
     List<Live> selectLiveListWithCompletionPointsEnabled();
 
     /**
      * 查询开启了完课优惠券配置的直播间(用于完课优惠券定时任务)
-     * 新:participateCondition=2 且 completionTypes 含 3
-     * 旧:participateCondition=3
-     * @return 直播列表
+     * 新:completionTypes 含 3,或 finishCoupons 非空;兼容旧 participateCondition=3 / finishCouponId
      */
     @Select("select * from live where status != 3 and live_type in (2,3) and is_audit = 1 " +
             "and config_json is not null " +
@@ -156,14 +156,9 @@ public interface LiveMapper
             "      and JSON_LENGTH(JSON_EXTRACT(config_json, '$.finishCoupons')) > 0)" +
             ") " +
             "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\"')" +
-            "    )" +
-            "  )" +
+            "  JSON_CONTAINS(JSON_EXTRACT(config_json, '$.completionTypes'), '3') " +
+            "  or JSON_CONTAINS(JSON_EXTRACT(config_json, '$.completionTypes'), '\"3\"') " +
+            "  or CAST(JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.participateCondition')) AS UNSIGNED) = 3" +
             ")")
     List<Live> selectLiveListWithCompletionCouponEnabled();
 

+ 3 - 2
fs-service/src/main/java/com/fs/live/service/ILiveCompletionCouponService.java

@@ -1,10 +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.LiveCompletionCouponClaimResult;
 import com.fs.live.vo.LiveCompletionCouponConfigVO;
 import com.fs.live.vo.LiveCompletionCouponNotifyResult;
 import com.fs.live.vo.LiveCompletionCouponStatusVO;
@@ -38,6 +38,7 @@ public interface ILiveCompletionCouponService {
 
     /**
      * 领取完课福利券(需先答题全部正确,点击领取后发券并写入留存关联)
+     * <p>支持 finishCoupons 多选及每券张数;返回全部发放明细</p>
      */
-    LiveCouponUser claimCompletionCoupon(LiveCompletionCouponClaimParam param, Long userId);
+    LiveCompletionCouponClaimResult claimCompletionCoupon(LiveCompletionCouponClaimParam param, Long userId);
 }

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

@@ -67,4 +67,24 @@ public interface ILiveCouponIssueService
     LiveCouponIssue selectLiveCouponIssueByCouponId(Long id);
 
     LiveCouponIssue selectIssueByLiveIdAndCouponId(Long liveId, Long couponId);
+
+    /**
+     * 预检库存是否足够(不扣减)。无限量返回 true。
+     */
+    boolean hasEnoughStock(LiveCouponIssue issue, int grantCount);
+
+    /**
+     * 预扣直播间优惠券库存(Redis + remainCount),支持一次扣多张。
+     * isPermanent=1 视为无限量,不扣减。
+     *
+     * @param issue      优惠券领取配置
+     * @param grantCount 扣减张数(>0)
+     * @return 扣减后剩余库存;库存不足或参数非法返回 null;无限量返回 -1
+     */
+    Long tryDeductStock(LiveCouponIssue issue, int grantCount);
+
+    /**
+     * 发放失败时回补库存(与 {@link #tryDeductStock} 成对使用)
+     */
+    void restoreStock(LiveCouponIssue issue, int grantCount);
 }

+ 108 - 17
fs-service/src/main/java/com/fs/live/service/impl/LiveCompletionCouponServiceImpl.java

@@ -14,6 +14,8 @@ 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.LiveCompletionCouponClaimItemVO;
+import com.fs.live.vo.LiveCompletionCouponClaimResult;
 import com.fs.live.vo.LiveCompletionCouponConfigVO;
 import com.fs.live.vo.LiveCompletionCouponInfoVO;
 import com.fs.live.vo.LiveCompletionCouponNotifyResult;
@@ -120,6 +122,11 @@ public class LiveCompletionCouponServiceImpl implements ILiveCompletionCouponSer
             List<LiveCompletionCouponInfoVO> couponInfos = loadCouponInfos(couponItems);
             result.setCoupons(couponInfos);
             result.setCoupon(couponInfos.isEmpty() ? null : couponInfos.get(0));
+            if (couponInfos.isEmpty()) {
+                log.info("[完课优惠券] 配置券均无效或已删除, 跳过, liveId={}, userId={}, configured={}",
+                        liveId, userId, couponItems != null ? couponItems.size() : 0);
+                return result;
+            }
 
             if (!isWatchRateEligible(liveId, userId, watchDuration, config)) {
                 log.info("[完课优惠券] 观看比例未达标(已在上层方法记录详情), liveId={}, userId={}", liveId, userId);
@@ -146,8 +153,10 @@ public class LiveCompletionCouponServiceImpl implements ILiveCompletionCouponSer
 
             result.setShouldNotify(true);
             result.setQuestions(questions);
-            log.info("[完课优惠券] 满足推送条件, liveId={}, userId={}, watchDuration={}, completionRate={}%",
-                    liveId, userId, watchDuration, config.getCompletionRate());
+            log.info("[完课优惠券] 满足推送条件, liveId={}, userId={}, watchDuration={}, completionRate={}%, couponKinds={}, totalSheets={}",
+                    liveId, userId, watchDuration, config.getCompletionRate(),
+                    couponInfos.size(),
+                    couponInfos.stream().mapToInt(c -> c.getCount() != null && c.getCount() > 0 ? c.getCount() : 1).sum());
         } catch (Exception e) {
             log.error("预检查完课优惠券弹窗失败, liveId={}, userId={}", liveId, userId, e);
         }
@@ -251,7 +260,7 @@ public class LiveCompletionCouponServiceImpl implements ILiveCompletionCouponSer
 
     @Override
     @Transactional(rollbackFor = Exception.class)
-    public LiveCouponUser claimCompletionCoupon(LiveCompletionCouponClaimParam param, Long userId) {
+    public LiveCompletionCouponClaimResult claimCompletionCoupon(LiveCompletionCouponClaimParam param, Long userId) {
         if (param == null || param.getLiveId() == null) {
             throw new BaseException("参数错误");
         }
@@ -277,23 +286,93 @@ public class LiveCompletionCouponServiceImpl implements ILiveCompletionCouponSer
         if (live == null) {
             throw new BaseException("直播不存在");
         }
+
+        List<LiveRewardCouponItem> couponItems = config.resolveCouponItems();
+        // 发券前统一预检:配置可用 + 总库存足够,避免多券场景扣到一半再回滚
+        assertCouponsReadyToClaim(liveId, couponItems);
+
         List<LiveCouponUser> allCouponUsers = new ArrayList<>();
-        for (LiveRewardCouponItem item : config.resolveCouponItems()) {
+        List<LiveCompletionCouponClaimItemVO> claimItems = new ArrayList<>();
+        // 记录已扣库存,任一券失败时回补,避免事务回滚后 Redis 库存丢失
+        List<AbstractMap.SimpleEntry<LiveCouponIssue, Integer>> deductedStocks = new ArrayList<>();
+        try {
+            for (LiveRewardCouponItem item : couponItems) {
+                if (item == null || item.getCouponId() == null) {
+                    continue;
+                }
+                List<LiveCouponUser> issued = issueCoupon(live, userId, item.getCouponId(),
+                        item.resolveCount(), deductedStocks);
+                allCouponUsers.addAll(issued);
+                claimItems.add(buildClaimItem(issued));
+            }
+            if (allCouponUsers.isEmpty()) {
+                throw new BaseException("优惠券发放失败");
+            }
+            List<LiveConsoleOpLogUser> relations = new ArrayList<>();
+            for (LiveCouponUser couponUser : allCouponUsers) {
+                relations.add(new LiveConsoleOpLogUser(null, userId, liveId, couponUser.getId()));
+            }
+            liveConsoleOpLogService.bindOpLogUsers(opLog.getId(), liveId, relations);
+
+            LiveCompletionCouponClaimResult result = new LiveCompletionCouponClaimResult();
+            result.setOpLogId(opLog.getId());
+            result.setCouponUser(allCouponUsers.get(0));
+            result.setCouponUserId(allCouponUsers.get(0).getId());
+            result.setCouponUserIds(allCouponUsers.stream()
+                    .map(LiveCouponUser::getId)
+                    .collect(Collectors.toList()));
+            result.setCouponCount(allCouponUsers.size());
+            result.setCoupons(claimItems);
+            return result;
+        } catch (RuntimeException e) {
+            for (AbstractMap.SimpleEntry<LiveCouponIssue, Integer> entry : deductedStocks) {
+                liveCouponIssueService.restoreStock(entry.getKey(), entry.getValue());
+            }
+            throw e;
+        }
+    }
+
+    /**
+     * 预检每张配置券:关联可用且总库存满足本次领取张数
+     */
+    private void assertCouponsReadyToClaim(Long liveId, List<LiveRewardCouponItem> couponItems) {
+        for (LiveRewardCouponItem item : couponItems) {
             if (item == null || item.getCouponId() == null) {
                 continue;
             }
-            List<LiveCouponUser> issued = issueCoupon(live, userId, item.getCouponId(), item.resolveCount());
-            allCouponUsers.addAll(issued);
-        }
-        if (allCouponUsers.isEmpty()) {
-            throw new BaseException("优惠券发放失败");
+            int grantCount = item.resolveCount();
+            LiveCoupon coupon = liveCouponService.selectLiveCouponById(item.getCouponId());
+            String title = coupon != null && StringUtils.isNotEmpty(coupon.getTitle())
+                    ? coupon.getTitle()
+                    : ("优惠券#" + item.getCouponId());
+
+            LiveCouponIssue couponIssue = liveCouponIssueService.selectIssueByLiveIdAndCouponId(liveId, item.getCouponId());
+            if (couponIssue == null || couponIssue.getStatus() == null || couponIssue.getStatus() != 1) {
+                throw new BaseException("「" + title + "」领取配置不可用或已领完");
+            }
+            if (!liveCouponIssueService.hasEnoughStock(couponIssue, grantCount)) {
+                throw new BaseException(grantCount > 1
+                        ? "「" + title + "」库存不足,需领取" + grantCount + "张"
+                        : "「" + title + "」已领完");
+            }
         }
-        List<LiveConsoleOpLogUser> relations = new ArrayList<>();
-        for (LiveCouponUser couponUser : allCouponUsers) {
-            relations.add(new LiveConsoleOpLogUser(null, userId, liveId, couponUser.getId()));
+    }
+
+    private LiveCompletionCouponClaimItemVO buildClaimItem(List<LiveCouponUser> issued) {
+        LiveCompletionCouponClaimItemVO vo = new LiveCompletionCouponClaimItemVO();
+        if (issued == null || issued.isEmpty()) {
+            vo.setCount(0);
+            vo.setCouponUserIds(Collections.emptyList());
+            return vo;
         }
-        liveConsoleOpLogService.bindOpLogUsers(opLog.getId(), liveId, relations);
-        return allCouponUsers.get(0);
+        LiveCouponUser first = issued.get(0);
+        vo.setCouponId(first.getCouponId());
+        vo.setTitle(first.getCouponTitle());
+        vo.setCouponPrice(first.getCouponPrice());
+        vo.setUseMinPrice(first.getUseMinPrice());
+        vo.setCount(issued.size());
+        vo.setCouponUserIds(issued.stream().map(LiveCouponUser::getId).collect(Collectors.toList()));
+        return vo;
     }
 
     /**
@@ -586,7 +665,8 @@ public class LiveCompletionCouponServiceImpl implements ILiveCompletionCouponSer
         return getCompletionCouponConfig(live);
     }
 
-    private List<LiveCouponUser> issueCoupon(Live live, Long userId, Long couponId, Integer couponCount) {
+    private List<LiveCouponUser> issueCoupon(Live live, Long userId, Long couponId, Integer couponCount,
+                                             List<AbstractMap.SimpleEntry<LiveCouponIssue, Integer>> deductedStocks) {
         LiveCoupon coupon = liveCouponService.selectLiveCouponById(couponId);
         if (coupon == null) {
             throw new BaseException("优惠券不存在");
@@ -598,6 +678,17 @@ public class LiveCompletionCouponServiceImpl implements ILiveCompletionCouponSer
         }
 
         int grantCount = couponCount != null && couponCount > 0 ? couponCount : 1;
+        Long remain = liveCouponIssueService.tryDeductStock(couponIssue, grantCount);
+        if (remain == null) {
+            String title = StringUtils.isNotEmpty(coupon.getTitle()) ? coupon.getTitle() : ("优惠券#" + couponId);
+            throw new BaseException(grantCount > 1
+                    ? "「" + title + "」库存不足,需领取" + grantCount + "张"
+                    : "「" + title + "」已领完");
+        }
+        if (deductedStocks != null && (couponIssue.getIsPermanent() == null || couponIssue.getIsPermanent() != 1)) {
+            deductedStocks.add(new AbstractMap.SimpleEntry<>(couponIssue, grantCount));
+        }
+
         Date now = new Date();
         List<LiveCouponUser> couponUsers = new ArrayList<>();
         for (int i = 0; i < grantCount; i++) {
@@ -638,8 +729,8 @@ public class LiveCompletionCouponServiceImpl implements ILiveCompletionCouponSer
         record.setCreateBy(String.valueOf(userId));
         liveRewardRecordService.insertLiveRewardRecord(record);
 
-        log.info("完课优惠券发放成功, liveId={}, userId={}, couponId={}, count={}",
-                live.getLiveId(), userId, couponId, grantCount);
+        log.info("完课优惠券发放成功, liveId={}, userId={}, couponId={}, count={}, remain={}",
+                live.getLiveId(), userId, couponId, grantCount, remain);
         return couponUsers;
     }
 

+ 13 - 1
fs-service/src/main/java/com/fs/live/service/impl/LiveConsoleOpLogServiceImpl.java

@@ -547,7 +547,19 @@ public class LiveConsoleOpLogServiceImpl implements ILiveConsoleOpLogService {
             claimedOpLogIds.add(latest.getId());
             if (couponCountByOpLogId != null && claimedCouponCountMap != null) {
                 Integer count = claimedCouponCountMap.get(couponId);
-                if (count != null && count > 0) {
+                // 完课优惠券一条留存可对应多张不同券:兜底时汇总本次 type 下已领总张数
+                if (latest.getOpType() != null
+                        && latest.getOpType() == LiveConsoleOpLog.OP_COMPLETION_COUPON) {
+                    int total = claimedCouponCountMap.values().stream()
+                            .filter(Objects::nonNull)
+                            .mapToInt(Integer::intValue)
+                            .sum();
+                    if (total > 0) {
+                        couponCountByOpLogId.put(latest.getId(), total);
+                    } else if (count != null && count > 0) {
+                        couponCountByOpLogId.put(latest.getId(), count);
+                    }
+                } else if (count != null && count > 0) {
                     couponCountByOpLogId.put(latest.getId(), count);
                 }
             }

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

@@ -2,12 +2,17 @@ package com.fs.live.service.impl;
 
 import java.util.Collections;
 import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import com.fs.common.constant.LiveKeysConstant;
+import com.fs.common.core.redis.RedisCache;
 import com.fs.common.utils.DateUtils;
 import com.fs.live.domain.LiveCouponIssueRelation;
 import com.fs.live.mapper.LiveCouponMapper;
 import com.fs.live.param.CouponPO;
 import com.fs.live.service.ILiveAutoTaskService;
 import com.fs.live.vo.LiveCouponListVo;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import com.fs.live.mapper.LiveCouponIssueMapper;
@@ -20,6 +25,7 @@ import com.fs.live.service.ILiveCouponIssueService;
  * @author fs
  * @date 2025-09-30
  */
+@Slf4j
 @Service
 public class LiveCouponIssueServiceImpl implements ILiveCouponIssueService
 {
@@ -29,6 +35,8 @@ public class LiveCouponIssueServiceImpl implements ILiveCouponIssueService
     private LiveCouponMapper liveCouponMapper;
     @Autowired
     private ILiveAutoTaskService liveAutoTaskService;
+    @Autowired
+    private RedisCache redisCache;
 
     /**
      * 查询优惠券领取
@@ -167,4 +175,107 @@ public class LiveCouponIssueServiceImpl implements ILiveCouponIssueService
     public LiveCouponIssue selectIssueByLiveIdAndCouponId(Long liveId, Long couponId) {
         return liveCouponIssueMapper.selectIssueByLiveIdAndCouponId(liveId, couponId);
     }
+
+    @Override
+    public boolean hasEnoughStock(LiveCouponIssue issue, int grantCount) {
+        if (issue == null || issue.getId() == null || grantCount <= 0) {
+            return false;
+        }
+        if (issue.getIsPermanent() != null && issue.getIsPermanent() == 1) {
+            return true;
+        }
+        String stockKey = String.format(LiveKeysConstant.LIVE_COUPON_NUM, issue.getId());
+        ensureStockCache(issue, stockKey);
+        long remain = readStockValue(redisCache.getCacheObject(stockKey));
+        return remain >= grantCount;
+    }
+
+    @Override
+    public Long tryDeductStock(LiveCouponIssue issue, int grantCount) {
+        if (issue == null || issue.getId() == null || grantCount <= 0) {
+            return null;
+        }
+        // 无限量不扣库存
+        if (issue.getIsPermanent() != null && issue.getIsPermanent() == 1) {
+            return -1L;
+        }
+
+        String stockKey = String.format(LiveKeysConstant.LIVE_COUPON_NUM, issue.getId());
+        ensureStockCache(issue, stockKey);
+
+        Long decrement = redisCache.decr(stockKey, (long) grantCount);
+        if (decrement == null || decrement < 0L) {
+            if (decrement != null) {
+                redisCache.incr(stockKey, (long) grantCount);
+                if (decrement + grantCount <= 0L) {
+                    markSoldOut(issue);
+                }
+            }
+            log.warn("优惠券库存不足, issueId={}, couponId={}, grantCount={}, remainAfter={}",
+                    issue.getId(), issue.getCouponId(), grantCount, decrement);
+            return null;
+        }
+
+        issue.setRemainCount(decrement);
+        if (decrement == 0L) {
+            issue.setStatus(-1);
+        }
+        updateLiveCouponIssue(issue);
+        return decrement;
+    }
+
+    @Override
+    public void restoreStock(LiveCouponIssue issue, int grantCount) {
+        if (issue == null || issue.getId() == null || grantCount <= 0) {
+            return;
+        }
+        if (issue.getIsPermanent() != null && issue.getIsPermanent() == 1) {
+            return;
+        }
+        String stockKey = String.format(LiveKeysConstant.LIVE_COUPON_NUM, issue.getId());
+        ensureStockCache(issue, stockKey);
+        Long after = redisCache.incr(stockKey, (long) grantCount);
+        if (after == null) {
+            return;
+        }
+        issue.setRemainCount(after);
+        if (issue.getStatus() != null && issue.getStatus() == -1 && after > 0L) {
+            issue.setStatus(1);
+        }
+        updateLiveCouponIssue(issue);
+        log.info("回补优惠券库存, issueId={}, couponId={}, grantCount={}, remainAfter={}",
+                issue.getId(), issue.getCouponId(), grantCount, after);
+    }
+
+    private void ensureStockCache(LiveCouponIssue issue, String stockKey) {
+        Object cached = redisCache.getCacheObject(stockKey);
+        if (cached != null) {
+            return;
+        }
+        long init = issue.getRemainCount() != null ? issue.getRemainCount() : 0L;
+        if (init < 0L) {
+            init = 0L;
+        }
+        redisCache.setCacheObject(stockKey, (int) init, 30, TimeUnit.MINUTES);
+    }
+
+    private long readStockValue(Object cached) {
+        if (cached == null) {
+            return 0L;
+        }
+        if (cached instanceof Number) {
+            return ((Number) cached).longValue();
+        }
+        try {
+            return Long.parseLong(String.valueOf(cached).trim());
+        } catch (Exception e) {
+            return 0L;
+        }
+    }
+
+    private void markSoldOut(LiveCouponIssue issue) {
+        issue.setStatus(-1);
+        issue.setRemainCount(0L);
+        updateLiveCouponIssue(issue);
+    }
 }

+ 30 - 11
fs-service/src/main/java/com/fs/live/utils/LiveCompletionConfigUtils.java

@@ -60,10 +60,21 @@ public final class LiveCompletionConfigUtils {
                 || participateCondition == PARTICIPATE_CONDITION_COMPLETION_COUPON);
     }
 
+    /**
+     * 解析完课奖励类型多选:2=完课积分,3=完课优惠券
+     * <p>
+     * 优先读 completionTypes(观看时长/完课分导航后可与观看时长并存)。
+     * 无 types 时再兼容旧 participateCondition=2/3。
+     * </p>
+     */
     public static List<Long> resolveCompletionTypes(JSONObject json) {
         if (json == null) {
             return Collections.emptyList();
         }
+        List<Long> types = parseLongList(json.get("completionTypes"));
+        if (!types.isEmpty()) {
+            return types;
+        }
         Long participateCondition = json.getLong("participateCondition");
         if (participateCondition == null) {
             return Collections.emptyList();
@@ -71,14 +82,11 @@ public final class LiveCompletionConfigUtils {
         if (participateCondition == PARTICIPATE_CONDITION_COMPLETION_COUPON) {
             return Collections.singletonList(TYPE_COUPON);
         }
-        if (participateCondition != PARTICIPATE_CONDITION_COMPLETION) {
-            return Collections.emptyList();
+        if (participateCondition == PARTICIPATE_CONDITION_COMPLETION) {
+            // 旧数据:condition=2 仅表示完课积分
+            return Collections.singletonList(TYPE_POINTS);
         }
-        List<Long> types = parseLongList(json.get("completionTypes"));
-        if (!types.isEmpty()) {
-            return types;
-        }
-        return Collections.singletonList(TYPE_POINTS);
+        return Collections.emptyList();
     }
 
     public static boolean hasCompletionType(JSONObject json, long type) {
@@ -136,36 +144,47 @@ public final class LiveCompletionConfigUtils {
         return resolveFinishCoupons(parseConfig(configJson));
     }
 
+    /**
+     * 完课优惠券模式:enabled + 类型含优惠券 + 至少一张完课券
+     * (不再强制 participateCondition,以支持与观看时长分导航并存)
+     */
     public static boolean isCompletionCouponMode(String configJson) {
         JSONObject json = parseConfig(configJson);
         if (json == null || !json.getBooleanValue("enabled")) {
             return false;
         }
-        if (!isCompletionParticipate(json) || !hasCompletionType(json, TYPE_COUPON)) {
+        if (!hasCompletionType(json, TYPE_COUPON)) {
             return false;
         }
         return !resolveFinishCoupons(json).isEmpty();
     }
 
+    /**
+     * 完课积分模式:enabled + 类型含积分 + 配置了 pointsConfig
+     */
     public static boolean isCompletionPointsMode(String configJson) {
         JSONObject json = parseConfig(configJson);
         if (json == null || !json.getBooleanValue("enabled")) {
             return false;
         }
-        if (!isCompletionParticipate(json) || !hasCompletionType(json, TYPE_POINTS)) {
+        if (!hasCompletionType(json, TYPE_POINTS)) {
             return false;
         }
         List<?> pointsConfig = json.getObject("pointsConfig", List.class);
         return pointsConfig != null && !pointsConfig.isEmpty();
     }
 
+    /**
+     * 观看时长奖励是否开启:enabled + 有效观看时长 + 至少一项实施动作
+     * (不再强制 participateCondition=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) {
+        Long watchDuration = json.getLong("watchDuration");
+        if (watchDuration == null || watchDuration <= 0) {
             return false;
         }
         return !resolveWatchActions(json).isEmpty();

+ 27 - 0
fs-service/src/main/java/com/fs/live/vo/LiveCompletionCouponClaimItemVO.java

@@ -0,0 +1,27 @@
+package com.fs.live.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 完课领取结果中的单券汇总
+ */
+@Data
+public class LiveCompletionCouponClaimItemVO {
+
+    private Long couponId;
+
+    private String title;
+
+    private BigDecimal couponPrice;
+
+    private BigDecimal useMinPrice;
+
+    /** 本券发放张数 */
+    private Integer count;
+
+    /** 本券对应的用户券 ID 列表 */
+    private List<Long> couponUserIds;
+}

+ 31 - 0
fs-service/src/main/java/com/fs/live/vo/LiveCompletionCouponClaimResult.java

@@ -0,0 +1,31 @@
+package com.fs.live.vo;
+
+import com.fs.live.domain.LiveCouponUser;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 完课优惠券领取结果(支持多选券 + 每券多张)
+ */
+@Data
+public class LiveCompletionCouponClaimResult {
+
+    /** 完课优惠券留存 ID */
+    private Long opLogId;
+
+    /** 兼容旧客户端:首张用户券 */
+    private LiveCouponUser couponUser;
+
+    /** 兼容旧客户端:首张用户券 ID */
+    private Long couponUserId;
+
+    /** 本次发放的全部用户券 ID(按发放顺序,含同券多张) */
+    private List<Long> couponUserIds;
+
+    /** 本次发放总张数 */
+    private Integer couponCount;
+
+    /** 按券种汇总 */
+    private List<LiveCompletionCouponClaimItemVO> coupons;
+}

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

@@ -8,6 +8,7 @@ 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.LiveCompletionCouponClaimResult;
 import com.fs.live.vo.LiveCompletionCouponStatusVO;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.*;
@@ -54,13 +55,23 @@ public class LiveCompletionCouponController extends AppBaseController {
      * <p>
      * 需先答题全部正确。完课类型多选时留存有两条,请传完课优惠券弹窗的 opLogId(opType=8),
      * 并建议回传 /answer 返回的 answerRecordId(recordId)。
+     * 按 finishCoupons 多选发放,每券按配置张数扣减总库存;data 含全部券明细。
      * </p>
      */
     @PostMapping("/claim")
     @RepeatSubmit
     public R claim(@RequestBody LiveCompletionCouponClaimParam param) {
         Long userId = Long.parseLong(getUserId());
+        LiveCompletionCouponClaimResult result = completionCouponService.claimCompletionCoupon(param, userId);
+        // data 返回完整领取结果;同时保留顶层字段兼容旧客户端(首张券 + 汇总)
         return R.ok("恭喜您,福利券已到账")
-                .put("data", completionCouponService.claimCompletionCoupon(param, userId));
+                .put("data", result)
+                .put("opLogId", result.getOpLogId())
+                .put("couponUserId", result.getCouponUserId())
+                .put("couponUserIds", result.getCouponUserIds())
+                .put("couponCount", result.getCouponCount())
+                .put("coupons", result.getCoupons())
+                // 兼容:旧逻辑 data 直接是 LiveCouponUser
+                .put("couponUser", result.getCouponUser());
     }
 }