吴树波 пре 2 часа
родитељ
комит
242c1e252f
37 измењених фајлова са 2521 додато и 216 уклоњено
  1. 40 12
      fs-company/src/main/java/com/fs/company/controller/course/FsCoursePlaySourceConfigController.java
  2. 38 0
      fs-company/src/main/java/com/fs/company/controller/qw/QwExternalContactController.java
  3. 4 3
      fs-company/src/main/java/com/fs/framework/config/SecurityConfig.java
  4. 49 0
      fs-ipad-task/src/main/java/com/fs/app/service/IpadSendServer.java
  5. 124 16
      fs-qw-task/src/main/java/com/fs/app/taskService/impl/SopLogsTaskServiceImpl.java
  6. 14 0
      fs-qwhook-sop/src/main/java/com/fs/app/controller/ApisQwSopController.java
  7. 14 0
      fs-qwhook-sop/src/main/java/com/fs/app/controller/QwSopController.java
  8. 1 1
      fs-service/src/main/java/com/fs/course/domain/FsCourseWatchLog.java
  9. 30 0
      fs-service/src/main/java/com/fs/course/mapper/FsCourseRedPacketLogMapper.java
  10. 20 0
      fs-service/src/main/java/com/fs/course/mapper/FsCourseWatchLogMapper.java
  11. 4 0
      fs-service/src/main/java/com/fs/course/service/impl/FsCourseRedPacketLogServiceImpl.java
  12. 4 4
      fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java
  13. 48 0
      fs-service/src/main/java/com/fs/course/vo/TodayFinishWatchForRedPacketVO.java
  14. 51 26
      fs-service/src/main/java/com/fs/feishu/service/FeiShuService.java
  15. 4 0
      fs-service/src/main/java/com/fs/his/domain/MerchantAppConfig.java
  16. 26 0
      fs-service/src/main/java/com/fs/his/mapper/MerchantAppConfigMapper.java
  17. 4 0
      fs-service/src/main/java/com/fs/his/param/WxSendRedPacketParam.java
  18. 7 0
      fs-service/src/main/java/com/fs/his/service/IFsStorePaymentService.java
  19. 396 74
      fs-service/src/main/java/com/fs/his/service/impl/FsStorePaymentServiceImpl.java
  20. 18 0
      fs-service/src/main/java/com/fs/his/service/impl/MerchantAppConfigServiceImpl.java
  21. 4 0
      fs-service/src/main/java/com/fs/qw/domain/QwUser.java
  22. 11 0
      fs-service/src/main/java/com/fs/qw/mapper/QwUserMapper.java
  23. 21 0
      fs-service/src/main/java/com/fs/qw/param/ExternalContactSendRedPacketParam.java
  24. 19 0
      fs-service/src/main/java/com/fs/qw/param/SidebarSendRedPacketParam.java
  25. 29 0
      fs-service/src/main/java/com/fs/qw/service/IExternalContactRedPacketService.java
  26. 1139 0
      fs-service/src/main/java/com/fs/qw/service/impl/ExternalContactRedPacketServiceImpl.java
  27. 242 60
      fs-service/src/main/java/com/fs/sop/service/impl/SopUserLogsInfoServiceImpl.java
  28. 6 0
      fs-service/src/main/resources/application-common.yml
  29. 3 0
      fs-service/src/main/resources/application-config-dev.yml
  30. 4 0
      fs-service/src/main/resources/application-config-druid-bjczwh.yml
  31. 5 0
      fs-service/src/main/resources/db/20260818-merchant-app-config-corp-ids.sql
  32. 3 0
      fs-service/src/main/resources/db/20260820-watch-type-feishu-comment.sql
  33. 41 2
      fs-service/src/main/resources/mapper/MerchantAppConfigMapper.xml
  34. 71 1
      fs-service/src/main/resources/mapper/course/FsCourseWatchLogMapper.xml
  35. 1 0
      fs-service/src/main/resources/mapper/sop/SopUserLogsMapper.xml
  36. 13 10
      fs-user-app/src/main/java/com/fs/app/controller/course/CourseMpLoginController.java
  37. 13 7
      fs-user-app/src/main/java/com/fs/app/controller/course/CourseTransferController.java

+ 40 - 12
fs-company/src/main/java/com/fs/company/controller/course/FsCoursePlaySourceConfigController.java

@@ -86,12 +86,15 @@ public class FsCoursePlaySourceConfigController extends BaseController {
     @Log(title = "点播播放源配置", businessType = BusinessType.INSERT)
     @Log(title = "点播播放源配置", businessType = BusinessType.INSERT)
     @PostMapping
     @PostMapping
     public AjaxResult add(@Valid @RequestBody FsCoursePlaySourceConfigCreateParam param) {
     public AjaxResult add(@Valid @RequestBody FsCoursePlaySourceConfigCreateParam param) {
-        Wrapper<FsCoursePlaySourceConfig> queryWrapper = Wrappers.<FsCoursePlaySourceConfig>lambdaQuery()
-                .eq(FsCoursePlaySourceConfig::getAppid, param.getAppid())
-                .eq(FsCoursePlaySourceConfig::getIsDel, 0);
-        if (fsCoursePlaySourceConfigService.count(queryWrapper) > 0) {
-            return AjaxResult.error("appid已存在");
+        String appid = normalizeAppid(param.getAppid());
+        if (StringUtils.isEmpty(appid)) {
+            return AjaxResult.error("appid不能为空");
+        }
+        AjaxResult duplicateError = checkAppidDuplicate(appid, null);
+        if (duplicateError != null) {
+            return duplicateError;
         }
         }
+        param.setAppid(appid);
 
 
         // 校验自定义授权配置
         // 校验自定义授权配置
         if (param.getCustomAuthEnabled() != null && param.getCustomAuthEnabled() == 1) {
         if (param.getCustomAuthEnabled() != null && param.getCustomAuthEnabled() == 1) {
@@ -219,14 +222,15 @@ public class FsCoursePlaySourceConfigController extends BaseController {
             return AjaxResult.error("点播播放源配置不存在");
             return AjaxResult.error("点播播放源配置不存在");
         }
         }
 
 
-        Wrapper<FsCoursePlaySourceConfig> queryWrapper = Wrappers.<FsCoursePlaySourceConfig>lambdaQuery()
-                .eq(FsCoursePlaySourceConfig::getAppid, param.getAppid())
-                .eq(FsCoursePlaySourceConfig::getIsDel, 0)
-                .last("limit 1");
-        FsCoursePlaySourceConfig one = fsCoursePlaySourceConfigService.getOne(queryWrapper);
-        if (Objects.nonNull(one) && !one.getId().equals(config.getId())) {
-            return AjaxResult.error("appid已存在");
+        String appid = normalizeAppid(param.getAppid());
+        if (StringUtils.isEmpty(appid)) {
+            return AjaxResult.error("appid不能为空");
+        }
+        AjaxResult duplicateError = checkAppidDuplicate(appid, config.getId());
+        if (duplicateError != null) {
+            return duplicateError;
         }
         }
+        param.setAppid(appid);
 
 
         LoginUser loginUser = SecurityUtils.getLoginUser();
         LoginUser loginUser = SecurityUtils.getLoginUser();
         Long currentCompanyId = loginUser.getCompany().getCompanyId();
         Long currentCompanyId = loginUser.getCompany().getCompanyId();
@@ -327,6 +331,30 @@ public class FsCoursePlaySourceConfigController extends BaseController {
         return ids.stream().distinct().collect(Collectors.toList());
         return ids.stream().distinct().collect(Collectors.toList());
     }
     }
 
 
+    /** 去除 appid 首尾空格 */
+    private static String normalizeAppid(String appid) {
+        if (appid == null) {
+            return null;
+        }
+        String trimmed = appid.trim();
+        return trimmed.isEmpty() ? null : trimmed;
+    }
+
+    /**
+     * 校验 appid 是否已存在(全局唯一,排除已删除记录)
+     *
+     * @param appid     已 trim 的 appid
+     * @param excludeId 编辑时排除自身 id,新增传 null
+     * @return 重复时返回错误 AjaxResult,否则返回 null
+     */
+    private AjaxResult checkAppidDuplicate(String appid, Long excludeId) {
+        FsCoursePlaySourceConfig existing = fsCoursePlaySourceConfigService.selectCoursePlaySourceConfigByAppId(appid);
+        if (existing != null && (excludeId == null || !excludeId.equals(existing.getId()))) {
+            return AjaxResult.error("appid已存在");
+        }
+        return null;
+    }
+
     private static boolean belongsToCompany(FsCoursePlaySourceConfig config, Long companyId) {
     private static boolean belongsToCompany(FsCoursePlaySourceConfig config, Long companyId) {
         if (companyId == null || config == null) {
         if (companyId == null || config == null) {
             return false;
             return false;

+ 38 - 0
fs-company/src/main/java/com/fs/company/controller/qw/QwExternalContactController.java

@@ -99,6 +99,9 @@ public class QwExternalContactController extends BaseController
     @Autowired
     @Autowired
     private IFsUserCompanyBindService fsUserCompanyBindService;
     private IFsUserCompanyBindService fsUserCompanyBindService;
 
 
+    @Autowired
+    private IExternalContactRedPacketService externalContactRedPacketService;
+
     @Autowired
     @Autowired
     private IQwGroupChatUserService qwGroupChatUserService;
     private IQwGroupChatUserService qwGroupChatUserService;
 
 
@@ -984,4 +987,39 @@ public class QwExternalContactController extends BaseController
         return AjaxResult.success(fsUserCompanyBindService.getRepeatCourseHistory(fsUserId, companyId));
         return AjaxResult.success(fsUserCompanyBindService.getRepeatCourseHistory(fsUserId, companyId));
     }
     }
 
 
+    /**
+     * 客户管理:当前企微主体下,当天完课客户一键发红包
+     */
+    @PreAuthorize("@ss.hasPermi('qw:externalContact:sendRedPacket')")
+    @Log(title = "销售端发红包(主体)", businessType = BusinessType.INSERT)
+    @PostMapping("/sendRedPacket")
+    public R sendRedPacket(@RequestBody ExternalContactSendRedPacketParam param) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        Long companyId = loginUser.getCompany().getCompanyId();
+        Long companyUserId = loginUser.getUser().getUserId();
+        if (param == null) {
+            param = new ExternalContactSendRedPacketParam();
+        }
+        param.setScope("corp");
+        param.setQwUserId(null);
+        return externalContactRedPacketService.sendRedPacketToExternalContacts(param, companyId, companyUserId);
+    }
+
+    /**
+     * 我的客户:当前企微账号下,当天完课客户一键发红包
+     */
+    @PreAuthorize("@ss.hasPermi('qw:myExternalContact:sendRedPacket')")
+    @Log(title = "销售端发红包(我的客户)", businessType = BusinessType.INSERT)
+    @PostMapping("/mySendRedPacket")
+    public R mySendRedPacket(@RequestBody ExternalContactSendRedPacketParam param) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        Long companyId = loginUser.getCompany().getCompanyId();
+        Long companyUserId = loginUser.getUser().getUserId();
+        if (param == null) {
+            param = new ExternalContactSendRedPacketParam();
+        }
+        param.setScope("mine");
+        return externalContactRedPacketService.sendRedPacketToExternalContacts(param, companyId, companyUserId);
+    }
+
 }
 }

+ 4 - 3
fs-company/src/main/java/com/fs/framework/config/SecurityConfig.java

@@ -119,10 +119,11 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter
                 .antMatchers("/msg/**/**").anonymous()
                 .antMatchers("/msg/**/**").anonymous()
                 .antMatchers("/msg").anonymous()
                 .antMatchers("/msg").anonymous()
                 .antMatchers("/common/getId**").anonymous()
                 .antMatchers("/common/getId**").anonymous()
-                .antMatchers("/common/uploadOSS**").anonymous()
-                .antMatchers("/company/user/common/uploadOSS").anonymous()
+                // 上传接口用 permitAll:允许匿名,也允许带登录 Token(anonymous 会拒已登录请求)
+                .antMatchers("/common/uploadOSS**").permitAll()
+                .antMatchers("/company/user/common/uploadOSS").permitAll()
                 .antMatchers("/pay/wxPay/payNotify**").anonymous()
                 .antMatchers("/pay/wxPay/payNotify**").anonymous()
-                .antMatchers("/common/uploadWang**").anonymous()
+                .antMatchers("/common/uploadWang**").permitAll()
                 .antMatchers("/common/download**").permitAll()
                 .antMatchers("/common/download**").permitAll()
                 .antMatchers("/common/test").anonymous()
                 .antMatchers("/common/test").anonymous()
                 .antMatchers("/common/download/resource**").permitAll()
                 .antMatchers("/common/download/resource**").permitAll()

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

@@ -17,8 +17,10 @@ import com.fs.company.mapper.CompanyUserMapper;
 import com.fs.company.service.ICompanyMiniappService;
 import com.fs.company.service.ICompanyMiniappService;
 import com.fs.course.domain.FsCoursePlaySourceConfig;
 import com.fs.course.domain.FsCoursePlaySourceConfig;
 import com.fs.course.domain.FsCourseWatchLog;
 import com.fs.course.domain.FsCourseWatchLog;
+import com.fs.course.domain.FsUserCourse;
 import com.fs.course.domain.FsUserCourseVideo;
 import com.fs.course.domain.FsUserCourseVideo;
 import com.fs.course.mapper.FsCoursePlaySourceConfigMapper;
 import com.fs.course.mapper.FsCoursePlaySourceConfigMapper;
+import com.fs.course.mapper.FsUserCourseMapper;
 import com.fs.course.mapper.FsUserCourseVideoMapper;
 import com.fs.course.mapper.FsUserCourseVideoMapper;
 import com.fs.course.service.IFsCoursePlaySourceConfigService;
 import com.fs.course.service.IFsCoursePlaySourceConfigService;
 import com.fs.course.service.IFsCourseWatchLogService;
 import com.fs.course.service.IFsCourseWatchLogService;
@@ -69,6 +71,7 @@ public class IpadSendServer {
     private final IFsCoursePlaySourceConfigService playSourceConfigService;
     private final IFsCoursePlaySourceConfigService playSourceConfigService;
     private final FsUserMapper fsUserMapper;
     private final FsUserMapper fsUserMapper;
     private final FsUserCourseVideoMapper fsUserCourseVideoMapper;
     private final FsUserCourseVideoMapper fsUserCourseVideoMapper;
+    private final FsUserCourseMapper fsUserCourseMapper;
 
 
 
 
     private static final List<String> PROJECT_NAMES = Arrays.asList("济南联志健康", "北京存在文化", "宽益堂");
     private static final List<String> PROJECT_NAMES = Arrays.asList("济南联志健康", "北京存在文化", "宽益堂");
@@ -418,6 +421,51 @@ public class IpadSendServer {
         }
         }
     }
     }
 
 
+    /**
+     * 发送前兜底:按前端逻辑补齐空字段
+     * linkTitle=课程名,linkDescribe=课节名,linkImageUrl=课程封面图
+     */
+    private void fillLinkCardDefaultsBeforeSend(QwSopCourseFinishTempSetting.Setting content, QwSopLogs qwSopLogs) {
+        if (content == null) {
+            return;
+        }
+        if (StringUtils.isNotEmpty(content.getLinkTitle())
+                && StringUtils.isNotEmpty(content.getLinkDescribe())
+                && StringUtils.isNotEmpty(content.getLinkImageUrl())) {
+            return;
+        }
+        try {
+            if (qwSopLogs == null || StringUtils.isEmpty(qwSopLogs.getContentJson())) {
+                return;
+            }
+            JSONObject root = JSON.parseObject(qwSopLogs.getContentJson());
+            if (root == null) {
+                return;
+            }
+            Long courseId = root.getLong("courseId");
+            Long videoId = root.getLong("videoId");
+            if (courseId != null && (StringUtils.isEmpty(content.getLinkTitle()) || StringUtils.isEmpty(content.getLinkImageUrl()))) {
+                FsUserCourse course = fsUserCourseMapper.selectFsUserCourseByCourseId(courseId);
+                if (course != null) {
+                    if (StringUtils.isEmpty(content.getLinkTitle()) && StringUtils.isNotEmpty(course.getCourseName())) {
+                        content.setLinkTitle(course.getCourseName());
+                    }
+                    if (StringUtils.isEmpty(content.getLinkImageUrl()) && StringUtils.isNotEmpty(course.getImgUrl())) {
+                        content.setLinkImageUrl(course.getImgUrl());
+                    }
+                }
+            }
+            if (videoId != null && StringUtils.isEmpty(content.getLinkDescribe())) {
+                String videoTitle = fsUserCourseVideoMapper.selectFsUserCourseVideoByVideoForTitle(videoId);
+                if (StringUtils.isNotEmpty(videoTitle)) {
+                    content.setLinkDescribe(videoTitle);
+                }
+            }
+        } catch (Exception e) {
+            log.warn("发送前补齐链接卡片失败 logId={}", qwSopLogs != null ? qwSopLogs.getId() : null, e);
+        }
+    }
+
     public void sendVideo(BaseVo vo, QwSopCourseFinishTempSetting.Setting content) {
     public void sendVideo(BaseVo vo, QwSopCourseFinishTempSetting.Setting content) {
         FileVo videoVo = FileVo.builder()
         FileVo videoVo = FileVo.builder()
                 .url(content.getVideoUrl())
                 .url(content.getVideoUrl())
@@ -853,6 +901,7 @@ public class IpadSendServer {
                 case "17":
                 case "17":
                 case "18":
                 case "18":
                     // 链接卡片:3=H5,17=飞书看课,18=飞书NEW
                     // 链接卡片:3=H5,17=飞书看课,18=飞书NEW
+                    fillLinkCardDefaultsBeforeSend(content, qwSopLogs);
                     sendLink(vo, content);
                     sendLink(vo, content);
                     break;
                     break;
                 case "4":
                 case "4":

+ 124 - 16
fs-qw-task/src/main/java/com/fs/app/taskService/impl/SopLogsTaskServiceImpl.java

@@ -170,6 +170,12 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
     @Autowired
     @Autowired
     private FsCourseWatchLogProjectFiller fsCourseWatchLogProjectFiller;
     private FsCourseWatchLogProjectFiller fsCourseWatchLogProjectFiller;
 
 
+    @Autowired
+    private FsUserCourseVideoMapper fsUserCourseVideoMapper;
+
+    @Autowired
+    private FsUserCourseMapper fsUserCourseMapper;
+
     @Autowired
     @Autowired
     private IQwSopLogsService qwSopLogsService;
     private IQwSopLogsService qwSopLogsService;
 
 
@@ -1841,32 +1847,65 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                         Long externalIdLong = null;
                         Long externalIdLong = null;
                         String chatIdForFeishu = null;
                         String chatIdForFeishu = null;
                         if (isGroupChat) {
                         if (isGroupChat) {
+                            // 群聊飞书发课:按群成员写看课记录(对齐小程序 case 4);发送仍只有一条群消息
+                            try {
+                                groupChat.getChatUserList().stream().filter(e -> e.getUserList() != null && !e.getUserList().isEmpty()).forEach(e -> {
+                                    Map<String, GroupUserExternalVo> userMap = PubFun.listToMapByGroupObject(e.getUserList(), GroupUserExternalVo::getUserId);
+                                    GroupUserExternalVo vo = userMap.get(groupChat.getOwner());
+                                    if (vo != null && vo.getId() != null) {
+                                        sopLogs.setFsUserId(vo.getFsUserId());
+                                        // watchType=3 飞书看课
+                                        addWatchLogIfNeeded(sopLogs, videoId, courseId, sendTime, qwUserId, companyUserId, companyId, vo.getId().toString(), logVo, 3);
+                                    }
+                                });
+                            } catch (Exception e) {
+                                log.error("群聊飞书(17)创建看课记录失败!", e);
+                            }
                             chatIdForFeishu = externalId;
                             chatIdForFeishu = externalId;
                         } else {
                         } else {
                             externalIdLong = Long.parseLong(externalId);
                             externalIdLong = Long.parseLong(externalId);
+                            // 私聊飞书发课:发时写入/更新看课记录,已存在则把 watch_type 改为飞书(3)
+                            addWatchLogIfNeeded(sopLogs, videoId, courseId, sendTime, qwUserId, companyUserId,
+                                    companyId, externalId, logVo, 3);
                         }
                         }
 
 
                         Map<String, String> feiShuLinkMap = createFeiShuLinkByMiniApp(setting, sopLogs.getCorpId(), sendTime, courseId, videoId,
                         Map<String, String> feiShuLinkMap = createFeiShuLinkByMiniApp(setting, sopLogs.getCorpId(), sendTime, courseId, videoId,
                                 qwUserId, companyUserId, companyId, externalIdLong, chatIdForFeishu, cachedCourseConfig);
                                 qwUserId, companyUserId, companyId, externalIdLong, chatIdForFeishu, cachedCourseConfig);
                         String shortLink = feiShuLinkMap.get("link");
                         String shortLink = feiShuLinkMap.get("link");
 
 
-                        Integer directDocShard = null;
-                        if (feishuShardAllocator != null) {
-                            directDocShard = feishuShardAllocator.nextShard(companyUserIdLong, videoId, courseId,
-                                    setting.getFeishuAccountId());
-                        }
-                        String feishuLink = feiShuService.resolveFeishuSendLink(videoId, companyIdLong, courseId, companyUserIdLong,
-                                shortLink, setting.getFeishuAccountId(), 0, directDocShard);
-                        if (StringUtils.isEmpty(feishuLink)) {
-                            log.error("生成飞书注册链接失败,跳过本条: sopId={}, videoId={}, externalId={}",
-                                    logVo.getSopId(), videoId, externalId);
-                            recordFeishuLinkGenerationFailed(logVo, sendTime, content.getTime(), qwUserId, qwUserName,
-                                    companyUserId, companyId, logVo.getCorpId(), sopLogs.getExternalId(), fsUserId, videoId,
-                                    "生成飞书注册链接失败");
-                            return;
+                        String feishuLink;
+                        if (isGroupChat) {
+                            // 群:发课时不建飞书文档,只发领取页;领取时按当前用户生成独立飞书链接
+                            feishuLink = buildFeishuClaimPageUrl(shortLink, cachedCourseConfig);
+                            if (StringUtils.isEmpty(feishuLink)) {
+                                log.error("生成飞书群领取链接失败,跳过本条: sopId={}, videoId={}, chatId={}",
+                                        logVo.getSopId(), videoId, chatIdForFeishu);
+                                recordFeishuLinkGenerationFailed(logVo, sendTime, content.getTime(), qwUserId, qwUserName,
+                                        companyUserId, companyId, logVo.getCorpId(), sopLogs.getExternalId(), fsUserId, videoId,
+                                        "生成飞书群领取链接失败");
+                                return;
+                            }
+                        } else {
+                            Integer directDocShard = null;
+                            if (feishuShardAllocator != null) {
+                                directDocShard = feishuShardAllocator.nextShard(companyUserIdLong, videoId, courseId,
+                                        setting.getFeishuAccountId());
+                            }
+                            feishuLink = feiShuService.resolveFeishuSendLink(videoId, companyIdLong, courseId, companyUserIdLong,
+                                    shortLink, setting.getFeishuAccountId(), 0, directDocShard, fsUserId);
+                            if (StringUtils.isEmpty(feishuLink)) {
+                                log.error("生成飞书注册链接失败,跳过本条: sopId={}, videoId={}, externalId={}",
+                                        logVo.getSopId(), videoId, externalId);
+                                recordFeishuLinkGenerationFailed(logVo, sendTime, content.getTime(), qwUserId, qwUserName,
+                                        companyUserId, companyId, logVo.getCorpId(), sopLogs.getExternalId(), fsUserId, videoId,
+                                        "生成飞书注册链接失败");
+                                return;
+                            }
                         }
                         }
                         setting.setLinkUrl(feishuLink);
                         setting.setLinkUrl(feishuLink);
                         setting.setIsBindUrl("2");
                         setting.setIsBindUrl("2");
+                        // 链接卡片标题/描述/封面为空时兜底,避免 PAD 发出空卡片
+                        fillFeishuLinkCardDefaults(setting, videoId, courseId);
                     } catch (Exception e) {
                     } catch (Exception e) {
                         log.error("生成飞书注册链接异常,跳过本条: sopId={}, videoId={}, externalId={}",
                         log.error("生成飞书注册链接异常,跳过本条: sopId={}, videoId={}, externalId={}",
                                 logVo.getSopId(), videoId, externalId, e);
                                 logVo.getSopId(), videoId, externalId, e);
@@ -1882,12 +1921,26 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                         Long externalIdFeishuNew = null;
                         Long externalIdFeishuNew = null;
                         String chatIdForFeishuNew = null;
                         String chatIdForFeishuNew = null;
                         if (isGroupChat) {
                         if (isGroupChat) {
-                            // 与 case 17 一致:群路径下 externalId 入参实际为 chatId
+                            // 与 case 17 一致:群路径下 externalId 入参实际为 chatId;发时按群成员写看课记录
+                            try {
+                                groupChat.getChatUserList().stream().filter(e -> e.getUserList() != null && !e.getUserList().isEmpty()).forEach(e -> {
+                                    Map<String, GroupUserExternalVo> userMap = PubFun.listToMapByGroupObject(e.getUserList(), GroupUserExternalVo::getUserId);
+                                    GroupUserExternalVo vo = userMap.get(groupChat.getOwner());
+                                    if (vo != null && vo.getId() != null) {
+                                        sopLogs.setFsUserId(vo.getFsUserId());
+                                        // watchType=3 飞书看课
+                                        addWatchLogIfNeeded(sopLogs, videoId, courseId, sendTime, qwUserId, companyUserId, companyId, vo.getId().toString(), logVo, 3);
+                                    }
+                                });
+                            } catch (Exception e) {
+                                log.error("群聊飞书NEW(18)创建看课记录失败!", e);
+                            }
                             chatIdForFeishuNew = externalId;
                             chatIdForFeishuNew = externalId;
                         } else {
                         } else {
                             externalIdFeishuNew = Long.parseLong(externalId);
                             externalIdFeishuNew = Long.parseLong(externalId);
+                            // watchType=3 飞书看课
                             addWatchLogIfNeeded(sopLogs, videoId, courseId, sendTime, qwUserId, companyUserId,
                             addWatchLogIfNeeded(sopLogs, videoId, courseId, sendTime, qwUserId, companyUserId,
-                                    companyId, externalId, logVo, 2);
+                                    companyId, externalId, logVo, 3);
                         }
                         }
                         CourseConfig feishuNewConfig = config != null ? config : cachedCourseConfig;
                         CourseConfig feishuNewConfig = config != null ? config : cachedCourseConfig;
                         String feishuNewUrl = createFeishuNewSendUrl(setting, sopLogs.getCorpId(), sendTime,
                         String feishuNewUrl = createFeishuNewSendUrl(setting, sopLogs.getCorpId(), sendTime,
@@ -1903,6 +1956,8 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                         }
                         }
                         setting.setLinkUrl(feishuNewUrl);
                         setting.setLinkUrl(feishuNewUrl);
                         setting.setIsBindUrl("2");
                         setting.setIsBindUrl("2");
+                        // 链接卡片标题/描述/封面为空时兜底,避免 PAD 发出空卡片
+                        fillFeishuLinkCardDefaults(setting, videoId, courseId);
                     } catch (Exception e) {
                     } catch (Exception e) {
                         log.error("生成飞书NEW链接异常,跳过本条: sopId={}, videoId={}, externalId={}",
                         log.error("生成飞书NEW链接异常,跳过本条: sopId={}, videoId={}, externalId={}",
                                 logVo.getSopId(), videoId, externalId, e);
                                 logVo.getSopId(), videoId, externalId, e);
@@ -2206,6 +2261,42 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
         return updateTime;
         return updateTime;
     }
     }
 
 
+    /**
+     * 飞书链接卡片(17/18)为空时按前端逻辑兜底:
+     * linkTitle=课程名,linkDescribe=课节名,linkImageUrl=课程封面图
+     */
+    private void fillFeishuLinkCardDefaults(QwSopTempSetting.Content.Setting setting, Long videoId, Long courseId) {
+        if (setting == null) {
+            return;
+        }
+        String courseName = null;
+        String courseImgUrl = null;
+        String videoTitle = null;
+        try {
+            if (courseId != null && (StringUtils.isEmpty(setting.getLinkTitle()) || StringUtils.isEmpty(setting.getLinkImageUrl()))) {
+                FsUserCourse course = fsUserCourseMapper.selectFsUserCourseByCourseId(courseId);
+                if (course != null) {
+                    courseName = course.getCourseName();
+                    courseImgUrl = course.getImgUrl();
+                }
+            }
+            if (videoId != null && StringUtils.isEmpty(setting.getLinkDescribe())) {
+                videoTitle = fsUserCourseVideoMapper.selectFsUserCourseVideoByVideoForTitle(videoId);
+            }
+        } catch (Exception e) {
+            log.warn("补齐飞书链接卡片失败 courseId={}, videoId={}", courseId, videoId, e);
+        }
+        if (StringUtils.isEmpty(setting.getLinkTitle()) && StringUtils.isNotEmpty(courseName)) {
+            setting.setLinkTitle(courseName);
+        }
+        if (StringUtils.isEmpty(setting.getLinkDescribe()) && StringUtils.isNotEmpty(videoTitle)) {
+            setting.setLinkDescribe(videoTitle);
+        }
+        if (StringUtils.isEmpty(setting.getLinkImageUrl()) && StringUtils.isNotEmpty(courseImgUrl)) {
+            setting.setLinkImageUrl(courseImgUrl);
+        }
+    }
+
     /**
     /**
      * 生成飞书看课课程短链(私聊传 externalId;发群传 chatId,与 SopUserLogsInfoServiceImpl 一致)
      * 生成飞书看课课程短链(私聊传 externalId;发群传 chatId,与 SopUserLogsInfoServiceImpl 一致)
      */
      */
@@ -2247,6 +2338,23 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
         return result;
         return result;
     }
     }
 
 
+    /**
+     * 群飞书(17):发课时只拼领取页,不调飞书建文档;领取时再按当前用户生成独立飞书链接。
+     * 格式与飞书NEW一致:{realLinkDomainName}/?uuid={shortLink}&appid={feishuMpAppId}
+     */
+    private String buildFeishuClaimPageUrl(String shortLink, CourseConfig config) {
+        if (StringUtils.isEmpty(shortLink) || config == null
+                || StringUtils.isEmpty(config.getRealLinkDomainName())
+                || StringUtils.isEmpty(config.getFeishuMpAppId())) {
+            return null;
+        }
+        String domain = config.getRealLinkDomainName().trim();
+        if (!domain.endsWith("/")) {
+            domain = domain + "/";
+        }
+        return domain + "?uuid=" + shortLink + "&appid=" + config.getFeishuMpAppId().trim();
+    }
+
     /**
     /**
      * 飞书NEW(contentType=18):按需授权链路处理——
      * 飞书NEW(contentType=18):按需授权链路处理——
      * 1)发送侧须调用 addWatchLogIfNeeded 写入看课记录;
      * 1)发送侧须调用 addWatchLogIfNeeded 写入看课记录;

+ 14 - 0
fs-qwhook-sop/src/main/java/com/fs/app/controller/ApisQwSopController.java

@@ -17,8 +17,10 @@ import com.fs.qw.param.sidebar.ExternalContactInfoParam;
 import com.fs.qw.param.sidebar.TagGroupListParam;
 import com.fs.qw.param.sidebar.TagGroupListParam;
 import com.fs.qw.param.sidebar.TagGroupUpdateParam;
 import com.fs.qw.param.sidebar.TagGroupUpdateParam;
 import com.fs.qw.result.QwExternalContactByQwResult;
 import com.fs.qw.result.QwExternalContactByQwResult;
+import com.fs.qw.service.IExternalContactRedPacketService;
 import com.fs.qw.service.IQwExternalContactService;
 import com.fs.qw.service.IQwExternalContactService;
 import com.fs.qw.service.IQwTagGroupService;
 import com.fs.qw.service.IQwTagGroupService;
+import com.fs.qw.param.SidebarSendRedPacketParam;
 import com.fs.qw.vo.QwTagGroupListVO;
 import com.fs.qw.vo.QwTagGroupListVO;
 import com.fs.qw.vo.sidebar.ExternalContactInfoVO;
 import com.fs.qw.vo.sidebar.ExternalContactInfoVO;
 import com.fs.qw.vo.sidebar.ExternalContactTagVO;
 import com.fs.qw.vo.sidebar.ExternalContactTagVO;
@@ -60,6 +62,9 @@ public class ApisQwSopController  extends BaseController {
 
 
     @Autowired
     @Autowired
     private IQwSopService qwSopService;
     private IQwSopService qwSopService;
+
+    @Autowired
+    private IExternalContactRedPacketService externalContactRedPacketService;
     /**
     /**
      * 更新AI发送状态
      * 更新AI发送状态
      */
      */
@@ -203,4 +208,13 @@ public class ApisQwSopController  extends BaseController {
         return getDataTable(list);
         return getDataTable(list);
     }
     }
 
 
+    /**
+     * 侧边栏发送红包:校验今日完课/已发/已领,返回确认收款链接
+     */
+    @PostMapping("/prepareSidebarRedPacket")
+    @ApiOperation("侧边栏准备完课红包领取链接")
+    public R prepareSidebarRedPacket(@RequestBody SidebarSendRedPacketParam param) {
+        return externalContactRedPacketService.prepareSidebarRedPacketLink(param);
+    }
+
 }
 }

+ 14 - 0
fs-qwhook-sop/src/main/java/com/fs/app/controller/QwSopController.java

@@ -14,10 +14,12 @@ import com.fs.qw.domain.QwTagGroup;
 import com.fs.qw.param.CourseQuizRedEnvelopeStatsParam;
 import com.fs.qw.param.CourseQuizRedEnvelopeStatsParam;
 import com.fs.qw.param.QwSidebarStatsParam;
 import com.fs.qw.param.QwSidebarStatsParam;
 import com.fs.qw.param.SopMsgParam;
 import com.fs.qw.param.SopMsgParam;
+import com.fs.qw.param.SidebarSendRedPacketParam;
 import com.fs.qw.param.sidebar.ExternalContactInfoParam;
 import com.fs.qw.param.sidebar.ExternalContactInfoParam;
 import com.fs.qw.param.sidebar.TagGroupListParam;
 import com.fs.qw.param.sidebar.TagGroupListParam;
 import com.fs.qw.param.sidebar.TagGroupUpdateParam;
 import com.fs.qw.param.sidebar.TagGroupUpdateParam;
 import com.fs.qw.result.QwExternalContactByQwResult;
 import com.fs.qw.result.QwExternalContactByQwResult;
+import com.fs.qw.service.IExternalContactRedPacketService;
 import com.fs.qw.service.IQwExternalContactService;
 import com.fs.qw.service.IQwExternalContactService;
 import com.fs.qw.service.IQwTagGroupService;
 import com.fs.qw.service.IQwTagGroupService;
 import com.fs.qw.vo.QwTagGroupListVO;
 import com.fs.qw.vo.QwTagGroupListVO;
@@ -63,6 +65,9 @@ public class QwSopController extends BaseController {
     @Autowired
     @Autowired
     private IQwSopService qwSopService;
     private IQwSopService qwSopService;
 
 
+    @Autowired
+    private IExternalContactRedPacketService externalContactRedPacketService;
+
     /**
     /**
      * 更新AI发送状态
      * 更新AI发送状态
      */
      */
@@ -151,4 +156,13 @@ public class QwSopController extends BaseController {
         List<FsCourseWatchLogStatisticsListVO> list = qwSopService.externalWatchRecordStatsList(qwParam);
         List<FsCourseWatchLogStatisticsListVO> list = qwSopService.externalWatchRecordStatsList(qwParam);
         return getDataTable(list);
         return getDataTable(list);
     }
     }
+
+    /**
+     * 侧边栏发送红包:校验今日完课/已发/已领,返回与一键发红包相同的确认收款链接,由前端 sendChatMessage 发出
+     */
+    @PostMapping("/prepareSidebarRedPacket")
+    @ApiOperation("侧边栏准备完课红包领取链接")
+    public R prepareSidebarRedPacket(@RequestBody SidebarSendRedPacketParam param) {
+        return externalContactRedPacketService.prepareSidebarRedPacketLink(param);
+    }
 }
 }

+ 1 - 1
fs-service/src/main/java/com/fs/course/domain/FsCourseWatchLog.java

@@ -93,7 +93,7 @@ public class FsCourseWatchLog extends BaseEntity
     /** 所属小程序id**/
     /** 所属小程序id**/
     private String appId;
     private String appId;
 
 
-    private Integer watchType;//看课方式:1 app  2 小程序
+    private Integer watchType;//看课方式:1 app  2 小程序  3 飞书
 
 
     /** 群聊ID */
     /** 群聊ID */
     @Excel(name = "群聊ID")
     @Excel(name = "群聊ID")

+ 30 - 0
fs-service/src/main/java/com/fs/course/mapper/FsCourseRedPacketLogMapper.java

@@ -2,6 +2,7 @@ package com.fs.course.mapper;
 
 
 import java.math.BigDecimal;
 import java.math.BigDecimal;
 import java.time.LocalDateTime;
 import java.time.LocalDateTime;
+import java.util.Date;
 import java.util.List;
 import java.util.List;
 import java.util.Map;
 import java.util.Map;
 
 
@@ -80,9 +81,38 @@ public interface FsCourseRedPacketLogMapper
     @Select("select * from fs_course_red_packet_log where out_batch_no = #{outBatchNo}")
     @Select("select * from fs_course_red_packet_log where out_batch_no = #{outBatchNo}")
     FsCourseRedPacketLog selectFsCourseRedPacketLogByBatchNo(@Param("outBatchNo") String outBatchNo);
     FsCourseRedPacketLog selectFsCourseRedPacketLogByBatchNo(@Param("outBatchNo") String outBatchNo);
 
 
+    /**
+     * 查询公司下这些会员已发放/发放中的课程红包(用于一键发红包去重)
+     */
+    @Select({"<script>",
+            "SELECT user_id AS userId, course_id AS courseId, video_id AS videoId FROM fs_course_red_packet_log",
+            "WHERE company_id = #{companyId} AND status IN (0, 1)",
+            "AND create_time &gt;= #{startTime} AND create_time &lt; #{endTime}",
+            "AND user_id IN",
+            "<foreach collection='userIds' item='id' open='(' separator=',' close=')'>#{id}</foreach>",
+            "</script>"})
+    List<FsCourseRedPacketLog> selectSentUserCourseByUserIds(@Param("companyId") Long companyId,
+                                                             @Param("userIds") List<Long> userIds,
+                                                             @Param("startTime") Date startTime,
+                                                             @Param("endTime") Date endTime);
+
     @Select("select * from fs_course_red_packet_log where batch_id = #{batchId}")
     @Select("select * from fs_course_red_packet_log where batch_id = #{batchId}")
     FsCourseRedPacketLog selectFsCourseRedPacketLogByBatchId(@Param("batchId") String batchId);
     FsCourseRedPacketLog selectFsCourseRedPacketLogByBatchId(@Param("batchId") String batchId);
 
 
+    /**
+     * 侧边栏发红包:查当天该用户+课+节是否已有发放记录(含 result,便于复用领取链接)
+     */
+    @Select("SELECT * FROM fs_course_red_packet_log WHERE company_id = #{companyId} AND user_id = #{userId} "
+            + "AND course_id = #{courseId} AND video_id = #{videoId} "
+            + "AND create_time >= #{startTime} AND create_time < #{endTime} "
+            + "AND status IN (0, 1) ORDER BY log_id DESC LIMIT 1")
+    FsCourseRedPacketLog selectTodayRedPacketLog(@Param("companyId") Long companyId,
+                                                 @Param("userId") Long userId,
+                                                 @Param("courseId") Long courseId,
+                                                 @Param("videoId") Long videoId,
+                                                 @Param("startTime") Date startTime,
+                                                 @Param("endTime") Date endTime);
+
 
 
     @Select({"<script> " +
     @Select({"<script> " +
             "select o.*,v.title as video_name,u.phone as user_name  from fs_course_red_packet_log l " +
             "select o.*,v.title as video_name,u.phone as user_name  from fs_course_red_packet_log l " +

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

@@ -509,6 +509,26 @@ public interface FsCourseWatchLogMapper extends BaseMapper<FsCourseWatchLog> {
 
 
     ArrayList<FsCourseWatchLogByFinishTimeParam> selectFsCourseWatchLogByFinishTime();
     ArrayList<FsCourseWatchLogByFinishTimeParam> selectFsCourseWatchLogByFinishTime();
 
 
+    /**
+     * 当天完课记录(log_type=2 且 watch_type=3 飞书看课),按主体/企微账号过滤,用于销售端一键发红包
+     */
+    List<TodayFinishWatchForRedPacketVO> selectTodayFinishWatchForRedPacket(
+            @Param("companyId") Long companyId,
+            @Param("corpId") String corpId,
+            @Param("qwUserId") Long qwUserId,
+            @Param("startTime") Date startTime,
+            @Param("endTime") Date endTime);
+
+    /**
+     * 侧边栏发红包:该客户在该销售下,今天是否有完课(不限制 watch_type;按 watch 表 company_user_id 归属销售)
+     */
+    TodayFinishWatchForRedPacketVO selectTodayFinishWatchForSidebarSales(
+            @Param("companyId") Long companyId,
+            @Param("companyUserId") Long companyUserId,
+            @Param("qwExternalContactId") Long qwExternalContactId,
+            @Param("startTime") Date startTime,
+            @Param("endTime") Date endTime);
+
     void batchUpdateWatchLog(@Param("list") List<FsCourseWatchLog> logs);
     void batchUpdateWatchLog(@Param("list") List<FsCourseWatchLog> logs);
 
 
     List<FsCourseUserStatisticsListVO> selectFsCourseUserStatisticsListVO(FsCourseUserStatisticsListParam param);
     List<FsCourseUserStatisticsListVO> selectFsCourseUserStatisticsListVO(FsCourseUserStatisticsListParam param);

+ 4 - 0
fs-service/src/main/java/com/fs/course/service/impl/FsCourseRedPacketLogServiceImpl.java

@@ -176,6 +176,7 @@ public class FsCourseRedPacketLogServiceImpl implements IFsCourseRedPacketLogSer
 
 
     @Override
     @Override
     public R syncRedPacket(String outBatchNo, String batchId) {
     public R syncRedPacket(String outBatchNo, String batchId) {
+        logger.info("【红包同步】开始 批次号(outBatchNo)={}, 微信单号(batchId)={}", outBatchNo, batchId);
         FsCourseRedPacketLog log = fsCourseRedPacketLogMapper.selectFsCourseRedPacketLogByBatchNo(outBatchNo);
         FsCourseRedPacketLog log = fsCourseRedPacketLogMapper.selectFsCourseRedPacketLogByBatchNo(outBatchNo);
         if (log!=null){
         if (log!=null){
             log.setStatus(1);
             log.setStatus(1);
@@ -189,8 +190,11 @@ public class FsCourseRedPacketLogServiceImpl implements IFsCourseRedPacketLogSer
             redLogs.setStatus(1L);
             redLogs.setStatus(1L);
             companyRedPacketBalanceLogsMapper.updateCompanyRedPacketBalanceLogsByRedPacketId(redLogs);
             companyRedPacketBalanceLogsMapper.updateCompanyRedPacketBalanceLogsByRedPacketId(redLogs);
 
 
+            logger.info("【红包同步】成功 logId={}, 批次号={}, 微信单号={}, companyId={}, userId={}",
+                    log.getLogId(), outBatchNo, batchId, log.getCompanyId(), log.getUserId());
             return R.ok();
             return R.ok();
         }
         }
+        logger.warn("【红包同步】批次不存在 批次号={}, 微信单号={}", outBatchNo, batchId);
         return R.error("批次不存在");
         return R.error("批次不存在");
     }
     }
 
 

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

@@ -3708,14 +3708,14 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
         }
         }
         Long externalUserId = externalContact.getId();
         Long externalUserId = externalContact.getId();
 
 
-        if (FeiShuService.isAuthRequired(param.getFeishuNeedAuth())) {
-            addWatchLogIfNeeded(param.getVideoId(), param.getCourseId(), param.getFsUserId(), qwUser, externalUserId, 2);
-        }
+        // 飞书发课:写入/更新看课记录;已存在则把 watch_type 改为飞书(3)
+        addWatchLogIfNeeded(param.getVideoId(), param.getCourseId(), param.getFsUserId(), qwUser, externalUserId, 3);
 
 
         Map<String, String> feishuH5Link = createFeishuH5Link(param.getCorpId(), createTime, courseId.intValue(), videoId.intValue(), String.valueOf(qwUserId), companyUserId, companyId, externalUserId, config, param.getFeishuAccountId(), param.getFeishuNeedAuth());
         Map<String, String> feishuH5Link = createFeishuH5Link(param.getCorpId(), createTime, courseId.intValue(), videoId.intValue(), String.valueOf(qwUserId), companyUserId, companyId, externalUserId, config, param.getFeishuAccountId(), param.getFeishuNeedAuth());
         String shortCode = feishuH5Link.get("link");
         String shortCode = feishuH5Link.get("link");
 
 
-        String feishuLink = feiShuService.resolveFeishuSendLink(videoId, companyId, courseId, companyUserId, shortCode, param.getFeishuAccountId(), param.getFeishuNeedAuth());
+        String feishuLink = feiShuService.resolveFeishuSendLink(videoId, companyId, courseId, companyUserId, shortCode,
+                param.getFeishuAccountId(), param.getFeishuNeedAuth(), null, param.getFsUserId());
 
 
         JSONObject news = new JSONObject(true);
         JSONObject news = new JSONObject(true);
         news.put("linkTitle", param.getTitle());
         news.put("linkTitle", param.getTitle());

+ 48 - 0
fs-service/src/main/java/com/fs/course/vo/TodayFinishWatchForRedPacketVO.java

@@ -0,0 +1,48 @@
+package com.fs.course.vo;
+
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 销售端一键发红包:当天飞书完课看课记录(watch_type=3,关联外部联系人)
+ */
+@Data
+public class TodayFinishWatchForRedPacketVO {
+
+    /** 看课记录ID */
+    private Long logId;
+
+    /** 会员ID */
+    private Long userId;
+
+    /** 课程ID */
+    private Long courseId;
+
+    /** 小节ID */
+    private Long videoId;
+
+    /** 营期ID */
+    private Long periodId;
+
+    /** 完课时间 */
+    private Date finishTime;
+
+    /** 外部联系人主键 */
+    private Long qwExternalContactId;
+
+    /** 企微主体 */
+    private String corpId;
+
+    /** 所属企微账号主键 */
+    private Long qwUserId;
+
+    /** 客户昵称 */
+    private String contactName;
+
+    /** 已存 openid */
+    private String openId;
+
+    /** 外部联系人 userid */
+    private String externalUserId;
+}

+ 51 - 26
fs-service/src/main/java/com/fs/feishu/service/FeiShuService.java

@@ -94,20 +94,31 @@ public class FeiShuService {
                                         Long companyUserId, String shortLink, Long feishuAccountId,
                                         Long companyUserId, String shortLink, Long feishuAccountId,
                                         Integer feishuNeedAuth) {
                                         Integer feishuNeedAuth) {
         return resolveFeishuSendLink(videoId, companyId, courseId, companyUserId, shortLink, feishuAccountId,
         return resolveFeishuSendLink(videoId, companyId, courseId, companyUserId, shortLink, feishuAccountId,
-                feishuNeedAuth, null);
+                feishuNeedAuth, null, null);
     }
     }
 
 
     /**
     /**
-     * @param directDocShard SOP 群发/自动发课分片序号,null 表示非分片场景(沿用按天复用逻辑)
+     * @param directDocShard SOP 群发/自动发课分片序号,null 表示非分片场景
      */
      */
     public String resolveFeishuSendLink(Long videoId, Long companyId, Long courseId,
     public String resolveFeishuSendLink(Long videoId, Long companyId, Long courseId,
                                         Long companyUserId, String shortLink, Long feishuAccountId,
                                         Long companyUserId, String shortLink, Long feishuAccountId,
                                         Integer feishuNeedAuth, Integer directDocShard) {
                                         Integer feishuNeedAuth, Integer directDocShard) {
+        return resolveFeishuSendLink(videoId, companyId, courseId, companyUserId, shortLink, feishuAccountId,
+                feishuNeedAuth, directDocShard, null);
+    }
+
+    /**
+     * @param directDocShard SOP 分片序号,null 表示非分片
+     * @param fsUserId       看课会员 ID,写入飞书 iframe 的 userId;群场景必须传本人 ID,避免多人共用同一文档身份
+     */
+    public String resolveFeishuSendLink(Long videoId, Long companyId, Long courseId,
+                                        Long companyUserId, String shortLink, Long feishuAccountId,
+                                        Integer feishuNeedAuth, Integer directDocShard, Long fsUserId) {
         if (isAuthRequired(feishuNeedAuth)) {
         if (isAuthRequired(feishuNeedAuth)) {
             return getFeishuRegisterLink(videoId, companyId, courseId, companyUserId, shortLink, feishuAccountId);
             return getFeishuRegisterLink(videoId, companyId, courseId, companyUserId, shortLink, feishuAccountId);
         }
         }
         return getFeishuDirectCourseLink(videoId, companyId, courseId, companyUserId, shortLink, feishuAccountId,
         return getFeishuDirectCourseLink(videoId, companyId, courseId, companyUserId, shortLink, feishuAccountId,
-                directDocShard);
+                directDocShard, fsUserId);
     }
     }
 
 
     /**
     /**
@@ -175,15 +186,25 @@ public class FeiShuService {
      */
      */
     public String getFeishuDirectCourseLink(Long videoId, Long companyId, Long courseId,
     public String getFeishuDirectCourseLink(Long videoId, Long companyId, Long courseId,
                                             Long companyUserId, String shortLink, Long feishuAccountId) {
                                             Long companyUserId, String shortLink, Long feishuAccountId) {
-        return getFeishuDirectCourseLink(videoId, companyId, courseId, companyUserId, shortLink, feishuAccountId, null);
+        return getFeishuDirectCourseLink(videoId, companyId, courseId, companyUserId, shortLink, feishuAccountId, null, null);
     }
     }
 
 
     /**
     /**
-     * @param directDocShard SOP 分片序号,null 时按销售+课程+账号+日期复用同一文档
+     * @param directDocShard SOP 分片序号,null 时按短链隔离文档(同一短链当天可复用)
      */
      */
     public String getFeishuDirectCourseLink(Long videoId, Long companyId, Long courseId,
     public String getFeishuDirectCourseLink(Long videoId, Long companyId, Long courseId,
                                             Long companyUserId, String shortLink, Long feishuAccountId,
                                             Long companyUserId, String shortLink, Long feishuAccountId,
                                             Integer directDocShard) {
                                             Integer directDocShard) {
+        return getFeishuDirectCourseLink(videoId, companyId, courseId, companyUserId, shortLink, feishuAccountId,
+                directDocShard, null);
+    }
+
+    /**
+     * @param fsUserId 看课会员 ID,嵌入 iframe 的 userId;为空时按 0 处理(兼容旧调用)
+     */
+    public String getFeishuDirectCourseLink(Long videoId, Long companyId, Long courseId,
+                                            Long companyUserId, String shortLink, Long feishuAccountId,
+                                            Integer directDocShard, Long fsUserId) {
         CourseConfig config = getCourseConfig();
         CourseConfig config = getCourseConfig();
         if (!Boolean.TRUE.equals(config.getEnableFeishuNewLink())) {
         if (!Boolean.TRUE.equals(config.getEnableFeishuNewLink())) {
             throw new CustomException("未开启飞书看课");
             throw new CustomException("未开启飞书看课");
@@ -200,6 +221,7 @@ public class FeiShuService {
             throw new CustomException("视频不存在: " + videoId);
             throw new CustomException("视频不存在: " + videoId);
         }
         }
         int questionFlag = resolveQuestionFlag(userCourseVideo);
         int questionFlag = resolveQuestionFlag(userCourseVideo);
+        long embedUserId = fsUserId != null ? fsUserId : 0L;
 
 
         List<FeishuAccount> accounts = clientPool.listEnabledAccounts(companyUserId, feishuAccountId);
         List<FeishuAccount> accounts = clientPool.listEnabledAccounts(companyUserId, feishuAccountId);
         CustomException lastError = null;
         CustomException lastError = null;
@@ -207,7 +229,8 @@ public class FeiShuService {
             FeishuClientHolder holder = clientPool.getClient(companyUserId, account.getId());
             FeishuClientHolder holder = clientPool.getClient(companyUserId, account.getId());
             try {
             try {
                 String docUrl = getOrCreateDirectCourseDocUrl(holder, account.getId(), companyUserId, companyId,
                 String docUrl = getOrCreateDirectCourseDocUrl(holder, account.getId(), companyUserId, companyId,
-                        courseId, videoId, shortLink, questionFlag, userCourseVideo.getTitle(), directDocShard);
+                        courseId, videoId, shortLink, questionFlag, userCourseVideo.getTitle(), directDocShard,
+                        embedUserId);
                 updateCourseLinkFeishuAccount(shortLink, account.getId());
                 updateCourseLinkFeishuAccount(shortLink, account.getId());
                 return docUrl;
                 return docUrl;
             } catch (CustomException e) {
             } catch (CustomException e) {
@@ -350,10 +373,9 @@ public class FeiShuService {
             updateCourseLinkFeishuAccount(param.getLink(), feishuAccountId);
             updateCourseLinkFeishuAccount(param.getLink(), feishuAccountId);
         }
         }
 
 
-        String cacheKey = String.format("feishu:course_link:%d:%d:%d:%d:%d:%s:%d",
-                param.getCompanyId(), param.getCompanyUserId(),
-                param.getCourseId(), param.getVideoId(),
-                param.getUserId(), param.getLink(), feishuAccountId);
+        // 领取层已按 link+userId 缓存;此处再建一层:link + userId,保证 17/18 群共享短链按人隔离
+        String cacheKey = String.format("feishu:course_link:%s:%d",
+                param.getLink(), param.getUserId() != null ? param.getUserId() : 0L);
 
 
         String cached = redisCache.getCacheObject(cacheKey);
         String cached = redisCache.getCacheObject(cacheKey);
         if (StringUtils.isNotBlank(cached)) {
         if (StringUtils.isNotBlank(cached)) {
@@ -387,17 +409,20 @@ public class FeiShuService {
     }
     }
 
 
     /**
     /**
-     * 同一销售、同一节课、同一飞书账号、同一天内复用免授权飞书文档,降低并发创建导致的 folder locked。
+     * 按短链 + 领取用户隔离免授权飞书文档:
+     * 私聊短链本身一人一条;群共享短链相同,必须再按 embedUserId 区分,否则 iframe 内 userId 会串号。
      */
      */
     private String getOrCreateDirectCourseDocUrl(FeishuClientHolder holder, Long feishuAccountId,
     private String getOrCreateDirectCourseDocUrl(FeishuClientHolder holder, Long feishuAccountId,
                                                  Long companyUserId, Long companyId, Long courseId,
                                                  Long companyUserId, Long companyId, Long courseId,
                                                  Long videoId, String shortLink, int questionFlag,
                                                  Long videoId, String shortLink, int questionFlag,
-                                                 String documentTitle, Integer directDocShard) throws Exception {
-        String cacheKey = buildDirectDocCacheKey(companyUserId, videoId, courseId, feishuAccountId, directDocShard);
+                                                 String documentTitle, Integer directDocShard,
+                                                 long embedUserId) throws Exception {
+        String cacheKey = buildDirectDocCacheKey(companyUserId, videoId, courseId, feishuAccountId, directDocShard,
+                shortLink, embedUserId);
         String cached = redisCache.getCacheObject(cacheKey);
         String cached = redisCache.getCacheObject(cacheKey);
         if (StringUtils.isNotBlank(cached)) {
         if (StringUtils.isNotBlank(cached)) {
-            log.debug("复用飞书免授权文档: companyUserId={}, videoId={}, accountId={}",
-                    companyUserId, videoId, feishuAccountId);
+            log.debug("复用飞书免授权文档: companyUserId={}, videoId={}, accountId={}, shortLink={}, userId={}",
+                    companyUserId, videoId, feishuAccountId, shortLink, embedUserId);
             return cached;
             return cached;
         }
         }
 
 
@@ -436,7 +461,7 @@ public class FeiShuService {
                 return cached;
                 return cached;
             }
             }
 
 
-            String iframeUrl = buildCoursePageLink(companyId, companyUserId, courseId, videoId, 0L, shortLink, questionFlag);
+            String iframeUrl = buildCoursePageLink(companyId, companyUserId, courseId, videoId, embedUserId, shortLink, questionFlag);
             String documentId = createDirectCourseDocumentWithRetry(holder, documentTitle, iframeUrl);
             String documentId = createDirectCourseDocumentWithRetry(holder, documentTitle, iframeUrl);
             String resultUrl = FEISHU_DOC_URL_PREFIX + documentId;
             String resultUrl = FEISHU_DOC_URL_PREFIX + documentId;
             int ttlSeconds = getDirectDocCacheTtlSeconds();
             int ttlSeconds = getDirectDocCacheTtlSeconds();
@@ -444,8 +469,8 @@ public class FeiShuService {
             // 写入分片索引,供一键群发失效时按实际分片精确删除(不依赖 SCAN / 固定上界)
             // 写入分片索引,供一键群发失效时按实际分片精确删除(不依赖 SCAN / 固定上界)
             registerDirectDocCacheKey(cacheKey, companyUserId, videoId, courseId, feishuAccountId, ttlSeconds);
             registerDirectDocCacheKey(cacheKey, companyUserId, videoId, courseId, feishuAccountId, ttlSeconds);
             feishuAccountMapper.incrementNumberUse(feishuAccountId);
             feishuAccountMapper.incrementNumberUse(feishuAccountId);
-            log.info("创建并缓存飞书免授权文档: companyUserId={}, videoId={}, accountId={}, docUrl={}",
-                    companyUserId, videoId, feishuAccountId, resultUrl);
+            log.info("创建并缓存飞书免授权文档: companyUserId={}, videoId={}, accountId={}, shortLink={}, userId={}, docUrl={}",
+                    companyUserId, videoId, feishuAccountId, shortLink, embedUserId, resultUrl);
             return resultUrl;
             return resultUrl;
         } catch (InterruptedException e) {
         } catch (InterruptedException e) {
             Thread.currentThread().interrupt();
             Thread.currentThread().interrupt();
@@ -576,17 +601,17 @@ public class FeiShuService {
         return deleted;
         return deleted;
     }
     }
 
 
+    /**
+     * 免授权文档缓存:fs_course_link.link + embedUserId(发群共用一条 link,领取时按用户区分)
+     */
     private String buildDirectDocCacheKey(Long companyUserId, Long videoId, Long courseId, Long feishuAccountId,
     private String buildDirectDocCacheKey(Long companyUserId, Long videoId, Long courseId, Long feishuAccountId,
-                                           Integer directDocShard) {
-        String dateKey = LocalDate.now(ZoneId.systemDefault()).format(DIRECT_DOC_DATE_FORMAT);
-        long courseIdVal = courseId != null ? courseId : 0L;
+                                           Integer directDocShard, String shortLink, long embedUserId) {
+        // 与领取约定一致:短链标识 + 用户id;分片仅用于一键群发刷新场景
+        String linkPart = StringUtils.isNotBlank(shortLink) ? shortLink.trim() : "0";
         if (directDocShard == null) {
         if (directDocShard == null) {
-            return String.format("%s%d:%d:%d:%d:%s",
-                    FEISHU_DIRECT_DOC_CACHE_PREFIX, companyUserId, videoId, courseIdVal, feishuAccountId, dateKey);
+            return String.format("%s%s:%d", FEISHU_DIRECT_DOC_CACHE_PREFIX, linkPart, embedUserId);
         }
         }
-        return String.format("%s%d:%d:%d:%d:%s:%d",
-                FEISHU_DIRECT_DOC_CACHE_PREFIX, companyUserId, videoId, courseIdVal, feishuAccountId, dateKey,
-                directDocShard);
+        return String.format("%s%s:%d:%d", FEISHU_DIRECT_DOC_CACHE_PREFIX, linkPart, embedUserId, directDocShard);
     }
     }
 
 
     private int getDirectDocCacheTtlSeconds() {
     private int getDirectDocCacheTtlSeconds() {

+ 4 - 0
fs-service/src/main/java/com/fs/his/domain/MerchantAppConfig.java

@@ -62,5 +62,9 @@ public class MerchantAppConfig extends BaseEntity{
      */
      */
     private String merchantId;
     private String merchantId;
 
 
+    /**
+     * 关联企微主体 corpId,多个逗号分隔
+     */
+    private String corpIds;
 
 
 }
 }

+ 26 - 0
fs-service/src/main/java/com/fs/his/mapper/MerchantAppConfigMapper.java

@@ -84,4 +84,30 @@ public interface MerchantAppConfigMapper extends BaseMapper<MerchantAppConfig>{
      * @return
      * @return
      */
      */
     MerchantAppConfig selectMerchantAppConfigByAppId(@Param("appId") String appId, @Param("payType") String payType);
     MerchantAppConfig selectMerchantAppConfigByAppId(@Param("appId") String appId, @Param("payType") String payType);
+
+    /**
+     * 检查 corp_ids 列是否已存在
+     */
+    Integer checkCorpIdsColumnExists();
+
+    /**
+     * 存量表补充 corp_ids 列
+     */
+    void addCorpIdsColumn();
+
+    /**
+     * 按企微主体 corpId 查找微信商户应用配置(多主体逗号分隔,取 id 最大的一条)
+     *
+     * @param corpId 企微主体 corpId
+     * @return 商户应用配置
+     */
+    MerchantAppConfig selectWxByCorpId(@Param("corpId") String corpId);
+
+    /**
+     * 按微信商户号查找配置(转账 V3 回调用,取 id 最大的一条)
+     *
+     * @param merchantId 微信商户号 mchId
+     * @return 商户应用配置
+     */
+    MerchantAppConfig selectWxByMerchantId(@Param("merchantId") String merchantId);
 }
 }

+ 4 - 0
fs-service/src/main/java/com/fs/his/param/WxSendRedPacketParam.java

@@ -28,5 +28,9 @@ public class WxSendRedPacketParam implements Serializable {
 
 
     private String mpAppId;
     private String mpAppId;
 
 
+    /**
+     * 销售端发红包:按企微主体查找商户应用配置。有值时走主体商户号,不再用全局 redPacket.config
+     */
+    private String corpId;
 
 
 }
 }

+ 7 - 0
fs-service/src/main/java/com/fs/his/service/IFsStorePaymentService.java

@@ -127,6 +127,13 @@ public interface IFsStorePaymentService
 
 
     String v3TransferNotifyWithCompanyId(Long companyId,String notifyData, HttpServletRequest request);
     String v3TransferNotifyWithCompanyId(Long companyId,String notifyData, HttpServletRequest request);
 
 
+    /**
+     * 商家转账 V3 回调:按路径中的商户号从 merchant_app_config 取配置验签并同步红包状态
+     *
+     * @param mchId 微信商户号
+     */
+    String v3TransferNotifyWithMchId(String mchId, String notifyData, HttpServletRequest request);
+
 
 
 
 
     R sendRedPacketTest(WxSendRedPacketParam param);
     R sendRedPacketTest(WxSendRedPacketParam param);

+ 396 - 74
fs-service/src/main/java/com/fs/his/service/impl/FsStorePaymentServiceImpl.java

@@ -215,6 +215,12 @@ public class FsStorePaymentServiceImpl implements IFsStorePaymentService {
     @Value("${enableRedPackAccount:0}")
     @Value("${enableRedPackAccount:0}")
     private String ENABLE_RED_PACK_ACCOUNT;
     private String ENABLE_RED_PACK_ACCOUNT;
 
 
+    /**
+     * 商家转账 V3 回调域名(不含尾斜杠),实际地址 = {domain}/app/wxpay/v3TransferNotifyWithMchId/{mchId}
+     */
+    @Value("${wx.transfer.notify-domain:}")
+    private String wxTransferNotifyDomain;
+
     @Autowired
     @Autowired
     private ICompanyConfigService companyConfigService;
     private ICompanyConfigService companyConfigService;
 
 
@@ -642,31 +648,7 @@ public class FsStorePaymentServiceImpl implements IFsStorePaymentService {
                 return R.error("[红包领取] 当前用户当前已经领取红包已经达到限制!");
                 return R.error("[红包领取] 当前用户当前已经领取红包已经达到限制!");
             }
             }
 
 
-            String json;
-            RedPacketConfig config = new RedPacketConfig();
-            // 根据红包模式获取配置
-            switch (param.getRedPacketMode()){
-                case 1:
-                    json = configService.selectConfigByKey("redPacket.config");
-                    config = JSONUtil.toBean(json, RedPacketConfig.class);
-                    break;
-                case 2:
-                    json = companyConfigService.selectRedPacketConfigByKey(param.getCompanyId());
-                    //如果分公司配置为空就走总后台的配置
-                    if (StringUtils.isEmpty(json)){
-                        json = configService.selectConfigByKey("redPacket.config");
-                    }
-                    config = JSONUtil.toBean(json, RedPacketConfig.class);
-                    break;
-                default:
-                    throw new UnsupportedOperationException("当前红包模式不支持!");
-            }
-            //H5的用公众号的appid发,小程序的用小程序的appid来发
-            if (param.getSource()==2){
-                // 传参appId为空时,仍然使用配置里面的
-                String appId = StringUtils.isBlank(param.getAppId()) ? config.getMiniappId() : param.getAppId();
-                config.setAppId(appId);
-            }
+            RedPacketConfig config = resolveRedPacketConfig(param);
             logger.info("最终传参 {}",config);
             logger.info("最终传参 {}",config);
             //组合返回参数
             //组合返回参数
             R result = new R();
             R result = new R();
@@ -683,6 +665,9 @@ public class FsStorePaymentServiceImpl implements IFsStorePaymentService {
             // 用户领取红包次数+1
             // 用户领取红包次数+1
             redisTemplateInteger.opsForValue().increment(userLimitKey, 1);
             redisTemplateInteger.opsForValue().increment(userLimitKey, 1);
             return result;
             return result;
+        }catch (CustomException e){
+            logger.error("领取红包失败原因:{}", e.getMessage());
+            return R.error(e.getMessage());
         }catch (Exception e){
         }catch (Exception e){
             logger.error("领取红包失败原因:{}", ExceptionUtils.getMessage(e),e);
             logger.error("领取红包失败原因:{}", ExceptionUtils.getMessage(e),e);
             if (e instanceof WxPayException && "北京存在文化".equals(signProjectName)) {
             if (e instanceof WxPayException && "北京存在文化".equals(signProjectName)) {
@@ -771,36 +756,7 @@ public class FsStorePaymentServiceImpl implements IFsStorePaymentService {
             redPacketLog.setAccBalanceBefore(companyMoney);
             redPacketLog.setAccBalanceBefore(companyMoney);
             redPacketLog.setSource(param.getSource());
             redPacketLog.setSource(param.getSource());
 
 
-            String json;
-            RedPacketConfig config = new RedPacketConfig();
-            // 根据红包模式获取配置
-            switch (param.getRedPacketMode()){
-                case 1:
-                    json = configService.selectConfigByKey("redPacket.config");
-                    config = JSONUtil.toBean(json, RedPacketConfig.class);
-                    break;
-                case 2:
-                    json = companyConfigService.selectRedPacketConfigByKey(param.getCompanyId());
-                    //如果分公司配置为空就走总后台的配置
-                    if (StringUtils.isEmpty(json)){
-                        json = configService.selectConfigByKey("redPacket.config");
-                    }
-                    config = JSONUtil.toBean(json, RedPacketConfig.class);
-                    break;
-                default:
-                    throw new UnsupportedOperationException("当前红包模式不支持!");
-            }
-            //H5的用公众号的appid发,小程序的用小程序的appid来发
-            if (param.getSource()==2){
-                // 传参appId为空时,仍然使用配置里面的
-                String appId = StringUtils.isBlank(param.getAppId()) ? config.getMiniappId() : param.getAppId();
-                config.setAppId(appId);
-            }
-            if("wx503cf8ab31f83dd4".equals(config.getAppId())){
-                config.setIsNew(0);
-            }else{
-                config.setIsNew(1);
-            }
+            RedPacketConfig config = resolveRedPacketConfig(param);
             logger.info("最终传参 {}",config);
             logger.info("最终传参 {}",config);
             //组合返回参数
             //组合返回参数
             R result = new R();
             R result = new R();
@@ -829,6 +785,13 @@ public class FsStorePaymentServiceImpl implements IFsStorePaymentService {
             redisTemplateInteger.opsForValue().increment(userLimitKey, 1);
             redisTemplateInteger.opsForValue().increment(userLimitKey, 1);
 
 
             return result;
             return result;
+        }catch (CustomException e){
+            logger.error("领取红包失败原因:{}", e.getMessage());
+            if(redPacketLog != null) {
+                redPacketLog.setStatus(2);
+                redPacketLog.setErrorMsg(e.getMessage());
+            }
+            return R.error(e.getMessage());
         }catch (Exception e){
         }catch (Exception e){
             logger.error("领取红包失败原因:{}", ExceptionUtils.getMessage(e),e);
             logger.error("领取红包失败原因:{}", ExceptionUtils.getMessage(e),e);
             if(redPacketLog != null) {
             if(redPacketLog != null) {
@@ -846,6 +809,276 @@ public class FsStorePaymentServiceImpl implements IFsStorePaymentService {
         }
         }
     }
     }
 
 
+    /**
+     * 解析发红包商户配置。
+     * 销售端传入 corpId 时只走主体绑定的商户应用配置,找不到则失败,不回退全局 redPacket.config。
+     * 仅侧边栏/销售一键发红包传 corpId:回调走 v3TransferNotifyWithMchId/{mchId};
+     * 其余(看课领红包等)仍用 company_config / 全局配置里的原 notifyUrl。
+     */
+    private RedPacketConfig resolveRedPacketConfig(WxSendRedPacketParam param) {
+        if (StringUtils.isNotEmpty(param.getCorpId())) {
+            MerchantAppConfig merchant = merchantAppConfigMapper.selectWxByCorpId(param.getCorpId());
+            if (merchant == null) {
+                throw new CustomException("未找到该企微主体的商户号配置");
+            }
+            RedPacketConfig config = buildRedPacketConfigFromMerchant(merchant);
+            applySourceAppId(param, config, merchant.getAppId());
+            // 侧边栏 / 一键发红包:按商户号拼装回调
+            applyRedPacketTransferMode(config, true);
+            return config;
+        }
+        String json;
+        RedPacketConfig config;
+        switch (param.getRedPacketMode()) {
+            case 1:
+                json = configService.selectConfigByKey("redPacket.config");
+                config = JSONUtil.toBean(json, RedPacketConfig.class);
+                break;
+            case 2:
+                json = companyConfigService.selectRedPacketConfigByKey(param.getCompanyId());
+                if (StringUtils.isEmpty(json)) {
+                    json = configService.selectConfigByKey("redPacket.config");
+                }
+                config = JSONUtil.toBean(json, RedPacketConfig.class);
+                break;
+            default:
+                throw new UnsupportedOperationException("当前红包模式不支持!");
+        }
+        applySourceAppId(param, config, null);
+        // 看课等原链路:不改写为商户号回调,保持 company_config 原路径
+        applyRedPacketTransferMode(config, false);
+        return config;
+    }
+
+    /**
+     * 发红包统一走升级版商家转账单笔(transferBills,isNew=1)。
+     * 公钥成对才加载;缺任一公钥字段则清空,避免 SDK 报「请确保和publicKeyId配套使用」。
+     *
+     * @param useMchIdNotifyUrl true=侧边栏/一键发红包,按商户号拼装回调;
+     *                          false=其余场景,仅按 isNew 校正 company_config 原 notifyUrl
+     */
+    private void applyRedPacketTransferMode(RedPacketConfig config, boolean useMchIdNotifyUrl) {
+        if (config == null) {
+            return;
+        }
+        // 升级后商户号不再支持批次接口,统一走单笔 V3
+        config.setIsNew(1);
+        boolean hasPublicKey = hasWxPublicKey(config);
+        if (!hasPublicKey) {
+            // 无完整公钥时不加载公钥,走平台证书模式即可
+            config.setPublicKeyId(null);
+            config.setPublicKeyPath(null);
+        }
+        // 回调地址兜底:部分分公司只配了 notifyUrlScrm
+        if (StringUtils.isBlank(config.getNotifyUrl()) && StringUtils.isNotBlank(config.getNotifyUrlScrm())) {
+            config.setNotifyUrl(config.getNotifyUrlScrm());
+        }
+        if (useMchIdNotifyUrl) {
+            // 仅销售侧边栏/一键发红包:按商户号拼装回调
+            if (!applyMchIdNotifyUrl(config)) {
+                alignNotifyUrlByTransferMode(config);
+            }
+        } else {
+            // 其余恢复以前逻辑:保留 company_config / 全局配置中的 notifyUrl(如 WithCompanyId/{id})
+            alignNotifyUrlByTransferMode(config);
+        }
+        logger.info("红包接口模式判定 mchId={}, hasPublicKey={}, isNew={}, useMchIdNotify={}, notifyUrl={}",
+                config.getMchId(), hasPublicKey, config.getIsNew(), useMchIdNotifyUrl, config.getNotifyUrl());
+    }
+
+    /**
+     * 按 yaml 域名 + 商户号生成转账 V3 回调地址。
+     * 成功返回 true(已写入 notifyUrl);域名或商户号缺失返回 false,由调用方走旧路径改写。
+     */
+    private boolean applyMchIdNotifyUrl(RedPacketConfig config) {
+        if (config == null || StringUtils.isBlank(config.getMchId())) {
+            return false;
+        }
+        String domain = StringUtils.trim(wxTransferNotifyDomain);
+        if (StringUtils.isBlank(domain)) {
+            domain = extractHttpDomain(config.getNotifyUrl());
+        }
+        if (StringUtils.isBlank(domain)) {
+            domain = extractHttpDomain(config.getNotifyUrlScrm());
+        }
+        if (StringUtils.isBlank(domain)) {
+            logger.warn("未配置 wx.transfer.notify-domain,且原 notifyUrl 无可用域名,无法按商户号生成回调 mchId={}",
+                    config.getMchId());
+            return false;
+        }
+        while (domain.endsWith("/")) {
+            domain = domain.substring(0, domain.length() - 1);
+        }
+        String url = domain + "/app/wxpay/v3TransferNotifyWithMchId/" + config.getMchId().trim();
+        config.setNotifyUrl(url);
+        config.setNotifyUrlScrm(url);
+        return true;
+    }
+
+    /**
+     * 从完整 URL 中提取协议+主机(含端口),失败返回 null
+     */
+    private static String extractHttpDomain(String url) {
+        if (StringUtils.isBlank(url)) {
+            return null;
+        }
+        try {
+            java.net.URI uri = java.net.URI.create(url.trim());
+            if (uri.getScheme() == null || uri.getHost() == null) {
+                return null;
+            }
+            StringBuilder sb = new StringBuilder();
+            sb.append(uri.getScheme()).append("://").append(uri.getHost());
+            if (uri.getPort() > 0) {
+                sb.append(":").append(uri.getPort());
+            }
+            return sb.toString();
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    /**
+     * 是否已配置微信平台公钥(公钥模式验签所需)
+     */
+    private boolean hasWxPublicKey(RedPacketConfig config) {
+        return StringUtils.isNotBlank(config.getPublicKeyId())
+                && StringUtils.isNotBlank(config.getPublicKeyPath());
+    }
+
+    /**
+     * 按 isNew 校正回调地址(单笔 V3 ↔ 批次 TransferNotify)——仅在无法按商户号拼装时兜底
+     */
+    private void alignNotifyUrlByTransferMode(RedPacketConfig config) {
+        Integer isNew = config.getIsNew();
+        if (isNew == null) {
+            return;
+        }
+        boolean useV3Bills = isNew == 1;
+        config.setNotifyUrl(rewriteTransferNotifyUrl(config.getNotifyUrl(), useV3Bills));
+        config.setNotifyUrlScrm(rewriteTransferNotifyUrl(config.getNotifyUrlScrm(), useV3Bills));
+    }
+
+    /**
+     * 改写转账回调路径(兼容旧配置):
+     * 批次:/TransferNotify、/transferNotifyWithCompanyId/{id}
+     * 单笔V3:/v3TransferNotify、/v3TransferNotifyWithCompanyId/{id}
+     * 推荐新地址:/v3TransferNotifyWithMchId/{mchId}
+     */
+    private String rewriteTransferNotifyUrl(String url, boolean useV3Bills) {
+        if (StringUtils.isBlank(url)) {
+            return url;
+        }
+        // 已是商户号路由,不改写
+        if (url.contains("/v3TransferNotifyWithMchId/")) {
+            return url;
+        }
+        // 误配成支付回调时,无法仅靠改写修复,交给 applyMchIdNotifyUrl
+        if (url.contains("/wxPayNotify")) {
+            return url;
+        }
+        if (useV3Bills) {
+            if (url.contains("/transferNotifyWithCompanyId/") && !url.contains("/v3TransferNotifyWithCompanyId/")) {
+                url = url.replace("/transferNotifyWithCompanyId/", "/v3TransferNotifyWithCompanyId/");
+            }
+            if (url.contains("/TransferNotify") && !url.contains("/v3TransferNotify")) {
+                url = url.replace("/TransferNotify", "/v3TransferNotify");
+            }
+        } else {
+            if (url.contains("/v3TransferNotifyWithCompanyId/")) {
+                url = url.replace("/v3TransferNotifyWithCompanyId/", "/transferNotifyWithCompanyId/");
+            }
+            if (url.contains("/v3TransferNotify") && !url.contains("WithCompanyId") && !url.contains("WithMchId")) {
+                url = url.replace("/v3TransferNotify", "/TransferNotify");
+            }
+        }
+        return url;
+    }
+
+    /**
+     * 把商户应用配置的 dataJson 转成发红包所需的 RedPacketConfig
+     */
+    private RedPacketConfig buildRedPacketConfigFromMerchant(MerchantAppConfig merchant) {
+        if (StringUtils.isBlank(merchant.getDataJson())) {
+            throw new CustomException("该企微主体的商户号配置不完整");
+        }
+        FsPayConfig pay = JSONUtil.toBean(merchant.getDataJson(), FsPayConfig.class);
+        if (pay == null) {
+            throw new CustomException("该企微主体的商户号配置解析失败");
+        }
+        RedPacketConfig config = new RedPacketConfig();
+        config.setMchId(StringUtils.isNotBlank(pay.getWxMchId()) ? pay.getWxMchId() : merchant.getMerchantId());
+        config.setMchKey(pay.getWxMchKey());
+        config.setApiV3Key(pay.getWxApiV3Key());
+        config.setKeyPath(pay.getKeyPath());
+        config.setPrivateKeyPath(pay.getPrivateKeyPath());
+        config.setPrivateCertPath(pay.getPrivateCertPath());
+        // 公钥成对才加载,缺一则留给 applyRedPacketTransferMode 清空
+        if (StringUtils.isNotBlank(pay.getPublicKeyId()) && StringUtils.isNotBlank(pay.getPublicKeyPath())) {
+            config.setPublicKeyId(pay.getPublicKeyId());
+            config.setPublicKeyPath(pay.getPublicKeyPath());
+        }
+        String notify = StringUtils.isNotBlank(pay.getNotifyUrlScrm()) ? pay.getNotifyUrlScrm() : merchant.getCallbackUrl();
+        config.setNotifyUrlScrm(notify);
+        config.setNotifyUrl(notify);
+        // 统一走升级版商家转账单笔
+        config.setIsNew(1);
+        List<String> appIds = splitCsv(merchant.getAppId());
+        if (!appIds.isEmpty()) {
+            config.setMiniappId(appIds.get(0));
+            config.setAppId(appIds.get(0));
+        }
+        return config;
+    }
+
+    /**
+     * 小程序来源用小程序 appId 发;销售端按主体发时强制用入参 APPID(企微 corpId)
+     */
+    private void applySourceAppId(WxSendRedPacketParam param, RedPacketConfig config, String merchantAppIds) {
+        if (param.getSource() == null || param.getSource() != 2) {
+            return;
+        }
+        if (StringUtils.isNotEmpty(param.getCorpId()) && StringUtils.isNotBlank(param.getAppId())) {
+            config.setAppId(param.getAppId());
+            return;
+        }
+        String picked = pickWxAppId(merchantAppIds, param.getAppId(), config.getMiniappId());
+        if (StringUtils.isNotBlank(picked)) {
+            config.setAppId(picked);
+        }
+    }
+
+    /**
+     * 优先使用入参 appId(且必须在商户 app_id 列表内),否则取商户第一个 appId
+     */
+    private String pickWxAppId(String merchantAppIds, String requestAppId, String miniappId) {
+        List<String> ids = splitCsv(merchantAppIds);
+        if (ids.isEmpty()) {
+            return StringUtils.isNotBlank(requestAppId) ? requestAppId : miniappId;
+        }
+        if (StringUtils.isNotBlank(requestAppId) && ids.contains(requestAppId)) {
+            return requestAppId;
+        }
+        if (StringUtils.isNotBlank(miniappId) && ids.contains(miniappId)) {
+            return miniappId;
+        }
+        return ids.get(0);
+    }
+
+    private List<String> splitCsv(String csv) {
+        if (StringUtils.isBlank(csv)) {
+            return Collections.emptyList();
+        }
+        List<String> list = new ArrayList<String>();
+        String[] parts = csv.split(",");
+        for (int i = 0; i < parts.length; i++) {
+            if (StringUtils.isNotBlank(parts[i])) {
+                list.add(parts[i].trim());
+            }
+        }
+        return list;
+    }
+
     private static long getExpireSeconds() {
     private static long getExpireSeconds() {
         Calendar calendar = Calendar.getInstance();
         Calendar calendar = Calendar.getInstance();
         calendar.add(Calendar.DAY_OF_YEAR, 1);
         calendar.add(Calendar.DAY_OF_YEAR, 1);
@@ -1150,16 +1383,24 @@ public class FsStorePaymentServiceImpl implements IFsStorePaymentService {
             signatureHeader.setSerial(request.getHeader("Wechatpay-Serial"));
             signatureHeader.setSerial(request.getHeader("Wechatpay-Serial"));
             signatureHeader.setSignature(request.getHeader("Wechatpay-Signature"));
             signatureHeader.setSignature(request.getHeader("Wechatpay-Signature"));
             TransferBillsNotifyResult result = wxPayService.parseTransferBillsNotifyV3Result(notifyData,signatureHeader);
             TransferBillsNotifyResult result = wxPayService.parseTransferBillsNotifyV3Result(notifyData,signatureHeader);
-            logger.info("到零钱回调1:{}",result.getResult());
-            if (result.getResult().getState().equals("SUCCESS")) {
-                R r = redPacketLogService.syncRedPacket(result.getResult().getOutBillNo(),result.getResult().getTransferBillNo());
-                logger.info("result:{}",r);
+            String outBatchNo = result.getResult().getOutBillNo();
+            String batchId = result.getResult().getTransferBillNo();
+            String state = result.getResult().getState();
+            // outBillNo=红包表 out_batch_no(商户批次号);transferBillNo=batch_id(微信单号)
+            logger.info("【转账回调V3】解析成功 批次号(outBatchNo)={}, 微信单号(batchId)={}, state={}, result={}",
+                    outBatchNo, batchId, state, result.getResult());
+            if ("SUCCESS".equals(state)) {
+                R r = redPacketLogService.syncRedPacket(outBatchNo, batchId);
+                logger.info("【转账回调V3】同步红包结果 批次号={}, 微信单号={}, code={}, msg={}",
+                        outBatchNo, batchId, r.get("code"), r.get("msg"));
                 if (r.get("code").equals(200)){
                 if (r.get("code").equals(200)){
                     return WxPayNotifyResponse.success("处理成功");
                     return WxPayNotifyResponse.success("处理成功");
                 }else {
                 }else {
+                    logger.warn("【转账回调V3】同步失败 批次号={}, 微信单号={}, result={}", outBatchNo, batchId, r);
                     return WxPayNotifyResponse.fail("");
                     return WxPayNotifyResponse.fail("");
                 }
                 }
             }else {
             }else {
+                logger.warn("【转账回调V3】状态非SUCCESS 批次号={}, 微信单号={}, state={}", outBatchNo, batchId, state);
                 return WxPayNotifyResponse.fail("");
                 return WxPayNotifyResponse.fail("");
             }
             }
         } catch (WxPayException e) {
         } catch (WxPayException e) {
@@ -1191,21 +1432,23 @@ public class FsStorePaymentServiceImpl implements IFsStorePaymentService {
             signatureHeader.setSerial(request.getHeader("Wechatpay-Serial"));
             signatureHeader.setSerial(request.getHeader("Wechatpay-Serial"));
             signatureHeader.setSignature(request.getHeader("Wechatpay-Signature"));
             signatureHeader.setSignature(request.getHeader("Wechatpay-Signature"));
             TransferBillsNotifyResult result = wxPayService.parseTransferBillsNotifyV3Result(notifyData, signatureHeader);
             TransferBillsNotifyResult result = wxPayService.parseTransferBillsNotifyV3Result(notifyData, signatureHeader);
-            logger.info("【App转账回调V3】解析成功,state={}, outBillNo={}, transferBillNo={}",
-                    result.getResult().getState(),
-                    result.getResult().getOutBillNo(),
-                    result.getResult().getTransferBillNo());
-            if (result.getResult().getState().equals("SUCCESS")) {
-                R r = redPacketLogService.syncRedPacket(result.getResult().getOutBillNo(), result.getResult().getTransferBillNo());
-                logger.info("【App转账回调V3】同步红包状态结果,code={}, msg={}", r.get("code"), r.get("msg"));
+            String outBatchNo = result.getResult().getOutBillNo();
+            String batchId = result.getResult().getTransferBillNo();
+            String state = result.getResult().getState();
+            logger.info("【App转账回调V3】解析成功 批次号(outBatchNo)={}, 微信单号(batchId)={}, state={}",
+                    outBatchNo, batchId, state);
+            if ("SUCCESS".equals(state)) {
+                R r = redPacketLogService.syncRedPacket(outBatchNo, batchId);
+                logger.info("【App转账回调V3】同步红包结果 批次号={}, 微信单号={}, code={}, msg={}",
+                        outBatchNo, batchId, r.get("code"), r.get("msg"));
                 if (r.get("code").equals(200)) {
                 if (r.get("code").equals(200)) {
                     return WxPayNotifyResponse.success("处理成功");
                     return WxPayNotifyResponse.success("处理成功");
                 }
                 }
-                logger.warn("【App转账回调V3】同步失败,outBillNo={}", result.getResult().getOutBillNo());
+                logger.warn("【App转账回调V3】同步失败 批次号={}, 微信单号={}", outBatchNo, batchId);
                 return WxPayNotifyResponse.fail("");
                 return WxPayNotifyResponse.fail("");
             }
             }
-            logger.warn("【App转账回调V3】状态非SUCCESS,state={}, outBillNo={}",
-                    result.getResult().getState(), result.getResult().getOutBillNo());
+            logger.warn("【App转账回调V3】状态非SUCCESS 批次号={}, 微信单号={}, state={}",
+                    outBatchNo, batchId, state);
             return WxPayNotifyResponse.fail("");
             return WxPayNotifyResponse.fail("");
         } catch (WxPayException e) {
         } catch (WxPayException e) {
             logger.error("【App转账回调V3】处理异常,returnMsg={}, message={}", e.getReturnMsg(), e.getMessage(), e);
             logger.error("【App转账回调V3】处理异常,returnMsg={}, message={}", e.getReturnMsg(), e.getMessage(), e);
@@ -1233,21 +1476,100 @@ public class FsStorePaymentServiceImpl implements IFsStorePaymentService {
             signatureHeader.setSerial(request.getHeader("Wechatpay-Serial"));
             signatureHeader.setSerial(request.getHeader("Wechatpay-Serial"));
             signatureHeader.setSignature(request.getHeader("Wechatpay-Signature"));
             signatureHeader.setSignature(request.getHeader("Wechatpay-Signature"));
             TransferBillsNotifyResult result = wxPayService.parseTransferBillsNotifyV3Result(notifyData,signatureHeader);
             TransferBillsNotifyResult result = wxPayService.parseTransferBillsNotifyV3Result(notifyData,signatureHeader);
-            logger.info("到零钱回调1:{}",result.getResult());
-            if (result.getResult().getState().equals("SUCCESS")) {
-                R r = redPacketLogService.syncRedPacket(result.getResult().getOutBillNo(),result.getResult().getTransferBillNo());
-                logger.info("result:{}",r);
+            String outBatchNo = result.getResult().getOutBillNo();
+            String batchId = result.getResult().getTransferBillNo();
+            String state = result.getResult().getState();
+            logger.info("【转账回调V3-分公司】解析成功 companyId={}, 批次号(outBatchNo)={}, 微信单号(batchId)={}, state={}, result={}",
+                    companyId, outBatchNo, batchId, state, result.getResult());
+            if ("SUCCESS".equals(state)) {
+                R r = redPacketLogService.syncRedPacket(outBatchNo, batchId);
+                logger.info("【转账回调V3-分公司】同步红包结果 companyId={}, 批次号={}, 微信单号={}, code={}, msg={}",
+                        companyId, outBatchNo, batchId, r.get("code"), r.get("msg"));
                 if (r.get("code").equals(200)){
                 if (r.get("code").equals(200)){
                     return WxPayNotifyResponse.success("处理成功");
                     return WxPayNotifyResponse.success("处理成功");
                 }else {
                 }else {
+                    logger.warn("【转账回调V3-分公司】同步失败 companyId={}, 批次号={}, 微信单号={}, result={}",
+                            companyId, outBatchNo, batchId, r);
                     return WxPayNotifyResponse.fail("");
                     return WxPayNotifyResponse.fail("");
                 }
                 }
             }else {
             }else {
+                logger.warn("【转账回调V3-分公司】状态非SUCCESS companyId={}, 批次号={}, 微信单号={}, state={}",
+                        companyId, outBatchNo, batchId, state);
                 return WxPayNotifyResponse.fail("");
                 return WxPayNotifyResponse.fail("");
             }
             }
         } catch (WxPayException e) {
         } catch (WxPayException e) {
             e.printStackTrace();
             e.printStackTrace();
-            logger.error("zyp \n【转账回调异常】:{}", e.getReturnMsg());
+            logger.error("zyp \n【转账回调异常】 companyId={}:{}", companyId, e.getReturnMsg());
+            return WxPayNotifyResponse.fail(e.getMessage());
+        }
+    }
+
+    /**
+     * 商家转账 V3 回调:路径带商户号,从 merchant_app_config 取证书/密钥验签后同步红包状态。
+     * 推荐地址:{wx.transfer.notify-domain}/app/wxpay/v3TransferNotifyWithMchId/{mchId}
+     */
+    @Override
+    public String v3TransferNotifyWithMchId(String mchId, String notifyData, HttpServletRequest request) {
+        logger.info("【转账回调V3-商户号】mchId={}, body={}", mchId, notifyData);
+        if (StringUtils.isBlank(mchId)) {
+            return WxPayNotifyResponse.fail("mchId empty");
+        }
+        try {
+            MerchantAppConfig merchant = merchantAppConfigMapper.selectWxByMerchantId(mchId.trim());
+            RedPacketConfig config;
+            if (merchant != null) {
+                config = buildRedPacketConfigFromMerchant(merchant);
+            } else {
+                // 兜底:全局 redPacket.config 且商户号一致时仍可验签(兼容未迁入 merchant_app_config 的配置)
+                String json = configService.selectConfigByKey("redPacket.config");
+                if (StringUtils.isBlank(json)) {
+                    logger.error("【转账回调V3-商户号】未找到商户配置且无全局红包配置 mchId={}", mchId);
+                    return WxPayNotifyResponse.fail("merchant not found");
+                }
+                config = JSONUtil.toBean(json, RedPacketConfig.class);
+                if (config == null || !mchId.trim().equals(StringUtils.trim(config.getMchId()))) {
+                    logger.error("【转账回调V3-商户号】未找到商户配置 mchId={}", mchId);
+                    return WxPayNotifyResponse.fail("merchant not found");
+                }
+                logger.warn("【转账回调V3-商户号】merchant_app_config 无记录,已回退全局 redPacket.config mchId={}", mchId);
+            }
+            applyRedPacketTransferMode(config, false);
+            WxPayConfig payConfig = new WxPayConfig();
+            BeanUtils.copyProperties(config, payConfig);
+            WxPayService wxPayService = new WxPayServiceImpl();
+            wxPayService.setConfig(payConfig);
+
+            SignatureHeader signatureHeader = new SignatureHeader();
+            signatureHeader.setTimeStamp(request.getHeader("Wechatpay-Timestamp"));
+            signatureHeader.setNonce(request.getHeader("Wechatpay-Nonce"));
+            signatureHeader.setSerial(request.getHeader("Wechatpay-Serial"));
+            signatureHeader.setSignature(request.getHeader("Wechatpay-Signature"));
+            TransferBillsNotifyResult result = wxPayService.parseTransferBillsNotifyV3Result(notifyData, signatureHeader);
+            String outBatchNo = result.getResult().getOutBillNo();
+            String batchId = result.getResult().getTransferBillNo();
+            String state = result.getResult().getState();
+            logger.info("【转账回调V3-商户号】解析成功 mchId={}, 批次号(outBatchNo)={}, 微信单号(batchId)={}, state={}",
+                    mchId, outBatchNo, batchId, state);
+            if ("SUCCESS".equals(state)) {
+                R r = redPacketLogService.syncRedPacket(outBatchNo, batchId);
+                logger.info("【转账回调V3-商户号】同步红包结果 mchId={}, 批次号={}, 微信单号={}, code={}, msg={}",
+                        mchId, outBatchNo, batchId, r.get("code"), r.get("msg"));
+                if (Integer.valueOf(200).equals(r.get("code"))) {
+                    return WxPayNotifyResponse.success("处理成功");
+                }
+                logger.warn("【转账回调V3-商户号】同步失败 mchId={}, 批次号={}, 微信单号={}, result={}",
+                        mchId, outBatchNo, batchId, r);
+                return WxPayNotifyResponse.fail("");
+            }
+            logger.warn("【转账回调V3-商户号】状态非SUCCESS mchId={}, 批次号={}, 微信单号={}, state={}",
+                    mchId, outBatchNo, batchId, state);
+            return WxPayNotifyResponse.fail("");
+        } catch (WxPayException e) {
+            logger.error("【转账回调V3-商户号】处理异常 mchId={}, returnMsg={}, message={}",
+                    mchId, e.getReturnMsg(), e.getMessage(), e);
+            return WxPayNotifyResponse.fail(e.getMessage());
+        } catch (Exception e) {
+            logger.error("【转账回调V3-商户号】系统异常 mchId={}, err={}", mchId, e.getMessage(), e);
             return WxPayNotifyResponse.fail(e.getMessage());
             return WxPayNotifyResponse.fail(e.getMessage());
         }
         }
     }
     }

+ 18 - 0
fs-service/src/main/java/com/fs/his/service/impl/MerchantAppConfigServiceImpl.java

@@ -52,6 +52,8 @@ public class MerchantAppConfigServiceImpl extends ServiceImpl<MerchantAppConfigM
                 Thread.sleep(5000);
                 Thread.sleep(5000);
                 Integer count = baseMapper.checkTableExists();
                 Integer count = baseMapper.checkTableExists();
                 if (ObjectUtil.isNotNull(count)&&count>0) {
                 if (ObjectUtil.isNotNull(count)&&count>0) {
+                    // 存量表补主体配置字段
+                    ensureCorpIdsColumn();
                     log.info("异步初始化商户配置表完成");
                     log.info("异步初始化商户配置表完成");
                     return;
                     return;
                 }
                 }
@@ -79,6 +81,22 @@ public class MerchantAppConfigServiceImpl extends ServiceImpl<MerchantAppConfigM
             throw e;
             throw e;
         }
         }
     }
     }
+
+    /**
+     * 存量 merchant_app_config 补充 corp_ids 列
+     */
+    private void ensureCorpIdsColumn() {
+        try {
+            Integer colCount = baseMapper.checkCorpIdsColumnExists();
+            if (ObjectUtil.isNull(colCount) || colCount == 0) {
+                log.info("商户配置表缺少 corp_ids,开始补充...");
+                baseMapper.addCorpIdsColumn();
+                log.info("商户配置表 corp_ids 字段补充成功");
+            }
+        } catch (Exception e) {
+            log.error("补充商户配置表 corp_ids 字段失败", e);
+        }
+    }
     /**
     /**
      * 查询商户应用配置
      * 查询商户应用配置
      *
      *

+ 4 - 0
fs-service/src/main/java/com/fs/qw/domain/QwUser.java

@@ -124,4 +124,8 @@ public class QwUser extends BaseEntity
 
 
     /** 发课使用的飞书账号 */
     /** 发课使用的飞书账号 */
     private Long feishuAccountId;
     private Long feishuAccountId;
+
+    public Integer getIpadStatus() {
+        return this.ipadStatus == null ? 0 : this.ipadStatus;
+    }
 }
 }

+ 11 - 0
fs-service/src/main/java/com/fs/qw/mapper/QwUserMapper.java

@@ -502,6 +502,17 @@ public interface QwUserMapper extends BaseMapper<QwUser>
             "</script>")
             "</script>")
     List<QwUser> selectIpadStatusByIds(@Param("ids") List<Long> ids);
     List<QwUser> selectIpadStatusByIds(@Param("ids") List<Long> ids);
 
 
+    /**
+     * 按主键批量查询企微 userid(SOP.qw_user_ids 存的是 qw_user.id,营期表存的是 qw_user.qw_user_id)
+     */
+    @Select("<script>" +
+            "select id, qw_user_id from qw_user where id in " +
+            "<foreach collection='ids' item='item' open='(' separator=',' close=')'>" +
+            "#{item}" +
+            "</foreach>" +
+            "</script>")
+    List<QwUser> selectQwUserIdByPkIds(@Param("ids") List<Long> ids);
+
     List<QwUser> selectQwUserByTest();
     List<QwUser> selectQwUserByTest();
 
 
     @Select("<script>" +
     @Select("<script>" +

+ 21 - 0
fs-service/src/main/java/com/fs/qw/param/ExternalContactSendRedPacketParam.java

@@ -0,0 +1,21 @@
+package com.fs.qw.param;
+
+import lombok.Data;
+
+/**
+ * 销售端一键发红包入参(按当天完课自动匹配,不再勾选客户/选课)
+ */
+@Data
+public class ExternalContactSendRedPacketParam {
+
+    /**
+     * 范围:corp=客户管理(当前企微主体全部客户);mine=我的客户(当前企微账号下客户)
+     */
+    private String scope;
+
+    /** 企微主体 corpId */
+    private String corpId;
+
+    /** 我的客户:当前选中的企微账号主键 */
+    private Long qwUserId;
+}

+ 19 - 0
fs-service/src/main/java/com/fs/qw/param/SidebarSendRedPacketParam.java

@@ -0,0 +1,19 @@
+package com.fs.qw.param;
+
+import lombok.Data;
+
+/**
+ * 企微侧边栏:给当前会话外部联系人准备完课红包领取链接
+ */
+@Data
+public class SidebarSendRedPacketParam {
+
+    /** 企微主体 corpId */
+    private String corpId;
+
+    /** 当前员工企微 userId(字符串,非主键) */
+    private String qwUserId;
+
+    /** 当前会话外部联系人 external_userid */
+    private String externalUserId;
+}

+ 29 - 0
fs-service/src/main/java/com/fs/qw/service/IExternalContactRedPacketService.java

@@ -0,0 +1,29 @@
+package com.fs.qw.service;
+
+import com.fs.common.core.domain.R;
+import com.fs.qw.param.ExternalContactSendRedPacketParam;
+import com.fs.qw.param.SidebarSendRedPacketParam;
+
+/**
+ * 销售端外部联系人发红包
+ */
+public interface IExternalContactRedPacketService {
+
+    /**
+     * 一键发放:当天飞书完课客户(watch_type=3),按主体/我的客户范围;已发放课程自动剔除;
+     * 冷却:客户管理按主体,我的客户按企微账号,各 1 小时
+     *
+     * @param param         范围与主体/企微账号
+     * @param companyId     登录公司
+     * @param companyUserId 登录销售
+     */
+    R sendRedPacketToExternalContacts(ExternalContactSendRedPacketParam param,
+                                      Long companyId,
+                                      Long companyUserId);
+
+    /**
+     * 侧边栏:按外部联系人 + 员工企微号;完课判定为今天+该销售(company_user_id)+该客户;
+     * 返回确认收款链接(与一键发红包同链)
+     */
+    R prepareSidebarRedPacketLink(SidebarSendRedPacketParam param);
+}

+ 1139 - 0
fs-service/src/main/java/com/fs/qw/service/impl/ExternalContactRedPacketServiceImpl.java

@@ -0,0 +1,1139 @@
+package com.fs.qw.service.impl;
+
+import cn.hutool.json.JSONUtil;
+import com.alibaba.fastjson.JSON;
+import com.fs.common.constant.FsConstants;
+import com.fs.common.core.domain.R;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.utils.StringUtils;
+import com.fs.company.service.ICompanyService;
+import com.fs.course.config.CourseConfig;
+import com.fs.course.domain.BalanceRollbackError;
+import com.fs.course.domain.FsCourseRedPacketLog;
+import com.fs.course.domain.FsCourseWatchLog;
+import com.fs.course.domain.FsUserCourse;
+import com.fs.course.domain.FsUserCourseVideo;
+import com.fs.course.domain.FsUserCourseVideoRedPackage;
+import com.fs.course.mapper.BalanceRollbackErrorMapper;
+import com.fs.course.mapper.FsCourseRedPacketLogMapper;
+import com.fs.course.mapper.FsCourseWatchLogMapper;
+import com.fs.course.mapper.FsUserCourseMapper;
+import com.fs.course.mapper.FsUserCourseVideoMapper;
+import com.fs.course.mapper.FsUserCourseVideoRedPackageMapper;
+import com.fs.course.service.ICourseRandomRedPacketService;
+import com.fs.course.vo.TodayFinishWatchForRedPacketVO;
+import com.fs.his.domain.FsUser;
+import com.fs.his.mapper.FsUserMapper;
+import com.fs.his.param.WxSendRedPacketParam;
+import com.fs.his.service.IFsStorePaymentService;
+import com.fs.qw.domain.QwExternalContact;
+import com.fs.qw.domain.QwUser;
+import com.fs.qw.mapper.QwExternalContactMapper;
+import com.fs.qw.mapper.QwUserMapper;
+import com.fs.qw.param.ExternalContactSendRedPacketParam;
+import com.fs.qw.param.SidebarSendRedPacketParam;
+import com.fs.qw.service.IExternalContactRedPacketService;
+import com.fs.qw.vo.QwSopCourseFinishTempSetting;
+import com.fs.qwApi.Result.QwOpenidResult;
+import com.fs.qwApi.param.QwOpenidByExternalcontactParams;
+import com.fs.qwApi.service.QwApiService;
+import com.fs.sop.domain.QwSopLogs;
+import com.fs.sop.service.IQwSopLogsService;
+import com.fs.system.service.ISysConfigService;
+import com.fs.voice.utils.StringUtil;
+import com.github.binarywang.wxpay.bean.transfer.TransferBillsResult;
+import lombok.extern.slf4j.Slf4j;
+import org.redisson.api.RLock;
+import org.redisson.api.RedissonClient;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.text.SimpleDateFormat;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 销售端一键发红包:当天完课、仅飞书看课(watch_type=3)、按主体/我的客户范围、课程去重;
+ * 冷却:客户管理按「销售账号+主体」,我的客户按「销售账号+企微账号」,各 1 小时。
+ * 不套外层事务,避免嵌套 rollback-only。
+ */
+@Slf4j
+@Service
+public class ExternalContactRedPacketServiceImpl implements IExternalContactRedPacketService {
+
+    private static final String SCOPE_CORP = "corp";
+    private static final String SCOPE_MINE = "mine";
+    /** 冷却 Redis key 前缀,完整 key = 前缀 + companyUserId + ":" + corpId */
+    private static final String COOLDOWN_KEY_PREFIX = "sales:redpacket:cooldown:";
+    /** 防重复点击锁前缀,完整 key = 前缀 + companyUserId + ":" + corpId */
+    private static final String CLICK_LOCK_PREFIX = "sales:redpacket:click:";
+    private static final int COOLDOWN_HOURS = 1;
+
+    @Autowired
+    private QwExternalContactMapper qwExternalContactMapper;
+    @Autowired
+    private QwUserMapper qwUserMapper;
+    @Autowired
+    private FsUserMapper fsUserMapper;
+    @Autowired
+    private FsUserCourseVideoMapper fsUserCourseVideoMapper;
+    @Autowired
+    private FsUserCourseMapper fsUserCourseMapper;
+    @Autowired
+    private FsUserCourseVideoRedPackageMapper fsUserCourseVideoRedPackageMapper;
+    @Autowired
+    private FsCourseRedPacketLogMapper redPacketLogMapper;
+    @Autowired
+    private FsCourseWatchLogMapper fsCourseWatchLogMapper;
+    @Autowired
+    private BalanceRollbackErrorMapper balanceRollbackErrorMapper;
+    @Autowired
+    private ICourseRandomRedPacketService courseRandomRedPacketService;
+    @Autowired
+    private IFsStorePaymentService paymentService;
+    @Autowired
+    private ICompanyService companyService;
+    @Autowired
+    private ISysConfigService configService;
+    @Autowired
+    private QwApiService qwApiService;
+    @Autowired
+    private RedisCache redisCache;
+    @Autowired
+    private RedissonClient redissonClient;
+    @Autowired
+    private IQwSopLogsService qwSopLogsService;
+
+    /** 确认收款链接待发送优先级(越大越靠前) */
+    private static final int CONFIRM_RECEIVE_SORT = 9999999;
+    /** sendType=3 完课发送,走 pad 现有校验(约 12 小时有效) */
+    private static final int CONFIRM_RECEIVE_SEND_TYPE = 3;
+
+    @Override
+    public R sendRedPacketToExternalContacts(ExternalContactSendRedPacketParam param,
+                                             Long companyId,
+                                             Long companyUserId) {
+        if (companyId == null || companyUserId == null) {
+            return R.error("公司或账号信息缺失");
+        }
+        if (param == null || StringUtil.strIsNullOrEmpty(param.getScope())) {
+            return R.error("发放范围缺失");
+        }
+        if (StringUtil.strIsNullOrEmpty(param.getCorpId())) {
+            return R.error("请先选择企微主体");
+        }
+
+        String scope = param.getScope();
+        Long filterQwUserId = null;
+        if (SCOPE_MINE.equals(scope)) {
+            if (param.getQwUserId() == null) {
+                return R.error("请先选择企微账号");
+            }
+            QwUser qwUser = qwUserMapper.selectQwUserById(param.getQwUserId());
+            if (qwUser == null) {
+                return R.error("企微账号不存在");
+            }
+            if (!companyId.equals(qwUser.getCompanyId())
+                    || qwUser.getCompanyUserId() == null
+                    || !companyUserId.equals(qwUser.getCompanyUserId())) {
+                return R.error("无权操作该企微账号下的客户");
+            }
+            if (StringUtils.isNotEmpty(qwUser.getCorpId())
+                    && !param.getCorpId().equals(qwUser.getCorpId())) {
+                return R.error("企微账号与主体不匹配");
+            }
+            filterQwUserId = param.getQwUserId();
+        } else if (!SCOPE_CORP.equals(scope)) {
+            return R.error("发放范围无效");
+        }
+
+        String json = configService.selectConfigByKey("course.config");
+        CourseConfig config = JSONUtil.toBean(json, CourseConfig.class);
+        if (config == null) {
+            return R.error("课程配置为空,请联系管理员");
+        }
+
+        // 冷却维度:客户管理按主体;我的客户按企微账号(与筛选/查询一致)
+        String cooldownDim = SCOPE_MINE.equals(scope)
+                ? ("qw:" + filterQwUserId)
+                : ("corp:" + param.getCorpId());
+        RLock clickLock = redissonClient.getLock(buildClickLockKey(companyUserId, cooldownDim));
+        boolean lockAcquired = false;
+        try {
+            if (!clickLock.tryLock(3, 30, TimeUnit.SECONDS)) {
+                return R.error("正在发放中,请勿重复点击");
+            }
+            lockAcquired = true;
+
+            String cooldownKey = buildCooldownKey(companyUserId, cooldownDim);
+            Long expireAt = parseExpireAt(redisCache.getCacheObject(cooldownKey));
+            long now = System.currentTimeMillis();
+            if (expireAt != null && expireAt > now) {
+                long remainMin = (expireAt - now + 59999L) / 60000L;
+                String coolMsg = SCOPE_MINE.equals(scope)
+                        ? ("当前企微账号发红包冷却中,请" + remainMin + "分钟后再试")
+                        : ("当前主体发红包冷却中,请" + remainMin + "分钟后再试");
+                return R.error(coolMsg)
+                        .put("cooldownRemainSeconds", (expireAt - now) / 1000);
+            }
+
+            LocalDate today = LocalDate.now();
+            Date startTime = Date.from(today.atStartOfDay(ZoneId.systemDefault()).toInstant());
+            Date endTime = Date.from(today.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant());
+            List<TodayFinishWatchForRedPacketVO> finishList =
+                    fsCourseWatchLogMapper.selectTodayFinishWatchForRedPacket(
+                            companyId, param.getCorpId(), filterQwUserId, startTime, endTime);
+            if (finishList == null || finishList.isEmpty()) {
+                return R.ok("成功0人,跳过0人,失败0人;当天没有符合条件的飞书完课客户")
+                        .put("success", 0).put("skip", 0).put("fail", 0)
+                        .put("cooldownRemainSeconds", 0);
+            }
+
+            // 同一客户 + 同一课程 + 同一小节 + 同一主体只取最近一条完课记录
+            Map<String, TodayFinishWatchForRedPacketVO> unique = new LinkedHashMap<>();
+            for (TodayFinishWatchForRedPacketVO row : finishList) {
+                if (row.getUserId() == null || row.getCourseId() == null || row.getVideoId() == null) {
+                    continue;
+                }
+                String key = row.getUserId() + "_" + row.getCourseId() + "_" + row.getVideoId() + "_" + param.getCorpId();
+                if (!unique.containsKey(key)) {
+                    unique.put(key, row);
+                }
+            }
+
+            Set<String> sentKeys = loadSentKeys(companyId, new ArrayList<>(unique.values()), startTime, endTime);
+
+            log.info("[销售一键发红包] 开始处理 companyId={}, companyUserId={}, corpId={}, 待处理人数={}",
+                    companyId, companyUserId, param.getCorpId(), unique.size());
+
+            int success = 0;
+            int skip = 0;
+            int fail = 0;
+            List<String> failDetails = new ArrayList<>();
+            for (TodayFinishWatchForRedPacketVO row : unique.values()) {
+                Long contactId = row.getQwExternalContactId();
+                String displayName = StringUtils.isNotEmpty(row.getContactName())
+                        ? row.getContactName() : ("客户#" + contactId);
+                // 已发放判定:用户 + 课程 + 小节(仅限今天,历史发放不拦截)
+                String sentKey = buildSentKey(row.getUserId(), row.getCourseId(), row.getVideoId());
+                if (sentKeys.contains(sentKey)) {
+                    skip++;
+                    logRedPacketDecision(contactId, null, displayName, row, "跳过", "今日已发放", null);
+                    continue;
+                }
+                try {
+                    QwExternalContact contact = qwExternalContactMapper.selectQwExternalContactById(contactId);
+                    if (contact == null) {
+                        fail++;
+                        String reason = "客户不存在";
+                        failDetails.add(displayName + ":" + reason);
+                        logRedPacketDecision(contactId, null, displayName, row, "失败", reason, null);
+                        continue;
+                    }
+                    if (contact.getCompanyId() == null || !companyId.equals(contact.getCompanyId())) {
+                        fail++;
+                        String reason = "无权操作";
+                        failDetails.add(displayName + ":" + reason);
+                        logRedPacketDecision(contactId, contact.getExternalUserId(), displayName, row, "失败", reason, null);
+                        continue;
+                    }
+                    FsUser user = fsUserMapper.selectFsUserByUserId(row.getUserId());
+                    if (user == null) {
+                        fail++;
+                        String reason = "会员不存在";
+                        failDetails.add(displayName + ":" + reason);
+                        logRedPacketDecision(contactId, contact.getExternalUserId(), displayName, row, "失败", reason, null);
+                        continue;
+                    }
+
+                    FsUserCourseVideo video = fsUserCourseVideoMapper.selectFsUserCourseVideoByVideoId(row.getVideoId());
+                    BigDecimal amount = resolveAmount(row.getVideoId(), companyId, row.getCourseId(), video);
+                    if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
+                        fail++;
+                        String reason = "红包金额为0";
+                        failDetails.add(displayName + ":" + reason);
+                        logRedPacketDecision(contactId, contact.getExternalUserId(), displayName, row, "失败", reason, null);
+                        continue;
+                    }
+
+                    String openId = resolveOpenId(contact);
+                    if (StringUtil.strIsNullOrEmpty(openId)) {
+                        fail++;
+                        String eid = contact.getExternalUserId();
+                        String reason = (StringUtils.isNotEmpty(eid) && eid.startsWith("wo"))
+                                ? "企微客户不支持转openid" : "openid转换失败";
+                        failDetails.add(displayName + ":" + reason);
+                        logRedPacketDecision(contactId, eid, displayName, row, "失败", reason, null);
+                        continue;
+                    }
+
+                    WxSendRedPacketParam packetParam = new WxSendRedPacketParam();
+                    packetParam.setOpenId(openId);
+                    packetParam.setAmount(amount);
+                    packetParam.setCompanyId(companyId);
+                    packetParam.setSource(2);
+                    packetParam.setRedPacketMode(config.getRedPacketMode());
+                    packetParam.setUser(user);
+                    String corpId = StringUtils.isNotEmpty(contact.getCorpId())
+                            ? contact.getCorpId() : param.getCorpId();
+                    packetParam.setCorpId(corpId);
+                    // 销售端发红包 APPID 传企微主体 corpId
+                    packetParam.setAppId(corpId);
+
+                    R sendResult = sendOneWithDeduction(packetParam, amount, config,
+                            companyId, companyUserId, contact, row.getCourseId(),
+                            row.getVideoId(), row.getPeriodId(), row.getLogId());
+                    if (sendResult != null && Integer.valueOf(200).equals(sendResult.get("code"))) {
+                        success++;
+                        sentKeys.add(sentKey);
+                        logRedPacketDecision(contactId, contact.getExternalUserId(), displayName, row,
+                                "发放", "发送成功,金额=" + amount, sendResult);
+                    } else {
+                        fail++;
+                        String reason = resolveFailMsg(sendResult);
+                        if (reason == null) {
+                            reason = "发放失败";
+                        }
+                        failDetails.add(displayName + ":" + reason);
+                        logRedPacketDecision(contactId, contact.getExternalUserId(), displayName, row,
+                                "失败", reason, sendResult);
+                    }
+                } catch (Exception e) {
+                    fail++;
+                    String reason = e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName();
+                    log.error("[销售一键发红包] 异常 contactId={}, externalUserId={}, userId={}, courseId={}, videoId={}, 原因={}",
+                            contactId, null, row.getUserId(), row.getCourseId(), row.getVideoId(), reason, e);
+                    failDetails.add(displayName + ":" + reason);
+                    logRedPacketDecision(contactId, null, displayName, row, "失败", reason, null);
+                }
+            }
+
+            log.info("[销售一键发红包] 处理完成 companyId={}, companyUserId={}, corpId={}, 成功={}, 跳过={}, 失败={}",
+                    companyId, companyUserId, param.getCorpId(), success, skip, fail);
+
+            StringBuilder msg = new StringBuilder();
+            msg.append("成功").append(success).append("人,跳过(今日已发放)").append(skip)
+                    .append("人,失败").append(fail).append("人");
+            if (!failDetails.isEmpty()) {
+                int show = Math.min(failDetails.size(), 20);
+                msg.append(";失败明细:").append(String.join(";", failDetails.subList(0, show)));
+                if (failDetails.size() > show) {
+                    msg.append("…");
+                }
+            }
+            int cooldownRemainSeconds = 0;
+            if (success > 0) {
+                long newExpireAt = now + TimeUnit.HOURS.toMillis(COOLDOWN_HOURS);
+                redisCache.setCacheObject(cooldownKey, newExpireAt, COOLDOWN_HOURS * 3600, TimeUnit.SECONDS);
+                cooldownRemainSeconds = COOLDOWN_HOURS * 3600;
+            }
+            return R.ok(msg.toString())
+                    .put("success", success)
+                    .put("skip", skip)
+                    .put("fail", fail)
+                    .put("failDetails", failDetails)
+                    .put("cooldownRemainSeconds", cooldownRemainSeconds);
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            return R.error("系统繁忙,请稍后重试");
+        } finally {
+            if (lockAcquired && clickLock.isHeldByCurrentThread()) {
+                try {
+                    clickLock.unlock();
+                } catch (IllegalMonitorStateException ignored) {
+                    // ignore
+                }
+            }
+        }
+    }
+
+    private Set<String> loadSentKeys(Long companyId, List<TodayFinishWatchForRedPacketVO> rows,
+                                     Date startTime, Date endTime) {
+        Set<String> sentKeys = new HashSet<>();
+        List<Long> userIds = new ArrayList<>();
+        for (TodayFinishWatchForRedPacketVO row : rows) {
+            if (row.getUserId() != null && !userIds.contains(row.getUserId())) {
+                userIds.add(row.getUserId());
+            }
+        }
+        if (userIds.isEmpty()) {
+            return sentKeys;
+        }
+        // 只查今天已发记录,历史发放不计入跳过
+        List<FsCourseRedPacketLog> sentList = redPacketLogMapper.selectSentUserCourseByUserIds(
+                companyId, userIds, startTime, endTime);
+        if (sentList == null) {
+            return sentKeys;
+        }
+        for (FsCourseRedPacketLog log : sentList) {
+            if (log.getUserId() != null && log.getCourseId() != null && log.getVideoId() != null) {
+                sentKeys.add(buildSentKey(log.getUserId(), log.getCourseId(), log.getVideoId()));
+            }
+        }
+        return sentKeys;
+    }
+
+    /** 已发放去重键:用户 + 课程 + 小节 */
+    private static String buildSentKey(Long userId, Long courseId, Long videoId) {
+        return userId + "_" + courseId + "_" + videoId;
+    }
+
+    /**
+     * 打印单个企微客户发红包决策日志:客户ID、发/不发、是否报错及原因
+     */
+    private void logRedPacketDecision(Long contactId, String externalUserId, String displayName,
+                                      TodayFinishWatchForRedPacketVO row, String decision, String reason, R sendResult) {
+        Object wxCode = sendResult != null ? sendResult.get("code") : null;
+        Object wxMsg = sendResult != null ? sendResult.get("msg") : null;
+        log.info("[销售一键发红包-明细] contactId={}, externalUserId={}, 客户名={}, userId={}, courseId={}, videoId={}, "
+                        + "结果={}, 原因={}, wxCode={}, wxMsg={}",
+                contactId,
+                externalUserId,
+                displayName,
+                row != null ? row.getUserId() : null,
+                row != null ? row.getCourseId() : null,
+                row != null ? row.getVideoId() : null,
+                decision,
+                reason,
+                wxCode,
+                wxMsg);
+    }
+
+    private static Long parseExpireAt(Object cached) {
+        if (cached == null) {
+            return null;
+        }
+        if (cached instanceof Number) {
+            return ((Number) cached).longValue();
+        }
+        try {
+            return Long.parseLong(cached.toString());
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+
+    /**
+     * 冷却 key:销售账号 + 维度(corp:{corpId} 或 qw:{qwUserId})
+     */
+    private static String buildCooldownKey(Long companyUserId, String dimension) {
+        return COOLDOWN_KEY_PREFIX + companyUserId + ":" + dimension;
+    }
+
+    /**
+     * 点击锁 key:与冷却同一维度
+     */
+    private static String buildClickLockKey(Long companyUserId, String dimension) {
+        return CLICK_LOCK_PREFIX + companyUserId + ":" + dimension;
+    }
+
+    /**
+     * 与领取红包一致:公司配置优先,否则小节金额 + 随机规则
+     */
+    private BigDecimal resolveAmount(Long videoId, Long companyId, Long courseId, FsUserCourseVideo video) {
+        BigDecimal amount = BigDecimal.ZERO;
+        FsUserCourseVideoRedPackage redPackage =
+                fsUserCourseVideoRedPackageMapper.selectRedPacketByCompanyIdZd(videoId, companyId);
+        if (redPackage != null && redPackage.getRedPacketMoney() != null) {
+            amount = redPackage.getRedPacketMoney();
+        } else if (video != null) {
+            amount = video.getRedPacketMoney() != null ? video.getRedPacketMoney() : BigDecimal.ZERO;
+            amount = courseRandomRedPacketService.resolveAmount(amount, courseId, companyId, video, true);
+        }
+        return amount;
+    }
+
+    /**
+     * 解析发红包所需 openid:
+     * 1)库中 qw_external_contact.open_id 已有则直接用;
+     * 2)否则调企微 externalContactConvertToOpenid,成功后回写 open_id。
+     * 说明:暂不支持企业微信外部联系人(external_userid 以 wo 开头)。
+     */
+    private String resolveOpenId(QwExternalContact contact) {
+        if (StringUtils.isNotEmpty(contact.getOpenId())) {
+            return contact.getOpenId();
+        }
+        if (StringUtil.strIsNullOrEmpty(contact.getExternalUserId())
+                || StringUtil.strIsNullOrEmpty(contact.getCorpId())) {
+            return null;
+        }
+        if (contact.getExternalUserId().startsWith("wo")) {
+            log.warn("企微外部联系人不支持转openid, id={}, externalUserId={}",
+                    contact.getId(), contact.getExternalUserId());
+            return null;
+        }
+        try {
+            QwOpenidByExternalcontactParams params = new QwOpenidByExternalcontactParams();
+            params.setExternal_userid(contact.getExternalUserId());
+            QwOpenidResult result = qwApiService.externalcontactToOpenid(params, contact.getCorpId());
+            if (result == null || result.getErrCode() != 0 || StringUtil.strIsNullOrEmpty(result.getOpenid())) {
+                log.error("外部联系人转openid失败, id={}, externalUserId={}, result={}",
+                        contact.getId(), contact.getExternalUserId(), JSON.toJSONString(result));
+                return null;
+            }
+            contact.setOpenId(result.getOpenid());
+            QwExternalContact update = new QwExternalContact();
+            update.setId(contact.getId());
+            update.setOpenId(result.getOpenid());
+            qwExternalContactMapper.updateQwExternalContact(update);
+            return result.getOpenid();
+        } catch (Exception e) {
+            log.error("外部联系人转openid异常, id={}, externalUserId={}",
+                    contact.getId(), contact.getExternalUserId(), e);
+            return null;
+        }
+    }
+
+    /**
+     * 对齐 sendRedPacketRewardToUser:可选 Redis 预扣 → sendRedPacket → 写流水 / 回滚
+     */
+    private R sendOneWithDeduction(WxSendRedPacketParam packetParam,
+                                   BigDecimal amount,
+                                   CourseConfig config,
+                                   Long companyId,
+                                   Long companyUserId,
+                                   QwExternalContact contact,
+                                   Long courseId,
+                                   Long videoId,
+                                   Long periodId,
+                                   Long watchLogId) {
+        if ("1".equals(config.getIsRedPackageBalanceDeduction())) {
+            BalanceRollbackError balanceRollbackError = new BalanceRollbackError();
+            balanceRollbackError.setCompanyId(companyId);
+            balanceRollbackError.setUserId(contact.getFsUserId() != null ? contact.getFsUserId() : packetParam.getUser().getUserId());
+            balanceRollbackError.setLogId(watchLogId);
+            balanceRollbackError.setVideoId(videoId);
+            balanceRollbackError.setStatus(0);
+            balanceRollbackError.setMoney(amount);
+
+            String companyMoneyKey = FsConstants.COMPANY_MONEY_KEY + companyId;
+            RLock lock1 = redissonClient.getLock(FsConstants.COMPANY_MONEY_LOCK + companyId);
+            boolean lockAcquired = false;
+            BigDecimal newMoney;
+            try {
+                if (lock1.tryLock(3, 10, TimeUnit.SECONDS)) {
+                    lockAcquired = true;
+                    String moneyStr = redisCache.getCacheObject(companyMoneyKey);
+                    if (StringUtils.isEmpty(moneyStr)) {
+                        return R.error("系统异常,请稍后重试");
+                    }
+                    BigDecimal originalMoney = new BigDecimal(moneyStr);
+                    if (originalMoney.compareTo(new BigDecimal("0.3")) < 0) {
+                        return R.error("服务商余额不足,请联系群主服务器充值!");
+                    }
+                    newMoney = originalMoney.subtract(amount);
+                    redisCache.setCacheObject(companyMoneyKey, newMoney.toString());
+                } else {
+                    return R.error("系统繁忙,请稍后重试");
+                }
+            } catch (Exception e) {
+                log.error("销售发红包预扣失败, companyId={}", companyId, e);
+                return R.error("系统异常,请稍后重试");
+            } finally {
+                if (lockAcquired && lock1.isHeldByCurrentThread()) {
+                    try {
+                        lock1.unlock();
+                    } catch (IllegalMonitorStateException ignored) {
+                        // ignore
+                    }
+                }
+            }
+
+            R sendRedPacket;
+            try {
+                sendRedPacket = paymentService.sendRedPacket(packetParam);
+            } catch (Exception e) {
+                log.error("销售发红包异常, contactId={}", contact.getId(), e);
+                rollbackBalance(balanceRollbackError);
+                return R.error("奖励发送失败:" + e.getMessage());
+            }
+
+            if (Integer.valueOf(200).equals(sendRedPacket.get("code"))) {
+                Long fsUserId = packetParam.getUser() != null ? packetParam.getUser().getUserId() : contact.getFsUserId();
+                insertRedPacketLog(sendRedPacket, companyId, companyUserId, contact, fsUserId,
+                        courseId, videoId, periodId, amount, watchLogId, packetParam.getAppId(), config);
+                // 待用户确认收款时,写入 qw_sop_logs 待发送(高优先级链接卡片)
+                tryEnqueueConfirmReceiveSopLog(sendRedPacket, packetParam, contact, config, courseId);
+                BigDecimal money = amount.multiply(BigDecimal.valueOf(-1));
+                companyService.asyncRecordBalanceLog(companyId, money, 15, newMoney, "销售发放红包", fsUserId);
+                return sendRedPacket;
+            }
+            rollbackBalance(balanceRollbackError);
+            return R.error(resolveFailMsg(sendRedPacket));
+        }
+
+        try {
+            R sendRedPacket = paymentService.sendRedPacket(packetParam);
+            if (Integer.valueOf(200).equals(sendRedPacket.get("code"))) {
+                Long fsUserId = packetParam.getUser() != null ? packetParam.getUser().getUserId() : contact.getFsUserId();
+                insertRedPacketLog(sendRedPacket, companyId, companyUserId, contact, fsUserId,
+                        courseId, videoId, periodId, amount, watchLogId, packetParam.getAppId(), config);
+                tryEnqueueConfirmReceiveSopLog(sendRedPacket, packetParam, contact, config, courseId);
+                return sendRedPacket;
+            }
+            return R.error(resolveFailMsg(sendRedPacket));
+        } catch (Exception e) {
+            log.error("销售发红包异常(无预扣), contactId={}", contact.getId(), e);
+            return R.error("奖励发送失败:" + e.getMessage());
+        }
+    }
+
+    /**
+     * 写入课程领取红包流水,并回写看课记录奖励类型(对齐用户自领)
+     */
+    private void insertRedPacketLog(R sendRedPacket,
+                                    Long companyId,
+                                    Long companyUserId,
+                                    QwExternalContact contact,
+                                    Long fsUserId,
+                                    Long courseId,
+                                    Long videoId,
+                                    Long periodId,
+                                    BigDecimal amount,
+                                    Long watchLogId,
+                                    String appId,
+                                    CourseConfig config) {
+        FsCourseRedPacketLog redPacketLog = new FsCourseRedPacketLog();
+        try {
+            if (sendRedPacket.get("isNew") != null && Integer.valueOf(1).equals(sendRedPacket.get("isNew"))) {
+                TransferBillsResult transferBillsResult = (TransferBillsResult) sendRedPacket.get("data");
+                redPacketLog.setResult(JSON.toJSONString(sendRedPacket));
+                if (transferBillsResult != null) {
+                    redPacketLog.setOutBatchNo(transferBillsResult.getOutBillNo());
+                }
+            } else if (sendRedPacket.get("orderCode") != null) {
+                redPacketLog.setOutBatchNo(sendRedPacket.get("orderCode").toString());
+            }
+            if (sendRedPacket.get("mchId") != null) {
+                redPacketLog.setMchId(sendRedPacket.get("mchId").toString());
+            }
+        } catch (Exception e) {
+            log.warn("解析发红包结果写入流水异常: {}", e.getMessage());
+            redPacketLog.setResult(JSON.toJSONString(sendRedPacket));
+        }
+        redPacketLog.setCourseId(courseId);
+        redPacketLog.setCompanyId(companyId);
+        redPacketLog.setUserId(fsUserId);
+        redPacketLog.setVideoId(videoId);
+        redPacketLog.setStatus(0);
+        redPacketLog.setQwUserId(contact.getQwUserId() != null ? String.valueOf(contact.getQwUserId()) : null);
+        redPacketLog.setCompanyUserId(companyUserId);
+        redPacketLog.setCreateTime(new Date());
+        redPacketLog.setAmount(amount);
+        redPacketLog.setPeriodId(periodId);
+        redPacketLog.setWatchLogId(watchLogId);
+        redPacketLog.setAppId(appId);
+        redPacketLogMapper.insertFsCourseRedPacketLog(redPacketLog);
+
+        // 更新看课记录奖励类型,便于领取红包记录关联展示与防重复领取
+        if (watchLogId != null && config != null && config.getRewardType() != null) {
+            FsCourseWatchLog watchLog = new FsCourseWatchLog();
+            watchLog.setLogId(watchLogId);
+            watchLog.setRewardType(config.getRewardType());
+            fsCourseWatchLogMapper.updateFsCourseWatchLog(watchLog);
+        }
+    }
+
+    /**
+     * 商家转账待用户确认时:拼 confirmReceive.html 链接,写入 qw_sop_logs 待发送(链接卡片)。
+     * 标题:课程已完成;描述:点击领取专属奖励;封面:课程 imgUrl。
+     * sendType=3(完课,约 12 小时有效);sort=9999999 高优先级。
+     * 域名与飞书 NEW 一致,取 course.config.realLinkDomainName。
+     * 入库失败不影响红包主流程成功。
+     */
+    private void tryEnqueueConfirmReceiveSopLog(R sendRedPacket,
+                                                WxSendRedPacketParam packetParam,
+                                                QwExternalContact contact,
+                                                CourseConfig config,
+                                                Long courseId) {
+        Long contactId = contact != null ? contact.getId() : null;
+        String externalUserId = contact != null ? contact.getExternalUserId() : null;
+        try {
+            String state = extractTransferState(sendRedPacket);
+            String packageInfo = extractPackageInfo(sendRedPacket);
+            String mchId = sendRedPacket.get("mchId") != null ? String.valueOf(sendRedPacket.get("mchId")) : null;
+            String appId = packetParam != null ? packetParam.getAppId() : null;
+            String outBillNo = extractOutBillNo(sendRedPacket);
+
+            log.info("[销售发红包-确认收款] 开始处理 contactId={}, externalUserId={}, outBillNo={}, state={}, mchId={}, appId={}, courseId={}, packageEmpty={}",
+                    contactId, externalUserId, outBillNo, state, mchId, appId, courseId, StringUtil.strIsNullOrEmpty(packageInfo));
+
+            if (!"WAIT_USER_CONFIRM".equals(state)) {
+                log.info("[销售发红包-确认收款] 非待确认状态,跳过写入待发送 contactId={}, state={}", contactId, state);
+                return;
+            }
+            if (StringUtil.strIsNullOrEmpty(packageInfo)) {
+                log.warn("[销售发红包-确认收款] package_info 为空,无法生成链接 contactId={}, outBillNo={}", contactId, outBillNo);
+                return;
+            }
+            if (StringUtil.strIsNullOrEmpty(mchId) || StringUtil.strIsNullOrEmpty(appId)) {
+                log.warn("[销售发红包-确认收款] mchId/appId 缺失,无法生成链接 contactId={}, mchId={}, appId={}", contactId, mchId, appId);
+                return;
+            }
+            if (config == null || StringUtil.strIsNullOrEmpty(config.getRealLinkDomainName())) {
+                log.error("[销售发红包-确认收款] 未配置通用看课域名(realLinkDomainName),无法生成链接 contactId={}", contactId);
+                return;
+            }
+            if (contact == null || contact.getQwUserId() == null) {
+                log.warn("[销售发红包-确认收款] 客户缺少企微账号 qwUserId,无法写入待发送 contactId={}", contactId);
+                return;
+            }
+            if (StringUtil.strIsNullOrEmpty(externalUserId)) {
+                log.warn("[销售发红包-确认收款] 客户 externalUserId 为空,无法写入待发送 contactId={}", contactId);
+                return;
+            }
+
+            QwUser qwUser = qwUserMapper.selectQwUserById(contact.getQwUserId());
+            if (qwUser == null) {
+                log.warn("[销售发红包-确认收款] 企微账号不存在 contactId={}, qwUserId={}", contactId, contact.getQwUserId());
+                return;
+            }
+            if (StringUtil.strIsNullOrEmpty(qwUser.getQwUserId())) {
+                log.warn("[销售发红包-确认收款] 企微账号 qwUserId 为空 contactId={}, qwUserPk={}", contactId, qwUser.getId());
+                return;
+            }
+
+            String confirmUrl = buildConfirmReceiveUrl(config.getRealLinkDomainName(), mchId, appId, packageInfo);
+            String courseCover = resolveCourseCoverUrl(courseId);
+            log.info("[销售发红包-确认收款] 链接已生成,准备写入 qw_sop_logs 链接卡片 contactId={}, qwUserKey={}, outBillNo={}, courseId={}, coverEmpty={}, url={}",
+                    contactId, qwUser.getId(), outBillNo, courseId, StringUtil.strIsNullOrEmpty(courseCover), confirmUrl);
+
+            // contentType=3:链接卡片(pad 走 sendLink)
+            QwSopCourseFinishTempSetting.Setting linkSetting = new QwSopCourseFinishTempSetting.Setting();
+            linkSetting.setContentType("3");
+            linkSetting.setLinkTitle("课程已完成");
+            linkSetting.setLinkDescribe("点击领取专属奖励");
+            linkSetting.setLinkUrl(confirmUrl);
+            linkSetting.setLinkImageUrl(courseCover);
+            List<QwSopCourseFinishTempSetting.Setting> settingList = new ArrayList<>();
+            settingList.add(linkSetting);
+
+            QwSopCourseFinishTempSetting settingRoot = new QwSopCourseFinishTempSetting();
+            settingRoot.setType(0);
+            settingRoot.setCourseType(0);
+            settingRoot.setSetting(settingList);
+
+            SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
+            QwSopLogs sopLogs = new QwSopLogs();
+            sopLogs.setQwUserid(qwUser.getQwUserId());
+            sopLogs.setQwUserKey(qwUser.getId());
+            sopLogs.setExternalUserId(externalUserId);
+            sopLogs.setExternalId(contact.getId());
+            sopLogs.setExternalUserName(contact.getName());
+            sopLogs.setLogType(2);
+            sopLogs.setSendStatus(3L);
+            sopLogs.setSendType(CONFIRM_RECEIVE_SEND_TYPE);
+            sopLogs.setSort(CONFIRM_RECEIVE_SORT);
+            sopLogs.setReceivingStatus(0L);
+            sopLogs.setSendTime(sdf.format(new Date()));
+            sopLogs.setCorpId(StringUtils.isNotEmpty(contact.getCorpId()) ? contact.getCorpId() : qwUser.getCorpId());
+            sopLogs.setCompanyId(contact.getCompanyId() != null ? contact.getCompanyId() : qwUser.getCompanyId());
+            sopLogs.setFsUserId(contact.getFsUserId());
+            sopLogs.setRemark("销售发红包确认收款");
+            sopLogs.setContentJson(JSON.toJSONString(settingRoot));
+
+            int rows = qwSopLogsService.insertQwSopLogs(sopLogs);
+            log.info("[销售发红包-确认收款] qw_sop_logs 链接卡片已写入 contactId={}, externalUserId={}, qwUserKey={}, outBillNo={}, rows={}, sort={}, sendType={}, url={}",
+                    contactId, externalUserId, qwUser.getId(), outBillNo, rows, CONFIRM_RECEIVE_SORT, CONFIRM_RECEIVE_SEND_TYPE, confirmUrl);
+        } catch (Exception e) {
+            log.error("[销售发红包-确认收款] 写入待发送异常(不影响红包主流程) contactId={}, externalUserId={}, err={}",
+                    contactId, externalUserId, e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 取课程封面:优先 imgUrl,其次 secondImg;查不到或为空则返回空串(仍发链接卡片)。
+     */
+    private String resolveCourseCoverUrl(Long courseId) {
+        if (courseId == null) {
+            return "";
+        }
+        try {
+            FsUserCourse course = fsUserCourseMapper.selectFsUserCourseByCourseId(courseId);
+            if (course == null) {
+                return "";
+            }
+            if (StringUtils.isNotEmpty(course.getImgUrl())) {
+                return course.getImgUrl();
+            }
+            if (StringUtils.isNotEmpty(course.getSecondImg())) {
+                return course.getSecondImg();
+            }
+        } catch (Exception e) {
+            log.warn("[销售发红包-确认收款] 查询课程封面失败 courseId={}, err={}", courseId, e.getMessage());
+        }
+        return "";
+    }
+
+    private String buildConfirmReceiveUrl(String domainName, String mchId, String appId, String packageInfo) {
+        String domain = domainName.trim();
+        while (domain.endsWith("/")) {
+            domain = domain.substring(0, domain.length() - 1);
+        }
+        return domain + "/confirmReceive.html"
+                + "?mchId=" + urlEncode(mchId)
+                + "&appId=" + urlEncode(appId)
+                + "&package=" + urlEncode(packageInfo);
+    }
+
+    private static String urlEncode(String value) {
+        try {
+            return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8.name());
+        } catch (Exception e) {
+            return value == null ? "" : value;
+        }
+    }
+
+    private String extractPackageInfo(R sendRedPacket) {
+        if (sendRedPacket == null) {
+            return null;
+        }
+        Object pkg = sendRedPacket.get("package");
+        if (pkg != null && StringUtils.isNotEmpty(String.valueOf(pkg))) {
+            return String.valueOf(pkg);
+        }
+        Object data = sendRedPacket.get("data");
+        if (data instanceof TransferBillsResult) {
+            return ((TransferBillsResult) data).getPackageInfo();
+        }
+        if (data != null) {
+            com.alibaba.fastjson.JSONObject obj = JSON.parseObject(JSON.toJSONString(data));
+            if (obj != null) {
+                String p = obj.getString("packageInfo");
+                if (StringUtils.isEmpty(p)) {
+                    p = obj.getString("package_info");
+                }
+                return p;
+            }
+        }
+        return null;
+    }
+
+    private String extractTransferState(R sendRedPacket) {
+        if (sendRedPacket == null) {
+            return null;
+        }
+        Object data = sendRedPacket.get("data");
+        if (data instanceof TransferBillsResult) {
+            return ((TransferBillsResult) data).getState();
+        }
+        if (data != null) {
+            com.alibaba.fastjson.JSONObject obj = JSON.parseObject(JSON.toJSONString(data));
+            if (obj != null) {
+                return obj.getString("state");
+            }
+        }
+        return null;
+    }
+
+    private String extractOutBillNo(R sendRedPacket) {
+        if (sendRedPacket == null) {
+            return null;
+        }
+        Object data = sendRedPacket.get("data");
+        if (data instanceof TransferBillsResult) {
+            return ((TransferBillsResult) data).getOutBillNo();
+        }
+        if (data != null) {
+            com.alibaba.fastjson.JSONObject obj = JSON.parseObject(JSON.toJSONString(data));
+            if (obj != null) {
+                String no = obj.getString("outBillNo");
+                if (StringUtils.isEmpty(no)) {
+                    no = obj.getString("out_bill_no");
+                }
+                return no;
+            }
+        }
+        Object orderCode = sendRedPacket.get("orderCode");
+        return orderCode != null ? String.valueOf(orderCode) : null;
+    }
+
+    private void rollbackBalance(BalanceRollbackError balanceRollbackError) {
+        String companyMoneyKey = FsConstants.COMPANY_MONEY_KEY + balanceRollbackError.getCompanyId();
+        RLock lock2 = redissonClient.getLock(FsConstants.COMPANY_MONEY_LOCK + balanceRollbackError.getCompanyId());
+        boolean lockAcquired = false;
+        boolean backError = true;
+        try {
+            if (lock2.tryLock(3, 10, TimeUnit.SECONDS)) {
+                lockAcquired = true;
+                String currentMoneyStr = redisCache.getCacheObject(companyMoneyKey);
+                if (StringUtils.isEmpty(currentMoneyStr)) {
+                    throw new RuntimeException("回滚余额异常");
+                }
+                BigDecimal rollbackMoney = new BigDecimal(currentMoneyStr).add(balanceRollbackError.getMoney());
+                redisCache.setCacheObject(companyMoneyKey, rollbackMoney.toString());
+                backError = false;
+            } else if (backError) {
+                balanceRollbackErrorMapper.insert(balanceRollbackError);
+            }
+        } catch (Exception e) {
+            log.error("销售发红包回滚余额异常, companyId={}", balanceRollbackError.getCompanyId(), e);
+            if (backError) {
+                try {
+                    balanceRollbackErrorMapper.insert(balanceRollbackError);
+                } catch (Exception ignore) {
+                    // ignore
+                }
+            }
+        } finally {
+            if (lockAcquired && lock2.isHeldByCurrentThread()) {
+                try {
+                    lock2.unlock();
+                } catch (IllegalMonitorStateException ignored) {
+                    // ignore
+                }
+            }
+        }
+    }
+
+    /**
+     * 侧边栏发红包:按外部联系人+员工企微号找人;
+     * 完课判定=今天 + 该销售(company_user_id) + 该客户(不限制 watch_type);
+     * 已领取提示;已发未领则复用原 confirmReceive 链接;可发则走与一键发红包相同转账并返回链接。
+     */
+    @Override
+    public R prepareSidebarRedPacketLink(SidebarSendRedPacketParam param) {
+        if (param == null || StringUtil.strIsNullOrEmpty(param.getCorpId())
+                || StringUtil.strIsNullOrEmpty(param.getQwUserId())
+                || StringUtil.strIsNullOrEmpty(param.getExternalUserId())) {
+            return R.error("参数不完整,请刷新侧边栏后重试");
+        }
+        String corpId = param.getCorpId().trim();
+        String qwUserIdStr = param.getQwUserId().trim();
+        String externalUserId = param.getExternalUserId().trim();
+
+        QwUser qwUser = qwUserMapper.selectQwUserByCorpIdAndUserId(corpId, qwUserIdStr);
+        if (qwUser == null || qwUser.getCompanyId() == null || qwUser.getCompanyUserId() == null) {
+            return R.error("员工未绑定销售公司,请先绑定后再试");
+        }
+        Long companyId = qwUser.getCompanyId();
+        Long companyUserId = qwUser.getCompanyUserId();
+
+        QwExternalContact contact = qwExternalContactMapper.selectQwExternalContactByExternalUserIdAndQwUserId(
+                externalUserId, corpId, qwUserIdStr);
+        if (contact == null) {
+            return R.error("未找到该外部联系人,请确认客户归属");
+        }
+        if (contact.getCompanyId() != null && !companyId.equals(contact.getCompanyId())) {
+            return R.error("无权操作该客户");
+        }
+
+        String json = configService.selectConfigByKey("course.config");
+        CourseConfig config = JSONUtil.toBean(json, CourseConfig.class);
+        if (config == null) {
+            return R.error("课程配置为空,请联系管理员");
+        }
+        if (StringUtil.strIsNullOrEmpty(config.getRealLinkDomainName())) {
+            return R.error("未配置看课域名,无法生成领取链接");
+        }
+
+        // 完课判定:今天 + 该销售(company_user_id) + 该客户
+        LocalDate today = LocalDate.now();
+        Date startTime = Date.from(today.atStartOfDay(ZoneId.systemDefault()).toInstant());
+        Date endTime = Date.from(today.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant());
+        TodayFinishWatchForRedPacketVO row = fsCourseWatchLogMapper.selectTodayFinishWatchForSidebarSales(
+                companyId, companyUserId, contact.getId(), startTime, endTime);
+        if (row == null || row.getUserId() == null || row.getCourseId() == null || row.getVideoId() == null) {
+            log.info("[侧边栏发红包] 今日该销售下未完课 companyId={}, companyUserId={}, contactId={}, externalUserId={}",
+                    companyId, companyUserId, contact.getId(), externalUserId);
+            return R.error("该客户今天在此销售下尚未完课,无法发红包")
+                    .put("action", "not_finished")
+                    .put("canSend", false);
+        }
+        log.info("[侧边栏发红包] 命中今日完课 companyId={}, companyUserId={}, contactId={}, courseId={}, videoId={}, logId={}",
+                companyId, companyUserId, contact.getId(), row.getCourseId(), row.getVideoId(), row.getLogId());
+
+        FsCourseRedPacketLog existLog = redPacketLogMapper.selectTodayRedPacketLog(
+                companyId, row.getUserId(), row.getCourseId(), row.getVideoId(), startTime, endTime);
+        if (existLog != null) {
+            if (Integer.valueOf(1).equals(existLog.getStatus())) {
+                return R.error("该客户今日红包已领取,无需重复发送")
+                        .put("action", "claimed")
+                        .put("canSend", false);
+            }
+            // status=0 发送中/待确认:复用原链接
+            String reuseUrl = rebuildConfirmUrlFromLog(existLog, config);
+            if (StringUtil.strIsNullOrEmpty(reuseUrl)) {
+                return R.error("红包已发放但领取链接已失效,请稍后重试或联系管理员")
+                        .put("action", "link_expired")
+                        .put("canSend", false);
+            }
+            String cover = resolveCourseCoverUrl(row.getCourseId());
+            log.info("[侧边栏发红包] 复用已有链接 contactId={}, externalUserId={}, logId={}, courseId={}, videoId={}",
+                    contact.getId(), externalUserId, existLog.getLogId(), row.getCourseId(), row.getVideoId());
+            return R.ok("红包链接已生成,可再次发送")
+                    .put("action", "resent")
+                    .put("canSend", true)
+                    .put("linkUrl", reuseUrl)
+                    .put("linkTitle", "课程已完成")
+                    .put("linkDescribe", "点击领取专属奖励")
+                    .put("linkImageUrl", cover);
+        }
+
+        RLock clickLock = redissonClient.getLock(
+                "sidebar:redpacket:click:" + companyId + ":" + contact.getId() + ":" + row.getVideoId());
+        boolean lockAcquired = false;
+        try {
+            if (!clickLock.tryLock(3, 30, TimeUnit.SECONDS)) {
+                return R.error("正在发放中,请勿重复点击").put("canSend", false).put("action", "busy");
+            }
+            lockAcquired = true;
+
+            // 锁内再查一次,避免并发重复创建
+            existLog = redPacketLogMapper.selectTodayRedPacketLog(
+                    companyId, row.getUserId(), row.getCourseId(), row.getVideoId(), startTime, endTime);
+            if (existLog != null) {
+                if (Integer.valueOf(1).equals(existLog.getStatus())) {
+                    return R.error("该客户今日红包已领取,无需重复发送")
+                            .put("action", "claimed")
+                            .put("canSend", false);
+                }
+                String reuseUrl = rebuildConfirmUrlFromLog(existLog, config);
+                if (StringUtils.isNotEmpty(reuseUrl)) {
+                    return R.ok("红包链接已生成,可再次发送")
+                            .put("action", "resent")
+                            .put("canSend", true)
+                            .put("linkUrl", reuseUrl)
+                            .put("linkTitle", "课程已完成")
+                            .put("linkDescribe", "点击领取专属奖励")
+                            .put("linkImageUrl", resolveCourseCoverUrl(row.getCourseId()));
+                }
+            }
+
+            FsUser user = fsUserMapper.selectFsUserByUserId(row.getUserId());
+            if (user == null) {
+                return R.error("会员不存在").put("canSend", false).put("action", "fail");
+            }
+            FsUserCourseVideo video = fsUserCourseVideoMapper.selectFsUserCourseVideoByVideoId(row.getVideoId());
+            BigDecimal amount = resolveAmount(row.getVideoId(), companyId, row.getCourseId(), video);
+            if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
+                return R.error("红包金额为0,无法发放").put("canSend", false).put("action", "fail");
+            }
+            String openId = resolveOpenId(contact);
+            if (StringUtil.strIsNullOrEmpty(openId)) {
+                String reason = (StringUtils.isNotEmpty(contact.getExternalUserId())
+                        && contact.getExternalUserId().startsWith("wo"))
+                        ? "企微客户不支持转openid" : "openid转换失败";
+                return R.error(reason).put("canSend", false).put("action", "fail");
+            }
+
+            WxSendRedPacketParam packetParam = new WxSendRedPacketParam();
+            packetParam.setOpenId(openId);
+            packetParam.setAmount(amount);
+            packetParam.setCompanyId(companyId);
+            packetParam.setSource(2);
+            packetParam.setRedPacketMode(config.getRedPacketMode());
+            packetParam.setUser(user);
+            String contactCorpId = StringUtils.isNotEmpty(contact.getCorpId()) ? contact.getCorpId() : corpId;
+            packetParam.setCorpId(contactCorpId);
+            packetParam.setAppId(contactCorpId);
+
+            R sendResult = sendOneWithDeduction(packetParam, amount, config,
+                    companyId, companyUserId, contact, row.getCourseId(),
+                    row.getVideoId(), row.getPeriodId(), row.getLogId());
+            if (sendResult == null || !Integer.valueOf(200).equals(sendResult.get("code"))) {
+                return R.error(resolveFailMsg(sendResult)).put("canSend", false).put("action", "fail");
+            }
+
+            String state = extractTransferState(sendResult);
+            String packageInfo = extractPackageInfo(sendResult);
+            String mchId = sendResult.get("mchId") != null ? String.valueOf(sendResult.get("mchId")) : null;
+            String appId = packetParam.getAppId();
+            if (!"WAIT_USER_CONFIRM".equals(state) || StringUtil.strIsNullOrEmpty(packageInfo)
+                    || StringUtil.strIsNullOrEmpty(mchId) || StringUtil.strIsNullOrEmpty(appId)) {
+                log.warn("[侧边栏发红包] 转账成功但无法生成确认链接 contactId={}, state={}, packageEmpty={}",
+                        contact.getId(), state, StringUtil.strIsNullOrEmpty(packageInfo));
+                return R.error("红包已发起,但暂无确认收款链接(可能已自动到账)")
+                        .put("action", "no_confirm_link")
+                        .put("canSend", false);
+            }
+
+            String confirmUrl = buildConfirmReceiveUrl(config.getRealLinkDomainName(), mchId, appId, packageInfo);
+            String cover = resolveCourseCoverUrl(row.getCourseId());
+            log.info("[侧边栏发红包] 新建链接成功 contactId={}, externalUserId={}, courseId={}, videoId={}, amount={}",
+                    contact.getId(), externalUserId, row.getCourseId(), row.getVideoId(), amount);
+            return R.ok("红包链接已生成")
+                    .put("action", "send")
+                    .put("canSend", true)
+                    .put("linkUrl", confirmUrl)
+                    .put("linkTitle", "课程已完成")
+                    .put("linkDescribe", "点击领取专属奖励")
+                    .put("linkImageUrl", cover);
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            return R.error("系统繁忙,请稍后重试").put("canSend", false).put("action", "busy");
+        } finally {
+            if (lockAcquired && clickLock.isHeldByCurrentThread()) {
+                try {
+                    clickLock.unlock();
+                } catch (IllegalMonitorStateException ignored) {
+                    // ignore
+                }
+            }
+        }
+    }
+
+    /**
+     * 从已有红包流水 result 中还原 confirmReceive 领取链接
+     */
+    private String rebuildConfirmUrlFromLog(FsCourseRedPacketLog existLog, CourseConfig config) {
+        if (existLog == null || config == null || StringUtil.strIsNullOrEmpty(config.getRealLinkDomainName())) {
+            return null;
+        }
+        String packageInfo = null;
+        String mchId = existLog.getMchId();
+        String appId = existLog.getAppId();
+        try {
+            if (StringUtils.isNotEmpty(existLog.getResult())) {
+                com.alibaba.fastjson.JSONObject root = JSON.parseObject(existLog.getResult());
+                if (root != null) {
+                    if (StringUtil.strIsNullOrEmpty(mchId) && root.get("mchId") != null) {
+                        mchId = String.valueOf(root.get("mchId"));
+                    }
+                    Object pkg = root.get("package");
+                    if (pkg != null && StringUtils.isNotEmpty(String.valueOf(pkg))) {
+                        packageInfo = String.valueOf(pkg);
+                    }
+                    com.alibaba.fastjson.JSONObject data = root.getJSONObject("data");
+                    if (data != null) {
+                        if (StringUtil.strIsNullOrEmpty(packageInfo)) {
+                            packageInfo = data.getString("packageInfo");
+                            if (StringUtils.isEmpty(packageInfo)) {
+                                packageInfo = data.getString("package_info");
+                            }
+                        }
+                        if (StringUtil.strIsNullOrEmpty(appId) && StringUtils.isNotEmpty(data.getString("appId"))) {
+                            appId = data.getString("appId");
+                        }
+                    }
+                }
+            }
+        } catch (Exception e) {
+            log.warn("[侧边栏发红包] 解析已有红包 result 失败 logId={}, err={}", existLog.getLogId(), e.getMessage());
+        }
+        if (StringUtil.strIsNullOrEmpty(packageInfo) || StringUtil.strIsNullOrEmpty(mchId)
+                || StringUtil.strIsNullOrEmpty(appId)) {
+            return null;
+        }
+        return buildConfirmReceiveUrl(config.getRealLinkDomainName(), mchId, appId, packageInfo);
+    }
+
+    private static String resolveFailMsg(R sendRedPacket) {
+        if (sendRedPacket == null) {
+            return "发放失败";
+        }
+        Object msg = sendRedPacket.get("msg");
+        return msg != null ? msg.toString() : "发放失败";
+    }
+}

+ 242 - 60
fs-service/src/main/java/com/fs/sop/service/impl/SopUserLogsInfoServiceImpl.java

@@ -27,6 +27,7 @@ import com.fs.course.domain.*;
 import com.fs.course.mapper.FsCourseDomainNameMapper;
 import com.fs.course.mapper.FsCourseDomainNameMapper;
 import com.fs.course.mapper.FsCourseLinkMapper;
 import com.fs.course.mapper.FsCourseLinkMapper;
 import com.fs.course.mapper.FsCourseWatchLogMapper;
 import com.fs.course.mapper.FsCourseWatchLogMapper;
+import com.fs.course.mapper.FsUserCourseMapper;
 import com.fs.course.param.FsCourseLinkCreateParam;
 import com.fs.course.param.FsCourseLinkCreateParam;
 import com.fs.course.service.IFsCourseLinkService;
 import com.fs.course.service.IFsCourseLinkService;
 import com.fs.course.service.IFsUserCourseVideoService;
 import com.fs.course.service.IFsUserCourseVideoService;
@@ -181,6 +182,9 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
     @Autowired
     @Autowired
     private IFsUserCourseVideoService iFsUserCourseVideoService;
     private IFsUserCourseVideoService iFsUserCourseVideoService;
 
 
+    @Autowired
+    private FsUserCourseMapper fsUserCourseMapper;
+
 
 
     @Autowired
     @Autowired
     private CompanyUserMapper companyUserMapper;
     private CompanyUserMapper companyUserMapper;
@@ -595,11 +599,20 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
             // 与个人路径「本次覆盖客户数」语义对齐:统计未退群的外部联系人(type=2),而非选中群数量
             // 与个人路径「本次覆盖客户数」语义对齐:统计未退群的外部联系人(type=2),而非选中群数量
             groupPathCustomerCount = countGroupMassSendCustomerCount(groupUserList, groupList);
             groupPathCustomerCount = countGroupMassSendCustomerCount(groupUserList, groupList);
 
 
-            //没有传值课程和课节 是直播的数据
+            //没有传值课程和课节 是直播的数据;群聊飞书/小程序发课统一按群成员写看课记录
             if(null != param.getCourseId() && null !=param.getVideoId()){
             if(null != param.getCourseId() && null !=param.getVideoId()){
                 try {
                 try {
                     groupList.forEach(groupChat -> {
                     groupList.forEach(groupChat -> {
                         QwUser qwUser = qwUserMapper.selectQwUserByIdByWeComeText2(groupChat.getOwner(), groupChat.getCorpId());
                         QwUser qwUser = qwUserMapper.selectQwUserByIdByWeComeText2(groupChat.getOwner(), groupChat.getCorpId());
+                        if (qwUser == null || qwUser.getId() == null
+                                || qwUser.getCompanyUserId() == null || qwUser.getCompanyId() == null) {
+                            log.error("群聊创建看课记录跳过:群主企微用户不存在或未绑定员工, owner={}, corpId={}, chatId={}",
+                                    groupChat.getOwner(), groupChat.getCorpId(), groupChat.getChatId());
+                            return;
+                        }
+                        if (groupChat.getChatUserList() == null) {
+                            return;
+                        }
                         groupChat.getChatUserList().stream().filter(e -> e.getUserList() != null && !e.getUserList().isEmpty()).forEach(e -> {
                         groupChat.getChatUserList().stream().filter(e -> e.getUserList() != null && !e.getUserList().isEmpty()).forEach(e -> {
                             Map<String, GroupUserExternalVo> userMap = PubFun.listToMapByGroupObject(e.getUserList(), GroupUserExternalVo::getUserId);
                             Map<String, GroupUserExternalVo> userMap = PubFun.listToMapByGroupObject(e.getUserList(), GroupUserExternalVo::getUserId);
                             GroupUserExternalVo vo = userMap.get(groupChat.getOwner());
                             GroupUserExternalVo vo = userMap.get(groupChat.getOwner());
@@ -782,38 +795,56 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                 break;
                                 break;
                             //飞书看课(固定免授权)
                             //飞书看课(固定免授权)
                             case "17":
                             case "17":
-                                st.setFeishuNeedAuth(0);
-                                Integer videoId18 = param.getVideoId() != null ? param.getVideoId().intValue() : null;
-                                Integer courseId18 = param.getCourseId() != null ? param.getCourseId().intValue() : null;
-                                Long companyIdLong = qwUser.getCompanyId();
-                                Long externalUserId = vo.getId();
-                                Long companyUserIdLong = Long.parseLong(companyUserId);
-                                if (videoId18 == null) {
-                                    log.error("飞书链接生成失败:视频ID为空,sopId={}", param.getSopId());
-                                    break;
-                                }
-                                Map<String, String> feishuH5Link = createFeishuH5Link(st, param.getCorpId(), createTime, courseId18, videoId18, String.valueOf(qwUser.getId()), companyUserId, String.valueOf(companyIdLong), externalUserId, config);
-                                String shortCode = feishuH5Link.get("link");
-                                String feishuLink = resolveFeishuSendLinkSafely(videoId18.longValue(), companyIdLong, courseId18.longValue(),
-                                        companyUserIdLong, shortCode, st.getFeishuAccountId(), 0,
-                                        param.getSopId(), null, externalUserId, vo.getFsUserId(),
-                                        qwUser.getQwUserId(), qwUser.getQwUserName(), companyUserId, companyId,
-                                        param.getCorpId(), createTime, param.getStartTime(), feishuShardAllocator);
-                                if (StringUtils.isNotEmpty(feishuLink)) {
-                                    st.setLinkUrl(feishuLink);
+                                try {
+                                    st.setFeishuNeedAuth(0);
+                                    Integer videoId18 = param.getVideoId() != null ? param.getVideoId().intValue() : null;
+                                    Integer courseId18 = param.getCourseId() != null ? param.getCourseId().intValue() : null;
+                                    Long companyIdLong = qwUser.getCompanyId();
+                                    Long externalUserId = vo.getId();
+                                    Long companyUserIdLong = Long.parseLong(companyUserId);
+                                    if (videoId18 == null) {
+                                        log.error("飞书链接生成失败:视频ID为空,sopId={}", param.getSopId());
+                                        break;
+                                    }
+                                    // 群成员飞书发课:发时写入看课记录(与 contentType=18 一致);watchType=3 飞书
+                                    addWatchLogIfNeeded(param.getSopId(), param.getVideoId(), param.getCourseId(),
+                                            vo.getFsUserId(), String.valueOf(qwUser.getId()), companyUserId,
+                                            String.valueOf(qwUser.getCompanyId()), vo.getId(),
+                                            param.getStartTime(), createTime, 3);
+                                    Map<String, String> feishuH5Link = createFeishuH5Link(st, param.getCorpId(), createTime, courseId18, videoId18, String.valueOf(qwUser.getId()), companyUserId, String.valueOf(companyIdLong), externalUserId, config);
+                                    String shortCode = feishuH5Link.get("link");
+                                    String feishuLink = resolveFeishuSendLinkSafely(videoId18.longValue(), companyIdLong, courseId18.longValue(),
+                                            companyUserIdLong, shortCode, st.getFeishuAccountId(), 0,
+                                            param.getSopId(), null, externalUserId, vo.getFsUserId(),
+                                            qwUser.getQwUserId(), qwUser.getQwUserName(), companyUserId, companyId,
+                                            param.getCorpId(), createTime, param.getStartTime(), feishuShardAllocator);
+                                    if (StringUtils.isNotEmpty(feishuLink)) {
+                                        st.setLinkUrl(feishuLink);
+                                        // 链接卡片标题/描述/封面为空时兜底,避免 PAD 发出空卡片
+                                        fillFeishuLinkCardDefaults(st, param.getVideoId().longValue(), param.getCourseId().longValue());
+                                    }
+                                } catch (Exception e) {
+                                    log.error("飞书群成员链接生成异常,仍写入发送记录: sopId={}, err={}",
+                                            param.getSopId(), e.getMessage(), e);
                                 }
                                 }
                                 break;
                                 break;
                             //飞书NEW:走需授权链路(发时写看课记录 + 入库短链),发出授权页 URL
                             //飞书NEW:走需授权链路(发时写看课记录 + 入库短链),发出授权页 URL
                             case "18":
                             case "18":
-                                Integer videoIdFeishuNew = param.getVideoId() != null ? param.getVideoId().intValue() : null;
-                                Integer courseIdFeishuNew = param.getCourseId() != null ? param.getCourseId().intValue() : null;
-                                // 与飞书需授权(17)一致:发送时写入看课记录
-                                addWatchLogIfNeeded(param.getSopId(), param.getVideoId(), param.getCourseId(),
-                                        vo.getFsUserId(), String.valueOf(qwUser.getId()), companyUserId,
-                                        String.valueOf(qwUser.getCompanyId()), vo.getId(),
-                                        param.getStartTime(), createTime, 2);
-                                st.setLinkUrl(createFeishuNewSendUrl(st, param.getCorpId(), createTime, courseIdFeishuNew, videoIdFeishuNew,
-                                        qwUser.getId(), companyUserId, String.valueOf(qwUser.getCompanyId()), vo.getId(), config, null));
+                                try {
+                                    Integer videoIdFeishuNew = param.getVideoId() != null ? param.getVideoId().intValue() : null;
+                                    Integer courseIdFeishuNew = param.getCourseId() != null ? param.getCourseId().intValue() : null;
+                                    // 与飞书需授权(17)一致:发送时写入看课记录;watchType=3 飞书
+                                    addWatchLogIfNeeded(param.getSopId(), param.getVideoId(), param.getCourseId(),
+                                            vo.getFsUserId(), String.valueOf(qwUser.getId()), companyUserId,
+                                            String.valueOf(qwUser.getCompanyId()), vo.getId(),
+                                            param.getStartTime(), createTime, 3);
+                                    st.setLinkUrl(createFeishuNewSendUrl(st, param.getCorpId(), createTime, courseIdFeishuNew, videoIdFeishuNew,
+                                            qwUser.getId(), companyUserId, String.valueOf(qwUser.getCompanyId()), vo.getId(), config, null));
+                                    fillFeishuLinkCardDefaults(st, param.getVideoId().longValue(), param.getCourseId().longValue());
+                                } catch (Exception e) {
+                                    log.error("飞书NEW群成员链接生成异常,仍写入发送记录: sopId={}, err={}",
+                                            param.getSopId(), e.getMessage(), e);
+                                }
                                 break;
                                 break;
                             //群公告
                             //群公告
                             case "11":
                             case "11":
@@ -848,6 +879,11 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
             else {
             else {
                 sopLogsList = groupList.stream().map(groupChat -> {
                 sopLogsList = groupList.stream().map(groupChat -> {
                     QwUser qwUser = qwUserMapper.selectQwUserByIdByWeComeText2(groupChat.getOwner(), groupChat.getCorpId());
                     QwUser qwUser = qwUserMapper.selectQwUserByIdByWeComeText2(groupChat.getOwner(), groupChat.getCorpId());
+                    if (qwUser == null) {
+                        log.error("企业微信用户不存在,跳过该群: owner={}, corpId={}, chatId={}",
+                                groupChat.getOwner(), groupChat.getCorpId(), groupChat.getChatId());
+                        return null;
+                    }
                     if(qwUser.getCompanyUserId() == null){
                     if(qwUser.getCompanyUserId() == null){
                         throw new BaseException("当前企微没绑定员工,无法发送!");
                         throw new BaseException("当前企微没绑定员工,无法发送!");
                     }
                     }
@@ -1023,35 +1059,45 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                     throw new RuntimeException(e);
                                     throw new RuntimeException(e);
                                 }
                                 }
                                 break;
                                 break;
-                            //飞书看课(发群:chatId 是企微字符串,不能转 Long,与小程序发群一样挂 chatId;固定免授权
+                            //飞书看课(发群:只发一条领取页;领取时按人建飞书链接,发课时不建文档
                             case "17":
                             case "17":
-                                st.setFeishuNeedAuth(0);
-                                Integer videoId18Group = param.getVideoId() != null ? param.getVideoId().intValue() : null;
-                                Integer courseId18Group = param.getCourseId() != null ? param.getCourseId().intValue() : null;
-                                Long companyIdLongGroup = Long.valueOf(qwUser.getCompanyId().toString());
-                                Long companyUserIdLongGroup = Long.valueOf(qwUser.getCompanyUserId().toString());
-                                if (videoId18Group == null) {
-                                    log.error("飞书链接生成失败:视频ID为空,sopId={}", param.getSopId());
-                                    break;
-                                }
-                                Map<String, String> feishuH5LinkGroup = createFeishuH5Link(st, param.getCorpId(), createTime, courseId18Group, videoId18Group, String.valueOf(qwUser.getId()), String.valueOf(companyUserIdLongGroup), String.valueOf(companyIdLongGroup), null, config, groupChat.getChatId());
-                                String shortCodeGroup = feishuH5LinkGroup.get("link");
-                                String feishuLinkGroup = resolveFeishuSendLinkSafely(videoId18Group.longValue(), companyIdLongGroup, courseId18Group.longValue(),
-                                        companyUserIdLongGroup, shortCodeGroup, st.getFeishuAccountId(), 0,
-                                        param.getSopId(), null, null, null,
-                                        qwUser.getQwUserId(), qwUser.getQwUserName(), String.valueOf(companyUserIdLongGroup), String.valueOf(companyIdLongGroup),
-                                        param.getCorpId(), createTime, param.getStartTime(), feishuShardAllocator);
-                                if (StringUtils.isNotEmpty(feishuLinkGroup)) {
-                                    st.setLinkUrl(feishuLinkGroup);
+                                try {
+                                    st.setFeishuNeedAuth(0);
+                                    Integer videoId18Group = param.getVideoId() != null ? param.getVideoId().intValue() : null;
+                                    Integer courseId18Group = param.getCourseId() != null ? param.getCourseId().intValue() : null;
+                                    Long companyIdLongGroup = Long.valueOf(qwUser.getCompanyId().toString());
+                                    Long companyUserIdLongGroup = Long.valueOf(qwUser.getCompanyUserId().toString());
+                                    if (videoId18Group == null) {
+                                        log.error("飞书链接生成失败:视频ID为空,sopId={}", param.getSopId());
+                                        break;
+                                    }
+                                    Map<String, String> feishuH5LinkGroup = createFeishuH5Link(st, param.getCorpId(), createTime, courseId18Group, videoId18Group, String.valueOf(qwUser.getId()), String.valueOf(companyUserIdLongGroup), String.valueOf(companyIdLongGroup), null, config, groupChat.getChatId());
+                                    String shortCodeGroup = feishuH5LinkGroup.get("link");
+                                    String claimPageUrlGroup = buildFeishuClaimPageUrl(shortCodeGroup, config);
+                                    if (StringUtils.isNotEmpty(claimPageUrlGroup)) {
+                                        st.setLinkUrl(claimPageUrlGroup);
+                                        fillFeishuLinkCardDefaults(st, param.getVideoId().longValue(), param.getCourseId().longValue());
+                                    } else {
+                                        log.error("飞书发群领取页为空,仍写入发送记录: sopId={}, chatId={}", param.getSopId(), groupChat.getChatId());
+                                    }
+                                } catch (Exception e) {
+                                    log.error("飞书发群链接生成异常,仍写入发送记录: sopId={}, chatId={}, err={}",
+                                            param.getSopId(), groupChat.getChatId(), e.getMessage(), e);
                                 }
                                 }
                                 break;
                                 break;
-                            //飞书NEW(发群:挂 chatId)
+                            //飞书NEW(发群:只发一条领取页,挂 chatId;领取时按 link+userId 建链
                             case "18":
                             case "18":
-                                Integer videoIdFeishuNewGroup = param.getVideoId() != null ? param.getVideoId().intValue() : null;
-                                Integer courseIdFeishuNewGroup = param.getCourseId() != null ? param.getCourseId().intValue() : null;
-                                st.setLinkUrl(createFeishuNewSendUrl(st, param.getCorpId(), createTime, courseIdFeishuNewGroup, videoIdFeishuNewGroup,
-                                        qwUser.getId(), String.valueOf(qwUser.getCompanyUserId()), String.valueOf(qwUser.getCompanyId()),
-                                        null, config, groupChat.getChatId()));
+                                try {
+                                    Integer videoIdFeishuNewGroup = param.getVideoId() != null ? param.getVideoId().intValue() : null;
+                                    Integer courseIdFeishuNewGroup = param.getCourseId() != null ? param.getCourseId().intValue() : null;
+                                    st.setLinkUrl(createFeishuNewSendUrl(st, param.getCorpId(), createTime, courseIdFeishuNewGroup, videoIdFeishuNewGroup,
+                                            qwUser.getId(), String.valueOf(qwUser.getCompanyUserId()), String.valueOf(qwUser.getCompanyId()),
+                                            null, config, groupChat.getChatId()));
+                                    fillFeishuLinkCardDefaults(st, param.getVideoId().longValue(), param.getCourseId().longValue());
+                                } catch (Exception e) {
+                                    log.error("飞书NEW发群链接生成异常,仍写入发送记录: sopId={}, chatId={}, err={}",
+                                            param.getSopId(), groupChat.getChatId(), e.getMessage(), e);
+                                }
                                 break;
                                 break;
                             //群公告
                             //群公告
                             case "11":
                             case "11":
@@ -1078,7 +1124,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                     setting.setLiveId(param.getLiveId());
                     setting.setLiveId(param.getLiveId());
                     sopLogs.setContentJson(JSON.toJSONString(setting));
                     sopLogs.setContentJson(JSON.toJSONString(setting));
                     return sopLogs;
                     return sopLogs;
-                }).collect(Collectors.toList());
+                }).filter(Objects::nonNull).collect(Collectors.toList());
             }
             }
         }else{
         }else{
             sopLogsList = new ArrayList<>();
             sopLogsList = new ArrayList<>();
@@ -1371,6 +1417,10 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                 log.error("飞书链接生成失败:视频ID为空,sopId={}", param.getSopId());
                                 log.error("飞书链接生成失败:视频ID为空,sopId={}", param.getSopId());
                                 break;
                                 break;
                             }
                             }
+                            // 飞书发课:发时写入/更新看课记录,已存在则把 watch_type 改为飞书(3)
+                            addWatchLogIfNeeded(item.getSopId(), param.getVideoId(), param.getCourseId(),
+                                    item.getFsUserId(), qwUserId, companyUserId, companyId,
+                                    item.getExternalId(), param.getStartTime(), createTime, 3);
                             String txt18 = StringUtil.strIsNullOrEmpty(qwUser.getWelcomeText()) ? "" : qwUser.getWelcomeText();
                             String txt18 = StringUtil.strIsNullOrEmpty(qwUser.getWelcomeText()) ? "" : qwUser.getWelcomeText();
                             String customerTitle = StringUtil.strIsNullOrEmpty(contact.getStageStatus()) || "0".equals(contact.getStageStatus()) ? "同学" : contact.getStageStatus();
                             String customerTitle = StringUtil.strIsNullOrEmpty(contact.getStageStatus()) || "0".equals(contact.getStageStatus()) ? "同学" : contact.getStageStatus();
                             if (StringUtils.isNotEmpty(st.getLinkTitle())) {
                             if (StringUtils.isNotEmpty(st.getLinkTitle())) {
@@ -1392,16 +1442,17 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                     param.getCorpId(), createTime, param.getStartTime(), feishuShardAllocator);
                                     param.getCorpId(), createTime, param.getStartTime(), feishuShardAllocator);
                             if (StringUtils.isNotEmpty(feishuLink)) {
                             if (StringUtils.isNotEmpty(feishuLink)) {
                                 st.setLinkUrl(feishuLink);
                                 st.setLinkUrl(feishuLink);
+                                fillFeishuLinkCardDefaults(st, param.getVideoId().longValue(), param.getCourseId().longValue());
                             }
                             }
                             break;
                             break;
                         //飞书NEW:走需授权链路(发时写看课记录 + 入库短链),发出授权页 URL
                         //飞书NEW:走需授权链路(发时写看课记录 + 入库短链),发出授权页 URL
                         case "18":
                         case "18":
                             Integer videoIdFeishuNewPrivate = param.getVideoId() != null ? param.getVideoId().intValue() : null;
                             Integer videoIdFeishuNewPrivate = param.getVideoId() != null ? param.getVideoId().intValue() : null;
                             Integer courseIdFeishuNewPrivate = param.getCourseId() != null ? param.getCourseId().intValue() : null;
                             Integer courseIdFeishuNewPrivate = param.getCourseId() != null ? param.getCourseId().intValue() : null;
-                            // 与飞书需授权(17)一致:发送时写入看课记录
+                            // 与飞书需授权(17)一致:发送时写入看课记录;watchType=3 飞书
                             addWatchLogIfNeeded(item.getSopId(), param.getVideoId(), param.getCourseId(),
                             addWatchLogIfNeeded(item.getSopId(), param.getVideoId(), param.getCourseId(),
                                     item.getFsUserId(), qwUserId, companyUserId, companyId,
                                     item.getFsUserId(), qwUserId, companyUserId, companyId,
-                                    item.getExternalId(), param.getStartTime(), createTime, 2);
+                                    item.getExternalId(), param.getStartTime(), createTime, 3);
                             String txtFeishuNew = StringUtil.strIsNullOrEmpty(qwUser.getWelcomeText()) ? "" : qwUser.getWelcomeText();
                             String txtFeishuNew = StringUtil.strIsNullOrEmpty(qwUser.getWelcomeText()) ? "" : qwUser.getWelcomeText();
                             String customerTitleFeishuNew = StringUtil.strIsNullOrEmpty(contact.getStageStatus()) || "0".equals(contact.getStageStatus()) ? "同学" : contact.getStageStatus();
                             String customerTitleFeishuNew = StringUtil.strIsNullOrEmpty(contact.getStageStatus()) || "0".equals(contact.getStageStatus()) ? "同学" : contact.getStageStatus();
                             if (StringUtils.isNotEmpty(st.getLinkTitle())) {
                             if (StringUtils.isNotEmpty(st.getLinkTitle())) {
@@ -1416,6 +1467,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                             }
                             }
                             st.setLinkUrl(createFeishuNewSendUrl(st, param.getCorpId(), createTime, courseIdFeishuNewPrivate, videoIdFeishuNewPrivate,
                             st.setLinkUrl(createFeishuNewSendUrl(st, param.getCorpId(), createTime, courseIdFeishuNewPrivate, videoIdFeishuNewPrivate,
                                     Long.valueOf(qwUserId), companyUserId, companyId, item.getExternalId(), config, null));
                                     Long.valueOf(qwUserId), companyUserId, companyId, item.getExternalId(), config, null));
+                            fillFeishuLinkCardDefaults(st, param.getVideoId().longValue(), param.getCourseId().longValue());
                             break;
                             break;
                         //群公告(仅用于一键群发,个人不应该有群公告)
                         //群公告(仅用于一键群发,个人不应该有群公告)
                         case "11":
                         case "11":
@@ -1595,6 +1647,8 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
 
 
     @Override
     @Override
     public R sendUserLogsInfoMsgSop(SendUserLogsInfoMsgParam param) {
     public R sendUserLogsInfoMsgSop(SendUserLogsInfoMsgParam param) {
+        // SOP.qw_user_ids 存 qw_user.id;营期 sop_user_logs.qw_user_id 存企微 userid。查询前统一换算
+        normalizeCampSendQwUserIds(param);
         List<String> sopUserLogsIds = sopUserLogsMapper.getSopUserLogsIds(param);
         List<String> sopUserLogsIds = sopUserLogsMapper.getSopUserLogsIds(param);
         if(sopUserLogsIds == null || sopUserLogsIds.isEmpty()){
         if(sopUserLogsIds == null || sopUserLogsIds.isEmpty()){
             throw new BaseException("SOP没有可发送的营期数据");
             throw new BaseException("SOP没有可发送的营期数据");
@@ -1602,10 +1656,80 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
         String[] ids = sopUserLogsIds.toArray(new String[0]);
         String[] ids = sopUserLogsIds.toArray(new String[0]);
         param.setIds(ids);
         param.setIds(ids);
         log.info("一键群发操作日志1:{}", JSON.toJSONString(param));
         log.info("一键群发操作日志1:{}", JSON.toJSONString(param));
+
+        // 群聊营期通常无 sop_user_logs_info 客户明细,INNER JOIN 会得到空列表;有 chat_id 时改走发群路径
+        List<SopUserLogsInfo> personalInfos = sopUserLogsMapper.selectSopUserLogsByIds(ids);
+        if (personalInfos == null || personalInfos.isEmpty()) {
+            List<SopUserLogs> campLogs = sopUserLogsMapper.selectSopUserLogsListByIds(Arrays.asList(ids));
+            List<String> chatIds = campLogs == null ? Collections.emptyList()
+                    : campLogs.stream()
+                    .map(SopUserLogs::getChatId)
+                    .filter(StringUtils::isNotEmpty)
+                    .distinct()
+                    .collect(Collectors.toList());
+            if (!chatIds.isEmpty()) {
+                Integer draftStrategy = param.getDraftStrategy();
+                if (draftStrategy != null && draftStrategy != 1) {
+                    throw new BaseException("群聊营期暂不支持草稿策略,请选择直接发送");
+                }
+                if (StringUtil.strIsNullOrEmpty(param.getSopId()) && param.getSopIds() != null && param.getSopIds().length > 0) {
+                    param.setSopId(param.getSopIds()[0]);
+                }
+                param.setFilterMode(2);
+                param.setSendType(1);
+                param.setChatIds(chatIds.toArray(new String[0]));
+                log.info("SOP营期一键群发检测到群聊营期,改走发群: sopId={}, chatCount={}",
+                        param.getSopId(), chatIds.size());
+                return sendUserLogsInfoMsg(param);
+            }
+            throw new BaseException("SOP没有可发送的营期数据");
+        }
+
         processQwSopLogsBySendMsg(param, param.getDraftStrategy(), 3);
         processQwSopLogsBySendMsg(param, param.getDraftStrategy(), 3);
         return null;
         return null;
     }
     }
 
 
+    /**
+     * 将前端/SOP 传入的 qwUserIds 统一为营期表使用的企微 userid。
+     * 兼容:① qw_user 主键 id(SOP 任务成员字段);② 已是企微 userid(我的 SOP / isMine)。
+     */
+    private void normalizeCampSendQwUserIds(SendUserLogsInfoMsgParam param) {
+        if (param == null || param.getQwUserIds() == null || param.getQwUserIds().isEmpty()) {
+            return;
+        }
+        LinkedHashSet<String> resolved = new LinkedHashSet<>();
+        List<Long> pkIds = new ArrayList<>();
+        for (String raw : param.getQwUserIds()) {
+            if (StringUtils.isBlank(raw)) {
+                continue;
+            }
+            String item = raw.trim();
+            resolved.add(item);
+            if (item.matches("^\\d+$")) {
+                try {
+                    pkIds.add(Long.valueOf(item));
+                } catch (NumberFormatException ignore) {
+                    // 超长数字串按原样保留
+                }
+            }
+        }
+        if (!pkIds.isEmpty()) {
+            try {
+                List<QwUser> byPk = qwUserMapper.selectQwUserIdByPkIds(pkIds);
+                if (byPk != null) {
+                    for (QwUser user : byPk) {
+                        if (user != null && StringUtils.isNotEmpty(user.getQwUserId())) {
+                            resolved.add(user.getQwUserId());
+                        }
+                    }
+                }
+            } catch (Exception e) {
+                log.warn("营期群发换算 qw_user.id -> qw_user_id 失败: {}", e.getMessage());
+            }
+        }
+        param.setQwUserIds(new ArrayList<>(resolved));
+    }
+
     @Override
     @Override
     public List<SopUserLogsInfo> selectRestoreByIsDaysNotStudy(String sopId, String userLogsId) {
     public List<SopUserLogsInfo> selectRestoreByIsDaysNotStudy(String sopId, String userLogsId) {
         return sopUserLogsInfoMapper.selectRestoreByIsDaysNotStudy(sopId, userLogsId);
         return sopUserLogsInfoMapper.selectRestoreByIsDaysNotStudy(sopId, userLogsId);
@@ -2151,6 +2275,10 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                         log.error("飞书链接生成失败:外部ID为空,sopId={}", item.getSopId());
                         log.error("飞书链接生成失败:外部ID为空,sopId={}", item.getSopId());
                         break;
                         break;
                     }
                     }
+                    // 飞书发课:发时写入/更新看课记录,已存在则把 watch_type 改为飞书(3)
+                    addWatchLogIfNeeded(item.getSopId(), param.getVideoId(), param.getCourseId(),
+                            item.getFsUserId(), String.valueOf(qwUser.getId()), companyUserId, companyId,
+                            item.getExternalId(), item.getStartTime(), dataTime, 3);
                     Map<String, String> feishuH5Link = createFeishuH5Link(st, param.getCorpId(), dataTime, courseId18, videoId18, String.valueOf(qwUser.getId()), companyUserId, companyId, externalId, config);
                     Map<String, String> feishuH5Link = createFeishuH5Link(st, param.getCorpId(), dataTime, courseId18, videoId18, String.valueOf(qwUser.getId()), companyUserId, companyId, externalId, config);
                     String shortLink = feishuH5Link.get("link");
                     String shortLink = feishuH5Link.get("link");
                     String feishuLink = resolveFeishuSendLinkSafely(videoId18.longValue(), companyIdLong, courseId18.longValue(),
                     String feishuLink = resolveFeishuSendLinkSafely(videoId18.longValue(), companyIdLong, courseId18.longValue(),
@@ -2173,6 +2301,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                     .replaceAll("#客户称呼#", customerTitle));
                                     .replaceAll("#客户称呼#", customerTitle));
                         }
                         }
                         st.setLinkUrl(feishuLink);
                         st.setLinkUrl(feishuLink);
+                        fillFeishuLinkCardDefaults(st, param.getVideoId().longValue(), param.getCourseId().longValue());
                     }
                     }
                     break;
                     break;
                 //飞书NEW:走需授权链路(发时写看课记录 + 入库短链),发出授权页 URL
                 //飞书NEW:走需授权链路(发时写看课记录 + 入库短链),发出授权页 URL
@@ -2184,10 +2313,10 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                         log.error("飞书NEW链接生成失败:外部ID为空,sopId={}", item.getSopId());
                         log.error("飞书NEW链接生成失败:外部ID为空,sopId={}", item.getSopId());
                         break;
                         break;
                     }
                     }
-                    // 与飞书需授权(17)一致:发送时写入看课记录
+                    // 与飞书需授权(17)一致:发送时写入看课记录;watchType=3 飞书
                     addWatchLogIfNeeded(item.getSopId(), param.getVideoId(), param.getCourseId(),
                     addWatchLogIfNeeded(item.getSopId(), param.getVideoId(), param.getCourseId(),
                             item.getFsUserId(), String.valueOf(qwUser.getId()), companyUserId, companyId,
                             item.getFsUserId(), String.valueOf(qwUser.getId()), companyUserId, companyId,
-                            item.getExternalId(), item.getStartTime(), dataTime, 2);
+                            item.getExternalId(), item.getStartTime(), dataTime, 3);
                     String txtFeishuNewPs = StringUtil.strIsNullOrEmpty(qwUser.getWelcomeText()) ? "" : qwUser.getWelcomeText();
                     String txtFeishuNewPs = StringUtil.strIsNullOrEmpty(qwUser.getWelcomeText()) ? "" : qwUser.getWelcomeText();
                     String customerTitleFeishuNewPs = contact == null ? "同学" :
                     String customerTitleFeishuNewPs = contact == null ? "同学" :
                             (StringUtil.strIsNullOrEmpty(contact.getStageStatus()) || "0".equals(contact.getStageStatus()) ? "同学" : contact.getStageStatus());
                             (StringUtil.strIsNullOrEmpty(contact.getStageStatus()) || "0".equals(contact.getStageStatus()) ? "同学" : contact.getStageStatus());
@@ -2203,6 +2332,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                     }
                     }
                     st.setLinkUrl(createFeishuNewSendUrl(st, param.getCorpId(), dataTime, courseIdFeishuNewPs, videoIdFeishuNewPs,
                     st.setLinkUrl(createFeishuNewSendUrl(st, param.getCorpId(), dataTime, courseIdFeishuNewPs, videoIdFeishuNewPs,
                             qwUser.getId(), companyUserId, companyId, externalIdFeishuNew, config, null));
                             qwUser.getId(), companyUserId, companyId, externalIdFeishuNew, config, null));
+                    fillFeishuLinkCardDefaults(st, param.getVideoId().longValue(), param.getCourseId().longValue());
                     break;
                     break;
 
 
                 default:
                 default:
@@ -2214,6 +2344,22 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
         return  list;
         return  list;
     }
     }
 
 
+    /**
+     * 群飞书(17):发课时只拼领取页,不调飞书建文档;领取时再按当前用户生成独立飞书链接。
+     */
+    private String buildFeishuClaimPageUrl(String shortLink, CourseConfig config) {
+        if (StringUtils.isEmpty(shortLink) || config == null
+                || StringUtils.isEmpty(config.getRealLinkDomainName())
+                || StringUtils.isEmpty(config.getFeishuMpAppId())) {
+            return null;
+        }
+        String domain = config.getRealLinkDomainName().trim();
+        if (!domain.endsWith("/")) {
+            domain = domain + "/";
+        }
+        return domain + "?uuid=" + shortLink + "&appid=" + config.getFeishuMpAppId().trim();
+    }
+
     /**
     /**
      * 飞书NEW(contentType=18):按需授权链路处理——
      * 飞书NEW(contentType=18):按需授权链路处理——
      * 1)发送侧须调用 addWatchLogIfNeeded 写入看课记录;
      * 1)发送侧须调用 addWatchLogIfNeeded 写入看课记录;
@@ -2464,6 +2610,42 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
         }
         }
     }
     }
 
 
+    /**
+     * 飞书链接卡片(17/18)为空时按前端逻辑兜底:
+     * linkTitle=课程名,linkDescribe=课节名,linkImageUrl=课程封面图
+     */
+    private void fillFeishuLinkCardDefaults(QwSopCourseFinishTempSetting.Setting setting, Long videoId, Long courseId) {
+        if (setting == null) {
+            return;
+        }
+        String courseName = null;
+        String courseImgUrl = null;
+        String videoTitle = null;
+        try {
+            if (courseId != null && (StringUtils.isEmpty(setting.getLinkTitle()) || StringUtils.isEmpty(setting.getLinkImageUrl()))) {
+                FsUserCourse course = fsUserCourseMapper.selectFsUserCourseByCourseId(courseId);
+                if (course != null) {
+                    courseName = course.getCourseName();
+                    courseImgUrl = course.getImgUrl();
+                }
+            }
+            if (videoId != null && StringUtils.isEmpty(setting.getLinkDescribe())) {
+                videoTitle = iFsUserCourseVideoService.selectFsUserCourseVideoByVideoForTitle(videoId);
+            }
+        } catch (Exception e) {
+            log.warn("补齐飞书链接卡片失败 courseId={}, videoId={}", courseId, videoId, e);
+        }
+        if (StringUtils.isEmpty(setting.getLinkTitle()) && StringUtils.isNotEmpty(courseName)) {
+            setting.setLinkTitle(courseName);
+        }
+        if (StringUtils.isEmpty(setting.getLinkDescribe()) && StringUtils.isNotEmpty(videoTitle)) {
+            setting.setLinkDescribe(videoTitle);
+        }
+        if (StringUtils.isEmpty(setting.getLinkImageUrl()) && StringUtils.isNotEmpty(courseImgUrl)) {
+            setting.setLinkImageUrl(courseImgUrl);
+        }
+    }
+
     //插入观看记录
     //插入观看记录
     public void addWatchLogIfNeeded(String sopId, Integer videoId, Integer courseId,
     public void addWatchLogIfNeeded(String sopId, Integer videoId, Integer courseId,
                                      Long fsUserId, String qwUserId, String companyUserId,
                                      Long fsUserId, String qwUserId, String companyUserId,
@@ -2786,7 +2968,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                 directDocShard = feishuShardAllocator.nextShard(companyUserId, videoId, courseId, feishuAccountId);
                 directDocShard = feishuShardAllocator.nextShard(companyUserId, videoId, courseId, feishuAccountId);
             }
             }
             String feishuLink = feiShuService.resolveFeishuSendLink(videoId, companyId, courseId, companyUserId,
             String feishuLink = feiShuService.resolveFeishuSendLink(videoId, companyId, courseId, companyUserId,
-                    shortLink, feishuAccountId, feishuNeedAuth, directDocShard);
+                    shortLink, feishuAccountId, feishuNeedAuth, directDocShard, fsUserId);
             if (StringUtils.isEmpty(feishuLink)) {
             if (StringUtils.isEmpty(feishuLink)) {
                 log.error("生成飞书注册链接失败,跳过本条: sopId={}, videoId={}, externalId={}",
                 log.error("生成飞书注册链接失败,跳过本条: sopId={}, videoId={}, externalId={}",
                         sopId, videoId, externalId);
                         sopId, videoId, externalId);

+ 6 - 0
fs-service/src/main/resources/application-common.yml

@@ -151,3 +151,9 @@ hsy:
   role_secret_key: T0RaaFl6UmhZV1V4WXpKbU5EWTBNMkZpT0RNNU9UY3daak0wTjJFd09XUQ==
   role_secret_key: T0RaaFl6UmhZV1V4WXpKbU5EWTBNMkZpT0RNNU9UY3daak0wTjJFd09XUQ==
   role_trn: trn:iam::2114522511:role/hylj
   role_trn: trn:iam::2114522511:role/hylj
 
 
+# 商家转账 V3 回调域名默认值(不含尾斜杠);各环境请在 application-config-* 中覆盖
+# 实际 notifyUrl = {notify-domain}/app/wxpay/v3TransferNotifyWithMchId/{mchId}
+wx:
+  transfer:
+    notify-domain: https://userapp.moonxiang.com
+

+ 3 - 0
fs-service/src/main/resources/application-config-dev.yml

@@ -8,6 +8,9 @@ logging:
     com.github.binarywang.demo.wx.cp: DEBUG
     com.github.binarywang.demo.wx.cp: DEBUG
     me.chanjar.weixin: DEBUG
     me.chanjar.weixin: DEBUG
 wx:
 wx:
+  # 商家转账 V3 回调域名(不含尾斜杠),实际地址 = {notify-domain}/app/wxpay/v3TransferNotifyWithMchId/{mchId}
+  transfer:
+    notify-domain: https://userapp.his.runtzh.com
   miniapp:
   miniapp:
     configs:
     configs:
       - appid: wx29d26f63f836be7f
       - appid: wx29d26f63f836be7f

+ 4 - 0
fs-service/src/main/resources/application-config-druid-bjczwh.yml

@@ -12,6 +12,10 @@ wx:
   open:
   open:
     appId: wxfefe26a6c1ff71ba
     appId: wxfefe26a6c1ff71ba
     secret: 4e87854df5f4051894d67cc0ecba3a9a
     secret: 4e87854df5f4051894d67cc0ecba3a9a
+  # 商家转账 V3 回调域名(不含尾斜杠)
+  # 实际 notifyUrl = {notify-domain}/app/wxpay/v3TransferNotifyWithMchId/{mchId}
+  transfer:
+    notify-domain: https://userapp.moonxiang.com
   miniapp:
   miniapp:
     configs:
     configs:
       - appid: wx94951f52d3ac5e25   #北京存在文化
       - appid: wx94951f52d3ac5e25   #北京存在文化

+ 5 - 0
fs-service/src/main/resources/db/20260818-merchant-app-config-corp-ids.sql

@@ -0,0 +1,5 @@
+-- 商户应用配置增加企微主体(可多选,逗号分隔)
+-- 存量库执行;服务启动时也会检测列并自动 ALTER
+-- 新建表已包含该字段(见 MerchantAppConfigMapper.xml CREATE TABLE)
+ALTER TABLE `merchant_app_config`
+  ADD COLUMN `corp_ids` varchar(1024) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '关联企微主体corpId,多个逗号分隔' AFTER `merchant_id`;

+ 3 - 0
fs-service/src/main/resources/db/20260820-watch-type-feishu-comment.sql

@@ -0,0 +1,3 @@
+-- 看课方式增加飞书标识:1 app  2 小程序  3 飞书(仅更新字段注释,无结构变更)
+ALTER TABLE `fs_course_watch_log`
+    MODIFY COLUMN `watch_type` tinyint DEFAULT NULL COMMENT '看课方式:1 app  2 小程序  3 飞书';

+ 41 - 2
fs-service/src/main/resources/mapper/MerchantAppConfigMapper.xml

@@ -16,6 +16,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="createdBy"    column="created_by"    />
         <result property="createdBy"    column="created_by"    />
         <result property="updatedBy"    column="updated_by"    />
         <result property="updatedBy"    column="updated_by"    />
         <result property="merchantId"    column="merchant_id"    />
         <result property="merchantId"    column="merchant_id"    />
+        <result property="corpIds"    column="corp_ids"    />
     </resultMap>
     </resultMap>
 
 
     <!-- 检查表是否存在 -->
     <!-- 检查表是否存在 -->
@@ -38,6 +39,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         `created_by` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建人ID或用户名',
         `created_by` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建人ID或用户名',
         `updated_by` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '修改人ID或用户名',
         `updated_by` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '修改人ID或用户名',
         `merchant_id` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '商户号',
         `merchant_id` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '商户号',
+        `corp_ids` varchar(1024) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '关联企微主体corpId,多个逗号分隔',
         PRIMARY KEY (`id`),
         PRIMARY KEY (`id`),
         KEY `idx_merchant_type` (`merchant_type`),
         KEY `idx_merchant_type` (`merchant_type`),
         KEY `idx_app_id` (`app_id`),
         KEY `idx_app_id` (`app_id`),
@@ -48,7 +50,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
 
 
 
 
     <sql id="selectMerchantAppConfigVo">
     <sql id="selectMerchantAppConfigVo">
-        select id, merchant_type, app_id, merchant_id,callback_url, data_json, created_time, updated_time, is_deleted, created_by, updated_by from merchant_app_config
+        select id, merchant_type, app_id, merchant_id, corp_ids, callback_url, data_json, created_time, updated_time, is_deleted, created_by, updated_by from merchant_app_config
     </sql>
     </sql>
 
 
     <select id="selectMerchantAppConfigList" parameterType="MerchantAppConfig" resultMap="MerchantAppConfigResult">
     <select id="selectMerchantAppConfigList" parameterType="MerchantAppConfig" resultMap="MerchantAppConfigResult">
@@ -68,7 +70,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         where id = #{id}
         where id = #{id}
     </select>
     </select>
     <select id="selectMerchantAppConfigByAppId" resultMap="MerchantAppConfigResult">
     <select id="selectMerchantAppConfigByAppId" resultMap="MerchantAppConfigResult">
-        select a.id, a.merchant_type, a.app_id, a.merchant_id, a.callback_url,
+        select a.id, a.merchant_type, a.app_id, a.merchant_id, a.corp_ids, a.callback_url,
                a.data_json, a.created_time, a.updated_time, a.is_deleted,
                a.data_json, a.created_time, a.updated_time, a.is_deleted,
                a.created_by, a.updated_by
                a.created_by, a.updated_by
         from merchant_app_config a
         from merchant_app_config a
@@ -77,6 +79,40 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
           and fcpsc.appid like CONCAT('%', #{appId}, '%')
           and fcpsc.appid like CONCAT('%', #{appId}, '%')
     </select>
     </select>
 
 
+    <!-- 检查 corp_ids 列是否存在 -->
+    <select id="checkCorpIdsColumnExists" resultType="java.lang.Integer">
+        SELECT COUNT(*) FROM information_schema.columns
+        WHERE table_schema = DATABASE()
+          AND table_name = 'merchant_app_config'
+          AND column_name = 'corp_ids'
+    </select>
+
+    <!-- 存量表补充主体配置字段 -->
+    <update id="addCorpIdsColumn">
+        ALTER TABLE `merchant_app_config`
+        ADD COLUMN `corp_ids` varchar(1024) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '关联企微主体corpId,多个逗号分隔' AFTER `merchant_id`
+    </update>
+
+    <!-- 按企微主体查找微信商户配置:同一 corpId 配多条时取 id 最大 -->
+    <select id="selectWxByCorpId" resultMap="MerchantAppConfigResult">
+        <include refid="selectMerchantAppConfigVo"/>
+        where merchant_type = 'wx'
+          and is_deleted = 0
+          and FIND_IN_SET(#{corpId}, REPLACE(corp_ids, ' ', ''))
+        order by id desc
+        limit 1
+    </select>
+
+    <!-- 按微信商户号查找:转账回调按路径中的 mchId 取配置 -->
+    <select id="selectWxByMerchantId" resultMap="MerchantAppConfigResult">
+        <include refid="selectMerchantAppConfigVo"/>
+        where merchant_type = 'wx'
+          and is_deleted = 0
+          and merchant_id = #{merchantId}
+        order by id desc
+        limit 1
+    </select>
+
     <insert id="insertMerchantAppConfig" parameterType="MerchantAppConfig" useGeneratedKeys="true" keyProperty="id">
     <insert id="insertMerchantAppConfig" parameterType="MerchantAppConfig" useGeneratedKeys="true" keyProperty="id">
         insert into merchant_app_config
         insert into merchant_app_config
         <trim prefix="(" suffix=")" suffixOverrides=",">
         <trim prefix="(" suffix=")" suffixOverrides=",">
@@ -90,6 +126,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="createdBy != null and createdBy != ''">created_by,</if>
             <if test="createdBy != null and createdBy != ''">created_by,</if>
             <if test="updatedBy != null and updatedBy != ''">updated_by,</if>
             <if test="updatedBy != null and updatedBy != ''">updated_by,</if>
             <if test="merchantId != null and merchantId != ''">merchant_id,</if>
             <if test="merchantId != null and merchantId != ''">merchant_id,</if>
+            <if test="corpIds != null">corp_ids,</if>
          </trim>
          </trim>
         <trim prefix="values (" suffix=")" suffixOverrides=",">
         <trim prefix="values (" suffix=")" suffixOverrides=",">
             <if test="merchantType != null and merchantType != ''">#{merchantType},</if>
             <if test="merchantType != null and merchantType != ''">#{merchantType},</if>
@@ -102,6 +139,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="createdBy != null and createdBy != ''">#{createdBy},</if>
             <if test="createdBy != null and createdBy != ''">#{createdBy},</if>
             <if test="updatedBy != null and updatedBy != ''">#{updatedBy},</if>
             <if test="updatedBy != null and updatedBy != ''">#{updatedBy},</if>
             <if test="merchantId != null and merchantId != ''">#{merchantId},</if>
             <if test="merchantId != null and merchantId != ''">#{merchantId},</if>
+            <if test="corpIds != null">#{corpIds},</if>
          </trim>
          </trim>
     </insert>
     </insert>
 
 
@@ -118,6 +156,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="createdBy != null and createdBy != ''">created_by = #{createdBy},</if>
             <if test="createdBy != null and createdBy != ''">created_by = #{createdBy},</if>
             <if test="updatedBy != null and updatedBy != ''">updated_by = #{updatedBy},</if>
             <if test="updatedBy != null and updatedBy != ''">updated_by = #{updatedBy},</if>
             <if test="merchantId != null and merchantId != ''">merchant_id = #{merchantId},</if>
             <if test="merchantId != null and merchantId != ''">merchant_id = #{merchantId},</if>
+            <if test="corpIds != null">corp_ids = #{corpIds},</if>
         </trim>
         </trim>
         where id = #{id}
         where id = #{id}
     </update>
     </update>

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

@@ -408,6 +408,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         on duplicate key update
         on duplicate key update
         <trim suffixOverrides=",">
         <trim suffixOverrides=",">
             <if test="updateTime != null">update_time = #{updateTime},</if>
             <if test="updateTime != null">update_time = #{updateTime},</if>
+            <!-- 飞书等渠道重发时,强制覆盖看课类型 -->
+            <if test="watchType != null">watch_type = #{watchType},</if>
         </trim>
         </trim>
 
 
             </insert>
             </insert>
@@ -463,7 +465,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
                 </foreach>
                 </foreach>
                 ON DUPLICATE KEY UPDATE
                 ON DUPLICATE KEY UPDATE
                 update_time = NOW(),
                 update_time = NOW(),
-                im_msg_send_detail_id = VALUES(im_msg_send_detail_id)
+                im_msg_send_detail_id = VALUES(im_msg_send_detail_id),
+                <!-- 有新 watch_type 时覆盖(飞书重发把历史 APP/小程序记录改为 3) -->
+                watch_type = IFNULL(VALUES(watch_type), watch_type)
             </insert>
             </insert>
 
 
 
 
@@ -1586,6 +1590,72 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <foreach collection="logIds" item="id" open="(" separator="," close=")">
         <foreach collection="logIds" item="id" open="(" separator="," close=")">
             #{id}
             #{id}
         </foreach>
         </foreach>
+        order by l.finish_time desc,l.update_time desc,l.create_time desc
+    </select>
+
+    <!-- 当天完课:完课标记 log_type=2,仅飞书看课 watch_type=3,时间优先 finish_time -->
+    <select id="selectTodayFinishWatchForRedPacket" resultType="com.fs.course.vo.TodayFinishWatchForRedPacketVO">
+        SELECT
+            w.log_id AS logId,
+            w.user_id AS userId,
+            w.course_id AS courseId,
+            w.video_id AS videoId,
+            w.period_id AS periodId,
+            IFNULL(w.finish_time, w.update_time) AS finishTime,
+            w.qw_external_contact_id AS qwExternalContactId,
+            c.corp_id AS corpId,
+            c.qw_user_id AS qwUserId,
+            c.name AS contactName,
+            c.open_id AS openId,
+            c.external_user_id AS externalUserId
+        FROM fs_course_watch_log w
+        INNER JOIN qw_external_contact c ON c.id = w.qw_external_contact_id
+        WHERE w.log_type = 2
+          AND w.watch_type = 3
+          AND c.company_id = #{companyId}
+          AND c.corp_id = #{corpId}
+          AND w.user_id IS NOT NULL
+          AND w.course_id IS NOT NULL
+          AND (
+                (w.finish_time IS NOT NULL AND w.finish_time &gt;= #{startTime} AND w.finish_time &lt; #{endTime})
+                OR (w.finish_time IS NULL AND w.update_time &gt;= #{startTime} AND w.update_time &lt; #{endTime})
+          )
+          <if test="qwUserId != null">
+              AND c.qw_user_id = #{qwUserId}
+          </if>
+        ORDER BY IFNULL(w.finish_time, w.update_time) DESC, w.log_id DESC
+    </select>
+
+    <!-- 侧边栏:今天 + 该销售(company_user_id) + 该客户 是否完课(不限制 watch_type) -->
+    <select id="selectTodayFinishWatchForSidebarSales" resultType="com.fs.course.vo.TodayFinishWatchForRedPacketVO">
+        SELECT
+            w.log_id AS logId,
+            w.user_id AS userId,
+            w.course_id AS courseId,
+            w.video_id AS videoId,
+            w.period_id AS periodId,
+            IFNULL(w.finish_time, w.update_time) AS finishTime,
+            w.qw_external_contact_id AS qwExternalContactId,
+            c.corp_id AS corpId,
+            c.qw_user_id AS qwUserId,
+            c.name AS contactName,
+            c.open_id AS openId,
+            c.external_user_id AS externalUserId
+        FROM fs_course_watch_log w
+        INNER JOIN qw_external_contact c ON c.id = w.qw_external_contact_id
+        WHERE w.log_type = 2
+          AND w.company_id = #{companyId}
+          AND w.company_user_id = #{companyUserId}
+          AND w.qw_external_contact_id = #{qwExternalContactId}
+          AND w.user_id IS NOT NULL
+          AND w.course_id IS NOT NULL
+          AND w.video_id IS NOT NULL
+          AND (
+                (w.finish_time IS NOT NULL AND w.finish_time &gt;= #{startTime} AND w.finish_time &lt; #{endTime})
+                OR (w.finish_time IS NULL AND w.update_time &gt;= #{startTime} AND w.update_time &lt; #{endTime})
+          )
+        ORDER BY IFNULL(w.finish_time, w.update_time) DESC, w.log_id DESC
+        LIMIT 1
     </select>
     </select>
 
 
 </mapper>
 </mapper>

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

@@ -453,6 +453,7 @@
             id,
             id,
             sop_id,
             sop_id,
             qw_user_id,
             qw_user_id,
+            chat_id,
             corp_id,
             corp_id,
             start_time,
             start_time,
             status
             status

+ 13 - 10
fs-user-app/src/main/java/com/fs/app/controller/course/CourseMpLoginController.java

@@ -76,7 +76,7 @@ public class CourseMpLoginController extends AppBaseController {
     /** H5 公众号登录 token 有效期:3 天 */
     /** H5 公众号登录 token 有效期:3 天 */
     private static final long MP_OAUTH_TOKEN_TTL_SECONDS = 3L * 24 * 60 * 60;
     private static final long MP_OAUTH_TOKEN_TTL_SECONDS = 3L * 24 * 60 * 60;
 
 
-    /** 领取资料:按短链 link 缓存飞书 docx(一个 link 一条,含群链接) */
+    /** 领取资料:按 fs_course_link.link + 用户 userId 缓存飞书 docx(17/18 群共享短链靠此区分) */
     private static final String FEISHU_CLAIM_LINK_CACHE_PREFIX = "feishu:h5_claim_link:";
     private static final String FEISHU_CLAIM_LINK_CACHE_PREFIX = "feishu:h5_claim_link:";
     private static final int FEISHU_CLAIM_LINK_CACHE_HOURS = 24;
     private static final int FEISHU_CLAIM_LINK_CACHE_HOURS = 24;
 
 
@@ -654,7 +654,7 @@ public class CourseMpLoginController extends AppBaseController {
     }
     }
 
 
     /**
     /**
-     * 领取资料时补建看课记录(群链路 watchType=1,私聊=2
+     * 领取资料时补建看课记录(飞书 NEW 领取链路,固定 watchType=3
      */
      */
     private FsCourseWatchLog buildClaimWatchLog(FsCourseLink courseLink, QwExternalContact contact,
     private FsCourseWatchLog buildClaimWatchLog(FsCourseLink courseLink, QwExternalContact contact,
                                                 FsUser currentUser, Long qwUserId) {
                                                 FsUser currentUser, Long qwUserId) {
@@ -672,8 +672,8 @@ public class CourseMpLoginController extends AppBaseController {
         watchLog.setCreateTime(now);
         watchLog.setCreateTime(now);
         watchLog.setUpdateTime(now);
         watchLog.setUpdateTime(now);
         watchLog.setLogType(3);
         watchLog.setLogType(3);
-        // 有 chatId 视为群看课,与 SOP 群发小程序 watchType=1 一致
-        watchLog.setWatchType(StringUtils.isNotBlank(courseLink.getChatId()) ? 1 : 2);
+        // 飞书看课标识
+        watchLog.setWatchType(3);
         return watchLog;
         return watchLog;
     }
     }
 
 
@@ -730,7 +730,7 @@ public class CourseMpLoginController extends AppBaseController {
     }
     }
 
 
     /**
     /**
-     * 按 link + 销售飞书 appId 缓存;账号被封换新号后 appId 变,自动未命中并重建
+     * 领取缓存键:fs_course_link.link + 用户 userId(17/18 发群共用一条 link,靠 userId 区分)
      */
      */
     private String buildFeishuJumpUrlCachedByLink(FsCourseLink courseLink, FsUser currentUser) {
     private String buildFeishuJumpUrlCachedByLink(FsCourseLink courseLink, FsUser currentUser) {
         String linkCode = courseLink.getLink();
         String linkCode = courseLink.getLink();
@@ -743,17 +743,18 @@ public class CourseMpLoginController extends AppBaseController {
             courseLink.setFeishuAccountId(activeAccount.getId());
             courseLink.setFeishuAccountId(activeAccount.getId());
             fsCourseLinkMapper.updateFsCourseLink(courseLink);
             fsCourseLinkMapper.updateFsCourseLink(courseLink);
         }
         }
-        String cacheKey = FEISHU_CLAIM_LINK_CACHE_PREFIX + linkCode + ":" + activeAccount.getAppId();
+        Long claimUserId = currentUser.getUserId() != null ? currentUser.getUserId() : 0L;
+        // 缓存:link表标识 + 用户id
+        String cacheKey = FEISHU_CLAIM_LINK_CACHE_PREFIX + linkCode + ":" + claimUserId;
         Object cached = redisCache.getCacheObject(cacheKey);
         Object cached = redisCache.getCacheObject(cacheKey);
         if (cached != null && StringUtils.isNotBlank(String.valueOf(cached))) {
         if (cached != null && StringUtils.isNotBlank(String.valueOf(cached))) {
-            logger.info("[H5领取] 命中飞书缓存 link={} appId={} userId={}",
-                    linkCode, activeAccount.getAppId(), currentUser.getUserId());
+            logger.info("[H5领取] 命中飞书缓存 link={} userId={}", linkCode, claimUserId);
             return String.valueOf(cached);
             return String.valueOf(cached);
         }
         }
         String feishuUrl = createFeishuJumpUrl(courseLink, currentUser);
         String feishuUrl = createFeishuJumpUrl(courseLink, currentUser);
         if (StringUtils.isNotBlank(feishuUrl)) {
         if (StringUtils.isNotBlank(feishuUrl)) {
             redisCache.setCacheObject(cacheKey, feishuUrl, FEISHU_CLAIM_LINK_CACHE_HOURS, TimeUnit.HOURS);
             redisCache.setCacheObject(cacheKey, feishuUrl, FEISHU_CLAIM_LINK_CACHE_HOURS, TimeUnit.HOURS);
-            logger.info("[H5领取] 写入飞书缓存 link={} appId={}", linkCode, activeAccount.getAppId());
+            logger.info("[H5领取] 写入飞书缓存 link={} userId={}", linkCode, claimUserId);
         }
         }
         return feishuUrl;
         return feishuUrl;
     }
     }
@@ -783,7 +784,9 @@ public class CourseMpLoginController extends AppBaseController {
                 courseLink.getCourseId(),
                 courseLink.getCourseId(),
                 courseLink.getCompanyUserId(),
                 courseLink.getCompanyUserId(),
                 linkCode,
                 linkCode,
-                courseLink.getFeishuAccountId());
+                courseLink.getFeishuAccountId(),
+                null,
+                currentUser.getUserId());
     }
     }
 
 
     private String buildH5JumpUrl(FsCourseLink courseLink) {
     private String buildH5JumpUrl(FsCourseLink courseLink) {

+ 13 - 7
fs-user-app/src/main/java/com/fs/app/controller/course/CourseTransferController.java

@@ -80,13 +80,7 @@ public class CourseTransferController {
     }
     }
 
 
     /**
     /**
-     * 分公司配置回调地址
-     * @param companyId
-     * @param notifyData
-     * @param request
-     * @param response
-     * @return
-     * @throws Exception
+     * 分公司配置回调地址(按 companyId,兼容旧链接)
      */
      */
     @PostMapping( "/v3TransferNotifyWithCompanyId/{companyId}")
     @PostMapping( "/v3TransferNotifyWithCompanyId/{companyId}")
     public String v3TransferNotifyWithCompanyId(@PathVariable("companyId") Long companyId,@RequestBody String notifyData,HttpServletRequest request, HttpServletResponse response) throws Exception {
     public String v3TransferNotifyWithCompanyId(@PathVariable("companyId") Long companyId,@RequestBody String notifyData,HttpServletRequest request, HttpServletResponse response) throws Exception {
@@ -94,6 +88,18 @@ public class CourseTransferController {
         return paymentService.v3TransferNotifyWithCompanyId(companyId,notifyData,request);
         return paymentService.v3TransferNotifyWithCompanyId(companyId,notifyData,request);
     }
     }
 
 
+    /**
+     * 商家转账 V3 回调(按商户号):从 merchant_app_config 取配置验签。
+     * 地址示例:https://userapp.xxx.com/app/wxpay/v3TransferNotifyWithMchId/{mchId}
+     */
+    @PostMapping("/v3TransferNotifyWithMchId/{mchId}")
+    public String v3TransferNotifyWithMchId(@PathVariable("mchId") String mchId,
+                                            @RequestBody String notifyData,
+                                            HttpServletRequest request,
+                                            HttpServletResponse response) throws Exception {
+        return paymentService.v3TransferNotifyWithMchId(mchId, notifyData, request);
+    }
+
     @Autowired
     @Autowired
     private IFsCourseRedPacketLogService fsCourseRedPacketLogService;
     private IFsCourseRedPacketLogService fsCourseRedPacketLogService;