Explorar o código

存在代码提交

吴树波 hai 2 horas
pai
achega
242c1e252f
Modificáronse 37 ficheiros con 2521 adicións e 216 borrados
  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)
     @PostMapping
     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) {
@@ -219,14 +222,15 @@ public class FsCoursePlaySourceConfigController extends BaseController {
             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();
         Long currentCompanyId = loginUser.getCompany().getCompanyId();
@@ -327,6 +331,30 @@ public class FsCoursePlaySourceConfigController extends BaseController {
         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) {
         if (companyId == null || config == null) {
             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
     private IFsUserCompanyBindService fsUserCompanyBindService;
 
+    @Autowired
+    private IExternalContactRedPacketService externalContactRedPacketService;
+
     @Autowired
     private IQwGroupChatUserService qwGroupChatUserService;
 
@@ -984,4 +987,39 @@ public class QwExternalContactController extends BaseController
         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("/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("/common/uploadWang**").anonymous()
+                .antMatchers("/common/uploadWang**").permitAll()
                 .antMatchers("/common/download**").permitAll()
                 .antMatchers("/common/test").anonymous()
                 .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.course.domain.FsCoursePlaySourceConfig;
 import com.fs.course.domain.FsCourseWatchLog;
+import com.fs.course.domain.FsUserCourse;
 import com.fs.course.domain.FsUserCourseVideo;
 import com.fs.course.mapper.FsCoursePlaySourceConfigMapper;
+import com.fs.course.mapper.FsUserCourseMapper;
 import com.fs.course.mapper.FsUserCourseVideoMapper;
 import com.fs.course.service.IFsCoursePlaySourceConfigService;
 import com.fs.course.service.IFsCourseWatchLogService;
@@ -69,6 +71,7 @@ public class IpadSendServer {
     private final IFsCoursePlaySourceConfigService playSourceConfigService;
     private final FsUserMapper fsUserMapper;
     private final FsUserCourseVideoMapper fsUserCourseVideoMapper;
+    private final FsUserCourseMapper fsUserCourseMapper;
 
 
     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) {
         FileVo videoVo = FileVo.builder()
                 .url(content.getVideoUrl())
@@ -853,6 +901,7 @@ public class IpadSendServer {
                 case "17":
                 case "18":
                     // 链接卡片:3=H5,17=飞书看课,18=飞书NEW
+                    fillLinkCardDefaultsBeforeSend(content, qwSopLogs);
                     sendLink(vo, content);
                     break;
                 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
     private FsCourseWatchLogProjectFiller fsCourseWatchLogProjectFiller;
 
+    @Autowired
+    private FsUserCourseVideoMapper fsUserCourseVideoMapper;
+
+    @Autowired
+    private FsUserCourseMapper fsUserCourseMapper;
+
     @Autowired
     private IQwSopLogsService qwSopLogsService;
 
@@ -1841,32 +1847,65 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                         Long externalIdLong = null;
                         String chatIdForFeishu = null;
                         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;
                         } else {
                             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,
                                 qwUserId, companyUserId, companyId, externalIdLong, chatIdForFeishu, cachedCourseConfig);
                         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.setIsBindUrl("2");
+                        // 链接卡片标题/描述/封面为空时兜底,避免 PAD 发出空卡片
+                        fillFeishuLinkCardDefaults(setting, videoId, courseId);
                     } catch (Exception e) {
                         log.error("生成飞书注册链接异常,跳过本条: sopId={}, videoId={}, externalId={}",
                                 logVo.getSopId(), videoId, externalId, e);
@@ -1882,12 +1921,26 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                         Long externalIdFeishuNew = null;
                         String chatIdForFeishuNew = null;
                         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;
                         } else {
                             externalIdFeishuNew = Long.parseLong(externalId);
+                            // watchType=3 飞书看课
                             addWatchLogIfNeeded(sopLogs, videoId, courseId, sendTime, qwUserId, companyUserId,
-                                    companyId, externalId, logVo, 2);
+                                    companyId, externalId, logVo, 3);
                         }
                         CourseConfig feishuNewConfig = config != null ? config : cachedCourseConfig;
                         String feishuNewUrl = createFeishuNewSendUrl(setting, sopLogs.getCorpId(), sendTime,
@@ -1903,6 +1956,8 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                         }
                         setting.setLinkUrl(feishuNewUrl);
                         setting.setIsBindUrl("2");
+                        // 链接卡片标题/描述/封面为空时兜底,避免 PAD 发出空卡片
+                        fillFeishuLinkCardDefaults(setting, videoId, courseId);
                     } catch (Exception e) {
                         log.error("生成飞书NEW链接异常,跳过本条: sopId={}, videoId={}, externalId={}",
                                 logVo.getSopId(), videoId, externalId, e);
@@ -2206,6 +2261,42 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
         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 一致)
      */
@@ -2247,6 +2338,23 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
         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):按需授权链路处理——
      * 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.TagGroupUpdateParam;
 import com.fs.qw.result.QwExternalContactByQwResult;
+import com.fs.qw.service.IExternalContactRedPacketService;
 import com.fs.qw.service.IQwExternalContactService;
 import com.fs.qw.service.IQwTagGroupService;
+import com.fs.qw.param.SidebarSendRedPacketParam;
 import com.fs.qw.vo.QwTagGroupListVO;
 import com.fs.qw.vo.sidebar.ExternalContactInfoVO;
 import com.fs.qw.vo.sidebar.ExternalContactTagVO;
@@ -60,6 +62,9 @@ public class ApisQwSopController  extends BaseController {
 
     @Autowired
     private IQwSopService qwSopService;
+
+    @Autowired
+    private IExternalContactRedPacketService externalContactRedPacketService;
     /**
      * 更新AI发送状态
      */
@@ -203,4 +208,13 @@ public class ApisQwSopController  extends BaseController {
         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.QwSidebarStatsParam;
 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.TagGroupListParam;
 import com.fs.qw.param.sidebar.TagGroupUpdateParam;
 import com.fs.qw.result.QwExternalContactByQwResult;
+import com.fs.qw.service.IExternalContactRedPacketService;
 import com.fs.qw.service.IQwExternalContactService;
 import com.fs.qw.service.IQwTagGroupService;
 import com.fs.qw.vo.QwTagGroupListVO;
@@ -63,6 +65,9 @@ public class QwSopController extends BaseController {
     @Autowired
     private IQwSopService qwSopService;
 
+    @Autowired
+    private IExternalContactRedPacketService externalContactRedPacketService;
+
     /**
      * 更新AI发送状态
      */
@@ -151,4 +156,13 @@ public class QwSopController extends BaseController {
         List<FsCourseWatchLogStatisticsListVO> list = qwSopService.externalWatchRecordStatsList(qwParam);
         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**/
     private String appId;
 
-    private Integer watchType;//看课方式:1 app  2 小程序
+    private Integer watchType;//看课方式:1 app  2 小程序  3 飞书
 
     /** 群聊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.time.LocalDateTime;
+import java.util.Date;
 import java.util.List;
 import java.util.Map;
 
@@ -80,9 +81,38 @@ public interface FsCourseRedPacketLogMapper
     @Select("select * from fs_course_red_packet_log where out_batch_no = #{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}")
     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 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();
 
+    /**
+     * 当天完课记录(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);
 
     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
     public R syncRedPacket(String outBatchNo, String batchId) {
+        logger.info("【红包同步】开始 批次号(outBatchNo)={}, 微信单号(batchId)={}", outBatchNo, batchId);
         FsCourseRedPacketLog log = fsCourseRedPacketLogMapper.selectFsCourseRedPacketLogByBatchNo(outBatchNo);
         if (log!=null){
             log.setStatus(1);
@@ -189,8 +190,11 @@ public class FsCourseRedPacketLogServiceImpl implements IFsCourseRedPacketLogSer
             redLogs.setStatus(1L);
             companyRedPacketBalanceLogsMapper.updateCompanyRedPacketBalanceLogsByRedPacketId(redLogs);
 
+            logger.info("【红包同步】成功 logId={}, 批次号={}, 微信单号={}, companyId={}, userId={}",
+                    log.getLogId(), outBatchNo, batchId, log.getCompanyId(), log.getUserId());
             return R.ok();
         }
+        logger.warn("【红包同步】批次不存在 批次号={}, 微信单号={}", outBatchNo, batchId);
         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();
 
-        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());
         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);
         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,
                                         Integer feishuNeedAuth) {
         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,
                                         Long companyUserId, String shortLink, Long feishuAccountId,
                                         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)) {
             return getFeishuRegisterLink(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,
                                             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,
                                             Long companyUserId, String shortLink, Long feishuAccountId,
                                             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();
         if (!Boolean.TRUE.equals(config.getEnableFeishuNewLink())) {
             throw new CustomException("未开启飞书看课");
@@ -200,6 +221,7 @@ public class FeiShuService {
             throw new CustomException("视频不存在: " + videoId);
         }
         int questionFlag = resolveQuestionFlag(userCourseVideo);
+        long embedUserId = fsUserId != null ? fsUserId : 0L;
 
         List<FeishuAccount> accounts = clientPool.listEnabledAccounts(companyUserId, feishuAccountId);
         CustomException lastError = null;
@@ -207,7 +229,8 @@ public class FeiShuService {
             FeishuClientHolder holder = clientPool.getClient(companyUserId, account.getId());
             try {
                 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());
                 return docUrl;
             } catch (CustomException e) {
@@ -350,10 +373,9 @@ public class FeiShuService {
             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);
         if (StringUtils.isNotBlank(cached)) {
@@ -387,17 +409,20 @@ public class FeiShuService {
     }
 
     /**
-     * 同一销售、同一节课、同一飞书账号、同一天内复用免授权飞书文档,降低并发创建导致的 folder locked。
+     * 按短链 + 领取用户隔离免授权飞书文档:
+     * 私聊短链本身一人一条;群共享短链相同,必须再按 embedUserId 区分,否则 iframe 内 userId 会串号。
      */
     private String getOrCreateDirectCourseDocUrl(FeishuClientHolder holder, Long feishuAccountId,
                                                  Long companyUserId, Long companyId, Long courseId,
                                                  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);
         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;
         }
 
@@ -436,7 +461,7 @@ public class FeiShuService {
                 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 resultUrl = FEISHU_DOC_URL_PREFIX + documentId;
             int ttlSeconds = getDirectDocCacheTtlSeconds();
@@ -444,8 +469,8 @@ public class FeiShuService {
             // 写入分片索引,供一键群发失效时按实际分片精确删除(不依赖 SCAN / 固定上界)
             registerDirectDocCacheKey(cacheKey, companyUserId, videoId, courseId, feishuAccountId, ttlSeconds);
             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;
         } catch (InterruptedException e) {
             Thread.currentThread().interrupt();
@@ -576,17 +601,17 @@ public class FeiShuService {
         return deleted;
     }
 
+    /**
+     * 免授权文档缓存:fs_course_link.link + embedUserId(发群共用一条 link,领取时按用户区分)
+     */
     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) {
-            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() {

+ 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;
 
+    /**
+     * 关联企微主体 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
      */
     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;
 
+    /**
+     * 销售端发红包:按企微主体查找商户应用配置。有值时走主体商户号,不再用全局 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);
 
+    /**
+     * 商家转账 V3 回调:按路径中的商户号从 merchant_app_config 取配置验签并同步红包状态
+     *
+     * @param mchId 微信商户号
+     */
+    String v3TransferNotifyWithMchId(String mchId, String notifyData, HttpServletRequest request);
+
 
 
     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}")
     private String ENABLE_RED_PACK_ACCOUNT;
 
+    /**
+     * 商家转账 V3 回调域名(不含尾斜杠),实际地址 = {domain}/app/wxpay/v3TransferNotifyWithMchId/{mchId}
+     */
+    @Value("${wx.transfer.notify-domain:}")
+    private String wxTransferNotifyDomain;
+
     @Autowired
     private ICompanyConfigService companyConfigService;
 
@@ -642,31 +648,7 @@ public class FsStorePaymentServiceImpl implements IFsStorePaymentService {
                 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);
             //组合返回参数
             R result = new R();
@@ -683,6 +665,9 @@ public class FsStorePaymentServiceImpl implements IFsStorePaymentService {
             // 用户领取红包次数+1
             redisTemplateInteger.opsForValue().increment(userLimitKey, 1);
             return result;
+        }catch (CustomException e){
+            logger.error("领取红包失败原因:{}", e.getMessage());
+            return R.error(e.getMessage());
         }catch (Exception e){
             logger.error("领取红包失败原因:{}", ExceptionUtils.getMessage(e),e);
             if (e instanceof WxPayException && "北京存在文化".equals(signProjectName)) {
@@ -771,36 +756,7 @@ public class FsStorePaymentServiceImpl implements IFsStorePaymentService {
             redPacketLog.setAccBalanceBefore(companyMoney);
             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);
             //组合返回参数
             R result = new R();
@@ -829,6 +785,13 @@ public class FsStorePaymentServiceImpl implements IFsStorePaymentService {
             redisTemplateInteger.opsForValue().increment(userLimitKey, 1);
 
             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){
             logger.error("领取红包失败原因:{}", ExceptionUtils.getMessage(e),e);
             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() {
         Calendar calendar = Calendar.getInstance();
         calendar.add(Calendar.DAY_OF_YEAR, 1);
@@ -1150,16 +1383,24 @@ public class FsStorePaymentServiceImpl implements IFsStorePaymentService {
             signatureHeader.setSerial(request.getHeader("Wechatpay-Serial"));
             signatureHeader.setSignature(request.getHeader("Wechatpay-Signature"));
             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)){
                     return WxPayNotifyResponse.success("处理成功");
                 }else {
+                    logger.warn("【转账回调V3】同步失败 批次号={}, 微信单号={}, result={}", outBatchNo, batchId, r);
                     return WxPayNotifyResponse.fail("");
                 }
             }else {
+                logger.warn("【转账回调V3】状态非SUCCESS 批次号={}, 微信单号={}, state={}", outBatchNo, batchId, state);
                 return WxPayNotifyResponse.fail("");
             }
         } catch (WxPayException e) {
@@ -1191,21 +1432,23 @@ public class FsStorePaymentServiceImpl implements IFsStorePaymentService {
             signatureHeader.setSerial(request.getHeader("Wechatpay-Serial"));
             signatureHeader.setSignature(request.getHeader("Wechatpay-Signature"));
             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)) {
                     return WxPayNotifyResponse.success("处理成功");
                 }
-                logger.warn("【App转账回调V3】同步失败,outBillNo={}", result.getResult().getOutBillNo());
+                logger.warn("【App转账回调V3】同步失败 批次号={}, 微信单号={}", outBatchNo, batchId);
                 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("");
         } catch (WxPayException 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.setSignature(request.getHeader("Wechatpay-Signature"));
             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)){
                     return WxPayNotifyResponse.success("处理成功");
                 }else {
+                    logger.warn("【转账回调V3-分公司】同步失败 companyId={}, 批次号={}, 微信单号={}, result={}",
+                            companyId, outBatchNo, batchId, r);
                     return WxPayNotifyResponse.fail("");
                 }
             }else {
+                logger.warn("【转账回调V3-分公司】状态非SUCCESS companyId={}, 批次号={}, 微信单号={}, state={}",
+                        companyId, outBatchNo, batchId, state);
                 return WxPayNotifyResponse.fail("");
             }
         } catch (WxPayException e) {
             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());
         }
     }

+ 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);
                 Integer count = baseMapper.checkTableExists();
                 if (ObjectUtil.isNotNull(count)&&count>0) {
+                    // 存量表补主体配置字段
+                    ensureCorpIdsColumn();
                     log.info("异步初始化商户配置表完成");
                     return;
                 }
@@ -79,6 +81,22 @@ public class MerchantAppConfigServiceImpl extends ServiceImpl<MerchantAppConfigM
             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;
+
+    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>")
     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();
 
     @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.FsCourseLinkMapper;
 import com.fs.course.mapper.FsCourseWatchLogMapper;
+import com.fs.course.mapper.FsUserCourseMapper;
 import com.fs.course.param.FsCourseLinkCreateParam;
 import com.fs.course.service.IFsCourseLinkService;
 import com.fs.course.service.IFsUserCourseVideoService;
@@ -181,6 +182,9 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
     @Autowired
     private IFsUserCourseVideoService iFsUserCourseVideoService;
 
+    @Autowired
+    private FsUserCourseMapper fsUserCourseMapper;
+
 
     @Autowired
     private CompanyUserMapper companyUserMapper;
@@ -595,11 +599,20 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
             // 与个人路径「本次覆盖客户数」语义对齐:统计未退群的外部联系人(type=2),而非选中群数量
             groupPathCustomerCount = countGroupMassSendCustomerCount(groupUserList, groupList);
 
-            //没有传值课程和课节 是直播的数据
+            //没有传值课程和课节 是直播的数据;群聊飞书/小程序发课统一按群成员写看课记录
             if(null != param.getCourseId() && null !=param.getVideoId()){
                 try {
                     groupList.forEach(groupChat -> {
                         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 -> {
                             Map<String, GroupUserExternalVo> userMap = PubFun.listToMapByGroupObject(e.getUserList(), GroupUserExternalVo::getUserId);
                             GroupUserExternalVo vo = userMap.get(groupChat.getOwner());
@@ -782,38 +795,56 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                 break;
                             //飞书看课(固定免授权)
                             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;
                             //飞书NEW:走需授权链路(发时写看课记录 + 入库短链),发出授权页 URL
                             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;
                             //群公告
                             case "11":
@@ -848,6 +879,11 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
             else {
                 sopLogsList = groupList.stream().map(groupChat -> {
                     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){
                         throw new BaseException("当前企微没绑定员工,无法发送!");
                     }
@@ -1023,35 +1059,45 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                     throw new RuntimeException(e);
                                 }
                                 break;
-                            //飞书看课(发群:chatId 是企微字符串,不能转 Long,与小程序发群一样挂 chatId;固定免授权
+                            //飞书看课(发群:只发一条领取页;领取时按人建飞书链接,发课时不建文档
                             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;
-                            //飞书NEW(发群:挂 chatId)
+                            //飞书NEW(发群:只发一条领取页,挂 chatId;领取时按 link+userId 建链
                             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;
                             //群公告
                             case "11":
@@ -1078,7 +1124,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                     setting.setLiveId(param.getLiveId());
                     sopLogs.setContentJson(JSON.toJSONString(setting));
                     return sopLogs;
-                }).collect(Collectors.toList());
+                }).filter(Objects::nonNull).collect(Collectors.toList());
             }
         }else{
             sopLogsList = new ArrayList<>();
@@ -1371,6 +1417,10 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                 log.error("飞书链接生成失败:视频ID为空,sopId={}", param.getSopId());
                                 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 customerTitle = StringUtil.strIsNullOrEmpty(contact.getStageStatus()) || "0".equals(contact.getStageStatus()) ? "同学" : contact.getStageStatus();
                             if (StringUtils.isNotEmpty(st.getLinkTitle())) {
@@ -1392,16 +1442,17 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                     param.getCorpId(), createTime, param.getStartTime(), feishuShardAllocator);
                             if (StringUtils.isNotEmpty(feishuLink)) {
                                 st.setLinkUrl(feishuLink);
+                                fillFeishuLinkCardDefaults(st, param.getVideoId().longValue(), param.getCourseId().longValue());
                             }
                             break;
                         //飞书NEW:走需授权链路(发时写看课记录 + 入库短链),发出授权页 URL
                         case "18":
                             Integer videoIdFeishuNewPrivate = param.getVideoId() != null ? param.getVideoId().intValue() : null;
                             Integer courseIdFeishuNewPrivate = param.getCourseId() != null ? param.getCourseId().intValue() : null;
-                            // 与飞书需授权(17)一致:发送时写入看课记录
+                            // 与飞书需授权(17)一致:发送时写入看课记录;watchType=3 飞书
                             addWatchLogIfNeeded(item.getSopId(), param.getVideoId(), param.getCourseId(),
                                     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 customerTitleFeishuNew = StringUtil.strIsNullOrEmpty(contact.getStageStatus()) || "0".equals(contact.getStageStatus()) ? "同学" : contact.getStageStatus();
                             if (StringUtils.isNotEmpty(st.getLinkTitle())) {
@@ -1416,6 +1467,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                             }
                             st.setLinkUrl(createFeishuNewSendUrl(st, param.getCorpId(), createTime, courseIdFeishuNewPrivate, videoIdFeishuNewPrivate,
                                     Long.valueOf(qwUserId), companyUserId, companyId, item.getExternalId(), config, null));
+                            fillFeishuLinkCardDefaults(st, param.getVideoId().longValue(), param.getCourseId().longValue());
                             break;
                         //群公告(仅用于一键群发,个人不应该有群公告)
                         case "11":
@@ -1595,6 +1647,8 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
 
     @Override
     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);
         if(sopUserLogsIds == null || sopUserLogsIds.isEmpty()){
             throw new BaseException("SOP没有可发送的营期数据");
@@ -1602,10 +1656,80 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
         String[] ids = sopUserLogsIds.toArray(new String[0]);
         param.setIds(ids);
         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);
         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
     public List<SopUserLogsInfo> selectRestoreByIsDaysNotStudy(String sopId, String userLogsId) {
         return sopUserLogsInfoMapper.selectRestoreByIsDaysNotStudy(sopId, userLogsId);
@@ -2151,6 +2275,10 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                         log.error("飞书链接生成失败:外部ID为空,sopId={}", item.getSopId());
                         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);
                     String shortLink = feishuH5Link.get("link");
                     String feishuLink = resolveFeishuSendLinkSafely(videoId18.longValue(), companyIdLong, courseId18.longValue(),
@@ -2173,6 +2301,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                     .replaceAll("#客户称呼#", customerTitle));
                         }
                         st.setLinkUrl(feishuLink);
+                        fillFeishuLinkCardDefaults(st, param.getVideoId().longValue(), param.getCourseId().longValue());
                     }
                     break;
                 //飞书NEW:走需授权链路(发时写看课记录 + 入库短链),发出授权页 URL
@@ -2184,10 +2313,10 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                         log.error("飞书NEW链接生成失败:外部ID为空,sopId={}", item.getSopId());
                         break;
                     }
-                    // 与飞书需授权(17)一致:发送时写入看课记录
+                    // 与飞书需授权(17)一致:发送时写入看课记录;watchType=3 飞书
                     addWatchLogIfNeeded(item.getSopId(), param.getVideoId(), param.getCourseId(),
                             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 customerTitleFeishuNewPs = contact == null ? "同学" :
                             (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,
                             qwUser.getId(), companyUserId, companyId, externalIdFeishuNew, config, null));
+                    fillFeishuLinkCardDefaults(st, param.getVideoId().longValue(), param.getCourseId().longValue());
                     break;
 
                 default:
@@ -2214,6 +2344,22 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
         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):按需授权链路处理——
      * 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,
                                      Long fsUserId, String qwUserId, String companyUserId,
@@ -2786,7 +2968,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                 directDocShard = feishuShardAllocator.nextShard(companyUserId, videoId, courseId, feishuAccountId);
             }
             String feishuLink = feiShuService.resolveFeishuSendLink(videoId, companyId, courseId, companyUserId,
-                    shortLink, feishuAccountId, feishuNeedAuth, directDocShard);
+                    shortLink, feishuAccountId, feishuNeedAuth, directDocShard, fsUserId);
             if (StringUtils.isEmpty(feishuLink)) {
                 log.error("生成飞书注册链接失败,跳过本条: 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_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
     me.chanjar.weixin: DEBUG
 wx:
+  # 商家转账 V3 回调域名(不含尾斜杠),实际地址 = {notify-domain}/app/wxpay/v3TransferNotifyWithMchId/{mchId}
+  transfer:
+    notify-domain: https://userapp.his.runtzh.com
   miniapp:
     configs:
       - appid: wx29d26f63f836be7f

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

@@ -12,6 +12,10 @@ wx:
   open:
     appId: wxfefe26a6c1ff71ba
     secret: 4e87854df5f4051894d67cc0ecba3a9a
+  # 商家转账 V3 回调域名(不含尾斜杠)
+  # 实际 notifyUrl = {notify-domain}/app/wxpay/v3TransferNotifyWithMchId/{mchId}
+  transfer:
+    notify-domain: https://userapp.moonxiang.com
   miniapp:
     configs:
       - 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="updatedBy"    column="updated_by"    />
         <result property="merchantId"    column="merchant_id"    />
+        <result property="corpIds"    column="corp_ids"    />
     </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或用户名',
         `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 '商户号',
+        `corp_ids` varchar(1024) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '关联企微主体corpId,多个逗号分隔',
         PRIMARY KEY (`id`),
         KEY `idx_merchant_type` (`merchant_type`),
         KEY `idx_app_id` (`app_id`),
@@ -48,7 +50,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
 
 
     <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>
 
     <select id="selectMerchantAppConfigList" parameterType="MerchantAppConfig" resultMap="MerchantAppConfigResult">
@@ -68,7 +70,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         where id = #{id}
     </select>
     <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.created_by, a.updated_by
         from merchant_app_config a
@@ -77,6 +79,40 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
           and fcpsc.appid like CONCAT('%', #{appId}, '%')
     </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 into merchant_app_config
         <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="updatedBy != null and updatedBy != ''">updated_by,</if>
             <if test="merchantId != null and merchantId != ''">merchant_id,</if>
+            <if test="corpIds != null">corp_ids,</if>
          </trim>
         <trim prefix="values (" suffix=")" suffixOverrides=",">
             <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="updatedBy != null and updatedBy != ''">#{updatedBy},</if>
             <if test="merchantId != null and merchantId != ''">#{merchantId},</if>
+            <if test="corpIds != null">#{corpIds},</if>
          </trim>
     </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="updatedBy != null and updatedBy != ''">updated_by = #{updatedBy},</if>
             <if test="merchantId != null and merchantId != ''">merchant_id = #{merchantId},</if>
+            <if test="corpIds != null">corp_ids = #{corpIds},</if>
         </trim>
         where id = #{id}
     </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
         <trim suffixOverrides=",">
             <if test="updateTime != null">update_time = #{updateTime},</if>
+            <!-- 飞书等渠道重发时,强制覆盖看课类型 -->
+            <if test="watchType != null">watch_type = #{watchType},</if>
         </trim>
 
             </insert>
@@ -463,7 +465,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
                 </foreach>
                 ON DUPLICATE KEY UPDATE
                 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>
 
 
@@ -1586,6 +1590,72 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <foreach collection="logIds" item="id" open="(" separator="," close=")">
             #{id}
         </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>
 
 </mapper>

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

@@ -453,6 +453,7 @@
             id,
             sop_id,
             qw_user_id,
+            chat_id,
             corp_id,
             start_time,
             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 天 */
     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 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,
                                                 FsUser currentUser, Long qwUserId) {
@@ -672,8 +672,8 @@ public class CourseMpLoginController extends AppBaseController {
         watchLog.setCreateTime(now);
         watchLog.setUpdateTime(now);
         watchLog.setLogType(3);
-        // 有 chatId 视为群看课,与 SOP 群发小程序 watchType=1 一致
-        watchLog.setWatchType(StringUtils.isNotBlank(courseLink.getChatId()) ? 1 : 2);
+        // 飞书看课标识
+        watchLog.setWatchType(3);
         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) {
         String linkCode = courseLink.getLink();
@@ -743,17 +743,18 @@ public class CourseMpLoginController extends AppBaseController {
             courseLink.setFeishuAccountId(activeAccount.getId());
             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);
         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);
         }
         String feishuUrl = createFeishuJumpUrl(courseLink, currentUser);
         if (StringUtils.isNotBlank(feishuUrl)) {
             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;
     }
@@ -783,7 +784,9 @@ public class CourseMpLoginController extends AppBaseController {
                 courseLink.getCourseId(),
                 courseLink.getCompanyUserId(),
                 linkCode,
-                courseLink.getFeishuAccountId());
+                courseLink.getFeishuAccountId(),
+                null,
+                currentUser.getUserId());
     }
 
     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}")
     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);
     }
 
+    /**
+     * 商家转账 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
     private IFsCourseRedPacketLogService fsCourseRedPacketLogService;