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

Merge remote-tracking branch 'origin/master_exclusive_shop_20250718' into master_exclusive_shop_20250718

yuhongqi 2 недель назад
Родитель
Сommit
ff114a9798
17 измененных файлов с 1380 добавлено и 9 удалено
  1. 6 0
      fs-common/src/main/java/com/fs/common/vo/LiveVo.java
  2. 3 0
      fs-live-socket/src/main/java/com/fs/live/task/Task.java
  3. 59 0
      fs-service-system/src/main/java/com/fs/live/domain/LiveCompletionPointsRecord.java
  4. 59 0
      fs-service-system/src/main/java/com/fs/live/mapper/LiveCompletionPointsRecordMapper.java
  5. 56 0
      fs-service-system/src/main/java/com/fs/live/service/ILiveCompletionPointsRecordService.java
  6. 388 0
      fs-service-system/src/main/java/com/fs/live/service/impl/LiveCompletionPointsRecordServiceImpl.java
  7. 29 2
      fs-service-system/src/main/java/com/fs/live/service/impl/LiveServiceImpl.java
  8. 125 0
      fs-service-system/src/main/resources/mapper/live/LiveCompletionPointsRecordMapper.xml
  9. 1 1
      fs-service-system/src/main/resources/mapper/live/LiveGiftMapper.xml
  10. 2 1
      fs-user-app/src/main/java/com/fs/app/controller/LiveController.java
  11. 1 1
      fs-user-app/src/main/java/com/fs/app/controller/LiveGiftController.java
  12. 488 0
      fs-user-app/src/main/java/com/fs/app/controller/live/LiveCompletionPointsController.java
  13. 2 0
      fs-user-app/src/main/java/com/fs/app/facade/LiveFacadeService.java
  14. 84 4
      fs-user-app/src/main/java/com/fs/app/facade/impl/LiveFacadeServiceImpl.java
  15. 25 0
      fs-user-app/src/main/java/com/fs/app/vo/ReceivePointsVO.java
  16. 31 0
      fs-user-app/src/main/java/com/fs/app/vo/RemainingTimeVO.java
  17. 21 0
      fs-user-app/src/main/java/com/fs/app/vo/UpdateWatchDurationVO.java

+ 6 - 0
fs-common/src/main/java/com/fs/common/vo/LiveVo.java

@@ -54,4 +54,10 @@ public class LiveVo {
     private String previewUrl;
     private Integer previewVideoType;
     private Long previewVideoId;
+
+    /** 是否开启直播完课积分功能 */
+    private Boolean completionPointsEnabled;
+
+    /** 今天是否已领取完课奖励 */
+    private Boolean todayRewardReceived;
 }

+ 3 - 0
fs-live-socket/src/main/java/com/fs/live/task/Task.java

@@ -371,6 +371,8 @@ public class Task {
         }
     }
 
+    // 已关闭:autoUpdateWatchReward方法已禁用,改为使用LiveCompletionPointsController中的倒计时功能
+    /*
     @Scheduled(cron = "0 0/1 * * * ?")
     @DistributeLock(key = "autoUpdateWatchReward", scene = "task")
     @Transactional
@@ -415,6 +417,7 @@ public class Task {
             }
         }
     }
+    */
     private void saveUserRewardRecord(Live live, List<Long> userIds,Long scoreAmount) {
         for (Long userId : userIds) {
             LiveRewardRecord record = new LiveRewardRecord();

+ 59 - 0
fs-service-system/src/main/java/com/fs/live/domain/LiveCompletionPointsRecord.java

@@ -0,0 +1,59 @@
+package com.fs.live.domain;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+/**
+ * 直播完课积分领取记录对象 live_completion_points_record
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class LiveCompletionPointsRecord extends BaseEntity {
+    
+    private static final long serialVersionUID = 1L;
+
+    /** 主键ID */
+    private Long id;
+
+    /** 直播ID */
+    private Long liveId;
+
+    /** 用户ID */
+    private Long userId;
+
+    /** 观看时长(秒) */
+    private Long watchDuration;
+
+    /** 视频总时长(秒) */
+    private Long videoDuration;
+
+    /** 完课比例(%) */
+    private BigDecimal completionRate;
+
+    /** 连续完课天数 */
+    private Integer continuousDays;
+
+    /** 获得积分 */
+    private Integer pointsAwarded;
+
+    /** 上次完课日期 */
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    private Date lastCompletionDate;
+
+    /** 本次完课日期 */
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    private Date currentCompletionDate;
+
+    /** 领取状态 0-未领取 1-已领取 */
+    private Integer receiveStatus;
+
+    /** 领取时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date receiveTime;
+}
+

+ 59 - 0
fs-service-system/src/main/java/com/fs/live/mapper/LiveCompletionPointsRecordMapper.java

@@ -0,0 +1,59 @@
+package com.fs.live.mapper;
+
+import com.fs.live.domain.LiveCompletionPointsRecord;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 直播完课积分记录Mapper接口
+ */
+public interface LiveCompletionPointsRecordMapper {
+
+    /**
+     * 插入完课积分记录
+     */
+    int insertRecord(LiveCompletionPointsRecord record);
+
+    /**
+     * 更新完课积分记录
+     */
+    int updateRecord(LiveCompletionPointsRecord record);
+
+    /**
+     * 查询用户某天的完课记录
+     */
+    LiveCompletionPointsRecord selectByUserAndDate(@Param("liveId") Long liveId, 
+                                                     @Param("userId") Long userId, 
+                                                     @Param("currentDate") Date currentDate);
+
+    /**
+     * 查询用户最近一次完课记录(不限直播间,用于计算连续天数)
+     */
+    LiveCompletionPointsRecord selectLatestByUser(@Param("userId") Long userId);
+
+    /**
+     * 查询用户在某直播间最近一次完课记录(不限制日期)
+     */
+    LiveCompletionPointsRecord selectLatestByUserAndLiveId(@Param("liveId") Long liveId, 
+                                                            @Param("userId") Long userId);
+
+    /**
+     * 查询用户未领取的完课记录列表
+     */
+    List<LiveCompletionPointsRecord> selectUnreceivedByUser(@Param("liveId") Long liveId, 
+                                                             @Param("userId") Long userId);
+
+    /**
+     * 查询用户的完课积分领取记录列表
+     */
+    List<LiveCompletionPointsRecord> selectRecordsByUser(@Param("liveId") Long liveId, 
+                                                          @Param("userId") Long userId);
+
+    /**
+     * 根据ID查询
+     */
+    LiveCompletionPointsRecord selectById(@Param("id") Long id);
+}
+

+ 56 - 0
fs-service-system/src/main/java/com/fs/live/service/ILiveCompletionPointsRecordService.java

@@ -0,0 +1,56 @@
+package com.fs.live.service;
+
+import com.fs.live.domain.LiveCompletionPointsRecord;
+
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 直播完课积分记录Service接口
+ */
+public interface ILiveCompletionPointsRecordService {
+
+    /**
+     * 检查并创建完课记录(定时任务调用)
+     * @param liveId 直播ID
+     * @param userId 用户ID
+     * @param watchDuration 观看时长(秒)
+     */
+    void checkAndCreateCompletionRecord(Long liveId, Long userId, Long watchDuration);
+
+    /**
+     * 用户领取完课积分
+     * @param recordId 完课记录ID
+     * @param userId 用户ID
+     * @return 领取结果
+     */
+    LiveCompletionPointsRecord receiveCompletionPoints(Long recordId, Long userId);
+
+    /**
+     * 获取用户完课状态
+     * @param liveId 直播ID
+     * @param userId 用户ID
+     * @return 未领取的完课记录列表
+     */
+    List<LiveCompletionPointsRecord> getUserUnreceivedRecords(Long liveId, Long userId);
+
+    /**
+     * 查询用户积分领取记录
+     * @param liveId 直播ID
+     * @param userId 用户ID
+     * @return 完课记录列表
+     */
+    List<LiveCompletionPointsRecord> getUserRecords(Long liveId, Long userId);
+
+    /**
+     * 根据用户和日期查询完课记录
+     * @param liveId 直播ID
+     * @param userId 用户ID
+     * @param date 日期
+     * @return 完课记录
+     */
+    LiveCompletionPointsRecord selectByUserAndDate(Long liveId, Long userId, Date date);
+
+    LiveCompletionPointsRecord createCompletionRecord(Long liveId, Long userId);
+}
+

+ 388 - 0
fs-service-system/src/main/java/com/fs/live/service/impl/LiveCompletionPointsRecordServiceImpl.java

@@ -0,0 +1,388 @@
+package com.fs.live.service.impl;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.fs.common.exception.BaseException;
+import com.fs.live.domain.Live;
+import com.fs.live.domain.LiveCompletionPointsRecord;
+import com.fs.live.domain.LiveWatchConfig;
+import com.fs.live.enums.FsUserIntegralLogTypeEnum;
+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.store.domain.FsUser;
+import com.fs.store.mapper.FsUserMapper;
+import com.fs.store.service.IFsUserBillService;
+import com.fs.store.service.IFsUserService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 直播完课积分记录Service业务层处理
+ */
+@Slf4j
+@Service
+public class LiveCompletionPointsRecordServiceImpl implements ILiveCompletionPointsRecordService {
+
+    @Autowired
+    private LiveCompletionPointsRecordMapper recordMapper;
+
+    @Autowired
+    private ILiveService liveService;
+
+    @Autowired
+    private FsUserMapper fsUserMapper;
+
+    @Autowired
+    private IFsUserService fsUserService;
+
+    @Autowired
+    private IFsUserBillService fsUserBillService;
+
+    @Autowired
+    private ILiveWatchUserService liveWatchUserService;
+
+
+    /**
+     * 检查并创建完课记录(由定时任务调用)
+     * @param liveId 直播间ID
+     * @param userId 用户ID
+     * @param watchDuration 观看时长(可为null,为null时从数据库自动累计直播+回放时长)
+     */
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void checkAndCreateCompletionRecord(Long liveId, Long userId, Long watchDuration) {
+        try {
+            // 1. 获取直播信息和配置
+            Live live = liveService.selectLiveByLiveId(liveId);
+            if (live == null) {
+                return;
+            }
+
+            // 2. 从直播间配置获取看课时长配置
+            String configJson = live.getConfigJson();
+            LiveWatchConfig config = null;
+            Long requiredWatchDuration = null;
+            
+            if (configJson != null && !configJson.isEmpty()) {
+                try {
+                    config = JSON.parseObject(configJson, LiveWatchConfig.class);
+                    if (config != null && config.getWatchDuration() != null) {
+                        // watchDuration是分钟,转换为秒
+                        requiredWatchDuration = config.getWatchDuration() * 60;
+                    }
+                } catch (Exception e) {
+                    log.warn("解析直播间配置失败: {}", e.getMessage());
+                }
+            }
+
+            // 检查是否开启完课积分功能
+            if (config == null || !config.getEnabled()) {
+                return;
+            }
+
+            // 3. 获取观看时长(如果为null,则从数据库累计直播+回放时长)
+            Long actualWatchDuration = watchDuration == null ? 0L : watchDuration * 60;
+
+
+
+            // 4. 判断是否达到完课标准(使用看课时长)
+            if (requiredWatchDuration != null && actualWatchDuration < requiredWatchDuration) {
+                return;
+            }
+
+            // 5. 获取视频总时长(秒)
+            Long videoDuration = live.getDuration();
+            if (videoDuration == null || videoDuration <= 0) {
+                return;
+            }
+
+            // 6. 计算完课比例
+            BigDecimal watchRate = BigDecimal.valueOf(actualWatchDuration)
+                    .multiply(BigDecimal.valueOf(100))
+                    .divide(BigDecimal.valueOf(videoDuration), 2, RoundingMode.HALF_UP);
+            
+            // 限制完课比例最大值为100.00%(防止数据库字段溢出)
+            if (watchRate.compareTo(BigDecimal.valueOf(100)) > 0) {
+                watchRate = BigDecimal.valueOf(100);
+            }
+
+            // 7. 检查今天是否已有完课记录
+            LocalDate today = LocalDate.now();
+            Date currentDate = Date.from(today.atStartOfDay(ZoneId.systemDefault()).toInstant());
+
+            LiveCompletionPointsRecord todayRecord = recordMapper.selectByUserAndDate(liveId, userId, currentDate);
+            if (todayRecord != null) {
+                return;
+            }
+
+            // 8. 查询最近一次完课记录(不限直播间),计算连续天数
+            LiveCompletionPointsRecord latestRecord = recordMapper.selectLatestByUser(userId);
+            int continuousDays = 1;
+
+            if (latestRecord != null) {
+                LocalDate lastDate = latestRecord.getCurrentCompletionDate()
+                        .toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
+
+                long daysBetween = ChronoUnit.DAYS.between(lastDate, today);
+
+                if (daysBetween == 0) {
+                    continuousDays = latestRecord.getContinuousDays();
+                } else if (daysBetween == 1) {
+                    // 昨天完课了,连续天数+1
+                    continuousDays = latestRecord.getContinuousDays() + 1;
+                } else {
+                    // 中断了,重新开始
+                    continuousDays = 1;
+                }
+            }
+
+            // 9. 计算积分(根据连续天数,简单规则:连续天数越多积分越多)
+            int points = calculatePoints(continuousDays);
+
+            // 10. 创建完课记录
+            LiveCompletionPointsRecord record = new LiveCompletionPointsRecord();
+            record.setLiveId(liveId);
+            record.setUserId(userId);
+            record.setWatchDuration(actualWatchDuration);
+            record.setVideoDuration(videoDuration);
+            record.setCompletionRate(watchRate);
+            record.setContinuousDays(continuousDays);
+            record.setPointsAwarded(points);
+            record.setCurrentCompletionDate(currentDate);
+            record.setReceiveStatus(0); // 未领取
+
+            if (latestRecord != null) {
+                record.setLastCompletionDate(latestRecord.getCurrentCompletionDate());
+            }
+
+            recordMapper.insertRecord(record);
+
+        } catch (Exception e) {
+            log.error("检查并创建完课记录失败, liveId={}, userId={}", liveId, userId, e);
+            throw e;
+        }
+    }
+
+    /**
+     * 用户领取完课积分
+     */
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public LiveCompletionPointsRecord receiveCompletionPoints(Long recordId, Long userId) {
+        // 1. 查询完课记录
+        LiveCompletionPointsRecord record = recordMapper.selectById(recordId);
+        if (record == null) {
+            throw new BaseException("完课记录不存在");
+        }
+
+        // 2. 校验用户
+        if (!record.getUserId().equals(userId)) {
+            throw new BaseException("无权领取该完课积分");
+        }
+
+        // 3. 校验领取状态
+        if (record.getReceiveStatus() == 1) {
+            throw new BaseException("该完课积分已领取");
+        }
+
+        // 4. 更新用户积分
+        FsUser user = fsUserMapper.selectFsUserByIdForUpdate(userId);
+        if (user == null) {
+            throw new BaseException("用户不存在");
+        }
+
+        Long currentIntegral = user.getIntegral() != null ? user.getIntegral().longValue() : 0L;
+        Long newIntegral = currentIntegral + record.getPointsAwarded();
+
+        // 更新用户积分
+        fsUserService.incrIntegral(java.util.Arrays.asList(userId), Long.valueOf(record.getPointsAwarded()));
+
+        // 5. 使用IFsUserBillService记录积分变动
+        // addBill(Long uid, String cate, Integer billType, String title, double number, double balance, String remark, String busId, Long tuiUserId)
+        // billType: 1-收入, 0-支出
+        // cate: 账单分类,这里使用 "integral" 表示积分
+        fsUserBillService.addBill(
+            userId,                                    // uid
+            "integral",                                // cate: 积分分类
+            1,                                         // billType: 1-收入
+            "直播完课积分奖励",                          // title
+            record.getPointsAwarded().doubleValue(),   // number: 积分数量
+            newIntegral.doubleValue(),                 // balance: 积分余额
+            "直播完课积分奖励,连续完课" + record.getContinuousDays() + "天", // remark
+            "live_completion_" + recordId,             // busId: 业务ID
+            null                                       // tuiUserId: 推荐人ID,这里为null
+        );
+        List<FsUser> addUserLists = new ArrayList<FsUser>();
+        addUserLists.add(user);
+        // 6. 更新完课记录状态
+        LiveCompletionPointsRecord updateRecord = new LiveCompletionPointsRecord();
+        updateRecord.setId(recordId);
+        updateRecord.setReceiveStatus(1);
+        updateRecord.setReceiveTime(new Date());
+        recordMapper.updateRecord(updateRecord);
+        fsUserService.openPlatformGeneralUserIntegralHandle(addUserLists,recordId, Long.valueOf(record.getPointsAwarded()), FsUserIntegralLogTypeEnum.TYPE_28.getValue());
+
+        // 7. 返回更新后的记录
+        record.setReceiveStatus(1);
+        record.setReceiveTime(new Date());
+
+        log.info("用户领取完课积分成功, userId={}, recordId={}, points={}", userId, recordId, record.getPointsAwarded());
+
+        return record;
+    }
+
+    /**
+     * 获取用户未领取的完课记录
+     */
+    @Override
+    public List<LiveCompletionPointsRecord> getUserUnreceivedRecords(Long liveId, Long userId) {
+        return recordMapper.selectUnreceivedByUser(liveId, userId);
+    }
+
+    /**
+     * 查询用户积分领取记录
+     */
+    @Override
+    public List<LiveCompletionPointsRecord> getUserRecords(Long liveId, Long userId) {
+        return recordMapper.selectRecordsByUser(liveId, userId);
+    }
+
+    /**
+     * 根据用户和日期查询完课记录
+     */
+    @Override
+    public LiveCompletionPointsRecord selectByUserAndDate(Long liveId, Long userId, Date date) {
+        return recordMapper.selectByUserAndDate(liveId, userId, date);
+    }
+
+    @Override
+    public LiveCompletionPointsRecord createCompletionRecord(Long liveId, Long userId) {
+        try {
+            // 1. 获取直播信息和配置
+            Live live = liveService.selectLiveByLiveId(liveId);
+            if (live == null) {
+                return null;
+            }
+
+            // 2. 从直播间配置获取看课时长配置
+            String configJson = live.getConfigJson();
+            LiveWatchConfig config = null;
+            
+            if (configJson != null && !configJson.isEmpty()) {
+                try {
+                    config = JSON.parseObject(configJson, LiveWatchConfig.class);
+                } catch (Exception e) {
+                    log.warn("解析直播间配置失败: {}", e.getMessage());
+                }
+            }
+
+            // 检查是否开启完课积分功能
+            if (config == null || !config.getEnabled()) {
+                return null;
+            }
+
+            // 3. 获取观看时长(初始为0)
+            long actualWatchDuration = 0L;
+
+            // 4. 获取视频总时长(秒)
+            Long videoDuration = live.getDuration();
+            if (videoDuration == null || videoDuration <= 0) {
+                return null;
+            }
+
+            // 5. 计算完课比例
+            BigDecimal watchRate = BigDecimal.valueOf(actualWatchDuration)
+                    .multiply(BigDecimal.valueOf(100))
+                    .divide(BigDecimal.valueOf(videoDuration), 2, RoundingMode.HALF_UP);
+
+            // 限制完课比例最大值为100.00%(防止数据库字段溢出)
+            if (watchRate.compareTo(BigDecimal.valueOf(100)) > 0) {
+                watchRate = BigDecimal.valueOf(100);
+            }
+
+            // 6. 检查今天是否已有完课记录
+            LocalDate today = LocalDate.now();
+            Date currentDate = Date.from(today.atStartOfDay(ZoneId.systemDefault()).toInstant());
+
+            LiveCompletionPointsRecord todayRecord = recordMapper.selectByUserAndDate(liveId, userId, currentDate);
+            if (todayRecord != null) {
+                return todayRecord;
+            }
+
+            // 7. 查询最近一次完课记录(不限直播间),计算连续天数
+            LiveCompletionPointsRecord latestRecord = recordMapper.selectLatestByUser(userId);
+            int continuousDays = 1;
+
+            if (latestRecord != null) {
+                LocalDate lastDate = latestRecord.getCurrentCompletionDate()
+                        .toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
+
+                long daysBetween = ChronoUnit.DAYS.between(lastDate, today);
+
+                if (daysBetween == 0) {
+                    continuousDays = latestRecord.getContinuousDays();
+                } else if (daysBetween == 1) {
+                    // 昨天完课了,连续天数+1
+                    continuousDays = latestRecord.getContinuousDays() + 1;
+                } else {
+                    // 中断了,重新开始
+                    continuousDays = 1;
+                }
+            }
+
+            // 8. 计算积分
+            int points = calculatePoints(continuousDays);
+
+            // 9. 创建完课记录
+            LiveCompletionPointsRecord record = new LiveCompletionPointsRecord();
+            record.setLiveId(liveId);
+            record.setUserId(userId);
+            record.setWatchDuration(actualWatchDuration);
+            record.setVideoDuration(videoDuration);
+            record.setCompletionRate(watchRate);
+            record.setContinuousDays(continuousDays);
+            record.setPointsAwarded(points);
+            record.setCurrentCompletionDate(currentDate);
+            record.setReceiveStatus(0); // 未领取
+
+            if (latestRecord != null) {
+                record.setLastCompletionDate(latestRecord.getCurrentCompletionDate());
+            }
+
+            recordMapper.insertRecord(record);
+            return record;
+        } catch (Exception e) {
+            log.error("创建完课记录失败, liveId={}, userId={}", liveId, userId, e);
+            throw e;
+        }
+    }
+    
+    /**
+     * 计算积分
+     * 根据连续天数计算应得积分(简单规则:连续天数越多积分越多)
+     * @param continuousDays 连续完课天数
+     * @return 应得积分
+     */
+    private int calculatePoints(int continuousDays) {
+        // 简单规则:第1天10积分,第2天20积分,第3天30积分,以此类推,最多100积分
+        if (continuousDays <= 0) {
+            return 10;
+        }
+        int points = continuousDays * 10;
+        return Math.min(points, 100); // 最多100积分
+    }
+}
+

+ 29 - 2
fs-service-system/src/main/java/com/fs/live/service/impl/LiveServiceImpl.java

@@ -206,6 +206,19 @@ public class LiveServiceImpl implements ILiveService
         // liveVo.setStoreId(storeId);
 		BeanUtils.copyProperties(live, liveVo);
 		liveVo.setNowDuration(200L);
+
+        boolean completionPointsEnabled = false;
+        String configJson = live.getConfigJson();
+        if (StringUtils.isNotEmpty(configJson)) {
+            try {
+                JSONObject jsonConfig = JSON.parseObject(configJson);
+                completionPointsEnabled = jsonConfig.getBooleanValue("enabled");
+            } catch (Exception e) {
+                log.warn("解析直播完课积分配置失败, liveId={}", id, e);
+            }
+        }
+        liveVo.setCompletionPointsEnabled(completionPointsEnabled);
+
         LiveVideo liveVideo = liveVideoService.selectLiveVideoByLiveIdAndType(id, 3);
         if (liveVideo != null) {
             liveVo.setPreviewUrl(liveVideo.getVideoUrl());
@@ -726,8 +739,22 @@ public class LiveServiceImpl implements ILiveService
         if (exist.getIsShow() != 1) {
             return R.error("直播已下架");
         }
-        String rtmpPushUrl = generateRtmpPushUrl("rtmp://200149.push.tlivecloud.com", "live", exist.getLiveId().toString());
-        String hlvPlayUrl = generateHlvPlayUrl("https://live.test.ifeiyu100.com", "live", exist.getLiveId().toString());
+        SysConfig sysConfig = sysConfigService.selectConfigByConfigKey("living.config");
+        if (sysConfig == null || StringUtils.isEmpty(sysConfig.getConfigValue())) {
+            log.error("直播配置不存在或为空, liveId: {}", live.getLiveId());
+            return R.error("直播配置不存在,请联系管理员配置");
+        }
+        Map<String, String> livingConfigMap = JSON.parseObject(sysConfig.getConfigValue(), Map.class);
+        if (livingConfigMap == null || livingConfigMap.isEmpty()) {
+            log.error("直播配置解析失败, liveId: {}, configValue: {}", live.getLiveId(), sysConfig.getConfigValue());
+            return R.error("直播配置解析失败");
+        }
+        if (StringUtils.isEmpty(livingConfigMap.get("domain")) || StringUtils.isEmpty(livingConfigMap.get("app"))) {
+            log.error("直播配置缺少必要参数, liveId: {}, configMap: {}", live.getLiveId(), livingConfigMap);
+            return R.error("直播配置缺少必要参数(domain/app)");
+        }
+        String rtmpPushUrl = generateRtmpPushUrl(livingConfigMap.get("domain"), livingConfigMap.get("app"), exist.getLiveId().toString());
+        String hlvPlayUrl = generateHlvPlayUrl(livingConfigMap.get("http"), livingConfigMap.get("app"), exist.getLiveId().toString());
         Date now = new Date();
         exist.setRtmpUrl(rtmpPushUrl);
         exist.setFlvHlsUrl(hlvPlayUrl);

+ 125 - 0
fs-service-system/src/main/resources/mapper/live/LiveCompletionPointsRecordMapper.xml

@@ -0,0 +1,125 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.fs.live.mapper.LiveCompletionPointsRecordMapper">
+
+    <resultMap type="com.fs.live.domain.LiveCompletionPointsRecord" id="LiveCompletionPointsRecordResult">
+        <id     property="id"                      column="id"    />
+        <result property="liveId"                  column="live_id"    />
+        <result property="userId"                  column="user_id"    />
+        <result property="watchDuration"           column="watch_duration"    />
+        <result property="videoDuration"           column="video_duration"    />
+        <result property="completionRate"          column="completion_rate"    />
+        <result property="continuousDays"          column="continuous_days"    />
+        <result property="pointsAwarded"           column="points_awarded"    />
+        <result property="lastCompletionDate"      column="last_completion_date"    />
+        <result property="currentCompletionDate"   column="current_completion_date"    />
+        <result property="receiveStatus"           column="receive_status"    />
+        <result property="receiveTime"             column="receive_time"    />
+        <result property="createTime"              column="create_time"    />
+        <result property="updateTime"              column="update_time"    />
+    </resultMap>
+
+    <!-- 插入完课积分记录 -->
+    <insert id="insertRecord" parameterType="com.fs.live.domain.LiveCompletionPointsRecord" useGeneratedKeys="true" keyProperty="id">
+        INSERT INTO live_completion_points_record (
+            live_id,
+            user_id,
+            watch_duration,
+            video_duration,
+            completion_rate,
+            continuous_days,
+            points_awarded,
+            last_completion_date,
+            current_completion_date,
+            receive_status,
+            receive_time,
+            create_time,
+            update_time
+        ) VALUES (
+            #{liveId},
+            #{userId},
+            #{watchDuration},
+            #{videoDuration},
+            #{completionRate},
+            #{continuousDays},
+            #{pointsAwarded},
+            #{lastCompletionDate},
+            #{currentCompletionDate},
+            #{receiveStatus},
+            #{receiveTime},
+            NOW(),
+            NOW()
+        )
+    </insert>
+
+    <!-- 更新完课积分记录 -->
+    <update id="updateRecord" parameterType="com.fs.live.domain.LiveCompletionPointsRecord">
+        UPDATE live_completion_points_record
+        <set>
+            <if test="watchDuration != null">watch_duration = #{watchDuration},</if>
+            <if test="videoDuration != null">video_duration = #{videoDuration},</if>
+            <if test="completionRate != null">completion_rate = #{completionRate},</if>
+            <if test="continuousDays != null">continuous_days = #{continuousDays},</if>
+            <if test="pointsAwarded != null">points_awarded = #{pointsAwarded},</if>
+            <if test="lastCompletionDate != null">last_completion_date = #{lastCompletionDate},</if>
+            <if test="currentCompletionDate != null">current_completion_date = #{currentCompletionDate},</if>
+            <if test="receiveStatus != null">receive_status = #{receiveStatus},</if>
+            <if test="receiveTime != null">receive_time = #{receiveTime},</if>
+            update_time = NOW()
+        </set>
+        WHERE id = #{id}
+    </update>
+
+    <!-- 查询用户某天的完课记录 -->
+    <select id="selectByUserAndDate" resultMap="LiveCompletionPointsRecordResult">
+        SELECT * FROM live_completion_points_record
+        WHERE live_id = #{liveId}
+          AND user_id = #{userId}
+          AND current_completion_date = #{currentDate}
+        LIMIT 1
+    </select>
+
+    <!-- 查询用户最近一次完课记录(不限直播间,用于计算连续天数) -->
+    <select id="selectLatestByUser" resultMap="LiveCompletionPointsRecordResult">
+        SELECT * FROM live_completion_points_record
+        WHERE user_id = #{userId}
+        ORDER BY current_completion_date DESC
+        LIMIT 1
+    </select>
+
+    <!-- 查询用户在某直播间最近一次完课记录(不限制日期) -->
+    <select id="selectLatestByUserAndLiveId" resultMap="LiveCompletionPointsRecordResult">
+        SELECT * FROM live_completion_points_record
+        WHERE live_id = #{liveId}
+          AND user_id = #{userId}
+        ORDER BY current_completion_date DESC, id DESC
+        LIMIT 1
+    </select>
+
+    <!-- 查询用户未领取的完课记录列表 -->
+    <select id="selectUnreceivedByUser" resultMap="LiveCompletionPointsRecordResult">
+        SELECT * FROM live_completion_points_record
+        WHERE live_id = #{liveId}
+          AND user_id = #{userId}
+          AND receive_status = 0
+        ORDER BY current_completion_date DESC
+    </select>
+
+    <!-- 查询用户的完课积分领取记录列表 -->
+    <select id="selectRecordsByUser" resultMap="LiveCompletionPointsRecordResult">
+        SELECT * FROM live_completion_points_record
+        WHERE live_id = #{liveId}
+          AND user_id = #{userId}
+        ORDER BY current_completion_date DESC, id DESC
+    </select>
+
+    <!-- 根据ID查询 -->
+    <select id="selectById" resultMap="LiveCompletionPointsRecordResult">
+        SELECT * FROM live_completion_points_record
+        WHERE id = #{id} for update
+    </select>
+
+</mapper>
+

+ 1 - 1
fs-service-system/src/main/resources/mapper/live/LiveGiftMapper.xml

@@ -76,7 +76,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
 
     <select id="selectActiveLiveGiftList" resultMap="LiveGiftResult">
         <include refid="selectLiveGiftVo"/>
-        where status = '1'
+        where status = '0'
         order by price asc
     </select>
 </mapper>

+ 2 - 1
fs-user-app/src/main/java/com/fs/app/controller/LiveController.java

@@ -101,7 +101,8 @@ public class LiveController extends AppBaseController {
 //			liveVo.setNowPri(BigDecimal.valueOf(liveVo.getDuration()).divide(BigDecimal.valueOf(liveVo.getNowDuration()), 20, RoundingMode.UP));
 //		}
 		return R.ok().put("data", liveVo).put("storeId", storeId);*/
-		return liveFacadeService.liveDetail(id);
+		Long userId = Long.parseLong(getUserId());
+		return liveFacadeService.liveDetailWithUserId(id,userId);
 	}
 
 	@Login

+ 1 - 1
fs-user-app/src/main/java/com/fs/app/controller/LiveGiftController.java

@@ -82,7 +82,7 @@ public class LiveGiftController extends AppBaseController {
             if (gift == null) {
                 return R.error("礼物不存在");
             }
-            if (!"1".equals(gift.getStatus())) {
+            if (!"0".equals(gift.getStatus())) {
                 return R.error("礼物已停用");
             }
 

+ 488 - 0
fs-user-app/src/main/java/com/fs/app/controller/live/LiveCompletionPointsController.java

@@ -0,0 +1,488 @@
+package com.fs.app.controller.live;
+
+import com.fs.app.controller.AppBaseController;
+import com.fs.app.vo.ReceivePointsVO;
+import com.fs.app.vo.RemainingTimeVO;
+import com.fs.app.vo.UpdateWatchDurationVO;
+import com.fs.common.annotation.RepeatSubmit;
+import com.fs.common.core.domain.R;
+import com.fs.live.domain.Live;
+import com.fs.live.domain.LiveCompletionPointsRecord;
+import com.fs.live.domain.LiveWatchConfig;
+import com.fs.live.mapper.LiveCompletionPointsRecordMapper;
+import com.fs.live.service.ILiveCompletionPointsRecordService;
+import com.fs.live.service.ILiveService;
+import com.fs.store.domain.FsUser;
+import com.fs.store.service.IFsUserService;
+import org.redisson.api.RLock;
+import org.redisson.api.RedissonClient;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.bind.annotation.*;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import com.alibaba.fastjson.JSON;
+
+/**
+ * 直播完课积分Controller
+ */
+@RestController
+@RequestMapping("/app/live/completion")
+public class LiveCompletionPointsController extends AppBaseController {
+
+    private static final Logger logger = LoggerFactory.getLogger(LiveCompletionPointsController.class);
+
+    @Autowired
+    private ILiveCompletionPointsRecordService completionPointsRecordService;
+
+    @Autowired
+    private IFsUserService fsUserService;
+
+    @Autowired
+    private ILiveService liveService;
+
+    @Autowired
+    private LiveCompletionPointsRecordMapper completionPointsRecordMapper;
+
+    @Autowired
+    private RedissonClient redissonClient;
+
+    /**
+     * 检查直播间是否开启观看积分奖励
+     * @param live 直播间信息
+     * @return 是否开启,如果未开启返回false
+     */
+    private boolean checkWatchRewardEnabled(Live live) {
+        if (live == null) {
+            return false;
+        }
+        String configJson = live.getConfigJson();
+        if (configJson == null || configJson.isEmpty()) {
+            return false;
+        }
+        try {
+            LiveWatchConfig config = JSON.parseObject(configJson, LiveWatchConfig.class);
+            return config != null && config.getEnabled() != null && config.getEnabled();
+        } catch (Exception e) {
+            logger.warn("解析直播间配置失败: {}", e.getMessage());
+            return false;
+        }
+    }
+
+    /**
+     * 领取完课积分
+     */
+    @PostMapping("/receive")
+    @RepeatSubmit
+    public R receive(@RequestParam Long liveId) {
+        Long userId = Long.parseLong(getUserId());
+        LiveCompletionPointsRecord record = completionPointsRecordService.receiveCompletionPoints(liveId, userId);
+        return R.ok().put("data", record);
+    }
+
+    /**
+     * 获取用户未领取的积分列表
+     */
+    @GetMapping("/unreceived")
+    public R getUnreceived(@RequestParam Long liveId) {
+        Long userId = Long.parseLong(getUserId());
+        List<LiveCompletionPointsRecord> records = completionPointsRecordService.getUserUnreceivedRecords(liveId, userId);
+        return R.ok().put("data", records);
+    }
+
+    /**
+     * 查询用户积分领取记录
+     */
+    @GetMapping("/records")
+    public R getRecords(@RequestParam Long liveId) {
+        Long userId = Long.parseLong(getUserId());
+        List<LiveCompletionPointsRecord> records = completionPointsRecordService.getUserRecords(liveId, userId);
+        return R.ok().put("data", records);
+    }
+
+    /**
+     * 查询用户积分余额和看直播信息统计
+     */
+    @GetMapping("/info")
+    public R getInfo(@RequestParam Long liveId) {
+        Long userId = Long.parseLong(getUserId());
+
+        // 1. 获取用户积分余额
+        FsUser user = fsUserService.selectFsUserByUserId(userId);
+        Long integral = user != null && user.getIntegral() != null ? user.getIntegral().longValue() : 0L;
+
+        // 2. 获取完课记录列表(包含已领取和未领取)
+        List<LiveCompletionPointsRecord> records = completionPointsRecordService.getUserRecords(liveId, userId);
+
+        // 3. 统计信息
+        long totalPoints = records.stream()
+                .filter(r -> r.getReceiveStatus() == 1)
+                .mapToLong(LiveCompletionPointsRecord::getPointsAwarded)
+                .sum();
+
+        long unreceivedCount = records.stream()
+                .filter(r -> r.getReceiveStatus() == 0)
+                .count();
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("integral", integral);  // 当前积分余额
+        result.put("totalPoints", totalPoints);  // 本直播间累计获得积分
+        result.put("totalDays", records.size());  // 累计看直播天数
+        result.put("unreceivedCount", unreceivedCount);  // 未领取记录数
+        result.put("records", records);  // 完课记录列表
+
+        return R.ok().put("data", result);
+    }
+
+    /**
+     * 用于测试和调试
+     */
+    @PostMapping("/test/create")
+    public R testCreateRecord(@RequestParam Long liveId, @RequestParam(required = false) Long watchDuration) {
+        Long userId = Long.parseLong(getUserId());
+
+        try {
+            // 调用完课记录创建方法(watchDuration为null时会自动从数据库累计)
+            completionPointsRecordService.checkAndCreateCompletionRecord(liveId, userId, watchDuration);
+            return R.ok("完课记录创建成功,请查看records接口查看结果");
+        } catch (Exception e) {
+            return R.error("创建失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 第一个接口:查询当前用户当前直播间领取积分的剩余时长
+     * GET请求,传入直播间id
+     * 查询当前用户和当前直播间的积分记录(不限制日期),如果不存在就生成看课记录
+     */
+    @GetMapping("/remaining-time")
+    public R getRemainingTime(@RequestParam Long liveId) {
+        Long userId = Long.parseLong(getUserId());
+
+        try {
+            // 1. 获取直播间信息
+            Live live = liveService.selectLiveByLiveId(liveId);
+            if (live == null) {
+                return R.error("直播间不存在");
+            }
+            // 2. 检查直播间是否开启观看积分奖励
+            if (!checkWatchRewardEnabled(live)) {
+                return R.error(406, "直播间未开启直播观看奖励");
+            }
+
+            // 2. 查询当前用户和当前直播间的最近一次完课记录(不限制日期)
+            LiveCompletionPointsRecord record = completionPointsRecordMapper.selectLatestByUserAndLiveId(liveId, userId);
+
+            // 3. 如果没有记录,查询直播间配置并生成记录
+            if (record == null) {
+                record = completionPointsRecordService.createCompletionRecord(liveId, userId);
+            }
+
+            // 4. 获取看课时长配置(从直播间配置1中获取)
+            Long requiredWatchDuration = null;
+            String configJson = live.getConfigJson();
+            if (configJson != null && !configJson.isEmpty()) {
+                try {
+                    LiveWatchConfig config = JSON.parseObject(configJson, LiveWatchConfig.class);
+                    if (config != null && config.getWatchDuration() != null) {
+                        // watchDuration是分钟,转换为秒
+                        requiredWatchDuration = config.getWatchDuration() * 60;
+                    }
+                } catch (Exception e) {
+                    logger.warn("解析直播间配置失败: {}", e.getMessage());
+                }
+            }
+
+            // 5. 计算剩余时长
+            RemainingTimeVO vo = new RemainingTimeVO();
+            Long videoDuration = live.getDuration() != null ? live.getDuration() : 0L;
+            Long watchDuration = record != null && record.getWatchDuration() != null
+                    ? record.getWatchDuration() : 0L;
+
+            vo.setVideoDuration(videoDuration);
+            vo.setWatchDuration(watchDuration);
+            
+            // 如果配置了看课时长,使用看课时长作为目标;否则使用视频总时长
+            Long targetDuration = requiredWatchDuration != null ? requiredWatchDuration : videoDuration;
+            vo.setRemainingTime(Math.max(0, targetDuration - watchDuration));
+            
+            // 使用RemainingTime和videoDuration计算完课比例
+            // 先计算基于videoDuration的剩余时长
+            if (videoDuration > 0) {
+                BigDecimal completionRate = BigDecimal.valueOf(targetDuration)
+                        .multiply(BigDecimal.valueOf(100))
+                        .divide(BigDecimal.valueOf(videoDuration), 2, java.math.RoundingMode.HALF_UP);
+                if (completionRate.compareTo(BigDecimal.valueOf(100)) > 0) {
+                    completionRate = BigDecimal.valueOf(100);
+                }
+                if (completionRate.compareTo(BigDecimal.ZERO) < 0) {
+                    completionRate = BigDecimal.ZERO;
+                }
+                vo.setCompletionRate(completionRate);
+            }
+            vo.setHasReceived(record != null && record.getReceiveStatus() != null && record.getReceiveStatus() == 1);
+
+            return R.ok().put("data", vo);
+        } catch (Exception e) {
+            return R.error("查询失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 第二个接口:更新用户的看课时长
+     * POST请求,传入直播间id和看课时长
+     * 更新用户看课completionPointsRecordService看课记录里面的时长
+     */
+    @PostMapping("/update-watch-duration")
+    @Transactional
+    public R updateWatchDuration(@RequestParam Long liveId, @RequestParam Long watchDuration) {
+        Long userId = Long.parseLong(getUserId());
+
+        try {
+            // 1. 获取直播间信息
+            Live live = liveService.selectLiveByLiveId(liveId);
+            if (live == null) {
+                return R.error("直播间不存在");
+            }
+
+            // 2. 检查直播间是否开启观看积分奖励
+            if (!checkWatchRewardEnabled(live)) {
+                return R.error(406, "直播间未开启直播观看奖励");
+            }
+
+            // 3. 判断当前时间是否在直播期间(状态为2,直播中)
+            boolean isLiveInProgress = false;
+            LocalDateTime now = LocalDateTime.now();
+
+            if (live.getStatus() != null && live.getStatus() == 2) {
+                // status=2 表示直播中
+                isLiveInProgress = true;
+            } else if (live.getStartTime() != null && live.getFinishTime() != null) {
+                // 判断当前时间是否在开播时间和结束时间之间
+                isLiveInProgress = (now.isAfter(live.getStartTime()) || now.isEqual(live.getStartTime()))
+                        && (now.isBefore(live.getFinishTime()) || now.isEqual(live.getFinishTime()));
+            }
+
+            if (!isLiveInProgress) {
+                return R.error("当前不在直播期间,无法更新看课时长");
+            }
+
+            // 4. 查询当前直播间的完课记录(不限制日期)
+            LiveCompletionPointsRecord record = completionPointsRecordMapper.selectLatestByUserAndLiveId(liveId, userId);
+
+            // 5. 计算看课时长
+            Date updateTime = null;
+            if (record != null && record.getUpdateTime() != null) {
+                updateTime = record.getUpdateTime();
+            }
+
+            // 判断更新时间与直播间开始时间的关系
+            Date startTime = live.getStartTime() != null
+                    ? java.sql.Timestamp.valueOf(live.getStartTime()) : null;
+
+            Date currentTime = new Date();
+            long timeDiff = 0L;
+
+            if (updateTime != null && startTime != null) {
+                if (updateTime.before(startTime)) {
+                    // 更新时间小于直播间开始时间,使用直播间开始时间进行计算
+                    timeDiff = (currentTime.getTime() - startTime.getTime()) / 1000; // 转换为秒
+                } else {
+                    // 更新时间大于等于开播时间,按照更新时间进行计算
+                    timeDiff = (currentTime.getTime() - updateTime.getTime()) / 1000; // 转换为秒
+                }
+            } else if (startTime != null) {
+                // 没有更新记录,使用直播间开始时间计算
+                timeDiff = (currentTime.getTime() - startTime.getTime()) / 1000; // 转换为秒
+            }
+
+            // 6. 如果请求传入的时间大于这个时间差,就使用计算出的看课时长,否则使用请求传入的时长
+            Long finalWatchDuration;
+            if (watchDuration > timeDiff) {
+                // 请求传入的时间大于时间差,使用计算出的看课时长
+                finalWatchDuration = timeDiff;
+            } else {
+                // 否则使用请求传入的时长
+                finalWatchDuration = watchDuration;
+            }
+
+            // 7. 更新完课记录中的看课时长
+            if (record == null) {
+                // 如果没有记录,先创建记录
+                record = completionPointsRecordService.createCompletionRecord(liveId, userId);
+                if (record == null) {
+                    return R.error(406, "直播间未开启直播观看奖励");
+                }
+                record.setWatchDuration(finalWatchDuration);
+                completionPointsRecordMapper.updateRecord(record);
+            } else {
+                // 更新现有记录的看课时长
+                Long currentWatchDuration = record.getWatchDuration() != null
+                        ? record.getWatchDuration() : 0L;
+                record.setWatchDuration(currentWatchDuration + finalWatchDuration);
+
+                // 重新计算完课比例(基于看课时长配置)
+                Long videoDuration = live.getDuration();
+                String configJson = live.getConfigJson();
+                Long requiredWatchDuration = null;
+                
+                // 从直播间配置获取看课时长
+                if (configJson != null && !configJson.isEmpty()) {
+                    try {
+                        LiveWatchConfig config = JSON.parseObject(configJson, LiveWatchConfig.class);
+                        if (config != null && config.getWatchDuration() != null) {
+                            // watchDuration是分钟,转换为秒
+                            requiredWatchDuration = config.getWatchDuration() * 60;
+                        }
+                    } catch (Exception e) {
+                        logger.warn("解析直播间配置失败: {}", e.getMessage());
+                    }
+                }
+                
+                // 如果配置了看课时长,使用看课时长计算比例;否则使用视频总时长
+                Long targetDuration = requiredWatchDuration != null ? requiredWatchDuration : videoDuration;
+                if (targetDuration != null && targetDuration > 0) {
+                    BigDecimal completionRate = BigDecimal.valueOf(record.getWatchDuration())
+                            .multiply(BigDecimal.valueOf(100))
+                            .divide(BigDecimal.valueOf(targetDuration), 2, java.math.RoundingMode.HALF_UP);
+                    if (completionRate.compareTo(BigDecimal.valueOf(100)) > 0) {
+                        completionRate = BigDecimal.valueOf(100);
+                    }
+                    record.setCompletionRate(completionRate);
+                }
+
+                int updateResult = completionPointsRecordMapper.updateRecord(record);
+                if (updateResult <= 0) {
+                    return R.error("更新看课时间失败");
+                }
+            }
+
+            UpdateWatchDurationVO vo = new UpdateWatchDurationVO();
+            vo.setWatchDuration(finalWatchDuration);
+            vo.setTotalWatchDuration(record != null && record.getWatchDuration() != null
+                    ? record.getWatchDuration() : finalWatchDuration);
+
+            return R.ok().put("data", vo);
+        } catch (Exception e) {
+            return R.error("更新失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 第三个接口:用户领取看课积分
+     * POST请求,传入直播间id
+     * 只查询这个直播间的看课记录(不限制日期),检查是否达到了完课标准
+     * 达到了更新了看课记录里面的领取条件,给用户发积分
+     * 没达到,返回报错
+     */
+    @PostMapping("/receive-points")
+    public R receivePoints(@RequestParam Long liveId) {
+        Long userId = Long.parseLong(getUserId());
+
+        // 创建唯一锁,确保同一个 liveId 和 userId 只能有一个线程在执行
+        String lockKey = String.format("receivePoints:liveId:%d:userId:%d", liveId, userId);
+        RLock lock = redissonClient.getLock(lockKey);
+
+        try {
+            // 尝试获取锁,等待时间0秒,锁持有时间15秒
+            boolean locked = lock.tryLock(0, 15, TimeUnit.SECONDS);
+            if (!locked) {
+                logger.warn("获取领取积分锁失败,liveId: {}, userId: {}", liveId, userId);
+                return R.error("系统繁忙,请稍后重试");
+            }
+
+            try {
+                // 1. 获取直播间信息和配置
+                Live live = liveService.selectLiveByLiveId(liveId);
+                if (live == null) {
+                    return R.error("直播间不存在");
+                }
+
+                // 2. 检查直播间是否开启观看积分奖励
+                if (!checkWatchRewardEnabled(live)) {
+                    return R.error(406, "直播间未开启直播观看奖励");
+                }
+
+                // 3. 查询当前用户和当前直播间的最近一次完课记录(不限制日期)
+                LiveCompletionPointsRecord record = completionPointsRecordMapper.selectLatestByUserAndLiveId(liveId, userId);
+
+                if (record == null) {
+                    return R.error("您还没有看课记录,无法领取积分");
+                }
+
+                // 4. 检查看课记录里面的时长是否达到完课标准
+                Long watchDuration = record.getWatchDuration();
+                if (watchDuration == null || watchDuration <= 0) {
+                    return R.error("您的看课时长不足,无法领取积分");
+                }
+
+                // 5. 从直播间配置获取看课时长(配置1中的watchDuration)
+                String configJson = live.getConfigJson();
+                Long requiredWatchDuration = null;
+                if (configJson != null && !configJson.isEmpty()) {
+                    try {
+                        LiveWatchConfig config = JSON.parseObject(configJson, LiveWatchConfig.class);
+                        if (config != null && config.getWatchDuration() != null) {
+                            // watchDuration是分钟,转换为秒
+                            requiredWatchDuration = config.getWatchDuration() * 60;
+                        }
+                    } catch (Exception e) {
+                        logger.warn("解析直播间配置失败: {}", e.getMessage());
+                    }
+                }
+
+                // 6. 判断是否达到完课标准(使用看课时长而不是完课比例)
+                if (requiredWatchDuration != null && watchDuration < requiredWatchDuration) {
+                    // 转换为分钟显示
+                    long requiredMinutes = requiredWatchDuration / 60;
+                    long currentMinutes = watchDuration / 60;
+                    return R.error("您的看课时长未达到标准(" + requiredMinutes + "分钟),当前看课时长:" + currentMinutes + "分钟");
+                }
+
+                // 7. 检查是否已领取
+                if (record.getReceiveStatus() != null && record.getReceiveStatus() == 1) {
+                    return R.error("该完课积分已领取");
+                }
+
+                // 8. 领取积分(更新看课记录的领取状态,给用户加积分)
+                LiveCompletionPointsRecord receivedRecord = completionPointsRecordService.receiveCompletionPoints(record.getId(), userId);
+
+                ReceivePointsVO vo = new ReceivePointsVO();
+                vo.setRecord(receivedRecord);
+                vo.setPoints(receivedRecord.getPointsAwarded());
+                vo.setContinuousDays(receivedRecord.getContinuousDays());
+
+                return R.ok().put("data", vo);
+            }catch (Exception e) {
+                logger.error("领取积分失败,liveId: {}, userId: {}", liveId, userId, e);
+                return R.error("领取失败: " + e.getMessage());
+            } finally {
+                // 释放锁
+                if (lock.isHeldByCurrentThread()) {
+                    lock.unlock();
+                    logger.debug("领取积分锁已释放,liveId: {}, userId: {}", liveId, userId);
+                }
+            }
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            logger.error("获取领取积分锁被中断,liveId: {}, userId: {}", liveId, userId, e);
+            return R.error("系统繁忙,请稍后重试");
+        } catch (Exception e) {
+            logger.error("领取积分异常,liveId: {}, userId: {}", liveId, userId, e);
+            return R.error("领取失败: " + e.getMessage());
+        }
+    }
+
+
+}
+

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

@@ -22,4 +22,6 @@ public interface LiveFacadeService {
     R redClaim(RedPO red);
 
     R couponClaim(CouponPO coupon);
+
+    R liveDetailWithUserId(Long id, Long userId);
 }

+ 84 - 4
fs-user-app/src/main/java/com/fs/app/facade/impl/LiveFacadeServiceImpl.java

@@ -13,10 +13,7 @@ import com.fs.common.core.page.PageRequest;
 import com.fs.common.core.page.TableDataInfo;
 import com.fs.common.core.redis.RedisUtil;
 import com.fs.core.aspectj.lock.DistributeLock;
-import com.fs.live.domain.Live;
-import com.fs.live.domain.LiveLotteryRegistration;
-import com.fs.live.domain.LiveRedConf;
-import com.fs.live.domain.LiveWatchUser;
+import com.fs.live.domain.*;
 import com.fs.live.param.CouponPO;
 import com.fs.live.param.LotteryPO;
 import com.fs.live.param.RedPO;
@@ -60,6 +57,10 @@ public class LiveFacadeServiceImpl extends BaseController implements LiveFacadeS
     @Autowired
     private ILiveLotteryConfService liveLotteryConfService;
 
+    @Autowired
+    private ILiveCompletionPointsRecordService completionPointsRecordService;
+
+
     @Override
     public R liveList(PageRequest pageRequest) {
         int start = (pageRequest.getCurrentPage() - 1) * pageRequest.getPageSize();
@@ -168,6 +169,83 @@ public class LiveFacadeServiceImpl extends BaseController implements LiveFacadeS
         return R.ok().put("data", liveVo);
     }
 
+    @Override
+    public R liveDetailWithUserId(Long id, Long userId) {
+        Object o = redisUtil.hashGet(LiveKeysConstant.LIVE_HOME_PAGE_DETAIL, String.valueOf(id));
+        LiveVo liveVo;
+        if (ObjectUtil.isNotEmpty(o)) {
+            liveVo = JSON.parseObject(o.toString(), LiveVo.class);
+        } else {
+            liveVo = liveService.asyncToCacheLiveDetail(id);
+        }
+        if (ObjectUtil.isEmpty(liveVo)) {
+            R.error("未找到直播");
+        }
+        if(liveVo.getIsShow() == 2) {
+            return R.error("直播未开放");
+        }
+
+        // 查询用户今天是否已领取完课奖励
+        if (userId != null) {
+            try {
+                List<LiveCompletionPointsRecord> unreceivedRecords =
+                        completionPointsRecordService.getUserUnreceivedRecords(id, userId);
+
+                // 判断是否有未领取的奖励,如果有则说明今天还未领取
+                // 如果没有未领取的,再查询是否有已领取的记录
+                if (unreceivedRecords != null && !unreceivedRecords.isEmpty()) {
+                    liveVo.setTodayRewardReceived(false);
+                } else {
+                    // 查询所有记录(包括已领取和未领取)
+                    List<LiveCompletionPointsRecord> allRecords =
+                            completionPointsRecordService.getUserRecords(id, userId);
+
+                    if (allRecords != null && !allRecords.isEmpty()) {
+                        // 检查最近一条记录是否是今天的且已领取
+                        LiveCompletionPointsRecord latestRecord = allRecords.get(0);
+                        Date today = new Date();
+                        Date recordDate = latestRecord.getCurrentCompletionDate();
+
+                        // 判断是否为同一天
+                        boolean isSameDay = recordDate != null &&
+                                isSameDay(recordDate, today);
+
+                        if (isSameDay && latestRecord.getReceiveStatus() == 1) {
+                            liveVo.setTodayRewardReceived(true);
+                        } else {
+                            liveVo.setTodayRewardReceived(false);
+                        }
+                    } else {
+                        liveVo.setTodayRewardReceived(false);
+                    }
+                }
+            } catch (Exception e) {
+                log.error("查询用户完课奖励领取状态失败, liveId={}, userId={}", id, userId, e);
+                liveVo.setTodayRewardReceived(false);
+            }
+        } else {
+            liveVo.setTodayRewardReceived(false);
+        }
+
+        return R.ok().put("data", liveVo);
+    }
+
+    /**
+     * 判断两个日期是否为同一天
+     */
+    private boolean isSameDay(Date date1, Date date2) {
+        if (date1 == null || date2 == null) {
+            return false;
+        }
+        Calendar cal1 = Calendar.getInstance();
+        Calendar cal2 = Calendar.getInstance();
+        cal1.setTime(date1);
+        cal2.setTime(date2);
+        return cal1.get(Calendar.YEAR) == cal2.get(Calendar.YEAR) &&
+                cal1.get(Calendar.DAY_OF_YEAR) == cal2.get(Calendar.DAY_OF_YEAR);
+    }
+
+
     @Override
     public R currentActivities(Long liveId, String userId) {
         // 直播间配置信息
@@ -212,6 +290,8 @@ public class LiveFacadeServiceImpl extends BaseController implements LiveFacadeS
         return iLiveCouponService.claimCoupon(coupon);
     }
 
+
+
     @Override
     @DistributeLock(keyExpression = "#lottery.liveId +'_'+#lottery.userId", scene = "draw_claim")
     public R drawClaim(LotteryPO lottery) {

+ 25 - 0
fs-user-app/src/main/java/com/fs/app/vo/ReceivePointsVO.java

@@ -0,0 +1,25 @@
+package com.fs.app.vo;
+
+import com.fs.live.domain.LiveCompletionPointsRecord;
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 领取积分返回VO
+ */
+@Data
+public class ReceivePointsVO implements Serializable {
+    
+    private static final long serialVersionUID = 1L;
+    
+    /** 完课记录 */
+    private LiveCompletionPointsRecord record;
+    
+    /** 获得的积分 */
+    private Integer points;
+    
+    /** 连续天数 */
+    private Integer continuousDays;
+}
+

+ 31 - 0
fs-user-app/src/main/java/com/fs/app/vo/RemainingTimeVO.java

@@ -0,0 +1,31 @@
+package com.fs.app.vo;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+
+/**
+ * 剩余时长返回VO
+ */
+@Data
+public class RemainingTimeVO implements Serializable {
+    
+    private static final long serialVersionUID = 1L;
+    
+    /** 剩余时长(秒) */
+    private Long remainingTime;
+    
+    /** 已观看时长(秒) */
+    private Long watchDuration;
+    
+    /** 视频总时长(秒) */
+    private Long videoDuration;
+    
+    /** 是否领取过 */
+    private Boolean hasReceived;
+
+    /** 完课比例(%) */
+    private BigDecimal completionRate;
+}
+

+ 21 - 0
fs-user-app/src/main/java/com/fs/app/vo/UpdateWatchDurationVO.java

@@ -0,0 +1,21 @@
+package com.fs.app.vo;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 更新看课时长返回VO
+ */
+@Data
+public class UpdateWatchDurationVO implements Serializable {
+    
+    private static final long serialVersionUID = 1L;
+    
+    /** 本次更新的看课时长(秒) */
+    private Long watchDuration;
+    
+    /** 总看课时长(秒) */
+    private Long totalWatchDuration;
+}
+