فهرست منبع

cid的bug迁移/一键群发时间修改/直播答题红包/直播核销卷/ai语音/app侧边栏

三七 3 هفته پیش
والد
کامیت
786e8bcec2
80فایلهای تغییر یافته به همراه2757 افزوده شده و 135 حذف شده
  1. 28 0
      fs-admin/src/main/java/com/fs/course/controller/FsUserCourseController.java
  2. 20 1
      fs-cid-workflow/src/main/java/com/fs/app/task/CidTask.java
  3. 65 0
      fs-company-app/src/main/java/com/fs/app/controller/FsCourseCouponUserController.java
  4. 181 40
      fs-company/src/main/java/com/fs/company/controller/qw/QwExternalContactController.java
  5. 50 0
      fs-company/src/main/java/com/fs/company/controller/qw/QwSopTempVoiceController.java
  6. 7 0
      fs-service/src/main/java/com/fs/company/mapper/CompanyAiWorkflowExecMapper.java
  7. 9 7
      fs-service/src/main/java/com/fs/company/mapper/CompanySiptaskInfoMapper.java
  8. 10 0
      fs-service/src/main/java/com/fs/company/mapper/CompanyVoiceRoboticCallLogCallphoneMapper.java
  9. 8 0
      fs-service/src/main/java/com/fs/company/mapper/EasyCallMapper.java
  10. 14 0
      fs-service/src/main/java/com/fs/company/service/IAiCallCallbackCompensateService.java
  11. 6 1
      fs-service/src/main/java/com/fs/company/service/ICompanyVoiceRoboticService.java
  12. 98 0
      fs-service/src/main/java/com/fs/company/service/easycall/OutboundLimitRetryRedisHelper.java
  13. 343 0
      fs-service/src/main/java/com/fs/company/service/easycall/OutboundLimitRetrySupport.java
  14. 228 0
      fs-service/src/main/java/com/fs/company/service/impl/AiCallCallbackCompensateServiceImpl.java
  15. 171 21
      fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticServiceImpl.java
  16. 21 0
      fs-service/src/main/java/com/fs/company/vo/PendingAiCallLogSyncVo.java
  17. 16 0
      fs-service/src/main/java/com/fs/company/vo/easycall/EasyCallCompletedCallVo.java
  18. 19 4
      fs-service/src/main/java/com/fs/course/mapper/FsCourseWatchLogMapper.java
  19. 2 0
      fs-service/src/main/java/com/fs/course/mapper/FsUserCourseMapper.java
  20. 11 0
      fs-service/src/main/java/com/fs/course/param/FsUserCourseEditParam.java
  21. 8 0
      fs-service/src/main/java/com/fs/course/service/IFsCourseQuestionBankService.java
  22. 3 0
      fs-service/src/main/java/com/fs/course/service/IFsUserCourseService.java
  23. 218 0
      fs-service/src/main/java/com/fs/course/service/impl/FsCourseQuestionBankServiceImpl.java
  24. 66 0
      fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseServiceImpl.java
  25. 23 0
      fs-service/src/main/java/com/fs/enums/NodeExectorEnum.java
  26. 13 2
      fs-service/src/main/java/com/fs/his/mapper/FsCourseCouponUserMapper.java
  27. 6 0
      fs-service/src/main/java/com/fs/his/mapper/FsUserMapper.java
  28. 1 0
      fs-service/src/main/java/com/fs/his/param/FsCourseCouponUserUParam.java
  29. 5 0
      fs-service/src/main/java/com/fs/his/service/IFsUserService.java
  30. 1 0
      fs-service/src/main/java/com/fs/his/service/impl/FsCourseCouponUserServiceImpl.java
  31. 8 0
      fs-service/src/main/java/com/fs/his/service/impl/FsUserServiceImpl.java
  32. 47 0
      fs-service/src/main/java/com/fs/live/domain/LiveCompletionAnswerRecord.java
  33. 8 0
      fs-service/src/main/java/com/fs/live/domain/LiveWatchConfig.java
  34. 24 0
      fs-service/src/main/java/com/fs/live/mapper/LiveCompletionAnswerRecordMapper.java
  35. 27 0
      fs-service/src/main/java/com/fs/live/param/LiveCompletionAnswerRecordParam.java
  36. 29 0
      fs-service/src/main/java/com/fs/live/param/LiveCompletionCouponAnswerItem.java
  37. 17 0
      fs-service/src/main/java/com/fs/live/param/LiveCompletionCouponAnswerParam.java
  38. 1 0
      fs-service/src/main/java/com/fs/live/param/LiveRedPacketParam.java
  39. 44 0
      fs-service/src/main/java/com/fs/live/service/ILiveCompletionAnswerRecordService.java
  40. 3 0
      fs-service/src/main/java/com/fs/live/service/ILiveService.java
  41. 82 0
      fs-service/src/main/java/com/fs/live/service/impl/LiveCompletionAnswerRecordServiceImpl.java
  42. 80 27
      fs-service/src/main/java/com/fs/live/service/impl/LiveRedPacketLogServiceImpl.java
  43. 58 4
      fs-service/src/main/java/com/fs/live/service/impl/LiveServiceImpl.java
  44. 11 4
      fs-service/src/main/java/com/fs/live/service/impl/LiveWatchUserServiceImpl.java
  45. 24 0
      fs-service/src/main/java/com/fs/live/vo/AnswerEvaluation.java
  46. 24 0
      fs-service/src/main/java/com/fs/live/vo/LiveCompletionAnswerDetailVO.java
  47. 39 0
      fs-service/src/main/java/com/fs/live/vo/LiveCompletionAnswerRecordListVO.java
  48. 27 0
      fs-service/src/main/java/com/fs/live/vo/LiveCompletionCouponAnswerResult.java
  49. 2 2
      fs-service/src/main/java/com/fs/live/vo/RecordTimeRangeVO.java
  50. 14 1
      fs-service/src/main/java/com/fs/qw/mapper/QwExternalContactMapper.java
  51. 3 0
      fs-service/src/main/java/com/fs/qw/mapper/QwUserMapper.java
  52. 5 0
      fs-service/src/main/java/com/fs/qw/param/QwExternalContactParam.java
  53. 4 0
      fs-service/src/main/java/com/fs/qw/service/IQwExternalContactService.java
  54. 5 0
      fs-service/src/main/java/com/fs/qw/service/IQwTagService.java
  55. 3 0
      fs-service/src/main/java/com/fs/qw/service/IQwUserService.java
  56. 5 0
      fs-service/src/main/java/com/fs/qw/service/impl/QwExternalContactServiceImpl.java
  57. 8 0
      fs-service/src/main/java/com/fs/qw/service/impl/QwTagServiceImpl.java
  58. 5 0
      fs-service/src/main/java/com/fs/qw/service/impl/QwUserServiceImpl.java
  59. 2 0
      fs-service/src/main/java/com/fs/qwApi/param/QwExternalContactHParam.java
  60. 3 0
      fs-service/src/main/java/com/fs/sop/mapper/QwSopTempVoiceMapper.java
  61. 1 0
      fs-service/src/main/java/com/fs/sop/params/SendUserLogsInfoMsgParam.java
  62. 2 0
      fs-service/src/main/java/com/fs/sop/service/IQwSopTempVoiceService.java
  63. 6 0
      fs-service/src/main/java/com/fs/sop/service/impl/QwSopTempVoiceServiceImpl.java
  64. 11 4
      fs-service/src/main/java/com/fs/sop/service/impl/SopUserLogsInfoServiceImpl.java
  65. 1 1
      fs-service/src/main/resources/application-druid-sxsm.yml
  66. 20 1
      fs-service/src/main/resources/db/changelog/table/live_group_type.sql
  67. 9 0
      fs-service/src/main/resources/mapper/company/CompanyAiWorkflowExecMapper.xml
  68. 11 6
      fs-service/src/main/resources/mapper/company/CompanySiptaskInfoMapper.xml
  69. 26 0
      fs-service/src/main/resources/mapper/company/CompanyVoiceRoboticCallLogCallphoneMapper.xml
  70. 1 1
      fs-service/src/main/resources/mapper/company/CompanyVoiceRoboticMapper.xml
  71. 25 0
      fs-service/src/main/resources/mapper/company/EasyCallMapper.xml
  72. 1 1
      fs-service/src/main/resources/mapper/course/FsCoursePlaySourceConfigMapper.xml
  73. 9 0
      fs-service/src/main/resources/mapper/course/FsUserCourseMapper.xml
  74. 92 0
      fs-service/src/main/resources/mapper/live/LiveCompletionAnswerRecordMapper.xml
  75. 12 0
      fs-service/src/main/resources/mapper/qw/QwUserMapper.xml
  76. 7 0
      fs-service/src/main/resources/mapper/sop/QwSopTempVoiceMapper.xml
  77. 108 0
      fs-user-app/src/main/java/com/fs/app/controller/AppBaseController.java
  78. 14 7
      fs-user-app/src/main/java/com/fs/app/controller/FsCourseCouponUserController.java
  79. 58 0
      fs-user-app/src/main/java/com/fs/app/controller/app/AppQwStatisticsController.java
  80. 80 0
      fs-user-app/src/main/java/com/fs/app/controller/live/LiveCompletionCouponController.java

+ 28 - 0
fs-admin/src/main/java/com/fs/course/controller/FsUserCourseController.java

@@ -18,6 +18,7 @@ import com.fs.common.utils.poi.ExcelUtil;
 import com.fs.course.cache.PublicCourseAppCacheNames;
 import com.fs.course.cache.PublicCourseAppCacheNames;
 import com.fs.course.config.CourseConfig;
 import com.fs.course.config.CourseConfig;
 import com.fs.course.domain.FsUserCourse;
 import com.fs.course.domain.FsUserCourse;
+import com.fs.course.param.FsUserCourseEditParam;
 import com.fs.course.params.FsUserCourseConfigParam;
 import com.fs.course.params.FsUserCourseConfigParam;
 import com.fs.course.service.IFsUserCourseService;
 import com.fs.course.service.IFsUserCourseService;
 import com.fs.course.service.IFsUserCourseVideoService;
 import com.fs.course.service.IFsUserCourseVideoService;
@@ -230,6 +231,30 @@ public class FsUserCourseController extends BaseController {
         return toAjax(1);
         return toAjax(1);
     }
     }
 
 
+    /**
+     * 快捷归属课程到销售公司
+     */
+    @PreAuthorize("@ss.hasPermi('course:userCourse:publicEdit')")
+    @Log(title = "快捷归属课程到销售公司", businessType = BusinessType.UPDATE)
+    @PostMapping("/publicEditCompany")
+    public R publicEditCompany(@RequestBody FsUserCourseEditParam param) {
+
+        return  fsUserCourseService.publicEditCompany(param);
+    }
+
+
+    /**
+     * 快捷移除课程-销售公司
+     */
+    @PreAuthorize("@ss.hasPermi('course:userCourse:publicEdit')")
+    @Log(title = "快捷移除课程-销售公司", businessType = BusinessType.UPDATE)
+    @PostMapping("/publicRemoveEditCompany")
+    public R publicRemoveEditCompany(@RequestBody FsUserCourseEditParam param) {
+
+        return  fsUserCourseService.publicRemoveEditCompany(param);
+    }
+
+
     /**
     /**
      * 统一修改课程红包
      * 统一修改课程红包
      */
      */
@@ -256,6 +281,9 @@ public class FsUserCourseController extends BaseController {
         return toAjax(1);
         return toAjax(1);
     }
     }
 
 
+
+
+
     /**
     /**
      * 复制课程
      * 复制课程
      */
      */

+ 20 - 1
fs-cid-workflow/src/main/java/com/fs/app/task/CidTask.java

@@ -2,6 +2,7 @@ package com.fs.app.task;
 
 
 import com.fs.app.service.CidWorkflowTaskService;
 import com.fs.app.service.CidWorkflowTaskService;
 import com.fs.app.service.OutboundRetryTaskService;
 import com.fs.app.service.OutboundRetryTaskService;
+import com.fs.company.service.IAiCallCallbackCompensateService;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.beans.factory.annotation.Value;
@@ -27,6 +28,12 @@ public class CidTask {
     @Autowired
     @Autowired
     private OutboundRetryTaskService outboundRetryTaskService;
     private OutboundRetryTaskService outboundRetryTaskService;
 
 
+    @Autowired
+    private IAiCallCallbackCompensateService aiCallCallbackCompensateService;
+
+
+    @Value("${cid-group-no:0}")
+    private Integer cidGroupNo;
 
 
     /**
     /**
      * 扫描当前分组下就绪任务,并开启执行
      * 扫描当前分组下就绪任务,并开启执行
@@ -54,7 +61,7 @@ public class CidTask {
 
 
     /**
     /**
      * 回扫超时认领任务 - 每5分钟执行一次
      * 回扫超时认领任务 - 每5分钟执行一次
-     * 将卡在认领态(RUNNING)且长时间未流转的任务重置为失败,兜底进程重启/线程池拒绝导致的永久卡死
+     * 将卡在认领态(RUNNING)且长时间未流转的任务重置为失败,兜底进程重启/线r程池拒绝导致的永久卡死
      */
      */
     @Scheduled(cron = "0 0/5 * * * ?")
     @Scheduled(cron = "0 0/5 * * * ?")
     public void resetTimeoutRunningTask() {
     public void resetTimeoutRunningTask() {
@@ -76,4 +83,16 @@ public class CidTask {
     }
     }
 
 
 
 
+    /**
+     * AI 外呼回调补偿 - 每5分钟执行一次
+     * 扫描未回调完成的外呼记录,EasyCall 已完成则按 runParam 中的 callBackUrl 模拟 HTTP 回调
+     */
+    @Scheduled(cron = "30 0/1 * * * ?")
+    public void compensateAiCallCallback() {
+
+            aiCallCallbackCompensateService.compensatePendingCallbacks(cidGroupNo);
+
+    }
+
+
 }
 }

+ 65 - 0
fs-company-app/src/main/java/com/fs/app/controller/FsCourseCouponUserController.java

@@ -0,0 +1,65 @@
+package com.fs.app.controller;
+
+
+import com.fs.app.annotation.Login;
+import com.fs.common.core.domain.R;
+import com.fs.company.domain.CompanyUser;
+import com.fs.company.service.ICompanyUserService;
+import com.fs.course.param.CourseCouponVerifyParam;
+import com.fs.his.domain.FsCourseCouponUser;
+import com.fs.his.param.FsCourseCouponUserUParam;
+import com.fs.his.service.IFsCourseCouponUserService;
+import com.fs.his.vo.CourseCouponUserListUVO;
+import com.fs.his.vo.FsCourseCouponUserVO;
+import com.github.pagehelper.PageHelper;
+import com.github.pagehelper.PageInfo;
+import io.swagger.annotations.Api;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+@Api("用户课程优惠券接口")
+@RestController
+@RequestMapping(value="/app/courseCouponUser")
+public class FsCourseCouponUserController extends AppBaseController {
+
+    @Autowired
+    private IFsCourseCouponUserService fsCourseCouponUserService;
+
+    @Autowired
+    private ICompanyUserService companyUserService;
+
+    /**
+     * 获取销售已核销的列表
+     */
+    @Login
+    @PostMapping("/getUseCouponByCompanyUserId")
+    public R getUseCouponByCompanyUserId(@RequestBody FsCourseCouponUserUParam param) {
+
+        Long companyUserId = Long.parseLong(getUserId());
+
+        PageHelper.startPage(param.getPageNum(),param.getPageSize());
+        List<FsCourseCouponUserVO> userVOS = fsCourseCouponUserService.getUseCouponByCompanyUserId(companyUserId);
+        PageInfo<FsCourseCouponUserVO> pageInfo = new PageInfo<>(userVOS);
+        return R.ok().put("data",pageInfo);
+    }
+
+    /**
+     * 销售扫码核销优惠券
+     */
+    @Login
+    @PostMapping("/verifyCoupon")
+    public R verifyCoupon(@RequestBody CourseCouponVerifyParam param) {
+        Long companyUserId = Long.parseLong(getUserId());
+        CompanyUser companyUser = companyUserService.selectCompanyUserById(companyUserId);
+        if (companyUser == null) {
+            return R.error("销售不存在");
+        }
+        param.setCompanyUserId(companyUserId);
+        return fsCourseCouponUserService.verifyCoupon(param);
+    }
+
+}

+ 181 - 40
fs-company/src/main/java/com/fs/company/controller/qw/QwExternalContactController.java

@@ -120,10 +120,34 @@ public class QwExternalContactController extends BaseController
             qwExternalContact.setStatusCondition(split);
             qwExternalContact.setStatusCondition(split);
         }
         }
         List<QwExternalContactVO> list = qwExternalContactService.selectQwExternalContactListVO(qwExternalContact);
         List<QwExternalContactVO> list = qwExternalContactService.selectQwExternalContactListVO(qwExternalContact);
+
+
+        // 批量查询所有客户的标签名称,避免N+1查询
+        List<String> allTagIds = list.stream()
+                .map(QwExternalContactVO::getTagIds)
+                .filter(tagIds -> !Objects.equals(tagIds, "[]") && tagIds != null)
+                .flatMap(tagIds -> {
+                    Gson gson = new Gson();
+                    List<String> ids = gson.fromJson(tagIds, new TypeToken<List<String>>() {}.getType());
+                    return ids.stream();
+                })
+                .distinct()
+                .collect(Collectors.toList());
+
+        Map<String, String> tagIdToName = batchQueryTagIdToName(allTagIds);
+
+        // 批量查询所有客户的下载APP状态,避免N+1查询
+        Set<Long> downloadAppUserIds = list.stream()
+                .map(QwExternalContactVO::getFsUserId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+
+        downloadAppUserIds = batchQueryDownloadAppUserIds(downloadAppUserIds);
+
+        Set<Long> finalDownloadAppUserIds = downloadAppUserIds;
         list.forEach(item->{
         list.forEach(item->{
 
 
             if (!Objects.equals(item.getTagIds(), "[]") && item.getTagIds()!=null) {
             if (!Objects.equals(item.getTagIds(), "[]") && item.getTagIds()!=null) {
-                QwTagSearchParam param = new QwTagSearchParam();
                 Gson gson = new Gson();
                 Gson gson = new Gson();
                 List<String> tagIds = gson.fromJson(
                 List<String> tagIds = gson.fromJson(
                         item.getTagIds(),
                         item.getTagIds(),
@@ -131,25 +155,20 @@ public class QwExternalContactController extends BaseController
                         }.getType()
                         }.getType()
                 );
                 );
 
 
-                param.setTagIds(tagIds);
-
-                item.setTagIdsName(iQwTagService.selectQwTagListByTagIds(param));
+                List<String> tagNames = tagIds.stream()
+                        .filter(tagIdToName::containsKey)
+                        .map(tagIdToName::get)
+                        .collect(Collectors.toList());
+                item.setTagIdsName(tagNames);
             }
             }
 
 
             if (!StringUtil.strIsNullOrEmpty(item.getState()) && !wayList.isEmpty()) {
             if (!StringUtil.strIsNullOrEmpty(item.getState()) && !wayList.isEmpty()) {
                 item.setState(item.getState()+"-"+getContactWayNameStream(item.getState(), wayList));
                 item.setState(item.getState()+"-"+getContactWayNameStream(item.getState(), wayList));
             }
             }
 
 
-            if (item.getFsUserId() != null) {
-                FsUser fsUser = fsUserService.selectFsUserByUserId(item.getFsUserId());
-                if (fsUser != null && (fsUser.getSource() != null || fsUser.getLoginDevice() != null)) {
-                    item.setIsDownloadApp(1);
-                } else {
-                    item.setIsDownloadApp(0);
-                }
-            } else {
-                item.setIsDownloadApp(0);
-            }
+            item.setIsDownloadApp(
+                    item.getFsUserId() != null && finalDownloadAppUserIds.contains(item.getFsUserId()) ? 1 : 0
+            );
 
 
         });
         });
 
 
@@ -186,6 +205,47 @@ public class QwExternalContactController extends BaseController
         return input.substring(lastColonIndex + 1);
         return input.substring(lastColonIndex + 1);
     }
     }
 
 
+    /**
+     * 分批查询标签ID对应的标签名称,防止IN子句过长
+     */
+    private Map<String, String> batchQueryTagIdToName(List<String> tagIds) {
+        if (tagIds == null || tagIds.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        Map<String, String> result = new HashMap<>();
+        int batchSize = 500;
+        int total = tagIds.size();
+        for (int i = 0; i < total; i += batchSize) {
+            int end = Math.min(i + batchSize, total);
+            List<String> batch = tagIds.subList(i, end);
+            List<QwTag> tagList = iQwTagService.selectQwTagListByTagIds(batch);
+            tagList.forEach(tag -> result.putIfAbsent(tag.getTagId(), tag.getName()));
+        }
+        return result;
+    }
+
+    /**
+     * 分批查询已下载APP的用户ID集合,防止IN子句过长
+     */
+    private Set<Long> batchQueryDownloadAppUserIds(Set<Long> userIds) {
+        if (userIds == null || userIds.isEmpty()) {
+            return Collections.emptySet();
+        }
+        List<Long> userIdList = new ArrayList<>(userIds);
+        Set<Long> result = new HashSet<>();
+        int batchSize = 500;
+        int total = userIdList.size();
+        for (int i = 0; i < total; i += batchSize) {
+            int end = Math.min(i + batchSize, total);
+            List<Long> batch = userIdList.subList(i, end);
+            List<FsUser> fsUsers = fsUserService.selectFsUserSourceByUserIds(batch);
+            fsUsers.stream()
+                    .filter(u -> u.getSource() != null || u.getLoginDevice() != null)
+                    .map(FsUser::getUserId)
+                    .forEach(result::add);
+        }
+        return result;
+    }
 
 
     @GetMapping("/test")
     @GetMapping("/test")
     public AjaxResult test()
     public AjaxResult test()
@@ -240,10 +300,33 @@ public class QwExternalContactController extends BaseController
 
 
         startPage();
         startPage();
         List<QwExternalContactVO> list = qwExternalContactService.selectQwExternalContactListVO(qwExternalContact);
         List<QwExternalContactVO> list = qwExternalContactService.selectQwExternalContactListVO(qwExternalContact);
+
+        // 批量查询所有客户的标签名称,避免N+1查询
+        List<String> allTagIds = list.stream()
+                .map(QwExternalContactVO::getTagIds)
+                .filter(tagIds -> !Objects.equals(tagIds, "[]") && tagIds != null)
+                .flatMap(tagIds -> {
+                    Gson gson = new Gson();
+                    List<String> ids = gson.fromJson(tagIds, new TypeToken<List<String>>() {}.getType());
+                    return ids.stream();
+                })
+                .distinct()
+                .collect(Collectors.toList());
+
+        Map<String, String> tagIdToName = batchQueryTagIdToName(allTagIds);
+
+        // 批量查询所有客户的下载APP状态,避免N+1查询
+        Set<Long> downloadAppUserIds = list.stream()
+                .map(QwExternalContactVO::getFsUserId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+
+        downloadAppUserIds = batchQueryDownloadAppUserIds(downloadAppUserIds);
+
+        Set<Long> finalDownloadAppUserIds = downloadAppUserIds;
         list.forEach(item->{
         list.forEach(item->{
 
 
             if (!Objects.equals(item.getTagIds(), "[]") && item.getTagIds()!=null) {
             if (!Objects.equals(item.getTagIds(), "[]") && item.getTagIds()!=null) {
-                QwTagSearchParam param = new QwTagSearchParam();
                 Gson gson = new Gson();
                 Gson gson = new Gson();
                 List<String> tagIds = gson.fromJson(
                 List<String> tagIds = gson.fromJson(
                         item.getTagIds(),
                         item.getTagIds(),
@@ -251,25 +334,20 @@ public class QwExternalContactController extends BaseController
                         }.getType()
                         }.getType()
                 );
                 );
 
 
-                param.setTagIds(tagIds);
-
-                item.setTagIdsName(iQwTagService.selectQwTagListByTagIds(param));
+                List<String> tagNames = tagIds.stream()
+                        .filter(tagIdToName::containsKey)
+                        .map(tagIdToName::get)
+                        .collect(Collectors.toList());
+                item.setTagIdsName(tagNames);
             }
             }
 
 
             if (!StringUtil.strIsNullOrEmpty(item.getState()) && !wayList.isEmpty()) {
             if (!StringUtil.strIsNullOrEmpty(item.getState()) && !wayList.isEmpty()) {
                 item.setState(item.getState()+"-"+getContactWayNameStream(item.getState(), wayList));
                 item.setState(item.getState()+"-"+getContactWayNameStream(item.getState(), wayList));
             }
             }
 
 
-            if (item.getFsUserId() != null) {
-                FsUser fsUser = fsUserService.selectFsUserByUserId(item.getFsUserId());
-                if (fsUser != null && (fsUser.getSource() != null || fsUser.getLoginDevice() != null)) {
-                    item.setIsDownloadApp(1);
-                } else {
-                    item.setIsDownloadApp(0);
-                }
-            } else {
-                item.setIsDownloadApp(0);
-            }
+            item.setIsDownloadApp(
+                    item.getFsUserId() != null && finalDownloadAppUserIds.contains(item.getFsUserId()) ? 1 : 0
+            );
 
 
         });
         });
 
 
@@ -293,10 +371,35 @@ public class QwExternalContactController extends BaseController
         startPage();
         startPage();
         qwExternalContact.setCompanyId(loginUser.getCompany().getCompanyId());
         qwExternalContact.setCompanyId(loginUser.getCompany().getCompanyId());
         List<QwExternalContactVO> list = qwExternalContactService.selectQwExternalContactListVO(qwExternalContact);
         List<QwExternalContactVO> list = qwExternalContactService.selectQwExternalContactListVO(qwExternalContact);
+
+
+        // 批量查询所有客户的标签名称,避免N+1查询
+        List<String> allTagIds = list.stream()
+                .map(QwExternalContactVO::getTagIds)
+                .filter(tagIds -> !Objects.equals(tagIds, "[]") && tagIds != null)
+                .flatMap(tagIds -> {
+                    Gson gson = new Gson();
+                    List<String> ids = gson.fromJson(tagIds, new TypeToken<List<String>>() {}.getType());
+                    return ids.stream();
+                })
+                .distinct()
+                .collect(Collectors.toList());
+
+        Map<String, String> tagIdToName = batchQueryTagIdToName(allTagIds);
+
+
+        // 批量查询所有客户的下载APP状态,避免N+1查询
+        Set<Long> downloadAppUserIds = list.stream()
+                .map(QwExternalContactVO::getFsUserId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+
+        downloadAppUserIds = batchQueryDownloadAppUserIds(downloadAppUserIds);
+
+        Set<Long> finalDownloadAppUserIds = downloadAppUserIds;
         list.forEach(item->{
         list.forEach(item->{
 
 
             if (!Objects.equals(item.getTagIds(), "[]") && item.getTagIds()!=null) {
             if (!Objects.equals(item.getTagIds(), "[]") && item.getTagIds()!=null) {
-                QwTagSearchParam param = new QwTagSearchParam();
                 Gson gson = new Gson();
                 Gson gson = new Gson();
                 List<String> tagIds = gson.fromJson(
                 List<String> tagIds = gson.fromJson(
                         item.getTagIds(),
                         item.getTagIds(),
@@ -304,14 +407,21 @@ public class QwExternalContactController extends BaseController
                         }.getType()
                         }.getType()
                 );
                 );
 
 
-                param.setTagIds(tagIds);
-
-                item.setTagIdsName(iQwTagService.selectQwTagListByTagIds(param));
+                List<String> tagNames = tagIds.stream()
+                        .filter(tagIdToName::containsKey)
+                        .map(tagIdToName::get)
+                        .collect(Collectors.toList());
+                item.setTagIdsName(tagNames);
             }
             }
 
 
             if (!StringUtil.strIsNullOrEmpty(item.getState()) && !wayList.isEmpty()) {
             if (!StringUtil.strIsNullOrEmpty(item.getState()) && !wayList.isEmpty()) {
                 item.setState(item.getState()+"-"+getContactWayNameStream(item.getState(), wayList));
                 item.setState(item.getState()+"-"+getContactWayNameStream(item.getState(), wayList));
             }
             }
+
+            item.setIsDownloadApp(
+                    item.getFsUserId() != null && finalDownloadAppUserIds.contains(item.getFsUserId()) ? 1 : 0
+            );
+
             //获取用户下单次数
             //获取用户下单次数
             try {
             try {
                 fillOrderCount(item);
                 fillOrderCount(item);
@@ -386,10 +496,24 @@ public class QwExternalContactController extends BaseController
             qwExternalContact.setStatusCondition(split);
             qwExternalContact.setStatusCondition(split);
         }
         }
         List<QwExternalContactVO> list = qwExternalContactService.selectQwExternalContactListVO(qwExternalContact);
         List<QwExternalContactVO> list = qwExternalContactService.selectQwExternalContactListVO(qwExternalContact);
+
+        // 批量查询所有客户的标签名称,避免N+1查询
+        List<String> allTagIds = list.stream()
+                .map(QwExternalContactVO::getTagIds)
+                .filter(tagIds -> !Objects.equals(tagIds, "[]") && tagIds != null)
+                .flatMap(tagIds -> {
+                    Gson gson = new Gson();
+                    List<String> ids = gson.fromJson(tagIds, new TypeToken<List<String>>() {}.getType());
+                    return ids.stream();
+                })
+                .distinct()
+                .collect(Collectors.toList());
+
+        Map<String, String> tagIdToName = batchQueryTagIdToName(allTagIds);
+
         list.forEach(item->{
         list.forEach(item->{
 
 
             if (!Objects.equals(item.getTagIds(), "[]") && item.getTagIds()!=null) {
             if (!Objects.equals(item.getTagIds(), "[]") && item.getTagIds()!=null) {
-                QwTagSearchParam param = new QwTagSearchParam();
                 Gson gson = new Gson();
                 Gson gson = new Gson();
                 List<String> tagIds = gson.fromJson(
                 List<String> tagIds = gson.fromJson(
                         item.getTagIds(),
                         item.getTagIds(),
@@ -397,9 +521,11 @@ public class QwExternalContactController extends BaseController
                         }.getType()
                         }.getType()
                 );
                 );
 
 
-                param.setTagIds(tagIds);
-
-                item.setTagIdsName(iQwTagService.selectQwTagListByTagIds(param));
+                List<String> tagNames = tagIds.stream()
+                        .filter(tagIdToName::containsKey)
+                        .map(tagIdToName::get)
+                        .collect(Collectors.toList());
+                item.setTagIdsName(tagNames);
             }
             }
 
 
             if (!StringUtil.strIsNullOrEmpty(item.getState()) && !wayList.isEmpty()) {
             if (!StringUtil.strIsNullOrEmpty(item.getState()) && !wayList.isEmpty()) {
@@ -432,10 +558,23 @@ public class QwExternalContactController extends BaseController
 
 
         List<QwExternalContactVO> list = qwExternalContactService.selectQwExternalContactListVO(qwExternalContact);
         List<QwExternalContactVO> list = qwExternalContactService.selectQwExternalContactListVO(qwExternalContact);
 
 
+        // 批量查询所有客户的标签名称,避免N+1查询
+        List<String> allTagIds = list.stream()
+                .map(QwExternalContactVO::getTagIds)
+                .filter(tagIds -> !Objects.equals(tagIds, "[]") && tagIds != null)
+                .flatMap(tagIds -> {
+                    Gson gson = new Gson();
+                    List<String> ids = gson.fromJson(tagIds, new TypeToken<List<String>>() {}.getType());
+                    return ids.stream();
+                })
+                .distinct()
+                .collect(Collectors.toList());
+
+        Map<String, String> tagIdToName = batchQueryTagIdToName(allTagIds);
+
         list.forEach(item->{
         list.forEach(item->{
 
 
             if (!Objects.equals(item.getTagIds(), "[]") && item.getTagIds()!=null) {
             if (!Objects.equals(item.getTagIds(), "[]") && item.getTagIds()!=null) {
-                QwTagSearchParam param = new QwTagSearchParam();
                 Gson gson = new Gson();
                 Gson gson = new Gson();
                 List<String> tagIds = gson.fromJson(
                 List<String> tagIds = gson.fromJson(
                         item.getTagIds(),
                         item.getTagIds(),
@@ -443,9 +582,11 @@ public class QwExternalContactController extends BaseController
                         }.getType()
                         }.getType()
                 );
                 );
 
 
-                param.setTagIds(tagIds);
-
-                item.setTagIdsName(iQwTagService.selectQwTagListByTagIds(param));
+                List<String> tagNames = tagIds.stream()
+                        .filter(tagIdToName::containsKey)
+                        .map(tagIdToName::get)
+                        .collect(Collectors.toList());
+                item.setTagIdsName(tagNames);
             }
             }
 
 
             if (!StringUtil.strIsNullOrEmpty(item.getState()) && !wayList.isEmpty()) {
             if (!StringUtil.strIsNullOrEmpty(item.getState()) && !wayList.isEmpty()) {

+ 50 - 0
fs-company/src/main/java/com/fs/company/controller/qw/QwSopTempVoiceController.java

@@ -0,0 +1,50 @@
+package com.fs.company.controller.qw;
+
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.R;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.utils.ServletUtils;
+import com.fs.course.param.FsCourseListBySidebarParam;
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.service.TokenService;
+import com.fs.qw.domain.QwUser;
+import com.fs.sop.domain.QwSop;
+import com.fs.sop.domain.QwSopTempVoice;
+import com.fs.sop.service.IQwSopTempVoiceService;
+import com.github.pagehelper.PageHelper;
+import com.github.pagehelper.PageInfo;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/qw/SopTempVoice")
+public class QwSopTempVoiceController extends BaseController {
+
+    @Autowired
+    private TokenService tokenService;
+
+    @Autowired
+    private IQwSopTempVoiceService sopTempVoiceService;
+
+
+    /**
+     * 获取销售语音列表
+     */
+    @PostMapping("/getQwUserVoice")
+    @ApiOperation("获取销售语音列表")
+    public TableDataInfo getQwUserVoice(@RequestBody FsCourseListBySidebarParam param){
+
+        startPage();
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+
+        List<QwSopTempVoice> voiceList = sopTempVoiceService.selectQwSopTempVoiceByCompanyUserId(loginUser.getUser().getUserId(),param.getKeyword());
+        return getDataTable(voiceList);
+    }
+
+}

+ 7 - 0
fs-service/src/main/java/com/fs/company/mapper/CompanyAiWorkflowExecMapper.java

@@ -144,4 +144,11 @@ public interface CompanyAiWorkflowExecMapper extends BaseMapper<CompanyAiWorkflo
                                 @Param("runningStatus") Integer runningStatus,
                                 @Param("runningStatus") Integer runningStatus,
                                 @Param("failStatus") Integer failStatus,
                                 @Param("failStatus") Integer failStatus,
                                 @Param("timeoutTime") java.time.LocalDateTime timeoutTime);
                                 @Param("timeoutTime") java.time.LocalDateTime timeoutTime);
+
+    /**
+     * 统计指定分组下 RUNNING 状态的工作流执行数
+     */
+    int countRunningExecByGroupNo(@Param("groupNo") Integer groupNo,
+                                  @Param("runningStatus") Integer runningStatus,
+                                  @Param("nodeExector") Integer nodeExector);
 }
 }

+ 9 - 7
fs-service/src/main/java/com/fs/company/mapper/CompanySiptaskInfoMapper.java

@@ -7,14 +7,14 @@ import org.apache.ibatis.annotations.Param;
 
 
 /**
 /**
  * 任务与外呼sip任务关联关系Mapper接口
  * 任务与外呼sip任务关联关系Mapper接口
- * 
+ *
  * @author fs
  * @author fs
  * @date 2026-04-20
  * @date 2026-04-20
  */
  */
 public interface CompanySiptaskInfoMapper extends BaseMapper<CompanySiptaskInfo>{
 public interface CompanySiptaskInfoMapper extends BaseMapper<CompanySiptaskInfo>{
     /**
     /**
      * 查询任务与外呼sip任务关联关系
      * 查询任务与外呼sip任务关联关系
-     * 
+     *
      * @param id 任务与外呼sip任务关联关系主键
      * @param id 任务与外呼sip任务关联关系主键
      * @return 任务与外呼sip任务关联关系
      * @return 任务与外呼sip任务关联关系
      */
      */
@@ -22,7 +22,7 @@ public interface CompanySiptaskInfoMapper extends BaseMapper<CompanySiptaskInfo>
 
 
     /**
     /**
      * 查询任务与外呼sip任务关联关系列表
      * 查询任务与外呼sip任务关联关系列表
-     * 
+     *
      * @param companySiptaskInfo 任务与外呼sip任务关联关系
      * @param companySiptaskInfo 任务与外呼sip任务关联关系
      * @return 任务与外呼sip任务关联关系集合
      * @return 任务与外呼sip任务关联关系集合
      */
      */
@@ -30,7 +30,7 @@ public interface CompanySiptaskInfoMapper extends BaseMapper<CompanySiptaskInfo>
 
 
     /**
     /**
      * 新增任务与外呼sip任务关联关系
      * 新增任务与外呼sip任务关联关系
-     * 
+     *
      * @param companySiptaskInfo 任务与外呼sip任务关联关系
      * @param companySiptaskInfo 任务与外呼sip任务关联关系
      * @return 结果
      * @return 结果
      */
      */
@@ -38,7 +38,7 @@ public interface CompanySiptaskInfoMapper extends BaseMapper<CompanySiptaskInfo>
 
 
     /**
     /**
      * 修改任务与外呼sip任务关联关系
      * 修改任务与外呼sip任务关联关系
-     * 
+     *
      * @param companySiptaskInfo 任务与外呼sip任务关联关系
      * @param companySiptaskInfo 任务与外呼sip任务关联关系
      * @return 结果
      * @return 结果
      */
      */
@@ -46,7 +46,7 @@ public interface CompanySiptaskInfoMapper extends BaseMapper<CompanySiptaskInfo>
 
 
     /**
     /**
      * 删除任务与外呼sip任务关联关系
      * 删除任务与外呼sip任务关联关系
-     * 
+     *
      * @param id 任务与外呼sip任务关联关系主键
      * @param id 任务与外呼sip任务关联关系主键
      * @return 结果
      * @return 结果
      */
      */
@@ -54,11 +54,13 @@ public interface CompanySiptaskInfoMapper extends BaseMapper<CompanySiptaskInfo>
 
 
     /**
     /**
      * 批量删除任务与外呼sip任务关联关系
      * 批量删除任务与外呼sip任务关联关系
-     * 
+     *
      * @param ids 需要删除的数据主键集合
      * @param ids 需要删除的数据主键集合
      * @return 结果
      * @return 结果
      */
      */
     int deleteCompanySiptaskInfoByIds(Long[] ids);
     int deleteCompanySiptaskInfoByIds(Long[] ids);
 
 
     CompanySiptaskInfo selectSipTaskInfoByTaskIdAndNodeKey(@Param("taskId") Long taskId, @Param("nodeKey") String nodeKey);
     CompanySiptaskInfo selectSipTaskInfoByTaskIdAndNodeKey(@Param("taskId") Long taskId, @Param("nodeKey") String nodeKey);
+
+    CompanySiptaskInfo selectSipTaskInfoByBatchId(@Param("batchId") Long batchId);
 }
 }

+ 10 - 0
fs-service/src/main/java/com/fs/company/mapper/CompanyVoiceRoboticCallLogCallphoneMapper.java

@@ -129,4 +129,14 @@ public interface CompanyVoiceRoboticCallLogCallphoneMapper extends BaseMapper<Co
      * 查询执行中任务下、外呼记录仍为执行中的 callbackUuid 列表
      * 查询执行中任务下、外呼记录仍为执行中的 callbackUuid 列表
      */
      */
     List<String> selectRunningCallbackUuidsByCompanyId(@Param("companyId") Long companyId);
     List<String> selectRunningCallbackUuidsByCompanyId(@Param("companyId") Long companyId);
+
+    /**
+     * 回调补偿触发后递增重试次数(仅 status=1 的记录)
+     */
+    int incrementCallbackCompensateRetryCount(@Param("logId") Long logId);
+
+    /**
+     * 查询执行中任务下、外呼记录仍为执行中的待补偿日志(含 runParam)
+     */
+    List<PendingAiCallLogSyncVo> selectPendingAiCallLogsForSync(@Param("maxRetryCount") int maxRetryCount);
 }
 }

+ 8 - 0
fs-service/src/main/java/com/fs/company/mapper/EasyCallMapper.java

@@ -4,6 +4,7 @@ import com.fs.common.annotation.DataSource;
 import com.fs.common.enums.DataSourceType;
 import com.fs.common.enums.DataSourceType;
 import com.fs.company.vo.InboundCallInfo;
 import com.fs.company.vo.InboundCallInfo;
 import com.fs.company.vo.easycall.EasyCallCallPhoneVO;
 import com.fs.company.vo.easycall.EasyCallCallPhoneVO;
+import com.fs.company.vo.easycall.EasyCallCompletedCallVo;
 import com.fs.company.vo.easycall.EasyCallOutBoundVO;
 import com.fs.company.vo.easycall.EasyCallOutBoundVO;
 import org.apache.ibatis.annotations.Param;
 import org.apache.ibatis.annotations.Param;
 import org.springframework.stereotype.Repository;
 import org.springframework.stereotype.Repository;
@@ -36,4 +37,11 @@ public interface EasyCallMapper {
      */
      */
     @DataSource(DataSourceType.EASYCALL)
     @DataSource(DataSourceType.EASYCALL)
     List<String> selectCompletedUuidsByCallbackUuids(@Param("callbackUuids") List<String> callbackUuids);
     List<String> selectCompletedUuidsByCallbackUuids(@Param("callbackUuids") List<String> callbackUuids);
+
+
+    /**
+     * 根据 callbackUuid 批量查询 EasyCall 已外呼完成的话单(含 callBackUuid 映射)
+     */
+    @DataSource(DataSourceType.EASYCALL)
+    List<EasyCallCompletedCallVo> selectCompletedCallsByCallbackUuids(@Param("callbackUuids") List<String> callbackUuids);
 }
 }

+ 14 - 0
fs-service/src/main/java/com/fs/company/service/IAiCallCallbackCompensateService.java

@@ -0,0 +1,14 @@
+package com.fs.company.service;
+
+/**
+ * AI 外呼回调补偿:扫描未回调完成记录,对 EasyCall 已完成的通话模拟 HTTP 回调
+ */
+public interface IAiCallCallbackCompensateService {
+
+    /**
+     * 当前租户维度执行补偿(需在租户库上下文中调用)
+     *
+     * @param cidGroupNo cid 工作流分组号
+     */
+    void compensatePendingCallbacks(Integer cidGroupNo);
+}

+ 6 - 1
fs-service/src/main/java/com/fs/company/service/ICompanyVoiceRoboticService.java

@@ -84,7 +84,7 @@ public interface ICompanyVoiceRoboticService extends IService<CompanyVoiceRoboti
      * @param delFlag 删除标志 0正常 1删除
      * @param delFlag 删除标志 0正常 1删除
      * @return 结果
      * @return 结果
      */
      */
-    public int updateDelFlag(Long id, Integer delFlag);
+    public void updateDelFlag(Long id, Integer delFlag);
 
 
     List<CompanyVoiceRoboticQwUserListVo> qwUserList();
     List<CompanyVoiceRoboticQwUserListVo> qwUserList();
     List<CompanyVoiceRoboticQwUserListVo> qwUserListCompany(CompanyVoiceRobotic companyVoiceRobotic);
     List<CompanyVoiceRoboticQwUserListVo> qwUserListCompany(CompanyVoiceRobotic companyVoiceRobotic);
@@ -137,4 +137,9 @@ public interface ICompanyVoiceRoboticService extends IService<CompanyVoiceRoboti
      * @return 追加结果(成功数、重复客户信息)
      * @return 追加结果(成功数、重复客户信息)
      */
      */
     R appendCustomersToRunningTask(Long taskId, List<Long> customerIds);
     R appendCustomersToRunningTask(Long taskId, List<Long> customerIds);
+
+    /**
+     * 任务是否处于执行中(未删除且 taskStatus=1)
+     */
+    boolean isTaskRunning(Long taskId);
 }
 }

+ 98 - 0
fs-service/src/main/java/com/fs/company/service/easycall/OutboundLimitRetryRedisHelper.java

@@ -0,0 +1,98 @@
+package com.fs.company.service.easycall;
+
+import com.fs.common.core.redis.RedisCacheT;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.stereotype.Component;
+
+import java.util.*;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 外呼限流重试队列专用 Redis 操作(ZSET 原子弹出、Hash 载荷、处理锁、网关限速等)。
+ * <p>
+ * 业务语义不进 {@link com.fs.common.core.redis.RedisCache} / {@link RedisCacheT} 通用工具类。
+ */
+@Component
+public class OutboundLimitRetryRedisHelper {
+
+    @Autowired
+    private RedisCacheT<String> redisCacheT;
+
+    /**
+     * Pop due members via Java ZSet API (Redisson-safe; same as WxAddWxFrequencyRedisHelper).
+     */
+    @SuppressWarnings({"unchecked", "rawtypes"})
+    public List<String> popDueMembersByScore(String zsetKey, double maxScore, int count) {
+        if (count <= 0) {
+            return Collections.emptyList();
+        }
+        RedisTemplate redisTemplate = redisCacheT.redisTemplate;
+        Set<Object> members = redisTemplate.opsForZSet().rangeByScore(zsetKey, 0, maxScore, 0, count);
+        if (members == null || members.isEmpty()) {
+            return Collections.emptyList();
+        }
+        List<String> dueMembers = new ArrayList<>(members.size());
+        for (Object member : members) {
+            Long removed = redisTemplate.opsForZSet().remove(zsetKey, member);
+            if (removed != null && removed > 0) {
+                dueMembers.add(String.valueOf(member));
+            }
+        }
+        return dueMembers;
+    }
+
+    public Double zScore(String zsetKey, Object member) {
+        return redisCacheT.redisTemplate.opsForZSet().score(zsetKey, member);
+    }
+
+    public void zSetAdd(String zsetKey, Object member, double score) {
+        redisCacheT.redisTemplate.opsForZSet().add(zsetKey, member, score);
+    }
+
+    public void zSetRemove(String zsetKey, Object member) {
+        redisCacheT.redisTemplate.opsForZSet().remove(zsetKey, member);
+    }
+
+    public void hashPut(String hashKey, String field, String value) {
+        redisCacheT.setCacheMapValue(hashKey, field, value);
+    }
+
+    public String hashGet(String hashKey, String field) {
+        return redisCacheT.getCacheMapValue(hashKey, field);
+    }
+
+    public Map<String, String> hashGetAll(String hashKey) {
+        Map<String, String> map = redisCacheT.getCacheMap(hashKey);
+        return map != null ? map : Collections.emptyMap();
+    }
+
+    public void hashDelete(String hashKey, String field) {
+        redisCacheT.redisTemplate.opsForHash().delete(hashKey, field);
+    }
+
+    public boolean setIfAbsent(String key, String value, long timeout, TimeUnit unit) {
+        return Boolean.TRUE.equals(
+                redisCacheT.redisTemplate.opsForValue().setIfAbsent(key, value, timeout, unit));
+    }
+
+    public void deleteKey(String key) {
+        redisCacheT.deleteObject(key);
+    }
+
+    public String getString(String key) {
+        return redisCacheT.getCacheObject(key);
+    }
+
+    public void setString(String key, String value, long timeout, TimeUnit unit) {
+        redisCacheT.setCacheObject(key, value, timeout, unit);
+    }
+
+    public Long incr(String key, long delta) {
+        return redisCacheT.redisTemplate.opsForValue().increment(key, delta);
+    }
+
+    public boolean expire(String key, long timeout, TimeUnit unit) {
+        return redisCacheT.expire(key, timeout, unit);
+    }
+}

+ 343 - 0
fs-service/src/main/java/com/fs/company/service/easycall/OutboundLimitRetrySupport.java

@@ -0,0 +1,343 @@
+package com.fs.company.service.easycall;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.fs.common.utils.StringUtils;
+import com.fs.company.domain.CompanySiptaskInfo;
+import com.fs.company.mapper.CompanySiptaskInfoMapper;
+import com.fs.company.vo.easycall.EasyCallCommonAddCallListParam;
+import com.fs.company.vo.easycall.EasyCallPhoneItemVO;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.ThreadLocalRandom;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 外呼线路限流重试队列:去重入队、抖动分散、原子出队、失败退避重入队。
+ */
+@Slf4j
+@Component
+public class OutboundLimitRetrySupport {
+
+    /** 与 EasyCallServiceImpl 保持一致 */
+    public static final String OUTBOUND_LIMIT_REDIS_PREFIX = "outbound:limit:";
+
+    public static final String RETRY_ZSET_KEY = OUTBOUND_LIMIT_REDIS_PREFIX + "retry:zset";
+
+    public static final String RETRY_PAYLOAD_HASH_KEY = OUTBOUND_LIMIT_REDIS_PREFIX + "retry:payload";
+
+    private static final String RETRY_PROCESSING_PREFIX = OUTBOUND_LIMIT_REDIS_PREFIX + "retry:processing:";
+
+    private static final String RETRY_DISPATCHED_PREFIX = OUTBOUND_LIMIT_REDIS_PREFIX + "retry:dispatched:";
+
+    /** 已成功 append+startTask 的 callBackUuid 标记 TTL(防 pop 后崩溃导致重复外呼) */
+    private static final int DISPATCHED_TTL_HOURS = 24;
+
+    private static final String RETRY_RATE_PREFIX = OUTBOUND_LIMIT_REDIS_PREFIX + "retry:rate:";
+
+    /** 到期后随机延迟上限(毫秒),平滑窗口边界流量 */
+    private static final int RETRY_JITTER_MAX_MS = 30_000;
+
+    /** 孤儿 payload 单次最多恢复条数 */
+    private static final int MAX_ORPHAN_RECOVER_BATCH = 100;
+
+    /** 失败退避基础延迟(毫秒) */
+    private static final long RETRY_BACKOFF_MS = 60_000L;
+
+    /** 单网关每秒最多重试外呼次数(多实例共享) */
+    private static final int MAX_RETRY_PER_GATEWAY_PER_SEC = 30;
+
+    /** 处理锁 TTL,防止实例崩溃后长期占锁 */
+    private static final int PROCESSING_LOCK_SECONDS = 600;
+
+    @Autowired
+    private OutboundLimitRetryRedisHelper retryRedisHelper;
+
+    @Autowired
+    private CompanySiptaskInfoMapper companySiptaskInfoMapper;
+
+    /**
+     * 限流后入队(member=callBackUuid,同 UUID 不重复入队,score 带抖动)。
+     */
+    public void enqueueRetry(Long companyId, Long gatewayId, EasyCallCommonAddCallListParam param, Date nextAvailableTime) {
+        if (param == null || nextAvailableTime == null) {
+            return;
+        }
+        try {
+            String callBackUuid = extractCallBackUuid(param);
+            String member = StringUtils.isNotBlank(callBackUuid) ? callBackUuid : UUID.randomUUID().toString();
+
+            JSONObject retryData = new JSONObject();
+            retryData.put("companyId", companyId);
+            retryData.put("gatewayId", gatewayId);
+            retryData.put("param", param);
+            retryData.put("callBackUuid", member);
+            retryData.put("nextAvailableTime", nextAvailableTime.getTime());
+            retryData.put("createTime", System.currentTimeMillis());
+            Long roboticId = resolveRoboticId(param);
+            if (roboticId != null) {
+                retryData.put("roboticId", roboticId);
+            }
+
+            String existingPayload = retryRedisHelper.hashGet(RETRY_PAYLOAD_HASH_KEY, member);
+            if (existingPayload != null) {
+                JSONObject oldData = JSON.parseObject(existingPayload);
+                if (oldData != null && oldData.containsKey("failCount")) {
+                    retryData.put("failCount", oldData.getIntValue("failCount"));
+                }
+            }
+
+            double score = computeScoreWithJitter(nextAvailableTime.getTime());
+
+            Double existingScore = retryRedisHelper.zScore(RETRY_ZSET_KEY, member);
+            if (existingScore != null && existingScore <= score) {
+                retryRedisHelper.hashPut(RETRY_PAYLOAD_HASH_KEY, member, retryData.toJSONString());
+                log.info("enqueueRetry: 已存在更早或相同调度,仅刷新 payload - member={}", member);
+                return;
+            }
+
+            retryRedisHelper.zSetAdd(RETRY_ZSET_KEY, member, score);
+            retryRedisHelper.hashPut(RETRY_PAYLOAD_HASH_KEY, member, retryData.toJSONString());
+            log.info("enqueueRetry: member={}, score={}, companyId={}, gatewayId={}", member, (long) score, companyId, gatewayId);
+        } catch (Exception e) {
+            log.error("enqueueRetry 异常 companyId={}, gatewayId={}", companyId, gatewayId, e);
+        }
+    }
+
+    /**
+     * 原子弹出已到期的重试 member(ZRANGE+ZREM 脚本,防多实例重复消费)。
+     */
+    public List<String> popDueMembers(int batchSize) {
+        long now = System.currentTimeMillis();
+        return retryRedisHelper.popDueMembersByScore(RETRY_ZSET_KEY, now, batchSize);
+    }
+
+    /**
+     * 解析任务 payload;兼容旧版 member 为整段 JSON 的写法。
+     */
+    public JSONObject resolvePayload(String member) {
+        if (StringUtils.isBlank(member)) {
+            return null;
+        }
+        if (member.trim().startsWith("{")) {
+            return JSON.parseObject(member);
+        }
+        String raw = retryRedisHelper.hashGet(RETRY_PAYLOAD_HASH_KEY, member);
+        if (raw == null) {
+            return null;
+        }
+        return JSON.parseObject(raw);
+    }
+
+    /**
+     * 尝试获取处理锁,避免极端情况下重复外呼。
+     */
+    public boolean tryAcquireProcessingLock(String callBackUuid) {
+        if (StringUtils.isBlank(callBackUuid)) {
+            return true;
+        }
+        String lockKey = RETRY_PROCESSING_PREFIX + callBackUuid;
+        return retryRedisHelper.setIfAbsent(lockKey, "1", PROCESSING_LOCK_SECONDS, TimeUnit.SECONDS);
+    }
+
+    public void releaseProcessingLock(String callBackUuid) {
+        if (StringUtils.isBlank(callBackUuid)) {
+            return;
+        }
+        retryRedisHelper.deleteKey(RETRY_PROCESSING_PREFIX + callBackUuid);
+    }
+
+    /**
+     * 是否已成功完成 append+startTask(防 pop 后进程崩溃再次重试导致重复外呼)。
+     */
+    public boolean isDispatched(String callBackUuid) {
+        if (StringUtils.isBlank(callBackUuid)) {
+            return false;
+        }
+        return retryRedisHelper.getString(RETRY_DISPATCHED_PREFIX + callBackUuid) != null;
+    }
+
+    /**
+     * 标记 callBackUuid 已完成派发(append 成功且 startTask 已触发)。
+     */
+    public void markDispatched(String callBackUuid) {
+        if (StringUtils.isBlank(callBackUuid)) {
+            return;
+        }
+        retryRedisHelper.setString(RETRY_DISPATCHED_PREFIX + callBackUuid, "1", DISPATCHED_TTL_HOURS, TimeUnit.HOURS);
+    }
+
+    /**
+     * 恢复 ZSET 已弹出但 payload 仍滞留 Hash 的孤儿任务(进程崩溃场景,不重试丢单)。
+     *
+     * @return 本次恢复入队条数
+     */
+    public int recoverOrphanedPayloads() {
+        Map<String, String> all = retryRedisHelper.hashGetAll(RETRY_PAYLOAD_HASH_KEY);
+        if (all == null || all.isEmpty()) {
+            return 0;
+        }
+        int recovered = 0;
+        for (Map.Entry<String, String> entry : all.entrySet()) {
+            if (recovered >= MAX_ORPHAN_RECOVER_BATCH) {
+                break;
+            }
+            String member = entry.getKey();
+            if (retryRedisHelper.zScore(RETRY_ZSET_KEY, member) != null) {
+                continue;
+            }
+            JSONObject data = JSON.parseObject(entry.getValue());
+            if (data == null) {
+                ackSuccess(member);
+                continue;
+            }
+            String callBackUuid = data.getString("callBackUuid");
+            EasyCallCommonAddCallListParam param = data.getObject("param", EasyCallCommonAddCallListParam.class);
+            if (StringUtils.isBlank(callBackUuid) && param != null) {
+                callBackUuid = extractCallBackUuid(param);
+            }
+            if (isDispatched(callBackUuid)) {
+                ackSuccess(member);
+                continue;
+            }
+            Long companyId = data.getLong("companyId");
+            Long gatewayId = data.getLong("gatewayId");
+            if (companyId == null || param == null) {
+                ackSuccess(member);
+                continue;
+            }
+            Date nextTime = new Date(System.currentTimeMillis() + RETRY_BACKOFF_MS);
+            enqueueRetry(companyId, gatewayId, param, nextTime);
+            recovered++;
+            log.warn("recoverOrphanedPayloads: 孤儿任务重新入队 member={}, callBackUuid={}", member, callBackUuid);
+        }
+        return recovered;
+    }
+
+    /**
+     * 按网关令牌桶限速,超限则延迟重入队。
+     *
+     * @return true 表示当前秒额度已用尽,调用方应跳过本次外呼
+     */
+    public boolean isGatewayRateLimited(Long companyId, Long gatewayId) {
+        if (companyId == null || gatewayId == null) {
+            return false;
+        }
+        String bucketKey = RETRY_RATE_PREFIX + companyId + ":" + gatewayId;
+        Long count = retryRedisHelper.incr(bucketKey, 1L);
+        if (count == null) {
+            count = 1L;
+        }
+        if (count == 1L) {
+            retryRedisHelper.expire(bucketKey, 1, TimeUnit.SECONDS);
+        }
+        return count > MAX_RETRY_PER_GATEWAY_PER_SEC;
+    }
+
+    /**
+     * 成功后清理 payload;member 已在 pop 时从 ZSET 移除。
+     */
+    public void ackSuccess(String member) {
+        if (StringUtils.isBlank(member) || member.trim().startsWith("{")) {
+            return;
+        }
+        retryRedisHelper.hashDelete(RETRY_PAYLOAD_HASH_KEY, member);
+    }
+
+    /**
+     * 失败或异常:退避后重新入队,不丢单。
+     */
+    public void requeueWithBackoff(JSONObject retryData, String member) {
+        if (retryData == null) {
+            return;
+        }
+        Long companyId = retryData.getLong("companyId");
+        Long gatewayId = retryData.getLong("gatewayId");
+        EasyCallCommonAddCallListParam param = retryData.getObject("param", EasyCallCommonAddCallListParam.class);
+        if (param == null) {
+            log.warn("requeueWithBackoff: param 为空,无法重入队 member={}", member);
+            return;
+        }
+        int failCount = retryData.getIntValue("failCount") + 1;
+        retryData.put("failCount", failCount);
+        if (StringUtils.isNotBlank(member) && !member.trim().startsWith("{")) {
+            retryRedisHelper.hashPut(RETRY_PAYLOAD_HASH_KEY, member, retryData.toJSONString());
+        }
+        long delay = RETRY_BACKOFF_MS * Math.min(failCount, 5);
+        Date nextTime = new Date(System.currentTimeMillis() + delay);
+        enqueueRetry(companyId, gatewayId, param, nextTime);
+        log.warn("requeueWithBackoff: member={}, failCount={}, nextDelayMs={}", member, failCount, delay);
+    }
+
+    public static String extractCallBackUuid(EasyCallCommonAddCallListParam param) {
+        if (param == null || param.getPhoneList() == null || param.getPhoneList().isEmpty()) {
+            return null;
+        }
+        EasyCallPhoneItemVO item = param.getPhoneList().get(0);
+        if (item == null || item.getBizJson() == null) {
+            return null;
+        }
+        JSONObject bizJson;
+        if (item.getBizJson() instanceof JSONObject) {
+            bizJson = (JSONObject) item.getBizJson();
+        } else {
+            bizJson = JSON.parseObject(String.valueOf(item.getBizJson()));
+        }
+        return bizJson != null ? bizJson.getString("callBackUuid") : null;
+    }
+
+    /**
+     * 任务暂停/删除时,取消该任务尚未派发的限流重试,避免继续 append 或 startTask 把 EasyCall 任务拉回运行。
+     */
+    public int cancelPendingRetriesForRobotic(Long roboticId) {
+        if (roboticId == null) {
+            return 0;
+        }
+        Map<String, String> all = retryRedisHelper.hashGetAll(RETRY_PAYLOAD_HASH_KEY);
+        if (all == null || all.isEmpty()) {
+            return 0;
+        }
+        int cancelled = 0;
+        for (Map.Entry<String, String> entry : all.entrySet()) {
+            JSONObject data = JSON.parseObject(entry.getValue());
+            if (data == null) {
+                continue;
+            }
+            Long rid = data.getLong("roboticId");
+            if (rid == null) {
+                EasyCallCommonAddCallListParam param = data.getObject("param", EasyCallCommonAddCallListParam.class);
+                rid = resolveRoboticId(param);
+            }
+            if (!roboticId.equals(rid)) {
+                continue;
+            }
+            String member = entry.getKey();
+            retryRedisHelper.zSetRemove(RETRY_ZSET_KEY, member);
+            ackSuccess(member);
+            cancelled++;
+        }
+        if (cancelled > 0) {
+            log.info("cancelPendingRetriesForRobotic: roboticId={}, cancelled={}", roboticId, cancelled);
+        }
+        return cancelled;
+    }
+
+    public Long resolveRoboticId(EasyCallCommonAddCallListParam param) {
+        if (param == null || param.getBatchId() == null || companySiptaskInfoMapper == null) {
+            return null;
+        }
+        CompanySiptaskInfo sipTaskInfo = companySiptaskInfoMapper.selectSipTaskInfoByBatchId(param.getBatchId());
+        return sipTaskInfo != null ? sipTaskInfo.getTaskId() : null;
+    }
+
+    private static double computeScoreWithJitter(long baseTimeMs) {
+        int jitter = ThreadLocalRandom.current().nextInt(RETRY_JITTER_MAX_MS + 1);
+        return baseTimeMs + jitter;
+    }
+}

+ 228 - 0
fs-service/src/main/java/com/fs/company/service/impl/AiCallCallbackCompensateServiceImpl.java

@@ -0,0 +1,228 @@
+package com.fs.company.service.impl;
+
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.http.HttpUtils;
+import com.fs.company.mapper.CompanyAiWorkflowExecMapper;
+import com.fs.company.mapper.CompanyVoiceRoboticCallLogCallphoneMapper;
+import com.fs.company.mapper.EasyCallMapper;
+import com.fs.company.service.IAiCallCallbackCompensateService;
+import com.fs.company.vo.PendingAiCallLogSyncVo;
+import com.fs.company.vo.easycall.EasyCallCompletedCallVo;
+import com.fs.enums.ExecutionStatusEnum;
+import com.fs.enums.NodeExectorEnum;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.redisson.api.RLock;
+import org.redisson.api.RedissonClient;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+
+/**
+ * AI 外呼回调补偿:与 his_scrm syncAiCallData 查询逻辑一致,通过 runParam 中的 callBackUrl 模拟 HTTP 回调
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class AiCallCallbackCompensateServiceImpl implements IAiCallCallbackCompensateService {
+
+    private static final String COMPENSATE_LOCK_PREFIX = "ai_call_callback_compensate:tenant:";
+    private static final String MANUAL_SYNC_LOCK_PREFIX = "ai_call_sync:company:";
+    private static final String SCAN_LOCK_PREFIX = "cid_workflow:scan_lock:";
+    private static final String ACTIVATE_LOCK_PREFIX = "cid_workflow:activate_lock:";
+
+    private static final int BATCH_SIZE = 500;
+    private static final long COMPENSATE_LOCK_MINUTES = 4L;
+
+    /** 回调补偿最大重试次数,超过后不再扫描 */
+    @Value("${ai-call.callback-compensate.max-retry:5}")
+    private int maxCallbackCompensateRetry;
+
+    private final RedisCache redisCache;
+    private final RedissonClient redissonClient;
+    private final CompanyVoiceRoboticCallLogCallphoneMapper callLogCallphoneMapper;
+    private final CompanyAiWorkflowExecMapper companyAiWorkflowExecMapper;
+    private final EasyCallMapper easyCallMapper;
+
+
+    private JdbcTemplate easyCallJdbcTemplate;
+
+    @Override
+    public void compensatePendingCallbacks(Integer cidGroupNo) {
+
+        String compensateLockKey = COMPENSATE_LOCK_PREFIX;
+        if (!redisCache.setIfAbsent(compensateLockKey, "1", COMPENSATE_LOCK_MINUTES, TimeUnit.MINUTES)) {
+            log.info("aiCallCallbackCompensate补偿任务进行中,跳过 ");
+            return;
+        }
+        try {
+            if (shouldSkipTenant(cidGroupNo)) {
+                return;
+            }
+            List<PendingAiCallLogSyncVo> pendingLogs =
+                    callLogCallphoneMapper.selectPendingAiCallLogsForSync(maxCallbackCompensateRetry);
+            if (pendingLogs == null || pendingLogs.isEmpty()) {
+                log.debug("aiCallCallbackCompensate: 无待补偿外呼记录");
+                return;
+            }
+            Map<String, PendingAiCallLogSyncVo> logByCallbackUuid = pendingLogs.stream()
+                    .filter(item -> StringUtils.isNotBlank(item.getCallbackUuid()))
+                    .collect(Collectors.toMap(PendingAiCallLogSyncVo::getCallbackUuid, item -> item, (a, b) -> a));
+            if (logByCallbackUuid.isEmpty()) {
+                return;
+            }
+            List<String> callbackUuids = logByCallbackUuid.keySet().stream().collect(Collectors.toList());
+            log.info("aiCallCallbackCompensate: 开始, 待检查callbackUuid数={}, maxRetry={}",
+                     callbackUuids.size(), maxCallbackCompensateRetry);
+
+            int triggered = 0;
+            int retryRecorded = 0;
+            for (int i = 0; i < callbackUuids.size(); i += BATCH_SIZE) {
+                List<String> batchUuids = callbackUuids.subList(i, Math.min(i + BATCH_SIZE, callbackUuids.size()));
+                List<EasyCallCompletedCallVo> completedCalls =
+                        easyCallMapper.selectCompletedCallsByCallbackUuids(batchUuids);
+                if (completedCalls == null || completedCalls.isEmpty()) {
+                    continue;
+                }
+                for (EasyCallCompletedCallVo completed : completedCalls) {
+                    if (completed == null || StringUtils.isBlank(completed.getUuid())
+                            || StringUtils.isBlank(completed.getCallBackUuid())) {
+                        continue;
+                    }
+                    PendingAiCallLogSyncVo pendingLog = logByCallbackUuid.get(completed.getCallBackUuid());
+                    if (pendingLog == null) {
+                        continue;
+                    }
+                    String callBackUrl = resolveCallBackUrl(pendingLog.getRunParam());
+                    if (StringUtils.isBlank(callBackUrl)) {
+                        log.warn("aiCallCallbackCompensate: runParam 无 callBackUrl, logId={}, callbackUuid={}",
+                                pendingLog.getLogId(), completed.getCallBackUuid());
+                        continue;
+                    }
+                    try {
+                        JSONObject payload = new JSONObject();
+                        payload.put("uuid", completed.getUuid());
+                        payload.put("cdrType", "outbound");
+                        String response = HttpUtils.doPost(callBackUrl, payload.toJSONString());
+                        triggered++;
+                        log.info("aiCallCallbackCompensate: 已模拟回调, logId={}, easyCallUuid={}, retryCount={}, url={}, response={}",
+                                 pendingLog.getLogId(), completed.getUuid(),
+                                pendingLog.getRetryCount(), callBackUrl, response);
+                    } catch (Exception e) {
+                        log.error("aiCallCallbackCompensate: HTTP 回调失败, logId={}, easyCallUuid={}, retryCount={}, url={}",
+                                 pendingLog.getLogId(), completed.getUuid(),
+                                pendingLog.getRetryCount(), callBackUrl, e);
+                    } finally {
+                        if (recordCallbackCompensateRetry(pendingLog)) {
+                            retryRecorded++;
+                        }
+                    }
+                }
+            }
+            log.info("aiCallCallbackCompensate: 完成, 触发回调数={}, 记录重试数={}",
+                     triggered, retryRecorded);
+        } catch (Exception e) {
+            log.error("aiCallCallbackCompensate: 执行异常,", e);
+        } finally {
+            redisCache.deleteObject(compensateLockKey);
+        }
+    }
+
+    /**
+     * 存在正在执行的工作流任务、手动同步或 cid 扫描锁占用时跳过
+     */
+    private boolean shouldSkipTenant(Integer cidGroupNo) {
+        if (isManualSyncRunning()) {
+            log.info("aiCallCallbackCompensate: 手动 syncAiCallData 进行中,跳过 ");
+            return true;
+        }
+        int groupNo = cidGroupNo == null ? 0 : cidGroupNo;
+        if (isCidWorkflowLockHeld(groupNo)) {
+            log.info("aiCallCallbackCompensate: cid 工作流扫描/激活锁占用,跳过, groupNo={}", groupNo);
+            return true;
+        }
+        int runningCount = companyAiWorkflowExecMapper.countRunningExecByGroupNo(
+                groupNo,
+                ExecutionStatusEnum.RUNNING.getValue(),
+                NodeExectorEnum.PC.getValue());
+        if (runningCount > 0) {
+            log.info("aiCallCallbackCompensate: 租户存在 RUNNING 工作流任务({}条),跳过, groupNo={}",
+                    runningCount, groupNo);
+            return true;
+        }
+        return false;
+    }
+
+    private boolean isManualSyncRunning() {
+        String pattern = MANUAL_SYNC_LOCK_PREFIX  + ":*";
+        try {
+            java.util.Collection<String> keys = redisCache.keys(pattern);
+            return keys != null && !keys.isEmpty();
+        } catch (Exception e) {
+            log.warn("aiCallCallbackCompensate: 检查手动同步锁失败 ", e);
+            return false;
+        }
+    }
+
+    private boolean isCidWorkflowLockHeld(int groupNo) {
+        String scanLockKey = SCAN_LOCK_PREFIX  + ":" + groupNo;
+        String activateLockKey = ACTIVATE_LOCK_PREFIX  + ":" + groupNo;
+        RLock scanLock = redissonClient.getLock(scanLockKey);
+        RLock activateLock = redissonClient.getLock(activateLockKey);
+        return scanLock.isLocked() || activateLock.isLocked();
+    }
+
+    /**
+     * 每次触发补偿回调后递增 call_log.retry_count;达到上限后不再被扫描
+     */
+    private boolean recordCallbackCompensateRetry(PendingAiCallLogSyncVo pendingLog) {
+        int rows = callLogCallphoneMapper.incrementCallbackCompensateRetryCount(pendingLog.getLogId());
+        if (rows <= 0) {
+            return false;
+        }
+        int previousRetry = pendingLog.getRetryCount() == null ? 0 : pendingLog.getRetryCount();
+        int nextRetry = previousRetry + 1;
+        pendingLog.setRetryCount(nextRetry);
+        if (nextRetry >= maxCallbackCompensateRetry) {
+            log.warn("aiCallCallbackCompensate: 已达最大补偿重试次数({}),将不再扫描, logId={}, callbackUuid={}",
+                    maxCallbackCompensateRetry, pendingLog.getLogId(), pendingLog.getCallbackUuid());
+        }
+        return true;
+    }
+
+    /**
+     * 从 runParam.phoneList[0].bizJson.callBackUrl 解析回调地址
+     */
+    static String resolveCallBackUrl(String runParam) {
+        if (StringUtils.isBlank(runParam)) {
+            return null;
+        }
+        try {
+            JSONObject root = JSONObject.parseObject(runParam);
+            JSONArray phoneList = root.getJSONArray("phoneList");
+            if (phoneList == null || phoneList.isEmpty()) {
+                return null;
+            }
+            JSONObject firstPhone = phoneList.getJSONObject(0);
+            if (firstPhone == null) {
+                return null;
+            }
+            JSONObject bizJson = firstPhone.getJSONObject("bizJson");
+            if (bizJson == null) {
+                return null;
+            }
+            return bizJson.getString("callBackUrl");
+        } catch (Exception e) {
+            return null;
+        }
+    }
+}

+ 171 - 21
fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticServiceImpl.java

@@ -27,6 +27,7 @@ import com.fs.company.mapper.*;
 import com.fs.company.param.ExecutionContext;
 import com.fs.company.param.ExecutionContext;
 import com.fs.company.param.PauseRoboticActiveParam;
 import com.fs.company.param.PauseRoboticActiveParam;
 import com.fs.company.service.*;
 import com.fs.company.service.*;
+import com.fs.company.service.easycall.OutboundLimitRetrySupport;
 import com.fs.company.service.impl.call.node.EasyCallTaskControlService;
 import com.fs.company.service.impl.call.node.EasyCallTaskControlService;
 import com.fs.company.vo.*;
 import com.fs.company.vo.*;
 import com.fs.company.vo.easycall.EasyCallCallPhoneVO;
 import com.fs.company.vo.easycall.EasyCallCallPhoneVO;
@@ -49,10 +50,12 @@ import com.github.pagehelper.PageInfo;
 import lombok.RequiredArgsConstructor;
 import lombok.RequiredArgsConstructor;
 import lombok.Synchronized;
 import lombok.Synchronized;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.scheduling.annotation.Async;
 import org.springframework.scheduling.annotation.Async;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 
 
 import java.util.*;
 import java.util.*;
+import java.util.concurrent.TimeUnit;
 import java.util.stream.Collectors;
 import java.util.stream.Collectors;
 
 
 import static com.fs.company.service.impl.call.node.AiCallTaskNode.EASYCALL_WORKFLOW_REDIS_KEY;
 import static com.fs.company.service.impl.call.node.AiCallTaskNode.EASYCALL_WORKFLOW_REDIS_KEY;
@@ -139,6 +142,13 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
     private static final int EASYCALL_DIALOGUE_MAX_RETRY = 5;
     private static final int EASYCALL_DIALOGUE_MAX_RETRY = 5;
     /** dialogue 每次重试等待时长(毫秒) */
     /** dialogue 每次重试等待时长(毫秒) */
     private static final long EASYCALL_DIALOGUE_RETRY_INTERVAL_MS = 30000L;
     private static final long EASYCALL_DIALOGUE_RETRY_INTERVAL_MS = 30000L;
+    /** 同一 uuid 仅允许一条 intent 重试链在跑 */
+    private static final String EASYCALL_INTENT_RETRY_ACTIVE_KEY = "easycall:intent:retry:active:";
+    /** EasyCall 回调业务已派发 finalize,用于跳过重复回调/过期重试 */
+    private static final String EASYCALL_CALLBACK_HANDLED_KEY = "easycall:callback:handled:";
+
+    @Autowired
+    private OutboundLimitRetrySupport outboundLimitRetrySupport;
 
 
     /**
     /**
      * 查询机器人外呼任务
      * 查询机器人外呼任务
@@ -794,10 +804,36 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
      * @return 结果
      * @return 结果
      */
      */
     @Override
     @Override
-    public int updateDelFlag(Long id, Integer delFlag) {
-        return companyVoiceRoboticMapper.updateDelFlag(id, delFlag);
+    public void updateDelFlag(Long id, Integer delFlag) {
+        if (delFlag != null && delFlag == 1) {
+            CompanyVoiceRobotic robotic = selectCompanyVoiceRoboticById(id);
+            if (robotic != null && Integer.valueOf(1).equals(robotic.getTaskStatus())) {
+                pauseRunningRoboticTask(robotic, "delete");
+            } else if (robotic != null && outboundLimitRetrySupport != null) {
+                outboundLimitRetrySupport.cancelPendingRetriesForRobotic(id);
+            }
+        }
+        CompanyVoiceRobotic update = new CompanyVoiceRobotic();
+        update.setId(id);
+        update.setDelFlag(delFlag);
+        companyVoiceRoboticMapper.updateById(update);
+    }
+
+    /**
+     * 暂停执行中的外呼任务(更新状态、Redis、EasyCall)
+     */
+    private void pauseRunningRoboticTask(CompanyVoiceRobotic robotic, String pauseSource) {
+        robotic.setTaskStatus(2);
+        robotic.setPauseSource(pauseSource);
+        updateCompanyVoiceRobotic(robotic);
+        redisCache2.setCacheObject("task:status:" + robotic.getId(), 2);
+        pauseEasyCallTask(robotic);
+        if (outboundLimitRetrySupport != null) {
+            outboundLimitRetrySupport.cancelPendingRetriesForRobotic(robotic.getId());
+        }
     }
     }
 
 
+
     @Override
     @Override
     public List<CompanyVoiceRoboticQwUserListVo> qwUserList() {
     public List<CompanyVoiceRoboticQwUserListVo> qwUserList() {
         return companyVoiceRoboticMapper.qwUserList();
         return companyVoiceRoboticMapper.qwUserList();
@@ -856,6 +892,13 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
         if (result == null || StringUtils.isBlank(result.getUuid())) {
         if (result == null || StringUtils.isBlank(result.getUuid())) {
             return;
             return;
         }
         }
+        if (isEasyCallCallbackHandled(result.getUuid())) {
+            log.info("easyCall回调已完成,跳过重复处理, uuid={}", result.getUuid());
+            return;
+        }
+        if (StringUtils.isNotBlank(result.getCdrBody())) {
+            clearEasyCallIntentRetryState(result.getUuid());
+        }
         //调用查询查找外呼结果
         //调用查询查找外呼结果
         EasyCallCallPhoneVO callPhoneRes = easyCallMapper.getCallPhoneInfoByUuid(result.getUuid());
         EasyCallCallPhoneVO callPhoneRes = easyCallMapper.getCallPhoneInfoByUuid(result.getUuid());
         if (null == callPhoneRes) {
         if (null == callPhoneRes) {
@@ -871,40 +914,122 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
         }
         }
             try{
             try{
                 // 【当前启用】cc_call_phone.intent 由 EasyCall 异步评估写入,回调时可能尚未赋值,进入延迟重试队列等待
                 // 【当前启用】cc_call_phone.intent 由 EasyCall 异步评估写入,回调时可能尚未赋值,进入延迟重试队列等待
+                if (isEasyCallRecordReadyForFinalize(callPhoneRes)) {
+                    log.info("easyCall话单已完整,不再等待intent,uuid={}", result.getUuid());
+                    clearEasyCallIntentRetryState(result.getUuid());
+                    tryHandleEasyCallResult(result, callPhoneRes);
+                    return;
+                }
+
                 if (StringUtils.isBlank(resolveCcCallPhoneIntent(callPhoneRes))) {
                 if (StringUtils.isBlank(resolveCcCallPhoneIntent(callPhoneRes))) {
-                    String retryKey = EASYCALL_INTENT_RETRY_KEY + result.getUuid();
-                    Integer retryCount = redisCache2.getCacheObject(retryKey);
-                    if (retryCount == null) {
-                        retryCount = 0;
-                    }
-                    if (retryCount < EASYCALL_INTENT_MAX_RETRY) {
-                        redisCache2.setCacheObject(retryKey, retryCount + 1, 10, java.util.concurrent.TimeUnit.MINUTES);
-                        log.info("easyCall外呼回调intent意向度暂未评估完成,uuid={},第{}次放入延迟重试队列", result.getUuid(), retryCount + 1);
-                        doRetryCallerResult4EasyCall(result, retryCount + 1);
-                    } else {
-                        log.warn("easyCall外呼回调intent意向度在{}次重试后仍为空,uuid={},以意向未知兜底处理", EASYCALL_INTENT_MAX_RETRY, result.getUuid());
-                        redisCache2.deleteObject(retryKey);
-                        doHandleEasyCallResult(callPhoneRes);
+                    if (!acquireEasyCallIntentRetryChain(result.getUuid())) {
+                        log.info("easyCall intent重试链已在进行,跳过重复调度, uuid={}", result.getUuid());
+                        return;
                     }
                     }
+                    log.info("easyCall外呼回调intent意向度暂未评估完成,uuid={},{}秒后第1次延迟重试",
+                            result.getUuid(), EASYCALL_INTENT_RETRY_INTERVAL_MS / 1000);
+                    redisCache2.setCacheObject(EASYCALL_INTENT_RETRY_KEY + result.getUuid(), 1L,
+                            10, TimeUnit.MINUTES);
+                    doRetryCallerResult4EasyCall(result, 1);
                     return;
                     return;
                 }
                 }
-                redisCache2.deleteObject(EASYCALL_INTENT_RETRY_KEY + result.getUuid());
-                doHandleEasyCallResult(callPhoneRes);
+                clearEasyCallIntentRetryState(result.getUuid());
+                tryHandleEasyCallResult(result, callPhoneRes);
+
+//                if (StringUtils.isBlank(resolveCcCallPhoneIntent(callPhoneRes))) {
+//                    String retryKey = EASYCALL_INTENT_RETRY_KEY + result.getUuid();
+//                    Integer retryCount = redisCache2.getCacheObject(retryKey);
+//                    if (retryCount == null) {
+//                        retryCount = 0;
+//                    }
+//                    if (retryCount < EASYCALL_INTENT_MAX_RETRY) {
+//                        redisCache2.setCacheObject(retryKey, retryCount + 1, 10, java.util.concurrent.TimeUnit.MINUTES);
+//                        log.info("easyCall外呼回调intent意向度暂未评估完成,uuid={},第{}次放入延迟重试队列", result.getUuid(), retryCount + 1);
+//                        doRetryCallerResult4EasyCall(result, retryCount + 1);
+//                    } else {
+//                        log.warn("easyCall外呼回调intent意向度在{}次重试后仍为空,uuid={},以意向未知兜底处理", EASYCALL_INTENT_MAX_RETRY, result.getUuid());
+//                        redisCache2.deleteObject(retryKey);
+//                        doHandleEasyCallResult(callPhoneRes);
+//                    }
+//                    return;
+//                }
+//                redisCache2.deleteObject(EASYCALL_INTENT_RETRY_KEY + result.getUuid());
+//                doHandleEasyCallResult(callPhoneRes);
 
 
             } catch (Exception e) {
             } catch (Exception e) {
                 throw new RuntimeException(e);
                 throw new RuntimeException(e);
             }
             }
 
 
     }
     }
-    private boolean isDialogueEmpty(String dialogue) {
-        if (StringUtils.isBlank(dialogue)) {
+
+    private boolean acquireEasyCallIntentRetryChain(String uuid) {
+        return redisCache2.setIfAbsent(EASYCALL_INTENT_RETRY_ACTIVE_KEY + uuid, "1", 10, TimeUnit.MINUTES);
+    }
+
+    private void tryHandleEasyCallResult(CdrDetailVo cdr, EasyCallCallPhoneVO callPhoneRes) {
+        if (cdr == null || StringUtils.isBlank(cdr.getUuid())) {
+            return;
+        }
+        if (isEasyCallCallbackHandled(cdr.getUuid())) {
+            log.info("easyCall回调已处理,跳过, uuid={}", cdr.getUuid());
+            return;
+        }
+        doHandleEasyCallResult(callPhoneRes);
+    }
+
+    /**
+     * 已接通话单是否具备 finalize 所需数据(避免仅有 callEndTime 时过早落库导致缺对话/缺扣费)。
+     */
+    public static boolean isEasyCallRecordReadyForFinalize(EasyCallCallPhoneVO vo) {
+        if (vo == null) {
+            return false;
+        }
+        if (vo.getCallEndTime() == null || vo.getCallEndTime() <= 0) {
+            return StringUtils.isNotBlank(vo.getUnconnectedReason());
+        }
+        if (isUnconnectedTerminalStatus(vo)) {
             return true;
             return true;
         }
         }
-        String trimmed = dialogue.trim();
-        return "[]".equals(trimmed);
+        if (isConnectedCall(vo)) {
+            return (vo.getValidTimeLen() != null && vo.getValidTimeLen() > 0)
+                    || !isDialogueEmpty(vo.getDialogue());
+        }
+        return vo.getCallEndTime() > 0;
     }
     }
 
 
+    private static boolean isConnectedCall(EasyCallCallPhoneVO vo) {
+        Short callstatus = vo.getCallstatus();
+        if (callstatus != null && callstatus == 6) {
+            return true;
+        }
+        return vo.getAnsweredTime() != null && vo.getAnsweredTime() > 0;
+    }
 
 
+    private static boolean isUnconnectedTerminalStatus(EasyCallCallPhoneVO vo) {
+        if (StringUtils.isNotBlank(vo.getUnconnectedReason())) {
+            return true;
+        }
+        Short callstatus = vo.getCallstatus();
+        if (callstatus == null) {
+            return false;
+        }
+        if (callstatus == 3 || callstatus == 5 || callstatus == 7) {
+            return true;
+        }
+        return callstatus >= 30 && callstatus <= 39;
+    }
+
+    private boolean isEasyCallCallbackHandled(String uuid) {
+        return Boolean.TRUE.equals(redisCache2.getCacheObject(EASYCALL_CALLBACK_HANDLED_KEY + uuid));
+    }
+
+    private void clearEasyCallIntentRetryState(String uuid) {
+        if (StringUtils.isBlank(uuid)) {
+            return;
+        }
+        redisCache2.deleteObject(EASYCALL_INTENT_RETRY_KEY + uuid);
+        redisCache2.deleteObject(EASYCALL_INTENT_RETRY_ACTIVE_KEY + uuid);
+    }
 
 
     /**
     /**
      * 延迟重试处理 EasyCall 外呼回调(等待 dialogue 对话内容异步写入完成)
      * 延迟重试处理 EasyCall 外呼回调(等待 dialogue 对话内容异步写入完成)
@@ -948,6 +1073,16 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
         redisCache2.deleteObject(EASYCALL_DIALOGUE_RETRY_KEY + result.getUuid());
         redisCache2.deleteObject(EASYCALL_DIALOGUE_RETRY_KEY + result.getUuid());
         doHandleEasyCallResult(callPhoneRes);
         doHandleEasyCallResult(callPhoneRes);
     }
     }
+
+    private static boolean isDialogueEmpty(String dialogue) {
+        if (StringUtils.isBlank(dialogue)) {
+            return true;
+        }
+        String trimmed = dialogue.trim();
+        return "[]".equals(trimmed);
+    }
+
+
     /**
     /**
      * 延迟重试处理 EasyCall 外呼回调(等待 intent 意向度异步评估完成)
      * 延迟重试处理 EasyCall 外呼回调(等待 intent 意向度异步评估完成)
      * 每次重试前等待 {@link #EASYCALL_INTENT_RETRY_INTERVAL_MS} 毫秒后重新拉取数据
      * 每次重试前等待 {@link #EASYCALL_INTENT_RETRY_INTERVAL_MS} 毫秒后重新拉取数据
@@ -2400,6 +2535,21 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
         return R.ok(resultData);
         return R.ok(resultData);
     }
     }
 
 
+    @Override
+    public boolean isTaskRunning(Long taskId) {
+        if (taskId == null) {
+            return false;
+        }
+        CompanyVoiceRobotic robotic = companyVoiceRoboticMapper.selectCompanyVoiceRoboticById(taskId);
+        if (robotic == null) {
+            return false;
+        }
+        if (Integer.valueOf(1).equals(robotic.getDelFlag())) {
+            return false;
+        }
+        return Integer.valueOf(1).equals(robotic.getTaskStatus());
+    }
+
     /**
     /**
      * 根据客户ID列表获取重复客户名称
      * 根据客户ID列表获取重复客户名称
      */
      */

+ 21 - 0
fs-service/src/main/java/com/fs/company/vo/PendingAiCallLogSyncVo.java

@@ -0,0 +1,21 @@
+package com.fs.company.vo;
+
+import lombok.Data;
+
+/**
+ * 待补偿 AI 外呼日志(未回调完成)
+ */
+@Data
+public class PendingAiCallLogSyncVo {
+
+    private Long logId;
+
+    private String callbackUuid;
+
+    private String runParam;
+
+    private Long companyId;
+
+    /** 回调补偿已重试次数 */
+    private Integer retryCount;
+}

+ 16 - 0
fs-service/src/main/java/com/fs/company/vo/easycall/EasyCallCompletedCallVo.java

@@ -0,0 +1,16 @@
+package com.fs.company.vo.easycall;
+
+import lombok.Data;
+
+/**
+ * EasyCall 已外呼完成的话单摘要
+ */
+@Data
+public class EasyCallCompletedCallVo {
+
+    /** cc_call_phone.uuid */
+    private String uuid;
+
+    /** biz_json.callBackUuid */
+    private String callBackUuid;
+}

+ 19 - 4
fs-service/src/main/java/com/fs/course/mapper/FsCourseWatchLogMapper.java

@@ -140,10 +140,18 @@ public interface FsCourseWatchLogMapper extends BaseMapper<FsCourseWatchLog> {
     @Select("select l.*,v.title,c.course_name from fs_course_watch_log l LEFT JOIN fs_user_course_video v ON v.video_id = l.video_id LEFT JOIN fs_user_course c ON c.course_id = l.course_id WHERE l.qw_external_contact_id =#{id} ORDER BY l.create_time  desc LIMIT 1  ")
     @Select("select l.*,v.title,c.course_name from fs_course_watch_log l LEFT JOIN fs_user_course_video v ON v.video_id = l.video_id LEFT JOIN fs_user_course c ON c.course_id = l.course_id WHERE l.qw_external_contact_id =#{id} ORDER BY l.create_time  desc LIMIT 1  ")
     FsCourseWatchLogVO selectFsCourseWatchLogByExtId(Long id);
     FsCourseWatchLogVO selectFsCourseWatchLogByExtId(Long id);
 
 
-    @Select("select l.qw_external_contact_id,l.company_id,l.company_user_id,l.log_type,ext.`level`,l.qw_user_id FROM fs_course_watch_log l LEFT JOIN qw_external_contact ext ON ext.id =l.qw_external_contact_id  where l.sop_id=#{SopId} and  AND l.create_time >= CURDATE() AND l.create_time < CURDATE() + INTERVAL 1 DAY  and l.log_type =3 and l.video_id not in (select video_id from fs_user_course_video WHERE is_first=1 ) ")
+    @Select("select l.qw_external_contact_id,l.company_id,l.company_user_id,l.log_type,ext.`level`,l.qw_user_id " +
+            "FROM fs_course_watch_log l LEFT JOIN qw_external_contact ext ON ext.id =l.qw_external_contact_id  " +
+            "where l.sop_id=#{SopId}  AND l.create_time >= CURDATE() AND l.create_time < CURDATE() + INTERVAL 1 DAY  " +
+            "and l.log_type =3 and l.video_id not in (select video_id from fs_user_course_video WHERE is_first=1 ) ")
     List<FsCourseWatchLogTaskVO> selectFsCourseWatchLogByDaySopId3(@Param("SopId")String SopId);
     List<FsCourseWatchLogTaskVO> selectFsCourseWatchLogByDaySopId3(@Param("SopId")String SopId);
 
 
-    @Select("select l.qw_external_contact_id,l.company_id,l.company_user_id,l.log_type,ext.`level`,l.qw_user_id FROM fs_course_watch_log l LEFT JOIN qw_external_contact ext ON ext.id =l.qw_external_contact_id  where l.sop_id=#{SopId} and  AND l.create_time >= CURDATE() AND l.create_time < CURDATE() + INTERVAL 1 DAY and l.log_type =4 and l.video_id not in (select video_id from fs_user_course_video WHERE is_first=1 ) ")
+    @Select("select l.qw_external_contact_id,l.company_id,l.company_user_id,l.log_type,ext.`level`,l.qw_user_id " +
+            " FROM fs_course_watch_log l " +
+            "LEFT JOIN qw_external_contact ext ON ext.id =l.qw_external_contact_id  " +
+            "where l.sop_id=#{SopId}  " +
+            "AND l.create_time >= CURDATE() AND l.create_time < CURDATE() + INTERVAL 1 DAY " +
+            "and l.log_type =4 and l.video_id not in (select video_id from fs_user_course_video WHERE is_first=1 ) ")
     List<FsCourseWatchLogTaskVO> selectFsCourseWatchLogByDaySopId4(@Param("SopId")String SopId);
     List<FsCourseWatchLogTaskVO> selectFsCourseWatchLogByDaySopId4(@Param("SopId")String SopId);
 
 
 //    @Select("   SELECT\n" +
 //    @Select("   SELECT\n" +
@@ -548,10 +556,17 @@ public interface FsCourseWatchLogMapper extends BaseMapper<FsCourseWatchLog> {
             "ORDER BY ds.report_date ASC  ")
             "ORDER BY ds.report_date ASC  ")
     List<Integer> selectFsCourseWatchLog7DayByExtId(Long extId);
     List<Integer> selectFsCourseWatchLog7DayByExtId(Long extId);
 
 
-    @Select("select l.user_id,l.company_id,l.company_user_id,l.log_type,ext.`level`,l.qw_user_id FROM fs_course_watch_log l LEFT JOIN fs_user ext ON ext.user_id =l.user_id  where l.sop_id=#{SopId} and  date(l.create_time)= CURDATE() and l.log_type =4 and l.video_id not in (select video_id from fs_user_course_video WHERE is_first=1 )")
+    @Select("select l.user_id,l.company_id,l.company_user_id,l.log_type,ext.`level`,l.qw_user_id " +
+            "FROM fs_course_watch_log l LEFT JOIN fs_user ext ON ext.user_id =l.user_id  " +
+            "where l.sop_id=#{SopId} and  date(l.create_time)= CURDATE() and l.log_type =4 " +
+            "and l.video_id not in (select video_id from fs_user_course_video WHERE is_first=1 ) ")
     List<FsCourseWatchLogTaskVO> selectFsCourseWatchLogByDaySopIdFsUser4(@Param("SopId")String SopId);
     List<FsCourseWatchLogTaskVO> selectFsCourseWatchLogByDaySopIdFsUser4(@Param("SopId")String SopId);
 
 
-    @Select("select l.qw_external_contact_id,l.company_id,l.company_user_id,l.log_type,ext.`level`,l.qw_user_id,ext.last_watch_time FROM fs_course_watch_log l LEFT JOIN qw_external_contact ext ON ext.id =l.qw_external_contact_id  where l.sop_id=#{SopId} and  date(l.create_time)= CURDATE() and l.log_type =3 and l.video_id not in (select video_id from fs_user_course_video WHERE is_first=1 ) and ext.last_watch_time < #{lastTime} and ext.last_watch_time !=0  ")
+    @Select("select l.qw_external_contact_id,l.company_id,l.company_user_id,l.log_type,ext.`level`,l.qw_user_id,ext.last_watch_time " +
+            "FROM fs_course_watch_log l LEFT JOIN qw_external_contact ext ON ext.id =l.qw_external_contact_id  " +
+            "where l.sop_id=#{SopId} and  date(l.create_time)= CURDATE() and l.log_type =3 " +
+            "and l.video_id not in (select video_id from fs_user_course_video WHERE is_first=1 ) " +
+            "and ext.last_watch_time < #{lastTime} and ext.last_watch_time !=0  ")
     List<FsCourseWatchLogTaskVO> selectFsCourseWatchLogByDaySopId3LastTime(@Param("SopId")String SopId,@Param("lastTime")Integer lastTime);
     List<FsCourseWatchLogTaskVO> selectFsCourseWatchLogByDaySopId3LastTime(@Param("SopId")String SopId,@Param("lastTime")Integer lastTime);
 
 
     @Select("SELECT l.qw_external_contact_id,l.project,l.video_id,l.course_id,l.log_type,l.qw_user_id,l.create_time lineTime,l.company_user_id as company_user_id,l.user_id as user_id,l.company_id as company_id FROM fs_course_watch_log l" +
     @Select("SELECT l.qw_external_contact_id,l.project,l.video_id,l.course_id,l.log_type,l.qw_user_id,l.create_time lineTime,l.company_user_id as company_user_id,l.user_id as user_id,l.company_id as company_id FROM fs_course_watch_log l" +

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

@@ -357,6 +357,8 @@ public interface FsUserCourseMapper
      */
      */
     List<Map<String, Object>> selectCourseNamesByIds(@Param("courseIds") Set<Long> courseIds);
     List<Map<String, Object>> selectCourseNamesByIds(@Param("courseIds") Set<Long> courseIds);
 
 
+    List<FsUserCourse> selectAllCourseByIds(@Param("courseIds") Set<Long> courseIds);
+
     @Select(" SELECT course_id as dictValue, \n" +
     @Select(" SELECT course_id as dictValue, \n" +
             "           course_name as dictLabel \n" +
             "           course_name as dictLabel \n" +
             "    FROM fs_user_course \n" +
             "    FROM fs_user_course \n" +

+ 11 - 0
fs-service/src/main/java/com/fs/course/param/FsUserCourseEditParam.java

@@ -0,0 +1,11 @@
+package com.fs.course.param;
+
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class FsUserCourseEditParam {
+    private List<Long> courseIds;
+    private Long companyId;
+}

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

@@ -7,6 +7,9 @@ import com.fs.course.dto.ImportResultDTO;
 import com.fs.course.param.FsCourseAnswerStatusQueryParam;
 import com.fs.course.param.FsCourseAnswerStatusQueryParam;
 import com.fs.course.param.FsCourseQuestionAnswerUParam;
 import com.fs.course.param.FsCourseQuestionAnswerUParam;
 import com.fs.course.param.LiveQuizSubmitUParam;
 import com.fs.course.param.LiveQuizSubmitUParam;
+import com.fs.live.domain.LiveWatchConfig;
+import com.fs.live.param.LiveCompletionCouponAnswerParam;
+import com.fs.live.vo.LiveCompletionCouponAnswerResult;
 
 
 import javax.validation.constraints.Size;
 import javax.validation.constraints.Size;
 import java.util.List;
 import java.util.List;
@@ -112,4 +115,9 @@ public interface IFsCourseQuestionBankService
      * 用户端:答题正确后领取本节配置的 integralReward 积分(与答题维度一致,Redis 唯一锁、幂等)。
      * 用户端:答题正确后领取本节配置的 integralReward 积分(与答题维度一致,Redis 唯一锁、幂等)。
      */
      */
     R claimPublicCourseAnswerIntegralReward(FsCourseAnswerStatusQueryParam param);
     R claimPublicCourseAnswerIntegralReward(FsCourseAnswerStatusQueryParam param);
+
+    /**
+     * 提交今日问题作答(仅记录答题结果,不发券)
+     */
+    LiveCompletionCouponAnswerResult submitAnswers(LiveCompletionCouponAnswerParam param, Long userId,LiveWatchConfig config);
 }
 }

+ 3 - 0
fs-service/src/main/java/com/fs/course/service/IFsUserCourseService.java

@@ -164,6 +164,9 @@ public interface IFsUserCourseService {
 
 
     R getAppCourseLearningOne(long userId);
     R getAppCourseLearningOne(long userId);
 
 
+    R publicEditCompany(FsUserCourseEditParam param);
+    R publicRemoveEditCompany(FsUserCourseEditParam param);
+
 }
 }
 
 
 
 

+ 218 - 0
fs-service/src/main/java/com/fs/course/service/impl/FsCourseQuestionBankServiceImpl.java

@@ -7,6 +7,7 @@ import com.alibaba.fastjson.JSONObject;
 import com.fs.common.core.domain.R;
 import com.fs.common.core.domain.R;
 import com.fs.common.core.redis.RedisCache;
 import com.fs.common.core.redis.RedisCache;
 import com.fs.common.exception.ServiceException;
 import com.fs.common.exception.ServiceException;
+import com.fs.common.exception.base.BaseException;
 import com.fs.common.utils.DateUtils;
 import com.fs.common.utils.DateUtils;
 import com.fs.common.utils.StringUtils;
 import com.fs.common.utils.StringUtils;
 import com.fs.course.config.CourseConfig;
 import com.fs.course.config.CourseConfig;
@@ -20,6 +21,8 @@ import com.fs.course.param.LiveQuizSubmitUParam;
 import com.fs.course.service.IFsCourseQuestionBankService;
 import com.fs.course.service.IFsCourseQuestionBankService;
 import com.fs.course.vo.FsCourseAnswerStatusVO;
 import com.fs.course.vo.FsCourseAnswerStatusVO;
 import com.fs.course.util.CourseConfigUserAnswerExpose;
 import com.fs.course.util.CourseConfigUserAnswerExpose;
+import com.fs.live.domain.Live;
+import com.fs.live.domain.LiveWatchConfig;
 import com.fs.live.mapper.LiveCourseQuestionRelMapper;
 import com.fs.live.mapper.LiveCourseQuestionRelMapper;
 import com.fs.his.domain.FsUser;
 import com.fs.his.domain.FsUser;
 import com.fs.his.domain.FsUserIntegralLogs;
 import com.fs.his.domain.FsUserIntegralLogs;
@@ -28,7 +31,16 @@ import com.fs.his.mapper.FsUserIntegralLogsMapper;
 import com.fs.his.mapper.FsUserMapper;
 import com.fs.his.mapper.FsUserMapper;
 import com.fs.his.service.IFsStorePaymentService;
 import com.fs.his.service.IFsStorePaymentService;
 import com.fs.his.service.IFsUserIntegralLogsService;
 import com.fs.his.service.IFsUserIntegralLogsService;
+import com.fs.live.param.LiveCompletionCouponAnswerItem;
+import com.fs.live.param.LiveCompletionCouponAnswerParam;
+import com.fs.live.service.ILiveCompletionAnswerRecordService;
+import com.fs.live.service.ILiveService;
+import com.fs.live.vo.AnswerEvaluation;
+import com.fs.live.vo.LiveCompletionAnswerDetailVO;
+import com.fs.live.vo.LiveCompletionAnswerRecordListVO;
+import com.fs.live.vo.LiveCompletionCouponAnswerResult;
 import com.fs.system.service.ISysConfigService;
 import com.fs.system.service.ISysConfigService;
+import com.fs.voice.utils.StringUtil;
 import lombok.Getter;
 import lombok.Getter;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.beans.factory.annotation.Value;
@@ -80,6 +92,10 @@ public class FsCourseQuestionBankServiceImpl implements IFsCourseQuestionBankSer
     private IFsUserIntegralLogsService fsUserIntegralLogsService;
     private IFsUserIntegralLogsService fsUserIntegralLogsService;
     @Autowired
     @Autowired
     private RedisCache redisCache;
     private RedisCache redisCache;
+
+    @Autowired
+    private ILiveCompletionAnswerRecordService liveCompletionAnswerRecordService;
+
     @Value("${cloud_host.company_name}")
     @Value("${cloud_host.company_name}")
     private String signProjectName;
     private String signProjectName;
 
 
@@ -872,6 +888,7 @@ public class FsCourseQuestionBankServiceImpl implements IFsCourseQuestionBankSer
         }
         }
     }
     }
 
 
+
     private static String resolveAnswerIntegralBusinessId(Long videoId, Long periodId) {
     private static String resolveAnswerIntegralBusinessId(Long videoId, Long periodId) {
         if (periodId != null) {
         if (periodId != null) {
             return videoId + ":p:" + periodId;
             return videoId + ":p:" + periodId;
@@ -1359,4 +1376,205 @@ public class FsCourseQuestionBankServiceImpl implements IFsCourseQuestionBankSer
         String cleanString = inputString.replaceAll("[\\[\\]\"\\s]", "");
         String cleanString = inputString.replaceAll("[\\[\\]\"\\s]", "");
         return cleanString.split(",");
         return cleanString.split(",");
     }
     }
+
+
+
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public LiveCompletionCouponAnswerResult submitAnswers(LiveCompletionCouponAnswerParam param, Long userId,LiveWatchConfig config) {
+
+        if (param == null || param.getLiveId() == null) {
+            throw new BaseException("参数错误");
+        }
+        if (param.getAnswers() == null || param.getAnswers().isEmpty()) {
+            throw new BaseException("请完成今日问题");
+        }
+
+        if (StringUtil.strIsNullOrEmpty(config.getFinishQuestionIds())){
+            throw new BaseException("未设置题目");
+        }
+
+        int i = liveCompletionAnswerRecordService.countLiveCompletionAnswerRecordByLiveUserId(param.getLiveId(), userId);
+
+        //答题次数
+        if (i >= config.getAnswerAttempts()){
+            LiveCompletionCouponAnswerResult result = new LiveCompletionCouponAnswerResult();
+            result.setAllCorrect(false);
+            result.setRecordId(null);
+            result.setAnswerNumOver(true);
+            result.setCorrectNum(0);
+            return result;
+        }
+
+        List<Long> configuredQuestionIds = Arrays.stream(config.getFinishQuestionIds().split(",")).map(Long::valueOf).collect(Collectors.toList());
+
+        List<LiveCompletionCouponAnswerItem> normalizedAnswers = normalizeUserAnswers(param.getAnswers());
+        AnswerEvaluation evaluation = evaluateAnswersWithDetails(normalizedAnswers, configuredQuestionIds);
+        Long recordId = liveCompletionAnswerRecordService.saveAnswerRecord(
+                param.getLiveId(), userId, evaluation.isAllCorrect(), normalizedAnswers, evaluation.getDetails());
+
+        LiveCompletionCouponAnswerResult result = new LiveCompletionCouponAnswerResult();
+        result.setAllCorrect(evaluation.isAllCorrect());
+        result.setRecordId(recordId);
+        result.setAnswerNumOver(false);
+        result.setCorrectNum(Math.max(0, config.getAnswerAttempts() - (i + 1)));
+        return result;
+    }
+
+    /**
+     * 将 App 扁平选项格式归一为每题一条作答记录。
+     * <p>App 可能按「每个选项一条」提交(含 answerIndex / answerName / isAnswer),
+     * 而题库答案按选项文案存储,需合并后再校验。</p>
+     */
+    private List<LiveCompletionCouponAnswerItem> normalizeUserAnswers(List<LiveCompletionCouponAnswerItem> rawAnswers) {
+        if (rawAnswers == null || rawAnswers.isEmpty()) {
+            return Collections.emptyList();
+        }
+
+        boolean flatOptionFormat = rawAnswers.stream().anyMatch(a -> a.getIsAnswer() != null)
+                || rawAnswers.stream()
+                .filter(a -> a.getQuestionId() != null)
+                .collect(Collectors.groupingBy(LiveCompletionCouponAnswerItem::getQuestionId))
+                .values().stream().anyMatch(list -> list.size() > 1);
+        if (!flatOptionFormat) {
+            return rawAnswers;
+        }
+
+        Map<Long, List<LiveCompletionCouponAnswerItem>> grouped = rawAnswers.stream()
+                .filter(a -> a.getQuestionId() != null)
+                .collect(Collectors.groupingBy(LiveCompletionCouponAnswerItem::getQuestionId));
+
+        List<Long> questionIds = new ArrayList<>(grouped.keySet());
+        Map<Long, FsCourseQuestionBank> questionMap = selectFsCourseQuestionBankByIds(questionIds)
+                .stream()
+                .collect(Collectors.toMap(FsCourseQuestionBank::getId, q -> q, (a, b) -> a));
+
+        List<LiveCompletionCouponAnswerItem> normalized = new ArrayList<>();
+        for (Map.Entry<Long, List<LiveCompletionCouponAnswerItem>> entry : grouped.entrySet()) {
+            Long questionId = entry.getKey();
+            List<LiveCompletionCouponAnswerItem> options = entry.getValue();
+
+            List<LiveCompletionCouponAnswerItem> selected = options.stream()
+                    .filter(a -> a.getIsAnswer() != null && a.getIsAnswer() == 1)
+                    .collect(Collectors.toList());
+
+            LiveCompletionCouponAnswerItem item = new LiveCompletionCouponAnswerItem();
+            item.setQuestionId(questionId);
+
+            if (selected.isEmpty()) {
+                if (options.size() == 1) {
+                    item.setAnswer(resolveAnswerText(options.get(0)));
+                    normalized.add(item);
+                }
+                continue;
+            }
+
+            FsCourseQuestionBank question = questionMap.get(questionId);
+            boolean multiChoice = question != null && question.getType() != null && question.getType() == 2L;
+
+            if (multiChoice) {
+                List<String> names = selected.stream()
+                        .map(this::resolveAnswerText)
+                        .filter(StringUtils::isNotEmpty)
+                        .collect(Collectors.toList());
+                item.setAnswer(JSON.toJSONString(names));
+            } else {
+                item.setAnswer(resolveAnswerText(selected.get(0)));
+            }
+            normalized.add(item);
+        }
+        return normalized;
+    }
+
+    private String resolveAnswerText(LiveCompletionCouponAnswerItem item) {
+        if (item == null) {
+            return null;
+        }
+        if (StringUtils.isNotEmpty(item.getAnswerName())) {
+            return item.getAnswerName().trim();
+        }
+        return StringUtils.isEmpty(item.getAnswer()) ? null : item.getAnswer().trim();
+    }
+
+    /**
+     * 校验是否答完全部题目,并返回是否全部答对及每题明细(答错不阻断记录)
+     */
+    private AnswerEvaluation evaluateAnswersWithDetails(List<LiveCompletionCouponAnswerItem> userAnswers,
+                                                        List<Long> configuredQuestionIds) {
+        Set<Long> submittedIds = userAnswers.stream()
+                .map(LiveCompletionCouponAnswerItem::getQuestionId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+        if (submittedIds.size() != configuredQuestionIds.size()
+                || !submittedIds.containsAll(configuredQuestionIds)) {
+            throw new BaseException("请完成全部今日问题");
+        }
+
+        Map<Long, FsCourseQuestionBank> correctAnswersMap =
+                selectFsCourseQuestionBankByIds(new ArrayList<>(submittedIds))
+                .stream()
+                .collect(Collectors.toMap(FsCourseQuestionBank::getId, q -> q));
+
+        boolean allCorrect = true;
+        List<LiveCompletionAnswerDetailVO> details = new ArrayList<>();
+        for (LiveCompletionCouponAnswerItem userAnswer : userAnswers) {
+            FsCourseQuestionBank correctAnswer = correctAnswersMap.get(userAnswer.getQuestionId());
+            if (correctAnswer == null || correctAnswer.getStatus() == null || correctAnswer.getStatus() == 0) {
+                throw new BaseException("题目不存在或已停用");
+            }
+            boolean questionCorrect = isAnswerCorrect(userAnswer.getAnswer(), correctAnswer);
+            if (!questionCorrect) {
+                allCorrect = false;
+            }
+
+            LiveCompletionAnswerDetailVO detail = new LiveCompletionAnswerDetailVO();
+            detail.setQuestionId(userAnswer.getQuestionId());
+            detail.setTitle(correctAnswer.getTitle());
+            detail.setType(correctAnswer.getType());
+            detail.setUserAnswer(userAnswer.getAnswer());
+            detail.setCorrectAnswer(correctAnswer.getAnswer());
+            detail.setIsRight(questionCorrect ? 1 : 0);
+            details.add(detail);
+        }
+        return new AnswerEvaluation(allCorrect, details);
+    }
+
+    private boolean isAnswerCorrect(String userAnswerValue, FsCourseQuestionBank correctAnswer) {
+        if (correctAnswer.getType() == null || correctAnswer.getType() == 1L) {
+            return Objects.equals(normalizeSingleAnswer(userAnswerValue), normalizeSingleAnswer(correctAnswer.getAnswer()));
+        }
+        String[] userAnswers = parseAnswerArray(userAnswerValue);
+        String[] correctAnswers = parseAnswerArray(correctAnswer.getAnswer());
+        Arrays.sort(userAnswers);
+        Arrays.sort(correctAnswers);
+        return Arrays.equals(userAnswers, correctAnswers);
+    }
+
+    private String normalizeSingleAnswer(String answer) {
+        if (StringUtils.isEmpty(answer)) {
+            return answer;
+        }
+        String trimmed = answer.trim();
+        if (trimmed.startsWith("[")) {
+            List<String> list = JSON.parseArray(trimmed, String.class);
+            if (list != null && !list.isEmpty()) {
+                return list.get(0);
+            }
+        }
+        return trimmed;
+    }
+
+    private String[] parseAnswerArray(String answer) {
+        if (StringUtils.isEmpty(answer)) {
+            return new String[0];
+        }
+        String trimmed = answer.trim();
+        if (trimmed.startsWith("[")) {
+            List<String> list = JSON.parseArray(trimmed, String.class);
+            return list == null ? new String[0] : list.toArray(new String[0]);
+        }
+        return trimmed.split(",");
+    }
+
 }
 }

+ 66 - 0
fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseServiceImpl.java

@@ -1004,6 +1004,8 @@ public class FsUserCourseServiceImpl implements IFsUserCourseService
         return R.ok().put("data", item);
         return R.ok().put("data", item);
     }
     }
 
 
+
+
     public Long getFsUserVideoDuration(Long videoId){
     public Long getFsUserVideoDuration(Long videoId){
         //将视频时长也存到redis
         //将视频时长也存到redis
         String videoRedisKey = "h5wxuser:video:duration:" + videoId;
         String videoRedisKey = "h5wxuser:video:duration:" + videoId;
@@ -1290,4 +1292,68 @@ public class FsUserCourseServiceImpl implements IFsUserCourseService
         }
         }
         return result.toString();
         return result.toString();
     }
     }
+
+    @Override
+    public R publicEditCompany(FsUserCourseEditParam param) {
+
+        List<Long> courseIds = param.getCourseIds();
+
+        Set<Long> courseIdSet = new HashSet<>(courseIds);  // 去重并转换为Set
+        List<FsUserCourse> fsUserCourses = fsUserCourseMapper.selectAllCourseByIds(courseIdSet);
+
+        Long companyId = param.getCompanyId();
+        fsUserCourses.forEach(item -> {
+            Set<Long> companyIdSet = new LinkedHashSet<>();
+            // 先加入新的companyId
+            companyIdSet.add(companyId);
+            // 再合并已有的companyIds
+            if (!StringUtil.strIsNullOrEmpty(item.getCompanyIds()) && !"0".equals(item.getCompanyIds())) {
+                String[] split = item.getCompanyIds().split(",");
+                for (String s : split) {
+                    // 去除前后空格及特殊字符,只保留数字
+                    String clean = s.replaceAll("[^0-9]", "");
+                    if (StringUtils.isNotBlank(clean)) {
+                        companyIdSet.add(Long.valueOf(clean));
+                    }
+                }
+                item.setCompanyIds(companyIdSet.stream().map(String::valueOf).collect(Collectors.joining(",")));
+                fsUserCourseMapper.updateFsUserCourse(item);
+            }else {
+                item.setCompanyIds(String.valueOf(companyId));
+                fsUserCourseMapper.updateFsUserCourse(item);
+            }
+
+        });
+
+        return R.ok();
+    }
+
+    @Override
+    public R publicRemoveEditCompany(FsUserCourseEditParam param) {
+
+        List<Long> courseIds = param.getCourseIds();
+
+        Set<Long> courseIdSet = new HashSet<>(courseIds);  // 去重并转换为Set
+        List<FsUserCourse> fsUserCourses = fsUserCourseMapper.selectAllCourseByIds(courseIdSet);
+
+        Long companyId = param.getCompanyId();
+        fsUserCourses.forEach(item -> {
+            if (!StringUtil.strIsNullOrEmpty(item.getCompanyIds()) && !"0".equals(item.getCompanyIds())) {
+                Set<Long> companyIdSet = new LinkedHashSet<>();
+                String[] split = item.getCompanyIds().split(",");
+                for (String s : split) {
+                    String clean = s.replaceAll("[^0-9]", "");
+                    if (StringUtils.isNotBlank(clean)) {
+                        companyIdSet.add(Long.valueOf(clean));
+                    }
+                }
+                // 移除目标companyId
+                companyIdSet.remove(companyId);
+                item.setCompanyIds(companyIdSet.stream().map(String::valueOf).collect(Collectors.joining(",")));
+                fsUserCourseMapper.updateFsUserCourse(item);
+            }
+        });
+
+        return R.ok();
+    }
 }
 }

+ 23 - 0
fs-service/src/main/java/com/fs/enums/NodeExectorEnum.java

@@ -0,0 +1,23 @@
+package com.fs.enums;
+
+/**
+ * 工作流任务执行端:PC 定时扫描与 APP 外呼各自认领。
+ */
+public enum NodeExectorEnum {
+
+    /** 普通 PC 侧执行(表字段默认值) */
+    PC(0),
+
+    /** APP 外呼等由移动端执行的节点 */
+    APP(10);
+
+    private final int value;
+
+    NodeExectorEnum(int value) {
+        this.value = value;
+    }
+
+    public int getValue() {
+        return value;
+    }
+}

+ 13 - 2
fs-service/src/main/java/com/fs/his/mapper/FsCourseCouponUserMapper.java

@@ -98,6 +98,17 @@ public interface FsCourseCouponUserMapper extends BaseMapper<FsCourseCouponUser>
     @Select("SELECT cu.*,c.title couponName FROM `fs_course_coupon_user` cu LEFT JOIN fs_course_coupon c ON cu.coupon_id = c.id WHERE cu.log_id = #{param.logId}")
     @Select("SELECT cu.*,c.title couponName FROM `fs_course_coupon_user` cu LEFT JOIN fs_course_coupon c ON cu.coupon_id = c.id WHERE cu.log_id = #{param.logId}")
     List<FsCourseCouponUserRecordVO> selectCourseCouponUserRecordList(@Param("param")FsCourseCouponUser courseCouponUser);
     List<FsCourseCouponUserRecordVO> selectCourseCouponUserRecordList(@Param("param")FsCourseCouponUser courseCouponUser);
 
 
-    @Select("select * from fs_course_coupon_user where verify_user_id = #{companyUserId} and status = 1 ")
-    List<FsCourseCouponUserVO> getUseCouponByCompanyUserId(Long companyUserId);
+    @Select("SELECT\n" +
+            "  cu.*,\n" +
+            "  cc.title AS couponTitle,\n" +
+            "  fu.nick_name as nickName\n" +
+            "FROM\n" +
+            "  fs_course_coupon_user cu\n" +
+            "  LEFT JOIN fs_course_coupon cc ON cu.coupon_id = cc.id\n" +
+            "  LEFT JOIN fs_user fu on cu.user_id=fu.user_id\n" +
+            "WHERE\n" +
+            "  cu.verify_user_id = #{companyUserId}\n" +
+            "  AND cu.STATUS = 1\n" +
+            "  AND cu.send_type = 2 ")
+    List<FsCourseCouponUserVO> getUseCouponByCompanyUserId(@Param("companyUserId") Long companyUserId);
 }
 }

+ 6 - 0
fs-service/src/main/java/com/fs/his/mapper/FsUserMapper.java

@@ -511,4 +511,10 @@ public interface FsUserMapper
 
 
     @Update("update fs_user set password = #{password} where user_id = #{userId}")
     @Update("update fs_user set password = #{password} where user_id = #{userId}")
     void updatePasswordByUserId(FsUser user);
     void updatePasswordByUserId(FsUser user);
+
+    @Select("<script>" +
+            "SELECT user_id, source, login_device FROM fs_user WHERE user_id IN " +
+            "<foreach collection='userIds' item='id' open='(' separator=',' close=')'>#{id}</foreach>" +
+            "</script>")
+    List<FsUser> selectFsUserSourceByUserIds(@Param("userIds") List<Long> userIds);
 }
 }

+ 1 - 0
fs-service/src/main/java/com/fs/his/param/FsCourseCouponUserUParam.java

@@ -8,6 +8,7 @@ import lombok.Data;
 public class FsCourseCouponUserUParam {
 public class FsCourseCouponUserUParam {
 
 
     private Long userId;
     private Long userId;
+    private Long companyUserId;
 
 
     //状态 0-未核销 1-已核销
     //状态 0-未核销 1-已核销
     private Integer status;
     private Integer status;

+ 5 - 0
fs-service/src/main/java/com/fs/his/service/IFsUserService.java

@@ -249,4 +249,9 @@ public interface IFsUserService
     List<FsUserVO> selectStatisticsList(FsUser fsUser);
     List<FsUserVO> selectStatisticsList(FsUser fsUser);
 
 
     FsUser selectFsUserByAppleKey(String appleKey);
     FsUser selectFsUserByAppleKey(String appleKey);
+
+    /**
+     * 根据用户ID批量查询source和loginDevice
+     */
+    List<FsUser> selectFsUserSourceByUserIds(List<Long> userIds);
 }
 }

+ 1 - 0
fs-service/src/main/java/com/fs/his/service/impl/FsCourseCouponUserServiceImpl.java

@@ -186,6 +186,7 @@ public class FsCourseCouponUserServiceImpl extends ServiceImpl<FsCourseCouponUse
         }
         }
 
 
 
 
+
         if (fsCourseCoupon.getLimitTime() != null && fsCourseCoupon.getLimitTime().before(nowDate)) {
         if (fsCourseCoupon.getLimitTime() != null && fsCourseCoupon.getLimitTime().before(nowDate)) {
 
 
             couponUser.setStatus(2);
             couponUser.setStatus(2);

+ 8 - 0
fs-service/src/main/java/com/fs/his/service/impl/FsUserServiceImpl.java

@@ -1666,4 +1666,12 @@ public class FsUserServiceImpl implements IFsUserService {
     public FsUser selectFsUserByAppleKey(String appleKey) {
     public FsUser selectFsUserByAppleKey(String appleKey) {
         return fsUserMapper.findUserByAppleKey(appleKey);
         return fsUserMapper.findUserByAppleKey(appleKey);
     }
     }
+
+    @Override
+    public List<FsUser> selectFsUserSourceByUserIds(List<Long> userIds) {
+        if (userIds == null || userIds.isEmpty()) {
+            return java.util.Collections.emptyList();
+        }
+        return fsUserMapper.selectFsUserSourceByUserIds(userIds);
+    }
 }
 }

+ 47 - 0
fs-service/src/main/java/com/fs/live/domain/LiveCompletionAnswerRecord.java

@@ -0,0 +1,47 @@
+package com.fs.live.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fs.common.annotation.Excel;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 直播完课答题记录对象 live_completion_answer_record
+ */
+@Data
+@TableName("live_completion_answer_record")
+public class LiveCompletionAnswerRecord implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    @Excel(name = "直播ID")
+    private Long liveId;
+
+    @Excel(name = "用户ID")
+    private Long userId;
+
+    @Excel(name = "用户名称")
+    private String userName;
+
+    /** 是否全部正确 0否 1是 */
+    @Excel(name = "是否全部正确", readConverterExp = "0=否,1=是")
+    private Integer isRight;
+
+    /** 用户作答原始 JSON */
+    private String answerJson;
+
+    /** 题目及作答明细 JSON */
+    private String questionJson;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @Excel(name = "答题时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+}

+ 8 - 0
fs-service/src/main/java/com/fs/live/domain/LiveWatchConfig.java

@@ -54,6 +54,14 @@ public class LiveWatchConfig extends BaseEntity{
      * 是否红包余额抵扣 1是 0否
      * 是否红包余额抵扣 1是 0否
      */
      */
     private String isRedPackageBalanceDeduction;
     private String isRedPackageBalanceDeduction;
+    /**
+    * 题目id 集合
+    */
+    private String finishQuestionIds;
+    /**
+    * 可答题次数
+    */
+    private int answerAttempts;
 
 
     /** 红包金额 */
     /** 红包金额 */
     @Excel(name = "红包金额")
     @Excel(name = "红包金额")

+ 24 - 0
fs-service/src/main/java/com/fs/live/mapper/LiveCompletionAnswerRecordMapper.java

@@ -0,0 +1,24 @@
+package com.fs.live.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.live.domain.LiveCompletionAnswerRecord;
+import com.fs.live.param.LiveCompletionAnswerRecordParam;
+import com.fs.live.vo.LiveCompletionAnswerRecordListVO;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 直播完课答题记录Mapper
+ */
+@Mapper
+public interface LiveCompletionAnswerRecordMapper extends BaseMapper<LiveCompletionAnswerRecord> {
+
+    List<LiveCompletionAnswerRecordListVO> selectLiveCompletionAnswerRecordList(LiveCompletionAnswerRecordParam param);
+
+    LiveCompletionAnswerRecordListVO selectLiveCompletionAnswerRecordById(Long id);
+
+    LiveCompletionAnswerRecordListVO selectLiveCompletionAnswerRecordByLiveUserId(@Param("liveId")  Long liveId,@Param("userId") Long userId);
+    int countLiveCompletionAnswerRecordByLiveUserId(@Param("liveId")  Long liveId,@Param("userId") Long userId);
+}

+ 27 - 0
fs-service/src/main/java/com/fs/live/param/LiveCompletionAnswerRecordParam.java

@@ -0,0 +1,27 @@
+package com.fs.live.param;
+
+import lombok.Data;
+
+/**
+ * 直播完课答题记录查询参数
+ */
+@Data
+public class LiveCompletionAnswerRecordParam {
+
+    private Integer pageNum;
+
+    private Integer pageSize;
+
+    private Long liveId;
+
+    private Long userId;
+
+    private String userName;
+
+    /** 是否全部正确 0否 1是 */
+    private Integer isRight;
+
+    private String beginTime;
+
+    private String endTime;
+}

+ 29 - 0
fs-service/src/main/java/com/fs/live/param/LiveCompletionCouponAnswerItem.java

@@ -0,0 +1,29 @@
+package com.fs.live.param;
+
+import lombok.Data;
+
+/**
+ * 完课优惠券单题作答
+ */
+@Data
+public class LiveCompletionCouponAnswerItem {
+
+    /** 题目 ID */
+    private Long questionId;
+
+    /**
+     * 用户答案
+     * 单选:选项文案,如「一斤半」
+     * 多选:JSON 数组字符串,如 ["A","B"]
+     */
+    private String answer;
+
+    /** 选项下标(App 扁平提交时使用) */
+    private Integer answerIndex;
+
+    /** 选项文案(App 扁平提交时使用,优先于 answer) */
+    private String answerName;
+
+    /** 是否为用户选中项:1 是 0 否(App 扁平提交时使用) */
+    private Integer isAnswer;
+}

+ 17 - 0
fs-service/src/main/java/com/fs/live/param/LiveCompletionCouponAnswerParam.java

@@ -0,0 +1,17 @@
+package com.fs.live.param;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 直播完课优惠券答题参数
+ */
+@Data
+public class LiveCompletionCouponAnswerParam {
+
+    private Long liveId;
+
+    /** 用户作答列表 */
+    private List<LiveCompletionCouponAnswerItem> answers;
+}

+ 1 - 0
fs-service/src/main/java/com/fs/live/param/LiveRedPacketParam.java

@@ -7,6 +7,7 @@ import javax.validation.constraints.NotNull;
 
 
 @Data
 @Data
 public class LiveRedPacketParam {
 public class LiveRedPacketParam {
+
     private Long userId;
     private Long userId;
 
 
     @NotNull(message = "直播间Id不能为空")
     @NotNull(message = "直播间Id不能为空")

+ 44 - 0
fs-service/src/main/java/com/fs/live/service/ILiveCompletionAnswerRecordService.java

@@ -0,0 +1,44 @@
+package com.fs.live.service;
+
+
+import com.fs.live.param.LiveCompletionAnswerRecordParam;
+import com.fs.live.param.LiveCompletionCouponAnswerItem;
+import com.fs.live.vo.LiveCompletionAnswerDetailVO;
+import com.fs.live.vo.LiveCompletionAnswerRecordListVO;
+
+import java.util.List;
+
+/**
+ * 直播完课答题记录Service
+ */
+public interface ILiveCompletionAnswerRecordService {
+
+    /**
+     * 查询完课答题记录列表
+     */
+    List<LiveCompletionAnswerRecordListVO> selectLiveCompletionAnswerRecordList(LiveCompletionAnswerRecordParam param);
+
+    /**
+     * 查询完课答题记录详情
+     */
+    LiveCompletionAnswerRecordListVO selectLiveCompletionAnswerRecordById(Long id);
+
+    /**
+    * 获取答题记录里 答对的 记录 有没有
+    */
+    LiveCompletionAnswerRecordListVO selectLiveCompletionAnswerRecordByLiveUserId(Long liveId,Long userId);
+
+    /**
+     * 获取答题记录次数
+     */
+    int countLiveCompletionAnswerRecordByLiveUserId(Long liveId,Long  userId);
+
+    /**
+     * 保存完课答题记录(/app/live/completion/coupon/answer 提交时调用)
+     *
+     * @return 记录主键ID
+     */
+    Long saveAnswerRecord(Long liveId, Long userId, boolean allCorrect,
+                          List<LiveCompletionCouponAnswerItem> answers,
+                          List<LiveCompletionAnswerDetailVO> questionDetails);
+}

+ 3 - 0
fs-service/src/main/java/com/fs/live/service/ILiveService.java

@@ -6,6 +6,7 @@ import com.fs.company.domain.CompanyUser;
 import com.fs.company.vo.CompanyVO;
 import com.fs.company.vo.CompanyVO;
 import com.fs.live.param.FsLiveListParam;
 import com.fs.live.param.FsLiveListParam;
 import com.fs.live.param.LiveNotifyParam;
 import com.fs.live.param.LiveNotifyParam;
+import com.fs.live.param.LiveRedPacketParam;
 import com.fs.live.vo.LiveVo;
 import com.fs.live.vo.LiveVo;
 import com.fs.common.core.domain.R;
 import com.fs.common.core.domain.R;
 import com.fs.live.domain.Live;
 import com.fs.live.domain.Live;
@@ -244,4 +245,6 @@ public interface ILiveService
      * 训练营直播间审核列表(含营期/训练营/企业名称)
      * 训练营直播间审核列表(含营期/训练营/企业名称)
      */
      */
     List<TrainingLiveAuditVO> selectTrainingLiveAuditList(Live query);
     List<TrainingLiveAuditVO> selectTrainingLiveAuditList(Live query);
+
+    R getQuestions(LiveRedPacketParam param);
 }
 }

+ 82 - 0
fs-service/src/main/java/com/fs/live/service/impl/LiveCompletionAnswerRecordServiceImpl.java

@@ -0,0 +1,82 @@
+package com.fs.live.service.impl;
+
+
+import com.alibaba.fastjson.JSON;
+import com.fs.common.utils.DateUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.his.domain.FsUser;
+import com.fs.his.mapper.FsUserMapper;
+import com.fs.live.domain.LiveCompletionAnswerRecord;
+import com.fs.live.mapper.LiveCompletionAnswerRecordMapper;
+import com.fs.live.param.LiveCompletionAnswerRecordParam;
+import com.fs.live.param.LiveCompletionCouponAnswerItem;
+import com.fs.live.service.ILiveCompletionAnswerRecordService;
+import com.fs.live.vo.LiveCompletionAnswerDetailVO;
+import com.fs.live.vo.LiveCompletionAnswerRecordListVO;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+/**
+ * 直播完课答题记录Service业务层
+ */
+@Service
+public class LiveCompletionAnswerRecordServiceImpl implements ILiveCompletionAnswerRecordService {
+
+    @Autowired
+    private LiveCompletionAnswerRecordMapper liveCompletionAnswerRecordMapper;
+
+    @Autowired
+    private FsUserMapper fsUserMapper;
+
+    @Override
+    public List<LiveCompletionAnswerRecordListVO> selectLiveCompletionAnswerRecordList(LiveCompletionAnswerRecordParam param) {
+        return liveCompletionAnswerRecordMapper.selectLiveCompletionAnswerRecordList(param);
+    }
+
+    @Override
+    public LiveCompletionAnswerRecordListVO selectLiveCompletionAnswerRecordById(Long id) {
+        return liveCompletionAnswerRecordMapper.selectLiveCompletionAnswerRecordById(id);
+    }
+
+    @Override
+    public LiveCompletionAnswerRecordListVO selectLiveCompletionAnswerRecordByLiveUserId(Long liveId, Long userId) {
+        return liveCompletionAnswerRecordMapper.selectLiveCompletionAnswerRecordByLiveUserId(liveId,userId);
+    }
+
+    @Override
+    public int countLiveCompletionAnswerRecordByLiveUserId(Long liveId, Long userId) {
+        return liveCompletionAnswerRecordMapper.countLiveCompletionAnswerRecordByLiveUserId(liveId,userId);
+    }
+
+    @Override
+    public Long saveAnswerRecord(Long liveId, Long userId, boolean allCorrect,
+                                 List<LiveCompletionCouponAnswerItem> answers,
+                                 List<LiveCompletionAnswerDetailVO> questionDetails) {
+        LiveCompletionAnswerRecord record = new LiveCompletionAnswerRecord();
+        record.setLiveId(liveId);
+        record.setUserId(userId);
+        record.setUserName(resolveUserName(userId));
+        record.setIsRight(allCorrect ? 1 : 0);
+        record.setAnswerJson(answers == null ? null : JSON.toJSONString(answers));
+        record.setQuestionJson(questionDetails == null ? null : JSON.toJSONString(questionDetails));
+        record.setCreateTime(DateUtils.getNowDate());
+        liveCompletionAnswerRecordMapper.insert(record);
+        return record.getId();
+    }
+
+    private String resolveUserName(Long userId) {
+        if (userId == null) {
+            return null;
+        }
+        FsUser user = fsUserMapper.selectFsUserByUserId(userId);
+        if (user == null) {
+            return null;
+        }
+        if (StringUtils.isNotEmpty(user.getNickName())) {
+            return user.getNickName();
+        }
+        return user.getPhone();
+    }
+}

+ 80 - 27
fs-service/src/main/java/com/fs/live/service/impl/LiveRedPacketLogServiceImpl.java

@@ -4,6 +4,7 @@ import java.math.BigDecimal;
 import java.text.SimpleDateFormat;
 import java.text.SimpleDateFormat;
 import java.util.*;
 import java.util.*;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
 
 
 import cn.hutool.json.JSONUtil;
 import cn.hutool.json.JSONUtil;
 import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSON;
@@ -39,6 +40,7 @@ import com.fs.live.param.CouponPO;
 import com.fs.live.param.LiveRedPacketParam;
 import com.fs.live.param.LiveRedPacketParam;
 import com.fs.live.mapper.LiveMapper;
 import com.fs.live.mapper.LiveMapper;
 import com.fs.live.service.*;
 import com.fs.live.service.*;
+import com.fs.live.vo.LiveCompletionAnswerRecordListVO;
 import com.fs.live.vo.RecordTimeRangeVO;
 import com.fs.live.vo.RecordTimeRangeVO;
 import com.fs.voice.utils.StringUtil;
 import com.fs.voice.utils.StringUtil;
 import com.github.binarywang.wxpay.bean.transfer.TransferBillsResult;
 import com.github.binarywang.wxpay.bean.transfer.TransferBillsResult;
@@ -127,6 +129,9 @@ public class LiveRedPacketLogServiceImpl extends ServiceImpl<LiveRedPacketLogMap
     @Autowired
     @Autowired
     private FsCourseCouponUserMapper courseCouponUserMapper;
     private FsCourseCouponUserMapper courseCouponUserMapper;
 
 
+    @Autowired
+    private ILiveCompletionAnswerRecordService liveCompletionAnswerRecordService;
+
 
 
     /**
     /**
      * 查询直播红包 记录
      * 查询直播红包 记录
@@ -353,6 +358,16 @@ public class LiveRedPacketLogServiceImpl extends ServiceImpl<LiveRedPacketLogMap
 
 
                     }
                     }
 
 
+                    //没设置题目直接发  设置了题目 判断答得对不  对也发 不对不发
+                    if (!StringUtil.strIsNullOrEmpty(config.getFinishQuestionIds())){
+                        LiveCompletionAnswerRecordListVO answerRecordListVO = liveCompletionAnswerRecordService.selectLiveCompletionAnswerRecordByLiveUserId(param.getLiveId(), param.getUserId());
+                        //没有答对的 直接红包OK 答对了走下面的 红包
+                        if (answerRecordListVO==null){
+                            return R.ok();
+                        }
+                    }
+
+
                     WxSendRedPacketParam packetParam = new WxSendRedPacketParam();
                     WxSendRedPacketParam packetParam = new WxSendRedPacketParam();
 
 
                     if (StringUtil.strIsNullOrEmpty(user.getAppOpenId())){
                     if (StringUtil.strIsNullOrEmpty(user.getAppOpenId())){
@@ -451,9 +466,12 @@ public class LiveRedPacketLogServiceImpl extends ServiceImpl<LiveRedPacketLogMap
             if (parts.length > 4 && !parts[4].isEmpty()) {
             if (parts.length > 4 && !parts[4].isEmpty()) {
                 range.setScoreAmount(Long.valueOf(parts[4]));
                 range.setScoreAmount(Long.valueOf(parts[4]));
             }
             }
-            // 核销卷ID
+            // 核销卷ID列表(~分隔)
             if (parts.length > 5 && !parts[5].isEmpty()) {
             if (parts.length > 5 && !parts[5].isEmpty()) {
-                range.setCouponId(Long.valueOf(parts[5]));
+                range.setCouponIds(Arrays.stream(parts[5].split("~"))
+                        .filter(s -> !s.isEmpty())
+                        .map(Long::valueOf)
+                        .collect(Collectors.toList()));
             }
             }
 
 
             result.add(range);
             result.add(range);
@@ -473,18 +491,45 @@ public class LiveRedPacketLogServiceImpl extends ServiceImpl<LiveRedPacketLogMap
 
 
         // 红包
         // 红包
         if (matched.getRewardTypes() != null && matched.getRewardTypes().contains("1")) {
         if (matched.getRewardTypes() != null && matched.getRewardTypes().contains("1")) {
-            WxSendRedPacketParam packetParam = new WxSendRedPacketParam();
-            if (StringUtil.strIsNullOrEmpty(user.getAppOpenId())) {
-                return R.error("请重新登录app");
+
+
+            //没设置题目直接发  设置了题目 判断答得对不  对也发 不对不发
+            if (!StringUtil.strIsNullOrEmpty(config.getFinishQuestionIds())){
+                LiveCompletionAnswerRecordListVO answerRecordListVO = liveCompletionAnswerRecordService.selectLiveCompletionAnswerRecordByLiveUserId(param.getLiveId(), param.getUserId());
+                //没有答对的 直接红包OK 答对了走下面的 红包
+                if (answerRecordListVO==null){
+                    hasReward = true;
+                    combinedRewardType |= 1;
+                }else {
+                    WxSendRedPacketParam packetParam = new WxSendRedPacketParam();
+                    if (StringUtil.strIsNullOrEmpty(user.getAppOpenId())) {
+                        return R.error("请重新登录app");
+                    }
+                    String openId = user.getAppOpenId();
+                    packetParam.setOpenId(openId);
+                    BeanUtils.copyProperties(param, packetParam);
+                    config.setRedPacketAmount(matched.getRedPacketAmount());
+                    lastResult = sendAppLiveRedPacketAuto(packetParam, watchUser, config, param);
+
+                    hasReward = true;
+                    combinedRewardType |= 1;
+                }
+
+            }else {
+                WxSendRedPacketParam packetParam = new WxSendRedPacketParam();
+                if (StringUtil.strIsNullOrEmpty(user.getAppOpenId())) {
+                    return R.error("请重新登录app");
+                }
+                String openId = user.getAppOpenId();
+                packetParam.setOpenId(openId);
+                BeanUtils.copyProperties(param, packetParam);
+                config.setRedPacketAmount(matched.getRedPacketAmount());
+                lastResult = sendAppLiveRedPacketAuto(packetParam, watchUser, config, param);
+
+                hasReward = true;
+                combinedRewardType |= 1;
             }
             }
-            String openId = user.getAppOpenId();
-            packetParam.setOpenId(openId);
-            BeanUtils.copyProperties(param, packetParam);
-            config.setRedPacketAmount(matched.getRedPacketAmount());
-            lastResult = sendAppLiveRedPacketAuto(packetParam, watchUser, config, param);
-//            lastResult = R.ok();
-            hasReward = true;
-            combinedRewardType |= 1;
+
         }
         }
         // 积分
         // 积分
         if (matched.getRewardTypes() != null && matched.getRewardTypes().contains("2")) {
         if (matched.getRewardTypes() != null && matched.getRewardTypes().contains("2")) {
@@ -495,7 +540,23 @@ public class LiveRedPacketLogServiceImpl extends ServiceImpl<LiveRedPacketLogMap
         }
         }
         // 课程优惠卷(核销卷)
         // 课程优惠卷(核销卷)
         if (matched.getRewardTypes() != null && matched.getRewardTypes().contains("3")) {
         if (matched.getRewardTypes() != null && matched.getRewardTypes().contains("3")) {
-            sendIssueCourseCoupon(param.getLiveId(),user.getUserId(),matched.getCouponId(),watchUser);
+            if (matched.getCouponIds() != null && !matched.getCouponIds().isEmpty()) {
+                List<String> failMsgs = new ArrayList<>();
+                for (Long couponId : matched.getCouponIds()) {
+                    try {
+                        R couponResult = sendIssueCourseCoupon(param.getLiveId(), user.getUserId(), couponId);
+                        if (couponResult.get("msg") != null) {
+                            failMsgs.add("核销卷" + couponId + ":" + couponResult.get("msg"));
+                        }
+                    } catch (Exception e) {
+                        logger.error("发放核销卷异常,couponId:{}", couponId, e);
+                        failMsgs.add("核销卷" + couponId + ":发放异常");
+                    }
+                }
+                if (!failMsgs.isEmpty()) {
+                    watchUser.setRemark(String.join("; ", failMsgs));
+                }
+            }
             hasReward = true;
             hasReward = true;
             combinedRewardType |= 4;
             combinedRewardType |= 4;
         }
         }
@@ -611,35 +672,27 @@ public class LiveRedPacketLogServiceImpl extends ServiceImpl<LiveRedPacketLogMap
     /**
     /**
      * 发放直播-课程优惠卷卷
      * 发放直播-课程优惠卷卷
      */
      */
-    public R sendIssueCourseCoupon(Long liveId, Long userId, Long couponId,LiveWatchUser watchUser) {
+    public R sendIssueCourseCoupon(Long liveId, Long userId, Long couponId) {
 
 
         //优惠券
         //优惠券
         FsCourseCoupon fsCourseCoupon = fsCourseCouponMapper.selectFsCourseCouponById(couponId);
         FsCourseCoupon fsCourseCoupon = fsCourseCouponMapper.selectFsCourseCouponById(couponId);
 
 
         if (fsCourseCoupon == null) {
         if (fsCourseCoupon == null) {
-            watchUser.setRemark("核销卷不存在-发放失败");
-            watchUserMapper.updateLiveWatchUser(watchUser);
-            return  R.ok();
+            return R.ok("不存在");
         }
         }
 
 
         if (fsCourseCoupon.getStatus() != 1) {
         if (fsCourseCoupon.getStatus() != 1) {
-            watchUser.setRemark("核销卷已停用-发放失败");
-            watchUserMapper.updateLiveWatchUser(watchUser);
-            return  R.ok();
+            return R.ok("已停用");
         }
         }
 
 
         if (fsCourseCoupon.getRemainNumber() <= 0) {
         if (fsCourseCoupon.getRemainNumber() <= 0) {
-            watchUser.setRemark("核销卷剩余数量不足-发放失败");
-            watchUserMapper.updateLiveWatchUser(watchUser);
-            return  R.ok();
+            return R.ok("剩余数量不足");
         }
         }
 
 
         //用户领取优惠券总数
         //用户领取优惠券总数
         Integer count = courseCouponUserMapper.selectCountByUserIdAndCouponId(userId, couponId);
         Integer count = courseCouponUserMapper.selectCountByUserIdAndCouponId(userId, couponId);
         if (count >= fsCourseCoupon.getLimitCount()) {
         if (count >= fsCourseCoupon.getLimitCount()) {
-            watchUser.setRemark("该核销卷领取达到上限,发放失败");
-            watchUserMapper.updateLiveWatchUser(watchUser);
-            return  R.ok();
+            return R.ok("领取达到上限");
         }
         }
 
 
         //发放优惠券
         //发放优惠券

+ 58 - 4
fs-service/src/main/java/com/fs/live/service/impl/LiveServiceImpl.java

@@ -24,13 +24,16 @@ import com.fs.company.vo.CompanyVO;
 import com.fs.core.config.WxMaConfiguration;
 import com.fs.core.config.WxMaConfiguration;
 import com.fs.course.config.CourseConfig;
 import com.fs.course.config.CourseConfig;
 import com.fs.course.domain.FsCourseLink;
 import com.fs.course.domain.FsCourseLink;
+import com.fs.course.domain.FsCourseQuestionBank;
 import com.fs.course.domain.FsCourseRealLink;
 import com.fs.course.domain.FsCourseRealLink;
 import com.fs.course.mapper.FsCourseLinkMapper;
 import com.fs.course.mapper.FsCourseLinkMapper;
+import com.fs.course.service.IFsCourseQuestionBankService;
 import com.fs.his.domain.FsStoreProduct;
 import com.fs.his.domain.FsStoreProduct;
 import com.fs.his.domain.FsUser;
 import com.fs.his.domain.FsUser;
 import com.fs.his.domain.FsUserWx;
 import com.fs.his.domain.FsUserWx;
 import com.fs.his.mapper.FsUserMapper;
 import com.fs.his.mapper.FsUserMapper;
 import com.fs.his.mapper.FsUserWxMapper;
 import com.fs.his.mapper.FsUserWxMapper;
+import com.fs.his.param.WxSendRedPacketParam;
 import com.fs.his.service.IFsUserService;
 import com.fs.his.service.IFsUserService;
 import com.fs.his.utils.LinkUtil;
 import com.fs.his.utils.LinkUtil;
 import com.fs.his.utils.PhoneUtil;
 import com.fs.his.utils.PhoneUtil;
@@ -40,6 +43,7 @@ import com.fs.live.dto.TemplateMessageSendRequestDTO;
 import com.fs.live.enums.MiniAppNotifyTaskStatusEnum;
 import com.fs.live.enums.MiniAppNotifyTaskStatusEnum;
 import com.fs.live.param.FsLiveListParam;
 import com.fs.live.param.FsLiveListParam;
 import com.fs.live.param.LiveNotifyParam;
 import com.fs.live.param.LiveNotifyParam;
+import com.fs.live.param.LiveRedPacketParam;
 import com.fs.live.vo.LiveVo;
 import com.fs.live.vo.LiveVo;
 import com.fs.common.constant.LiveKeysConstant;
 import com.fs.common.constant.LiveKeysConstant;
 import com.fs.common.core.domain.R;
 import com.fs.common.core.domain.R;
@@ -142,14 +146,11 @@ public class LiveServiceImpl implements ILiveService
     private LiveMiniprogramSubNotifyTaskMapper liveMiniprogramSubNotifyTaskMapper;
     private LiveMiniprogramSubNotifyTaskMapper liveMiniprogramSubNotifyTaskMapper;
     @Autowired
     @Autowired
     private LiveCouponIssueMapper liveCouponIssueMapper;
     private LiveCouponIssueMapper liveCouponIssueMapper;
-    @Autowired
-    private FsUserMapper fsUserMapper;
+
     @Autowired
     @Autowired
     private FsUserWxMapper fsUserWxMapper;
     private FsUserWxMapper fsUserWxMapper;
     @Autowired
     @Autowired
     private CompanyMapper companyMapper;
     private CompanyMapper companyMapper;
-    @Autowired
-    private LiveCouponMapper liveCouponMapper;
 
 
     @Autowired
     @Autowired
     LiveTagConfigMapper liveTagConfigMapper;
     LiveTagConfigMapper liveTagConfigMapper;
@@ -181,6 +182,10 @@ public class LiveServiceImpl implements ILiveService
     @Autowired
     @Autowired
     private FsCourseLinkMapper fsCourseLinkMapper;
     private FsCourseLinkMapper fsCourseLinkMapper;
 
 
+    @Autowired
+    private IFsCourseQuestionBankService fsCourseQuestionBankService;
+
+
     @Override
     @Override
     public Live selectLiveDbByLiveId(Long liveId) {
     public Live selectLiveDbByLiveId(Long liveId) {
         // 缓存中没有,从数据库查询
         // 缓存中没有,从数据库查询
@@ -654,6 +659,55 @@ public class LiveServiceImpl implements ILiveService
         return baseMapper.selectTrainingLiveAuditList(query);
         return baseMapper.selectTrainingLiveAuditList(query);
     }
     }
 
 
+    @Override
+    public R getQuestions(LiveRedPacketParam param) {
+
+        R liveStatusByUserID = liveWatchUserService.getLiveStatusByUserID(param.getLiveId(), param.getUserId());
+        // 直播 还是录播
+        String rewardType = liveStatusByUserID.get("liveStatus").toString();
+
+        Live live = this.selectLiveByLiveId(param.getLiveId());
+        LiveWatchConfig config = JSON.parseObject(live.getConfigJson(), LiveWatchConfig.class);
+        // 先查是否开启了启动 直播完课奖励
+        if (config.getEnabled() && 3 == config.getParticipateCondition()) {
+
+            //判断直播数据 是否满足 完课/如果有录播数据 再判断录播是否满足完课
+            Long duration = live.getDuration();
+            Long completionRate = config.getCompletionRate();
+
+            if (duration == null || duration <= 0 || completionRate == null || completionRate <= 0) {
+                return R.error("直播完课配置异常");
+            }
+            if (StringUtil.strIsNullOrEmpty(config.getFinishQuestionIds())){
+                return R.ok().put("questions",null);
+            }
+            List<Long> ids = Arrays.stream(config.getFinishQuestionIds().split(",")).map(Long::valueOf).collect(Collectors.toList());
+            List<FsCourseQuestionBank> questions = fsCourseQuestionBankService.selectFsCourseQuestionBankByIds(ids);
+
+            //如果是直播 直接返回题目 和答案
+            if ("直播".equals(rewardType)){
+
+                return R.ok().put("questions",questions);
+
+            }
+            // 是录播,那么判断时间范围是否有 设置 红包
+            else if ("录播".equals(rewardType)){
+
+                List<RecordTimeRangeVO> ranges = LiveRedPacketLogServiceImpl.parseRecordRewards(config.getRecordTimeRangeStr());
+                RecordTimeRangeVO matched = LiveRedPacketLogServiceImpl.matchCurrentTime(ranges);
+                if (matched != null && matched.getRewardTypes() != null && matched.getRewardTypes().contains("1")) {
+                    return R.ok().put("questions",questions);
+                }else {
+                    return R.ok().put("questions",null);
+                }
+            }else {
+                return R.ok().put("questions",null);
+            }
+        }else {
+            return R.error("当前直播 并未启动 直播完课奖励 ");
+        }
+    }
+
     /**
     /**
      * 查询直播列表
      * 查询直播列表
      *
      *

+ 11 - 4
fs-service/src/main/java/com/fs/live/service/impl/LiveWatchUserServiceImpl.java

@@ -1378,11 +1378,13 @@ public class LiveWatchUserServiceImpl implements ILiveWatchUserService {
 
 
             return R.ok().put("watchUser",watchUser)
             return R.ok().put("watchUser",watchUser)
                     .put("rewardType",replayUserVO.getRewardType())
                     .put("rewardType",replayUserVO.getRewardType())
-                    .put("replayUser",replayUserVO);
+                    .put("replayUser",replayUserVO)
+                    .put("liveStatus","录播");
         }
         }
         //直播不空 那要找一下录播
         //直播不空 那要找一下录播
         else {
         else {
             Integer rewardType=null;
             Integer rewardType=null;
+            String liveStatus;
             // 直播没有记录 查录播
             // 直播没有记录 查录播
             replayUser = baseMapper.getWatchLiveByReplayFlag(userId, liveId);
             replayUser = baseMapper.getWatchLiveByReplayFlag(userId, liveId);
 
 
@@ -1393,20 +1395,25 @@ public class LiveWatchUserServiceImpl implements ILiveWatchUserService {
             LiveWatchUserStatusVO watchUserVO=new LiveWatchUserStatusVO();
             LiveWatchUserStatusVO watchUserVO=new LiveWatchUserStatusVO();
             BeanUtils.copyProperties(watchUser, watchUserVO);
             BeanUtils.copyProperties(watchUser, watchUserVO);
 
 
-            //录播不空
+            //录播不空,标记为录播
             if (replayUser!=null){
             if (replayUser!=null){
+                liveStatus="录播";
 
 
                 LiveWatchUserStatusVO replayUserVO=new LiveWatchUserStatusVO();
                 LiveWatchUserStatusVO replayUserVO=new LiveWatchUserStatusVO();
                 BeanUtils.copyProperties(replayUser, replayUserVO);
                 BeanUtils.copyProperties(replayUser, replayUserVO);
 
 
                 return R.ok().put("watchUser",watchUserVO)
                 return R.ok().put("watchUser",watchUserVO)
                         .put("rewardType",rewardType)
                         .put("rewardType",rewardType)
-                        .put("replayUser",replayUserVO);
+                        .put("replayUser",replayUserVO)
+                        .put("liveStatus",liveStatus);
             }
             }
 
 
+            // replayUser为空,标记为直播
+            liveStatus="直播";
             return R.ok().put("watchUser",watchUserVO)
             return R.ok().put("watchUser",watchUserVO)
                     .put("rewardType",rewardType)
                     .put("rewardType",rewardType)
-                    .put("replayUser",replayUser);
+                    .put("replayUser",replayUser)
+                    .put("liveStatus",liveStatus);
         }
         }
 
 
 
 

+ 24 - 0
fs-service/src/main/java/com/fs/live/vo/AnswerEvaluation.java

@@ -0,0 +1,24 @@
+package com.fs.live.vo;
+
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class AnswerEvaluation {
+    private final boolean allCorrect;
+    private final List<LiveCompletionAnswerDetailVO> details;
+
+    public AnswerEvaluation(boolean allCorrect, List<LiveCompletionAnswerDetailVO> details) {
+        this.allCorrect = allCorrect;
+        this.details = details;
+    }
+
+    public boolean isAllCorrect() {
+        return allCorrect;
+    }
+
+    public List<LiveCompletionAnswerDetailVO> getDetails() {
+        return details;
+    }
+}

+ 24 - 0
fs-service/src/main/java/com/fs/live/vo/LiveCompletionAnswerDetailVO.java

@@ -0,0 +1,24 @@
+package com.fs.live.vo;
+
+import lombok.Data;
+
+/**
+ * 完课答题单题明细(落库快照)
+ */
+@Data
+public class LiveCompletionAnswerDetailVO {
+
+    private Long questionId;
+
+    private String title;
+
+    /** 题型 1单选 2多选 */
+    private Long type;
+
+    private String userAnswer;
+
+    private String correctAnswer;
+
+    /** 本题是否答对 0否 1是 */
+    private Integer isRight;
+}

+ 39 - 0
fs-service/src/main/java/com/fs/live/vo/LiveCompletionAnswerRecordListVO.java

@@ -0,0 +1,39 @@
+package com.fs.live.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fs.common.annotation.Excel;
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 直播完课答题记录列表 VO
+ */
+@Data
+public class LiveCompletionAnswerRecordListVO {
+
+    private Long id;
+
+    @Excel(name = "直播ID")
+    private Long liveId;
+
+    @Excel(name = "直播名称")
+    private String liveName;
+
+    @Excel(name = "用户ID")
+    private Long userId;
+
+    @Excel(name = "用户名称")
+    private String userName;
+
+    @Excel(name = "是否全部正确", readConverterExp = "0=否,1=是")
+    private Integer isRight;
+
+    private String answerJson;
+
+    private String questionJson;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @Excel(name = "答题时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+}

+ 27 - 0
fs-service/src/main/java/com/fs/live/vo/LiveCompletionCouponAnswerResult.java

@@ -0,0 +1,27 @@
+package com.fs.live.vo;
+
+import liquibase.pro.packaged.I;
+import lombok.Data;
+
+/**
+ * 完课优惠券答题结果
+ */
+@Data
+public class LiveCompletionCouponAnswerResult {
+
+    /**
+    *  是否答题次数结束
+    */
+    private boolean answerNumOver=false;
+
+    /** 是否全部答对 */
+    private boolean allCorrect;
+
+    /** 答题记录ID(写入 live_completion_answer_record) */
+    private Long recordId;
+
+    /**
+    * 剩余答题机会
+    */
+    private Integer correctNum;
+}

+ 2 - 2
fs-service/src/main/java/com/fs/live/vo/RecordTimeRangeVO.java

@@ -22,8 +22,8 @@ public class RecordTimeRangeVO {
     private Long scoreAmount;
     private Long scoreAmount;
 
 
     /**
     /**
-    * 核销卷ID
+    * 核销卷ID列表
     */
     */
-    private Long couponId;
+    private List<Long> couponIds;
 
 
 }
 }

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

@@ -1,6 +1,8 @@
 package com.fs.qw.mapper;
 package com.fs.qw.mapper;
 
 
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.common.annotation.DataSource;
+import com.fs.common.enums.DataSourceType;
 import com.fs.fastGpt.domain.FastgptChatArtificialWords;
 import com.fs.fastGpt.domain.FastgptChatArtificialWords;
 import com.fs.hisStore.vo.FsStoreOrderScrmSidebarVO;
 import com.fs.hisStore.vo.FsStoreOrderScrmSidebarVO;
 import com.fs.qw.domain.QwExternalContact;
 import com.fs.qw.domain.QwExternalContact;
@@ -270,7 +272,7 @@ public interface QwExternalContactMapper extends BaseMapper<QwExternalContact> {
             "            <if test=\"remarkCorpName != null  and remarkCorpName != ''\"> and ec.remark_corp_name like concat('%', #{remarkCorpName}, '%')</if>\n" +
             "            <if test=\"remarkCorpName != null  and remarkCorpName != ''\"> and ec.remark_corp_name like concat('%', #{remarkCorpName}, '%')</if>\n" +
             "            <if test=\"addWay != null \"> and ec.add_way = #{addWay}</if>\n" +
             "            <if test=\"addWay != null \"> and ec.add_way = #{addWay}</if>\n" +
             "            <if test=\"operUserid != null  and operUserid != ''\"> and ec.oper_userid = #{operUserid}</if>\n" +
             "            <if test=\"operUserid != null  and operUserid != ''\"> and ec.oper_userid = #{operUserid}</if>\n" +
-            "            <if test=\"corpId != null  and corpId != ''\"> and ec.corp_id = #{corpId}</if>\n" +
+            "            <if test=\"corpId != null  and corpId != '' and exportType==1 \"> and ec.corp_id = #{corpId}</if>\n" +
             "            <if test=\"companyId != null \"> and qu.company_id = #{companyId}</if>\n" +
             "            <if test=\"companyId != null \"> and qu.company_id = #{companyId}</if>\n" +
             "            <if test=\"companyUserId != null \"> and ec.company_user_id = #{companyUserId}</if>\n" +
             "            <if test=\"companyUserId != null \"> and ec.company_user_id = #{companyUserId}</if>\n" +
             "            <if test=\"customerId != null \"> and ec.customer_id = #{customerId}</if>\n" +
             "            <if test=\"customerId != null \"> and ec.customer_id = #{customerId}</if>\n" +
@@ -671,4 +673,15 @@ public interface QwExternalContactMapper extends BaseMapper<QwExternalContact> {
      * 同主体下指定外部联系人的全部跟进记录
      * 同主体下指定外部联系人的全部跟进记录
      */
      */
     List<QwRepeatRecordListVO> selectRepeatRecordList(RepeatRecordListParam param);
     List<QwRepeatRecordListVO> selectRepeatRecordList(RepeatRecordListParam param);
+
+    @DataSource(DataSourceType.SLAVE)
+    @Select("SELECT id,external_user_id,name,avatar,remark,description,fs_user_id FROM  qw_external_contact " +
+            "WHERE user_id = #{map.userId}   " +
+            "AND corp_id =#{map.corpId} " +
+            "AND fs_user_id = #{map.fsUserId} " +
+            "AND `status` != 4 " +
+            "ORDER BY id desc " +
+            "limit 1 ")
+    QwExternalContact getQwExternalContactDetail(@Param("map")QwExternalContactHParam param);
+
 }
 }

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

@@ -551,4 +551,7 @@ public interface QwUserMapper extends BaseMapper<QwUser>
     QwUser selectQwUserconvertSopVoice(@Param("companyUserId") Long companyUserID);
     QwUser selectQwUserconvertSopVoice(@Param("companyUserId") Long companyUserID);
 
 
     List<QwUser> selectQwUserByIdsPrimary(@Param("collect") List<Long> collect);
     List<QwUser> selectQwUserByIdsPrimary(@Param("collect") List<Long> collect);
+
+    List<QwUserVO> selectQwUserListByCompanyUserIdAndFsUserId(@Param("companyUserId")Long companyUserId,@Param("fsUserId") Long fsUserId);
+
 }
 }

+ 5 - 0
fs-service/src/main/java/com/fs/qw/param/QwExternalContactParam.java

@@ -161,4 +161,9 @@ public class QwExternalContactParam {
     @TableField(exist = false)
     @TableField(exist = false)
     private Integer isDownloadApp;
     private Integer isDownloadApp;
 
 
+    /**
+     * 1:导出当前主体 2:导出所有绑定的主体
+     */
+    private Integer exportType=1;
+
 }
 }

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

@@ -278,4 +278,8 @@ public interface IQwExternalContactService extends IService<QwExternalContact> {
      * 企微用户-查询外部联系人信息
      * 企微用户-查询外部联系人信息
      */
      */
     QwExternalContact selectQwExternalContactByExternalUserIdSidebar(FsStoreOrderScrmSidebarVO param);
     QwExternalContact selectQwExternalContactByExternalUserIdSidebar(FsStoreOrderScrmSidebarVO param);
+
+    QwExternalContact getQwExternalContactDetail(QwExternalContactHParam param);
+
+
 }
 }

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

@@ -71,5 +71,10 @@ public interface IQwTagService
 
 
     public List<String> selectQwTagListByTagIds(QwTagSearchParam param);
     public List<String> selectQwTagListByTagIds(QwTagSearchParam param);
 
 
+    /**
+     * 根据标签ID列表批量查询标签
+     */
+    List<QwTag> selectQwTagListByTagIds(List<String> tagIds);
+
     List<QwTagVO> getTagListByUserId(ContactTagListParam param);
     List<QwTagVO> getTagListByUserId(ContactTagListParam param);
 }
 }

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

@@ -244,4 +244,7 @@ public interface IQwUserService
      * 根据条件动态查询企微用户列表
      * 根据条件动态查询企微用户列表
      */
      */
     List<QwUserListVo> selectQwUserListByCondition(QwUserQueryDto query);
     List<QwUserListVo> selectQwUserListByCondition(QwUserQueryDto query);
+
+    List<QwUserVO> selectQwUserListByCompanyUserIdAndFsUserId(Long companyUserId,Long fsUserId);
+
 }
 }

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

@@ -6411,5 +6411,10 @@ public class QwExternalContactServiceImpl extends ServiceImpl<QwExternalContactM
         return qwExternalContactMapper.updateContactMobilesById(id, contactMobiles);
         return qwExternalContactMapper.updateContactMobilesById(id, contactMobiles);
     }
     }
 
 
+    @Override
+    public QwExternalContact getQwExternalContactDetail(QwExternalContactHParam param) {
+        return qwExternalContactMapper.getQwExternalContactDetail(param);
+    }
+
 
 
 }
 }

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

@@ -200,6 +200,14 @@ public class QwTagServiceImpl implements IQwTagService
         return qwTagMapper.selectQwTagListNameByTagIds(param);
         return qwTagMapper.selectQwTagListNameByTagIds(param);
     }
     }
 
 
+    @Override
+    public List<QwTag> selectQwTagListByTagIds(List<String> tagIds) {
+        if (tagIds == null || tagIds.isEmpty()) {
+            return java.util.Collections.emptyList();
+        }
+        return qwTagMapper.selectQwTagListByTagIdsNew(tagIds);
+    }
+
     @Override
     @Override
     public List<QwTagVO> getTagListByUserId(ContactTagListParam param) {
     public List<QwTagVO> getTagListByUserId(ContactTagListParam param) {
         String[] keywords = new String[0];
         String[] keywords = new String[0];

+ 5 - 0
fs-service/src/main/java/com/fs/qw/service/impl/QwUserServiceImpl.java

@@ -1689,6 +1689,11 @@ public class QwUserServiceImpl implements IQwUserService
         return qwUserList;
         return qwUserList;
     }
     }
 
 
+    @Override
+    public List<QwUserVO> selectQwUserListByCompanyUserIdAndFsUserId(Long companyUserId, Long fsUserId) {
+        return qwUserMapper.selectQwUserListByCompanyUserIdAndFsUserId(companyUserId,fsUserId);
+    }
+
     /**
     /**
      * 根据销售公司和企微ID查询企微用户
      * 根据销售公司和企微ID查询企微用户
      */
      */

+ 2 - 0
fs-service/src/main/java/com/fs/qwApi/param/QwExternalContactHParam.java

@@ -11,4 +11,6 @@ public class QwExternalContactHParam {
     private  String externalUserId;
     private  String externalUserId;
 
 
     private  String corpId;
     private  String corpId;
+
+    private  Long fsUserId;
 }
 }

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

@@ -96,5 +96,8 @@ public interface QwSopTempVoiceMapper extends BaseMapper<QwSopTempVoice>{
     @DataSource(DataSourceType.SOP)
     @DataSource(DataSourceType.SOP)
     int updateBatchWithDataSource(@Param("list") List<QwSopTempVoice> list);
     int updateBatchWithDataSource(@Param("list") List<QwSopTempVoice> list);
 
 
+    @DataSource(DataSourceType.SOP)
+    List<QwSopTempVoice> selectQwSopTempVoiceByCompanyUserId(@Param("companyUserId") Long companyUserId,@Param("keyword") String keyword);
+
 
 
 }
 }

+ 1 - 0
fs-service/src/main/java/com/fs/sop/params/SendUserLogsInfoMsgParam.java

@@ -30,6 +30,7 @@ public class SendUserLogsInfoMsgParam {
     //草稿策略 1、正常发 2、清除草稿 3、发送草稿
     //草稿策略 1、正常发 2、清除草稿 3、发送草稿
     private Integer draftStrategy;
     private Integer draftStrategy;
 
 
+
     private String sendTime;
     private String sendTime;
 
 
     private Integer isMine; //1:我的营期
     private Integer isMine; //1:我的营期

+ 2 - 0
fs-service/src/main/java/com/fs/sop/service/IQwSopTempVoiceService.java

@@ -93,4 +93,6 @@ public interface IQwSopTempVoiceService extends IService<QwSopTempVoice>{
     List<QwSopTempVoice> convertSopVoiceEveryDay();
     List<QwSopTempVoice> convertSopVoiceEveryDay();
 
 
     int updateBatchWithDataSource(List<QwSopTempVoice> list);
     int updateBatchWithDataSource(List<QwSopTempVoice> list);
+
+    List<QwSopTempVoice> selectQwSopTempVoiceByCompanyUserId(Long companyUserId,String keyword);
 }
 }

+ 6 - 0
fs-service/src/main/java/com/fs/sop/service/impl/QwSopTempVoiceServiceImpl.java

@@ -341,4 +341,10 @@ public class QwSopTempVoiceServiceImpl extends ServiceImpl<QwSopTempVoiceMapper,
     public int updateBatchWithDataSource(List<QwSopTempVoice> list) {
     public int updateBatchWithDataSource(List<QwSopTempVoice> list) {
       return  qwSopTempVoiceMapper.updateBatchWithDataSource(list);
       return  qwSopTempVoiceMapper.updateBatchWithDataSource(list);
     }
     }
+
+    @Override
+    @DataSource(DataSourceType.SOP)
+    public List<QwSopTempVoice> selectQwSopTempVoiceByCompanyUserId(Long companyUserId,String keyword) {
+        return qwSopTempVoiceMapper.selectQwSopTempVoiceByCompanyUserId(companyUserId ,keyword);
+    }
 }
 }

+ 11 - 4
fs-service/src/main/java/com/fs/sop/service/impl/SopUserLogsInfoServiceImpl.java

@@ -83,6 +83,7 @@ import org.springframework.stereotype.Service;
 
 
 import javax.validation.ConstraintViolationException;
 import javax.validation.ConstraintViolationException;
 import java.io.Serializable;
 import java.io.Serializable;
+import java.text.ParseException;
 import java.text.SimpleDateFormat;
 import java.text.SimpleDateFormat;
 import java.time.LocalDate;
 import java.time.LocalDate;
 import java.time.LocalDateTime;
 import java.time.LocalDateTime;
@@ -2002,8 +2003,13 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
 //                    List<QwSopLogs> sopLogsList = processInsertSopUserLogsInfo(logs, qwUser, param, words, config, qwCompany, finalSort,
 //                    List<QwSopLogs> sopLogsList = processInsertSopUserLogsInfo(logs, qwUser, param, words, config, qwCompany, finalSort,
 //                            finalSendType,miniMap,companies);
 //                            finalSendType,miniMap,companies);
 
 
-                    SopLogsResult result = processInsertSopUserLogsInfo(logs, qwUser, param, words, config, qwCompany, finalSort,
-                            finalSendType, miniMap,companies);
+                    SopLogsResult result = null;
+                    try {
+                        result = processInsertSopUserLogsInfo(logs, qwUser, param, words, config, qwCompany, finalSort,
+                                finalSendType, miniMap,companies);
+                    } catch (ParseException e) {
+                        throw new RuntimeException(e);
+                    }
 
 
                     //批量插入 发送记录
                     //批量插入 发送记录
                     if (!result.getSopLogsList().isEmpty()) {
                     if (!result.getSopLogsList().isEmpty()) {
@@ -2031,7 +2037,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
     private SopLogsResult processInsertSopUserLogsInfo(List<SopUserLogsInfo> sopUserLogsInfos, QwUser qwUser,
     private SopLogsResult processInsertSopUserLogsInfo(List<SopUserLogsInfo> sopUserLogsInfos, QwUser qwUser,
                                                        SendUserLogsInfoMsgParam param, List<FastGptChatReplaceWords> words,
                                                        SendUserLogsInfoMsgParam param, List<FastGptChatReplaceWords> words,
                                                        CourseConfig config, QwCompany qwCompany, int finalSort, int finalSendType,
                                                        CourseConfig config, QwCompany qwCompany, int finalSort, int finalSendType,
-                                                       Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap, List<Company> companies ){
+                                                       Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap, List<Company> companies ) throws ParseException {
 
 
         SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
         SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
 
 
@@ -2040,7 +2046,8 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
 
 
         Date dataTime;
         Date dataTime;
         if (!StringUtil.strIsNullOrEmpty(param.getSendTime())){
         if (!StringUtil.strIsNullOrEmpty(param.getSendTime())){
-            dataTime = processDate(param.getSendTime());
+//            dataTime = processDate(param.getSendTime());
+            dataTime= sdf.parse(param.getSendTime());
         }else {
         }else {
             dataTime=new Date();
             dataTime=new Date();
         }
         }

+ 1 - 1
fs-service/src/main/resources/application-druid-sxsm.yml

@@ -184,7 +184,7 @@ spring:
                             multi-statement-allow: true
                             multi-statement-allow: true
 rocketmq:
 rocketmq:
     enabled: false
     enabled: false
-    name-server:  # RocketMQ NameServer 地址
+    name-server: tencenttdmq # RocketMQ NameServer 地址
     producer:
     producer:
         group: my-producer-group
         group: my-producer-group
         access-key: ak16pknjrda5f27fdaa338ab # 替换为实际的 accessKey
         access-key: ak16pknjrda5f27fdaa338ab # 替换为实际的 accessKey

+ 20 - 1
fs-service/src/main/resources/db/changelog/table/live_group_type.sql

@@ -39,4 +39,23 @@ CREATE TABLE `scrm_customer_info` (
                                       PRIMARY KEY (`id`),
                                       PRIMARY KEY (`id`),
                                       UNIQUE KEY `uk_external_contact_id` (`external_contact_id`),
                                       UNIQUE KEY `uk_external_contact_id` (`external_contact_id`),
                                       KEY `idx_company_id` (`company_id`)
                                       KEY `idx_company_id` (`company_id`)
-) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='SCRM客户信息表';
+) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='SCRM客户信息表';
+
+
+--changeset sgw:20260629-live_completion_answer_record
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'live_completion_answer_record'
+CREATE TABLE `live_completion_answer_record` (
+                                                 `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+                                                 `live_id` bigint NOT NULL COMMENT '直播ID',
+                                                 `user_id` bigint NOT NULL COMMENT '用户ID',
+                                                 `user_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '用户名称',
+                                                 `is_right` tinyint NOT NULL DEFAULT '0' COMMENT '是否全部正确 0否 1是',
+                                                 `answer_json` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci COMMENT '用户作答原始JSON',
+                                                 `question_json` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci COMMENT '题目及作答明细JSON',
+                                                 `create_time` datetime DEFAULT NULL COMMENT '答题时间',
+                                                 PRIMARY KEY (`id`) USING BTREE,
+                                                 KEY `idx_live_id` (`live_id`) USING BTREE,
+                                                 KEY `idx_user_id` (`user_id`) USING BTREE,
+                                                 KEY `idx_create_time` (`create_time`) USING BTREE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='直播完课答题记录';

+ 9 - 0
fs-service/src/main/resources/mapper/company/CompanyAiWorkflowExecMapper.xml

@@ -306,4 +306,13 @@
                  inner join company_voice_robotic_business t2 on t1.business_key = t2.id
                  inner join company_voice_robotic_business t2 on t1.business_key = t2.id
         where t1.workflow_instance_id = #{workflowInstanceId}
         where t1.workflow_instance_id = #{workflowInstanceId}
     </select>
     </select>
+
+    <select id="countRunningExecByGroupNo" resultType="int">
+        SELECT COUNT(1)
+        FROM company_ai_workflow_exec
+        WHERE cid_group_no = #{groupNo}
+          AND status = #{runningStatus}
+    </select>
+
+
 </mapper>
 </mapper>

+ 11 - 6
fs-service/src/main/resources/mapper/company/CompanySiptaskInfoMapper.xml

@@ -3,7 +3,7 @@
 PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
 PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
 "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
 "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
 <mapper namespace="com.fs.company.mapper.CompanySiptaskInfoMapper">
 <mapper namespace="com.fs.company.mapper.CompanySiptaskInfoMapper">
-    
+
     <resultMap type="CompanySiptaskInfo" id="CompanySiptaskInfoResult">
     <resultMap type="CompanySiptaskInfo" id="CompanySiptaskInfoResult">
         <result property="id"    column="id"    />
         <result property="id"    column="id"    />
         <result property="workflowId"    column="workflow_id"    />
         <result property="workflowId"    column="workflow_id"    />
@@ -19,7 +19,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
 
 
     <select id="selectCompanySiptaskInfoList" parameterType="CompanySiptaskInfo" resultMap="CompanySiptaskInfoResult">
     <select id="selectCompanySiptaskInfoList" parameterType="CompanySiptaskInfo" resultMap="CompanySiptaskInfoResult">
         <include refid="selectCompanySiptaskInfoVo"/>
         <include refid="selectCompanySiptaskInfoVo"/>
-        <where>  
+        <where>
             <if test="workflowId != null "> and workflow_id = #{workflowId}</if>
             <if test="workflowId != null "> and workflow_id = #{workflowId}</if>
             <if test="taskId != null "> and task_id = #{taskId}</if>
             <if test="taskId != null "> and task_id = #{taskId}</if>
             <if test="nodeKey != null  and nodeKey != ''"> and node_key = #{nodeKey}</if>
             <if test="nodeKey != null  and nodeKey != ''"> and node_key = #{nodeKey}</if>
@@ -27,12 +27,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="taskJson != null  and taskJson != ''"> and task_json = #{taskJson}</if>
             <if test="taskJson != null  and taskJson != ''"> and task_json = #{taskJson}</if>
         </where>
         </where>
     </select>
     </select>
-    
+
     <select id="selectCompanySiptaskInfoById" parameterType="Long" resultMap="CompanySiptaskInfoResult">
     <select id="selectCompanySiptaskInfoById" parameterType="Long" resultMap="CompanySiptaskInfoResult">
         <include refid="selectCompanySiptaskInfoVo"/>
         <include refid="selectCompanySiptaskInfoVo"/>
         where id = #{id}
         where id = #{id}
     </select>
     </select>
-        
+
     <insert id="insertCompanySiptaskInfo" parameterType="CompanySiptaskInfo" useGeneratedKeys="true" keyProperty="id">
     <insert id="insertCompanySiptaskInfo" parameterType="CompanySiptaskInfo" useGeneratedKeys="true" keyProperty="id">
         insert into company_siptask_info
         insert into company_siptask_info
         <trim prefix="(" suffix=")" suffixOverrides=",">
         <trim prefix="(" suffix=")" suffixOverrides=",">
@@ -68,7 +68,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
     </delete>
     </delete>
 
 
     <delete id="deleteCompanySiptaskInfoByIds" parameterType="String">
     <delete id="deleteCompanySiptaskInfoByIds" parameterType="String">
-        delete from company_siptask_info where id in 
+        delete from company_siptask_info where id in
         <foreach item="id" collection="array" open="(" separator="," close=")">
         <foreach item="id" collection="array" open="(" separator="," close=")">
             #{id}
             #{id}
         </foreach>
         </foreach>
@@ -76,4 +76,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
     <select id="selectSipTaskInfoByTaskIdAndNodeKey" resultType="com.fs.company.domain.CompanySiptaskInfo">
     <select id="selectSipTaskInfoByTaskIdAndNodeKey" resultType="com.fs.company.domain.CompanySiptaskInfo">
         select * from company_siptask_info where task_id=#{taskId} and node_key=#{nodeKey} limit 1
         select * from company_siptask_info where task_id=#{taskId} and node_key=#{nodeKey} limit 1
     </select>
     </select>
-</mapper>
+
+    <select id="selectSipTaskInfoByBatchId" resultType="com.fs.company.domain.CompanySiptaskInfo">
+        select * from company_siptask_info where batch_id=#{batchId} limit 1
+    </select>
+
+</mapper>

+ 26 - 0
fs-service/src/main/resources/mapper/company/CompanyVoiceRoboticCallLogCallphoneMapper.xml

@@ -550,4 +550,30 @@
         </trim>
         </trim>
         where log_id = #{logId}
         where log_id = #{logId}
     </update>
     </update>
+
+    <update id="incrementCallbackCompensateRetryCount">
+        UPDATE company_voice_robotic_call_log_callphone
+        SET retry_count = IFNULL(retry_count, 0) + 1
+        WHERE log_id = #{logId}
+          AND status = 1
+    </update>
+
+
+    <select id="selectPendingAiCallLogsForSync" resultType="com.fs.company.vo.PendingAiCallLogSyncVo">
+        SELECT t1.log_id        AS logId,
+               t1.callback_uuid AS callbackUuid,
+               t1.run_param     AS runParam,
+               t1.company_id    AS companyId,
+               IFNULL(t1.retry_count, 0) AS retryCount
+        FROM company_voice_robotic_call_log_callphone t1
+                 INNER JOIN company_voice_robotic cvr ON cvr.id = t1.robotic_id
+        WHERE cvr.task_status = 1
+          AND (cvr.del_flag = 0 OR cvr.del_flag IS NULL)
+          AND t1.status = 1
+          AND t1.callback_uuid IS NOT NULL
+          AND t1.callback_uuid != ''
+          AND IFNULL(t1.retry_count, 0) &lt; #{maxRetryCount}
+    </select>
+
+
 </mapper>
 </mapper>

+ 1 - 1
fs-service/src/main/resources/mapper/company/CompanyVoiceRoboticMapper.xml

@@ -66,7 +66,7 @@
         inner join company_user u on a.create_user = u.user_id
         inner join company_user u on a.create_user = u.user_id
         inner join company_dept d on u.dept_id = d.dept_id
         inner join company_dept d on u.dept_id = d.dept_id
         <where>
         <where>
-            a.del_flag = 0
+            a.del_flag != 1
             <if test="taskType != null "> and a.task_type = #{taskType} </if>
             <if test="taskType != null "> and a.task_type = #{taskType} </if>
             <if test="companyId != null"> and a.company_id = #{companyId} </if>
             <if test="companyId != null"> and a.company_id = #{companyId} </if>
             <if test="companyUserId != null"> and a.company_user_id = #{companyUserId} </if>
             <if test="companyUserId != null"> and a.company_user_id = #{companyUserId} </if>

+ 25 - 0
fs-service/src/main/resources/mapper/company/EasyCallMapper.xml

@@ -28,14 +28,39 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         select * from cc_call_phone where biz_json like CONCAT('%', #{callBackUuid}, '%')
         select * from cc_call_phone where biz_json like CONCAT('%', #{callBackUuid}, '%')
     </select>
     </select>
 
 
+<!--    <select id="selectCompletedUuidsByCallbackUuids" resultType="java.lang.String">-->
+<!--        SELECT uuid-->
+<!--        FROM cc_call_phone-->
+<!--        WHERE callout_time != 0-->
+<!--        AND JSON_UNQUOTE(JSON_EXTRACT(biz_json, '$.callBackUuid')) IN-->
+<!--        <foreach collection="callbackUuids" item="item" open="(" separator="," close=")">-->
+<!--            #{item}-->
+<!--        </foreach>-->
+<!--    </select>-->
+
     <select id="selectCompletedUuidsByCallbackUuids" resultType="java.lang.String">
     <select id="selectCompletedUuidsByCallbackUuids" resultType="java.lang.String">
         SELECT uuid
         SELECT uuid
         FROM cc_call_phone
         FROM cc_call_phone
         WHERE callout_time != 0
         WHERE callout_time != 0
+        AND call_end_time IS NOT NULL
+        AND call_end_time &gt; 0
         AND JSON_UNQUOTE(JSON_EXTRACT(biz_json, '$.callBackUuid')) IN
         AND JSON_UNQUOTE(JSON_EXTRACT(biz_json, '$.callBackUuid')) IN
         <foreach collection="callbackUuids" item="item" open="(" separator="," close=")">
         <foreach collection="callbackUuids" item="item" open="(" separator="," close=")">
             #{item}
             #{item}
         </foreach>
         </foreach>
     </select>
     </select>
 
 
+
+    <select id="selectCompletedCallsByCallbackUuids" resultType="com.fs.company.vo.easycall.EasyCallCompletedCallVo">
+        SELECT uuid,
+        JSON_UNQUOTE(JSON_EXTRACT(biz_json, '$.callBackUuid')) AS callBackUuid
+        FROM cc_call_phone
+        WHERE callout_time != 0
+        AND call_end_time IS NOT NULL
+        AND call_end_time &gt; 0
+        AND JSON_UNQUOTE(JSON_EXTRACT(biz_json, '$.callBackUuid')) IN
+        <foreach collection="callbackUuids" item="item" open="(" separator="," close=")">
+            #{item}
+        </foreach>
+    </select>
 </mapper>
 </mapper>

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

@@ -8,7 +8,7 @@
         select
         select
             fcpsc.*
             fcpsc.*
         from fs_course_play_source_config fcpsc
         from fs_course_play_source_config fcpsc
-        where fcpsc.is_del = 0
+        where fcpsc.is_del = 0 and fcpsc.status = 0
         <if test="params.name != null and params.name != ''">
         <if test="params.name != null and params.name != ''">
             and fcpsc.name like concat('%', #{params.name}, '%')
             and fcpsc.name like concat('%', #{params.name}, '%')
         </if>
         </if>

+ 9 - 0
fs-service/src/main/resources/mapper/course/FsUserCourseMapper.xml

@@ -491,4 +491,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             AND course_id != #{excludeCourseId}
             AND course_id != #{excludeCourseId}
         </if>
         </if>
     </select>
     </select>
+
+    <select id="selectAllCourseByIds" resultType="com.fs.course.domain.FsUserCourse">
+        SELECT * FROM fs_user_course
+        WHERE is_del = 0 AND course_id IN
+        <foreach collection="courseIds" item="id" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </select>
+
 </mapper>
 </mapper>

+ 92 - 0
fs-service/src/main/resources/mapper/live/LiveCompletionAnswerRecordMapper.xml

@@ -0,0 +1,92 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.fs.live.mapper.LiveCompletionAnswerRecordMapper">
+
+    <select id="selectLiveCompletionAnswerRecordList"
+            parameterType="com.fs.live.param.LiveCompletionAnswerRecordParam"
+            resultType="com.fs.live.vo.LiveCompletionAnswerRecordListVO">
+        SELECT
+            r.id,
+            r.live_id AS liveId,
+            l.live_name AS liveName,
+            r.user_id AS userId,
+            r.user_name AS userName,
+            r.is_right AS isRight,
+            r.answer_json AS answerJson,
+            r.question_json AS questionJson,
+            r.create_time AS createTime
+        FROM live_completion_answer_record r
+        LEFT JOIN live l ON l.live_id = r.live_id
+        <where>
+            <if test="liveId != null">
+                AND r.live_id = #{liveId}
+            </if>
+            <if test="userId != null">
+                AND r.user_id = #{userId}
+            </if>
+            <if test="userName != null and userName != ''">
+                AND r.user_name LIKE CONCAT('%', #{userName}, '%')
+            </if>
+            <if test="isRight != null">
+                AND r.is_right = #{isRight}
+            </if>
+            <if test="beginTime != null and beginTime != ''">
+                AND r.create_time &gt;= #{beginTime}
+            </if>
+            <if test="endTime != null and endTime != ''">
+                AND r.create_time &lt;= #{endTime}
+            </if>
+        </where>
+        ORDER BY r.create_time DESC
+    </select>
+
+    <select id="selectLiveCompletionAnswerRecordById" parameterType="Long"
+            resultType="com.fs.live.vo.LiveCompletionAnswerRecordListVO">
+        SELECT
+            r.id,
+            r.live_id AS liveId,
+            l.live_name AS liveName,
+            r.user_id AS userId,
+            r.user_name AS userName,
+            r.is_right AS isRight,
+            r.answer_json AS answerJson,
+            r.question_json AS questionJson,
+            r.create_time AS createTime
+        FROM live_completion_answer_record r
+        LEFT JOIN live l ON l.live_id = r.live_id
+        WHERE r.id = #{id}
+    </select>
+
+    <select id="selectLiveCompletionAnswerRecordByLiveUserId"
+            resultType="com.fs.live.vo.LiveCompletionAnswerRecordListVO">
+        SELECT
+            id,
+            live_id AS liveId,
+            user_id AS userId,
+            user_name AS userName,
+            is_right AS isRight,
+            answer_json AS answerJson,
+            question_json AS questionJson,
+            create_time AS createTime
+        FROM live_completion_answer_record
+        WHERE
+            live_id = #{liveId}
+          AND user_id = #{userId}
+        AND is_right = 1
+         ORDER BY create_time DESC
+         LIMIT 1
+
+    </select>
+
+    <select id="countLiveCompletionAnswerRecordByLiveUserId" resultType="java.lang.Integer">
+        SELECT
+         COUNT(1)
+        FROM live_completion_answer_record
+        WHERE
+            live_id = #{liveId}
+          AND user_id = #{userId}
+    </select>
+
+</mapper>

+ 12 - 0
fs-service/src/main/resources/mapper/qw/QwUserMapper.xml

@@ -437,4 +437,16 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         </foreach>
         </foreach>
     </select>
     </select>
 
 
+    <select id="selectQwUserListByCompanyUserIdAndFsUserId" resultType="com.fs.qw.vo.QwUserVO">
+        select
+            qu.*,
+            qc.corp_name
+        from qw_user qu
+                 inner join qw_company qc on qu.corp_id = qc.corp_id
+                 left join qw_external_contact qwe on qu.qw_user_id = qwe.user_id
+        where qu.is_del = 0 and qu.app_key is not null
+          AND qwe.fs_user_id =#{fsUserId}
+          and qu.company_user_id = #{companyUserId}
+    </select>
+
 </mapper>
 </mapper>

+ 7 - 0
fs-service/src/main/resources/mapper/sop/QwSopTempVoiceMapper.xml

@@ -203,4 +203,11 @@
         </foreach>
         </foreach>
     </update>
     </update>
 
 
+    <select id="selectQwSopTempVoiceByCompanyUserId" resultType="com.fs.sop.domain.QwSopTempVoice">
+        select id,voice_txt from qw_sop_temp_voice
+        where company_user_id = #{companyUserId}
+        and record_type =1
+        <if test="keyword != null and keyword!='' ">  and voice_txt like concat('%', #{keyword}, '%') </if>
+    </select>
+
 </mapper>
 </mapper>

+ 108 - 0
fs-user-app/src/main/java/com/fs/app/controller/AppBaseController.java

@@ -13,6 +13,8 @@ import com.fs.common.exception.CustomException;
 import com.fs.common.utils.ServletUtils;
 import com.fs.common.utils.ServletUtils;
 import com.fs.common.utils.StringUtils;
 import com.fs.common.utils.StringUtils;
 import com.fs.common.utils.sql.SqlUtil;
 import com.fs.common.utils.sql.SqlUtil;
+import com.fs.company.domain.CompanyUserUser;
+import com.fs.company.service.ICompanyUserUserService;
 import com.github.pagehelper.PageHelper;
 import com.github.pagehelper.PageHelper;
 import com.github.pagehelper.PageInfo;
 import com.github.pagehelper.PageInfo;
 import io.jsonwebtoken.Claims;
 import io.jsonwebtoken.Claims;
@@ -28,6 +30,12 @@ public class AppBaseController {
 	@Autowired
 	@Autowired
     public RedisCache redisCache;
     public RedisCache redisCache;
 
 
+	@Autowired
+	private ICompanyUserUserService companyUserUserService;
+
+	/**
+	* 用户token
+	*/
 	public String getDoctorId()
 	public String getDoctorId()
 	{
 	{
 		String headValue =  ServletUtils.getRequest().getHeader("APPToken");
 		String headValue =  ServletUtils.getRequest().getHeader("APPToken");
@@ -36,6 +44,9 @@ public class AppBaseController {
 		return doctorId;
 		return doctorId;
 	}
 	}
 
 
+	/**
+	 * 用户token
+	 */
 	public String getUserId()
 	public String getUserId()
 	{
 	{
 		String headValue =  ServletUtils.getRequest().getHeader("APPToken");
 		String headValue =  ServletUtils.getRequest().getHeader("APPToken");
@@ -51,6 +62,10 @@ public class AppBaseController {
 		}
 		}
 		return null;
 		return null;
 	}
 	}
+
+	/**
+	* 客服登录的token(互医的 有问诊套餐包订单)
+	*/
 	public Long getCompanyUserId( )
 	public Long getCompanyUserId( )
 	{
 	{
 		Long companyUesrId=redisCache.getCacheObject("company-user-token:"+ ServletUtils.getRequest().getHeader("companyUserToken"));
 		Long companyUesrId=redisCache.getCacheObject("company-user-token:"+ ServletUtils.getRequest().getHeader("companyUserToken"));
@@ -60,6 +75,25 @@ public class AppBaseController {
 		return companyUesrId;
 		return companyUesrId;
 	}
 	}
 
 
+	/**
+	 * 销售登录的token(登录进去是手动看课的)
+	 */
+	public String getCompanyUserCourseId( )
+	{
+		String companyUserId = ServletUtils.getRequest().getHeader("corpUserCourseToken");
+		if (StringUtils.isNotEmpty(companyUserId)){
+			Claims claims=jwtUtils.getClaimByToken(companyUserId);
+			if(claims!=null){
+				String userId = claims.getSubject().toString();
+				return userId;
+			}
+			else{
+				return null;
+			}
+		}
+		return null;
+	}
+
 	/**
 	/**
 	 * 设置请求分页数据
 	 * 设置请求分页数据
 	 */
 	 */
@@ -128,4 +162,78 @@ public class AppBaseController {
 		return AjaxResult.error();
 		return AjaxResult.error();
 	}
 	}
 
 
+	/** C 端本人,或销售名下客户(CompanyUserToken fs_user_id和company_user_id之间的绑定关系) */
+	protected R denyIfCannotAccessFsUser(Long fsUserId) {
+		if (fsUserId == null) {
+			return R.error("非法操作");
+		}
+		if (isCurrentUser(fsUserId)) {
+			return null;
+		}
+		try {
+			Long companyUserId = Long.parseLong(getCompanyUserCourseId());
+			CompanyUserUser query = new CompanyUserUser();
+			query.setCompanyUserId(companyUserId);
+			query.setUserId(fsUserId);
+			List<CompanyUserUser> bindings = companyUserUserService.selectCompanyUserUserList(query);
+			if (bindings != null && !bindings.isEmpty()) {
+				return null;
+			}
+		} catch (CustomException ignored) {
+		}
+		return R.error("非法操作");
+	}
+
+
+	protected boolean isCurrentUser(Long userId) {
+		if (userId == null || StringUtils.isEmpty(getUserId())) {
+			return false;
+		}
+		return userId.equals(Long.parseLong(getUserId()));
+	}
+
+
+	/**
+	* 互医 客服登录(问诊 套餐包订单版)
+	*/
+	protected R denyIfNotCurrentCompanyUser(Long companyUserId) {
+		if (!isCurrentCompanyUser(companyUserId)) {
+			return R.error("非法操作");
+		}
+		return null;
+	}
+
+
+	protected boolean isCurrentCompanyUser(Long companyUserId) {
+		if (companyUserId == null) {
+			return false;
+		}
+		try {
+			return companyUserId.equals(getCompanyUserId());
+		} catch (CustomException e) {
+			return false;
+		}
+	}
+
+	/**
+	 * 销售登录(app手动发课版)
+	 */
+	protected R denyIfNotCurrentCourseCompanyUser(Long companyUserId) {
+		if (!isCurrentCourseCompanyUser(companyUserId)) {
+			return R.error("非法操作");
+		}
+		return null;
+	}
+
+	protected boolean isCurrentCourseCompanyUser(Long companyUserId) {
+		if (companyUserId == null) {
+			return false;
+		}
+		try {
+			return companyUserId.equals(Long.parseLong(getCompanyUserCourseId()));
+		} catch (CustomException e) {
+			return false;
+		}
+	}
+
 }
 }

+ 14 - 7
fs-user-app/src/main/java/com/fs/app/controller/FsCourseCouponUserController.java

@@ -30,6 +30,7 @@ public class FsCourseCouponUserController extends AppBaseController {
     Logger logger= LoggerFactory.getLogger(getClass());
     Logger logger= LoggerFactory.getLogger(getClass());
     @Autowired
     @Autowired
     private IFsCourseCouponUserService fsCourseCouponUserService;
     private IFsCourseCouponUserService fsCourseCouponUserService;
+
     @Autowired
     @Autowired
     private ICompanyUserService companyUserService;
     private ICompanyUserService companyUserService;
 
 
@@ -55,14 +56,21 @@ public class FsCourseCouponUserController extends AppBaseController {
         return fsCourseCouponUserService.useCoupon(courseCouponUser.getUserId(), courseCouponUser.getId());
         return fsCourseCouponUserService.useCoupon(courseCouponUser.getUserId(), courseCouponUser.getId());
     }
     }
 
 
+
+
     /**
     /**
-    * 获取销售已核销的列表
-    */
+     * 获取销售已核销的列表
+     */
     @Login
     @Login
-    @GetMapping("/getUseCouponByCompanyUserId/{companyUserId}")
-    public List<FsCourseCouponUserVO> getUseCouponByCompanyUserId(@PathVariable(value = "companyUserId") Long companyUserId) {
+    @PostMapping("/getUseCouponByCompanyUserId")
+    public R getUseCouponByCompanyUserId(@RequestBody FsCourseCouponUserUParam param) {
 
 
-        return fsCourseCouponUserService.getUseCouponByCompanyUserId(companyUserId);
+        Long companyUserId = Long.parseLong(getUserId());
+
+        PageHelper.startPage(param.getPageNum(),param.getPageSize());
+        List<FsCourseCouponUserVO> userVOS = fsCourseCouponUserService.getUseCouponByCompanyUserId(companyUserId);
+        PageInfo<FsCourseCouponUserVO> pageInfo = new PageInfo<>(userVOS);
+        return R.ok().put("data",pageInfo);
     }
     }
 
 
     /**
     /**
@@ -71,7 +79,7 @@ public class FsCourseCouponUserController extends AppBaseController {
     @Login
     @Login
     @PostMapping("/verifyCoupon")
     @PostMapping("/verifyCoupon")
     public R verifyCoupon(@RequestBody CourseCouponVerifyParam param) {
     public R verifyCoupon(@RequestBody CourseCouponVerifyParam param) {
-        Long companyUserId = getCompanyUserId();
+        Long companyUserId = Long.parseLong(getUserId());
         CompanyUser companyUser = companyUserService.selectCompanyUserById(companyUserId);
         CompanyUser companyUser = companyUserService.selectCompanyUserById(companyUserId);
         if (companyUser == null) {
         if (companyUser == null) {
             return R.error("销售不存在");
             return R.error("销售不存在");
@@ -80,5 +88,4 @@ public class FsCourseCouponUserController extends AppBaseController {
         return fsCourseCouponUserService.verifyCoupon(param);
         return fsCourseCouponUserService.verifyCoupon(param);
     }
     }
 
 
-
 }
 }

+ 58 - 0
fs-user-app/src/main/java/com/fs/app/controller/app/AppQwStatisticsController.java

@@ -0,0 +1,58 @@
+package com.fs.app.controller.app;
+
+import com.fs.app.annotation.Login;
+import com.fs.app.controller.AppBaseController;
+import com.fs.common.core.domain.R;
+import com.fs.common.exception.CustomException;
+import com.fs.qw.domain.QwExternalContact;
+import com.fs.qw.service.IQwExternalContactService;
+import com.fs.qwApi.param.QwExternalContactHParam;
+import com.fs.statistics.dto.WatchCourseStatisticsDTO;
+import com.fs.statistics.param.WatchCourseStatisticsParam;
+import com.fs.statistics.service.IStatisticsService;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * app客户个人侧边栏-企微统计数据
+ */
+@RestController
+@RequestMapping("/app/qw/statistics")
+public class AppQwStatisticsController extends AppBaseController {
+    @Autowired
+    private IStatisticsService statisticsService;
+
+    @Autowired
+    private IQwExternalContactService iQwExternalContactService;
+
+    @Login
+    @PostMapping("/course/watch")
+    @ApiOperation("会员看课详情")
+    public R queryCourseWatchStatistics(@RequestParam(value = "userId", required = false) String userId,
+                                        @RequestParam(value = "corpId", required = false) String corpId,
+                                        @RequestParam(value = "fsUserId", required = false) Long fsUserId,
+                                        @RequestParam(value = "type", required = false) Integer type) {
+        R denied = denyIfCannotAccessFsUser(fsUserId);
+        if (denied != null) {
+            return denied;
+        }
+        QwExternalContactHParam qwExternalContactHParam = new QwExternalContactHParam();
+        qwExternalContactHParam.setUserId(userId);
+        qwExternalContactHParam.setCorpId(corpId);
+        qwExternalContactHParam.setFsUserId(fsUserId);
+        QwExternalContact qwExternalContact = iQwExternalContactService.getQwExternalContactDetail(qwExternalContactHParam);
+        if (qwExternalContact == null || qwExternalContact.getId() == null) {
+            throw new CustomException("未查询到对应的外部联系人");
+        }
+        WatchCourseStatisticsParam param = new WatchCourseStatisticsParam();
+        param.setType(type);
+        param.setQwExternalContactId(qwExternalContact.getId());
+        WatchCourseStatisticsDTO watchCourseStatisticsDTO = statisticsService.queryWatchCourse(param);
+        return R.ok().put("data", watchCourseStatisticsDTO);
+    }
+
+}

+ 80 - 0
fs-user-app/src/main/java/com/fs/app/controller/live/LiveCompletionCouponController.java

@@ -0,0 +1,80 @@
+package com.fs.app.controller.live;
+
+import com.alibaba.fastjson.JSON;
+import com.fs.app.controller.AppBaseController;
+import com.fs.common.annotation.RepeatSubmit;
+import com.fs.common.core.domain.R;
+import com.fs.common.exception.base.BaseException;
+import com.fs.course.service.IFsCourseQuestionBankService;
+import com.fs.live.domain.Live;
+import com.fs.live.domain.LiveWatchConfig;
+import com.fs.live.param.LiveCompletionCouponAnswerParam;
+import com.fs.live.param.LiveRedPacketParam;
+import com.fs.live.service.ILiveService;
+import com.fs.live.service.ILiveWatchUserService;
+import com.fs.live.vo.LiveCompletionCouponAnswerResult;
+import com.fs.voice.utils.StringUtil;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * 直播完课优惠券Controller
+ */
+@RestController
+@RequestMapping("/app/live/completion/coupon")
+public class LiveCompletionCouponController extends AppBaseController {
+
+    @Autowired
+    private ILiveService iLiveService;
+
+    @Autowired
+    private IFsCourseQuestionBankService questionBankService;
+
+
+   /**
+   * 获取直播间的题目(如果是 直播奖励 红包的 ,才去题目 和返回题目 )
+   */
+    @RepeatSubmit
+    @PostMapping("/questions")
+    public R questions(@RequestBody LiveRedPacketParam param) {
+
+        if (param.getSource()==1  && StringUtil.strIsNullOrEmpty(param.getAppId())){
+            return R.error("appId不能为空");
+        }
+
+        if (-1==param.getCompanyId() || -1==param.getCompanyUserId()){
+            return R.error("客服信息错误,请从客服处 领取链接观看");
+        }
+
+        param.setUserId(Long.parseLong(getUserId()));
+
+        return iLiveService.getQuestions(param);
+    }
+
+    /**
+     * 提交今日问题(校验答题结果并写入 live_completion_answer_record,不发券)
+     */
+    @PostMapping("/answer")
+    @RepeatSubmit
+    public R answer(@RequestBody LiveCompletionCouponAnswerParam param) {
+        Long userId = Long.parseLong(getUserId());
+
+        Live live = iLiveService.selectLiveByLiveId(param.getLiveId());
+        LiveWatchConfig config = JSON.parseObject(live.getConfigJson(), LiveWatchConfig.class);
+
+        LiveCompletionCouponAnswerResult result = questionBankService.submitAnswers(param, userId,config);
+
+        if (result.isAnswerNumOver()){
+            return R.ok("答题次数超过限制").put("correctNum",result.getCorrectNum());
+        }else {
+            if (!result.isAllCorrect()) {
+                return R.ok("回答错误,请重新作答").put("correctNum",result.getCorrectNum());
+            }
+
+            return R.ok("答题正确,请点击领取奖励").put("data", result);
+        }
+
+    }
+
+}