Quellcode durchsuchen

Merge remote-tracking branch 'origin/bjcz_his_scrm' into 北京存在

吴树波 vor 8 Stunden
Ursprung
Commit
716e836179
26 geänderte Dateien mit 562 neuen und 144 gelöschten Zeilen
  1. 1 1
      fs-company/src/main/java/com/fs/company/controller/company/CompanyConfigController.java
  2. 1 1
      fs-company/src/main/java/com/fs/company/controller/course/FsCourseRedPacketLogController.java
  3. 5 6
      fs-company/src/main/java/com/fs/company/controller/fastGpt/FastGptPushTokenTotalController.java
  4. 1 1
      fs-company/src/main/java/com/fs/framework/config/DataSourceConfig.java
  5. 74 2
      fs-qw-task/src/main/java/com/fs/app/taskService/impl/SopLogsTaskServiceImpl.java
  6. 5 1
      fs-service/src/main/java/com/fs/company/mapper/CompanyConfigMapper.java
  7. 3 2
      fs-service/src/main/java/com/fs/company/service/ICompanyConfigService.java
  8. 1 1
      fs-service/src/main/java/com/fs/company/service/impl/CompanyConfigServiceImpl.java
  9. 4 0
      fs-service/src/main/java/com/fs/course/mapper/FsCourseAnswerLogsMapper.java
  10. 16 2
      fs-service/src/main/java/com/fs/course/mapper/FsCourseWatchLogMapper.java
  11. 6 0
      fs-service/src/main/java/com/fs/course/mapper/FsUserCourseVideoRedPackageMapper.java
  12. 256 54
      fs-service/src/main/java/com/fs/course/service/impl/FsCourseRedPacketLogServiceImpl.java
  13. 1 1
      fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java
  14. 3 0
      fs-service/src/main/java/com/fs/qw/mapper/QwContactWayMapper.java
  15. 1 0
      fs-service/src/main/java/com/fs/qw/mapper/QwExternalContactMapper.java
  16. 3 0
      fs-service/src/main/java/com/fs/qw/mapper/QwGroupChatUserMapper.java
  17. 3 0
      fs-service/src/main/java/com/fs/qw/mapper/QwRestrictionPushRecordMapper.java
  18. 3 0
      fs-service/src/main/java/com/fs/qw/mapper/QwTagMapper.java
  19. 2 1
      fs-service/src/main/java/com/fs/qw/service/AsyncQwAiChatSopService.java
  20. 86 1
      fs-service/src/main/java/com/fs/qw/service/impl/QwExternalContactServiceImpl.java
  21. 3 0
      fs-service/src/main/java/com/fs/sop/domain/QwSopTempContent.java
  22. 1 10
      fs-service/src/main/java/com/fs/sop/mapper/QwSopTempContentMapper.java
  23. 3 0
      fs-service/src/main/java/com/fs/store/mapper/FsUserCourseCountMapper.java
  24. 19 0
      fs-service/src/main/resources/mapper/course/FsUserCompanyBindMapper.xml
  25. 15 0
      fs-service/src/main/resources/mapper/sop/QwSopTempContentMapper.xml
  26. 46 60
      fs-service/src/main/resources/mapper/statis/FsStatisSalerWatchMapper.xml

+ 1 - 1
fs-company/src/main/java/com/fs/company/controller/company/CompanyConfigController.java

@@ -115,7 +115,7 @@ public class CompanyConfigController extends BaseController
             return R.error("用户信息错误");
         }
         Long companyId = loginUser.getCompany().getCompanyId();
-        // 公司端配置主备小程序时,只展示当前公司在 fs_course_play_source_config 中配的小程序
+        // 公司端配置主备小程序时,只展示当前公司在 fs_course_play_source_config 中配的小程序(company_id 或 company_ids 逗号分隔包含当前公司)
         List<CompanyMiniAppVO> companyMiniAppList = companyConfigService.getCompanyMiniAppListByCompany(companyId);
         SaveCompanyMiniAppParam param = companyConfigService.getCurrentCompanyMiniApp(companyId);
         return R.ok().put("data",companyMiniAppList).put("current",param);

+ 1 - 1
fs-company/src/main/java/com/fs/company/controller/course/FsCourseRedPacketLogController.java

@@ -249,7 +249,7 @@ public class FsCourseRedPacketLogController extends BaseController
      * 删除短链课程看课记录
      */
 
-    @Log(title = "短链课程看课记录", businessType = BusinessType.DELETE)
+    @Log(title = "补发红包", businessType = BusinessType.DELETE)
     @PutMapping("/retryCourseRedPacketLog/{logIds}")
     @RepeatSubmit
     public R retryCourseRedPacketLog(@PathVariable Long[] logIds)

+ 5 - 6
fs-company/src/main/java/com/fs/company/controller/fastGpt/FastGptPushTokenTotalController.java

@@ -21,7 +21,7 @@ import java.util.List;
 
 /**
  * FastGPT推送Token统计Controller
- * 
+ *
  * @author fs
  * @date 2025-12-24
  */
@@ -39,16 +39,15 @@ public class FastGptPushTokenTotalController extends BaseController {
      * 查询FastGPT推送Token统计列表
      * 每个公司只能查看自己公司的数据
      */
-    @PreAuthorize("@ss.hasPermi('fastGpt:pushTokenTotal:list')")
     @GetMapping("/list")
     public TableDataInfo tokenList(FastGptPushTokenTotal pushTokenInfo) {
         // 获取当前登录用户的公司ID
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
         Long companyId = loginUser.getCompany().getCompanyId();
-        
+
         // 强制设置查询条件为当前公司ID,确保只能查看自己公司的数据
         pushTokenInfo.setCompanyId(companyId);
-        
+
         // 查询数据
         List<FastGptPushTokenTotal> list = qwPushCountService.selectFastGptPushTokenTotalList(pushTokenInfo);
 
@@ -57,7 +56,7 @@ public class FastGptPushTokenTotalController extends BaseController {
         sumTotal.setCompanyName("合计");
         Long sum = list.stream().mapToLong(FastGptPushTokenTotal::getCount).sum();
         sumTotal.setCount(sum);
-        
+
         // 计算合计金额
         Double amountSum = list.stream()
             .filter(item -> item.getAmount() != null)
@@ -71,7 +70,7 @@ public class FastGptPushTokenTotalController extends BaseController {
         Integer pageSize = pageDomain.getPageSize();
 
         int total = list.size();
-        
+
         // 在内存中进行分页处理
         if (pageNum != null && pageSize != null) {
             int fromIndex = (pageNum - 1) * pageSize;

+ 1 - 1
fs-company/src/main/java/com/fs/framework/config/DataSourceConfig.java

@@ -48,7 +48,7 @@ public class DataSourceConfig {
                                         @Qualifier("slaveDataSource") DataSource slaveDataSource) {
         Map<Object, Object> targetDataSources = new HashMap<>();
         targetDataSources.put(DataSourceType.MASTER.name(), masterDataSource);
-        targetDataSources.put(DataSourceType.SLAVE.name(), masterDataSource);
+        targetDataSources.put(DataSourceType.SLAVE.name(), slaveDataSource);
         targetDataSources.put(DataSourceType.SOP.name(), sopDataSource);
         return new DynamicDataSource(masterDataSource, targetDataSources);
     }

+ 74 - 2
fs-qw-task/src/main/java/com/fs/app/taskService/impl/SopLogsTaskServiceImpl.java

@@ -49,6 +49,7 @@ import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.collections4.CollectionUtils;
 import org.springframework.beans.BeanUtils;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.dao.DuplicateKeyException;
 import org.springframework.retry.annotation.Backoff;
 import org.springframework.retry.annotation.Retryable;
 import org.springframework.scheduling.annotation.Async;
@@ -68,6 +69,7 @@ import java.time.temporal.ChronoUnit;
 import java.util.*;
 import java.util.concurrent.*;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Function;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
@@ -2099,6 +2101,65 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
         }
     }
 
+    /**
+     * 排查 fs_course_link 重复键:入队时打印 link + 业务指纹,便于与「批量入库前/失败」日志按 link、时间对齐。
+     */
+    private void logFsCourseLinkEnqueue(FsCourseLink courseLink) {
+        if (courseLink == null) {
+            return;
+        }
+        log.info("[FsCourseLink排查][入队] link={} linkType={} companyId={} companyUserId={} qwUserId={} videoId={} courseId={} qwExternalId={} corpId={} createTime={} thread={}",
+                courseLink.getLink(),
+                courseLink.getLinkType(),
+                courseLink.getCompanyId(),
+                courseLink.getCompanyUserId(),
+                courseLink.getQwUserId(),
+                courseLink.getVideoId(),
+                courseLink.getCourseId(),
+                courseLink.getQwExternalId(),
+                courseLink.getCorpId(),
+                courseLink.getCreateTime(),
+                Thread.currentThread().getName());
+    }
+
+    /**
+     * 批量入库前:区分「本批多条相同 link」与「link 唯一但可能撞库已存在」。
+     */
+    private void logFsCourseLinkBatchBeforeInsert(String diagId, List<FsCourseLink> batch) {
+        if (CollectionUtils.isEmpty(batch)) {
+            return;
+        }
+        int size = batch.size();
+        Map<String, Long> linkFreq = batch.stream()
+                .map(FsCourseLink::getLink)
+                .filter(Objects::nonNull)
+                .collect(Collectors.groupingBy(Function.identity(), Collectors.counting()));
+        int distinctLinks = linkFreq.size();
+        List<String> intraDup = linkFreq.entrySet().stream()
+                .filter(e -> e.getValue() > 1)
+                .map(Map.Entry::getKey)
+                .collect(Collectors.toList());
+        int sampleN = Math.min(15, size);
+        List<String> fingerprints = new ArrayList<>(sampleN);
+        for (int i = 0; i < sampleN; i++) {
+            FsCourseLink cl = batch.get(i);
+            fingerprints.add(String.format("link=%s|ext=%s|vid=%s|course=%s|cu=%s",
+                    cl.getLink(),
+                    cl.getQwExternalId(),
+                    cl.getVideoId(),
+                    cl.getCourseId(),
+                    cl.getCompanyUserId()));
+        }
+        log.info("[FsCourseLink排查][批量入库前] diagId={} batchSize={} distinctLinkCount={} intraBatchDuplicateLinks={} thread={} fingerprintSampleSize={} fingerprints={}",
+                diagId,
+                size,
+                distinctLinks,
+                intraDup,
+                Thread.currentThread().getName(),
+                sampleN,
+                String.join(" || ", fingerprints));
+    }
+
     /**
      * 将 FsCourseWatchLog 放入队列
      */
@@ -2108,6 +2169,8 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
             if (!offered) {
                 log.error("FsCourseLink 队列已满,无法添加日志: {}", JSON.toJSONString(courseLink));
                 // 处理队列已满的情况,例如记录到失败队列或持久化存储
+            } else {
+                logFsCourseLinkEnqueue(courseLink);
             }
         } catch (InterruptedException e) {
             Thread.currentThread().interrupt();
@@ -2325,11 +2388,20 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
             backoff = @Backoff(delay = 2000)
     )
     public void batchInsertFsCourseLink(List<FsCourseLink> courseLinkToInsert) {
+        String diagId = UUID.randomUUID().toString().replace("-", "").substring(0, 12);
         try {
+            logFsCourseLinkBatchBeforeInsert(diagId, courseLinkToInsert);
             fsCourseLinkMapper.insertFsCourseLinkBatch(courseLinkToInsert);
-            log.info("批量插入 FsCourseLink 完成,共插入 {} 条记录。", courseLinkToInsert.size());
+            log.info("批量插入 FsCourseLink 完成,共插入 {} 条记录。[FsCourseLink排查] diagId={}", courseLinkToInsert.size(), diagId);
+        } catch (DuplicateKeyException e) {
+            Throwable root = e.getMostSpecificCause() != null ? e.getMostSpecificCause() : e;
+            log.error("[FsCourseLink排查][DuplicateKey] diagId={} batchSize={} rootMsg={} 解读: distinctLinkCount=batchSize且intraBatchDuplicateLinks为空时多为link已在库中(重复入队/MQ重投/任务重跑); 若 intraBatchDuplicateLinks 非空则为同一批内多条相同link。请在同一时间点检索 [入队] 与本文 diagId 关联。",
+                    diagId,
+                    courseLinkToInsert == null ? 0 : courseLinkToInsert.size(),
+                    root.getMessage(),
+                    e);
         } catch (Exception e) {
-            log.error("批量插入 FsCourseLink 失败: {}", e.getMessage(), e);
+            log.error("批量插入 FsCourseLink 失败: {} [FsCourseLink排查] diagId={}", e.getMessage(), diagId, e);
             // 可选:将失败的数据记录到失败队列或持久化存储以便后续重试
         }
     }

+ 5 - 1
fs-service/src/main/java/com/fs/company/mapper/CompanyConfigMapper.java

@@ -98,6 +98,10 @@ public interface CompanyConfigMapper
             "       fcpsc.appid\n" +
             "FROM fs_course_play_source_config fcpsc\n" +
             "WHERE fcpsc.is_del = 0\n" +
-            "  AND fcpsc.company_id = #{companyId}")
+            "  AND (fcpsc.status IS NULL OR fcpsc.status <> 2)\n" +
+            "  AND (\n" +
+            "        fcpsc.company_id = #{companyId}\n" +
+            "        OR (fcpsc.company_ids IS NOT NULL AND fcpsc.company_ids != '' AND FIND_IN_SET(#{companyId}, fcpsc.company_ids))\n" +
+            "      )")
     List<CompanyMiniAppVO> getCompanyMiniAppListByCompany(@Param("companyId") Long companyId);
 }

+ 3 - 2
fs-service/src/main/java/com/fs/company/service/ICompanyConfigService.java

@@ -82,8 +82,9 @@ public interface ICompanyConfigService
     List<CompanyMiniAppVO> getCompanyMiniAppList(Long companyId);
 
     /**
-     * 获取当前公司自身在 fs_course_play_source_config 中配置的小程序列表
-     * 仅返回 company_id = 当前公司、is_del = 0 的记录
+     * 获取当前公司在 fs_course_play_source_config 中可关联的小程序列表
+     * 匹配条件:company_id = 当前公司,或 company_ids 逗号分隔中包含当前公司;is_del = 0
+     * 不包含小程序状态为 2(封禁)的记录;status 为空时仍展示
      * @param companyId
      * @return
      */

+ 1 - 1
fs-service/src/main/java/com/fs/company/service/impl/CompanyConfigServiceImpl.java

@@ -194,7 +194,7 @@ public class CompanyConfigServiceImpl implements ICompanyConfigService
     }
 
     /**
-     * 获取当前公司自身在 fs_course_play_source_config 中配置的小程序列表
+     * 获取当前公司在 fs_course_play_source_config 中可关联的小程序列表(含 company_ids 多公司绑定;排除 status=2 封禁)
      * @param companyId
      * @return
      */

+ 4 - 0
fs-service/src/main/java/com/fs/course/mapper/FsCourseAnswerLogsMapper.java

@@ -1,5 +1,7 @@
 package com.fs.course.mapper;
 
+import com.fs.common.annotation.DataSource;
+import com.fs.common.enums.DataSourceType;
 import com.fs.course.domain.FsCourseAnswerLogs;
 import com.fs.course.param.FsCourseAnswerLogsParam;
 import com.fs.course.vo.FsCourseAnswerLogsListVO;
@@ -132,8 +134,10 @@ public interface FsCourseAnswerLogsMapper
 
     Long selectRedStatus(@Param("userId") Long userId, @Param("videoId") Long videoId, @Param("periodId") Long periodId);
     Integer selectRedStatus2(@Param("userId") Long userId, @Param("videoId") Long videoId, @Param("periodId") Long periodId);
+    @DataSource(DataSourceType.SLAVE)
     List<FsCourseAnswerLogsListVO> selectFsCourseAnswerLogsListVONew(FsCourseAnswerLogsParam param);
 
+    @DataSource(DataSourceType.SLAVE)
     Long selectFsCourseAnswerLogsListVONewCount(FsCourseAnswerLogsParam param);
 
     @Select("select * from fs_course_answer_logs where video_id = #{videoId} and user_id = #{userId} and is_right = 1 limit 1")

+ 16 - 2
fs-service/src/main/java/com/fs/course/mapper/FsCourseWatchLogMapper.java

@@ -1,6 +1,8 @@
 package com.fs.course.mapper;
 
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.common.annotation.DataSource;
+import com.fs.common.enums.DataSourceType;
 import com.fs.course.domain.FsCourseWatchLog;
 import com.fs.course.dto.WatchLogDTO;
 import com.fs.course.param.*;
@@ -133,10 +135,22 @@ public interface FsCourseWatchLogMapper extends BaseMapper<FsCourseWatchLog> {
     @Select("select l.*,v.title,c.course_name from fs_course_watch_log l LEFT JOIN fs_user_course_video v ON v.video_id = l.video_id LEFT JOIN fs_user_course c ON c.course_id = l.course_id WHERE l.qw_external_contact_id =#{id} ORDER BY l.create_time  desc LIMIT 1  ")
     FsCourseWatchLogVO selectFsCourseWatchLogByExtId(Long id);
 
-    @Select("select l.qw_external_contact_id,l.company_id,l.company_user_id,l.log_type,ext.`level`,l.qw_user_id FROM fs_course_watch_log l LEFT JOIN qw_external_contact ext ON ext.id =l.qw_external_contact_id  where l.sop_id=#{SopId} and  date(l.create_time)= CURDATE() and l.log_type =3 and l.video_id not in (select video_id from fs_user_course_video WHERE is_first=1 ) ")
+    @DataSource(DataSourceType.SLAVE)
+    @Select("SELECT l.qw_external_contact_id,l.company_id,l.company_user_id,l.log_type,ext.`level`,l.qw_user_id "
+            + "FROM fs_course_watch_log l "
+            + "LEFT JOIN qw_external_contact ext ON ext.id = l.qw_external_contact_id "
+            + "WHERE l.sop_id = #{SopId} AND l.log_type = 3 "
+            + "AND l.create_time >= CURDATE() AND l.create_time < CURDATE() + INTERVAL 1 DAY "
+            + "AND NOT EXISTS (SELECT 1 FROM fs_user_course_video v WHERE v.video_id = l.video_id AND v.is_first = 1)")
     List<FsCourseWatchLogTaskVO> selectFsCourseWatchLogByDaySopId3(@Param("SopId")String SopId);
 
-    @Select("select l.qw_external_contact_id,l.company_id,l.company_user_id,l.log_type,ext.`level`,l.qw_user_id FROM fs_course_watch_log l LEFT JOIN qw_external_contact ext ON ext.id =l.qw_external_contact_id  where l.sop_id=#{SopId} and  date(l.create_time)= CURDATE() and l.log_type =4 and l.video_id not in (select video_id from fs_user_course_video WHERE is_first=1 ) ")
+    @DataSource(DataSourceType.SLAVE)
+    @Select("SELECT l.qw_external_contact_id,l.company_id,l.company_user_id,l.log_type,ext.`level`,l.qw_user_id "
+            + "FROM fs_course_watch_log l "
+            + "LEFT JOIN qw_external_contact ext ON ext.id = l.qw_external_contact_id "
+            + "WHERE l.sop_id = #{SopId} AND l.log_type = 4 "
+            + "AND l.create_time >= CURDATE() AND l.create_time < CURDATE() + INTERVAL 1 DAY "
+            + "AND NOT EXISTS (SELECT 1 FROM fs_user_course_video v WHERE v.video_id = l.video_id AND v.is_first = 1)")
     List<FsCourseWatchLogTaskVO> selectFsCourseWatchLogByDaySopId4(@Param("SopId")String SopId);
 
 //    @Select("   SELECT\n" +

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

@@ -77,6 +77,12 @@ public interface FsUserCourseVideoRedPackageMapper
     @Select("select * from fs_user_course_video_red_package where video_id =#{videoId} and company_id = #{companyId} and period_id = #{periodId}")
     FsUserCourseVideoRedPackage selectRedPacketByCompanyId(@Param("videoId") Long videoId,@Param("companyId") Long companyId, @Param("periodId") Long periodId);
 
+
+    @Select("select * from fs_user_course_video_red_package where (del_flag = '0' or del_flag is null) " +
+            "and data_type = 1 and video_id = #{videoId} and company_id = #{companyId} " +
+            "and (period_id is null or period_id = 0) order by id desc limit 1")
+    FsUserCourseVideoRedPackage selectRedPacketByCompanyIdZd(@Param("videoId") Long videoId, @Param("companyId") Long companyId);
+
     /**
      * 批量查询匹配的红包数据
      *

+ 256 - 54
fs-service/src/main/java/com/fs/course/service/impl/FsCourseRedPacketLogServiceImpl.java

@@ -4,6 +4,7 @@ import java.math.BigDecimal;
 import java.time.LocalDateTime;
 import java.time.format.DateTimeFormatter;
 import java.util.*;
+import java.util.concurrent.TimeUnit;
 import java.util.stream.Collectors;
 
 import cn.hutool.core.collection.CollectionUtil;
@@ -15,15 +16,19 @@ import com.fs.common.utils.StringUtils;
 import com.fs.company.cache.ICompanyDeptCacheService;
 import com.fs.company.domain.Company;
 import com.fs.company.domain.CompanyMoneyLogs;
+import com.fs.common.constant.FsConstants;
+import com.fs.common.core.redis.RedisCache;
 import com.fs.company.domain.CompanyRedPacketBalanceLogs;
 import com.fs.company.mapper.CompanyMapper;
 import com.fs.company.mapper.CompanyMoneyLogsMapper;
 import com.fs.company.mapper.CompanyRedPacketBalanceLogsMapper;
 import com.fs.company.service.ICompanyConfigService;
-import com.fs.company.service.ICompanyConfigService;
+import com.fs.company.service.ICompanyService;
 import com.fs.course.config.CourseConfig;
 import com.fs.course.config.RedPacketConfig;
+import com.fs.course.domain.BalanceRollbackError;
 import com.fs.course.domain.FsCourseWatchLog;
+import com.fs.course.mapper.BalanceRollbackErrorMapper;
 import com.fs.course.mapper.FsCourseRedPacketLogMapper;
 import com.fs.course.mapper.FsCourseWatchLogMapper;
 import com.fs.course.mapper.FsUserCoursePeriodMapper;
@@ -31,7 +36,9 @@ import com.fs.course.param.FsCourseRedPacketLogParam;
 import com.fs.course.vo.FsCourseRedPacketLogListPVO;
 import com.fs.course.vo.FsCourseRedPacketLogListVO;
 import com.fs.his.domain.FsUser;
+import com.fs.his.domain.FsUserWx;
 import com.fs.his.mapper.FsUserMapper;
+import com.fs.his.service.IFsUserWxService;
 import com.fs.his.param.WxSendRedPacketParam;
 import com.fs.his.service.IFsStorePaymentService;
 import com.fs.his.vo.OptionsVO;
@@ -45,12 +52,14 @@ import com.github.binarywang.wxpay.exception.WxPayException;
 import com.github.binarywang.wxpay.service.TransferService;
 import com.github.binarywang.wxpay.service.WxPayService;
 import com.github.binarywang.wxpay.service.impl.WxPayServiceImpl;
+import org.redisson.api.RLock;
+import org.redisson.api.RedissonClient;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.BeanUtils;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
-import com.fs.course.mapper.FsCourseRedPacketLogMapper;
 import com.fs.course.domain.FsCourseRedPacketLog;
 import com.fs.course.service.IFsCourseRedPacketLogService;
 import org.springframework.transaction.annotation.Transactional;
@@ -239,76 +248,269 @@ public class FsCourseRedPacketLogServiceImpl implements IFsCourseRedPacketLogSer
     @Autowired
     private FsUserMapper fsUserMapper;
     @Autowired
+    private IFsUserWxService fsUserWxService;
+    @Autowired
     private FsCourseWatchLogMapper courseWatchLogMapper;
     @Autowired
     private CompanyMoneyLogsMapper moneyLogsMapper;
+    @Autowired
+    private RedissonClient redissonClient;
+    @Autowired
+    private RedisCache redisCache;
+    @Autowired
+    private ICompanyService companyService;
+    @Autowired
+    private BalanceRollbackErrorMapper balanceRollbackErrorMapper;
+
+    @Value("${isNewWxMerchant}")
+    private Boolean isNewWxMerchant;
     @Override
     @Transactional
     public R retryCourseRedPacketLog(Long[] logIds) {
-        int suc=0;
-        int err=0;
-        for (int i = 0; i < logIds.length; i++) {
-            Long id = logIds[i];
+        int suc = 0;
+        int err = 0;
+        CourseConfig courseConfig = JSONUtil.toBean(configService.selectConfigByKey("course.config"), CourseConfig.class);
+        int redPacketMode = courseConfig != null && courseConfig.getRedPacketMode() != null
+                ? courseConfig.getRedPacketMode() : 1;
+        boolean useRedisDeduction = courseConfig != null && "1".equals(courseConfig.getIsRedPackageBalanceDeduction());
+
+        for (Long id : logIds) {
             FsCourseRedPacketLog param = fsCourseRedPacketLogMapper.selectFsCourseRedPacketLogByLogId(id);
 
+            if (param == null || param.getStatus() != 0) {
+                err++;
+                continue;
+            }
+
+            Company company = companyMapper.selectCompanyById(param.getCompanyId());
+            if (company == null) {
+                err++;
+                continue;
+            }
+
+            BigDecimal amount = param.getAmount();
+            if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
+                err++;
+                continue;
+            }
+
+            FsUser user = fsUserMapper.selectFsUserByUserId(param.getUserId());
+            if (user == null) {
+                err++;
+                continue;
+            }
+
+            String appId = StringUtils.isNotBlank(param.getAppId()) ? param.getAppId() : company.getCourseMiniAppId();
+            if (StringUtils.isBlank(appId) && courseConfig != null && StringUtils.isNotBlank(courseConfig.getLoginMiniAppId())) {
+                appId = courseConfig.getLoginMiniAppId();
+            }
 
-            if (param!=null&&param.getStatus()==2){
-                Company company = companyMapper.selectCompanyByIdForUpdate(param.getCompanyId());
-                BigDecimal amount = param.getAmount();
-                BigDecimal money = company.getMoney();
-                BigDecimal subtract = money.subtract(amount);
-                if (subtract.compareTo(BigDecimal.ZERO)<0){
+            WxSendRedPacketParam packetParam = new WxSendRedPacketParam();
+            packetParam.setOpenId(user.getMpOpenId());
+            if (user.getMpOpenId() != null && !Boolean.TRUE.equals(isNewWxMerchant)) {
+                packetParam.setOpenId(user.getMpOpenId());
+            } else {
+                if (StringUtils.isBlank(appId)) {
+                    logger.warn("补发红包失败:无法解析小程序 appId,logId={}", id);
+                    err++;
+                    continue;
+                }
+                FsUserWx fsUserWx = fsUserWxService.selectByAppIdAndUserId(appId, param.getUserId(), 1);
+                if (fsUserWx == null || StringUtils.isBlank(fsUserWx.getOpenId())) {
+                    logger.warn("补发红包失败:fs_user_wx 缺少 openId,logId={} appId={} userId={}", id, appId, param.getUserId());
                     err++;
+                    continue;
                 }
-                WxSendRedPacketParam packetParam = new WxSendRedPacketParam();
-                FsUser user = fsUserMapper.selectFsUserByUserId(param.getUserId());
-                packetParam.setOpenId(user.getMaOpenId());
-                packetParam.setOpenId(user.getCourseMaOpenId());
-                packetParam.setAmount(param.getAmount());
-                packetParam.setSource(2);
-                packetParam.setRedPacketMode(1);
-                packetParam.setCompanyId(param.getCompanyId());
-                packetParam.setUser(user);
-                R sendRedPacket = paymentService.sendRedPacket(packetParam);
-                if (sendRedPacket.get("code").equals(200)) {
-                    FsCourseRedPacketLog redPacketLog = new FsCourseRedPacketLog();
-                    TransferBillsResult transferBillsResult;
-                    if (sendRedPacket.get("isNew").equals(1)){
-                        transferBillsResult = (TransferBillsResult)sendRedPacket.get("data");
-                        redPacketLog.setResult(JSON.toJSONString(sendRedPacket));
-                        redPacketLog.setOutBatchNo(transferBillsResult.getOutBillNo());
-                    }else {
-                        redPacketLog.setOutBatchNo(sendRedPacket.get("orderCode").toString());
+                packetParam.setOpenId(fsUserWx.getOpenId());
+            }
+
+            if (courseConfig != null && StringUtils.isNotEmpty(courseConfig.getMpAppId())) {
+                packetParam.setMpAppId(courseConfig.getMpAppId());
+            }
+            packetParam.setAmount(amount);
+            packetParam.setSource(2);
+            packetParam.setRedPacketMode(redPacketMode);
+            packetParam.setCompanyId(param.getCompanyId());
+            packetParam.setAppId(appId);
+            packetParam.setUser(user);
+
+            if (useRedisDeduction) {
+                if (packetParam.getCompanyId() == null) {
+                    err++;
+                    continue;
+                }
+
+                BalanceRollbackError balanceRollbackError = new BalanceRollbackError();
+                balanceRollbackError.setCompanyId(packetParam.getCompanyId());
+                balanceRollbackError.setUserId(param.getUserId());
+                balanceRollbackError.setLogId(param.getWatchLogId());
+                balanceRollbackError.setVideoId(param.getVideoId());
+                balanceRollbackError.setStatus(0);
+                balanceRollbackError.setMoney(amount);
+
+                String companyMoneyKey = FsConstants.COMPANY_MONEY_KEY + packetParam.getCompanyId();
+                RLock lock1 = redissonClient.getLock(FsConstants.COMPANY_MONEY_LOCK + packetParam.getCompanyId());
+                boolean lockAcquired = false;
+                BigDecimal newMoney = null;
+                try {
+                    if (lock1.tryLock(3, 10, TimeUnit.SECONDS)) {
+                        lockAcquired = true;
+                        String moneyStr = redisCache.getCacheObject(companyMoneyKey);
+                        if (StringUtils.isEmpty(moneyStr)) {
+                            logger.error("补发红包获取 redis 余额缓存异常,logId={} companyId={}", id, packetParam.getCompanyId());
+                            err++;
+                            continue;
+                        }
+                        BigDecimal originalMoney = new BigDecimal(moneyStr);
+                        if (originalMoney.compareTo(BigDecimal.ZERO) < 0.3) {
+                            logger.warn("补发红包 redis 余额不足,logId={} 当前余额={}", id, originalMoney);
+                            err++;
+                            continue;
+                        }
+                        newMoney = originalMoney.subtract(amount);
+                        redisCache.setCacheObject(companyMoneyKey, newMoney.toString());
+                    } else {
+                        logger.warn("补发红包获取 redis 锁失败,logId={}", id);
+                        err++;
+                        continue;
+                    }
+                } catch (Exception e) {
+                    logger.error("补发红包预扣 redis 余额失败 logId={}", id, e);
+                    err++;
+                    continue;
+                } finally {
+                    if (lockAcquired && lock1.isHeldByCurrentThread()) {
+                        try {
+                            lock1.unlock();
+                        } catch (IllegalMonitorStateException e) {
+                            logger.warn("补发红包释放 redis 锁异常 companyId={}", packetParam.getCompanyId());
+                        }
                     }
-                    FsCourseWatchLog log = new FsCourseWatchLog();
-                    log.setLogId(param.getWatchLogId());
-                    log.setRewardType(1);
-                    courseWatchLogMapper.updateFsCourseWatchLog(log);
-                    // 添加红包记录
-                    redPacketLog.setLogId(param.getLogId());
-                    redPacketLog.setStatus(0);
-                    fsCourseRedPacketLogMapper.updateFsCourseRedPacketLog(redPacketLog);
-                    // 更新观看记录的奖励类型
-                    company.setMoney(subtract);
-                    companyMapper.updateCompany(company);
-                    CompanyMoneyLogs logs=new CompanyMoneyLogs();
-                    logs.setCompanyId(company.getCompanyId());
-                    logs.setRemark("扣除红包金额");
-                    logs.setMoney(amount.multiply(new BigDecimal(-1)));
-                    logs.setLogsType(15);
-                    logs.setBalance(company.getMoney());
-                    logs.setCreateTime(new Date());
-                    moneyLogsMapper.insertCompanyMoneyLogs(logs);
+                }
+
+                R sendRedPacket;
+                try {
+                    sendRedPacket = paymentService.sendRedPacket(packetParam);
+                } catch (Exception e) {
+                    logger.error("补发红包接口异常 logId={}", id, e);
+                    rollbackBalance(balanceRollbackError);
+                    err++;
+                    continue;
+                }
+
+                if (sendRedPacket != null && sendRedPacket.get("code") != null && sendRedPacket.get("code").equals(200)) {
+                    updateRedPacketAndWatchLogAfterRetry(param, sendRedPacket, appId, courseConfig);
+                    BigDecimal moneyNeg = amount.multiply(BigDecimal.valueOf(-1));
+                    companyService.asyncRecordBalanceLog(param.getCompanyId(), moneyNeg, 15, newMoney, "发放红包", param.getLogId());
                     suc++;
-                }else {
+                } else {
+                    rollbackBalance(balanceRollbackError);
                     err++;
                 }
-            }else {
-                err++;
+            } else {
+                if (company.getMoney().compareTo(BigDecimal.ZERO) <= 0) {
+                    err++;
+                    continue;
+                }
+                R sendRedPacket;
+                try {
+                    sendRedPacket = paymentService.sendRedPacket(packetParam);
+                } catch (Exception e) {
+                    logger.error("补发红包接口异常 logId={}", id, e);
+                    err++;
+                    continue;
+                }
+
+                if (sendRedPacket != null && sendRedPacket.get("code") != null && sendRedPacket.get("code").equals(200)) {
+                    updateRedPacketAndWatchLogAfterRetry(param, sendRedPacket, appId, courseConfig);
+                    suc++;
+                } else {
+                    err++;
+                }
+            }
+        }
+        return R.ok("成功:" + suc + " 失败:" + err);
+    }
+
+
+    private void updateRedPacketAndWatchLogAfterRetry(FsCourseRedPacketLog param, R sendRedPacket, String appId,
+            CourseConfig courseConfig) {
+        FsCourseRedPacketLog redPacketLog = new FsCourseRedPacketLog();
+
+        if (sendRedPacket.get("isNew") != null && sendRedPacket.get("isNew").equals(1)) {
+            TransferBillsResult transferBillsResult = (TransferBillsResult) sendRedPacket.get("data");
+            redPacketLog.setResult(JSON.toJSONString(sendRedPacket));
+            redPacketLog.setOutBatchNo(transferBillsResult.getOutBillNo());
+            if (transferBillsResult.getTransferBillNo() != null) {
+                redPacketLog.setBatchId(transferBillsResult.getTransferBillNo());
+            }
+        } else {
+            Object orderCode = sendRedPacket.get("orderCode");
+            if (orderCode != null) {
+                redPacketLog.setOutBatchNo(orderCode.toString());
+            }
+            Object batchIdObj = sendRedPacket.get("batchId");
+            if (batchIdObj != null) {
+                redPacketLog.setBatchId(batchIdObj.toString());
             }
+            redPacketLog.setResult(JSON.toJSONString(sendRedPacket));
+        }
+        Object mchIdObj = sendRedPacket.get("mchId");
+        if (mchIdObj != null && StringUtils.isNotEmpty(String.valueOf(mchIdObj))) {
+            redPacketLog.setMchId(String.valueOf(mchIdObj));
+        }
 
+        if (param.getWatchLogId() != null) {
+            FsCourseWatchLog log = new FsCourseWatchLog();
+            log.setLogId(param.getWatchLogId());
+            int rewardType = courseConfig != null && courseConfig.getRewardType() != null ? courseConfig.getRewardType() : 1;
+            log.setRewardType(rewardType);
+            courseWatchLogMapper.updateFsCourseWatchLog(log);
+        }
+
+        redPacketLog.setLogId(param.getLogId());
+        redPacketLog.setStatus(0);
+        if (StringUtils.isBlank(param.getAppId()) && StringUtils.isNotBlank(appId)) {
+            redPacketLog.setAppId(appId);
+        }
+        fsCourseRedPacketLogMapper.updateFsCourseRedPacketLog(redPacketLog);
+    }
+
+    private void rollbackBalance(BalanceRollbackError balanceRollbackError) {
+        String companyMoneyKey = FsConstants.COMPANY_MONEY_KEY + balanceRollbackError.getCompanyId();
+        RLock lock2 = redissonClient.getLock(FsConstants.COMPANY_MONEY_LOCK + balanceRollbackError.getCompanyId());
+        boolean lockAcquired = false;
+        boolean backError = true;
+        try {
+            if (lock2.tryLock(3, 10, TimeUnit.SECONDS)) {
+                lockAcquired = true;
+                String currentMoneyStr = redisCache.getCacheObject(companyMoneyKey);
+                if (StringUtils.isEmpty(currentMoneyStr)) {
+                    throw new RuntimeException("回滚余额异常");
+                }
+                BigDecimal rollbackMoney = new BigDecimal(currentMoneyStr).add(balanceRollbackError.getMoney());
+                redisCache.setCacheObject(companyMoneyKey, rollbackMoney.toString());
+                backError = false;
+                logger.info("补发红包余额回滚成功: companyId={}, amount={}", balanceRollbackError.getCompanyId(),
+                        balanceRollbackError.getMoney());
+            } else {
+                logger.warn("补发红包回滚余额时获取锁失败: companyId={}", balanceRollbackError.getCompanyId());
+                balanceRollbackErrorMapper.insert(balanceRollbackError);
+            }
+        } catch (Exception e) {
+            logger.error("补发红包回滚余额时发生异常: companyId={}", balanceRollbackError.getCompanyId(), e);
+            if (backError) {
+                balanceRollbackErrorMapper.insert(balanceRollbackError);
+            }
+        } finally {
+            if (lockAcquired && lock2.isHeldByCurrentThread()) {
+                try {
+                    lock2.unlock();
+                } catch (IllegalMonitorStateException e) {
+                    logger.warn("补发红包回滚释放锁异常: balanceRollbackError={}", balanceRollbackError);
+                }
+            }
         }
-        return R.ok("成功:"+suc+" 失败:"+err);
     }
 
     @Override

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

@@ -1844,7 +1844,7 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
         }
         // 确定红包金额
         BigDecimal amount = BigDecimal.ZERO;
-        FsUserCourseVideoRedPackage redPackage = fsUserCourseVideoRedPackageMapper.selectRedPacketByCompanyId(param.getVideoId(), param.getCompanyId(), param.getPeriodId());
+        FsUserCourseVideoRedPackage redPackage = fsUserCourseVideoRedPackageMapper.selectRedPacketByCompanyIdZd(param.getVideoId(), param.getCompanyId());
 
         if (redPackage != null) {
             amount = redPackage.getRedPacketMoney();

+ 3 - 0
fs-service/src/main/java/com/fs/qw/mapper/QwContactWayMapper.java

@@ -1,6 +1,8 @@
 package com.fs.qw.mapper;
 
 import com.alibaba.fastjson.JSONObject;
+import com.fs.common.annotation.DataSource;
+import com.fs.common.enums.DataSourceType;
 import com.fs.qw.domain.QwContactWay;
 import com.fs.qw.param.QwStatisticsParam;
 import com.fs.qw.vo.QwWayStatisticsListVO;
@@ -36,6 +38,7 @@ public interface QwContactWayMapper
      * @param qwContactWay 企微活码
      * @return 企微活码集合
      */
+    @DataSource(DataSourceType.SLAVE)
     public List<QwContactWay> selectQwContactWayList(QwContactWay qwContactWay);
 
     /**

+ 1 - 0
fs-service/src/main/java/com/fs/qw/mapper/QwExternalContactMapper.java

@@ -313,6 +313,7 @@ public interface QwExternalContactMapper extends BaseMapper<QwExternalContact> {
             "        </where>"+
             "order by ec.create_time desc,ec.id desc"+
             "</script>"})
+    @DataSource(DataSourceType.SLAVE)
     List<QwExternalContactVO> selectQwExternalContactListVO(QwExternalContactParam qwExternalContact);
 
     @Select({"<script> " +

+ 3 - 0
fs-service/src/main/java/com/fs/qw/mapper/QwGroupChatUserMapper.java

@@ -1,5 +1,7 @@
 package com.fs.qw.mapper;
 
+import com.fs.common.annotation.DataSource;
+import com.fs.common.enums.DataSourceType;
 import com.fs.qw.domain.QwGroupChatUser;
 import com.fs.qw.param.QwGroupChatUserDataType;
 import com.fs.qw.vo.QwGroupChatUserVO;
@@ -152,5 +154,6 @@ public interface QwGroupChatUserMapper
 
     List<QwGroupChatUser> selectByChatId(SopUserLogsInfo sopUserLogsInfo);
 
+    @DataSource(DataSourceType.SLAVE)
     List<String> selectChatNameByUserId(@Param("userId")String userId);
 }

+ 3 - 0
fs-service/src/main/java/com/fs/qw/mapper/QwRestrictionPushRecordMapper.java

@@ -1,6 +1,8 @@
 package com.fs.qw.mapper;
 
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.common.annotation.DataSource;
+import com.fs.common.enums.DataSourceType;
 import com.fs.fastGpt.domain.FastGptPushTokenTotal;
 import com.fs.fastGpt.param.FastGptPushTokenDeptTotalParam;
 import com.fs.fastGpt.vo.FastGptPushTokenDeptTotalVO;
@@ -49,6 +51,7 @@ public interface QwRestrictionPushRecordMapper extends BaseMapper<QwRestrictionP
             "</script>")
     public int deleteQwRestrictionPushRecordIds(@Param("ids") Long[] ids);
 
+    @DataSource(DataSourceType.SLAVE)
     List<FastGptPushTokenTotal> selectFastgptPushTokenTotal(String dateTime);
 
     FastGptPushTokenTotal selectFastGptPushTokenTotalByInfo(FastGptPushTokenTotal fastGptPushTotal);

+ 3 - 0
fs-service/src/main/java/com/fs/qw/mapper/QwTagMapper.java

@@ -1,5 +1,7 @@
 package com.fs.qw.mapper;
 
+import com.fs.common.annotation.DataSource;
+import com.fs.common.enums.DataSourceType;
 import com.fs.qw.domain.QwTag;
 import com.fs.qw.param.QwTagSearchParam;
 import com.fs.qw.param.newparam.ContactTagListParam;
@@ -96,6 +98,7 @@ public interface QwTagMapper
             "</script>")
     List<String> selectQwTagListNameByTagIds(@Param("date") QwTagSearchParam param);
 
+    @DataSource(DataSourceType.SLAVE)
     List<QwTag> selectQwTagListByTagIdsNew(@Param("tagIds") List<String> tagIds);
 
     List<QwTagVO> selectTagListByUserId(@Param("param") ContactTagListParam param,  @Param("keywords") String[] keywords);

+ 2 - 1
fs-service/src/main/java/com/fs/qw/service/AsyncQwAiChatSopService.java

@@ -217,7 +217,8 @@ public class AsyncQwAiChatSopService {
                     clonedContent.setVideoId(Long.valueOf(content.getVideoId()));
                     clonedContent.setSetting(settingList);
                     clonedContent.setType(content.getType());
-                    clonedContent.setCourseType(0);
+                    Integer ruleCourseType = content.getCourseType();
+                    clonedContent.setCourseType(ruleCourseType != null ? ruleCourseType : 0);
                     sopLogs.setContentJson(JSON.toJSONString(clonedContent));
                     sopLogsList.add(sopLogs);
                 });

+ 86 - 1
fs-service/src/main/java/com/fs/qw/service/impl/QwExternalContactServiceImpl.java

@@ -21,13 +21,17 @@ import com.fs.config.cloud.CloudHostProper;
 import com.fs.course.domain.FsCourseSop;
 import com.fs.course.domain.FsCourseSopLogs;
 import com.fs.course.domain.FsCourseWatchLog;
+import com.fs.course.domain.FsUserCourse;
 import com.fs.course.mapper.FsCourseLinkMapper;
 import com.fs.course.mapper.FsCourseSopLogsMapper;
 import com.fs.course.mapper.FsCourseSopMapper;
 import com.fs.course.mapper.FsCourseWatchLogMapper;
+import com.fs.course.mapper.FsUserCourseMapper;
 import com.fs.course.param.FsCourseLinkCreateParam;
 import com.fs.course.param.FsCourseListBySidebarParam;
 import com.fs.course.service.IFsCourseLinkService;
+import com.fs.course.service.IFsUserCourseVideoService;
+import com.fs.course.vo.FsUserCourseVideoQVO;
 import com.fs.crm.domain.CrmCustomer;
 import com.fs.crm.mapper.CrmCustomerMapper;
 import com.fs.his.domain.FsUser;
@@ -78,12 +82,15 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.BeanUtils;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Lazy;
 import org.springframework.data.redis.core.RedisTemplate;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
 import java.io.IOException;
 import java.math.BigDecimal;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
 import java.text.ParseException;
 import java.text.SimpleDateFormat;
 import java.time.*;
@@ -107,6 +114,10 @@ public class QwExternalContactServiceImpl extends ServiceImpl<QwExternalContactM
 
     private static final String miniappRealLink = "/pages_course/video.html?course=";
 
+    /** 欢迎语补发 SOP 日志中小程序卡片封面兜底图(解析课程图失败时使用) */
+    private static final String DEFAULT_REISSUE_MINIPROGRAM_PIC =
+            "https://cos.his.cdwjyyh.com/fs/20250331/ec2b4e73be8048afbd526124a655ad56.png";
+
     @Autowired
     private QwExternalContactMapper qwExternalContactMapper;
     @Autowired
@@ -199,6 +210,13 @@ public class QwExternalContactServiceImpl extends ServiceImpl<QwExternalContactM
     @Autowired
     private IFsCourseLinkService iFsCourseLinkService;
 
+    @Lazy
+    @Autowired
+    private IFsUserCourseVideoService fsUserCourseVideoService;
+
+    @Autowired
+    private FsUserCourseMapper fsUserCourseMapper;
+
     @Autowired
     private IQwExternalErrRetryService errRetryService;
 
@@ -2971,7 +2989,7 @@ public class QwExternalContactServiceImpl extends ServiceImpl<QwExternalContactM
                     settingItem.setContentType("4");
                     settingItem.setMiniprogramAppid(item.getMiniprogram().getAppid());
                     settingItem.setMiniprogramPage(item.getMiniprogram().getPage());
-                    settingItem.setMiniprogramPicUrl("https://cos.his.cdwjyyh.com/fs/20250331/ec2b4e73be8048afbd526124a655ad56.png");
+                    settingItem.setMiniprogramPicUrl(resolveMiniprogramCoverPicUrlForSopLog(item.getMiniprogram()));
                     settingItem.setMiniprogramTitle(item.getMiniprogram().getTitle());
                     break;
                 default:
@@ -2986,6 +3004,73 @@ public class QwExternalContactServiceImpl extends ServiceImpl<QwExternalContactM
         return setting;
     }
 
+    /**
+     * 补发欢迎语写入 SOP 日志时:小程序卡片封面优先取课节缩略图 thumbnail,
+     * 与后台 {@code FsUserCourseVideoController#getInfo} 中 {@code selectFsUserCourseVideoByVideoIdVO(videoId, null)} 一致;
+     * 若无 thumbnail 再尝试课程封面;videoId/courseId 可从附件或 page 上 {@code ?course=} JSON 解析。
+     */
+    private String resolveMiniprogramCoverPicUrlForSopLog(SendWelcomeMsgParam.Attachment.MiniProgramAttachment miniprogram) {
+        if (miniprogram == null) {
+            return DEFAULT_REISSUE_MINIPROGRAM_PIC;
+        }
+        Long videoId = null;
+        Long courseId = null;
+        if (!StringUtil.strIsNullOrEmpty(miniprogram.getVideoId())) {
+            try {
+                videoId = Long.valueOf(miniprogram.getVideoId());
+            } catch (NumberFormatException ignored) {
+            }
+        }
+        if (!StringUtil.strIsNullOrEmpty(miniprogram.getCourseId())) {
+            try {
+                courseId = Long.valueOf(miniprogram.getCourseId());
+            } catch (NumberFormatException ignored) {
+            }
+        }
+        if (videoId == null && courseId == null && !StringUtil.strIsNullOrEmpty(miniprogram.getPage())) {
+            String page = miniprogram.getPage();
+            int q = page.indexOf("?course=");
+            if (q >= 0) {
+                String jsonPart = page.substring(q + "?course=".length());
+                try {
+                    if (jsonPart.contains("%")) {
+                        jsonPart = URLDecoder.decode(jsonPart, StandardCharsets.UTF_8.name());
+                    }
+                    com.alibaba.fastjson.JSONObject obj = JSON.parseObject(jsonPart);
+                    if (obj != null) {
+                        if (obj.containsKey("videoId") && obj.get("videoId") != null) {
+                            try {
+                                videoId = obj.getLong("videoId");
+                            } catch (Exception ignored) {
+                            }
+                        }
+                        if (obj.containsKey("courseId") && obj.get("courseId") != null) {
+                            try {
+                                courseId = obj.getLong("courseId");
+                            } catch (Exception ignored) {
+                            }
+                        }
+                    }
+                } catch (Exception e) {
+                    logger.debug("解析欢迎语小程序 path 中的 course 参数失败: {}", e.getMessage());
+                }
+            }
+        }
+        if (videoId != null) {
+            FsUserCourseVideoQVO videoQvo = fsUserCourseVideoService.selectFsUserCourseVideoByVideoIdVO(videoId, null);
+            if (videoQvo != null && StringUtils.isNotEmpty(videoQvo.getThumbnail())) {
+                return videoQvo.getThumbnail();
+            }
+        }
+        if (courseId != null) {
+            FsUserCourse course = fsUserCourseMapper.selectFsUserCourseByCourseId(courseId);
+            if (course != null && StringUtils.isNotEmpty(course.getImgUrl())) {
+                return course.getImgUrl();
+            }
+        }
+        return DEFAULT_REISSUE_MINIPROGRAM_PIC;
+    }
+
     @Override
     public void insertQwExternalContactByExternalUserId2(String externalUserID, String userID, Long companyId, String corpId, String state, String welcomeCode) throws ParseException {
 

+ 3 - 0
fs-service/src/main/java/com/fs/sop/domain/QwSopTempContent.java

@@ -48,6 +48,9 @@ public class QwSopTempContent{
     @TableField(exist = false)
     private Integer type;
 
+    @TableField(exist = false)
+    private Integer courseType;
+
     /**
      *  课程
      */

+ 1 - 10
fs-service/src/main/java/com/fs/sop/mapper/QwSopTempContentMapper.java

@@ -91,16 +91,7 @@ public interface QwSopTempContentMapper extends BaseMapper<QwSopTempContent>{
             "\tAND c.content_type = 3")
     List<QwSopTempContentVO> updateSiFenTemp();
 
-    @Select("select   tc.id,\n" +
-            "  tc.content_type,\n" +
-            "  tc.content,\n" +
-            "  tr.course_id,\n" +
-            "  tr.video_id," +
-            "  tr.content_type as type " +
-            "FROM\n" +
-            "  qw_sop_temp_content tc " +
-            "left join qw_sop_temp_rules tr on  tc.rules_id=tr.id " +
-            "where tc.temp_id=#{tempId}")
+
     List<QwSopTempContent> selectQwSopTempContentByTempIdAndRules(@Param("tempId") String tempId);
 
     List<QwSopTempContent> selectQwSopTempContentByTempId(@Param("tempId") String tempId);

+ 3 - 0
fs-service/src/main/java/com/fs/store/mapper/FsUserCourseCountMapper.java

@@ -1,5 +1,7 @@
 package com.fs.store.mapper;
 
+import com.fs.common.annotation.DataSource;
+import com.fs.common.enums.DataSourceType;
 import com.fs.store.domain.FsUserCourseCount;
 import com.fs.store.vo.FsUserLastCount;
 import org.apache.ibatis.annotations.Param;
@@ -78,6 +80,7 @@ public interface FsUserCourseCountMapper
      * @param pageSize 每页数量
      * @return
      */
+    @DataSource(DataSourceType.SLAVE)
     List<Long> getUsersByPage(@Param("offset") int offset, @Param("pageSize") int pageSize);
 
     /**

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

@@ -128,6 +128,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         </foreach>
     </delete>
 
+    <!-- 重粉看课:仅展示同一粉丝在同一项目下、由至少两个不同企微员工产生过绑定/看课记录的数据 -->
     <select id="getWatchLogList" resultType="com.fs.course.vo.UserWatchLogListVo">
         select
         a.*,
@@ -139,6 +140,24 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         qc.corp_name
         from
         fs_user_company_bind a
+        inner join (
+            select fs_user_id, project_id
+            from fs_user_company_bind
+            where project_id is not null
+              and qw_user_id is not null
+            <if test="fsUserId != null">
+              and fs_user_id = #{fsUserId}
+            </if>
+            <if test="fsUserId == null and externalUserId != null">
+              and fs_user_id in (
+                select u.fs_user_id from fs_user_company_bind u
+                where u.qw_external_contact_id = #{externalUserId}
+                  and u.fs_user_id is not null
+              )
+            </if>
+            group by fs_user_id, project_id
+            having count(distinct qw_user_id) &gt;= 2
+        ) dup_proj on dup_proj.fs_user_id = a.fs_user_id and dup_proj.project_id = a.project_id
         left join fs_user b on a.fs_user_id = b.user_id
         left join sys_dict_data c on c.dict_type = 'sys_course_project' and dict_value = a.project_id
         left join fs_user_course e on a.course_id = e.course_id

+ 15 - 0
fs-service/src/main/resources/mapper/sop/QwSopTempContentMapper.xml

@@ -51,6 +51,21 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         where temp_id = #{tempId}
     </select>
 
+    <select id="selectQwSopTempContentByTempIdAndRules" resultType="com.fs.sop.domain.QwSopTempContent">
+        SELECT
+               tc.content_type,
+               tc.id,
+               tc.content_type,
+               tc.content,
+               tr.course_id,
+               tr.video_id,
+               tr.course_type,
+               tr.content_type as type
+        FROM qw_sop_temp_content tc
+                 LEFT JOIN qw_sop_temp_rules tr ON tc.rules_id = tr.id
+        WHERE tc.temp_id = #{tempId}
+    </select>
+
     <insert id="insertQwSopTempContent" parameterType="QwSopTempContent">
         insert into qw_sop_temp_content
         <trim prefix="(" suffix=")" suffixOverrides=",">

+ 46 - 60
fs-service/src/main/resources/mapper/statis/FsStatisSalerWatchMapper.xml

@@ -492,34 +492,35 @@
             interrupt_num,
             dept_id
         )
-        SELECT
-            temp.company_user_id,
-            temp.sop_id,
-            temp.period_id,
-            temp.this_date as data_date,
-            COALESCE(log_stats.send_count, 0) as send_num,
-            COALESCE(log_stats.period_count, 0) as train_camp_num,
-            COALESCE(log_stats.not_register_count, 0) as not_registered_num,
-            COALESCE(log_stats.completed_count, 0) as completed_num,
-            COALESCE(log_stats.interrupt_count, 0) as interrupt_num,
-            temp.dept_id
-        FROM fs_statis_temp_param temp
-                 LEFT JOIN (
-            -- 一次性统计所有指标,避免重复扫描
+        WITH log_stats AS (
             SELECT
                 company_user_id,
                 sop_id,
                 qw_user_id,
                 camp_period_time,
-                COUNT(DISTINCT log_id) as send_count,
-                COUNT(DISTINCT qw_external_contact_id) as period_count,
-                COUNT(DISTINCT CASE WHEN log_type = 3 THEN log_id END) as not_register_count,
-                COUNT(DISTINCT CASE WHEN log_type = 2 THEN log_id END) as completed_count,
-                COUNT(DISTINCT CASE WHEN (log_type = 4 or log_type = 1) THEN log_id END) as interrupt_count
+                COUNT(DISTINCT log_id) AS send_count,
+                COUNT(DISTINCT qw_external_contact_id) AS period_count,
+                COUNT(DISTINCT CASE WHEN log_type = 3 THEN log_id END) AS not_register_count,
+                COUNT(DISTINCT CASE WHEN log_type = 2 THEN log_id END) AS completed_count,
+                COUNT(DISTINCT CASE WHEN log_type IN (1, 4) THEN log_id END) AS interrupt_count
             FROM fs_course_watch_log
-            WHERE (DATE(create_time) = #{date})
+            WHERE create_time &gt;= STR_TO_DATE(#{date}, '%Y-%m-%d')
+              AND create_time &lt; DATE_ADD(STR_TO_DATE(#{date}, '%Y-%m-%d'), INTERVAL 1 DAY)
             GROUP BY company_user_id, sop_id, qw_user_id, camp_period_time
-        ) log_stats ON temp.company_user_id = log_stats.company_user_id
+        )
+        SELECT
+            temp.company_user_id,
+            temp.sop_id,
+            temp.period_id,
+            temp.this_date AS data_date,
+            COALESCE(log_stats.send_count, 0) AS send_num,
+            COALESCE(log_stats.period_count, 0) AS train_camp_num,
+            COALESCE(log_stats.not_register_count, 0) AS not_registered_num,
+            COALESCE(log_stats.completed_count, 0) AS completed_num,
+            COALESCE(log_stats.interrupt_count, 0) AS interrupt_num,
+            temp.dept_id
+        FROM fs_statis_temp_param temp
+        LEFT JOIN log_stats ON temp.company_user_id = log_stats.company_user_id
             AND temp.sop_id = log_stats.sop_id
             AND temp.start_time = log_stats.camp_period_time
             AND temp.qw_user_id = log_stats.qw_user_id
@@ -527,54 +528,39 @@
 
         UNION ALL
 
--- 孤立数据处理部分保持不变,但也进行了优化
         SELECT
             orphan_summary.company_user_id,
             orphan_summary.sop_id,
-            '-1' as period_id,
-            #{date} as data_date,
+            '-1' AS period_id,
+            #{date} AS data_date,
             orphan_summary.send_num,
             orphan_summary.train_camp_num,
             orphan_summary.not_registered_num,
             orphan_summary.completed_num,
             orphan_summary.interrupt_num,
-            cu.dept_id as dept_id
+            cu.dept_id AS dept_id
         FROM (
-                 SELECT
-                     orphan_data.company_user_id,
-                     orphan_data.sop_id,
-                     SUM(orphan_data.send_count) as send_num,
-                     SUM(orphan_data.period_count) as train_camp_num,
-                     SUM(orphan_data.not_registered_num) as not_registered_num,
-                     SUM(orphan_data.completed_count) as completed_num,
-                     SUM(orphan_data.interrupt_num) as interrupt_num
-                 FROM (
-                          SELECT
-                              log_data.company_user_id,
-                              log_data.sop_id,
-                              log_data.qw_user_id,
-                              log_data.camp_period_time,
-                              COUNT(DISTINCT log_data.log_id) as send_count,
-                              COUNT(DISTINCT log_data.qw_external_contact_id) as period_count,
-                              COUNT(DISTINCT CASE WHEN log_data.log_type = 3 THEN log_data.log_id END) as not_registered_num,
-                              COUNT(DISTINCT CASE WHEN log_data.log_type = 2 THEN log_data.log_id END) as completed_count,
-                              COUNT(DISTINCT CASE WHEN (log_data.log_type = 4 or log_type = 1) THEN log_data.log_id END) as interrupt_num
-                          FROM fs_course_watch_log log_data
-                          WHERE (DATE(log_data.create_time) = #{date})
-                          GROUP BY log_data.company_user_id, log_data.sop_id, log_data.qw_user_id, log_data.camp_period_time
-                      ) orphan_data
-                 WHERE NOT EXISTS (
-                     SELECT 1
-                     FROM fs_statis_temp_param temp
-                     WHERE temp.company_user_id = orphan_data.company_user_id
-                       AND ((temp.sop_id = orphan_data.sop_id) OR (temp.sop_id IS NULL AND orphan_data.sop_id IS NULL))
-                       AND temp.qw_user_id = orphan_data.qw_user_id
-                       AND ((temp.start_time = orphan_data.camp_period_time) OR (temp.start_time IS NULL AND orphan_data.camp_period_time IS NULL))
-                 )
-                 GROUP BY orphan_data.company_user_id, orphan_data.sop_id
-             ) orphan_summary
-         LEFT JOIN company_user cu ON cu.user_id = orphan_summary.company_user_id
-        WHERE cu.dept_id IS NOT NULL AND cu.dept_id != 0  -- 过滤掉dept_id为NULL或0的记录
+            SELECT
+                orphan_data.company_user_id,
+                orphan_data.sop_id,
+                SUM(orphan_data.send_count) AS send_num,
+                SUM(orphan_data.period_count) AS train_camp_num,
+                SUM(orphan_data.not_register_count) AS not_registered_num,
+                SUM(orphan_data.completed_count) AS completed_num,
+                SUM(orphan_data.interrupt_count) AS interrupt_num
+            FROM log_stats orphan_data
+            WHERE NOT EXISTS (
+                SELECT 1
+                FROM fs_statis_temp_param temp
+                WHERE temp.company_user_id = orphan_data.company_user_id
+                  AND ((temp.sop_id = orphan_data.sop_id) OR (temp.sop_id IS NULL AND orphan_data.sop_id IS NULL))
+                  AND temp.qw_user_id = orphan_data.qw_user_id
+                  AND ((temp.start_time = orphan_data.camp_period_time) OR (temp.start_time IS NULL AND orphan_data.camp_period_time IS NULL))
+            )
+            GROUP BY orphan_data.company_user_id, orphan_data.sop_id
+        ) orphan_summary
+        LEFT JOIN company_user cu ON cu.user_id = orphan_summary.company_user_id
+        WHERE cu.dept_id IS NOT NULL AND cu.dept_id != 0
     </insert>
 
 </mapper>