瀏覽代碼

notion文档看课和飞书缓存修改

wjj 22 小時之前
父節點
當前提交
8b104b74ec
共有 38 個文件被更改,包括 2922 次插入5 次删除
  1. 1 0
      fs-company/src/main/java/com/fs/framework/config/SecurityConfig.java
  2. 28 0
      fs-qw-task/src/main/java/com/fs/app/task/qwTask.java
  3. 2 0
      fs-qw-task/src/main/java/com/fs/app/taskService/SopLogsTaskService.java
  4. 637 0
      fs-qw-task/src/main/java/com/fs/app/taskService/impl/SopLogsTaskServiceImpl.java
  5. 6 0
      fs-qwhook-sop/src/main/java/com/fs/app/controller/ApisFsUserCourseVideoController.java
  6. 6 0
      fs-qwhook-sop/src/main/java/com/fs/app/controller/FsUserCourseVideoController.java
  7. 6 0
      fs-qwhook/src/main/java/com/fs/app/controller/ApisFsUserCourseVideoController.java
  8. 8 0
      fs-service/src/main/java/com/fs/course/mapper/FsCourseWatchLogMapper.java
  9. 2 0
      fs-service/src/main/java/com/fs/course/service/IFsUserCourseVideoService.java
  10. 53 1
      fs-service/src/main/java/com/fs/course/service/impl/FsCourseWatchLogServiceImpl.java
  11. 106 0
      fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java
  12. 152 0
      fs-service/src/main/java/com/fs/notion/client/NotionClient.java
  13. 261 0
      fs-service/src/main/java/com/fs/notion/client/NotionInternalClient.java
  14. 20 0
      fs-service/src/main/java/com/fs/notion/config/NotionConfig.java
  15. 86 0
      fs-service/src/main/java/com/fs/notion/controller/NotionController.java
  16. 18 0
      fs-service/src/main/java/com/fs/notion/dto/NotionBatchRequest.java
  17. 26 0
      fs-service/src/main/java/com/fs/notion/dto/NotionBatchResponse.java
  18. 18 0
      fs-service/src/main/java/com/fs/notion/dto/NotionPageRequest.java
  19. 28 0
      fs-service/src/main/java/com/fs/notion/dto/NotionPageResponse.java
  20. 33 0
      fs-service/src/main/java/com/fs/notion/service/INotionService.java
  21. 206 0
      fs-service/src/main/java/com/fs/notion/service/impl/NotionServiceImpl.java
  22. 64 0
      fs-service/src/main/java/com/fs/qw/domain/QwUserIdMapping.java
  23. 5 0
      fs-service/src/main/java/com/fs/qw/mapper/QwExternalContactMapper.java
  24. 55 0
      fs-service/src/main/java/com/fs/qw/mapper/QwUserIdMappingMapper.java
  25. 65 0
      fs-service/src/main/java/com/fs/qw/redpacket/param/WorkWxRedPacketParam.java
  26. 53 0
      fs-service/src/main/java/com/fs/qw/redpacket/result/WorkWxRedPacketResult.java
  27. 415 0
      fs-service/src/main/java/com/fs/qw/redpacket/service/WorkWxRedPacketService.java
  28. 8 0
      fs-service/src/main/java/com/fs/qw/service/IQwExternalContactService.java
  29. 58 0
      fs-service/src/main/java/com/fs/qw/service/IQwUserIdMappingService.java
  30. 48 0
      fs-service/src/main/java/com/fs/qw/service/impl/QwExternalContactServiceImpl.java
  31. 132 0
      fs-service/src/main/java/com/fs/qw/service/impl/QwUserIdMappingServiceImpl.java
  32. 82 2
      fs-service/src/main/java/com/fs/qwApi/service/impl/QwApiServiceImpl.java
  33. 3 0
      fs-service/src/main/java/com/fs/sop/mapper/SopUserLogsMapper.java
  34. 104 1
      fs-service/src/main/java/com/fs/sop/service/impl/SopUserLogsInfoServiceImpl.java
  35. 20 1
      fs-service/src/main/resources/mapper/course/FsCourseWatchLogMapper.xml
  36. 13 0
      fs-service/src/main/resources/mapper/qw/QwExternalContactMapper.xml
  37. 76 0
      fs-service/src/main/resources/mapper/qw/QwUserIdMappingMapper.xml
  38. 18 0
      fs-service/src/main/resources/mapper/sop/SopUserLogsMapper.xml

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

@@ -110,6 +110,7 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter
                         "/**/*.js",
                         "/profile/**"
                 ).permitAll()
+                .antMatchers("/api/notion/pages/**").permitAll()
                 .antMatchers("/xiaoShouYi/**").permitAll()
                 .antMatchers("/test").anonymous()
                 .antMatchers("/company/companyVoiceRobotic/callerResult").anonymous()

+ 28 - 0
fs-qw-task/src/main/java/com/fs/app/task/qwTask.java

@@ -19,7 +19,9 @@ import org.springframework.stereotype.Component;
 
 import java.time.LocalDate;
 import java.time.LocalDateTime;
+import java.time.LocalTime;
 import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
 import java.util.List;
 
 /**
@@ -117,6 +119,32 @@ public class qwTask {
         sopLogsTaskService.selectSopUserLogsListByTime(currentTime);
     }
 
+    @Scheduled(cron = "0 0 18 * * ?") // 每天下午6点执行
+    @Async
+    public void selectSopUserLogsListByTimeNew() throws Exception {
+        // 获取第二天
+        LocalDate tomorrow = LocalDate.now().plusDays(1);
+
+        List<LocalDateTime> dateTimeList = new ArrayList<>();
+        // 从 4 点到 23 点(包含 23 点)
+        for (int hour = 4; hour <= 23; hour++) {
+            LocalDateTime dt = LocalDateTime.of(tomorrow, LocalTime.of(hour, 0));
+            dateTimeList.add(dt);
+        }
+        for (LocalDateTime currentTime : dateTimeList) {
+            log.info("每天18点任务实际执行时间: {}", currentTime);
+            // 调用服务方法处理SOP用户日志
+            sopLogsTaskService.selectNewSopUserLogsListByTime(currentTime);
+        }
+//        // 获取当前时间,精确到小时
+//        LocalDateTime currentTime = LocalDateTime.now().withMinute(0).withSecond(0).withNano(0);
+//        // 打印日志,确认任务执行时间
+//        log.info("任务实际执行时间: {}", currentTime);
+//
+//        // 调用服务方法处理SOP用户日志
+//        sopLogsTaskService.selectNewSopUserLogsListByTime(currentTime);
+    }
+
     /**
      * 定时任务:微信SOP处理
      * 执行时间:每小时的第5分钟执行

+ 2 - 0
fs-qw-task/src/main/java/com/fs/app/taskService/SopLogsTaskService.java

@@ -17,5 +17,7 @@ public interface SopLogsTaskService {
      */
     void createCourseFinishMsg();
 
+    public void selectNewSopUserLogsListByTime(LocalDateTime currentTime) throws Exception ;
+
 //    void creatMessMessage(QwSopLogs logs);
 }

+ 637 - 0
fs-qw-task/src/main/java/com/fs/app/taskService/impl/SopLogsTaskServiceImpl.java

@@ -8,6 +8,7 @@ import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.fs.app.taskService.SopLogsTaskService;
 import com.fs.common.core.domain.R;
 import com.fs.common.core.redis.RedisCache;
+import com.fs.common.exception.CustomException;
 import com.fs.common.exception.base.BaseException;
 import com.fs.common.utils.DateUtils;
 import com.fs.common.utils.PubFun;
@@ -26,7 +27,14 @@ import com.fs.course.param.FsCourseLinkCreateParam;
 import com.fs.course.service.IFsCourseFinishTempService;
 import com.fs.course.service.IFsCourseLinkService;
 import com.fs.course.service.IFsUserCourseService;
+import com.fs.feishu.domain.FeishuAccount;
+import com.fs.feishu.domain.FeishuLinkError;
+import com.fs.feishu.mapper.FeishuAccountMapper;
+import com.fs.feishu.mapper.FeishuLinkErrorMapper;
 import com.fs.feishu.service.FeiShuService;
+import com.fs.feishu.service.FeishuClientPool;
+import com.fs.feishu.service.FeishuDocApiService;
+import com.fs.feishu.util.FeishuErrorCode;
 import com.fs.his.domain.FsCourseReissueConfig;
 import com.fs.his.service.IFsCourseReissueConfigService;
 import com.fs.qw.domain.*;
@@ -53,6 +61,7 @@ import com.fs.system.service.ISysConfigService;
 import com.fs.utils.ShortCodeGeneratorUtils;
 import com.fs.voice.utils.StringUtil;
 import com.fs.xiaoshouyi.service.XiaoShouYiTrackLinkCacheService;
+import com.lark.oapi.Client;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.BeanUtils;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -226,6 +235,18 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
     @Autowired
     private IFsCourseReissueConfigService fsCourseReissueConfigService;
 
+    @Autowired
+    private FeishuLinkErrorMapper feishuLinkErrorMapper;
+
+    private static final String FEISHU_COMPANY_USER_KEY = "feishu:account_company_user:";
+    @Autowired
+    private FeishuAccountMapper feishuAccountMapper;
+    @Autowired
+    private FeishuDocApiService docApiService;
+    @Autowired
+    private FeishuClientPool clientPool;
+    @Autowired
+    private FsUserCourseVideoMapper fsUserCourseVideoMapper;
 
 
     @PostConstruct
@@ -410,6 +431,56 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
         log.info("====== SOP 用户日志处理完成,耗时 {} 毫秒 ======", (endTimeMillis - startTimeMillis));
     }
 
+
+    @Override
+    public void selectNewSopUserLogsListByTime(LocalDateTime currentTime)  throws Exception {
+        long startTimeMillis = System.currentTimeMillis();
+        log.info("====== 开始选择和处理 SOP 用户日志 ======");
+
+        // 获取缓存的配置
+        CourseConfig config;
+        synchronized(configLock) {
+            config = cachedCourseConfig;
+        }
+
+        List<SopUserLogsVo> sopUserLogsVos = sopUserLogsMapper.selectNewSopUserLogsListByTime();
+        if (sopUserLogsVos.isEmpty()) {
+            log.info("没有需要处理的 SOP 用户日志。");
+            return;
+        }
+
+        Map<String, List<SopUserLogsVo>> sopLogsGroupedById = sopUserLogsVos.stream()
+                .collect(Collectors.groupingBy(SopUserLogsVo::getSopId));
+
+        // 查询公司关联小程序数据
+        List<CompanyMiniapp> miniList = companyMiniappService.list(new QueryWrapper<CompanyMiniapp>().orderByAsc("sort_num"));
+
+        Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap = miniList.stream().collect(Collectors.groupingBy(CompanyMiniapp::getCompanyId, Collectors.groupingBy(CompanyMiniapp::getType)));
+
+
+        List<Company> companies = companyMapper.selectCompanyAllList();
+
+        log.info("共分组 {} 个 SOP ID 进行处理。", sopLogsGroupedById.size());
+
+        CountDownLatch sopGroupLatch = new CountDownLatch(sopLogsGroupedById.size());
+
+        for (Map.Entry<String, List<SopUserLogsVo>> entry : sopLogsGroupedById.entrySet()) {
+            String sopId = entry.getKey();
+            List<SopUserLogsVo> userLogsVos = entry.getValue();
+            processSopGroupAsyncNew(sopId, userLogsVos, sopGroupLatch,currentTime, new HashMap<>(),config,miniMap,companies);
+        }
+
+        // 等待所有 SOP 分组处理完成
+        sopGroupLatch.await();
+
+        // 触发批量插入(可选,如果需要立即插入队列中的数据)
+        // batchInsertQwSopLogs();
+        // batchInsertFsCourseWatchLogs();
+
+        long endTimeMillis = System.currentTimeMillis();
+        log.info("====== SOP 用户日志处理完成,耗时 {} 毫秒 ======", (endTimeMillis - startTimeMillis));
+    }
+
     @Async("sopTaskExecutor")
     @Retryable(
             value = { Exception.class },
@@ -428,6 +499,24 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
         }
     }
 
+    @Async("sopTaskExecutor")
+    @Retryable(
+            value = { Exception.class },
+            maxAttempts = 3,
+            backoff = @Backoff(delay = 2000)
+    )
+    public void processSopGroupAsyncNew(String sopId, List<SopUserLogsVo> userLogsVos, CountDownLatch latch ,LocalDateTime currentTime,
+                                     Map<String, QwGroupChat> groupChatMap,CourseConfig config,Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap,
+                                     List<Company> companies) {
+        try {
+            processSopGroupNew(sopId, userLogsVos,currentTime, groupChatMap, config,miniMap,companies);
+        } catch (Exception e) {
+            log.error("处理 SOP ID {} 时发生异常: {}", sopId, e.getMessage(), e);
+        } finally {
+            latch.countDown();
+        }
+    }
+
 
     private void processSopGroup(String sopId, List<SopUserLogsVo> userLogsVos,LocalDateTime currentTime, Map<String,
                                          QwGroupChat> groupChatMap,CourseConfig config,Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap,
@@ -487,6 +576,64 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
         log.info("SOP ID {} 的所有用户日志已处理完毕。", sopId);
     }
 
+    private void processSopGroupNew(String sopId, List<SopUserLogsVo> userLogsVos,LocalDateTime currentTime, Map<String,
+            QwGroupChat> groupChatMap,CourseConfig config,Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap,
+                                 List<Company> companies) throws Exception {
+        QwSopRuleTimeVO ruleTimeVO = sopMapper.selectQwSopByClickHouseId(sopId);
+
+        if (ruleTimeVO == null) {
+//            sopUserLogsMapper.deleteSopUserLogsBySopId(sopId);
+            log.error("SOP ID {} 已删除或不存在,相关日志已清除。", sopId);
+            return;
+        }
+        QwSopTemp qwSopTemp = qwSopTempMapper.selectQwSopTempById(ruleTimeVO.getTempId());
+        if (qwSopTemp == null) {
+//            sopUserLogsMapper.deleteSopUserLogsBySopId(sopId);
+            log.error("SOP ID {} 模板不存在,相关日志已清除。", sopId);
+            return;
+        }
+
+        ruleTimeVO.setTempStatus(qwSopTemp.getStatus());
+        ruleTimeVO.setTempGap(qwSopTemp.getGap());
+
+        if (ruleTimeVO.getStatus() == 0 || "0".equals(ruleTimeVO.getTempStatus())) {
+//            SopUserLogs sopUserLogs = new SopUserLogs();
+//            sopUserLogs.setSopId(sopId);
+//            sopUserLogs.setStatus(2);
+//            sopUserLogsMapper.updateSopUserLogsByStatus(sopUserLogs);
+            log.error("SOP ID {} 的状态为停用,相关日志状态已更新。", sopId);
+            return;
+        }
+
+        List<QwSopTempRules> rulesList = qwSopTempRulesService.listByTempId(ruleTimeVO.getTempId());
+        if (rulesList.isEmpty()) {
+            log.error("SOP ID {} 的 TempSetting 为空,跳过处理。", sopId);
+            return;
+        }
+
+        QwCompany qwCompany = iQwCompanyService.getQwCompanyByRedis(ruleTimeVO.getCorpId());
+
+        if (qwCompany == null ) {
+            log.error("SOP ID {} 的 公司信息为空 为空,跳过处理。", sopId);
+            return ;
+        }
+
+        CountDownLatch userLogsLatch = new CountDownLatch(userLogsVos.size());
+        for (SopUserLogsVo logVo : userLogsVos) {
+            processUserLogAsyncNew(logVo, ruleTimeVO, rulesList, userLogsLatch, currentTime, groupChatMap,qwCompany.getMiniAppId(),
+                    config,miniMap,companies);
+        }
+
+        // 等待所有用户日志处理完成
+        try {
+            userLogsLatch.await();
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            log.error("等待用户日志处理完成时被中断: {}", e.getMessage(), e);
+        }
+        log.info("SOP ID {} 的所有用户日志已处理完毕。", sopId);
+    }
+
     @Async("sopTaskExecutor")
     @Retryable(
             value = { Exception.class },
@@ -506,6 +653,25 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
         }
     }
 
+    @Async("sopTaskExecutor")
+    @Retryable(
+            value = { Exception.class },
+            maxAttempts = 3,
+            backoff = @Backoff(delay = 2000)
+    )
+    public void processUserLogAsyncNew(SopUserLogsVo logVo, QwSopRuleTimeVO ruleTimeVO, List<QwSopTempRules> tempSettings,
+                                    CountDownLatch latch, LocalDateTime currentTime, Map<String, QwGroupChat> groupChatMap,
+                                    String miniAppId,CourseConfig config,Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap,
+                                    List<Company> companies) {
+        try {
+            processUserLogNew(logVo, ruleTimeVO, tempSettings,currentTime, groupChatMap, miniAppId, config,miniMap,companies);
+        } catch (Exception e) {
+            log.error("处理用户日志 {} 时发生异常: {}", logVo.getId(), e.getMessage(), e);
+        } finally {
+            latch.countDown();
+        }
+    }
+
 
     private void processUserLog(SopUserLogsVo logVo, QwSopRuleTimeVO ruleTimeVO, List<QwSopTempRules> tempSettings,
                                 LocalDateTime currentTime, Map<String, QwGroupChat> groupChatMap,String miniAppId,
@@ -692,6 +858,181 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
         }
     }
 
+    private void processUserLogNew(SopUserLogsVo logVo, QwSopRuleTimeVO ruleTimeVO, List<QwSopTempRules> tempSettings,
+                                LocalDateTime currentTime, Map<String, QwGroupChat> groupChatMap,String miniAppId,
+                                CourseConfig config,Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap,
+                                List<Company> companies) {
+        try {
+
+            LocalDate startDate = LocalDate.parse(logVo.getStartTime(), DATE_FORMATTER);
+            LocalDate currentDate = currentTime.toLocalDate();
+
+            long daysBetween = ChronoUnit.DAYS.between(startDate, currentDate);
+            int tempGap = ruleTimeVO.getTempGap();
+
+            if (tempGap <= 0) {
+                log.error("SOP ID {} 的 TempGap {} 无效,跳过处理。", logVo.getSopId(), tempGap);
+                return;
+            }
+
+            int intervalDay = (int) (daysBetween / tempGap);
+            if (intervalDay < 0 || intervalDay >= tempSettings.size()) {
+                log.info("用户日志 {} 的 intervalDay {} 超出 TempSettings 范围,跳过处理。", logVo.getId(), intervalDay);
+                return;
+            }
+            long day = daysBetween;
+            if(day == 0 && ruleTimeVO.getIsAutoSop() == 1){
+                day = 1;
+            }else{
+                day++;
+            }
+            List<QwSopTempSetting.Content> contents = getDay(tempSettings, day);
+            if (contents == null || contents.isEmpty()) {
+                log.error("SOP ID {} 的 TempSetting 内容为空,跳过处理。天数 {}", logVo.getSopId(),day);
+                return;
+            }
+
+
+            //获取企业微信员工的称呼//从redis里或者从库里取
+            QwUser qwUserByRedis = qwExternalContactService.getQwUserByRedis(logVo.getCorpId(),logVo.getQwUserId());
+            if (qwUserByRedis==null){
+                log.error("无企微员工信息 {} 跳过处理。:{}", logVo.getUserId(),logVo.getCorpId());
+                return;
+            }
+
+            String qwUserId = String.valueOf(qwUserByRedis.getId()).trim();
+            String companyUserId = String.valueOf(qwUserByRedis.getCompanyUserId()).trim();
+            String companyId = String.valueOf(qwUserByRedis.getCompanyId()).trim();
+            Integer sendMsgType = qwUserByRedis.getSendMsgType();
+            Long serverId = qwUserByRedis.getServerId();
+
+            if (StringUtil.strIsNullOrEmpty(companyUserId) || StringUtil.strIsNullOrEmpty(companyId) || "null".equals(companyUserId)) {
+                log.error("员工未绑定销售账号或公司,跳过处理:"+qwUserId);
+                return;
+            }
+
+            CompanyUser companyUser = companyUserService.selectCompanyUserByIdForRedis(Long.valueOf(companyUserId));
+            if (Objects.nonNull(companyUser)) {
+                if (!StringUtil.strIsNullOrEmpty(companyUser.getDomain())) {
+                    logVo.setDomain(companyUser.getDomain().trim());
+                } else {
+                    logVo.setDomain(config.getRealLinkDomainName().trim());
+                }
+            } else {
+                logVo.setDomain(config.getRealLinkDomainName().trim());
+            }
+
+            //寻找时间
+//            LocalDateTime currentTime = LocalDateTime.of(2024, 12, 25,23 , 40);
+
+            // 先算好 60分钟后 ~ 再60分钟后的时间段
+            LocalDateTime startRangeFirst = currentTime.plusMinutes(60);
+
+            // 如果发现已经跨天
+            if (!startRangeFirst.toLocalDate().equals(currentDate)) {
+                // 更新 currentDate
+                currentDate = startRangeFirst.toLocalDate();
+
+                // 重新计算 daysBetween
+                daysBetween = ChronoUnit.DAYS.between(startDate, currentDate);
+                intervalDay = (int) (daysBetween / tempGap);
+                day = daysBetween;
+                if(day == 0 && ruleTimeVO.getIsAutoSop() == 1){
+                    day = 1;
+                }else{
+                    day++;
+                }
+//
+//                // 再次验证 intervalDay 是否在范围内
+//                if (intervalDay < 0 || intervalDay >= tempSettings.size()) {
+//                    log.info("跨天后,intervalDay={} 超出 TempSettings 范围,跳过。", intervalDay);
+//                    return;
+//                }
+//
+//                if (daysBetween % tempGap != 0) {
+//                    log.error("天数差 {} 不是 tempGap {} 的整数倍,跳过操作,SopId {} ", daysBetween, tempGap,logVo.getSopId());
+//                    return;
+//                }
+
+                // 重新拿新的 “天” 的 Setting
+                contents = getDay(tempSettings, day);
+                if (contents == null || contents.isEmpty()) {
+                    log.error("跨天-SOP ID {} 的 TempSetting 内容为空,跳过处理。", logVo.getSopId());
+                    return;
+                }
+            }
+
+
+            // 只有整倍数才做事
+            if (daysBetween % tempGap != 0) {
+                log.error("天数差 {} 不是 tempGap {} 的整数倍,跳过操作,SopId {} ", daysBetween, tempGap,logVo.getSopId());
+                return;
+            }
+
+
+            for (QwSopTempSetting.Content content : contents) {
+                try {
+
+                    LocalTime elementLocalTime = LocalTime.parse(content.getTime());
+                    LocalDateTime elementDateTime = LocalDateTime.of(currentTime.toLocalDate(), elementLocalTime);
+
+                    // 动态调整 elementDateTime 的日期
+                    if (elementLocalTime.isBefore(currentTime.toLocalTime())) {
+                        elementDateTime = elementDateTime.plusDays(1);
+                    }
+
+                    LocalDateTime startRange = currentTime.plusMinutes(60);
+                    LocalDateTime endRange = startRange.plusMinutes(60);
+
+                    // 跨天逻辑修正:仅当 startRange 的时间晚于 endRange 的时间时调整
+                    if (startRange.toLocalTime().isAfter(endRange.toLocalTime())
+                            && startRange.toLocalDate().equals(endRange.toLocalDate())) {
+                        endRange = endRange.plusDays(1); // 将 endRange 调整为第二天
+                    }
+                    if (!elementDateTime.isBefore(startRange) && !elementDateTime.isAfter(endRange.minusMinutes(1))) {
+
+                        // 如果时间差在目标范围内,更新记录
+                        // 组合年月日和element的时间
+                        LocalDate targetDate = startDate.plusDays(intervalDay * tempGap);
+
+                        // 将 targetDate 和 elementTime 组合成 LocalDateTime
+                        LocalDateTime dateTime = LocalDateTime.of(targetDate, elementLocalTime);
+
+                        // 将 LocalDateTime 转换为 Date
+                        Date sendTime = Date.from(dateTime.atZone(ZoneId.systemDefault()).toInstant());
+
+                        SopUserLogsInfo userLogsInfo=new SopUserLogsInfo();
+                        userLogsInfo.setSopId(logVo.getSopId());
+                        userLogsInfo.setUserLogsId(logVo.getId());
+
+                        List<SopUserLogsInfo> sopUserLogsInfos = sopUserLogsInfoMapper.selectSopUserLogsInfoList(userLogsInfo);
+                        if (logVo.getIsRegister() == 1) {
+                            List<Long> externalContactIdList = PubFun.listToNewList(sopUserLogsInfos, SopUserLogsInfo::getExternalId);
+                            if (!externalContactIdList.isEmpty()) {
+                                List<QwExternalContact> list = qwExternalContactService.list(new QueryWrapper<QwExternalContact>().isNotNull("fs_user_id").in("id", externalContactIdList));
+                                Map<Long, QwExternalContact> map = PubFun.listToMapByGroupObject(list, QwExternalContact::getId);
+                                sopUserLogsInfos = sopUserLogsInfos.stream().filter(e -> map.containsKey(e.getExternalId())).collect(Collectors.toList());
+                            }
+                        }
+
+
+
+
+                        insertSopUserLogsNew(sopUserLogsInfos, logVo, sendTime, ruleTimeVO, content, qwUserId,
+                                companyUserId, companyId, qwUserByRedis.getWelcomeText(),qwUserByRedis.getQwUserName(),
+                                groupChatMap, miniAppId,config,miniMap, sendMsgType,companies,day, serverId);
+
+                    }
+                } catch (Exception e) {
+                    log.error("解析模板内容 {} 失败: {}", content.getTime(), e.getMessage(), e);
+                }
+            }
+
+        } catch (Exception e) {
+            log.error("解析解析模板 {} 失败: {}", logVo.getStartTime(), e.getMessage(), e);
+        }
+    }
+
 
     private List<QwSopTempSetting.Content> getDay(List<QwSopTempRules> tempSettings, long days){
         List<QwSopTempRules> collect = tempSettings.stream().filter(e -> e.getDayNum() == days && e.getTime() != null).collect(Collectors.toList());
@@ -834,6 +1175,83 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
 //        });
     }
 
+    //消息处理
+    private void insertSopUserLogsNew(List<SopUserLogsInfo> sopUserLogsInfos, SopUserLogsVo logVo, Date sendTime,
+                                   QwSopRuleTimeVO ruleTimeVO, QwSopTempSetting.Content content,
+                                   String qwUserId,String companyUserId,String companyId,String welcomeText,String qwUserName,
+                                   Map<String, QwGroupChat> groupChatMap,String miniAppId,CourseConfig config,
+                                   Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap, Integer sendMsgType,
+                                   List<Company> companies,Long day,Long serverId) {
+        String formattedSendTime = sendTime.toInstant()
+                .atZone(ZoneId.systemDefault())
+                .format(DATE_TIME_FORMATTER);
+        int type = content.getType();
+        Long courseId = content.getCourseId();
+        Long videoId = content.getVideoId();
+        Integer isOfficial = content.getIsOfficial() != null ? Integer.valueOf(content.getIsOfficial()) : 0;
+
+
+        // 发送语音 start
+        if(content.getSetting() == null){
+            return;
+        }
+        List<QwSopTempSetting.Content.Setting> setting = content.getSetting().stream().filter(e -> "7".equals(e.getContentType())).collect(Collectors.toList());
+        if (!setting.isEmpty()) {
+            List<String> valuesList = PubFun.listToNewList(setting, QwSopTempSetting.Content.Setting::getValue);
+            if (valuesList != null && !valuesList.isEmpty()) {
+                try {
+                    List<QwSopTempVoice> voiceList = qwSopTempVoiceService.getVoiceByText(Long.parseLong(companyUserId), valuesList);
+                    if (voiceList != null && !voiceList.isEmpty()) {
+                        Map<String, QwSopTempVoice> collect = voiceList.stream().collect(Collectors.toMap(QwSopTempVoice::getVoiceTxt, e -> e));
+                        setting.parallelStream().filter(e -> "7".equals(e.getContentType())).forEach(st -> {
+                            QwSopTempVoice voice = collect.get(st.getValue());
+                            if (voice.getVoiceUrl() == null) {
+                                return;
+                            }
+                            st.setVoiceUrl(voice.getVoiceUrl());
+                            st.setVoiceDuration(voice.getDuration() + "");
+                        });
+                    }
+                } catch (NumberFormatException e) {
+                    throw new RuntimeException(e);
+                }
+            }
+        }
+//        // 发送语音 end
+        if (content.getType()==5){
+            sopAddTag(logVo,content,sendTime);
+        }
+
+        //发送销售医生任务
+//        if (content.getType()==6){
+//            addSopOrderLog(logVo,content,sendTime);
+//        }
+
+        //当语音模板的qw_sop_temp_voice中无对应语音,就不生成qw_sop_logs记录
+        if (content.getType() == 7 && content.getSetting() != null && !content.getSetting().isEmpty()) {
+            if (content.getSetting().get(0).getVoiceUrl() == null) {
+                return;
+            }
+        }
+
+
+        // 处理每个 externalContactId
+        sopUserLogsInfos.forEach(contactId -> {
+            try {
+                String externalId = contactId.getExternalId().toString();
+                String externalUserName = contactId.getExternalUserName();
+                Long fsUserId = contactId.getFsUserId();
+                Integer grade = contactId.getGrade();
+                QwSopLogs sopLogs = createBaseLog(formattedSendTime, logVo, ruleTimeVO, contactId.getExternalContactId(), externalUserName, fsUserId, isOfficial, contactId.getExternalId(),contactId.getIsDaysNotStudy());
+                handleLogBasedOnTypeNew(sopLogs, content, logVo, sendTime, courseId, videoId,
+                        type, qwUserId, companyUserId, companyId, externalId, welcomeText, qwUserName, fsUserId, false, miniAppId,
+                        null,config, miniMap, grade, sendMsgType,companies,day,serverId);
+            } catch (Exception e) {
+                log.error("处理 externalContactId {} 时发生异常: {}", contactId, e.getMessage(), e);
+            }
+        });
+    }
+
     private void sopAddTag(SopUserLogsVo logVo, QwSopTempSetting.Content content, Date sendTime) {
         String id = logVo.getId();
         String addTag = content.getAddTag();
@@ -970,6 +1388,23 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                 break;
         }
     }
+
+
+    private void handleLogBasedOnTypeNew(QwSopLogs sopLogs, QwSopTempSetting.Content content,
+                                      SopUserLogsVo logVo, Date sendTime, Long courseId, Long videoId, int type, String qwUserId,
+                                      String companyUserId, String companyId, String externalId, String welcomeText,
+                                      String qwUserName, Long fsUserId, boolean isGroupChat, String miniAppId,
+                                      QwGroupChat groupChat,CourseConfig config,
+                                      Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap,
+                                      Integer grade, Integer sendMsgType ,List<Company> companies,Long day,Long serverId) {
+        if (type == 2) {
+            handleCourseMessageNew(sopLogs, content, logVo, sendTime, courseId, videoId,
+                    qwUserId, companyUserId, companyId, externalId, welcomeText, qwUserName, fsUserId,
+                    isGroupChat, miniAppId, groupChat, config, miniMap, grade, sendMsgType, companies, day, serverId);
+        } else {
+            log.error("未知的消息类型 {},跳过处理。", type);
+        }
+    }
     private void handleVoiceMessage(QwSopLogs sopLogs, QwSopTempSetting.Content content, String companyUserId) {
         sopLogs.setContentJson(JSON.toJSONString(content));
         enqueueQwSopLogs(sopLogs);
@@ -1289,6 +1724,208 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
         enqueueQwSopLogs(sopLogs);
     }
 
+    private void handleCourseMessageNew(QwSopLogs sopLogs, QwSopTempSetting.Content content,
+                                     SopUserLogsVo logVo, Date sendTime, Long courseId, Long videoId, String qwUserId, String companyUserId,
+                                     String companyId, String externalId, String welcomeText, String qwUserName,
+                                     Long fsUserId, boolean isGroupChat, String miniAppId, QwGroupChat groupChat,CourseConfig config,Map<Long,
+            Map<Integer, List<CompanyMiniapp>>> miniMap,Integer grade, Integer sendMsgType,
+                                     List<Company> companies,Long day,Long serverId) {
+        // 深拷贝 Content 对象,避免使用 JSON
+        QwSopTempSetting.Content clonedContent = deepCopyContent(content);
+        if (clonedContent == null) {
+            log.error("Failed to clone content, skipping handleCourseMessage.");
+            return;
+        }
+
+        Long msgNum = Long.valueOf(generateRandomNumberWithLock());
+        sopLogs.setSmsLogsId(msgNum);
+
+        AtomicInteger index = new AtomicInteger(0);
+
+        String isOfficial = clonedContent.getIsOfficial();
+
+        List<QwSopTempSetting.Content.Setting> settings = clonedContent.getSetting();
+        if (settings == null || settings.isEmpty()) {
+            log.error("Cloned content settings are empty, skipping.");
+            return;
+        }
+        FsUserCourseVideo video = fsUserCourseVideoMapper.selectFsUserCourseVideoByVideoId(videoId);
+
+        // 顺序处理每个 Setting,避免过多的并行导致线程开销
+        for (QwSopTempSetting.Content.Setting setting : settings) {
+
+            Integer currentIndex = index.getAndIncrement();
+            //后台SOP发课配置
+            //createSetting(setting);
+
+            switch (setting.getContentType()) {
+
+                case "18" :
+                    //飞书看课链接
+                    Long companyIdLong = Long.parseLong(companyId);
+                    Long companyUserIdLong = Long.parseLong(companyUserId);
+                    long externalIdLong = Long.parseLong(externalId);
+
+
+                    String key = "fei_shu_doc_id:" + externalIdLong + "_" + videoId;
+                    String feiShuDocId = redisCache.getCacheObject(key);
+                    if (org.apache.commons.lang3.StringUtils.isBlank(feiShuDocId)) {
+                        feiShuDocId = getFeiShuDocId(video, companyIdLong, companyUserIdLong);
+                        if (org.apache.commons.lang3.StringUtils.isNotBlank(feiShuDocId)) {
+                            redisCache.setCacheObject("fei_shu_doc_id:" + externalIdLong + "_" + video.getVideoId(), feiShuDocId,1,TimeUnit.DAYS);
+                        }
+                    }
+                    break;
+                case "19" :
+                    break;
+                default:
+                    break;
+            }
+
+        }
+        sopLogs.setContentJson(JSON.toJSONString(clonedContent));
+        enqueueQwSopLogs(sopLogs);
+    }
+
+    public List<FeishuAccount> getFeiShuAccountCompanyUser(Long companyId, Long companyUserId) {
+        String cacheKey = FEISHU_COMPANY_USER_KEY + companyId + ":" + companyUserId;
+        String cached = redisCache.getCacheObject(cacheKey);
+        if (org.apache.commons.lang3.StringUtils.isNotBlank(cached)) {
+            try {
+                return JSONUtil.parseArray(cached).toList(FeishuAccount.class);
+            } catch (Exception e) {
+                log.warn("缓存数据解析失败,key: {}, 将清除缓存", cacheKey, e);
+                redisCache.deleteObject(cacheKey);
+            }
+        }
+        FeishuAccount account = new FeishuAccount();
+        account.setCompanyId(companyId);
+        account.setCompanyUserId(companyUserId);
+        account.setStatus(1);
+        List<FeishuAccount> feiShuAccounts = feishuAccountMapper.selectFeishuAccountList(account);
+        if (feiShuAccounts.isEmpty()) {
+            redisCache.setCacheObject(cacheKey, "[]", 1, TimeUnit.MINUTES);
+        } else {
+            redisCache.setCacheObject(cacheKey, JSONUtil.toJsonStr(feiShuAccounts), 1, TimeUnit.DAYS);
+        }
+        return feiShuAccounts;
+    }
+
+    private void disablePersonalAccount(FeishuAccount account, String errorMsg) {
+        feishuAccountMapper.updateStatusAndErrorMsg(account.getId(), 0, errorMsg);
+        clearCompanyUserCache(account.getCompanyId(), account.getCompanyUserId());
+    }
+
+    private void clearCompanyUserCache(Long companyId, Long companyUserId) {
+        if (companyId != null && companyUserId != null) {
+            String cacheKey = FEISHU_COMPANY_USER_KEY + companyId + ":" + companyUserId;
+            redisCache.deleteObject(cacheKey);
+        }
+    }
+
+    private CourseConfig getCourseConfig() {
+        String json = sysConfigService.selectConfigByKey("course.config");
+        if (org.apache.commons.lang3.StringUtils.isBlank(json)) {
+            throw new CustomException("飞书看课配置为空");
+        }
+        return JSONUtil.toBean(json, CourseConfig.class);
+    }
+
+    private Client buildClient(FeishuAccount account) {
+        return Client.newBuilder(account.getAppId(), account.getAppSecret())
+                .logReqAtDebug(true)
+                .build();
+    }
+
+    public String getFeiShuDocId(FsUserCourseVideo video, Long companyId,
+                                 Long companyUserId) {
+
+
+        // 1. 尝试销售个人账号创建文档
+        List<FeishuAccount> personalAccounts = getFeiShuAccountCompanyUser(companyId, companyUserId);
+        if (!personalAccounts.isEmpty()) {
+            for (FeishuAccount account : personalAccounts) {
+                if (account.getStatus() != 1) continue;
+                Client client = buildClient(account);
+                try {
+                    String docId = docApiService.createDocument(client, video.getTitle() + "-注册");
+                    redisCache.setCacheObject("fei_shu_client:" + docId, account,1,TimeUnit.DAYS);
+                    return docId;
+
+                } catch (CustomException e) {
+                    Integer code = e.getCode();
+                    if (code == null) {
+                        recordLinkError(video.getVideoId(), companyId, video.getCourseId(), companyUserId, "定时生成的缓存docId", e.getMessage(), 1);
+                        return null;
+                    }
+                    if (FeishuErrorCode.isAccountDisable(code)) {
+                        disablePersonalAccount(account, e.getMessage());
+                        log.warn("个人账号[{}]被禁用,尝试下一个", account.getAppId());
+                    } else if (FeishuErrorCode.isRateLimit(code) || FeishuErrorCode.isInternalRetryable(code)) {
+                        log.warn("个人账号[{}]可重试错误,切换下一个", account.getAppId());
+                    } else {
+                        // 业务错误或其他,记录失败并返回
+                        recordLinkError(video.getVideoId(), companyId, video.getCourseId(), companyUserId, "定时生成的缓存docId", e.getMessage(), 1);
+                        return null;
+                    }
+                } catch (Exception e) {
+                    recordLinkError(video.getVideoId(), companyId, video.getCourseId(), companyUserId, "定时生成的缓存docId", e.getMessage(), 1);
+                    return null;
+                }
+            }
+            log.warn("所有个人账号失败,降级到公共账号池");
+        }
+
+        // 2. 公共账号池
+        int retries = clientPool.getAvailableCount();
+        for (int i = 0; i < retries; i++) {
+            FeishuClientPool.ClientWrapper wrapper = clientPool.getClientWrapper();
+            Client client = wrapper.getClient();
+            FeishuAccount account = wrapper.getAccount();
+            try {
+                String docId = docApiService.createDocument(client, video.getTitle() + "-注册");
+                redisCache.setCacheObject("fei_shu_client:" + docId, account);
+                return docId;
+
+            } catch (CustomException e) {
+                Integer code = e.getCode();
+                if (code == null) {
+                    recordLinkError(video.getVideoId(), companyId, video.getCourseId(), companyUserId, "定时生成的缓存docId", e.getMessage(), 1);
+                    return null;
+                }
+                if (FeishuErrorCode.isAccountDisable(code)) {
+                    clientPool.disableAccount(account, e.getMessage());
+                    log.warn("公共账号被禁用,尝试下一个");
+                } else if (FeishuErrorCode.isRateLimit(code) || FeishuErrorCode.isInternalRetryable(code)) {
+                    log.warn("公共账号可重试错误,切换下一个");
+                } else {
+                    recordLinkError(video.getVideoId(), companyId, video.getCourseId(), companyUserId, "定时生成的缓存docId", e.getMessage(), 1);
+                    return null;
+                }
+            } catch (Exception e) {
+                recordLinkError(video.getVideoId(), companyId, video.getCourseId(), companyUserId, "定时生成的缓存docId", e.getMessage(), 1);
+                return null;
+            }
+        }
+        recordLinkError(video.getVideoId(), companyId, video.getCourseId(), companyUserId, "定时生成的缓存docId", "所有公共账号不可用", 1);
+        return null;
+    }
+
+    private void recordLinkError(Long videoId, Long companyId, Long courseId,
+                                 Long companyUserId, String link, String errorInfo, Integer type) {
+        FeishuLinkError error = new FeishuLinkError();
+        error.setVideoId(videoId);
+        error.setCompanyId(companyId);
+        error.setCourseId(courseId);
+        error.setCompanyUserId(companyUserId);
+        error.setLink(link);
+        error.setErrorInfo(errorInfo);
+        error.setCreateTime(DateUtils.getNowDate());
+        error.setType(type);
+        feishuLinkErrorMapper.insertFeishuLinkError(error);
+        log.info("飞书链接创建失败已记录,videoId={}, link={}, error={}", videoId, link, errorInfo);
+    }
+
     private Long getCourseProjectType(Long courseId){
 
         String courseKey="h5user:course:project:" + courseId;

+ 6 - 0
fs-qwhook-sop/src/main/java/com/fs/app/controller/ApisFsUserCourseVideoController.java

@@ -184,6 +184,12 @@ public class ApisFsUserCourseVideoController extends BaseController {
         return fsUserCourseVideoService.createCartLinkByFeiShu(param);//生成飞书群发课程链接
     }
 
+    @GetMapping("/createNotionLinkNotin")
+    @ApiOperation("创建Notion链接")
+    public R createNotionLinkNotin(FsCourseLinkMiniParam param) {
+        return fsUserCourseVideoService.createNotionLink(param);//生成Notion链接
+    }
+
 
 
     @PostMapping("/selectTagList")

+ 6 - 0
fs-qwhook-sop/src/main/java/com/fs/app/controller/FsUserCourseVideoController.java

@@ -135,6 +135,12 @@ public class FsUserCourseVideoController  extends BaseController  {
         return fsUserCourseVideoService.createCartLinkByFeiShu(param);//生成飞书群发课程链接
     }
 
+    @GetMapping("/createNotionLinkNotin")
+    @ApiOperation("创建Notion链接")
+    public R createNotionLinkNotin(FsCourseLinkMiniParam param) {
+        return fsUserCourseVideoService.createNotionLink(param);//生成Notion链接
+    }
+
     /**
      * 创建 发客户小程序
      */

+ 6 - 0
fs-qwhook/src/main/java/com/fs/app/controller/ApisFsUserCourseVideoController.java

@@ -170,4 +170,10 @@ public class ApisFsUserCourseVideoController extends BaseController {
         return fsUserCourseVideoService.createCartLinkByFeiShu(param);//生成飞书群发课程链接
     }
 
+    @GetMapping("/createNotionLinkNotin")
+    @ApiOperation("创建Notion链接")
+    public R createNotionLinkNotin(FsCourseLinkMiniParam param) {
+        return fsUserCourseVideoService.createNotionLink(param);//生成Notion链接
+    }
+
 }

+ 8 - 0
fs-service/src/main/java/com/fs/course/mapper/FsCourseWatchLogMapper.java

@@ -630,6 +630,14 @@ public interface FsCourseWatchLogMapper extends BaseMapper<FsCourseWatchLog> {
             "  );")
     List<FsCourseWatchLog> selectTodayWatchLogs(@Param("logType") Integer logType);
 
+
+    List<FsCourseWatchLog> selectWatchLogByQwExternalIds(@Param("qwExternalIds") List<Long> qwExternalIds);
+
+    /**
+     * 批量更新看课记录的userId
+     */
+    void batchUpdateWatchLogUserId(@Param("list") List<FsCourseWatchLog> list);
+
     /**
      * 根据视频ID、外部联系人ID和QW用户ID查询课程观看记录(走索引)
      * */

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

@@ -167,6 +167,8 @@ public interface IFsUserCourseVideoService
 
     R createCartLinkByFeiShu(FsCourseLinkMiniParam param);
 
+    R createNotionLink(FsCourseLinkMiniParam param);
+
     R createCartLink(FsCourseLinkMiniParam param);
 
     /**

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

@@ -1078,6 +1078,20 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
                 totalCost > 0 ? String.format("%.2f", logs.size() * 1000.0 / totalCost) : "∞");
     }
 
+    /**
+     * 批量更新看课记录的userId
+     */
+    private void batchUpdateWatchLogUserId(List<FsCourseWatchLog> logs) {
+        if (CollectionUtils.isEmpty(logs)) {
+            return;
+        }
+        int batchSize = 200;
+        for (int i = 0; i < logs.size(); i += batchSize) {
+            int end = Math.min(i + batchSize, logs.size());
+            List<FsCourseWatchLog> batchList = logs.subList(i, end);
+            fsCourseWatchLogMapper.batchUpdateWatchLogUserId(batchList);
+        }
+    }
 
 
     @Override
@@ -1340,9 +1354,47 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
 
     @Override
     public R getUserAnswer(Long userId) {
+        log.info("获取看课信息,用户id:{}",userId);
         FsCourseWatchLog watchLog = fsCourseWatchLogMapper.selectAnswerWatchLog(userId);
         if (watchLog == null) {
-            return R.error("没有看课记录,请完成看课");
+            FsUser user = fsUserService.selectFsUserByUserId(userId);
+            if (user == null) {
+                return R.error("用户信息为空");
+            }
+            log.info("客户信息:{}",user);
+            List<QwExternalContact> contacts = qwExternalContactMapper.selectQwExternalByUnionID(user.getUnionId());
+            if (CollectionUtils.isEmpty(contacts)) {
+                return R.error("未添加客户企微");
+            }
+            for (QwExternalContact contact : contacts) {
+                if (contact.getFsUserId() == null) {
+                    contact.setFsUserId(userId);
+                }
+            }
+            //批量更新contacts
+            qwExternalContactMapper.batchUpdateFsUserIdById(contacts);
+            List<Long> qwExternalIds = contacts.stream().map(QwExternalContact::getId).collect(Collectors.toList());
+            List<FsCourseWatchLog> watchLogs = fsCourseWatchLogMapper.selectWatchLogByQwExternalIds(qwExternalIds);
+            if (CollectionUtils.isEmpty(watchLogs)) {
+                return R.error("没有关联的看课记录,请联系客服");
+            }
+            for (FsCourseWatchLog fsCourseWatchLog : watchLogs) {
+                fsCourseWatchLog.setUserId(userId);
+            }
+            //批量更新watchLogs
+            batchUpdateWatchLogUserId(watchLogs);
+
+            Optional<FsCourseWatchLog> result = watchLogs.stream()
+                    .filter(log -> log.getLogType() == 2)
+                    .max(Comparator.comparing(
+                            log -> Optional.ofNullable(log.getUpdateTime())
+                                    .orElse(log.getCreateTime())
+                    ));
+            watchLog = result.orElse(null);
+            if (watchLog == null) {
+                return R.error("没有完课记录,请完成看课");
+
+            }
         }
         if (watchLog.getRedPacketStatus() == 1) {
             return R.error("已领取红包");

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

@@ -53,6 +53,9 @@ import com.fs.his.service.IFsUserWxService;
 import com.fs.his.utils.ConfigUtil;
 import com.fs.his.utils.PhoneUtil;
 import com.fs.his.vo.OptionsVO;
+import com.fs.notion.dto.NotionPageRequest;
+import com.fs.notion.dto.NotionPageResponse;
+import com.fs.notion.service.INotionService;
 import com.fs.qw.domain.*;
 import com.fs.qw.mapper.*;
 import com.fs.qw.service.IQwCompanyService;
@@ -134,6 +137,7 @@ public class FsUserCourseVideoServiceImpl implements IFsUserCourseVideoService
     private static final String REAL_LINK_PREFIX = "/courseH5/pages/course/learning?course=";
     private static final String SHORT_LINK_PREFIX = "/courseH5/pages/course/learning?s=";
     private static final String appActivityLink = "/pages_course/luckybag.html?link=";
+    private static final String notionLink = "https://notion.hcdyfa.top/pages_course/videoDetail?logId=";
     @Autowired
     ICompanyService companyService;
     @Autowired
@@ -276,6 +280,9 @@ public class FsUserCourseVideoServiceImpl implements IFsUserCourseVideoService
     @Autowired
     private FeiShuService feiShuService;
 
+    @Autowired
+    private INotionService notionService;
+
     private final BlockingQueue<FsCourseWatchLog> watchLogsQueue = new LinkedBlockingQueue<>(20000);
 
 
@@ -2409,6 +2416,52 @@ public class FsUserCourseVideoServiceImpl implements IFsUserCourseVideoService
 
     }
 
+    @Override
+    public R createNotionLink(FsCourseLinkMiniParam param) {
+        QwUser qwUser = qwExternalContactService.getQwUserByRedis(param.getCorpId().trim(), param.getQwUserId().trim());
+
+        if (qwUser==null||qwUser.getCompanyId()==null||qwUser.getCompanyUserId()==null){
+            return R.error("员工未绑定 销售公司 或 销售 请先绑定");
+        }
+
+        QwCompany qwCompany = iQwCompanyService.getQwCompanyByRedis(param.getCorpId());
+
+        if (qwCompany == null ) {
+            return  R.error().put("msg","企业不存在,请联系管理员");
+        }
+
+        String json = configService.selectConfigByKey("course.config");
+        CourseConfig config = JSON.parseObject(json, CourseConfig.class);
+
+        if (config == null) {
+            return R.error().put("msg","课程默认配置为空,请联系管理员");
+        }
+
+        Long courseId = param.getCourseId();
+        //qw_user表的主键id
+        Long qwUserId = qwUser.getId();
+        Date createTime = DateUtils.getNowDate();
+        Long externalUserId = param.getExternalUserId();
+        Long videoId = param.getVideoId();
+        Long companyId = qwUser.getCompanyId();
+        Long companyUserId = qwUser.getCompanyUserId();
+
+        Long notionWatchLogId = addNotionWatchLog(param.getVideoId(), param.getCourseId(), param.getFsUserId(), qwUser, param.getExternalUserId());
+        createNotionH5Link(param.getCorpId(), createTime, courseId.intValue(), videoId.intValue(), String.valueOf(qwUserId), companyUserId, companyId, externalUserId, config);
+        NotionPageRequest request = new NotionPageRequest();
+        request.setTitle(param.getTitle());
+        request.setIframeUrl(notionLink + notionWatchLogId);
+        NotionPageResponse page = notionService.createPage(request);
+        //生成卡片链接
+        JSONObject news = new JSONObject(true); // true 表示保持字段顺序
+        news.put("linkTitle", param.getTitle());
+        news.put("linkDescribe", param.getTitle());
+        news.put("linkImageUrl", config.getSidebarImageUrl());
+        news.put("linkUrl", page.getPublicUrl());
+
+        return R.ok().put("data",news);
+    }
+
 
     //插入观看记录
     private void addWatchLogIfNeeded(Long videoId, Long courseId,
@@ -2445,6 +2498,59 @@ public class FsUserCourseVideoServiceImpl implements IFsUserCourseVideoService
 
     }
 
+    private void createNotionH5Link(String corpId, Date sendTime,
+                                    Integer courseId, Integer videoId, String qwUserId,
+                                    Long companyUserId, Long companyId, Long externalId, CourseConfig config){
+        FsCourseLink link = createFsCourseLink(corpId, sendTime, courseId, videoId, Long.valueOf(qwUserId),
+                companyUserId, companyId, externalId, 3, null);
+        FsCourseRealLink courseMap = new FsCourseRealLink();
+        BeanUtils.copyProperties(link, courseMap);
+
+        String courseJson = JSON.toJSONString(courseMap);
+        String realLinkFull = REAL_LINK_PREFIX + courseJson;
+
+        link.setRealLink(realLinkFull);
+        LocalDate tomorrow = LocalDate.now().plusDays(1);
+        LocalDateTime expireDateTime = LocalDateTime.of(tomorrow, LocalTime.of(23, 59, 59));
+        Date expireDate = Date.from(expireDateTime.atZone(ZoneId.systemDefault()).toInstant());
+
+        link.setUpdateTime(expireDate);
+        fsCourseLinkMapper.insertFsCourseLink(link);
+    }
+
+    private Long addNotionWatchLog(Long videoId, Long courseId,
+                                   Long fsUserId, QwUser qwUser,Long externalId) {
+        try {
+
+            FsCourseWatchLog watchLog = new FsCourseWatchLog();
+            watchLog.setVideoId(videoId);
+            watchLog.setQwExternalContactId(externalId);
+            watchLog.setSendType(2);
+            watchLog.setQwUserId(qwUser.getId());
+            watchLog.setDuration(0L);
+            watchLog.setCourseId(courseId);
+            watchLog.setCompanyUserId(qwUser.getCompanyUserId());
+            watchLog.setCompanyId(qwUser.getCompanyId());
+            watchLog.setCreateTime(new Date());
+            watchLog.setUpdateTime(new Date());
+            watchLog.setLogType(3);
+
+            if (fsUserId == null) {
+                fsUserId=0L;
+            }
+
+            watchLog.setUserId(fsUserId);
+
+            //存看课记录
+            courseWatchLogMapper.insertOrUpdateFsCourseWatchLog(watchLog);
+            return watchLog.getLogId();
+
+        }catch (Exception e){
+            logger.error("一键群发失败-插入观看记录失败:"+e.getMessage());
+        }
+        return null;
+    }
+
     private String createLinkByMiniApp(Date sendTime, Long courseId, Long videoId,
                                        QwUser qwUser, Long externalId,int type,String domainName,Integer isRoom) {
 

+ 152 - 0
fs-service/src/main/java/com/fs/notion/client/NotionClient.java

@@ -0,0 +1,152 @@
+package com.fs.notion.client;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import okhttp3.*;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Notion 官方 API 客户端
+ */
+public class NotionClient implements AutoCloseable {
+
+    private static final Logger log = LoggerFactory.getLogger(NotionClient.class);
+    private static final String NOTION_API_BASE = "https://api.notion.com/v1";
+    private static final String NOTION_VERSION = "2022-06-28";
+
+    private final String apiKey;
+    private final OkHttpClient httpClient;
+    private final ObjectMapper objectMapper;
+
+    public NotionClient(String apiKey) {
+        this.apiKey = apiKey;
+        this.httpClient = new OkHttpClient();
+        this.objectMapper = new ObjectMapper();
+    }
+
+    @Override
+    public void close() {
+        httpClient.dispatcher().executorService().shutdown();
+        httpClient.connectionPool().evictAll();
+    }
+
+    // ==================== 页面相关 ====================
+
+    /**
+     * 获取页面详情
+     */
+    public JsonNode getPage(String pageId) throws IOException {
+        Request request = buildGetRequest("/pages/" + pageId);
+        return executeRequest(request);
+    }
+
+    /**
+     * 在页面下创建子页面(带自定义内容块)
+     */
+    public JsonNode createPageWithChildren(String parentPageId, String title, List<Map<String, Object>> children) throws IOException {
+        Map<String, Object> body = new HashMap<>();
+        body.put("parent", Collections.singletonMap("page_id", parentPageId));
+
+        Map<String, Object> textMap = new HashMap<>();
+        textMap.put("content", title);
+        Map<String, Object> richText = Collections.singletonMap("text", textMap);
+        Map<String, Object> titleProp = Collections.singletonMap("title", Collections.singletonList(richText));
+        body.put("properties", Collections.singletonMap("title", titleProp));
+
+        if (children != null && !children.isEmpty()) {
+            body.put("children", children);
+        }
+
+        Request request = buildPostRequest("/pages", body);
+        return executeRequest(request);
+    }
+
+    /**
+     * 在页面下创建子页面(仅标题,无内容块)
+     */
+    public JsonNode createPageUnderPage(String parentPageId, String title) throws IOException {
+        return createPageWithChildren(parentPageId, title, null);
+    }
+
+    /**
+     * 搜索 Notion 内容(获取父页面 ID)
+     */
+    public JsonNode search(String query) throws IOException {
+        Map<String, Object> body = new HashMap<>();
+        if (query != null && !query.isEmpty()) {
+            body.put("query", query);
+        }
+        Map<String, Object> filter = new HashMap<>();
+        filter.put("value", "page");
+        filter.put("property", "object");
+        body.put("filter", filter);
+        body.put("page_size", 100);
+
+        Request request = buildPostRequest("/search", body);
+        return executeRequest(request);
+    }
+
+    /**
+     * 获取块的子块
+     */
+    public JsonNode getBlockChildren(String blockId) throws IOException {
+        HttpUrl url = HttpUrl.parse(NOTION_API_BASE + "/blocks/" + blockId + "/children")
+                .newBuilder()
+                .addQueryParameter("page_size", "100")
+                .build();
+        Request request = new Request.Builder()
+                .url(url)
+                .header("Authorization", "Bearer " + apiKey)
+                .header("Notion-Version", NOTION_VERSION)
+                .get()
+                .build();
+        return executeRequest(request);
+    }
+
+    // ==================== 内部工具方法 ====================
+
+    private Request buildGetRequest(String path) {
+        return new Request.Builder()
+                .url(NOTION_API_BASE + path)
+                .header("Authorization", "Bearer " + apiKey)
+                .header("Notion-Version", NOTION_VERSION)
+                .get()
+                .build();
+    }
+
+    private Request buildPostRequest(String path, Object body) throws JsonProcessingException {
+        String json = objectMapper.writeValueAsString(body);
+        RequestBody requestBody = RequestBody.create(MediaType.parse("application/json"),json);
+        return new Request.Builder()
+                .url(NOTION_API_BASE + path)
+                .header("Authorization", "Bearer " + apiKey)
+                .header("Notion-Version", NOTION_VERSION)
+                .post(requestBody)
+                .build();
+    }
+
+    private JsonNode executeRequest(Request request) throws IOException {
+        try (Response response = httpClient.newCall(request).execute()) {
+            String responseBody = response.body() != null ? response.body().string() : "{}";
+            if (!response.isSuccessful()) {
+                throw new IOException("Notion API 请求失败 [" + response.code() + "]: " + responseBody);
+            }
+            return objectMapper.readTree(responseBody);
+        }
+    }
+
+    /**
+     * 格式化 JSON 输出
+     */
+    public String toPrettyJson(JsonNode node) throws JsonProcessingException {
+        return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(node);
+    }
+}

+ 261 - 0
fs-service/src/main/java/com/fs/notion/client/NotionInternalClient.java

@@ -0,0 +1,261 @@
+package com.fs.notion.client;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import okhttp3.*;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.*;
+
+/**
+ * Notion 内部私有 API 客户端
+ * 通过 token_v2 调用 saveTransactionsFanout 实现一键公开发布
+ */
+public class NotionInternalClient implements AutoCloseable {
+
+    private static final Logger log = LoggerFactory.getLogger(NotionInternalClient.class);
+    private static final String API_BASE = "https://www.notion.so/api/v3";
+
+    private final String tokenV2;
+    private final OkHttpClient httpClient;
+    private final ObjectMapper objectMapper;
+
+    private static final String HARDCODED_DOMAIN_ID = "396f80aa-760e-81e6-97ac-007071aa9309";
+
+    private String spaceId;
+    private String userId;
+    private String domainId;
+
+    public NotionInternalClient(String tokenV2) {
+        this.tokenV2 = tokenV2;
+        this.httpClient = new OkHttpClient.Builder()
+                .followRedirects(true)
+                .build();
+        this.objectMapper = new ObjectMapper();
+    }
+
+    /**
+     * 初始化:从内部 API 获取 spaceId, userId, domainId
+     */
+    public void init() throws IOException {
+        Map<String, Object> body = new LinkedHashMap<>();
+        body.put("limit", 100);
+        Request req = buildPost("/loadUserContent", body);
+        JsonNode resp = executeRequest(req);
+
+        JsonNode spaces = resp.at("/recordMap/space");
+        if (spaces.size() > 0) this.spaceId = spaces.fieldNames().next();
+        JsonNode users = resp.at("/recordMap/notion_user");
+        if (users.size() > 0) this.userId = users.fieldNames().next();
+
+        if (spaceId == null || userId == null) {
+            throw new IOException("无法获取 spaceId 或 userId");
+        }
+
+        JsonNode spaceValue = resp.at("/recordMap/space/" + spaceId + "/value");
+        domainId = spaceValue.at("/domain").asText();
+        if (domainId.isEmpty()) domainId = spaceValue.at("/domain_id").asText();
+
+        if (domainId == null || domainId.isEmpty()) {
+            try {
+                body = new LinkedHashMap<>();
+                body.put("spaceId", spaceId);
+                body.put("fetchPublishedMetadata", true);
+                body.put("type", "sites");
+                req = buildPost("/getPublicBlocksForUser", body);
+                resp = executeRequest(req);
+                JsonNode sites = resp.at("/recordMap/site");
+                if (sites.size() > 0) {
+                    domainId = resp.at("/recordMap/site/" + sites.fieldNames().next() + "/value/domain_id").asText();
+                }
+            } catch (Exception ignored) {
+            }
+        }
+
+        if (domainId == null || domainId.isEmpty()) {
+            domainId = HARDCODED_DOMAIN_ID;
+            log.info("[Notion内部API] 使用固定 domainId (抓包获取)");
+        }
+
+        log.info("[Notion内部API] 就绪");
+    }
+
+    /**
+     * 将页面设为公开可访问(模拟网页端"发布"按钮)
+     *
+     * @param pageId 页面 UUID(带连字符格式)
+     */
+    public JsonNode makePagePublic(String pageId) throws IOException {
+        if (spaceId == null || userId == null || domainId == null) {
+            init();
+        }
+
+        String siteId = UUID.randomUUID().toString();
+        String requestId = UUID.randomUUID().toString();
+        long now = System.currentTimeMillis();
+
+        List<Map<String, Object>> transactions = new ArrayList<>();
+
+        // 事务 1: 创建 site 记录 + 更新 block format
+        Map<String, Object> tx1 = new LinkedHashMap<>();
+        tx1.put("id", UUID.randomUUID().toString());
+        tx1.put("spaceId", spaceId);
+        tx1.put("debug", Collections.singletonMap("userAction", "publishSite.create"));
+
+        List<Map<String, Object>> ops1 = new ArrayList<>();
+
+        Map<String, Object> op1a = new LinkedHashMap<>();
+        Map<String, Object> op1aPointer = new HashMap<>();
+        op1aPointer.put("table", "site");
+        op1aPointer.put("id", siteId);
+        op1aPointer.put("spaceId", spaceId);
+        op1a.put("pointer", op1aPointer);
+        op1a.put("path", Collections.emptyList());
+        op1a.put("command", "set");
+
+        Map<String, Object> args1a = new LinkedHashMap<>();
+        args1a.put("id", siteId);
+        args1a.put("type", "site");
+        args1a.put("space_id", spaceId);
+        args1a.put("version", 1);
+        args1a.put("last_version", 1);
+        args1a.put("domain_id", domainId);
+        args1a.put("parent_id", pageId);
+        args1a.put("parent_table", "block");
+        args1a.put("created_time", now);
+        args1a.put("created_by_id", userId);
+        args1a.put("created_by_table", "notion_user");
+        args1a.put("last_edited_time", now);
+        args1a.put("last_edited_by_id", userId);
+        args1a.put("last_edited_by_table", "notion_user");
+        args1a.put("header", new HashMap<>());
+        args1a.put("settings", Collections.singletonMap("hideUpsellBanner", true));
+        op1a.put("args", args1a);
+        ops1.add(op1a);
+
+        Map<String, Object> op1b = new LinkedHashMap<>();
+        Map<String, Object> op1bPointer = new HashMap<>();
+        op1bPointer.put("id", pageId);
+        op1bPointer.put("table", "block");
+        op1bPointer.put("spaceId", spaceId);
+        op1b.put("pointer", op1bPointer);
+        op1b.put("path", Collections.singletonList("format"));
+        op1b.put("command", "update");
+        op1b.put("args", Collections.singletonMap("site_id", siteId));
+        ops1.add(op1b);
+
+        Map<String, Object> op1c = new LinkedHashMap<>();
+        Map<String, Object> op1cPointer = new HashMap<>();
+        op1cPointer.put("id", pageId);
+        op1cPointer.put("table", "block");
+        op1cPointer.put("spaceId", spaceId);
+        op1c.put("pointer", op1cPointer);
+        op1c.put("path", Collections.emptyList());
+        op1c.put("command", "update");
+        Map<String, Object> op1cArgs = new HashMap<>();
+        op1cArgs.put("last_edited_time", now + 1);
+        op1cArgs.put("last_edited_by_id", userId);
+        op1cArgs.put("last_edited_by_table", "notion_user");
+        op1c.put("args", op1cArgs);
+        ops1.add(op1c);
+
+        tx1.put("operations", ops1);
+        transactions.add(tx1);
+
+        // 事务 2: 设置公开权限
+        Map<String, Object> tx2 = new LinkedHashMap<>();
+        tx2.put("id", UUID.randomUUID().toString());
+        tx2.put("spaceId", spaceId);
+        tx2.put("debug", Collections.singletonMap("userAction", "PermissionItem.handlePermissionItemChange"));
+
+        List<Map<String, Object>> ops2 = new ArrayList<>();
+
+        Map<String, Object> op2a = new LinkedHashMap<>();
+        Map<String, Object> op2aPointer = new HashMap<>();
+        op2aPointer.put("id", pageId);
+        op2aPointer.put("table", "block");
+        op2aPointer.put("spaceId", spaceId);
+        op2a.put("pointer", op2aPointer);
+        op2a.put("command", "setPermissionItem");
+        op2a.put("path", Collections.singletonList("permissions"));
+        Map<String, Object> op2aArgs = new HashMap<>();
+        op2aArgs.put("type", "public_permission");
+        op2aArgs.put("role", "reader");
+        op2aArgs.put("is_site", true);
+        op2aArgs.put("added_timestamp", now + 2);
+        op2a.put("args", op2aArgs);
+        ops2.add(op2a);
+
+        Map<String, Object> op2b = new LinkedHashMap<>();
+        Map<String, Object> op2bPointer = new HashMap<>();
+        op2bPointer.put("id", pageId);
+        op2bPointer.put("table", "block");
+        op2bPointer.put("spaceId", spaceId);
+        op2b.put("pointer", op2bPointer);
+        op2b.put("path", Collections.emptyList());
+        op2b.put("command", "update");
+        Map<String, Object> op2bArgs = new HashMap<>();
+        op2bArgs.put("last_edited_time", now + 3);
+        op2bArgs.put("last_edited_by_id", userId);
+        op2bArgs.put("last_edited_by_table", "notion_user");
+        op2b.put("args", op2bArgs);
+        ops2.add(op2b);
+
+        tx2.put("operations", ops2);
+        transactions.add(tx2);
+
+        Map<String, Object> body = new LinkedHashMap<>();
+        body.put("requestId", requestId);
+        body.put("transactions", transactions);
+
+        Request req = buildPost("/saveTransactionsFanout", body);
+        return executeRequest(req);
+    }
+
+    // ==================== 内部工具方法 ====================
+
+    private Request buildPost(String path, Object body) throws IOException {
+        String json = objectMapper.writeValueAsString(body);
+        RequestBody requestBody = RequestBody.create(MediaType.parse("application/json"),json);
+        return new Request.Builder()
+                .url(API_BASE + path)
+                .header("Content-Type", "application/json")
+                .header("Cookie", "token_v2=" + tokenV2)
+                .post(requestBody)
+                .build();
+    }
+
+    private JsonNode executeRequest(Request request) throws IOException {
+        try (Response response = httpClient.newCall(request).execute()) {
+            String body = response.body() != null ? response.body().string() : "{}";
+            if (!response.isSuccessful()) {
+                throw new IOException("HTTP " + response.code() + ": " + body);
+            }
+            return objectMapper.readTree(body);
+        }
+    }
+
+    /**
+     * 检测 token_v2 是否有效
+     */
+    public boolean isTokenValid() {
+        try {
+            Map<String, Object> body = new LinkedHashMap<>();
+            body.put("limit", 1);
+            Request req = buildPost("/loadUserContent", body);
+            JsonNode resp = executeRequest(req);
+            JsonNode spaces = resp.at("/recordMap/space");
+            return spaces != null && spaces.size() > 0;
+        } catch (Exception e) {
+            return false;
+        }
+    }
+
+    @Override
+    public void close() {
+        httpClient.dispatcher().executorService().shutdown();
+        httpClient.connectionPool().evictAll();
+    }
+}

+ 20 - 0
fs-service/src/main/java/com/fs/notion/config/NotionConfig.java

@@ -0,0 +1,20 @@
+package com.fs.notion.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * Notion 配置
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "notion")
+public class NotionConfig {
+    /** Notion API Key (Integration Secret) */
+    private String apiKey = "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";
+    /** 公开站点域名 */
+    private String domain = "syysy.notion.site";
+    /** 批量创建线程数 */
+    private int threadCount = 8;
+}

+ 86 - 0
fs-service/src/main/java/com/fs/notion/controller/NotionController.java

@@ -0,0 +1,86 @@
+package com.fs.notion.controller;
+
+import com.fs.common.core.domain.R;
+import com.fs.notion.dto.NotionBatchRequest;
+import com.fs.notion.dto.NotionBatchResponse;
+import com.fs.notion.dto.NotionPageRequest;
+import com.fs.notion.dto.NotionPageResponse;
+import com.fs.notion.service.INotionService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * Notion 页面创建 REST 接口
+ *
+ * <pre>
+ * POST /api/notion/pages        - 创建单个页面
+ * POST /api/notion/pages/batch  - 批量创建页面(多线程并行)
+ * GET  /api/notion/pages/health - 健康检查 + token 状态
+ * GET  /api/notion/pages/token  - 检测 token_v2 是否有效
+ * </pre>
+ */
+@RestController
+@RequestMapping("/api/notion/pages")
+public class NotionController {
+
+    @Autowired
+    private INotionService notionService;
+
+    /**
+     * 创建单个页面
+     */
+    @PostMapping
+    public R createPage(@RequestBody NotionPageRequest request) {
+        NotionPageResponse result = notionService.createPage(request);
+        R r = R.ok();
+        r.put("data", result);
+        return r;
+    }
+
+    /**
+     * 批量创建页面(多线程并行)
+     */
+    @PostMapping("/batch")
+    public R createPagesBatch(@RequestBody NotionBatchRequest request) {
+        long start = System.currentTimeMillis();
+        List<NotionPageResponse> results = notionService.createPagesBatch(request.getPages());
+
+        long success = results.stream().filter(NotionPageResponse::isSuccess).count();
+        long fail = results.size() - success;
+
+        NotionBatchResponse response = NotionBatchResponse.builder()
+                .totalElapsedMs(System.currentTimeMillis() - start)
+                .successCount((int) success)
+                .failCount((int) fail)
+                .results(results)
+                .build();
+        R r = R.ok();
+        r.put("data", response);
+        return r;
+    }
+
+    /**
+     * 健康检查 + token 状态
+     */
+    @GetMapping("/health")
+    public R health() {
+        R r = R.ok();
+        r.put("status", "ok");
+        r.put("tokenValid", notionService.isTokenValid());
+        return r;
+    }
+
+    /**
+     * 检测 token_v2 是否有效
+     */
+    @GetMapping("/token")
+    public R checkToken() {
+        boolean valid = notionService.isTokenValid();
+        R r = R.ok();
+        r.put("valid", valid);
+        r.put("message", valid ? "token_v2 有效" : "token_v2 已过期,请更新 token_v2.txt 后重启");
+        return r;
+    }
+}

+ 18 - 0
fs-service/src/main/java/com/fs/notion/dto/NotionBatchRequest.java

@@ -0,0 +1,18 @@
+package com.fs.notion.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+/**
+ * 批量创建请求
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class NotionBatchRequest {
+    /** 页面列表 */
+    private List<NotionPageRequest> pages;
+}

+ 26 - 0
fs-service/src/main/java/com/fs/notion/dto/NotionBatchResponse.java

@@ -0,0 +1,26 @@
+package com.fs.notion.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+/**
+ * 批量创建响应
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class NotionBatchResponse {
+    /** 批量操作总耗时(毫秒) */
+    private long totalElapsedMs;
+    /** 成功数 */
+    private int successCount;
+    /** 失败数 */
+    private int failCount;
+    /** 每个页面的结果 */
+    private List<NotionPageResponse> results;
+}

+ 18 - 0
fs-service/src/main/java/com/fs/notion/dto/NotionPageRequest.java

@@ -0,0 +1,18 @@
+package com.fs.notion.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * Notion 页面创建请求
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class NotionPageRequest {
+    /** 页面标题 */
+    private String title;
+    /** iframe 嵌入链接 */
+    private String iframeUrl;
+}

+ 28 - 0
fs-service/src/main/java/com/fs/notion/dto/NotionPageResponse.java

@@ -0,0 +1,28 @@
+package com.fs.notion.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * Notion 页面创建响应
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class NotionPageResponse {
+    /** 是否成功 */
+    private boolean success;
+    /** 页面标题 */
+    private String title;
+    /** Notion 内部链接 */
+    private String notionUrl;
+    /** 公开可访问链接 */
+    private String publicUrl;
+    /** 耗时(毫秒) */
+    private long elapsedMs;
+    /** 错误信息 */
+    private String error;
+}

+ 33 - 0
fs-service/src/main/java/com/fs/notion/service/INotionService.java

@@ -0,0 +1,33 @@
+package com.fs.notion.service;
+
+import com.fs.notion.dto.NotionPageRequest;
+import com.fs.notion.dto.NotionPageResponse;
+
+import java.util.List;
+
+/**
+ * Notion 页面创建 + 公开发布服务接口
+ */
+public interface INotionService {
+
+    /**
+     * 创建单个页面(同步)
+     */
+    NotionPageResponse createPage(NotionPageRequest request);
+
+    /**
+     * 批量创建页面(多线程并行)
+     */
+    List<NotionPageResponse> createPagesBatch(List<NotionPageRequest> requests);
+
+    /**
+     * 创建单个页面并返回公开链接
+     * @return [notionUrl, publicUrl]
+     */
+    String[] createPageWithPublish(String title, String iframeUrl);
+
+    /**
+     * 检测 token_v2 是否有效
+     */
+    boolean isTokenValid();
+}

+ 206 - 0
fs-service/src/main/java/com/fs/notion/service/impl/NotionServiceImpl.java

@@ -0,0 +1,206 @@
+package com.fs.notion.service.impl;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fs.notion.client.NotionClient;
+import com.fs.notion.client.NotionInternalClient;
+import com.fs.notion.config.NotionConfig;
+import com.fs.notion.dto.NotionPageRequest;
+import com.fs.notion.dto.NotionPageResponse;
+import com.fs.notion.service.INotionService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.PostConstruct;
+import javax.annotation.PreDestroy;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.*;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Notion 页面创建 + 公开发布服务实现
+ */
+@Slf4j
+@Service
+public class NotionServiceImpl implements INotionService {
+
+    @Autowired
+    private NotionConfig notionConfig;
+
+    private String parentPageId;
+    private String tokenV2;
+    private ExecutorService executor;
+
+    @PostConstruct
+    public void init() {
+        executor = Executors.newFixedThreadPool(notionConfig.getThreadCount());
+
+        // 初始化,获取父页面 ID
+        try (NotionClient client = new NotionClient(notionConfig.getApiKey())) {
+            JsonNode result = client.search("");
+            JsonNode results = result.get("results");
+            if (results != null && results.size() > 0) {
+                for (JsonNode item : results) {
+                    if ("page".equals(item.get("object").asText())) {
+                        if (item.has("archived") && item.get("archived").asBoolean()) continue;
+                        if (item.has("in_trash") && item.get("in_trash").asBoolean()) continue;
+                        parentPageId = item.get("id").asText();
+                        String title = "无标题";
+                        try {
+                            JsonNode titleNode = item.at("/properties/title/title");
+                            if (titleNode.isArray() && titleNode.size() > 0) {
+                                title = titleNode.get(0).at("/plain_text").asText();
+                            }
+                        } catch (Exception ignored) {}
+                        log.info("[Notion] 父页面: {} ({})", title, parentPageId);
+                        break;
+                    }
+                }
+            }
+            if (parentPageId == null) {
+                log.warn("[Notion] 未找到可用的父页面,请确保 Integration 已关联页面");
+            }
+        } catch (Exception e) {
+            log.error("[Notion] 初始化失败", e);
+        }
+
+        // 加载 token_v2
+        try {
+            tokenV2 = new String(Files.readAllBytes(Paths.get("C:/token_v2.txt"))).trim();
+            log.info("[Notion] token_v2 已加载");
+        } catch (Exception e) {
+            log.warn("[Notion] 未找到 token_v2.txt,页面将不会自动公开");
+        }
+    }
+
+    @Override
+    public NotionPageResponse createPage(NotionPageRequest request) {
+        long start = System.currentTimeMillis();
+        try {
+            String[] result = createPageWithPublish(request.getTitle(), request.getIframeUrl());
+            return NotionPageResponse.builder()
+                    .success(true)
+                    .title(request.getTitle())
+                    .notionUrl(result[0])
+                    .publicUrl(result[1])
+                    .elapsedMs(System.currentTimeMillis() - start)
+                    .build();
+        } catch (Exception e) {
+            log.error("[Notion] 创建失败: {}", request.getTitle(), e);
+            return NotionPageResponse.builder()
+                    .success(false)
+                    .title(request.getTitle())
+                    .error(e.getMessage())
+                    .elapsedMs(System.currentTimeMillis() - start)
+                    .build();
+        }
+    }
+
+    @Override
+    public List<NotionPageResponse> createPagesBatch(List<NotionPageRequest> requests) {
+        long start = System.currentTimeMillis();
+        List<Future<NotionPageResponse>> futures = new CopyOnWriteArrayList<>();
+        AtomicInteger index = new AtomicInteger(0);
+
+        for (NotionPageRequest req : requests) {
+            int idx = index.getAndIncrement();
+            futures.add(executor.submit(() -> {
+                long t0 = System.currentTimeMillis();
+                try {
+                    String[] result = createPageWithPublish(req.getTitle(), req.getIframeUrl());
+                    return NotionPageResponse.builder()
+                            .success(true)
+                            .title(req.getTitle())
+                            .notionUrl(result[0])
+                            .publicUrl(result[1])
+                            .elapsedMs(System.currentTimeMillis() - t0)
+                            .build();
+                } catch (Exception e) {
+                    log.error("[Notion] 批量创建失败 [{}]: {}", idx, req.getTitle(), e);
+                    return NotionPageResponse.builder()
+                            .success(false)
+                            .title(req.getTitle())
+                            .error(e.getMessage())
+                            .elapsedMs(System.currentTimeMillis() - t0)
+                            .build();
+                }
+            }));
+        }
+
+        // 收集结果
+        List<NotionPageResponse> results = new CopyOnWriteArrayList<>();
+        for (Future<NotionPageResponse> f : futures) {
+            try {
+                results.add(f.get(60, TimeUnit.SECONDS));
+            } catch (Exception e) {
+                results.add(NotionPageResponse.builder()
+                        .success(false).error("超时: " + e.getMessage()).build());
+            }
+        }
+
+        log.info("[Notion] 批量完成: {} 个页面, 总耗时 {}ms", requests.size(),
+                System.currentTimeMillis() - start);
+        return results;
+    }
+
+    @Override
+    public String[] createPageWithPublish(String title, String iframeUrl) {
+        if (parentPageId == null) {
+            throw new IllegalStateException("父页面未初始化,请确保 Integration 已关联页面");
+        }
+
+        NotionClient client = new NotionClient(notionConfig.getApiKey());
+        NotionInternalClient ic = (tokenV2 != null) ? new NotionInternalClient(tokenV2) : null;
+
+        try {
+            if (ic != null) ic.init();
+
+            // 构建 embed 块
+            Map<String, Object> embedBlock = new HashMap<>();
+            embedBlock.put("object", "block");
+            embedBlock.put("type", "embed");
+            embedBlock.put("embed", Collections.singletonMap("url", iframeUrl));
+            List<Map<String, Object>> children = Collections.singletonList(embedBlock);
+
+            // 创建页面
+            JsonNode page = client.createPageWithChildren(parentPageId, title, children);
+            String pageId = page.get("id").asText();
+            String notionUrl = page.get("url").asText();
+            String publicUrl = notionUrl;
+
+            // 公开发布
+            if (ic != null) {
+                ic.makePagePublic(pageId);
+                publicUrl = "https://" + notionConfig.getDomain() + "/"
+                        + notionUrl.substring(notionUrl.lastIndexOf('/') + 1);
+            }
+
+            return new String[]{notionUrl, publicUrl};
+        } catch (Exception e) {
+            throw new RuntimeException("创建 Notion 页面失败: " + e.getMessage(), e);
+        } finally {
+            client.close();
+            if (ic != null) ic.close();
+        }
+    }
+
+    @Override
+    public boolean isTokenValid() {
+        if (tokenV2 == null) return false;
+        try (NotionInternalClient ic = new NotionInternalClient(tokenV2)) {
+            return ic.isTokenValid();
+        } catch (Exception e) {
+            return false;
+        }
+    }
+
+    @PreDestroy
+    public void destroy() {
+        if (executor != null) executor.shutdown();
+    }
+}

+ 64 - 0
fs-service/src/main/java/com/fs/qw/domain/QwUserIdMapping.java

@@ -0,0 +1,64 @@
+package com.fs.qw.domain;
+
+import com.baomidou.mybatisplus.annotation.FieldFill;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fs.common.annotation.Excel;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 企微userid-openid-unionid映射表 qw_user_id_mapping
+ *
+ * @author fs
+ * @date 2026-07-27
+ */
+@Data
+public class QwUserIdMapping implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    /** id */
+    private Long id;
+
+    /** 企业微信成员userid */
+    @Excel(name = "企业微信成员userid")
+    private String qwUserId;
+
+    /** 对应公众号下的openid */
+    @Excel(name = "openid")
+    private String openid;
+
+    /** 微信unionid(统一身份标识) */
+    @Excel(name = "unionid")
+    private String unionid;
+
+    /** 企业corpid */
+    @Excel(name = "企业corpid")
+    private String corpId;
+
+    /** 公众号appid(转换时的目标appid) */
+    @Excel(name = "公众号appid")
+    private String appId;
+
+    /** 映射类型:1-成员userid转openid 2-外部联系人external_userid转openid */
+    @Excel(name = "映射类型")
+    private Integer mappingType;
+
+    /** 是否删除 */
+    @Excel(name = "是否删除")
+    private Integer isDel;
+
+    /** 创建时间 */
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @TableField(fill = FieldFill.INSERT)
+    private Date createTime;
+
+    /** 更新时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private Date updateTime;
+}

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

@@ -582,4 +582,9 @@ public interface QwExternalContactMapper extends BaseMapper<QwExternalContact> {
      *  根据企业主体id+创建日期范围查询企微客户列表
      * */
     List<QwExternalContact> selectQwExternalContactListByCreateTime(@Param("corpId") String corpId, @Param("startTime") Date startTime, @Param("endTime") Date endTime);
+
+    /**
+     * 批量更新外部联系人的fsUserId
+     */
+    int batchUpdateFsUserIdById(@Param("list") List<QwExternalContact> list);
 }

+ 55 - 0
fs-service/src/main/java/com/fs/qw/mapper/QwUserIdMappingMapper.java

@@ -0,0 +1,55 @@
+package com.fs.qw.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.qw.domain.QwUserIdMapping;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 企微userid-openid-unionid映射Mapper接口
+ *
+ * @author fs
+ * @date 2026-07-27
+ */
+public interface QwUserIdMappingMapper extends BaseMapper<QwUserIdMapping> {
+
+    /**
+     * 根据qwUserId、appId和映射类型查询映射记录
+     */
+    QwUserIdMapping selectByQwUserIdAndAppId(@Param("qwUserId") String qwUserId,
+                                               @Param("appId") String appId,
+                                               @Param("mappingType") Integer mappingType);
+
+    /**
+     * 根据openid查询映射记录
+     */
+    QwUserIdMapping selectByOpenid(@Param("openid") String openid);
+
+    /**
+     * 根据unionid查询映射记录
+     */
+    List<QwUserIdMapping> selectByUnionid(@Param("unionid") String unionid);
+
+    /**
+     * 更新openid
+     */
+    int updateOpenid(@Param("id") Long id,
+                     @Param("openid") String openid,
+                     @Param("unionid") String unionid,
+                     @Param("appId") String appId);
+
+    /**
+     * 根据qwUserId、corpId和映射类型查询映射记录(当appId未知时的降级查询)
+     */
+    QwUserIdMapping selectByQwUserIdAndCorpId(@Param("qwUserId") String qwUserId,
+                                               @Param("corpId") String corpId,
+                                               @Param("mappingType") Integer mappingType);
+
+    /**
+     * 批量查询映射记录
+     */
+    List<QwUserIdMapping> selectListByQwUserIds(@Param("qwUserIds") List<String> qwUserIds,
+                                                 @Param("appId") String appId,
+                                                 @Param("mappingType") Integer mappingType);
+}

+ 65 - 0
fs-service/src/main/java/com/fs/qw/redpacket/param/WorkWxRedPacketParam.java

@@ -0,0 +1,65 @@
+package com.fs.qw.redpacket.param;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 企业微信发放企业红包 请求参数
+ * 对应 sendworkwxredpack API
+ *
+ * @author fs
+ * @date 2026-07-27
+ */
+@Data
+public class WorkWxRedPacketParam implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    /** 随机字符串,不长于32位 */
+    private String nonceStr;
+
+    /** 商户订单号(每个订单号必须唯一) */
+    private String mchBillno;
+
+    /** 微信支付分配的商户号 */
+    private String mchId;
+
+    /** 公众账号appid(企业微信corpid即为此appId) */
+    private String wxappid;
+
+    /** 发送者名称(与agentid互斥) */
+    private String senderName;
+
+    /** 发送红包的应用id(与senderName互斥) */
+    private Integer agentid;
+
+    /** 发送者头像素材id */
+    private String senderHeaderMediaId;
+
+    /** 接受红包的用户openid */
+    private String reOpenid;
+
+    /** 金额,单位分 */
+    private Integer totalAmount;
+
+    /** 红包祝福语 */
+    private String wishing;
+
+    /** 项目名称 */
+    private String actName;
+
+    /** 备注信息 */
+    private String remark;
+
+    /** 场景id */
+    private String sceneId;
+
+    /** 商户密钥(用于签名) */
+    private String mchKey;
+
+    /** 企业微信支付应用secret(用于workwx_sign签名) */
+    private String workWxSecret;
+
+    /** 计算后的workwx_sign值(内部使用,用于参与WxPay sign计算) */
+    private transient String workwxSign;
+}

+ 53 - 0
fs-service/src/main/java/com/fs/qw/redpacket/result/WorkWxRedPacketResult.java

@@ -0,0 +1,53 @@
+package com.fs.qw.redpacket.result;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 企业微信发放企业红包 响应结果
+ *
+ * @author fs
+ * @date 2026-07-27
+ */
+@Data
+public class WorkWxRedPacketResult implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    /** 返回状态码 SUCCESS/FAIL */
+    private String returnCode;
+
+    /** 返回信息 */
+    private String returnMsg;
+
+    /** 以下字段在return_code为SUCCESS时有返回 */
+
+    /** 业务结果 SUCCESS/FAIL */
+    private String resultCode;
+
+    /** 错误代码 */
+    private String errCode;
+
+    /** 错误代码描述 */
+    private String errCodeDes;
+
+    /** 以下字段在result_code为SUCCESS时有返回 */
+
+    /** 商户订单号 */
+    private String mchBillno;
+
+    /** 商户号 */
+    private String mchId;
+
+    /** 公众账号appid */
+    private String wxappid;
+
+    /** 用户openid */
+    private String reOpenid;
+
+    /** 付款金额,单位分 */
+    private Integer totalAmount;
+
+    /** 微信单号 */
+    private String sendListid;
+}

+ 415 - 0
fs-service/src/main/java/com/fs/qw/redpacket/service/WorkWxRedPacketService.java

@@ -0,0 +1,415 @@
+package com.fs.qw.redpacket.service;
+
+import com.fs.common.utils.StringUtils;
+import com.fs.qw.redpacket.param.WorkWxRedPacketParam;
+import com.fs.qw.redpacket.result.WorkWxRedPacketResult;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.http.ssl.SSLContexts;
+import org.apache.http.util.EntityUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.core.io.Resource;
+import org.springframework.stereotype.Service;
+
+import javax.net.ssl.SSLContext;
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.StringReader;
+import java.io.StringWriter;
+import java.security.KeyStore;
+import java.security.MessageDigest;
+import java.util.*;
+
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.NodeList;
+import org.xml.sax.InputSource;
+
+import javax.xml.transform.OutputKeys;
+import javax.xml.transform.Transformer;
+import javax.xml.transform.TransformerFactory;
+import javax.xml.transform.dom.DOMSource;
+import javax.xml.transform.stream.StreamResult;
+
+/**
+ * 企业微信发放企业红包服务
+ * 调用 sendworkwxredpack API
+ *
+ * @author fs
+ * @date 2026-07-27
+ */
+@Service
+public class WorkWxRedPacketService {
+
+    private static final Logger log = LoggerFactory.getLogger(WorkWxRedPacketService.class);
+
+    /** 企业红包接口地址 */
+    private static final String SEND_RED_PACKET_URL = "https://api.mch.weixin.qq.com/mmpaymkttransfers/sendworkwxredpack";
+
+    /** workwx_sign 签名字段顺序(按文档要求) */
+    private static final List<String> WORKWX_SIGN_FIELDS = Arrays.asList(
+            "act_name", "mch_billno", "mch_id", "nonce_str", "re_openid", "total_amount", "wxappid"
+    );
+
+    /** 缓存 SSLContext,避免重复加载证书 */
+    private final Map<String, SSLContext> sslContextCache = new HashMap<>();
+
+    /**
+     * 发送企业红包
+     *
+     * @param param   红包请求参数
+     * @param certP12Path 商户证书p12文件路径(支持 classpath: 前缀和绝对路径)
+     * @return 红包发送结果
+     */
+    public WorkWxRedPacketResult sendRedPacket(WorkWxRedPacketParam param, String certP12Path) {
+        log.info("开始发送企业红包: mchBillno={}, reOpenid={}, totalAmount={}分",
+                param.getMchBillno(), param.getReOpenid(), param.getTotalAmount());
+
+        try {
+            // 1. 生成随机字符串
+            if (StringUtils.isEmpty(param.getNonceStr())) {
+                param.setNonceStr(generateNonceStr(32));
+            }
+
+            // 2. 构建 XML 请求体
+            String xmlBody = buildXmlRequestBody(param);
+            log.debug("企业红包请求XML: {}", xmlBody);
+
+            // 3. 加载SSL证书并发送请求
+            SSLContext sslContext = getOrCreateSSLContext(certP12Path, param.getMchId());
+            String responseXml = doSSLPost(xmlBody, sslContext);
+            log.debug("企业红包响应XML: {}", responseXml);
+
+            // 4. 解析响应
+            WorkWxRedPacketResult result = parseXmlResponse(responseXml);
+            log.info("企业红包发送结果: returnCode={}, resultCode={}, mchBillno={}, sendListid={}",
+                    result.getReturnCode(), result.getResultCode(),
+                    result.getMchBillno(), result.getSendListid());
+
+            return result;
+        } catch (Exception e) {
+            log.error("发送企业红包异常: mchBillno={}, error={}", param.getMchBillno(), e.getMessage(), e);
+            WorkWxRedPacketResult errorResult = new WorkWxRedPacketResult();
+            errorResult.setReturnCode("FAIL");
+            errorResult.setReturnMsg(e.getMessage());
+            return errorResult;
+        }
+    }
+
+    /**
+     * 构建 XML 请求体,包含签名
+     */
+    String buildXmlRequestBody(WorkWxRedPacketParam param) throws Exception {
+        DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
+        DocumentBuilder builder = factory.newDocumentBuilder();
+        Document doc = builder.newDocument();
+
+        Element root = doc.createElement("xml");
+        doc.appendChild(root);
+
+        // 基础参数
+        addCDataElement(doc, root, "nonce_str", param.getNonceStr());
+        addCDataElement(doc, root, "mch_billno", param.getMchBillno());
+        addCDataElement(doc, root, "mch_id", param.getMchId());
+        addCDataElement(doc, root, "wxappid", param.getWxappid());
+
+        // 发送者名称(与agentid互斥)
+//        if (StringUtils.isNotEmpty(param.getSenderName())) {
+//            addCDataElement(doc, root, "sender_name", param.getSenderName());
+//        }
+        // agentid(与sender_name互斥)
+        if (param.getAgentid() != null) {
+            addTextElement(doc, root, "agentid", String.valueOf(param.getAgentid()));
+        }
+
+        if (StringUtils.isNotEmpty(param.getSenderHeaderMediaId())) {
+            addCDataElement(doc, root, "sender_header_media_id", param.getSenderHeaderMediaId());
+        }
+
+        addCDataElement(doc, root, "re_openid", param.getReOpenid());
+        addTextElement(doc, root, "total_amount", String.valueOf(param.getTotalAmount()));
+        addCDataElement(doc, root, "wishing", param.getWishing());
+        addCDataElement(doc, root, "act_name", param.getActName());
+        addCDataElement(doc, root, "remark", param.getRemark());
+
+        if (StringUtils.isNotEmpty(param.getSceneId())) {
+            addCDataElement(doc, root, "scene_id", param.getSceneId());
+        }
+
+        // 先计算 workwx_sign(因为它需要参与 WxPay sign 的计算)
+        String workwxSign = generateWorkWxSign(param);
+        param.setWorkwxSign(workwxSign);
+
+        // WXPay签名(包含 workwx_sign 在内的所有参数)
+        String sign = generateWxPaySign(param);
+        addCDataElement(doc, root, "sign", sign);
+
+        // workwx_sign
+        addCDataElement(doc, root, "workwx_sign", workwxSign);
+
+        // 将Document转为字符串
+        TransformerFactory tf = TransformerFactory.newInstance();
+        Transformer transformer = tf.newTransformer();
+        transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8");
+        transformer.setOutputProperty(OutputKeys.INDENT, "no");
+
+        StringWriter writer = new StringWriter();
+        transformer.transform(new DOMSource(doc), new StreamResult(writer));
+        return writer.toString();
+    }
+
+    /**
+     * 生成 WXPay MD5 签名
+     */
+    String generateWxPaySign(WorkWxRedPacketParam param) throws Exception {
+        Map<String, String> signMap = new TreeMap<>();
+        signMap.put("nonce_str", nvl(param.getNonceStr()));
+        signMap.put("mch_billno", nvl(param.getMchBillno()));
+        signMap.put("mch_id", nvl(param.getMchId()));
+        signMap.put("wxappid", nvl(param.getWxappid()));
+        if (StringUtils.isNotEmpty(param.getSenderName())) {
+            signMap.put("sender_name", param.getSenderName());
+        }
+        if (param.getAgentid() != null) {
+            signMap.put("agentid", String.valueOf(param.getAgentid()));
+        }
+        signMap.put("re_openid", nvl(param.getReOpenid()));
+        signMap.put("total_amount", String.valueOf(param.getTotalAmount()));
+        signMap.put("wishing", nvl(param.getWishing()));
+        signMap.put("act_name", nvl(param.getActName()));
+        signMap.put("remark", nvl(param.getRemark()));
+        if (StringUtils.isNotEmpty(param.getSceneId())) {
+            signMap.put("scene_id", param.getSceneId());
+        }
+        // workwx_sign 作为请求参数需参与 WxPay 签名
+        if (StringUtils.isNotEmpty(param.getWorkwxSign())) {
+            signMap.put("workwx_sign", param.getWorkwxSign());
+        }
+
+        // 拼接签名字符串
+        StringBuilder sb = new StringBuilder();
+        for (Map.Entry<String, String> entry : signMap.entrySet()) {
+            if (StringUtils.isNotEmpty(entry.getValue())) {
+                if (sb.length() > 0) {
+                    sb.append("&");
+                }
+                sb.append(entry.getKey()).append("=").append(entry.getValue());
+            }
+        }
+        // 拼接商户密钥
+        sb.append("&key=").append(param.getMchKey());
+
+        String signStr = sb.toString();
+        String sign = md5(signStr).toUpperCase();
+        log.info("WXPay签名原串: {}", signStr);
+        log.info("WXPay签名结果: {}", sign);
+        return sign;
+    }
+
+    /**
+     * 生成 workwx_sign 签名
+     * 签名字段:act_name, mch_billno, mch_id, nonce_str, re_openid, total_amount, wxappid
+     * 当使用agentid时还需加上agentid;当使用sender_name时还需加上sender_name
+     */
+    String generateWorkWxSign(WorkWxRedPacketParam param) throws Exception {
+        StringBuilder sb = new StringBuilder();
+        sb.append("act_name=").append(nvl(param.getActName())).append("&");
+        sb.append("mch_billno=").append(nvl(param.getMchBillno())).append("&");
+        sb.append("mch_id=").append(nvl(param.getMchId())).append("&");
+        sb.append("nonce_str=").append(nvl(param.getNonceStr())).append("&");
+        sb.append("re_openid=").append(nvl(param.getReOpenid())).append("&");
+        sb.append("total_amount=").append(param.getTotalAmount()).append("&");
+        sb.append("wxappid=").append(nvl(param.getWxappid()));
+
+        // agentid与sender_name互斥,使用哪个就签哪个
+        if (param.getAgentid() != null) {
+            sb.append("&").append("agentid=").append(param.getAgentid());
+        } else if (StringUtils.isNotEmpty(param.getSenderName())) {
+            sb.append("&").append("sender_name=").append(param.getSenderName());
+        }
+
+        // workwx_sign 的签名密钥是应用的 secret
+        String signKey = param.getWorkWxSecret();
+        sb.append("&secret=").append(signKey);
+
+        String signStr = sb.toString();
+        String sign = md5(signStr).toUpperCase();
+        log.info("workwx_sign签名原串: {}", signStr);
+        log.info("workwx_sign结果: {}", sign);
+        return sign;
+    }
+
+    /**
+     * 解析 XML 响应
+     */
+    WorkWxRedPacketResult parseXmlResponse(String xml) throws Exception {
+        WorkWxRedPacketResult result = new WorkWxRedPacketResult();
+        if (StringUtils.isEmpty(xml)) {
+            result.setReturnCode("FAIL");
+            result.setReturnMsg("响应为空");
+            return result;
+        }
+
+        DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
+        factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
+        DocumentBuilder builder = factory.newDocumentBuilder();
+        Document doc = builder.parse(new InputSource(new StringReader(xml)));
+
+        result.setReturnCode(getTextValue(doc, "return_code"));
+        result.setReturnMsg(getTextValue(doc, "return_msg"));
+        result.setResultCode(getTextValue(doc, "result_code"));
+        result.setErrCode(getTextValue(doc, "err_code"));
+        result.setErrCodeDes(getTextValue(doc, "err_code_des"));
+        result.setMchBillno(getTextValue(doc, "mch_billno"));
+        result.setMchId(getTextValue(doc, "mch_id"));
+        result.setWxappid(getTextValue(doc, "wxappid"));
+        result.setReOpenid(getTextValue(doc, "re_openid"));
+
+        String totalAmount = getTextValue(doc, "total_amount");
+        if (StringUtils.isNotEmpty(totalAmount)) {
+            result.setTotalAmount(Integer.parseInt(totalAmount));
+        }
+        result.setSendListid(getTextValue(doc, "send_listid"));
+
+        return result;
+    }
+
+    /**
+     * 加载或获取缓存的 SSLContext
+     */
+    private SSLContext getOrCreateSSLContext(String certP12Path, String mchId) throws Exception {
+        String cacheKey = certP12Path + "|" + mchId;
+        SSLContext cached = sslContextCache.get(cacheKey);
+        if (cached != null) {
+            return cached;
+        }
+
+        synchronized (sslContextCache) {
+            cached = sslContextCache.get(cacheKey);
+            if (cached != null) {
+                return cached;
+            }
+
+            KeyStore keyStore = KeyStore.getInstance("PKCS12");
+            FileInputStream inputStream;
+            if (certP12Path.startsWith("classpath:")) {
+                // classpath: 前缀,从类路径加载
+                String classPath = certP12Path.substring("classpath:".length());
+                Resource resource = new ClassPathResource(classPath);
+                inputStream = new FileInputStream(resource.getFile());
+            } else {
+                // 绝对路径
+                File file = new File(certP12Path);
+                if (!file.exists()) {
+                    throw new RuntimeException("商户证书文件不存在: " + certP12Path);
+                }
+                inputStream = new FileInputStream(file);
+            }
+
+            try {
+                keyStore.load(inputStream, mchId.toCharArray());
+            } finally {
+                inputStream.close();
+            }
+
+            SSLContext sslContext = SSLContexts.custom()
+                    .setProtocol("TLSv1.2")
+                    .loadKeyMaterial(keyStore, mchId.toCharArray())
+                    .build();
+
+            sslContextCache.put(cacheKey, sslContext);
+            log.info("加载商户证书成功: certPath={}, mchId={}", certP12Path, mchId);
+            return sslContext;
+        }
+    }
+
+    /**
+     * 带 SSL 客户端证书的 HTTPS POST 请求
+     */
+    private String doSSLPost(String xmlBody, SSLContext sslContext) throws Exception {
+        SSLConnectionSocketFactory sslsf = new SSLConnectionSocketFactory(
+                sslContext,
+                new String[]{"TLSv1.2"},
+                null,
+                SSLConnectionSocketFactory.getDefaultHostnameVerifier()
+        );
+
+        try (CloseableHttpClient httpClient = HttpClients.custom()
+                .setSSLSocketFactory(sslsf)
+                .build()) {
+
+            HttpPost httpPost = new HttpPost(SEND_RED_PACKET_URL);
+            httpPost.setEntity(new StringEntity(xmlBody, "UTF-8"));
+            httpPost.setHeader("Content-Type", "application/xml; charset=UTF-8");
+
+            try (CloseableHttpResponse response = httpClient.execute(httpPost)) {
+                return EntityUtils.toString(response.getEntity(), "UTF-8");
+            }
+        }
+    }
+
+    /**
+     * 清空 SSL 缓存(证书更新后调用)
+     */
+    public void clearSSLCache() {
+        sslContextCache.clear();
+        log.info("SSL证书缓存已清空");
+    }
+
+    // ========== 工具方法 ==========
+
+    private static String nvl(String value) {
+        return value != null ? value : "";
+    }
+
+    private static String md5(String input) throws Exception {
+        MessageDigest md = MessageDigest.getInstance("MD5");
+        byte[] digest = md.digest(input.getBytes("UTF-8"));
+        StringBuilder sb = new StringBuilder();
+        for (byte b : digest) {
+            sb.append(String.format("%02x", b & 0xff));
+        }
+        return sb.toString();
+    }
+
+    private static String generateNonceStr(int length) {
+        String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
+        StringBuilder sb = new StringBuilder();
+        Random random = new Random();
+        for (int i = 0; i < length; i++) {
+            sb.append(chars.charAt(random.nextInt(chars.length())));
+        }
+        return sb.toString();
+    }
+
+    private static void addCDataElement(Document doc, Element parent, String name, String value) {
+        if (value == null) return;
+        Element element = doc.createElement(name);
+        element.appendChild(doc.createCDATASection(value));
+        parent.appendChild(element);
+    }
+
+    private static void addTextElement(Document doc, Element parent, String name, String value) {
+        if (value == null) return;
+        Element element = doc.createElement(name);
+        element.appendChild(doc.createTextNode(value));
+        parent.appendChild(element);
+    }
+
+    private static String getTextValue(Document doc, String tagName) {
+        NodeList nodeList = doc.getElementsByTagName(tagName);
+        if (nodeList.getLength() > 0) {
+            String text = nodeList.item(0).getTextContent();
+            return text != null ? text : null;
+        }
+        return null;
+    }
+}

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

@@ -261,4 +261,12 @@ public interface IQwExternalContactService extends IService<QwExternalContact> {
      * 同步企微客户手机号
      */
     int syncFsUserPhone();
+
+    /**
+     * 根据企微成员userid获取openid(用于给内部员工发红包等场景)
+     * @param qwUserId 企微成员userid
+     * @param corpId 企业corpid
+     * @return openid,未找到返回null
+     */
+    String getOpenidByQwUserId(String qwUserId, String corpId);
 }

+ 58 - 0
fs-service/src/main/java/com/fs/qw/service/IQwUserIdMappingService.java

@@ -0,0 +1,58 @@
+package com.fs.qw.service;
+
+import com.fs.qw.domain.QwUserIdMapping;
+
+import java.util.List;
+
+/**
+ * 企微userid-openid-unionid映射Service接口
+ *
+ * @author fs
+ * @date 2026-07-27
+ */
+public interface IQwUserIdMappingService {
+
+    /**
+     * 根据qwUserId、appId和映射类型获取openid(优先从本地缓存获取)
+     *
+     * @param qwUserId    企微成员userid或外部联系人external_userid
+     * @param appId       公众号appid
+     * @param mappingType 映射类型:1-成员 2-外部联系人
+     * @return 映射记录,未找到返回null
+     */
+    QwUserIdMapping getByQwUserId(String qwUserId, String appId, Integer mappingType);
+
+    /**
+     * 根据openid反向查询映射记录
+     */
+    QwUserIdMapping getByOpenid(String openid);
+
+    /**
+     * 根据unionid查询映射记录列表
+     */
+    List<QwUserIdMapping> getByUnionid(String unionid);
+
+    /**
+     * 根据qwUserId、corpId和映射类型获取openid(当appId未知时的降级查询)
+     */
+    QwUserIdMapping getByQwUserIdAndCorpId(String qwUserId, String corpId, Integer mappingType);
+
+    /**
+     * 批量查询映射记录
+     */
+    List<QwUserIdMapping> getListByQwUserIds(List<String> qwUserIds, String appId, Integer mappingType);
+
+    /**
+     * 保存或更新映射记录(存在则更新openid,不存在则新增)
+     *
+     * @param qwUserId    企微成员userid或外部联系人external_userid
+     * @param openid      公众号openid
+     * @param unionid     微信unionid(可选)
+     * @param corpId      企业corpid
+     * @param appId       公众号appid
+     * @param mappingType 映射类型:1-成员 2-外部联系人
+     * @return 保存后的映射记录
+     */
+    QwUserIdMapping saveOrUpdate(String qwUserId, String openid, String unionid,
+                                  String corpId, String appId, Integer mappingType);
+}

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

@@ -213,6 +213,9 @@ public class QwExternalContactServiceImpl extends ServiceImpl<QwExternalContactM
     @Autowired
     private ISopUserLogsService sopUserLogsService;
 
+    @Autowired
+    private IQwUserIdMappingService iQwUserIdMappingService;
+
     @Autowired
     private IFsCourseLinkService iFsCourseLinkService;
 
@@ -6359,4 +6362,49 @@ public class QwExternalContactServiceImpl extends ServiceImpl<QwExternalContactM
         return false;
     }
 
+
+    @Override
+    public String getOpenidByQwUserId(String qwUserId, String corpId) {
+        log.info("getOpenidByQwUserId请求: qwUserId={}, corpId={}", qwUserId, corpId);
+
+        if (StringUtils.isEmpty(qwUserId)) {
+            log.warn("getOpenidByQwUserId: qwUserId为空");
+            return null;
+        }
+        if (StringUtils.isEmpty(corpId)) {
+            log.warn("getOpenidByQwUserId: corpId为空, qwUserId={}", qwUserId);
+            return null;
+        }
+
+        // 1. 先从本地映射表查缓存
+        QwUserIdMapping cached = iQwUserIdMappingService.getByQwUserIdAndCorpId(
+                qwUserId, corpId, 1);
+        if (cached != null && StringUtils.isNotEmpty(cached.getOpenid())) {
+            log.info("getOpenidByQwUserId本地缓存命中: qwUserId={}, openid={}", qwUserId, cached.getOpenid());
+            return cached.getOpenid();
+        }
+
+        // 2. 缓存未命中,调用企微API获取
+        log.info("getOpenidByQwUserId本地缓存未命中,调用企微API: qwUserId={}, corpId={}", qwUserId, corpId);
+        try {
+            QwOpenidByUserParams param = new QwOpenidByUserParams();
+            param.setUserid(qwUserId);
+            QwOpenidResult result = qwApiService.useridToOpenid(param, corpId);
+            if (result != null && result.getErrCode() == 0 && StringUtils.isNotEmpty(result.getOpenid())) {
+                log.info("getOpenidByQwUserId企微API调用成功: qwUserId={}, openid={}", qwUserId, result.getOpenid());
+                return result.getOpenid();
+            } else if (result != null) {
+                log.error("getOpenidByQwUserId企微API返回失败: qwUserId={}, errcode={}, errmsg={}",
+                        qwUserId, result.getErrCode(), result.getErrMsg());
+            } else {
+                log.error("getOpenidByQwUserId企微API返回null: qwUserId={}", qwUserId);
+            }
+        } catch (Exception e) {
+            log.error("getOpenidByQwUserId: 调用企微API异常, qwUserId={}, error={}", qwUserId, e.getMessage(), e);
+        }
+
+        log.warn("getOpenidByQwUserId最终返回null: qwUserId={}, corpId={}", qwUserId, corpId);
+        return null;
+    }
+
 }

+ 132 - 0
fs-service/src/main/java/com/fs/qw/service/impl/QwUserIdMappingServiceImpl.java

@@ -0,0 +1,132 @@
+package com.fs.qw.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.fs.common.utils.StringUtils;
+import com.fs.qw.domain.QwUserIdMapping;
+import com.fs.qw.mapper.QwUserIdMappingMapper;
+import com.fs.qw.service.IQwUserIdMappingService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 企微userid-openid-unionid映射Service业务层处理
+ *
+ * @author fs
+ * @date 2026-07-27
+ */
+@Service
+public class QwUserIdMappingServiceImpl extends ServiceImpl<QwUserIdMappingMapper, QwUserIdMapping>
+        implements IQwUserIdMappingService {
+
+    private static final Logger logger = LoggerFactory.getLogger(QwUserIdMappingServiceImpl.class);
+
+    @Autowired
+    private QwUserIdMappingMapper qwUserIdMappingMapper;
+
+    @Override
+    public QwUserIdMapping getByQwUserId(String qwUserId, String appId, Integer mappingType) {
+        return qwUserIdMappingMapper.selectByQwUserIdAndAppId(qwUserId, appId, mappingType);
+    }
+
+    @Override
+    public QwUserIdMapping getByOpenid(String openid) {
+        return qwUserIdMappingMapper.selectByOpenid(openid);
+    }
+
+    @Override
+    public List<QwUserIdMapping> getByUnionid(String unionid) {
+        return qwUserIdMappingMapper.selectByUnionid(unionid);
+    }
+
+    @Override
+    public QwUserIdMapping getByQwUserIdAndCorpId(String qwUserId, String corpId, Integer mappingType) {
+        QwUserIdMapping result = qwUserIdMappingMapper.selectByQwUserIdAndCorpId(qwUserId, corpId, mappingType);
+        if (result != null) {
+            logger.debug("本地映射表命中: qwUserId={}, corpId={}, mappingType={}, openid={}",
+                    qwUserId, corpId, mappingType, result.getOpenid());
+        } else {
+            logger.debug("本地映射表未命中: qwUserId={}, corpId={}, mappingType={}",
+                    qwUserId, corpId, mappingType);
+        }
+        return result;
+    }
+
+    @Override
+    public List<QwUserIdMapping> getListByQwUserIds(List<String> qwUserIds, String appId, Integer mappingType) {
+        return qwUserIdMappingMapper.selectListByQwUserIds(qwUserIds, appId, mappingType);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public QwUserIdMapping saveOrUpdate(String qwUserId, String openid, String unionid,
+                                         String corpId, String appId, Integer mappingType) {
+        logger.debug("saveOrUpdate开始: qwUserId={}, openid={}, unionid={}, corpId={}, appId={}, mappingType={}",
+                qwUserId, openid, unionid, corpId, appId, mappingType);
+
+        // 参数校验
+        if (StringUtils.isEmpty(qwUserId) || StringUtils.isEmpty(openid)) {
+            logger.warn("saveOrUpdate参数不合法: qwUserId={}, openid={}, 已拒绝保存", qwUserId, openid);
+            return null;
+        }
+
+        // 先查是否存在:优先按appId查,appId为空时按corpId查
+        QwUserIdMapping existing = null;
+        if (StringUtils.isNotEmpty(appId)) {
+            existing = qwUserIdMappingMapper.selectByQwUserIdAndAppId(qwUserId, appId, mappingType);
+            logger.debug("按appId查询已有记录: qwUserId={}, appId={}, mappingType={}, 结果={}",
+                    qwUserId, appId, mappingType, existing != null ? "存在" : "不存在");
+        }
+        if (existing == null && StringUtils.isNotEmpty(corpId)) {
+            existing = qwUserIdMappingMapper.selectByQwUserIdAndCorpId(qwUserId, corpId, mappingType);
+            logger.debug("按corpId降级查询已有记录: qwUserId={}, corpId={}, mappingType={}, 结果={}",
+                    qwUserId, corpId, mappingType, existing != null ? "存在" : "不存在");
+        }
+
+        if (existing != null) {
+            // 如果openid没变,直接返回
+            if (openid.equals(existing.getOpenid())
+                    && (StringUtils.isEmpty(unionid) || unionid.equals(existing.getUnionid()))) {
+                logger.debug("openid未变化,跳过更新: qwUserId={}, openid={}, id={}",
+                        qwUserId, openid, existing.getId());
+                return existing;
+            }
+            // 更新openid和unionid(同时补充appId)
+            String oldOpenid = existing.getOpenid();
+            int rows = qwUserIdMappingMapper.updateOpenid(existing.getId(), openid, unionid, appId);
+            logger.info("更新企微userid-openid映射: qwUserId={}, oldOpenid={}, newOpenid={}, mappingType={}, rows={}",
+                    qwUserId, oldOpenid, openid, mappingType, rows);
+            existing.setOpenid(openid);
+            if (StringUtils.isNotEmpty(unionid)) {
+                existing.setUnionid(unionid);
+            }
+            if (StringUtils.isNotEmpty(appId)) {
+                existing.setAppId(appId);
+            }
+            return existing;
+        }
+
+        // 不存在则新增
+        QwUserIdMapping mapping = new QwUserIdMapping();
+        mapping.setQwUserId(qwUserId);
+        mapping.setOpenid(openid);
+        mapping.setUnionid(unionid);
+        mapping.setCorpId(corpId);
+        mapping.setAppId(appId);
+        mapping.setMappingType(mappingType);
+        mapping.setIsDel(0);
+        mapping.setCreateTime(new Date());
+        mapping.setUpdateTime(new Date());
+        int rows = qwUserIdMappingMapper.insert(mapping);
+        logger.info("新增企微userid-openid映射: qwUserId={}, openid={}, corpId={}, mappingType={}, rows={}, id={}",
+                qwUserId, openid, corpId, mappingType, rows, mapping.getId());
+        return mapping;
+    }
+}

+ 82 - 2
fs-service/src/main/java/com/fs/qwApi/service/impl/QwApiServiceImpl.java

@@ -8,8 +8,10 @@ import com.fs.common.core.redis.RedisCache;
 import com.fs.common.exception.base.BaseException;
 import com.fs.common.utils.StringUtils;
 import com.fs.qw.domain.QwCompany;
+import com.fs.qw.domain.QwUserIdMapping;
 import com.fs.qw.param.QwUpdateContactWayParam;
 import com.fs.qw.service.IQwCompanyService;
+import com.fs.qw.service.IQwUserIdMappingService;
 import com.fs.qwApi.Result.*;
 import com.fs.qwApi.config.QwApiConfig;
 import com.fs.qwApi.domain.*;
@@ -58,6 +60,9 @@ public class QwApiServiceImpl implements QwApiService {
     @Autowired
     private RedisCache redisCache;
 
+    @Autowired
+    private IQwUserIdMappingService iQwUserIdMappingService;
+
     private static final OkHttpClient client = new OkHttpClient.Builder()
             .connectTimeout(30, TimeUnit.SECONDS)
             .readTimeout(30, TimeUnit.SECONDS)
@@ -908,14 +913,89 @@ public class QwApiServiceImpl implements QwApiService {
 
     @Override
     public QwOpenidResult useridToOpenid(QwOpenidByUserParams param, String corpId) {
+        log.info("useridToOpenid请求: userid={}, corpId={}", param.getUserid(), corpId);
+
+        // 1. 先从本地映射表查缓存
+        QwUserIdMapping cached = iQwUserIdMappingService.getByQwUserIdAndCorpId(
+                param.getUserid(), corpId, 1);
+        if (cached != null && StringUtils.isNotEmpty(cached.getOpenid())) {
+            log.info("useridToOpenid本地缓存命中: userid={}, openid={}", param.getUserid(), cached.getOpenid());
+            QwOpenidResult result = new QwOpenidResult();
+            result.setErrCode(0);
+            result.setOpenid(cached.getOpenid());
+            return result;
+        }
+
+        // 2. 缓存未命中,调用企微API
+        log.info("useridToOpenid本地缓存未命中,调用企微API: userid={}, corpId={}", param.getUserid(), corpId);
         String json = sendBookPost(QwApiConfig.convertToOpenid, param, corpId);
-        return JSON.parseObject(json, QwOpenidResult.class);
+        QwOpenidResult result = JSON.parseObject(json, QwOpenidResult.class);
+
+        // 3. 记录API调用结果
+        if (result == null) {
+            log.error("useridToOpenid企微API返回null: userid={}, corpId={}, json={}", param.getUserid(), corpId, json);
+        } else if (result.getErrCode() != 0) {
+            log.error("useridToOpenid企微API调用失败: userid={}, corpId={}, errcode={}, errmsg={}",
+                    param.getUserid(), corpId, result.getErrCode(), result.getErrMsg());
+        } else if (StringUtils.isEmpty(result.getOpenid())) {
+            log.warn("useridToOpenid企微API返回成功但openid为空: userid={}, corpId={}",
+                    param.getUserid(), corpId);
+        } else {
+            log.info("useridToOpenid企微API调用成功: userid={}, corpId={}, openid={}",
+                    param.getUserid(), corpId, result.getOpenid());
+        }
+
+        // 4. 保存到本地映射表
+        if (result != null && result.getErrCode() == 0 && StringUtils.isNotEmpty(result.getOpenid())) {
+            iQwUserIdMappingService.saveOrUpdate(param.getUserid(), result.getOpenid(), null,
+                    corpId, null, 1);
+        }
+        return result;
     }
 
     @Override
     public QwOpenidResult externalcontactToOpenid(QwOpenidByExternalcontactParams param, String corpId) {
+        log.info("externalcontactToOpenid请求: externalUserId={}, corpId={}", param.getExternal_userid(), corpId);
+
+        // 1. 先从本地映射表查缓存
+        QwUserIdMapping cached = iQwUserIdMappingService.getByQwUserIdAndCorpId(
+                param.getExternal_userid(), corpId, 2);
+        if (cached != null && StringUtils.isNotEmpty(cached.getOpenid())) {
+            log.info("externalcontactToOpenid本地缓存命中: externalUserId={}, openid={}",
+                    param.getExternal_userid(), cached.getOpenid());
+            QwOpenidResult result = new QwOpenidResult();
+            result.setErrCode(0);
+            result.setOpenid(cached.getOpenid());
+            return result;
+        }
+
+        // 2. 缓存未命中,调用企微API
+        log.info("externalcontactToOpenid本地缓存未命中,调用企微API: externalUserId={}, corpId={}",
+                param.getExternal_userid(), corpId);
         String json = sendPost(QwApiConfig.externalContactConvertToOpenid, param, corpId);
-        return JSON.parseObject(json, QwOpenidResult.class);
+        QwOpenidResult result = JSON.parseObject(json, QwOpenidResult.class);
+
+        // 3. 记录API调用结果
+        if (result == null) {
+            log.error("externalcontactToOpenid企微API返回null: externalUserId={}, corpId={}, json={}",
+                    param.getExternal_userid(), corpId, json);
+        } else if (result.getErrCode() != 0) {
+            log.error("externalcontactToOpenid企微API调用失败: externalUserId={}, corpId={}, errcode={}, errmsg={}",
+                    param.getExternal_userid(), corpId, result.getErrCode(), result.getErrMsg());
+        } else if (StringUtils.isEmpty(result.getOpenid())) {
+            log.warn("externalcontactToOpenid企微API返回成功但openid为空: externalUserId={}, corpId={}",
+                    param.getExternal_userid(), corpId);
+        } else {
+            log.info("externalcontactToOpenid企微API调用成功: externalUserId={}, corpId={}, openid={}",
+                    param.getExternal_userid(), corpId, result.getOpenid());
+        }
+
+        // 4. 保存到本地映射表
+        if (result != null && result.getErrCode() == 0 && StringUtils.isNotEmpty(result.getOpenid())) {
+            iQwUserIdMappingService.saveOrUpdate(param.getExternal_userid(), result.getOpenid(), null,
+                    corpId, null, 2);
+        }
+        return result;
     }
 
 

+ 3 - 0
fs-service/src/main/java/com/fs/sop/mapper/SopUserLogsMapper.java

@@ -55,6 +55,9 @@ public interface SopUserLogsMapper {
     @DataSource(DataSourceType.SOP)
     public List<SopUserLogsVo> selectSopUserLogsListByTime();
 
+    @DataSource(DataSourceType.SOP)
+    public List<SopUserLogsVo> selectNewSopUserLogsListByTime();
+
     @DataSource(DataSourceType.SOP)
     public List<SopUserLogs> meetsTheRatingByUserInfo();
 

+ 104 - 1
fs-service/src/main/java/com/fs/sop/service/impl/SopUserLogsInfoServiceImpl.java

@@ -32,6 +32,9 @@ import com.fs.course.service.IFsUserCourseVideoService;
 import com.fs.fastGpt.domain.FastGptChatReplaceWords;
 import com.fs.fastGpt.mapper.FastGptChatReplaceWordsMapper;
 import com.fs.feishu.service.FeiShuService;
+import com.fs.notion.dto.NotionPageRequest;
+import com.fs.notion.dto.NotionPageResponse;
+import com.fs.notion.service.INotionService;
 import com.fs.his.domain.FsCourseReissueConfig;
 import com.fs.his.service.IFsCourseReissueConfigService;
 import com.fs.qw.domain.*;
@@ -110,6 +113,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
     private static final String gjminiappLink = "/pages/course/learning?course=";
 
     private static final String feishuMiniappLink = "/pages_course/video?course=";
+    private static final String notionLink = "https://notion.hcdyfa.top/pages_course/videoDetail?logId=";
 
     @Lazy
     @Autowired
@@ -203,6 +207,9 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
     @Autowired
     private FeiShuService feiShuService;
 
+    @Autowired
+    private INotionService notionService;
+
     @Autowired
     private IFsCourseReissueConfigService fsCourseReissueConfigService;
 
@@ -606,7 +613,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                             replaceContent(st.getContentType(), st.getLinkTitle(), st::setLinkTitle, words); // 替换 linkTitle
                             replaceContent(st.getContentType(), st.getLinkDescribe(), st::setLinkDescribe, words); // 替换 linkTitle
                         }
-
+                        Long externalId = vo.getId();
                         //后台SOP发课配置
                         //createSetting(st);
                         switch (st.getContentType()) {
@@ -729,6 +736,18 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                     log.error("生成飞书注册链接失败,sopId={}", param.getSopId());
                                 }
                                 break;
+                            case "19":
+                                String qwUserId = qwUser.getQwUserId();
+                                String companyId = qwUser.getCompanyId().toString();
+                                Long notionWatchLogId = addNotionWatchLog(param.getSopId(), param.getVideoId(), param.getCourseId(), null, qwUserId, companyUserId, companyId, externalId, param.getStartTime(), createTime);
+                                createNotionH5Link(st, param.getCorpId(), createTime, param.getCourseId(), param.getVideoId(), String.valueOf(qwUser.getId()), companyUserId, companyId, externalId, config);
+                                NotionPageRequest request = new NotionPageRequest();
+                                request.setTitle(st.getLinkDescribe());
+                                request.setIframeUrl(notionLink + notionWatchLogId);
+
+                                NotionPageResponse page = notionService.createPage(request);
+                                st.setLinkUrl(page.getPublicUrl());
+                                break;
                         }
                     }
                     setting.setSetting(list);
@@ -898,6 +917,21 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                     log.error("生成飞书注册链接失败,sopId={}", param.getSopId());
                                 }
                                 break;
+                            case "19":
+                                String qwUserId = qwUser.getQwUserId();
+                                String companyId = qwUser.getCompanyId().toString();
+                                Long externalId = Long.valueOf(groupChat.getChatId());
+                                Long companyUserId = Long.valueOf(qwUser.getCompanyUserId().toString());
+
+                                Long notionWatchLogId = addNotionWatchLog(param.getSopId(), param.getVideoId(), param.getCourseId(), null, qwUserId, String.valueOf(companyUserId), companyId, externalId, param.getStartTime(), createTime);
+                                createNotionH5Link(st, param.getCorpId(), createTime, param.getCourseId(), param.getVideoId(), String.valueOf(qwUser.getId()), String.valueOf(companyUserId), companyId, externalId, config);
+                                NotionPageRequest request = new NotionPageRequest();
+                                request.setTitle(st.getLinkDescribe());
+                                request.setIframeUrl(notionLink + notionWatchLogId);
+
+                                NotionPageResponse page = notionService.createPage(request);
+                                st.setLinkUrl(page.getPublicUrl());
+                                break;
                         }
                     }
                     setting.setSetting(list);
@@ -1248,6 +1282,18 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                 log.error("生成飞书注册链接失败,sopId={}", param.getSopId());
                             }
                             break;
+                        case "19":
+                            Long notionWatchLogId = addNotionWatchLog(param.getSopId(), param.getVideoId(), param.getCourseId(),
+                                    item.getFsUserId(), qwUserId, companyUserId, companyId,
+                                    item.getExternalId(), param.getStartTime(), createTime);
+                            createNotionH5Link(st, param.getCorpId(), createTime, param.getCourseId(), param.getVideoId(), String.valueOf(qwUser.getId()), companyUserId, companyId, item.getExternalId(), config);
+                            NotionPageRequest request = new NotionPageRequest();
+                            request.setTitle(st.getLinkDescribe());
+                            request.setIframeUrl(notionLink + notionWatchLogId);
+
+                            NotionPageResponse page = notionService.createPage(request);
+                            st.setLinkUrl(page.getPublicUrl());
+                            break;
                         case "21":
                             if (sopLogs.getFsUserId() != null && !Long.valueOf(0L).equals(sopLogs.getFsUserId())) {
                                 addWatchLogIfNeeded(item.getSopId(), param.getVideoId(), param.getCourseId(), item.getFsUserId(), qwUserId, companyUserId, companyId,
@@ -1932,6 +1978,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                     String shortLink = feishuH5Link.get("link");
                     log.info("课程短链接生成成功:{}", shortLink);
                     //3.调用生成飞书伪链接
+                    //3.调用生成飞书注册授权链接
                     String feishuLink = feiShuService.getNewFeishuRegisterLink(videoId.longValue(), companyIdLong, courseId.longValue(), companyUserIdLong,shortLink);
 
                     if (StringUtils.isNotEmpty(feishuLink)) {
@@ -1954,7 +2001,18 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                         log.error("生成飞书注册链接失败,sopId={}", item.getSopId());
                     }
                     break;
+                case "19":
+                    Long notionWatchLogId = addNotionWatchLog(item.getSopId(), param.getVideoId(), param.getCourseId(),
+                            item.getFsUserId(), String.valueOf(qwUser.getId()), companyUserId, companyId,
+                            item.getExternalId(), item.getStartTime(), dataTime);
+                    createNotionH5Link(st, param.getCorpId(), dataTime, param.getCourseId(), param.getVideoId(), String.valueOf(qwUser.getId()), companyUserId, companyId, item.getExternalId(), config);
+                    NotionPageRequest request = new NotionPageRequest();
+                    request.setTitle(st.getLinkDescribe());
+                    request.setIframeUrl(notionLink + notionWatchLogId);
 
+                    NotionPageResponse page = notionService.createPage(request);
+                    st.setLinkUrl(page.getPublicUrl());
+                    break;
                 case "21":
                     if (sopLogs.getFsUserId() != null && !Long.valueOf(0L).equals(sopLogs.getFsUserId())) {
                         addWatchLogIfNeeded(item.getSopId(), param.getVideoId(), param.getCourseId(), item.getFsUserId(), String.valueOf(qwUser.getId()), companyUserId, companyId,
@@ -2028,6 +2086,22 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
         return result;
     }
 
+    private void createNotionH5Link(QwSopCourseFinishTempSetting.Setting setting, String corpId, Date sendTime,
+                                    Integer courseId, Integer videoId, String qwUserId,
+                                    String companyUserId, String companyId, Long externalId, CourseConfig config){
+        FsCourseLink link = createFsCourseLink(corpId, sendTime, courseId, videoId, Long.valueOf(qwUserId),
+                companyUserId, companyId, externalId, 3, null);
+        FsCourseRealLink courseMap = new FsCourseRealLink();
+        BeanUtils.copyProperties(link, courseMap);
+
+        String courseJson = JSON.toJSONString(courseMap);
+        String realLinkFull = REAL_LINK_PREFIX + courseJson;
+
+        link.setRealLink(realLinkFull);
+        link.setUpdateTime(createUpdateTime(setting, sendTime, config));
+        fsCourseLinkMapper.insertFsCourseLink(link);
+    }
+
     private String createSmsShortLink(QwSopCourseFinishTempSetting.Setting setting, String corpId, Date sendTime,
                                       Integer courseId, Integer videoId, String qwUserId,
                                       String companyUserId, String companyId, Long externalId, CourseConfig config) {
@@ -2163,6 +2237,35 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
 
     }
 
+    private Long addNotionWatchLog(String sopId, Integer videoId, Integer courseId,
+                             Long fsUserId, String qwUserId, String companyUserId,
+                             String companyId, Long externalId, String startTime,Date createTime) {
+        try {
+            FsCourseWatchLog watchLog = new FsCourseWatchLog();
+            watchLog.setVideoId(videoId != null ? videoId.longValue() : null);
+            watchLog.setQwExternalContactId(externalId);
+            watchLog.setSendType(2);
+            watchLog.setQwUserId(Long.valueOf(qwUserId));
+            watchLog.setSopId(sopId);
+            watchLog.setDuration(0L);
+            watchLog.setCourseId(courseId != null ? courseId.longValue() : null);
+            watchLog.setCompanyUserId(companyUserId != null ? Long.valueOf(companyUserId) : null);
+            watchLog.setCompanyId(companyId != null ? Long.valueOf(companyId) : null);
+            watchLog.setCreateTime(createTime);
+            watchLog.setUpdateTime(createTime);
+            watchLog.setLogType(3);
+            watchLog.setUserId(fsUserId);
+            watchLog.setCampPeriodTime(convertStringToDate(startTime,"yyyy-MM-dd"));
+
+            //存看课记录
+            fsCourseWatchLogMapper.insertOrUpdateFsCourseWatchLog(watchLog);
+            return watchLog.getLogId();
+        }catch (Exception e){
+            log.error("一键群发失败-插入观看记录失败:"+e.getMessage());
+        }
+        return null;
+    }
+
     private String generateShortLink(QwSopCourseFinishTempSetting.Setting setting, String corpId, Date sendTime,
                                      Integer courseId, Integer videoId, Long qwUserId,
                                      String companyUserId, String companyId,String domainName, Long externalId,CourseConfig config) {

+ 20 - 1
fs-service/src/main/resources/mapper/course/FsCourseWatchLogMapper.xml

@@ -365,7 +365,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         </trim>
     </insert>
 
-    <insert id="insertOrUpdateFsCourseWatchLog" parameterType="FsCourseWatchLog">
+    <insert id="insertOrUpdateFsCourseWatchLog" parameterType="FsCourseWatchLog" useGeneratedKeys="true" keyProperty="logId">
         insert into fs_course_watch_log
         <trim prefix="(" suffix=")" suffixOverrides=",">
             <if test="userId != null">user_id,</if>
@@ -1174,4 +1174,23 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         </foreach>
     </update>
 
+    <select id="selectWatchLogByQwExternalIds" resultMap="FsCourseWatchLogResult" parameterType="list">
+        SELECT
+        *
+        FROM fs_course_watch_log
+        WHERE user_id IS NULL
+        AND qw_external_id IN
+        <foreach collection="qwExternalIds" item="externalId" open="(" close=")" separator=",">
+            #{externalId}
+        </foreach>
+    </select>
+
+    <update id="batchUpdateWatchLogUserId" parameterType="java.util.List">
+        <foreach collection="list" item="item" separator=";">
+            UPDATE fs_course_watch_log
+            SET user_id = #{item.userId}
+            WHERE log_id = #{item.logId}
+        </foreach>
+    </update>
+
 </mapper>

+ 13 - 0
fs-service/src/main/resources/mapper/qw/QwExternalContactMapper.xml

@@ -820,5 +820,18 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
           AND create_time &lt; #{endTime}
     </select>
 
+    <update id="batchUpdateFsUserIdById" parameterType="java.util.List">
+        UPDATE qw_external_contact
+        SET fs_user_id = CASE id
+        <foreach collection="list" item="item">
+            WHEN #{item.id} THEN #{item.fsUserId}
+        </foreach>
+        ELSE fs_user_id
+        END
+        WHERE id IN
+        <foreach collection="list" item="item" open="(" separator="," close=")">
+            #{item.id}
+        </foreach>
+    </update>
 
 </mapper>

+ 76 - 0
fs-service/src/main/resources/mapper/qw/QwUserIdMappingMapper.xml

@@ -0,0 +1,76 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.fs.qw.mapper.QwUserIdMappingMapper">
+
+    <resultMap type="QwUserIdMapping" id="QwUserIdMappingResult">
+        <result property="id"    column="id"    />
+        <result property="qwUserId"    column="qw_user_id"    />
+        <result property="openid"    column="openid"    />
+        <result property="unionid"    column="unionid"    />
+        <result property="corpId"    column="corp_id"    />
+        <result property="appId"    column="app_id"    />
+        <result property="mappingType"    column="mapping_type"    />
+        <result property="createTime"    column="create_time"    />
+        <result property="updateTime"    column="update_time"    />
+        <result property="isDel"    column="is_del"    />
+    </resultMap>
+
+    <sql id="selectQwUserIdMappingVo">
+        select id, qw_user_id, openid, unionid, corp_id, app_id, mapping_type, create_time, update_time, is_del
+        from qw_user_id_mapping
+    </sql>
+
+    <select id="selectByQwUserIdAndAppId" resultMap="QwUserIdMappingResult">
+        <include refid="selectQwUserIdMappingVo"/>
+        where qw_user_id = #{qwUserId}
+        and app_id = #{appId}
+        and mapping_type = #{mappingType}
+        and is_del = 0
+        limit 1
+    </select>
+
+    <select id="selectByOpenid" resultMap="QwUserIdMappingResult">
+        <include refid="selectQwUserIdMappingVo"/>
+        where openid = #{openid}
+        and is_del = 0
+        limit 1
+    </select>
+
+    <select id="selectByUnionid" resultMap="QwUserIdMappingResult">
+        <include refid="selectQwUserIdMappingVo"/>
+        where unionid = #{unionid}
+        and is_del = 0
+    </select>
+
+    <select id="selectListByQwUserIds" resultMap="QwUserIdMappingResult">
+        <include refid="selectQwUserIdMappingVo"/>
+        where qw_user_id in
+        <foreach collection="qwUserIds" item="qwUserId" open="(" separator="," close=")">
+            #{qwUserId}
+        </foreach>
+        and app_id = #{appId}
+        and mapping_type = #{mappingType}
+        and is_del = 0
+    </select>
+
+    <select id="selectByQwUserIdAndCorpId" resultMap="QwUserIdMappingResult">
+        <include refid="selectQwUserIdMappingVo"/>
+        where qw_user_id = #{qwUserId}
+        and corp_id = #{corpId}
+        and mapping_type = #{mappingType}
+        and is_del = 0
+        limit 1
+    </select>
+
+    <update id="updateOpenid">
+        update qw_user_id_mapping
+        set openid = #{openid},
+            unionid = #{unionid},
+            <if test="appId != null and appId != ''">app_id = #{appId},</if>
+            update_time = now()
+        where id = #{id}
+    </update>
+
+</mapper>

+ 18 - 0
fs-service/src/main/resources/mapper/sop/SopUserLogsMapper.xml

@@ -208,6 +208,24 @@
           and b.send_type != 4 and b.status in (2,3)
     </select>
 
+    <select id="selectNewSopUserLogsListByTime" resultType="com.fs.sop.vo.SopUserLogsVo">
+        select a.*,
+               b.min_conversion_day,
+               b.max_conversion_day,
+               b.min_send,
+               b.max_send,
+               b.is_fixed,
+               b.is_register,
+               b.chat_id,
+               b.filter_mode,
+               b.is_samp_send
+        from sop_user_logs a
+                 inner join qw_sop b on a.sop_id = b.id
+        where a.start_time &lt;= Now()
+          and a.status = 1
+          and b.send_type != 4 and b.status in (2,3) AND b.chat_id IS NULL
+    </select>
+
     <select id="meetsTheRatingByUserInfo"   resultMap="SopUserLogsResult">
         SELECT
             ul.id,