Jelajahi Sumber

重粉查询2.0,记得更新中间表

xw 15 jam lalu
induk
melakukan
3325afb88e
21 mengubah file dengan 539 tambahan dan 106 penghapusan
  1. 34 0
      fs-service/src/main/java/com/fs/course/domain/FsUserProjectQw.java
  2. 38 0
      fs-service/src/main/java/com/fs/course/domain/FsUserProjectRepeat.java
  3. 6 0
      fs-service/src/main/java/com/fs/course/mapper/FsUserCompanyBindMapper.java
  4. 24 0
      fs-service/src/main/java/com/fs/course/mapper/FsUserProjectQwMapper.java
  5. 16 0
      fs-service/src/main/java/com/fs/course/mapper/FsUserProjectRepeatMapper.java
  6. 28 0
      fs-service/src/main/java/com/fs/course/service/IUserProjectRepeatMaintainService.java
  7. 31 66
      fs-service/src/main/java/com/fs/course/service/impl/CourseRepeatByProjectServiceImpl.java
  8. 46 38
      fs-service/src/main/java/com/fs/course/service/impl/FsUserCompanyBindServiceImpl.java
  9. 92 0
      fs-service/src/main/java/com/fs/course/service/impl/UserProjectRepeatMaintainServiceImpl.java
  10. 11 0
      fs-service/src/main/java/com/fs/course/support/CourseProjectEquivalence.java
  11. 2 0
      fs-service/src/main/java/com/fs/course/vo/CourseProgressResultVO.java
  12. 18 0
      fs-service/src/main/java/com/fs/course/vo/RepeatCourseHistoryVO.java
  13. 14 0
      fs-service/src/main/java/com/fs/course/vo/RepeatProjectResultVO.java
  14. 20 2
      fs-service/src/main/java/com/fs/qw/mapper/QwExternalContactMapper.java
  15. 16 0
      fs-service/src/main/java/com/fs/qw/param/QwExternalContactParam.java
  16. 8 0
      fs-service/src/main/java/com/fs/qw/param/UserWatchLogParam.java
  17. 13 0
      fs-service/src/main/java/com/fs/qw/service/impl/QwExternalContactServiceImpl.java
  18. 30 0
      fs-service/src/main/resources/db/20250610-用户项目重粉中间表.sql
  19. 29 0
      fs-service/src/main/resources/mapper/course/FsUserCompanyBindMapper.xml
  20. 43 0
      fs-service/src/main/resources/mapper/course/FsUserProjectQwMapper.xml
  21. 20 0
      fs-service/src/main/resources/mapper/course/FsUserProjectRepeatMapper.xml

+ 34 - 0
fs-service/src/main/java/com/fs/course/domain/FsUserProjectQw.java

@@ -0,0 +1,34 @@
+package com.fs.course.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 用户-项目-企微销售去重对象 fs_user_project_qw
+ */
+@Data
+@TableName("fs_user_project_qw")
+public class FsUserProjectQw implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long fsUserId;
+
+    private Long projectId;
+
+    private Long qwUserId;
+
+    private Long companyId;
+
+    private Date firstBindTime;
+
+    private Date updateTime;
+}

+ 38 - 0
fs-service/src/main/java/com/fs/course/domain/FsUserProjectRepeat.java

@@ -0,0 +1,38 @@
+package com.fs.course.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 用户-项目重粉标记对象 fs_user_project_repeat
+ */
+@Data
+@TableName("fs_user_project_repeat")
+public class FsUserProjectRepeat implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long fsUserId;
+
+    /** 等价项目组 canonical 项目 ID */
+    private Long projectId;
+
+    private Long companyId;
+
+    private Integer qwUserCount;
+
+    /** 是否重粉 0否 1是 */
+    private Integer isRepeat;
+
+    private Date createTime;
+
+    private Date updateTime;
+}

+ 6 - 0
fs-service/src/main/java/com/fs/course/mapper/FsUserCompanyBindMapper.java

@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.fs.course.domain.FsUserCompanyBind;
 import com.fs.course.vo.CourseProgressResultVO;
 import com.fs.course.vo.RelatedSalesResultVO;
+import com.fs.course.vo.RepeatProjectResultVO;
 import com.fs.course.vo.UserWatchLogListVo;
 import com.fs.qw.param.UserWatchLogParam;
 import org.apache.ibatis.annotations.Param;
@@ -81,4 +82,9 @@ public interface FsUserCompanyBindMapper extends BaseMapper<FsUserCompanyBind>{
      * 通过fsUserId查找FsCourseWatchLog记录,计算每门课程的进度
      */
     List<CourseProgressResultVO> getCourseProgressByFsUserId(@Param("fsUserId") Long fsUserId);
+
+    /**
+     * 查询重粉看课历史 - 客户企微发课看过的项目(send_type=2)
+     */
+    List<RepeatProjectResultVO> getUserWatchedProjectsByFsUserId(@Param("fsUserId") Long fsUserId);
 }

+ 24 - 0
fs-service/src/main/java/com/fs/course/mapper/FsUserProjectQwMapper.java

@@ -0,0 +1,24 @@
+package com.fs.course.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.course.domain.FsUserProjectQw;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+@Mapper
+public interface FsUserProjectQwMapper extends BaseMapper<FsUserProjectQw> {
+
+    int insertOrIgnore(@Param("fsUserId") Long fsUserId,
+                       @Param("projectId") Long projectId,
+                       @Param("qwUserId") Long qwUserId,
+                       @Param("companyId") Long companyId);
+
+    int countDistinctQwUser(@Param("fsUserId") Long fsUserId,
+                            @Param("equivalentProjectIds") List<Long> equivalentProjectIds);
+
+    int existsQwUserInProjects(@Param("fsUserId") Long fsUserId,
+                               @Param("qwUserId") Long qwUserId,
+                               @Param("equivalentProjectIds") List<Long> equivalentProjectIds);
+}

+ 16 - 0
fs-service/src/main/java/com/fs/course/mapper/FsUserProjectRepeatMapper.java

@@ -0,0 +1,16 @@
+package com.fs.course.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.course.domain.FsUserProjectRepeat;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+@Mapper
+public interface FsUserProjectRepeatMapper extends BaseMapper<FsUserProjectRepeat> {
+
+    int upsert(@Param("fsUserId") Long fsUserId,
+               @Param("projectId") Long projectId,
+               @Param("companyId") Long companyId,
+               @Param("qwUserCount") Integer qwUserCount,
+               @Param("isRepeat") Integer isRepeat);
+}

+ 28 - 0
fs-service/src/main/java/com/fs/course/service/IUserProjectRepeatMaintainService.java

@@ -0,0 +1,28 @@
+package com.fs.course.service;
+
+/**
+ * 维护用户-项目重粉中间表(fs_user_project_qw / fs_user_project_repeat)。
+ * 上线后看课/绑定时调用,列表按项目筛重粉走中间表索引查询。
+ */
+public interface IUserProjectRepeatMaintainService {
+
+    /**
+     * 记录用户在某项目下与某企微销售的绑定,并刷新等价项目组重粉标记。
+     */
+    void maintain(Long fsUserId, Long projectId, Long qwUserId, Long companyId);
+
+    /**
+     * 查询用户在某项目(含等价项目)下是否重粉,无中间表记录视为正常。
+     */
+    boolean isRepeatByProject(Long fsUserId, Long projectId);
+
+    /**
+     * 查询用户是否在任一等价项目组下为重粉。
+     */
+    boolean isRepeatInAnyProject(Long fsUserId);
+
+    /**
+     * 判断用户在某项目下绑定指定企微销售后是否会成为重粉(写入前判重,走中间表)。
+     */
+    boolean wouldBeRepeatAfterBind(Long fsUserId, Long projectId, Long qwUserId);
+}

+ 31 - 66
fs-service/src/main/java/com/fs/course/service/impl/CourseRepeatByProjectServiceImpl.java

@@ -3,7 +3,6 @@ package com.fs.course.service.impl;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.fs.common.utils.StringUtils;
 import com.fs.course.domain.*;
-import com.fs.course.mapper.FsCourseWatchLogMapper;
 import com.fs.course.mapper.FsUserCompanyQwMapper;
 import com.fs.course.mapper.FsUserCourseMapper;
 import com.fs.course.mapper.FsUserCourseVideoMapper;
@@ -12,6 +11,7 @@ import com.fs.course.service.ICourseRepeatByProjectService;
 import com.fs.course.support.CourseProjectEquivalence;
 import com.fs.course.service.IFsUserCompanyBindService;
 import com.fs.course.service.IFsUserCompanyUserService;
+import com.fs.course.service.IUserProjectRepeatMaintainService;
 import com.fs.his.domain.FsUser;
 import com.fs.his.mapper.FsUserMapper;
 import com.fs.qw.domain.QwExternalContact;
@@ -43,13 +43,13 @@ public class CourseRepeatByProjectServiceImpl implements ICourseRepeatByProjectS
     @Autowired
     private IFsUserCompanyBindService fsUserCompanyBindService;
     @Autowired
-    private FsCourseWatchLogMapper courseWatchLogMapper;
-    @Autowired
     private QwExternalContactMapper qwExternalContactMapper;
     @Autowired
     private IQwExternalContactService qwExternalContactService;
     @Autowired
     private FsUserMapper fsUserMapper;
+    @Autowired
+    private IUserProjectRepeatMaintainService userProjectRepeatMaintainService;
 
     @Override
     public void checkAndMarkRepeatByProject(FsUserCourseVideoAddKfUParam param, FsUser fsUser) {
@@ -59,7 +59,9 @@ public class CourseRepeatByProjectServiceImpl implements ICourseRepeatByProjectS
             return;
         }
         List<Long> equivalentProjectIds = CourseProjectEquivalence.equivalentProjectIds(projectId);
-        boolean projectRepeat = isRepeatWatchByProject(param, equivalentProjectIds);
+        Long currentQwUserId = parseQwUserId(param.getQwUserId());
+        boolean projectRepeat = userProjectRepeatMaintainService.wouldBeRepeatAfterBind(
+                param.getUserId(), projectId, currentQwUserId);
         boolean userAlreadyRepeat = fsUser.getIsRepeat() != null && fsUser.getIsRepeat() == 1;
         log.info("看课重粉判断(按项目):userId={}, projectId={}, equivalentProjectIds={}, projectRepeat={}, userAlreadyRepeat={}",
                 param.getUserId(), projectId, equivalentProjectIds, projectRepeat, userAlreadyRepeat);
@@ -91,68 +93,6 @@ public class CourseRepeatByProjectServiceImpl implements ICourseRepeatByProjectS
         return 0L;
     }
 
-    private boolean isRepeatWatchByProject(FsUserCourseVideoAddKfUParam param, List<Long> equivalentProjectIds) {
-        if (CollectionUtils.isEmpty(equivalentProjectIds)) {
-            return false;
-        }
-        Long userId = param.getUserId();
-        Long currentCompanyUserId = param.getCompanyUserId();
-        Long currentQwUserId = parseQwUserId(param.getQwUserId());
-
-        for (Long projectId : equivalentProjectIds) {
-            FsUserCompanyQw existBind = fsUserCompanyQwMapper.selectByUserAndProject(userId, projectId);
-            if (existBind != null) {
-                if (currentCompanyUserId != null && existBind.getCompanyUserId() != null
-                        && !existBind.getCompanyUserId().equals(currentCompanyUserId)) {
-                    return true;
-                }
-                if (currentQwUserId != null && existBind.getQwUserId() != null
-                        && !existBind.getQwUserId().equals(currentQwUserId)) {
-                    return true;
-                }
-            }
-        }
-
-        for (Long projectId : equivalentProjectIds) {
-            FsUserCompanyUser userCompanyUser = userCompanyUserService.selectByUserIdAndProjectId(userId, projectId);
-            if (userCompanyUser != null && currentCompanyUserId != null
-                    && userCompanyUser.getCompanyUserId() != null
-                    && !userCompanyUser.getCompanyUserId().equals(currentCompanyUserId)) {
-                return true;
-            }
-        }
-
-        List<FsUserCompanyBind> binds = fsUserCompanyBindService.list(
-                new QueryWrapper<FsUserCompanyBind>().eq("fs_user_id", userId).in("project_id", equivalentProjectIds));
-        if (CollectionUtils.isNotEmpty(binds)) {
-            if (currentQwUserId != null && binds.stream().map(FsUserCompanyBind::getQwUserId).filter(Objects::nonNull)
-                    .anyMatch(qwId -> !qwId.equals(currentQwUserId))) {
-                return true;
-            }
-            if (currentCompanyUserId != null && binds.stream().map(FsUserCompanyBind::getCompanyUserId).filter(Objects::nonNull)
-                    .anyMatch(id -> !id.equals(currentCompanyUserId))) {
-                return true;
-            }
-        }
-
-        QueryWrapper<FsCourseWatchLog> watchWrapper = new QueryWrapper<FsCourseWatchLog>()
-                .in("project", equivalentProjectIds)
-                .isNotNull("company_user_id");
-        watchWrapper.and(w -> {
-            w.eq("user_id", userId);
-            if (param.getQwExternalId() != null) {
-                w.or().eq("qw_external_contact_id", param.getQwExternalId());
-            }
-            return w;
-        });
-        List<FsCourseWatchLog> watchLogs = courseWatchLogMapper.selectList(watchWrapper);
-        if (CollectionUtils.isNotEmpty(watchLogs) && currentCompanyUserId != null) {
-            return watchLogs.stream().map(FsCourseWatchLog::getCompanyUserId).filter(Objects::nonNull)
-                    .anyMatch(id -> !id.equals(currentCompanyUserId));
-        }
-        return false;
-    }
-
     private Long parseQwUserId(String qwUserId) {
         if (StringUtils.isEmpty(qwUserId)) {
             return null;
@@ -165,6 +105,8 @@ public class CourseRepeatByProjectServiceImpl implements ICourseRepeatByProjectS
     }
 
     private void markRepeatFansByProject(FsUser fsUser, FsUserCourseVideoAddKfUParam param, List<Long> equivalentProjectIds) {
+        syncProjectRepeatTable(param, equivalentProjectIds);
+
         List<QwExternalContact> toUpdate = new ArrayList<>();
         if (param.getQwExternalId() != null) {
             QwExternalContact current = qwExternalContactMapper.selectById(param.getQwExternalId());
@@ -211,6 +153,26 @@ public class CourseRepeatByProjectServiceImpl implements ICourseRepeatByProjectS
         }
     }
 
+    /** 将本次及已有绑定销售同步写入中间表,保证 fs_user_project_repeat 与判重结果一致 */
+    private void syncProjectRepeatTable(FsUserCourseVideoAddKfUParam param, List<Long> equivalentProjectIds) {
+        Long currentQwUserId = parseQwUserId(param.getQwUserId());
+        Long projectId = equivalentProjectIds.isEmpty() ? null : equivalentProjectIds.get(0);
+        if (currentQwUserId != null && projectId != null) {
+            userProjectRepeatMaintainService.maintain(
+                    param.getUserId(), projectId, currentQwUserId, param.getCompanyId());
+        }
+        List<FsUserCompanyBind> binds = fsUserCompanyBindService.list(
+                new QueryWrapper<FsUserCompanyBind>().eq("fs_user_id", param.getUserId()).in("project_id", equivalentProjectIds));
+        if (CollectionUtils.isNotEmpty(binds)) {
+            for (FsUserCompanyBind bind : binds) {
+                if (bind.getQwUserId() != null && bind.getProjectId() != null) {
+                    userProjectRepeatMaintainService.maintain(
+                            bind.getFsUserId(), bind.getProjectId(), bind.getQwUserId(), bind.getCompanyId());
+                }
+            }
+        }
+    }
+
     private void recordUserProjectBind(FsUserCourseVideoAddKfUParam param, Long qwExternalId, String sceneName) {
         try {
             FsUserCourseVideo video = fsUserCourseVideoMapper.selectFsUserCourseVideoByVideoId(param.getVideoId());
@@ -239,6 +201,9 @@ public class CourseRepeatByProjectServiceImpl implements ICourseRepeatByProjectS
                     param.getVideoId()
             );
 
+            userProjectRepeatMaintainService.maintain(
+                    param.getUserId(), projectId, qwUserId, param.getCompanyId());
+
             log.info("【{}-记录绑定关系成功】用户ID: {}, 项目ID: {}, 销售ID: {}, 外部联系人id: {}",
                     sceneName, param.getUserId(), projectId, qwUserId, qwExternalId);
 

+ 46 - 38
fs-service/src/main/java/com/fs/course/service/impl/FsUserCompanyBindServiceImpl.java

@@ -3,6 +3,7 @@ package com.fs.course.service.impl;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.fs.common.utils.DateUtils;
+import com.fs.common.utils.DictUtils;
 import com.fs.common.utils.PubFun;
 import com.fs.company.domain.Company;
 import com.fs.company.domain.CompanyUser;
@@ -15,10 +16,12 @@ import com.fs.course.mapper.FsCourseWatchLogMapper;
 import com.fs.course.mapper.FsUserCompanyBindMapper;
 import com.fs.course.mapper.FsUserCourseMapper;
 import com.fs.course.service.IFsUserCompanyBindService;
+import com.fs.course.service.IUserProjectRepeatMaintainService;
 import com.fs.course.support.CourseProjectEquivalence;
 import com.fs.course.vo.CourseProgressResultVO;
 import com.fs.course.vo.RelatedSalesResultVO;
 import com.fs.course.vo.RepeatCourseHistoryVO;
+import com.fs.course.vo.RepeatProjectResultVO;
 import com.fs.course.vo.UserWatchLogListVo;
 import com.fs.his.mapper.FsUserMapper;
 import com.fs.qw.domain.QwCompany;
@@ -37,12 +40,9 @@ import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Date;
 import java.util.HashMap;
-import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
-import java.util.Objects;
-import java.util.Set;
-import java.util.stream.Collectors;
+import java.util.TreeSet;
 
 /**
  * 用户客服关联Service业务层处理
@@ -56,6 +56,7 @@ import java.util.stream.Collectors;
 public class FsUserCompanyBindServiceImpl extends ServiceImpl<FsUserCompanyBindMapper, FsUserCompanyBind> implements IFsUserCompanyBindService {
 
     private final CompanyUserMapper companyUserMapper;
+    private final IUserProjectRepeatMaintainService userProjectRepeatMaintainService;
     private FsUserMapper fsUserMapper;
     private FsCourseWatchLogMapper fsCourseWatchLogMapper;
     private QwExternalContactMapper qwExternalContactMapper;
@@ -166,7 +167,7 @@ public class FsUserCompanyBindServiceImpl extends ServiceImpl<FsUserCompanyBindM
                 }
             }
             if (qwExternalContact.getUserRepeat() == 0 && project != 0
-                    && isRepeatWatchByProject(fsUserId, project, qwUserId)) {
+                    && userProjectRepeatMaintainService.isRepeatByProject(fsUserId, project)) {
                 qwExternalContact.setUserRepeat(1);
                 qwExternalContactMapper.updateById(qwExternalContact);
             }
@@ -198,6 +199,7 @@ public class FsUserCompanyBindServiceImpl extends ServiceImpl<FsUserCompanyBindM
                 }
                 try {
                     save(bind);
+                    userProjectRepeatMaintainService.maintain(fsUserId, project, qwUserId, companyId);
                 } catch (Exception e) {
                     log.error("添加重粉失败", e);
                 }
@@ -239,6 +241,7 @@ public class FsUserCompanyBindServiceImpl extends ServiceImpl<FsUserCompanyBindM
             one.setUpdateTime(new Date());
             try {
                 save(one);
+                userProjectRepeatMaintainService.maintain(fsUserId, course.getProject(), qwUserId, one.getCompanyId());
             } catch (Exception e) {
                 log.error("添加重粉失败", e);
             }
@@ -254,6 +257,10 @@ public class FsUserCompanyBindServiceImpl extends ServiceImpl<FsUserCompanyBindM
         if(param.getExternalUserId() == null &&  param.getFsUserId() == null){
             return Collections.emptyList();
         }
+        if (param.getProjectId() != null && param.getProjectId() != 0) {
+            param.setEquivalentProjectIds(
+                    CourseProjectEquivalence.equivalentProjectIds(param.getProjectId().longValue()));
+        }
         List<UserWatchLogListVo> list = baseMapper.getWatchLogList(param);
         // 权限判断:如果传了companyId,对无权限的主体脱敏
         if (param.getCompanyId() != null && list != null) {
@@ -289,7 +296,8 @@ public class FsUserCompanyBindServiceImpl extends ServiceImpl<FsUserCompanyBindM
             vo.setAvatar(contact.getAvatar());
             vo.setRemark(contact.getRemark());
         }
-        vo.setUserRepeat(isUserRepeatByProject(fsUserId) ? 1 : 0);
+        vo.setUserRepeat(userProjectRepeatMaintainService.isRepeatInAnyProject(fsUserId) ? 1 : 0);
+        vo.setProjectList(buildUserProjectList(fsUserId));
 
         // 2. 查询关联销售(单条SQL + GROUP BY 优化)
         List<RelatedSalesResultVO> salesResults = baseMapper.getRelatedSalesByFsUserId(fsUserId);
@@ -318,6 +326,7 @@ public class FsUserCompanyBindServiceImpl extends ServiceImpl<FsUserCompanyBindM
             RepeatCourseHistoryVO.CourseProgressVO cp = new RepeatCourseHistoryVO.CourseProgressVO();
             cp.setCourseId(c.getCourseId());
             cp.setCourseName(c.getCourseName());
+            cp.setProjectId(c.getProjectId());
             cp.setTotalCount(c.getTotalCount() != null ? c.getTotalCount() : 0);
             cp.setWatchedCount(c.getWatchedCount() != null ? c.getWatchedCount() : 0);
             // 计算百分比
@@ -367,41 +376,40 @@ public class FsUserCompanyBindServiceImpl extends ServiceImpl<FsUserCompanyBindM
     }
 
     /**
-     * 按项目判重:同一项目(含等价项目)下存在至少两个不同企微销售则视为看课重粉
+     * 构建客户企微发课看过的项目列表:等价项目(如百膳食养/安康食养)分项展示,重粉项目标记 isRepeat=1
      */
-    private boolean isUserRepeatByProject(Long fsUserId) {
-        List<FsUserCompanyBind> binds = baseMapper.selectList(
-                new QueryWrapper<FsUserCompanyBind>()
-                        .eq("fs_user_id", fsUserId)
-                        .isNotNull("project_id")
-                        .ne("project_id", 0)
-                        .isNotNull("qw_user_id"));
-        if (binds.isEmpty()) {
-            return false;
+    private List<RepeatCourseHistoryVO.RepeatProjectVO> buildUserProjectList(Long fsUserId) {
+        List<RepeatProjectResultVO> watched = baseMapper.getUserWatchedProjectsByFsUserId(fsUserId);
+        Map<Long, String> nameMap = new HashMap<>();
+        TreeSet<Long> projectIds = new TreeSet<>();
+        if (watched != null) {
+            for (RepeatProjectResultVO item : watched) {
+                if (item.getProjectId() == null) {
+                    continue;
+                }
+                projectIds.add(item.getProjectId());
+                if (item.getProjectName() != null) {
+                    nameMap.put(item.getProjectId(), item.getProjectName());
+                }
+            }
         }
-        Map<String, Set<Long>> groupToQwUsers = new HashMap<>();
-        for (FsUserCompanyBind bind : binds) {
-            String groupKey = CourseProjectEquivalence.equivalentProjectIds(bind.getProjectId())
-                    .stream().sorted().map(String::valueOf).collect(Collectors.joining(","));
-            groupToQwUsers.computeIfAbsent(groupKey, k -> new HashSet<>()).add(bind.getQwUserId());
+        // 等价项目组 1/28:只要看过其中任一,两个项目名都展示
+        if (projectIds.contains(1L) || projectIds.contains(28L)) {
+            projectIds.add(1L);
+            projectIds.add(28L);
         }
-        return groupToQwUsers.values().stream().anyMatch(qwUserIds -> qwUserIds.size() >= 2);
-    }
-
-    private boolean isRepeatWatchByProject(Long fsUserId, Long projectId, Long currentQwUserId) {
-        List<Long> equivalentProjectIds = CourseProjectEquivalence.equivalentProjectIds(projectId);
-        List<FsUserCompanyBind> binds = baseMapper.selectList(
-                new QueryWrapper<FsUserCompanyBind>()
-                        .eq("fs_user_id", fsUserId)
-                        .in("project_id", equivalentProjectIds)
-                        .isNotNull("qw_user_id"));
-        Set<Long> qwUserIds = binds.stream()
-                .map(FsUserCompanyBind::getQwUserId)
-                .filter(Objects::nonNull)
-                .collect(Collectors.toSet());
-        if (currentQwUserId != null) {
-            qwUserIds.add(currentQwUserId);
+        List<RepeatCourseHistoryVO.RepeatProjectVO> list = new ArrayList<>();
+        for (Long projectId : projectIds) {
+            RepeatCourseHistoryVO.RepeatProjectVO vo = new RepeatCourseHistoryVO.RepeatProjectVO();
+            vo.setProjectId(projectId);
+            String projectName = nameMap.get(projectId);
+            if (projectName == null) {
+                projectName = DictUtils.getDictLabel("sys_course_project", String.valueOf(projectId));
+            }
+            vo.setProjectName(projectName);
+            vo.setIsRepeat(userProjectRepeatMaintainService.isRepeatByProject(fsUserId, projectId) ? 1 : 0);
+            list.add(vo);
         }
-        return qwUserIds.size() >= 2;
+        return list;
     }
 }

+ 92 - 0
fs-service/src/main/java/com/fs/course/service/impl/UserProjectRepeatMaintainServiceImpl.java

@@ -0,0 +1,92 @@
+package com.fs.course.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.fs.course.domain.FsUserProjectRepeat;
+import com.fs.course.mapper.FsUserProjectQwMapper;
+import com.fs.course.mapper.FsUserProjectRepeatMapper;
+import com.fs.course.service.IUserProjectRepeatMaintainService;
+import com.fs.course.support.CourseProjectEquivalence;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+@Slf4j
+@Service
+public class UserProjectRepeatMaintainServiceImpl implements IUserProjectRepeatMaintainService {
+
+    @Autowired
+    private FsUserProjectQwMapper fsUserProjectQwMapper;
+
+    @Autowired
+    private FsUserProjectRepeatMapper fsUserProjectRepeatMapper;
+
+    @Override
+    public void maintain(Long fsUserId, Long projectId, Long qwUserId, Long companyId) {
+        if (fsUserId == null || projectId == null || projectId == 0L || qwUserId == null) {
+            return;
+        }
+        try {
+            fsUserProjectQwMapper.insertOrIgnore(fsUserId, projectId, qwUserId, companyId);
+
+            List<Long> equivalentProjectIds = CourseProjectEquivalence.equivalentProjectIds(projectId);
+            Long canonicalProjectId = CourseProjectEquivalence.canonicalProjectId(projectId);
+            int qwUserCount = fsUserProjectQwMapper.countDistinctQwUser(fsUserId, equivalentProjectIds);
+            int isRepeat = qwUserCount >= 2 ? 1 : 0;
+
+            fsUserProjectRepeatMapper.upsert(fsUserId, canonicalProjectId, companyId, qwUserCount, isRepeat);
+        } catch (Exception e) {
+            log.error("维护项目重粉中间表失败 fsUserId={}, projectId={}, qwUserId={}",
+                    fsUserId, projectId, qwUserId, e);
+        }
+    }
+
+    @Override
+    public boolean isRepeatByProject(Long fsUserId, Long projectId) {
+        if (fsUserId == null || projectId == null || projectId == 0L) {
+            return false;
+        }
+        Long canonicalProjectId = CourseProjectEquivalence.canonicalProjectId(projectId);
+        FsUserProjectRepeat record = fsUserProjectRepeatMapper.selectOne(
+                new QueryWrapper<FsUserProjectRepeat>()
+                        .eq("fs_user_id", fsUserId)
+                        .eq("project_id", canonicalProjectId)
+                        .last("LIMIT 1"));
+        return record != null && record.getIsRepeat() != null && record.getIsRepeat() == 1;
+    }
+
+    @Override
+    public boolean isRepeatInAnyProject(Long fsUserId) {
+        if (fsUserId == null) {
+            return false;
+        }
+        Integer count = fsUserProjectRepeatMapper.selectCount(
+                new QueryWrapper<FsUserProjectRepeat>()
+                        .eq("fs_user_id", fsUserId)
+                        .eq("is_repeat", 1));
+        return count != null && count > 0;
+    }
+
+    @Override
+    public boolean wouldBeRepeatAfterBind(Long fsUserId, Long projectId, Long qwUserId) {
+        if (fsUserId == null || projectId == null || projectId == 0L) {
+            return false;
+        }
+        if (isRepeatByProject(fsUserId, projectId)) {
+            return true;
+        }
+        if (qwUserId == null) {
+            return false;
+        }
+        List<Long> equivalentProjectIds = CourseProjectEquivalence.equivalentProjectIds(projectId);
+        int distinctCount = fsUserProjectQwMapper.countDistinctQwUser(fsUserId, equivalentProjectIds);
+        if (distinctCount >= 2) {
+            return true;
+        }
+        if (distinctCount == 0) {
+            return false;
+        }
+        return fsUserProjectQwMapper.existsQwUserInProjects(fsUserId, qwUserId, equivalentProjectIds) == 0;
+    }
+}

+ 11 - 0
fs-service/src/main/java/com/fs/course/support/CourseProjectEquivalence.java

@@ -31,4 +31,15 @@ public final class CourseProjectEquivalence {
     public static boolean isInProjectGroup1And28(Long projectId) {
         return projectId != null && (projectId.equals(1L) || projectId.equals(28L));
     }
+
+    /**
+     * 等价项目组的 canonical ID(取最小组内 ID,用于 fs_user_project_repeat 唯一键)。
+     */
+    public static Long canonicalProjectId(Long projectId) {
+        List<Long> ids = equivalentProjectIds(projectId);
+        if (ids.isEmpty()) {
+            return projectId;
+        }
+        return ids.stream().min(Long::compareTo).orElse(projectId);
+    }
 }

+ 2 - 0
fs-service/src/main/java/com/fs/course/vo/CourseProgressResultVO.java

@@ -14,6 +14,8 @@ public class CourseProgressResultVO {
     private Long courseId;
     /** 课程名称 */
     private String courseName;
+    /** 课程所属项目ID */
+    private Integer projectId;
     /** 课程总节数 */
     private Integer totalCount;
     /** 已完课节数 */

+ 18 - 0
fs-service/src/main/java/com/fs/course/vo/RepeatCourseHistoryVO.java

@@ -21,12 +21,28 @@ public class RepeatCourseHistoryVO {
     /** 重粉状态 0正常 1重粉 */
     private Integer userRepeat;
 
+    /** 客户企微发课看过的项目列表(send_type=2;等价项目如百膳食养/安康食养分别展示,重粉项目 isRepeat=1) */
+    private List<RepeatProjectVO> projectList;
+
     /** 关联销售列表 */
     private List<RelatedSalesVO> salesList;
 
     /** 课程学习进度列表 */
     private List<CourseProgressVO> courseList;
 
+    /**
+     * 客户企微发课看过的项目(含等价项目分项展示)
+     */
+    @Data
+    public static class RepeatProjectVO {
+        /** 项目ID */
+        private Long projectId;
+        /** 项目名称 */
+        private String projectName;
+        /** 是否该项目下重粉 0否 1是 */
+        private Integer isRepeat;
+    }
+
     /**
      * 关联销售信息
      */
@@ -54,6 +70,8 @@ public class RepeatCourseHistoryVO {
         private Long courseId;
         /** 课程名称 */
         private String courseName;
+        /** 课程所属项目ID */
+        private Integer projectId;
         /** 课程总节数 */
         private Integer totalCount;
         /** 已学习节数(有看课记录的小节数) */

+ 14 - 0
fs-service/src/main/java/com/fs/course/vo/RepeatProjectResultVO.java

@@ -0,0 +1,14 @@
+package com.fs.course.vo;
+
+import lombok.Data;
+
+/**
+ * 重粉看课历史 - 客户看过的项目查询结果VO(Mapper用)
+ */
+@Data
+public class RepeatProjectResultVO {
+    /** 项目ID */
+    private Long projectId;
+    /** 项目名称 */
+    private String projectName;
+}

+ 20 - 2
fs-service/src/main/java/com/fs/qw/mapper/QwExternalContactMapper.java

@@ -287,7 +287,16 @@ public interface QwExternalContactMapper extends BaseMapper<QwExternalContact> {
             "   </foreach> " +
             "</if >\n" +
             "            <if test=\"stageStatus != null \"> and ec.stage_status = #{stageStatus}</if>\n" +
-            "            <if test=\"userRepeat != null \"> and ec.user_repeat = #{userRepeat}</if>\n" +
+            "            <if test=\"projectId != null and userRepeat != null and canonicalProjectId != null\">\n" +
+            "                  and ec.fs_user_id is not null\n" +
+            "                  and exists (\n" +
+            "                    select 1 from fs_user_project_repeat pr\n" +
+            "                    where pr.fs_user_id = ec.fs_user_id\n" +
+            "                      and pr.project_id = #{canonicalProjectId}\n" +
+            "                      and pr.is_repeat = #{userRepeat}\n" +
+            "                  )\n" +
+            "            </if>\n" +
+            "            <if test=\"projectId == null and userRepeat != null \"> and ec.user_repeat = #{userRepeat}</if>\n" +
             "            <if test=\"transferStatus != null \"> and ec.transfer_status = #{transferStatus}</if>\n" +
             "            <if test=\"qwUserId != null \"> and ec.qw_user_id = #{qwUserId}</if>\n" +
             "            <if test=\"level != null \"> and ec.level = #{level}</if>\n" +
@@ -352,7 +361,16 @@ public interface QwExternalContactMapper extends BaseMapper<QwExternalContact> {
             "            <if test=\"customerId != null \"> and ec.customer_id = #{customerId}</if>\n" +
             "            <if test=\"status != null \"> and ec.status = #{status}</if>\n" +
             "            <if test=\"stageStatus != null \"> and ec.stage_status = #{stageStatus}</if>\n" +
-            "            <if test=\"userRepeat != null \"> and ec.user_repeat = #{userRepeat}</if>\n" +
+            "            <if test=\"projectId != null and userRepeat != null and canonicalProjectId != null\">\n" +
+            "                  and ec.fs_user_id is not null\n" +
+            "                  and exists (\n" +
+            "                    select 1 from fs_user_project_repeat pr\n" +
+            "                    where pr.fs_user_id = ec.fs_user_id\n" +
+            "                      and pr.project_id = #{canonicalProjectId}\n" +
+            "                      and pr.is_repeat = #{userRepeat}\n" +
+            "                  )\n" +
+            "            </if>\n" +
+            "            <if test=\"projectId == null and userRepeat != null \"> and ec.user_repeat = #{userRepeat}</if>\n" +
             "            <if test=\"transferStatus != null \"> and ec.transfer_status = #{transferStatus}</if>\n" +
             "            <if test=\"qwUserId != null \"> and ec.qw_user_id = #{qwUserId}</if>\n" +
             "            <if test=\"level != null \"> and ec.level = #{level}</if>\n" +

+ 16 - 0
fs-service/src/main/java/com/fs/qw/param/QwExternalContactParam.java

@@ -51,6 +51,22 @@ public class QwExternalContactParam {
     private Integer userRepeat;
     private Integer isRepeat;
 
+    /**
+     * 课程项目ID(按项目筛选重粉时使用)
+     */
+    private Long projectId;
+
+    /**
+     * 等价项目ID列表(由 projectId 推导,供 Mapper 按项目判重)
+     */
+    private List<Long> equivalentProjectIds;
+
+    /**
+     * 等价项目组 canonical 项目 ID(供 fs_user_project_repeat 查询)
+     */
+    @TableField(exist = false)
+    private Long canonicalProjectId;
+
 
     private Long customerId;
 

+ 8 - 0
fs-service/src/main/java/com/fs/qw/param/UserWatchLogParam.java

@@ -2,6 +2,8 @@ package com.fs.qw.param;
 
 import lombok.Data;
 
+import java.util.List;
+
 @Data
 public class UserWatchLogParam {
 
@@ -13,4 +15,10 @@ public class UserWatchLogParam {
     /** 当前登录用户的运营公司ID(权限判断用) */
     private Long companyId;
 
+    /** 课程项目ID(可选,传则只查该项目看课明细;1与28等价合并) */
+    private Integer projectId;
+
+    /** 等价项目ID列表(由 projectId 推导,供 Mapper 过滤) */
+    private List<Long> equivalentProjectIds;
+
 }

+ 13 - 0
fs-service/src/main/java/com/fs/qw/service/impl/QwExternalContactServiceImpl.java

@@ -876,14 +876,26 @@ public class QwExternalContactServiceImpl extends ServiceImpl<QwExternalContactM
 
     @Override
     public List<QwExternalContactVO> selectQwExternalContactListVO(QwExternalContactParam qwExternalContact) {
+        prepareProjectRepeatFilter(qwExternalContact);
         return qwExternalContactMapper.selectQwExternalContactListVO(qwExternalContact);
     }
 
     @Override
     public List<QwExternalContactUnionIdExportVO> selectQwExternalContactUnionIdExportVO(QwExternalContactParam qwExternalContact) {
+        prepareProjectRepeatFilter(qwExternalContact);
         return qwExternalContactMapper.selectQwExternalContactUnionIdExportVO(qwExternalContact);
     }
 
+    /**
+     * 按项目筛选重粉时,将 projectId 展开为等价项目 ID 列表。
+     */
+    private void prepareProjectRepeatFilter(QwExternalContactParam param) {
+        if (param.getProjectId() != null && param.getProjectId() != 0L) {
+            param.setEquivalentProjectIds(com.fs.course.support.CourseProjectEquivalence.equivalentProjectIds(param.getProjectId()));
+            param.setCanonicalProjectId(com.fs.course.support.CourseProjectEquivalence.canonicalProjectId(param.getProjectId()));
+        }
+    }
+
     @Override
     public List<QwExternalContactVOTime> selectQwExternalContactListVOByIds(List<Long> ids) {
         return qwExternalContactMapper.selectQwExternalContactListVOByIds(ids);
@@ -6113,6 +6125,7 @@ public class QwExternalContactServiceImpl extends ServiceImpl<QwExternalContactM
 
     @Override
     public List<QwExternalContactVO> selectQwExternalContactListVONewSys(QwExternalContactParam qwExternalContact) {
+        prepareProjectRepeatFilter(qwExternalContact);
         return qwExternalContactMapper.selectQwExternalContactListVONewSys(qwExternalContact);
     }
 

+ 30 - 0
fs-service/src/main/resources/db/20250610-用户项目重粉中间表.sql

@@ -0,0 +1,30 @@
+-- 用户项目重粉中间表:上线后看课/绑定时写入,列表按项目筛重粉走索引查询,不扫 fs_user_company_bind 流水
+
+CREATE TABLE IF NOT EXISTS `fs_user_project_qw` (
+    `id`              BIGINT       NOT NULL AUTO_INCREMENT COMMENT '主键',
+    `fs_user_id`      BIGINT       NOT NULL COMMENT '小程序用户ID',
+    `project_id`      BIGINT       NOT NULL COMMENT '课程项目ID(原始)',
+    `qw_user_id`      BIGINT       NOT NULL COMMENT '企微销售ID',
+    `company_id`      BIGINT       DEFAULT NULL COMMENT '公司ID',
+    `first_bind_time` DATETIME     DEFAULT CURRENT_TIMESTAMP COMMENT '首次绑定时间',
+    `update_time`     DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`) USING BTREE,
+    UNIQUE KEY `uk_user_project_qw` (`fs_user_id`, `project_id`, `qw_user_id`),
+    KEY `idx_user_project` (`fs_user_id`, `project_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
+  COMMENT='用户-项目-企微销售去重表(重粉判重维度)';
+
+CREATE TABLE IF NOT EXISTS `fs_user_project_repeat` (
+    `id`             BIGINT   NOT NULL AUTO_INCREMENT COMMENT '主键',
+    `fs_user_id`     BIGINT   NOT NULL COMMENT '小程序用户ID',
+    `project_id`     BIGINT   NOT NULL COMMENT '等价项目组 canonical 项目ID',
+    `company_id`     BIGINT   DEFAULT NULL COMMENT '最近写入时的公司ID',
+    `qw_user_count`  INT      NOT NULL DEFAULT 1 COMMENT '等价项目组内不同企微销售数',
+    `is_repeat`      TINYINT  NOT NULL DEFAULT 0 COMMENT '是否重粉 0否 1是',
+    `create_time`    DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `update_time`    DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`) USING BTREE,
+    UNIQUE KEY `uk_user_project` (`fs_user_id`, `project_id`),
+    KEY `idx_project_repeat` (`project_id`, `is_repeat`, `fs_user_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
+  COMMENT='用户-项目重粉标记表(列表筛选)';

+ 29 - 0
fs-service/src/main/resources/mapper/course/FsUserCompanyBindMapper.xml

@@ -178,6 +178,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="videoId != null">
                 and a.video_id = #{videoId}
             </if>
+            <if test="equivalentProjectIds != null and equivalentProjectIds.size() > 0">
+                and a.project_id in
+                <foreach collection="equivalentProjectIds" item="pid" open="(" separator="," close=")">
+                    #{pid}
+                </foreach>
+            </if>
         </where>
         order by create_time desc
     </select>
@@ -211,6 +217,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         SELECT
             uc.course_id AS courseId,
             uc.course_name AS courseName,
+            uc.project AS projectId,
             COALESCE(vt.total_count, 0) AS totalCount,
             COALESCE(ws.watched_count, 0) AS watchedCount,
             latest.video_name AS latestSection,
@@ -222,6 +229,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             SELECT DISTINCT course_id
             FROM fs_course_watch_log
             WHERE user_id = #{fsUserId}
+              AND send_type = 2
         ) watched
         INNER JOIN fs_user_course uc ON uc.course_id = watched.course_id
         LEFT JOIN (
@@ -234,6 +242,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             SELECT course_id, COUNT(DISTINCT video_id) AS watched_count
             FROM fs_course_watch_log
             WHERE user_id = #{fsUserId}
+              AND send_type = 2
             GROUP BY course_id
         ) ws ON ws.course_id = watched.course_id
         LEFT JOIN (
@@ -253,13 +262,33 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
                 SELECT course_id, MAX(create_time) AS max_time
                 FROM fs_course_watch_log
                 WHERE user_id = #{fsUserId}
+                  AND send_type = 2
                 GROUP BY course_id
             ) lt ON lt.course_id = wl.course_id AND lt.max_time = wl.create_time
             LEFT JOIN qw_user qu ON qu.id = wl.qw_user_id
             LEFT JOIN qw_company qc ON qc.corp_id = qu.corp_id
             WHERE wl.user_id = #{fsUserId}
+              AND wl.send_type = 2
             GROUP BY wl.course_id
         ) latest ON latest.course_id = watched.course_id
         ORDER BY uc.course_id DESC
     </select>
+
+    <!-- 重粉看课历史 - 客户企微发课看过的项目(与课程进度口径一致:send_type=2,项目取自课程表) -->
+    <select id="getUserWatchedProjectsByFsUserId" resultType="com.fs.course.vo.RepeatProjectResultVO">
+        SELECT
+            p.project_id AS projectId,
+            c.dict_label AS projectName
+        FROM (
+            SELECT DISTINCT uc.project AS project_id
+            FROM fs_course_watch_log wl
+            INNER JOIN fs_user_course uc ON uc.course_id = wl.course_id
+            WHERE wl.user_id = #{fsUserId}
+              AND wl.send_type = 2
+              AND uc.project IS NOT NULL
+              AND uc.project != 0
+        ) p
+        LEFT JOIN sys_dict_data c ON c.dict_type = 'sys_course_project' AND c.dict_value = p.project_id
+        ORDER BY p.project_id
+    </select>
 </mapper>

+ 43 - 0
fs-service/src/main/resources/mapper/course/FsUserProjectQwMapper.xml

@@ -0,0 +1,43 @@
+<?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.course.mapper.FsUserProjectQwMapper">
+
+    <insert id="insertOrIgnore">
+        INSERT INTO fs_user_project_qw (
+            fs_user_id, project_id, qw_user_id, company_id, first_bind_time, update_time
+        ) VALUES (
+            #{fsUserId}, #{projectId}, #{qwUserId}, #{companyId}, NOW(), NOW()
+        )
+        ON DUPLICATE KEY UPDATE update_time = NOW()
+    </insert>
+
+    <select id="countDistinctQwUser" resultType="int">
+        SELECT COUNT(DISTINCT qw_user_id)
+        FROM fs_user_project_qw
+        WHERE fs_user_id = #{fsUserId}
+          AND qw_user_id IS NOT NULL
+        <if test="equivalentProjectIds != null and equivalentProjectIds.size() > 0">
+          AND project_id IN
+          <foreach collection="equivalentProjectIds" item="pid" open="(" separator="," close=")">
+              #{pid}
+          </foreach>
+        </if>
+    </select>
+
+    <select id="existsQwUserInProjects" resultType="int">
+        SELECT COUNT(1)
+        FROM fs_user_project_qw
+        WHERE fs_user_id = #{fsUserId}
+          AND qw_user_id = #{qwUserId}
+        <if test="equivalentProjectIds != null and equivalentProjectIds.size() > 0">
+          AND project_id IN
+          <foreach collection="equivalentProjectIds" item="pid" open="(" separator="," close=")">
+              #{pid}
+          </foreach>
+        </if>
+        LIMIT 1
+    </select>
+
+</mapper>

+ 20 - 0
fs-service/src/main/resources/mapper/course/FsUserProjectRepeatMapper.xml

@@ -0,0 +1,20 @@
+<?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.course.mapper.FsUserProjectRepeatMapper">
+
+    <insert id="upsert">
+        INSERT INTO fs_user_project_repeat (
+            fs_user_id, project_id, company_id, qw_user_count, is_repeat, create_time, update_time
+        ) VALUES (
+            #{fsUserId}, #{projectId}, #{companyId}, #{qwUserCount}, #{isRepeat}, NOW(), NOW()
+        )
+        ON DUPLICATE KEY UPDATE
+            qw_user_count = VALUES(qw_user_count),
+            is_repeat = VALUES(is_repeat),
+            company_id = VALUES(company_id),
+            update_time = NOW()
+    </insert>
+
+</mapper>