xw 23 timmar sedan
förälder
incheckning
cfc880b939

+ 17 - 0
fs-service/src/main/java/com/fs/course/config/CourseConfig.java

@@ -6,6 +6,7 @@ import lombok.Data;
 import java.io.Serializable;
 import java.math.BigDecimal;
 import java.time.LocalTime;
+import java.util.Date;
 import java.util.List;
 
 @Data
@@ -102,6 +103,22 @@ public class CourseConfig implements Serializable {
     // 控制休息提示是否打开 默认打开 0-关闭 1-打开
     private Integer isOpenRestReminder;
 
+    /**
+     * 是否开启按项目自动看课重粉限制(关闭则不限制)
+     */
+    private Boolean projectRepeatLimitEnabled;
+
+    /**
+     * 按项目重粉限制生效时间,此时间之后的看课行为才受限
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date projectRepeatLimitEffectiveTime;
+
+    /**
+     * 先导课是否豁免重粉限制,默认 true
+     */
+    private Boolean pilotCourseSkipRepeatLimit;
+
 
     @Data
     public static class DisabledTimeVo{

+ 33 - 0
fs-service/src/main/java/com/fs/course/service/ICourseProjectSalesBindService.java

@@ -0,0 +1,33 @@
+package com.fs.course.service;
+
+import com.fs.common.core.domain.R;
+import com.fs.course.config.CourseConfig;
+import com.fs.course.param.FsUserCourseVideoAddKfUParam;
+
+/**
+ * 按项目看课销售绑定:isAddKf 入口同步校验与首次绑定
+ */
+public interface ICourseProjectSalesBindService {
+
+    /**
+     * 全局开关开启且已到生效时间
+     */
+    boolean isLimitActive(CourseConfig config);
+
+    /**
+     * 加载 course.config
+     */
+    CourseConfig loadCourseConfig();
+
+    /**
+     * 是否先导课(豁免重粉限制)
+     */
+    boolean isPilotCourse(Long videoId, CourseConfig config);
+
+    /**
+     * 同步校验并在首次进入时写入 fs_user_company_qw。
+     *
+     * @return null 表示通过,否则为拦截响应
+     */
+    R checkAndBind(FsUserCourseVideoAddKfUParam param);
+}

+ 249 - 0
fs-service/src/main/java/com/fs/course/service/impl/CourseProjectSalesBindServiceImpl.java

@@ -0,0 +1,249 @@
+package com.fs.course.service.impl;
+
+import cn.hutool.json.JSONUtil;
+import com.fs.common.core.domain.R;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.utils.StringUtils;
+import com.fs.course.config.CourseConfig;
+import com.fs.course.domain.FsUserCompanyQw;
+import com.fs.course.domain.FsUserCourse;
+import com.fs.course.domain.FsUserCourseVideo;
+import com.fs.course.mapper.FsUserCompanyQwMapper;
+import com.fs.course.mapper.FsUserCourseMapper;
+import com.fs.course.mapper.FsUserCourseVideoMapper;
+import com.fs.course.param.FsUserCourseVideoAddKfUParam;
+import com.fs.course.service.ICourseProjectSalesBindService;
+import com.fs.course.support.CourseProjectEquivalence;
+import com.fs.course.support.CourseProjectSalesBindConstants;
+import com.fs.enums.ExceptionCodeEnum;
+import com.fs.system.service.ISysConfigService;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.collections4.CollectionUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.Date;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+@Slf4j
+@Service
+public class CourseProjectSalesBindServiceImpl implements ICourseProjectSalesBindService {
+
+    @Autowired
+    private ISysConfigService configService;
+    @Autowired
+    private RedisCache redisCache;
+    @Autowired
+    private FsUserCompanyQwMapper fsUserCompanyQwMapper;
+    @Autowired
+    private FsUserCourseVideoMapper fsUserCourseVideoMapper;
+    @Autowired
+    private FsUserCourseMapper fsUserCourseMapper;
+
+    @Override
+    public boolean isLimitActive(CourseConfig config) {
+        if (config == null || !Boolean.TRUE.equals(config.getProjectRepeatLimitEnabled())) {
+            return false;
+        }
+        Date effectiveTime = config.getProjectRepeatLimitEffectiveTime();
+        if (effectiveTime == null) {
+            return false;
+        }
+        return !new Date().before(effectiveTime);
+    }
+
+    @Override
+    public CourseConfig loadCourseConfig() {
+        String json = configService.selectConfigByKey("course.config");
+        if (StringUtils.isEmpty(json)) {
+            return new CourseConfig();
+        }
+        return JSONUtil.toBean(json, CourseConfig.class);
+    }
+
+    @Override
+    public boolean isPilotCourse(Long videoId, CourseConfig config) {
+        if (videoId == null) {
+            return false;
+        }
+        if (config != null && Boolean.FALSE.equals(config.getPilotCourseSkipRepeatLimit())) {
+            return false;
+        }
+        FsUserCourseVideo video = getVideoCached(videoId);
+        if (video == null) {
+            return false;
+        }
+        if (video.getIsFirst() != null && video.getIsFirst() == 1) {
+            return true;
+        }
+        return StringUtils.isNotEmpty(video.getTitle()) && video.getTitle().contains("先导课");
+    }
+
+    @Override
+    public R checkAndBind(FsUserCourseVideoAddKfUParam param) {
+        if (param == null || param.getUserId() == null || param.getCompanyUserId() == null) {
+            return null;
+        }
+
+        CourseConfig config = loadCourseConfig();
+        if (!isLimitActive(config)) {
+            return null;
+        }
+
+        if (isPilotCourse(param.getVideoId(), config)) {
+            log.debug("按项目重粉限制跳过先导课 userId={}, videoId={}", param.getUserId(), param.getVideoId());
+            return null;
+        }
+
+        Long projectId = resolveCourseProjectId(param);
+        if (projectId == null || projectId == 0L) {
+            return null;
+        }
+
+        List<Long> equivalentProjectIds = CourseProjectEquivalence.equivalentProjectIds(projectId);
+        if (CollectionUtils.isEmpty(equivalentProjectIds)) {
+            return null;
+        }
+
+        Long currentCompanyUserId = param.getCompanyUserId();
+        FsUserCompanyQw matchedBind = null;
+
+        for (Long equivalentProjectId : equivalentProjectIds) {
+            FsUserCompanyQw existBind = getBindRecord(param.getUserId(), equivalentProjectId);
+            if (existBind == null || existBind.getCompanyUserId() == null) {
+                continue;
+            }
+            if (!existBind.getCompanyUserId().equals(currentCompanyUserId)) {
+                log.info("按项目重粉限制拦截 userId={}, projectId={}, boundSales={}, currentSales={}, firstBindTime={}",
+                        param.getUserId(), equivalentProjectId, existBind.getCompanyUserId(),
+                        currentCompanyUserId, existBind.getFirstBindTime());
+                return R.error(ExceptionCodeEnum.USER_PROJECT_OTHER_SALES_BOUND.getCode(),
+                        ExceptionCodeEnum.USER_PROJECT_OTHER_SALES_BOUND.getDescription());
+            }
+            matchedBind = existBind;
+        }
+
+        if (matchedBind != null) {
+            for (Long equivalentProjectId : equivalentProjectIds) {
+                cacheBind(param.getUserId(), equivalentProjectId, matchedBind.getCompanyUserId());
+            }
+            return null;
+        }
+
+        return tryFirstBind(param, projectId, currentCompanyUserId);
+    }
+
+    private R tryFirstBind(FsUserCourseVideoAddKfUParam param, Long projectId, Long currentCompanyUserId) {
+        Long qwUserId = parseQwUserId(param.getQwUserId());
+        try {
+            fsUserCompanyQwMapper.insertOrUpdate(
+                    param.getUserId(),
+                    projectId,
+                    qwUserId,
+                    currentCompanyUserId,
+                    param.getCompanyId(),
+                    param.getQwExternalId(),
+                    param.getCourseId(),
+                    param.getVideoId()
+            );
+        } catch (Exception e) {
+            log.error("按项目销售绑定写入失败 userId={}, projectId={}", param.getUserId(), projectId, e);
+            return null;
+        }
+
+        FsUserCompanyQw bind = fsUserCompanyQwMapper.selectByUserAndProject(param.getUserId(), projectId);
+        if (bind != null && bind.getCompanyUserId() != null
+                && !bind.getCompanyUserId().equals(currentCompanyUserId)) {
+            log.info("按项目重粉限制并发拦截 userId={}, projectId={}, boundSales={}, currentSales={}, firstBindTime={}",
+                    param.getUserId(), projectId, bind.getCompanyUserId(), currentCompanyUserId, bind.getFirstBindTime());
+            return R.error(ExceptionCodeEnum.USER_PROJECT_OTHER_SALES_BOUND.getCode(),
+                    ExceptionCodeEnum.USER_PROJECT_OTHER_SALES_BOUND.getDescription());
+        }
+
+        cacheBind(param.getUserId(), projectId, currentCompanyUserId);
+        log.info("按项目销售首次绑定 userId={}, projectId={}, companyUserId={}, firstBindTime={}",
+                param.getUserId(), projectId, currentCompanyUserId,
+                bind != null ? bind.getFirstBindTime() : null);
+        return null;
+    }
+
+    private FsUserCompanyQw getBindRecord(Long userId, Long projectId) {
+        Long cachedCompanyUserId = redisCache.getCacheObject(CourseProjectSalesBindConstants.bindCacheKey(userId, projectId));
+        if (cachedCompanyUserId != null) {
+            FsUserCompanyQw cached = new FsUserCompanyQw();
+            cached.setFsUserId(userId);
+            cached.setProjectId(projectId);
+            cached.setCompanyUserId(cachedCompanyUserId);
+            return cached;
+        }
+
+        FsUserCompanyQw existBind = fsUserCompanyQwMapper.selectByUserAndProject(userId, projectId);
+        if (existBind != null && existBind.getCompanyUserId() != null) {
+            cacheBind(userId, projectId, existBind.getCompanyUserId());
+        }
+        return existBind;
+    }
+
+    private void cacheBind(Long userId, Long projectId, Long companyUserId) {
+        if (userId == null || projectId == null || companyUserId == null) {
+            return;
+        }
+        redisCache.setCacheObject(
+                CourseProjectSalesBindConstants.bindCacheKey(userId, projectId),
+                companyUserId,
+                CourseProjectSalesBindConstants.BIND_CACHE_DAYS,
+                TimeUnit.DAYS
+        );
+    }
+
+    private Long resolveCourseProjectId(FsUserCourseVideoAddKfUParam param) {
+        if (param.getCourseId() != null) {
+            FsUserCourse course = fsUserCourseMapper.selectFsUserCourseByCourseId(param.getCourseId());
+            if (course != null && course.getProject() != null && course.getProject() != 0L) {
+                return course.getProject();
+            }
+        }
+        if (param.getVideoId() != null) {
+            FsUserCourseVideo video = getVideoCached(param.getVideoId());
+            if (video != null && video.getCourseId() != null) {
+                FsUserCourse course = fsUserCourseMapper.selectFsUserCourseByCourseId(video.getCourseId());
+                if (course != null && course.getProject() != null && course.getProject() != 0L) {
+                    return course.getProject();
+                }
+            }
+        }
+        return 0L;
+    }
+
+    private FsUserCourseVideo getVideoCached(Long videoId) {
+        String cacheKey = CourseProjectSalesBindConstants.videoMetaCacheKey(videoId);
+        FsUserCourseVideo cached = redisCache.getCacheObject(cacheKey);
+        if (cached != null) {
+            return cached;
+        }
+        FsUserCourseVideo video = fsUserCourseVideoMapper.selectFsUserCourseVideoByVideoId(videoId);
+        if (video != null) {
+            FsUserCourseVideo meta = new FsUserCourseVideo();
+            meta.setVideoId(video.getVideoId());
+            meta.setCourseId(video.getCourseId());
+            meta.setTitle(video.getTitle());
+            meta.setIsFirst(video.getIsFirst());
+            redisCache.setCacheObject(cacheKey, meta,
+                    CourseProjectSalesBindConstants.VIDEO_META_CACHE_HOURS, TimeUnit.HOURS);
+            return meta;
+        }
+        return null;
+    }
+
+    private Long parseQwUserId(String qwUserId) {
+        if (StringUtils.isEmpty(qwUserId)) {
+            return null;
+        }
+        try {
+            return Long.parseLong(qwUserId);
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+}

+ 10 - 0
fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java

@@ -46,6 +46,7 @@ import com.fs.course.param.newfs.UserCourseVideoPageParam;
 import com.fs.course.service.IFsCourseLinkService;
 import com.fs.course.service.IFsUserCompanyBindService;
 import com.fs.course.service.IFsUserCompanyUserService;
+import com.fs.course.service.ICourseProjectSalesBindService;
 import com.fs.course.service.IFsUserCourseVideoService;
 import com.fs.course.utils.H5WxUserWatchRedisUtil;
 import com.fs.course.param.newfs.*;
@@ -266,6 +267,9 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
     @Autowired
     private CourseRepeatByProjectMqProducer courseRepeatByProjectMqProducer;
 
+    @Autowired
+    private ICourseProjectSalesBindService courseProjectSalesBindService;
+
     @Autowired
     private SysDictDataMapper sysDictDataMapper;
 
@@ -820,6 +824,12 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
         String noRegisterMsg = "由于您还未完成注册,请联系伴学助手完成注册即可观看!";
         //非独属链接提示
         String noMemberMsg = "此链接已被绑定,请联系伴学助手领取您的专属链接,专属链接请勿分享哦!";
+
+        R bindCheck = courseProjectSalesBindService.checkAndBind(param);
+        if (bindCheck != null) {
+            return bindCheck;
+        }
+
         try {
             courseRepeatByProjectMqProducer.submitCheck(param);
         } catch (Exception e) {

+ 30 - 0
fs-service/src/main/java/com/fs/course/support/CourseProjectSalesBindConstants.java

@@ -0,0 +1,30 @@
+package com.fs.course.support;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 按项目看课销售绑定 Redis 缓存常量
+ */
+public final class CourseProjectSalesBindConstants {
+
+    /** 用户-项目绑定销售缓存:course:project:sales:bind:{userId}:{projectId} -> companyUserId */
+    public static final String BIND_CACHE_KEY_PREFIX = "course:project:sales:bind:";
+
+    /** 视频元数据缓存:course:project:sales:video:{videoId} */
+    public static final String VIDEO_META_CACHE_KEY_PREFIX = "course:project:sales:video:";
+
+    public static final int BIND_CACHE_DAYS = 7;
+
+    public static final int VIDEO_META_CACHE_HOURS = 1;
+
+    private CourseProjectSalesBindConstants() {
+    }
+
+    public static String bindCacheKey(Long userId, Long projectId) {
+        return BIND_CACHE_KEY_PREFIX + userId + ":" + projectId;
+    }
+
+    public static String videoMetaCacheKey(Long videoId) {
+        return VIDEO_META_CACHE_KEY_PREFIX + videoId;
+    }
+}

+ 1 - 0
fs-service/src/main/java/com/fs/enums/ExceptionCodeEnum.java

@@ -26,6 +26,7 @@ public enum ExceptionCodeEnum {
     WATCH_LATEST_COURSE(482, "请观看最新的课程项目"),
     EXCEED_COURSE_LIMIT(483, "超过项目看课数量限制"),
     ALREADY_WATCHED_OTHER_SALES_COURSE(484, "已看过其他销售分享的此课程,不能重复观看"),
+    USER_PROJECT_OTHER_SALES_BOUND(485, "您已在其他伴学助手处观看过该项目课程,请联系原助手继续学习"),
 
     // ============ 参数相关错误 (500-519) ============
     PARAM_ERROR(501, "参数错误!"),