吴树波 1 日 前
コミット
0baa5ad328

+ 3 - 3
fs-admin/src/main/java/com/fs/FSApplication.java

@@ -12,11 +12,11 @@ import org.springframework.transaction.annotation.Transactional;
  */
 @SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
 @Transactional
-@EnableAsync
-@EnableScheduling
+//@EnableAsync
+//@EnableScheduling
 public class FSApplication {
     public static void main(String[] args) {
-        // System.setProperty("spring.devtools.restart.enabled", "false");
+         System.setProperty("spring.devtools.restart.enabled", "false");
         SpringApplication.run(FSApplication.class, args);
         System.out.println("admin启动成功");
     }

+ 73 - 0
fs-admin/src/main/java/com/fs/course/controller/FsCourseWatchLogController.java

@@ -44,9 +44,14 @@ import com.fs.common.core.controller.BaseController;
 import com.fs.common.core.domain.AjaxResult;
 import com.fs.common.enums.BusinessType;
 import com.fs.course.domain.FsCourseWatchLog;
+import com.fs.course.domain.FsUserCourse;
+import com.fs.course.domain.FsUserCourseVideo;
 import com.fs.course.service.IFsCourseWatchLogService;
+import com.fs.course.service.IFsUserCourseService;
+import com.fs.course.service.IFsUserCourseVideoService;
 import com.fs.common.utils.poi.ExcelUtil;
 import com.fs.common.core.page.TableDataInfo;
+import org.springframework.web.bind.annotation.RequestParam;
 
 /**
  * 短链课程看课记录Controller
@@ -65,6 +70,10 @@ public class FsCourseWatchLogController extends BaseController
     private IQwWatchLogService qwWatchLogService;
     @Autowired
     private IStatisticsTableService iStatisticsTableService;
+    @Autowired
+    private IFsUserCourseService fsUserCourseService;
+    @Autowired
+    private IFsUserCourseVideoService fsUserCourseVideoService;
     /**
      * 查询短链课程看课记录列表
      */
@@ -294,4 +303,68 @@ public class FsCourseWatchLogController extends BaseController
         ExcelUtil<FsCoureseWatchLogVO> util = new ExcelUtil<FsCoureseWatchLogVO>(FsCoureseWatchLogVO.class);
         return util.exportExcel(list, "公司观看人数统计数据");
     }
+
+    /**
+     * 按项目查询课程下拉(完播统计)
+     */
+    @GetMapping("/listCoursesByProject")
+    public AjaxResult listCoursesByProject(@RequestParam("project") Long project) {
+        if (project == null) {
+            return AjaxResult.error("请先选择项目");
+        }
+        FsUserCourse query = new FsUserCourse();
+        query.setProject(project);
+        List<FsUserCourse> courses = fsUserCourseService.selectFsUserCourseList(query);
+        List<FsUserCourse> result = new ArrayList<>();
+        if (courses != null) {
+            for (FsUserCourse course : courses) {
+                if (course.getIsDel() == null || course.getIsDel() == 0) {
+                    result.add(course);
+                }
+            }
+            result.sort((a, b) -> {
+                Long sa = a.getSort() == null ? Long.MAX_VALUE : a.getSort();
+                Long sb = b.getSort() == null ? Long.MAX_VALUE : b.getSort();
+                int cmp = sa.compareTo(sb);
+                if (cmp != 0) {
+                    return cmp;
+                }
+                Long ida = a.getCourseId() == null ? 0L : a.getCourseId();
+                Long idb = b.getCourseId() == null ? 0L : b.getCourseId();
+                return ida.compareTo(idb);
+            });
+        }
+        return AjaxResult.success(result);
+    }
+
+    /**
+     * 按课程查询小节下拉(完播统计小节区间)
+     */
+    @GetMapping("/listVideosByCourse")
+    public AjaxResult listVideosByCourse(@RequestParam("courseId") Long courseId) {
+        if (courseId == null) {
+            return AjaxResult.error("请先选择课程");
+        }
+        FsUserCourseVideo query = new FsUserCourseVideo();
+        query.setCourseId(courseId);
+        query.setIsDel(0);
+        List<FsUserCourseVideo> videos = fsUserCourseVideoService.selectFsUserCourseVideoList(query);
+        if (videos == null) {
+            videos = new ArrayList<>();
+        } else {
+            // 按 course_sort、video_id 排序,与统计解析顺序一致
+            videos.sort((a, b) -> {
+                Long sa = a.getCourseSort() == null ? Long.MAX_VALUE : a.getCourseSort();
+                Long sb = b.getCourseSort() == null ? Long.MAX_VALUE : b.getCourseSort();
+                int cmp = sa.compareTo(sb);
+                if (cmp != 0) {
+                    return cmp;
+                }
+                Long ida = a.getVideoId() == null ? 0L : a.getVideoId();
+                Long idb = b.getVideoId() == null ? 0L : b.getVideoId();
+                return ida.compareTo(idb);
+            });
+        }
+        return AjaxResult.success(videos);
+    }
 }

+ 2 - 3
fs-admin/src/main/java/com/fs/his/task/Task.java

@@ -321,9 +321,8 @@ public class Task {
                         multiple=new BigDecimal(config);
                     }
                     fastGptPushTotal.setCount(BigDecimal.valueOf(count).multiply(multiple).setScale(0, RoundingMode.UP).longValue());
-                    // 计算金额,1元 = 10万token
-                    double amount = fastGptPushTotal.getCount() / 100000.0;
-                    fastGptPushTotal.setAmount(Math.round(amount * 100.0) / 100.0);
+                    // 计算金额:1元 = 10万token,按 token 用 BigDecimal 四舍五入到分
+                    fastGptPushTotal.setAmount(FastGptPushTokenTotal.calcAmountByCount(fastGptPushTotal.getCount()));
 
                     FastGptPushTokenTotal pushTotal = qwRestrictionPushRecordMapper.selectFastGptPushTokenTotalByInfo(fastGptPushTotal);
                     if(pushTotal == null){

+ 18 - 20
fs-admin/src/main/java/com/fs/qw/controller/QwPushCountController.java

@@ -163,22 +163,21 @@ public class QwPushCountController extends BaseController {
     public TableDataInfo tokenList(FastGptPushTokenTotal pushTokenInfo) {
         List<FastGptPushTokenTotal> list = qwPushCountService.selectFastGptPushTokenTotalList(pushTokenInfo);
 
-        // 计算规则:1元 = 10万token,即 amount = count / 100000.0
+        // 金额一律由本行 token 计算,不用库里已四舍五入的 amount 累加
         for (FastGptPushTokenTotal item : list) {
-            if (item.getCount() != null) {
-                double amount = item.getCount() / 100000.0;
-                item.setAmount(Math.round(amount * 100.0) / 100.0);
-            }
+            item.setAmount(FastGptPushTokenTotal.calcAmountByCount(item.getCount()));
         }
 
-        // 计算总和
+        // 合计:先汇总 token,再按总 token 算金额(避免各行金额再求和导致精度丢失)
         FastGptPushTokenTotal sumTotal = new FastGptPushTokenTotal();
         sumTotal.setCompanyName("合计");
-        Long sum = list.stream().mapToLong(FastGptPushTokenTotal::getCount).sum();
+        Long sum = list.stream()
+                .map(FastGptPushTokenTotal::getCount)
+                .filter(Objects::nonNull)
+                .mapToLong(Long::longValue)
+                .sum();
         sumTotal.setCount(sum);
-        // 根据总token数计算合计金额
-        double amountSum = sum / 100000.0;
-        sumTotal.setAmount(Math.round(amountSum * 100.0) / 100.0);
+        sumTotal.setAmount(FastGptPushTokenTotal.calcAmountByCount(sum));
 
         // 获取分页参数
         PageDomain pageDomain = TableSupport.buildPageRequest();
@@ -262,22 +261,21 @@ public class QwPushCountController extends BaseController {
     public AjaxResult tokenExport(@RequestBody FastGptPushTokenTotal pushTokenInfo) {
         List<FastGptPushTokenTotal> list = qwPushCountService.selectFastGptPushTokenTotalList(pushTokenInfo);
 
-        // 计算规则:1元 = 10万token,即 amount = count / 100000.0
+        // 金额一律由本行 token 计算,不用库里已四舍五入的 amount 累加
         for (FastGptPushTokenTotal item : list) {
-            if (item.getCount() != null) {
-                double amount = item.getCount() / 100000.0;
-                item.setAmount(Math.round(amount * 100.0) / 100.0);
-            }
+            item.setAmount(FastGptPushTokenTotal.calcAmountByCount(item.getCount()));
         }
 
-        // 计算总和
+        // 合计:先汇总 token,再按总 token 算金额
         FastGptPushTokenTotal sumTotal = new FastGptPushTokenTotal();
         sumTotal.setCompanyName("合计");
-        Long sum = list.stream().mapToLong(FastGptPushTokenTotal::getCount).sum();
+        Long sum = list.stream()
+                .map(FastGptPushTokenTotal::getCount)
+                .filter(Objects::nonNull)
+                .mapToLong(Long::longValue)
+                .sum();
         sumTotal.setCount(sum);
-        // 根据总token数计算合计金额
-        double amountSum = sum / 100000.0;
-        sumTotal.setAmount(Math.round(amountSum * 100.0) / 100.0);
+        sumTotal.setAmount(FastGptPushTokenTotal.calcAmountByCount(sum));
 
         list.add(sumTotal); // 将合计行添加到列表末尾
 

+ 13 - 9
fs-company/src/main/java/com/fs/company/controller/fastGpt/FastGptPushTokenTotalController.java

@@ -18,6 +18,7 @@ import org.springframework.web.bind.annotation.RestController;
 
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Objects;
 
 /**
  * FastGPT推送Token统计Controller
@@ -51,18 +52,21 @@ public class FastGptPushTokenTotalController extends BaseController {
         // 查询数据
         List<FastGptPushTokenTotal> list = qwPushCountService.selectFastGptPushTokenTotalList(pushTokenInfo);
 
-        // 计算总和
+        // 金额一律由本行 token 计算,不用库里已四舍五入的 amount 累加
+        for (FastGptPushTokenTotal item : list) {
+            item.setAmount(FastGptPushTokenTotal.calcAmountByCount(item.getCount()));
+        }
+
+        // 合计:先汇总 token,再按总 token 算金额(避免各行金额再求和导致精度丢失)
         FastGptPushTokenTotal sumTotal = new FastGptPushTokenTotal();
         sumTotal.setCompanyName("合计");
-        Long sum = list.stream().mapToLong(FastGptPushTokenTotal::getCount).sum();
+        Long sum = list.stream()
+                .map(FastGptPushTokenTotal::getCount)
+                .filter(Objects::nonNull)
+                .mapToLong(Long::longValue)
+                .sum();
         sumTotal.setCount(sum);
-
-        // 计算合计金额
-        Double amountSum = list.stream()
-            .filter(item -> item.getAmount() != null)
-            .mapToDouble(FastGptPushTokenTotal::getAmount)
-            .sum();
-        sumTotal.setAmount(Math.round(amountSum * 100.0) / 100.0);
+        sumTotal.setAmount(FastGptPushTokenTotal.calcAmountByCount(sum));
 
         // 获取分页参数
         PageDomain pageDomain = TableSupport.buildPageRequest();

+ 0 - 1
fs-ipad-task/src/main/java/com/fs/app/service/IpadSendServer.java

@@ -103,7 +103,6 @@ public class IpadSendServer {
                 appid = resolvedAppId;
             }
             // 获取配置文件里面的项目名称
-            String signProjectName = SpringUtils.getProperty("cloud_host.company_name");
             //区分新老用户,新用户发送备用小程序,老用户发送主小程序,TODO 需要使用的项目在参数里面加上
             log.info("ID:{}, qwUserId:{},externalId:{},进入区分发小程序逻辑", vo.getId(), vo.getQwUserId(), vo.getExId());
             // 判断消息是否是群聊,不是群聊再进去,TODO 当权无法判断群聊是否可以发送对应的小程序

+ 18 - 0
fs-service/src/main/java/com/fs/course/param/FsCourseWatchLogListParam.java

@@ -127,6 +127,24 @@ public class FsCourseWatchLogListParam implements Serializable {
     private List<String> userIds;
     private Integer watchType;
 
+    /** 企微主体 corpId */
+    private String corpId;
+
+    /**
+     * 客户标签 ID 列表(解析后供 Mapper 使用)
+     */
+    private List<String> tagIds;
+
+    /**
+     * 客户标签 ID(逗号分隔,GET 传参用,避免数组序列化问题)
+     */
+    private String tagIdList;
+
+    /**
+     * 标签匹配规则:1=含全部标签(同时包含所选全部,与顺序无关),2=含任意标签,默认任意
+     */
+    private Integer tagFilterType;
+
     public List<String> getUserIds() {
         if (userIds == null || userIds.isEmpty()) {
             return userIds;

+ 17 - 0
fs-service/src/main/java/com/fs/course/param/FsCourseWatchLogParam.java

@@ -4,6 +4,8 @@ import com.fs.common.annotation.Excel;
 import com.fs.common.core.domain.BaseEntity;
 import lombok.Data;
 
+import java.util.List;
+
 @Data
 public class FsCourseWatchLogParam extends BaseEntity {
 
@@ -22,6 +24,21 @@ public class FsCourseWatchLogParam extends BaseEntity {
     private Long companyId;
     private String appId;
     private String companyName;
+
+    /** 归属项目(字典 sys_course_project) */
+    private Long project;
+
+    /** 所选课程 ID */
+    private Long courseId;
+
+    /**
+     * 所选小节视频 ID 列表,逗号分隔(如 101,102,103)
+     * GET 传数组易绑定失败,统一用字符串再解析,保证按 video_id 精确查询
+     */
+    private String videoIdList;
+
+    /** 解析后的小节视频 ID 列表(Service 下推 Mapper) */
+    private List<Long> videoIds;
  
    
 }

+ 143 - 2
fs-service/src/main/java/com/fs/course/service/impl/FsCourseWatchLogServiceImpl.java

@@ -11,9 +11,11 @@ import com.baomidou.mybatisplus.core.toolkit.ObjectUtils;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.fs.common.BeanCopyUtils;
 import com.fs.common.core.redis.RedisCache;
+import com.fs.common.exception.ServiceException;
 import com.fs.common.utils.DateUtils;
 import com.fs.common.utils.DictUtils;
 import com.fs.common.utils.date.DateUtil;
+import com.github.pagehelper.PageHelper;
 import com.fs.company.cache.ICompanyCacheService;
 import com.fs.company.cache.ICompanyDeptCacheService;
 import com.fs.company.cache.ICompanyUserCacheService;
@@ -749,6 +751,7 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
 
     @Override
     public List<FsCourseWatchLogListVO> selectFsCourseWatchLogListVO(FsCourseWatchLogListParam param) {
+        prepareTagIdsForWatchLogList(param);
 
         // 因为selectFsCourseWatchLogListVO 这个方法中查询了看课日志记录表 需要限制创建时间必传
         if ((StringUtils.isEmpty(param.getSTime()) || StringUtils.isEmpty(param.getETime())) &&
@@ -1516,7 +1519,7 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
 
     @Override
     public List<FsCourseWatchLogListVO> selectFsCourseWatchLogListVOexport(FsCourseWatchLogListParam param) {
-
+        prepareTagIdsForWatchLogList(param);
 
         if (param.getSendType() == 1 && param.getPeriodETime() != null && param.getPeriodSTime() != null) {
             List<Long> periodIds = userCoursePeriodDaysService.selectFsUserCoursePeriodDaysByTime(param.getPeriodSTime(), param.getPeriodETime());
@@ -1798,7 +1801,145 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
     }
     @Override
     public List<FsCoureseWatchLogVO> selectUserIdList(FsCourseWatchLogParam fsCourseWatchLog) {
-        return  fsCourseWatchLogMapper.selectUserIdList(fsCourseWatchLog);
+        // 有小节区间时先解析 video_id,无匹配则直接空列表
+        if (!prepareVideoIdsForUserIdList(fsCourseWatchLog)) {
+            PageHelper.clearPage();
+            return Collections.emptyList();
+        }
+        return fsCourseWatchLogMapper.selectUserIdList(fsCourseWatchLog);
+    }
+
+    /**
+     * 解析小节过滤口径:
+     * 1) 未选小节,或勾选了该课程当前全部小节 → 按 course_id 统计(与「全选」一致,含历史小节日志)
+     * 2) 仅勾选部分小节 → 按 video_id IN (...) 精确过滤
+     *
+     * @return false 表示有小节筛选但无有效 ID,应返回空列表;无需 video_id 收窄时返回 true
+     */
+    private boolean prepareVideoIdsForUserIdList(FsCourseWatchLogParam param) {
+        if (param == null) {
+            return true;
+        }
+        if (param.getCourseId() != null && param.getProject() == null) {
+            throw new ServiceException("请先选择项目后再选择课程");
+        }
+        // 未选课程:不能带小节;仅按项目(或都不选)过滤
+        if (param.getCourseId() == null) {
+            if (com.fs.common.utils.StringUtils.isNotEmpty(param.getVideoIdList())) {
+                throw new ServiceException("请先选择课程后再按小节筛选");
+            }
+            param.setVideoIds(null);
+            return true;
+        }
+
+        List<Long> requestVideoIds = parseVideoIdList(param.getVideoIdList());
+        // 未选手动小节:按课程统计,不用 video_id 收窄
+        if (CollectionUtils.isEmpty(requestVideoIds)) {
+            param.setVideoIds(null);
+            return true;
+        }
+
+        List<FsUserCourseVideo> courseVideos = fsUserCourseVideoMapper.selectVideoByCourseId(param.getCourseId());
+        Set<Long> validSet = new HashSet<>();
+        if (CollectionUtils.isNotEmpty(courseVideos)) {
+            for (FsUserCourseVideo video : courseVideos) {
+                if (video.getVideoId() != null) {
+                    validSet.add(video.getVideoId());
+                }
+            }
+        }
+        List<Long> videoIds = new ArrayList<>();
+        for (Long videoId : requestVideoIds) {
+            if (validSet.contains(videoId) && !videoIds.contains(videoId)) {
+                videoIds.add(videoId);
+            }
+        }
+        if (videoIds.isEmpty()) {
+            param.setVideoIds(Collections.emptyList());
+            return false;
+        }
+        // 勾选数量覆盖当前课程全部小节时,视为「全选」,与不选小节同一课程口径
+        if (!validSet.isEmpty() && videoIds.size() == validSet.size() && validSet.containsAll(videoIds)) {
+            param.setVideoIds(null);
+            return true;
+        }
+        param.setVideoIds(videoIds);
+        return true;
+    }
+
+    /** 解析逗号分隔的视频 ID 字符串 */
+    private List<Long> parseVideoIdList(String videoIdList) {
+        if (StringUtils.isEmpty(videoIdList)) {
+            return Collections.emptyList();
+        }
+        List<Long> result = new ArrayList<>();
+        String[] parts = videoIdList.split(",");
+        for (String part : parts) {
+            if (part == null) {
+                continue;
+            }
+            String trimmed = part.trim();
+            if (trimmed.isEmpty()) {
+                continue;
+            }
+            try {
+                result.add(Long.valueOf(trimmed));
+            } catch (NumberFormatException e) {
+                throw new ServiceException("小节视频ID格式不正确:" + trimmed);
+            }
+        }
+        return result;
+    }
+
+    /**
+     * 解析标签参数,对齐企微客户:支持 tagIdList / tagIds 逗号串 / List
+     */
+    private void prepareTagIdsForWatchLogList(FsCourseWatchLogListParam param) {
+        if (param == null) {
+            return;
+        }
+        List<String> result = new ArrayList<>();
+        if (StringUtils.isNotEmpty(param.getTagIdList())) {
+            result.addAll(splitTagIdCsv(param.getTagIdList()));
+        } else if (CollectionUtils.isNotEmpty(param.getTagIds())) {
+            for (String item : param.getTagIds()) {
+                if (item == null) {
+                    continue;
+                }
+                // 企微客户前端 join 成逗号串后,Spring 可能绑成单元素 List
+                if (item.contains(",")) {
+                    result.addAll(splitTagIdCsv(item));
+                } else {
+                    String trimmed = item.trim();
+                    if (!trimmed.isEmpty()) {
+                        result.add(trimmed);
+                    }
+                }
+            }
+        }
+        if (result.isEmpty()) {
+            param.setTagIds(null);
+        } else {
+            param.setTagIds(result);
+        }
+    }
+
+    private List<String> splitTagIdCsv(String csv) {
+        List<String> result = new ArrayList<>();
+        if (StringUtils.isEmpty(csv)) {
+            return result;
+        }
+        String[] parts = csv.split(",");
+        for (String part : parts) {
+            if (part == null) {
+                continue;
+            }
+            String trimmed = part.trim();
+            if (!trimmed.isEmpty()) {
+                result.add(trimmed);
+            }
+        }
+        return result;
     }
 
 }

+ 19 - 0
fs-service/src/main/java/com/fs/fastGpt/domain/FastGptPushTokenTotal.java

@@ -4,9 +4,15 @@ import com.fs.common.annotation.Excel;
 import com.fs.common.core.domain.BaseEntity;
 import lombok.Data;
 
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+
 @Data
 public class FastGptPushTokenTotal extends BaseEntity {
 
+    /** 计价:1 元 = 10 万 token */
+    public static final BigDecimal TOKEN_PER_YUAN = new BigDecimal("100000");
+
     private Long id;
     private Integer type;
     private Long qwUserId;
@@ -21,4 +27,17 @@ public class FastGptPushTokenTotal extends BaseEntity {
     @Excel(name = "生成时间")
     private String statTime;
 
+    /**
+     * 按 token 消耗计算金额(不以各行已四舍五入的金额再累加,避免精度丢失)
+     * 规则:1 元 = 10 万 token,保留两位小数(四舍五入)
+     */
+    public static Double calcAmountByCount(Long count) {
+        if (count == null) {
+            return null;
+        }
+        return BigDecimal.valueOf(count)
+                .divide(TOKEN_PER_YUAN, 2, RoundingMode.HALF_UP)
+                .doubleValue();
+    }
+
 }

+ 2 - 1
fs-service/src/main/java/com/fs/im/service/impl/OpenIMServiceImpl.java

@@ -432,6 +432,7 @@ public class OpenIMServiceImpl implements OpenIMService {
      */
     @Override
     public OpenImResponseDTO updateUserInfo(CompanyUser companyUser){
+        companyUser = companyUserMapper.selectCompanyUserById(companyUser.getUserId());
         String adminToken = getAdminToken();
         long time = System.currentTimeMillis();
         OpenImResponseDTO responseDTO = null;
@@ -457,7 +458,7 @@ public class OpenIMServiceImpl implements OpenIMService {
             updateUserInfo.setUserID(user.getUserID());
             updateUserInfo.setNickname(companyUser.getNickName());
             //updateUserInfo.setFaceURL(Optional.ofNullable(user.getFaceURL()).orElse("http://company.muyi88.com/profile/avatar/2025/09/10/35ed3564-aad9-4580-a746-5da01e24d2ab.jpeg"));
-            updateUserInfo.setFaceURL("http://company.muyi88.com/prod-api/profile/avatar/2025/04/08/aa418999-6738-492d-9856-491916f714d9.jpeg");
+            updateUserInfo.setFaceURL(companyUser.getAvatar());
             updateUserInfo.setEx(Optional.ofNullable(user.getEx()).orElse(""));
 
             Map<String, Object> bodyMap = new HashMap<>();

+ 20 - 0
fs-service/src/main/java/com/fs/statis/dto/StatsWatchLogPageListDTO.java

@@ -64,4 +64,24 @@ public class StatsWatchLogPageListDTO implements Serializable {
      */
     private Boolean includeSend0;
 
+    /**
+     * 归属项目(字典 sys_course_project)
+     */
+    private Long project;
+
+    /**
+     * 课程区间起始序号(1-based,项目下按 sort 排序后的第 N 门课)
+     */
+    private Integer courseStart;
+
+    /**
+     * 课程区间结束序号(1-based,含边界)
+     */
+    private Integer courseEnd;
+
+    /**
+     * 解析后的课程 ID 列表(Service 下推 Mapper,前端无需传)
+     */
+    private List<Long> courseIds;
+
 }

+ 10 - 0
fs-service/src/main/java/com/fs/statis/mapper/FsStatisSalerWatchMapper.java

@@ -82,6 +82,16 @@ public interface FsStatisSalerWatchMapper {
 //    @DataSource(DataSourceType.SLAVE)
     List<FsStatisSalerWatch> queryList(StatsWatchLogPageListDTO param);
 
+    /**
+     * 按看课日志实时聚合(项目/课程区间筛选)
+     */
+    List<FsStatisSalerWatch> queryListByWatchLog(StatsWatchLogPageListDTO param);
+
+    /**
+     * 按看课日志实时聚合导出(项目/课程区间筛选)
+     */
+    List<FsStatisSalerWatch> queryListExportByWatchLog(StatsWatchLogPageListDTO param);
+
     void batchSave(@Param("list") List<FsStatisSalerWatch> writeData);
 
     @DataSource(DataSourceType.SLAVE)

+ 111 - 14
fs-service/src/main/java/com/fs/statis/service/impl/FsStatisSalerWatchServiceImpl.java

@@ -1,12 +1,15 @@
 package com.fs.statis.service.impl;
 
 import com.baomidou.mybatisplus.core.toolkit.ObjectUtils;
+import com.fs.common.exception.ServiceException;
 import com.fs.common.utils.StringUtils;
 import com.fs.company.cache.ICompanyUserCacheService;
 import com.fs.company.domain.CompanyDept;
 import com.fs.company.domain.CompanyUser;
 import com.fs.company.mapper.CompanyDeptMapper;
 import com.fs.company.mapper.CompanyUserMapper;
+import com.fs.course.domain.FsUserCourse;
+import com.fs.course.mapper.FsUserCourseMapper;
 import com.fs.qw.cache.QwSopCacheService;
 import com.fs.qw.cache.QwUserCacheService;
 import com.fs.qw.domain.QwUser;
@@ -65,6 +68,9 @@ public class FsStatisSalerWatchServiceImpl implements FsStatisSalerWatchService
     @Autowired
     private QwSopCacheService qwSopCacheService;
 
+    @Autowired
+    private FsUserCourseMapper fsUserCourseMapper;
+
     @Autowired
     public FsStatisSalerWatchServiceImpl(FsStatisSalerWatchMapper fsStatisSalerWatchMapper,
                                          CompanyUserMapper companyUserMapper,
@@ -146,22 +152,16 @@ public class FsStatisSalerWatchServiceImpl implements FsStatisSalerWatchService
         if(StringUtils.isNull(param.getStartDate()) && StringUtils.isNull(param.getEndDate())) {
             return Collections.emptyList();
         }
-        List<FsStatisSalerWatch> fsStatisSalerWatches = fsStatisSalerWatchMapper.queryList(param);
-        for (FsStatisSalerWatch item : fsStatisSalerWatches) {
-            if(item.getDeptId() != null) {
-                CompanyDept companyDept = companyDeptMapper.selectCompanyDeptById(item.getDeptId());
-                if(ObjectUtils.isNotNull(companyDept)) {
-                    item.setDeptName(companyDept.getDeptName());
-                }
+        List<FsStatisSalerWatch> fsStatisSalerWatches;
+        if (needWatchLogQuery(param)) {
+            if (!prepareCourseIds(param)) {
+                return Collections.emptyList();
             }
-            if(item.getCompanyUserId() != null) {
-                String companyUserName = companyUserCacheService.selectCompanyUserNameUserById(item.getCompanyUserId());
-                if(StringUtils.isNotEmpty(companyUserName)){
-                    item.setCompanyUserName(companyUserName);
-                }
-            }
-
+            fsStatisSalerWatches = fsStatisSalerWatchMapper.queryListByWatchLog(param);
+        } else {
+            fsStatisSalerWatches = fsStatisSalerWatchMapper.queryList(param);
         }
+        fillUserAndDeptName(fsStatisSalerWatches);
         return fsStatisSalerWatches;
     }
 
@@ -238,9 +238,106 @@ public class FsStatisSalerWatchServiceImpl implements FsStatisSalerWatchService
         if(StringUtils.isNull(param.getStartDate()) && StringUtils.isNull(param.getEndDate())) {
             return Collections.emptyList();
         }
+        if (needWatchLogQuery(param)) {
+            if (!prepareCourseIds(param)) {
+                return Collections.emptyList();
+            }
+            return fsStatisSalerWatchMapper.queryListExportByWatchLog(param);
+        }
         return fsStatisSalerWatchMapper.queryListExport(param);
     }
 
+    /**
+     * 是否走看课日志实时聚合(项目或课程区间)
+     */
+    private boolean needWatchLogQuery(StatsWatchLogPageListDTO param) {
+        if (param == null) {
+            return false;
+        }
+        if (param.getProject() != null) {
+            return true;
+        }
+        return param.getCourseStart() != null || param.getCourseEnd() != null;
+    }
+
+    /**
+     * 解析项目下课程 ID:按 sort 升序,空 sort 靠后;有区间则按下标切片(1-based)
+     * @return false 表示应返回空列表(无匹配课程)
+     */
+    private boolean prepareCourseIds(StatsWatchLogPageListDTO param) {
+        if (param.getCourseStart() != null || param.getCourseEnd() != null) {
+            if (param.getProject() == null) {
+                throw new ServiceException("请先选择项目后再按课程区间筛选");
+            }
+        }
+        if (param.getProject() == null) {
+            return false;
+        }
+        FsUserCourse query = new FsUserCourse();
+        query.setProject(param.getProject());
+        query.setIsDel(0);
+        List<FsUserCourse> courses = fsUserCourseMapper.selectFsUserCourseList(query);
+        if (CollectionUtils.isEmpty(courses)) {
+            param.setCourseIds(Collections.emptyList());
+            return false;
+        }
+        courses.sort(Comparator
+                .comparing((FsUserCourse c) -> c.getSort() == null)
+                .thenComparing(c -> c.getSort() == null ? 0L : c.getSort())
+                .thenComparing(c -> c.getCourseId() == null ? 0L : c.getCourseId()));
+        int from = param.getCourseStart() == null ? 1 : param.getCourseStart();
+        int to = param.getCourseEnd() == null ? courses.size() : param.getCourseEnd();
+        if (from < 1) {
+            from = 1;
+        }
+        if (to < from) {
+            throw new ServiceException("课程区间起始不能大于结束");
+        }
+        if (from > courses.size()) {
+            param.setCourseIds(Collections.emptyList());
+            return false;
+        }
+        if (to > courses.size()) {
+            to = courses.size();
+        }
+        List<Long> courseIds = new ArrayList<>();
+        for (int i = from - 1; i < to; i++) {
+            Long courseId = courses.get(i).getCourseId();
+            if (courseId != null) {
+                courseIds.add(courseId);
+            }
+        }
+        if (courseIds.isEmpty()) {
+            param.setCourseIds(Collections.emptyList());
+            return false;
+        }
+        param.setCourseIds(courseIds);
+        return true;
+    }
+
+    /**
+     * 补齐部门名、销售昵称
+     */
+    private void fillUserAndDeptName(List<FsStatisSalerWatch> list) {
+        if (CollectionUtils.isEmpty(list)) {
+            return;
+        }
+        for (FsStatisSalerWatch item : list) {
+            if (item.getDeptId() != null) {
+                CompanyDept companyDept = companyDeptMapper.selectCompanyDeptById(item.getDeptId());
+                if (ObjectUtils.isNotNull(companyDept)) {
+                    item.setDeptName(companyDept.getDeptName());
+                }
+            }
+            if (item.getCompanyUserId() != null) {
+                String companyUserName = companyUserCacheService.selectCompanyUserNameUserById(item.getCompanyUserId());
+                if (StringUtils.isNotEmpty(companyUserName)) {
+                    item.setCompanyUserName(companyUserName);
+                }
+            }
+        }
+    }
+
     @Override
     public List<FsStatisSopWatch> exportQueryPeriodList(StatsWatchLogPageListDTO param) {
         if(StringUtils.isNull(param.getStartDate()) && StringUtils.isNull(param.getEndDate())) {

+ 58 - 14
fs-service/src/main/resources/mapper/course/FsCourseWatchLogMapper.xml

@@ -210,6 +210,29 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
                     <foreach collection='maps.userIds' item='item' open='(' separator=',' close=')'>
                         #{item}
                     </foreach>
+            </if>
+            <if test="maps.corpId != null and maps.corpId != ''">
+                and qec.corp_id = #{maps.corpId}
+            </if>
+            <if test="maps.tagIds != null and maps.tagIds.size() &gt; 0">
+                <choose>
+                    <!-- 2=含任意标签:OR -->
+                    <when test="maps.tagFilterType != null and maps.tagFilterType == 2">
+                        and (
+                        <foreach collection="maps.tagIds" item="item" index="index" separator=" or ">
+                            <![CDATA[find_in_set(#{item}, REPLACE(REPLACE(REPLACE(REPLACE(IFNULL(qec.tag_ids,''), '"', ''), '[', ''), ']', ''), ' ', ''))]]>
+                        </foreach>
+                        )
+                    </when>
+                    <!-- 默认/1=含全部标签:AND(与企微客户 selectQwExternalContactListVO 一致,与顺序无关) -->
+                    <otherwise>
+                        and (
+                        <foreach collection="maps.tagIds" item="item" index="index" separator=" and ">
+                            <![CDATA[find_in_set(#{item}, REPLACE(REPLACE(REPLACE(REPLACE(IFNULL(qec.tag_ids,''), '"', ''), '[', ''), ']', ''), ' ', ''))]]>
+                        </foreach>
+                        )
+                    </otherwise>
+                </choose>
             </if>
                 </where>
                  order by l.finish_time desc,l.update_time desc,l.create_time desc
@@ -937,6 +960,29 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
                     #{periodId}
                 </foreach>
             </if>
+            <if test="maps.corpId != null and maps.corpId != ''">
+                and qec.corp_id = #{maps.corpId}
+            </if>
+            <if test="maps.tagIds != null and maps.tagIds.size() &gt; 0">
+                <choose>
+                    <!-- 2=含任意标签:OR -->
+                    <when test="maps.tagFilterType != null and maps.tagFilterType == 2">
+                        and (
+                        <foreach collection="maps.tagIds" item="item" index="index" separator=" or ">
+                            <![CDATA[find_in_set(#{item}, REPLACE(REPLACE(REPLACE(REPLACE(IFNULL(qec.tag_ids,''), '"', ''), '[', ''), ']', ''), ' ', ''))]]>
+                        </foreach>
+                        )
+                    </when>
+                    <!-- 默认/1=含全部标签:AND(与企微客户 selectQwExternalContactListVO 一致,与顺序无关) -->
+                    <otherwise>
+                        and (
+                        <foreach collection="maps.tagIds" item="item" index="index" separator=" and ">
+                            <![CDATA[find_in_set(#{item}, REPLACE(REPLACE(REPLACE(REPLACE(IFNULL(qec.tag_ids,''), '"', ''), '[', ''), ']', ''), ' ', ''))]]>
+                        </foreach>
+                        )
+                    </otherwise>
+                </choose>
+            </if>
 
         </where>
         order by l.finish_time desc,l.update_time desc,l.create_time desc
@@ -1125,25 +1171,23 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         FROM fs_course_watch_log
         WHERE create_time &gt;= #{createTime}
         AND create_time &lt; DATE_ADD(DATE(#{createTime}), INTERVAL 1 DAY)
+        <if test="project != null">
+            AND project = #{project}
+        </if>
+        <if test="courseId != null">
+            AND course_id = #{courseId}
+        </if>
+        <if test="videoIds != null and videoIds.size() &gt; 0">
+            AND video_id IN
+            <foreach collection="videoIds" open="(" close=")" separator="," item="vid">
+                #{vid}
+            </foreach>
+        </if>
         GROUP BY company_id
         ) l ON c.company_id = l.company_id
         WHERE c.is_del = '0'
         ORDER BY c.company_id
 
-<!--        <if test="companyId != null">-->
-<!--            AND l.company_id = #{companyId}-->
-<!--        </if>-->
-<!--        <if test="appId != null">-->
-<!--            AND l.app_id = #{appId}-->
-<!--        </if>-->
-<!--        <if test="companyName != null">-->
-<!--            AND c.company_name like concat('%', #{companyName}, '%')-->
-<!--        </if>-->
-<!--        GROUP BY-->
-<!--        c.company_id, c.company_name-->
-<!--        ORDER BY-->
-<!--        c.company_id-->
-
     </select>
 
 

+ 1 - 1
fs-service/src/main/resources/mapper/qw/QwRestrictionPushRecordMapper.xml

@@ -27,7 +27,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         where  type=#{type} and qw_user_id=#{qwUserId} and company_id=#{companyId} and status=#{status} and stat_time=#{statTime}
     </select>
     <select id="selectFastGptPushTokenTotalList" resultType="com.fs.fastGpt.domain.FastGptPushTokenTotal">
-        select ft.id,ft.type,ft.qw_user_id as qwUserId,ft.company_id as companyId,com.company_name as companyName,ft.status,ft.stat_time statTime,sum(ft.count) count,sum(ft.amount) amount
+        select ft.id,ft.type,ft.qw_user_id as qwUserId,ft.company_id as companyId,com.company_name as companyName,ft.status,ft.stat_time statTime,sum(ft.count) count
         from fastgpt_push_token_total ft left join company com on ft.company_id= com.company_id
         <where>
             <if test="type != null" >and `ft.type` = #{type} </if>

+ 111 - 0
fs-service/src/main/resources/mapper/statis/FsStatisSalerWatchMapper.xml

@@ -82,6 +82,117 @@
         </where>
         GROUP BY company_user_id
     </select>
+
+    <!-- 项目/课程区间:按 fs_course_watch_log.course_id 实时聚合(口径对齐 generateData.log_stats) -->
+    <select id="queryListByWatchLog" resultType="com.fs.statis.domain.FsStatisSalerWatch">
+        SELECT
+            log.company_user_id AS company_user_id,
+            ANY_VALUE(cu.dept_id) AS dept_id,
+            COUNT(DISTINCT log.log_id) AS send_num,
+            COUNT(DISTINCT log.qw_external_contact_id) AS train_camp_num,
+            COUNT(DISTINCT CASE WHEN log.log_type = 3 THEN log.log_id END) AS not_registered_num,
+            COUNT(DISTINCT CASE WHEN log.log_type IN (1, 2, 4) THEN log.qw_external_contact_id END) AS registered_num,
+            COUNT(DISTINCT CASE WHEN log.log_type = 2 THEN log.log_id END) AS completed_num,
+            COUNT(DISTINCT CASE WHEN log.log_type IN (1, 4) THEN log.log_id END) AS interrupt_num,
+            0 AS qw_repeat_num,
+            0 AS user_repeat_num,
+            CASE WHEN COUNT(DISTINCT log.qw_external_contact_id) &gt; 0
+                THEN ROUND(COUNT(DISTINCT CASE WHEN log.log_type IN (1, 2, 4) THEN log.qw_external_contact_id END) * 1.0
+                    / COUNT(DISTINCT log.qw_external_contact_id), 4)
+                ELSE 0 END AS reg_rate,
+            CASE WHEN COUNT(DISTINCT log.log_id) &gt; 0
+                THEN ROUND(COUNT(DISTINCT CASE WHEN log.log_type = 2 THEN log.log_id END) * 1.0
+                    / COUNT(DISTINCT log.log_id), 4)
+                ELSE 0 END AS finished_rate
+        FROM fs_course_watch_log log
+        LEFT JOIN company_user cu ON cu.user_id = log.company_user_id AND cu.del_flag = 0
+        <where>
+            log.company_user_id IS NOT NULL
+            <if test="startDate != null and endDate != null">
+                AND log.create_time &gt;= STR_TO_DATE(#{startDate}, '%Y-%m-%d')
+                AND log.create_time &lt; DATE_ADD(STR_TO_DATE(#{endDate}, '%Y-%m-%d'), INTERVAL 1 DAY)
+            </if>
+            <if test="courseIds != null and courseIds.size() &gt; 0">
+                AND log.course_id IN
+                <foreach collection="courseIds" open="(" close=")" separator="," item="cid">
+                    #{cid}
+                </foreach>
+            </if>
+            <if test="userIds != null and userIds.size() &gt; 0">
+                AND log.company_user_id IN
+                <foreach collection="userIds" open="(" close=")" separator="," item="item">
+                    ${item}
+                </foreach>
+            </if>
+        </where>
+        GROUP BY log.company_user_id
+        <choose>
+            <when test="includeSend0 != null and includeSend0">
+                HAVING COUNT(DISTINCT log.log_id) = 0
+            </when>
+            <otherwise>
+                HAVING COUNT(DISTINCT log.log_id) != 0
+            </otherwise>
+        </choose>
+    </select>
+
+    <select id="queryListExportByWatchLog" resultType="com.fs.statis.domain.FsStatisSalerWatch">
+        SELECT
+            concat(u.company_id,'_',c.company_name) AS company_name,
+            concat(u.user_name,'_',u.nick_name) AS company_user_name,
+            ANY_VALUE(concat(dept.dept_id,'_',dept.dept_name)) AS dept_name,
+            log.company_user_id AS company_user_id,
+            COUNT(DISTINCT log.log_id) AS send_num,
+            COUNT(DISTINCT log.qw_external_contact_id) AS train_camp_num,
+            COUNT(DISTINCT CASE WHEN log.log_type = 3 THEN log.log_id END) AS not_registered_num,
+            COUNT(DISTINCT CASE WHEN log.log_type IN (1, 2, 4) THEN log.qw_external_contact_id END) AS registered_num,
+            COUNT(DISTINCT CASE WHEN log.log_type = 2 THEN log.log_id END) AS completed_num,
+            COUNT(DISTINCT CASE WHEN log.log_type IN (1, 4) THEN log.log_id END) AS interrupt_num,
+            0 AS qw_repeat_num,
+            0 AS user_repeat_num,
+            CASE WHEN COUNT(DISTINCT log.qw_external_contact_id) &gt; 0
+                THEN ROUND(COUNT(DISTINCT CASE WHEN log.log_type IN (1, 2, 4) THEN log.qw_external_contact_id END) * 1.0
+                    / COUNT(DISTINCT log.qw_external_contact_id), 4)
+                ELSE 0 END AS reg_rate,
+            CASE WHEN COUNT(DISTINCT log.log_id) &gt; 0
+                THEN ROUND(COUNT(DISTINCT CASE WHEN log.log_type = 2 THEN log.log_id END) * 1.0
+                    / COUNT(DISTINCT log.log_id), 4)
+                ELSE 0 END AS finished_rate
+        FROM fs_course_watch_log log
+        LEFT JOIN company_user u ON u.del_flag = 0 AND log.company_user_id = u.user_id
+        LEFT JOIN company_dept dept ON dept.del_flag = 0 AND u.dept_id = dept.dept_id
+        LEFT JOIN company c ON c.company_id = u.company_id
+        <where>
+            log.company_user_id IS NOT NULL
+            <if test="startDate != null and endDate != null">
+                AND log.create_time &gt;= STR_TO_DATE(#{startDate}, '%Y-%m-%d')
+                AND log.create_time &lt; DATE_ADD(STR_TO_DATE(#{endDate}, '%Y-%m-%d'), INTERVAL 1 DAY)
+            </if>
+            <if test="courseIds != null and courseIds.size() &gt; 0">
+                AND log.course_id IN
+                <foreach collection="courseIds" open="(" close=")" separator="," item="cid">
+                    #{cid}
+                </foreach>
+            </if>
+            <if test="userIds != null and userIds.size() &gt; 0">
+                AND log.company_user_id IN
+                <foreach collection="userIds" open="(" close=")" separator="," item="item">
+                    ${item}
+                </foreach>
+            </if>
+        </where>
+        GROUP BY log.company_user_id
+        <choose>
+            <when test="includeSend0 != null and includeSend0">
+                HAVING COUNT(DISTINCT log.log_id) = 0
+            </when>
+            <otherwise>
+                HAVING COUNT(DISTINCT log.log_id) != 0
+            </otherwise>
+        </choose>
+        ORDER BY u.company_id
+    </select>
+
     <select id="queryPeriodList" resultType="com.fs.statis.domain.FsStatisSalerWatch">
         SELECT
         period_id,