Bläddra i källkod

Merge branch 'master' of http://1.14.104.71:10880/root/ylrz_his_scrm_java

caoliqin 1 vecka sedan
förälder
incheckning
58a9830e18
100 ändrade filer med 4750 tillägg och 233 borttagningar
  1. 28 1
      fs-admin/src/main/java/com/fs/company/controller/CompanyVoiceRoboticController.java
  2. 2 0
      fs-admin/src/main/java/com/fs/live/controller/LiveController.java
  3. 89 0
      fs-admin/src/main/java/com/fs/live/controller/LiveQuestionLiveController.java
  4. 224 0
      fs-admin/src/main/java/com/fs/live/controller/LiveTrainingCampAdminController.java
  5. 259 0
      fs-admin/src/main/java/com/fs/xiaoshouyi/controller/XiaoShouYiController.java
  6. 113 0
      fs-admin/src/main/java/com/fs/xiaoshouyi/controller/XsyAccountController.java
  7. 36 0
      fs-admin/src/main/java/com/fs/xiaoshouyi/controller/XsyCompanyBindController.java
  8. 3 2
      fs-company-app/src/main/java/com/fs/app/controller/FsUserCourseVideoController.java
  9. 222 0
      fs-company/src/main/java/com/fs/company/controller/company/CompanyInboundCallManageController.java
  10. 19 0
      fs-company/src/main/java/com/fs/company/controller/company/CompanyVoiceRoboticController.java
  11. 58 0
      fs-company/src/main/java/com/fs/company/controller/crm/CrmCustomerController.java
  12. 12 7
      fs-company/src/main/java/com/fs/company/controller/live/LiveController.java
  13. 58 58
      fs-company/src/main/java/com/fs/company/controller/live/LiveDataController.java
  14. 69 0
      fs-company/src/main/java/com/fs/company/controller/live/LiveQuestionLiveController.java
  15. 182 0
      fs-company/src/main/java/com/fs/company/controller/live/LiveTrainingCampController.java
  16. 5 1
      fs-company/src/main/java/com/fs/company/controller/qw/QwUserController.java
  17. 1 1
      fs-company/src/main/java/com/fs/framework/config/SecurityConfig.java
  18. 2 1
      fs-company/src/main/java/com/fs/user/FsUserAdminController.java
  19. 96 0
      fs-company/src/main/java/com/fs/xiaoshouyi/client/XiaoShouYiHttpClient.java
  20. 259 0
      fs-company/src/main/java/com/fs/xiaoshouyi/controller/XiaoShouYiController.java
  21. 131 0
      fs-company/src/main/java/com/fs/xiaoshouyi/controller/XsyAccountController.java
  22. 61 0
      fs-company/src/main/java/com/fs/xiaoshouyi/controller/XsyBindController.java
  23. 3 0
      fs-framework/src/main/java/com/fs/framework/config/SecurityConfig.java
  24. 94 0
      fs-live-app/src/main/java/com/fs/live/websocket/service/WebSocketServer.java
  25. 43 0
      fs-service/src/main/java/com/fs/company/domain/CompanySiptaskInfo.java
  26. 5 1
      fs-service/src/main/java/com/fs/company/domain/CompanyVoiceRoboticCallees.java
  27. 5 0
      fs-service/src/main/java/com/fs/company/domain/CompanyWxAccount.java
  28. 3 0
      fs-service/src/main/java/com/fs/company/mapper/CompanyAiWorkflowExecMapper.java
  29. 2 1
      fs-service/src/main/java/com/fs/company/mapper/CompanyMapper.java
  30. 64 0
      fs-service/src/main/java/com/fs/company/mapper/CompanySiptaskInfoMapper.java
  31. 22 7
      fs-service/src/main/java/com/fs/company/mapper/CompanyVoiceRoboticCallLogCallphoneMapper.java
  32. 2 0
      fs-service/src/main/java/com/fs/company/mapper/CompanyWxAccountMapper.java
  33. 2 0
      fs-service/src/main/java/com/fs/company/mapper/CompanyWxClientMapper.java
  34. 162 0
      fs-service/src/main/java/com/fs/company/mapper/EasyCallInboundLlmMapper.java
  35. 18 0
      fs-service/src/main/java/com/fs/company/param/AddWxActionParam.java
  36. 7 0
      fs-service/src/main/java/com/fs/company/service/CompanyWorkflowEngine.java
  37. 69 0
      fs-service/src/main/java/com/fs/company/service/ICompanyInboundCallManageService.java
  38. 61 0
      fs-service/src/main/java/com/fs/company/service/ICompanySiptaskInfoService.java
  39. 13 7
      fs-service/src/main/java/com/fs/company/service/ICompanyVoiceRoboticCallLogCallphoneService.java
  40. 2 0
      fs-service/src/main/java/com/fs/company/service/ICompanyWxClientService.java
  41. 99 0
      fs-service/src/main/java/com/fs/company/service/impl/CompanyInboundCallManageServiceImpl.java
  42. 91 0
      fs-service/src/main/java/com/fs/company/service/impl/CompanySiptaskInfoServiceImpl.java
  43. 60 13
      fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticCallLogCallphoneServiceImpl.java
  44. 149 26
      fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticServiceImpl.java
  45. 73 9
      fs-service/src/main/java/com/fs/company/service/impl/CompanyWorkflowEngineImpl.java
  46. 5 0
      fs-service/src/main/java/com/fs/company/service/impl/CompanyWxClientServiceImpl.java
  47. 57 34
      fs-service/src/main/java/com/fs/company/service/impl/CompanyWxServiceImpl.java
  48. 23 0
      fs-service/src/main/java/com/fs/company/service/impl/call/node/AbstractWorkflowNode.java
  49. 309 0
      fs-service/src/main/java/com/fs/company/service/impl/call/node/AiAddWxTaskNewNode.java
  50. 73 37
      fs-service/src/main/java/com/fs/company/service/impl/call/node/AiCallTaskNode.java
  51. 15 14
      fs-service/src/main/java/com/fs/company/service/impl/call/node/EndNode.java
  52. 3 0
      fs-service/src/main/java/com/fs/company/service/impl/call/node/WorkflowNodeFactory.java
  53. 14 0
      fs-service/src/main/java/com/fs/company/vo/CalleeRoboticCallOutCountVO.java
  54. 6 0
      fs-service/src/main/java/com/fs/company/vo/CompanyUserQwListVO.java
  55. 2 0
      fs-service/src/main/java/com/fs/company/vo/CompanyVO.java
  56. 14 0
      fs-service/src/main/java/com/fs/company/vo/CustomerRoboticCallOutCountVO.java
  57. 14 0
      fs-service/src/main/java/com/fs/company/vo/easycall/EasyCallBizGroupVO.java
  58. 75 0
      fs-service/src/main/java/com/fs/company/vo/easycall/EasyCallInboundLlmVO.java
  59. 14 0
      fs-service/src/main/java/com/fs/company/vo/easycall/EasyCallIvrVO.java
  60. 5 0
      fs-service/src/main/java/com/fs/course/config/CourseConfig.java
  61. 16 2
      fs-service/src/main/java/com/fs/course/param/FsCourseSendRewardUParam.java
  62. 30 0
      fs-service/src/main/java/com/fs/course/param/LiveQuizSubmitUParam.java
  63. 6 0
      fs-service/src/main/java/com/fs/course/service/IFsCourseQuestionBankService.java
  64. 2 1
      fs-service/src/main/java/com/fs/course/service/IFsUserCourseService.java
  65. 82 0
      fs-service/src/main/java/com/fs/course/service/impl/FsCourseQuestionBankServiceImpl.java
  66. 9 3
      fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseServiceImpl.java
  67. 5 5
      fs-service/src/main/java/com/fs/crm/service/impl/CrmCustomerAnalyzeServiceImpl.java
  68. 2 0
      fs-service/src/main/java/com/fs/crm/vo/CrmCustomerListQueryVO.java
  69. 2 0
      fs-service/src/main/java/com/fs/crm/vo/CrmLineCustomerListQueryVO.java
  70. 2 0
      fs-service/src/main/java/com/fs/crm/vo/CrmMyCustomerListQueryVO.java
  71. 5 1
      fs-service/src/main/java/com/fs/enums/NodeTypeEnum.java
  72. 10 0
      fs-service/src/main/java/com/fs/his/config/CidPhoneConfig.java
  73. 2 0
      fs-service/src/main/java/com/fs/his/dto/SendResultDetailDTO.java
  74. 2 0
      fs-service/src/main/java/com/fs/hisStore/vo/StoreOperMainVO.java
  75. 28 0
      fs-service/src/main/java/com/fs/ipad/WxIpadSendUtils.java
  76. 20 0
      fs-service/src/main/java/com/fs/ipad/vo/WxBaseVo.java
  77. 11 0
      fs-service/src/main/java/com/fs/ipad/vo/WxTxtVo.java
  78. 14 1
      fs-service/src/main/java/com/fs/live/domain/Live.java
  79. 22 0
      fs-service/src/main/java/com/fs/live/domain/LiveCourseQuestionRel.java
  80. 37 0
      fs-service/src/main/java/com/fs/live/domain/LiveTrainingCamp.java
  81. 52 0
      fs-service/src/main/java/com/fs/live/domain/LiveTrainingPeriod.java
  82. 28 0
      fs-service/src/main/java/com/fs/live/mapper/LiveCourseQuestionRelMapper.java
  83. 8 0
      fs-service/src/main/java/com/fs/live/mapper/LiveMapper.java
  84. 25 0
      fs-service/src/main/java/com/fs/live/mapper/LiveTrainingCampMapper.java
  85. 27 0
      fs-service/src/main/java/com/fs/live/mapper/LiveTrainingPeriodMapper.java
  86. 18 0
      fs-service/src/main/java/com/fs/live/param/LiveTrainingLiveAuditBody.java
  87. 21 0
      fs-service/src/main/java/com/fs/live/service/ILiveCourseQuestionRelService.java
  88. 6 0
      fs-service/src/main/java/com/fs/live/service/ILiveService.java
  89. 27 0
      fs-service/src/main/java/com/fs/live/service/ILiveTrainingCampService.java
  90. 34 0
      fs-service/src/main/java/com/fs/live/service/ILiveTrainingPeriodService.java
  91. 90 0
      fs-service/src/main/java/com/fs/live/service/impl/LiveCourseQuestionRelServiceImpl.java
  92. 4 0
      fs-service/src/main/java/com/fs/live/service/impl/LiveServiceImpl.java
  93. 88 0
      fs-service/src/main/java/com/fs/live/service/impl/LiveTrainingCampServiceImpl.java
  94. 185 0
      fs-service/src/main/java/com/fs/live/service/impl/LiveTrainingPeriodServiceImpl.java
  95. 2 0
      fs-service/src/main/java/com/fs/live/vo/DateRange.java
  96. 34 0
      fs-service/src/main/java/com/fs/live/vo/TrainingLiveAuditVO.java
  97. 2 0
      fs-service/src/main/java/com/fs/qw/vo/QwSopCourseFinishTempSetting.java
  98. 175 0
      fs-service/src/main/java/com/fs/sop/service/impl/SopUserLogsInfoServiceImpl.java
  99. 6 0
      fs-service/src/main/java/com/fs/wx/sop/domain/WxSopLogs.java
  100. 4 0
      fs-service/src/main/java/com/fs/wx/sop/mapper/WxSopLogsMapper.java

+ 28 - 1
fs-admin/src/main/java/com/fs/company/controller/CompanyVoiceRoboticController.java

@@ -10,16 +10,21 @@ import com.fs.common.annotation.Log;
 import com.fs.common.core.controller.BaseController;
 import com.fs.common.core.domain.AjaxResult;
 import com.fs.common.core.domain.R;
+import com.fs.common.core.domain.model.LoginUser;
 import com.fs.common.core.page.TableDataInfo;
 import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.ServletUtils;
 import com.fs.common.utils.StringUtils;
 import com.fs.common.utils.poi.ExcelUtil;
 import com.fs.company.domain.CompanyVoiceRobotic;
 import com.fs.company.domain.CompanyVoiceRoboticCallees;
 import com.fs.company.domain.CompanyVoiceRoboticWx;
+import com.fs.company.service.ICompanyVoiceRoboticCallLogCallphoneService;
 import com.fs.company.service.ICompanyVoiceRoboticCalleesService;
 import com.fs.company.service.ICompanyVoiceRoboticService;
 import com.fs.company.service.ICompanyVoiceRoboticWxService;
+import com.fs.company.vo.CalleeRoboticCallOutCountVO;
+import com.fs.framework.web.service.TokenService;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.transaction.annotation.Transactional;
@@ -27,11 +32,13 @@ import org.springframework.web.bind.annotation.*;
 
 import java.util.Arrays;
 import java.util.List;
+import java.util.Map;
+import java.util.Objects;
 import java.util.stream.Collectors;
 
 /**
  * 机器人外呼任务Controller
- * 
+ *
  * @author fs
  * @date 2024-12-04
  */
@@ -47,6 +54,10 @@ public class CompanyVoiceRoboticController extends BaseController
     private AiCallService aiCallService;
     @Autowired
     private ICompanyVoiceRoboticWxService companyVoiceRoboticWxService;
+    @Autowired
+    private TokenService tokenService;
+    @Autowired
+    private ICompanyVoiceRoboticCallLogCallphoneService companyVoiceRoboticCallLogCallphoneService;
 
     /**
      * 查询机器人外呼任务列表
@@ -71,6 +82,22 @@ public class CompanyVoiceRoboticController extends BaseController
     public TableDataInfo calleesList(Long id){
         startPage();
         List<CompanyVoiceRoboticCallees> list = companyVoiceRoboticCalleesService.selectCompanyVoiceRoboticCalleesListByRoboticId(id);
+        if (list != null && !list.isEmpty() && id != null) {
+            Long companyId = null;
+            LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+            if (loginUser != null && loginUser.getUser() != null) {
+                companyId = loginUser.getUser().getCompanyId();
+            }
+            List<Long> calleeIds = list.stream().map(CompanyVoiceRoboticCallees::getId).filter(Objects::nonNull).distinct().collect(Collectors.toList());
+            if (!calleeIds.isEmpty()) {
+                Map<Long, Long> countMap = companyVoiceRoboticCallLogCallphoneService.countRoboticCallOutByCalleeIds(calleeIds, id, companyId).stream()
+                        .collect(Collectors.toMap(CalleeRoboticCallOutCountVO::getCalleeId, CalleeRoboticCallOutCountVO::getCallCount, (a, b) -> a));
+                for (CompanyVoiceRoboticCallees row : list) {
+                    long n = row.getId() == null ? 0L : countMap.getOrDefault(row.getId(), 0L);
+                    row.setRoboticCallOutCount((int) n);
+                }
+            }
+        }
         return getDataTable(list);
     }
 

+ 2 - 0
fs-admin/src/main/java/com/fs/live/controller/LiveController.java

@@ -61,6 +61,7 @@ public class LiveController extends BaseController {
     @GetMapping("/list")
     public TableDataInfo list(Live live) {
         startPage();
+        live.setExcludeCampLive(true);
         List<Live> list = liveService.selectLiveList(live);
         return getDataTable(list);
     }
@@ -72,6 +73,7 @@ public class LiveController extends BaseController {
     @Log(title = "直播", businessType = BusinessType.EXPORT)
     @GetMapping("/export")
     public AjaxResult export(Live live) {
+        live.setExcludeCampLive(true);
         List<Live> list = liveService.selectLiveList(live);
         ExcelUtil<Live> util = new ExcelUtil<Live>(Live.class);
         return util.exportExcel(list, "直播数据");

+ 89 - 0
fs-admin/src/main/java/com/fs/live/controller/LiveQuestionLiveController.java

@@ -0,0 +1,89 @@
+package com.fs.live.controller;
+
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.live.domain.Live;
+import com.fs.live.service.ILiveCourseQuestionRelService;
+import com.fs.live.service.ILiveService;
+import com.fs.live.vo.LiveQuestionLiveVO;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * 总后台:直播间答题配置(关联课程题库),与企业端能力一致,按直播间解析所属企业。
+ */
+@RestController
+@RequestMapping("/live/liveQuestionLive")
+public class LiveQuestionLiveController extends BaseController {
+
+    @Autowired
+    private ILiveCourseQuestionRelService liveCourseQuestionRelService;
+    @Autowired
+    private ILiveService liveService;
+
+    @PreAuthorize("@ss.hasPermi('live:live:query')")
+    @GetMapping("/list")
+    public TableDataInfo list(@RequestParam Long liveId) {
+        Long companyId = requireCompanyIdByLiveId(liveId);
+        if (companyId == null) {
+            return getDataTable(Collections.emptyList());
+        }
+        startPage();
+        List<LiveQuestionLiveVO> list = liveCourseQuestionRelService.selectLinkedByLiveId(liveId, companyId);
+        return getDataTable(list);
+    }
+
+    @PreAuthorize("@ss.hasPermi('live:live:query')")
+    @GetMapping("/optionList")
+    public TableDataInfo optionList(@RequestParam Long liveId,
+                                    @RequestParam(required = false) String title,
+                                    @RequestParam(required = false) Integer type) {
+        Long companyId = requireCompanyIdByLiveId(liveId);
+        if (companyId == null) {
+            return getDataTable(Collections.emptyList());
+        }
+        startPage();
+        List<LiveQuestionLiveVO> list = liveCourseQuestionRelService.selectOptionQuestionBank(
+                liveId, companyId, title, type);
+        return getDataTable(list);
+    }
+
+    @PreAuthorize("@ss.hasPermi('live:live:edit')")
+    @Log(title = "直播间试题", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestParam Long liveId, @RequestParam String questionIds) {
+        Long companyId = requireCompanyIdByLiveId(liveId);
+        if (companyId == null) {
+            return AjaxResult.error("直播间不存在");
+        }
+        int rows = liveCourseQuestionRelService.batchAdd(liveId, companyId, questionIds);
+        return rows > 0 ? AjaxResult.success("添加成功") : AjaxResult.success("无新增(可能已全部存在)");
+    }
+
+    @PreAuthorize("@ss.hasPermi('live:live:edit')")
+    @Log(title = "直播间试题", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{liveId}")
+    public AjaxResult remove(@PathVariable Long liveId, @RequestParam String ids) {
+        Long companyId = requireCompanyIdByLiveId(liveId);
+        if (companyId == null) {
+            return AjaxResult.error("直播间不存在");
+        }
+        int rows = liveCourseQuestionRelService.deleteByRelIds(liveId, companyId, ids);
+        return toAjax(rows);
+    }
+
+    private Long requireCompanyIdByLiveId(Long liveId) {
+        if (liveId == null) {
+            return null;
+        }
+        Live live = liveService.selectLiveDbByLiveId(liveId);
+        return live != null ? live.getCompanyId() : null;
+    }
+}

+ 224 - 0
fs-admin/src/main/java/com/fs/live/controller/LiveTrainingCampAdminController.java

@@ -0,0 +1,224 @@
+package com.fs.live.controller;
+
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.StringUtils;
+import com.fs.company.domain.CompanyUser;
+import com.fs.company.mapper.CompanyUserMapper;
+import com.fs.live.domain.Live;
+import com.fs.live.domain.LiveTrainingCamp;
+import com.fs.live.domain.LiveTrainingPeriod;
+import com.fs.live.param.LiveTrainingLiveAuditBody;
+import com.fs.live.service.ILiveService;
+import com.fs.live.service.ILiveTrainingCampService;
+import com.fs.live.service.ILiveTrainingPeriodService;
+import com.fs.live.vo.TrainingLiveAuditVO;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * 总后台:直播训练营 / 营期 / 营期直播间(跨企业)及训练营直播间审核
+ */
+@RestController
+@RequestMapping("/live/trainingCamp/admin")
+public class LiveTrainingCampAdminController extends BaseController {
+
+    @Autowired
+    private ILiveTrainingCampService liveTrainingCampService;
+    @Autowired
+    private ILiveTrainingPeriodService liveTrainingPeriodService;
+    @Autowired
+    private ILiveService liveService;
+    @Autowired
+    private CompanyUserMapper companyUserMapper;
+
+    // ---------- 训练营 ----------
+
+    @PreAuthorize("@ss.hasPermi('live:trainingCampAdmin:list')")
+    @GetMapping("/camp/list")
+    public TableDataInfo campList(LiveTrainingCamp query) {
+        startPage();
+        List<LiveTrainingCamp> list = liveTrainingCampService.selectLiveTrainingCampListAdmin(query);
+        return getDataTable(list);
+    }
+
+    @PreAuthorize("@ss.hasPermi('live:trainingCampAdmin:query')")
+    @GetMapping("/camp/{campId}")
+    public AjaxResult getCamp(@PathVariable Long campId, @RequestParam(required = false) Long companyId) {
+        LiveTrainingCamp c = liveTrainingCampService.selectLiveTrainingCampByIdForAdmin(campId, companyId);
+        return c != null ? AjaxResult.success(c) : AjaxResult.error("训练营不存在或无权限");
+    }
+
+    @PreAuthorize("@ss.hasPermi('live:trainingCampAdmin:add')")
+    @Log(title = "总后台直播训练营", businessType = BusinessType.INSERT)
+    @PostMapping("/camp")
+    public AjaxResult addCamp(@RequestBody LiveTrainingCamp camp) {
+        if (camp.getCompanyId() == null) {
+            return AjaxResult.error("请选择企业");
+        }
+        camp.setCreateBy(getUsername());
+        camp.setCreateTime(new java.util.Date());
+        if (camp.getSortOrder() == null) {
+            camp.setSortOrder(0);
+        }
+        if (camp.getStatus() == null) {
+            camp.setStatus(0);
+        }
+        return toAjax(liveTrainingCampService.insertLiveTrainingCamp(camp));
+    }
+
+    @PreAuthorize("@ss.hasPermi('live:trainingCampAdmin:edit')")
+    @Log(title = "总后台直播训练营", businessType = BusinessType.UPDATE)
+    @PutMapping("/camp")
+    public AjaxResult editCamp(@RequestBody LiveTrainingCamp camp) {
+        if (camp.getCampId() == null || camp.getCompanyId() == null) {
+            return AjaxResult.error("参数不完整");
+        }
+        camp.setUpdateBy(getUsername());
+        camp.setUpdateTime(new java.util.Date());
+        return toAjax(liveTrainingCampService.updateLiveTrainingCamp(camp));
+    }
+
+    @PreAuthorize("@ss.hasPermi('live:trainingCampAdmin:remove')")
+    @Log(title = "总后台直播训练营", businessType = BusinessType.DELETE)
+    @DeleteMapping("/camp/{campIds}")
+    public AjaxResult removeCamp(@PathVariable Long[] campIds, @RequestParam Long companyId) {
+        return toAjax(liveTrainingCampService.deleteLiveTrainingCampByIds(campIds, companyId, null));
+    }
+
+    // ---------- 营期 ----------
+
+    @PreAuthorize("@ss.hasPermi('live:trainingCampAdmin:list')")
+    @GetMapping("/period/list")
+    public TableDataInfo periodList(LiveTrainingPeriod query) {
+        startPage();
+        List<LiveTrainingPeriod> list = liveTrainingPeriodService.selectLiveTrainingPeriodListAdmin(query);
+        return getDataTable(list);
+    }
+
+    @PreAuthorize("@ss.hasPermi('live:trainingCampAdmin:query')")
+    @GetMapping("/period/{periodId}")
+    public AjaxResult getPeriod(@PathVariable Long periodId, @RequestParam(required = false) Long companyId) {
+        LiveTrainingPeriod p = liveTrainingPeriodService.selectLiveTrainingPeriodByIdForAdmin(periodId, companyId);
+        if (p == null) {
+            return AjaxResult.error("营期不存在或无权限");
+        }
+        LiveTrainingCamp camp = liveTrainingCampService.selectLiveTrainingCampByIdForAdmin(p.getCampId(), null);
+        if (camp != null) {
+            p.setCompanyId(camp.getCompanyId());
+        }
+        return AjaxResult.success(p);
+    }
+
+    @PreAuthorize("@ss.hasPermi('live:trainingCampAdmin:add')")
+    @Log(title = "总后台直播营期", businessType = BusinessType.INSERT)
+    @PostMapping("/period")
+    public AjaxResult addPeriod(@RequestBody LiveTrainingPeriod period) {
+        if (period.getCampId() == null) {
+            return AjaxResult.error("请选择训练营");
+        }
+        LiveTrainingCamp camp = liveTrainingCampService.selectLiveTrainingCampByIdForAdmin(period.getCampId(), null);
+        if (camp == null) {
+            return AjaxResult.error("训练营不存在");
+        }
+        period.setCompanyId(camp.getCompanyId());
+        period.setCreateBy(camp.getCreateBy());
+        period.setCreateTime(new java.util.Date());
+        if (period.getSortOrder() == null) {
+            period.setSortOrder(0);
+        }
+        if (period.getStatus() == null) {
+            period.setStatus(0);
+        }
+        return toAjax(liveTrainingPeriodService.insertLiveTrainingPeriod(period));
+    }
+
+    @PreAuthorize("@ss.hasPermi('live:trainingCampAdmin:edit')")
+    @Log(title = "总后台直播营期", businessType = BusinessType.UPDATE)
+    @PutMapping("/period")
+    public AjaxResult editPeriod(@RequestBody LiveTrainingPeriod period) {
+        if (period.getPeriodId() == null || period.getCompanyId() == null) {
+            return AjaxResult.error("参数不完整");
+        }
+        period.setUpdateBy(getUsername());
+        period.setUpdateTime(new java.util.Date());
+        return toAjax(liveTrainingPeriodService.updateLiveTrainingPeriod(period));
+    }
+
+    @PreAuthorize("@ss.hasPermi('live:trainingCampAdmin:remove')")
+    @Log(title = "总后台直播营期", businessType = BusinessType.DELETE)
+    @DeleteMapping("/period/{periodIds}")
+    public AjaxResult removePeriod(@PathVariable Long[] periodIds, @RequestParam Long companyId) {
+        return toAjax(liveTrainingPeriodService.deleteLiveTrainingPeriodByIds(periodIds, companyId, null));
+    }
+
+    // ---------- 营期下直播间 ----------
+
+    @PreAuthorize("@ss.hasPermi('live:trainingCampAdmin:list')")
+    @GetMapping("/live/list")
+    public TableDataInfo trainingLiveList(Live query, @RequestParam Long companyId) {
+        if (query.getTrainingPeriodId() == null) {
+            return getDataTable(Collections.emptyList());
+        }
+        LiveTrainingPeriod p = liveTrainingPeriodService.selectLiveTrainingPeriodByIdForAdmin(
+                query.getTrainingPeriodId(), companyId);
+        if (p == null) {
+            return getDataTable(Collections.emptyList());
+        }
+        query.setCompanyId(companyId);
+        List<CompanyUser> users = companyUserMapper.selectCompanyUserByCompanyId(companyId);
+        if (users != null && !users.isEmpty()) {
+            query.setCompanyUserId(users.get(0).getUserId());
+        }
+        startPage();
+        List<Live> list = liveService.selectLiveList(query);
+        return getDataTable(list);
+    }
+
+    @PreAuthorize("@ss.hasPermi('live:trainingCampAdmin:live:add')")
+    @Log(title = "总后台训练营直播间", businessType = BusinessType.INSERT)
+    @PostMapping("/live")
+    public AjaxResult addTrainingLive(@RequestBody Live live, @RequestParam Long companyId) {
+        if (StringUtils.isEmpty(live.getCreateBy())) {
+            live.setCreateBy(String.valueOf(getUserId()));
+        }
+        return toAjax(liveTrainingPeriodService.insertTrainingLiveForAdmin(live, companyId));
+    }
+
+    // ---------- 训练营直播间审核 ----------
+
+    @PreAuthorize("@ss.hasPermi('live:trainingCampAdmin:audit:list')")
+    @GetMapping("/live/auditList")
+    public TableDataInfo auditList(Live query) {
+        startPage();
+        List<TrainingLiveAuditVO> list = liveService.selectTrainingLiveAuditList(query);
+        return getDataTable(list);
+    }
+
+    @PreAuthorize("@ss.hasPermi('live:trainingCampAdmin:audit:edit')")
+    @Log(title = "训练营直播间审核", businessType = BusinessType.UPDATE)
+    @PutMapping("/live/audit")
+    public AjaxResult audit(@RequestBody LiveTrainingLiveAuditBody body) {
+        if (body == null || body.getLiveId() == null || body.getPassed() == null) {
+            return AjaxResult.error("参数不完整");
+        }
+        Live db = liveService.selectLiveDbByLiveId(body.getLiveId());
+        if (db == null || db.getTrainingPeriodId() == null) {
+            return AjaxResult.error("仅支持训练营直播间审核");
+        }
+        Live upd = new Live();
+        upd.setLiveId(body.getLiveId());
+        upd.setIsAudit(Boolean.TRUE.equals(body.getPassed()) ? 1 : 2);
+        if (StringUtils.isNotEmpty(body.getRemark())) {
+            upd.setRemark(body.getRemark());
+        }
+        return toAjax(liveService.updateLive(upd));
+    }
+}

+ 259 - 0
fs-admin/src/main/java/com/fs/xiaoshouyi/controller/XiaoShouYiController.java

@@ -0,0 +1,259 @@
+package com.fs.xiaoshouyi.controller;
+
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.xiaoshouyi.dto.*;
+import com.fs.xiaoshouyi.service.XiaoShouYiMaterialService;
+import com.fs.xiaoshouyi.service.XiaoShouYiOAuthService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.util.StringUtils;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+import org.springframework.web.servlet.view.RedirectView;
+
+import java.io.File;
+
+@Slf4j
+@RestController
+@RequestMapping("/xiaoShouYi")
+@RequiredArgsConstructor
+public class XiaoShouYiController {
+
+    private final XiaoShouYiOAuthService oAuthService;
+    private final XiaoShouYiMaterialService materialService;
+
+
+    /**
+     * 获取授权URL
+     */
+    @PreAuthorize("@ss.hasPermi('xsy:auth:url')")
+    @GetMapping("/auth/url/{accountId}")
+    public AjaxResult getAuthUrl(@PathVariable Long accountId) {
+        return AjaxResult.success(oAuthService.buildAuthUrl(accountId));
+    }
+
+    /**
+     * 授权回调
+     */
+    @GetMapping("/auth/callback/{accountId}")
+    public RedirectView authCallback(@PathVariable Long accountId,
+                                     @RequestParam("code") String code) {
+
+        try {
+            TokenResponse tokenResp = oAuthService.exchangeCodeForToken(accountId, code);
+
+            if (tokenResp.isSuccess()) {
+                return new RedirectView("/xiaoShouYi/success");
+            }
+
+            return new RedirectView("/xiaoShouYi/error?msg=" + tokenResp.getError());
+
+        } catch (Exception e) {
+            log.error("授权回调异常 accountId={}", accountId, e);
+            return new RedirectView("/xiaoShouYi/error?msg=" + e.getMessage());
+        }
+    }
+
+
+
+    /**
+     * 生成追踪链接
+     */
+    @PostMapping("/generateLink")
+    public AjaxResult generateLink(@RequestParam Long companyUserId,
+                                   @Validated @RequestBody GenerateLinkRequest request) {
+
+        try {
+            if (request.getMaterialIds() == null || request.getMaterialIds().isEmpty()) {
+                return AjaxResult.error("materialIds不能为空");
+            }
+
+            GenerateLinkResponse response = materialService.generateMaterialTrackLink(
+                                                                    companyUserId,
+                                                                    request.getMaterialIds(),
+                                                                    request.getSendUserId());
+
+            return response.isSuccess()
+                    ? AjaxResult.success(response)
+                    : AjaxResult.error(response.getMsg());
+
+        } catch (Exception e) {
+            log.error("生成追踪链接异常", e);
+            return AjaxResult.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 发送确认
+     */
+    @PostMapping("/sendConfirm")
+    public AjaxResult sendConfirm(@RequestParam Long companyUserId,
+                                  @RequestParam Long forwardId,
+                                  @RequestParam Integer forwardType) {
+
+        try {
+            SendConfirmResponse response =
+                    materialService.sendConfirmAuto(companyUserId, forwardId, forwardType);
+
+            return response.isSuccess()
+                    ? AjaxResult.success(response)
+                    : AjaxResult.error(response.getMsg());
+
+        } catch (Exception e) {
+            log.error("发送确认异常", e);
+            return AjaxResult.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 查询素材
+     */
+    @PostMapping("/materials/query")
+    public AjaxResult queryMaterials(@RequestParam Long companyUserId,
+                                     @RequestBody QueryMaterialRequest request) {
+
+        try {
+            QueryMaterialResponse response =
+                    materialService.queryMaterialsAuto(companyUserId, request);
+
+            if (!Boolean.TRUE.equals(response.getSuccess())) {
+                return AjaxResult.error(response.getMsg());
+            }
+
+            return AjaxResult.success(response.getData());
+
+        } catch (Exception e) {
+            log.error("查询素材异常", e);
+            return AjaxResult.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 上传素材文件
+     */
+    @PostMapping("/uploadFile")
+    public AjaxResult uploadFile(@RequestParam Long companyUserId,
+                                 @RequestParam("file") MultipartFile multipartFile,
+                                 @RequestParam(defaultValue = "false") Boolean isVideo) {
+
+        if (multipartFile == null || multipartFile.isEmpty()) {
+            return AjaxResult.error("文件不能为空");
+        }
+
+        File tempFile = null;
+        try {
+            // 保留文件后缀
+            String suffix = getSuffix(multipartFile.getOriginalFilename());
+            tempFile = File.createTempFile("xsy_", suffix);
+
+            multipartFile.transferTo(tempFile);
+
+            UploadMaterialFileResponse response =
+                    materialService.uploadMaterialFileAuto(companyUserId, tempFile, isVideo);
+
+            if (!Boolean.TRUE.equals(response.getSuccess())) {
+                return AjaxResult.error(response.getMsg());
+            }
+
+            return AjaxResult.success(response.getData());
+
+        } catch (Exception e) {
+            log.error("上传素材异常", e);
+            return AjaxResult.error(e.getMessage());
+        } finally {
+            if (tempFile != null && tempFile.exists()) {
+                tempFile.delete();
+            }
+        }
+    }
+
+    /**
+     * 创建素材
+     */
+    @PostMapping("/createMaterial")
+    public AjaxResult createMaterial(@RequestParam Long companyUserId,
+                                     @RequestBody CreateMaterialRequest request) {
+
+        try {
+            CreateMaterialResponse response =
+                    materialService.createMaterialAuto(companyUserId, request);
+
+            return response.getSuccess()
+                    ? AjaxResult.success(response)
+                    : AjaxResult.error(response.getMsg());
+
+        } catch (Exception e) {
+            log.error("创建素材异常", e);
+            return AjaxResult.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 上传 + 创建素材
+     */
+    @PostMapping("/createMaterialWithUpload")
+    public AjaxResult createMaterialWithUpload(@RequestParam Long companyUserId,
+                                               @RequestParam("file") MultipartFile file,
+                                               @RequestParam(defaultValue = "false") Boolean isVideo,
+                                               @RequestParam String corpName,
+                                               @RequestParam Integer materialType,
+                                               @RequestParam String categoryName,
+                                               @RequestParam String title) {
+
+        File tempFile = null;
+
+        try {
+            if (!StringUtils.hasText(corpName) || !StringUtils.hasText(categoryName) || !StringUtils.hasText(title)) {
+                return AjaxResult.error("必填参数不能为空");
+            }
+
+            String suffix = getSuffix(file.getOriginalFilename());
+            tempFile = File.createTempFile("xsy_", suffix);
+            file.transferTo(tempFile);
+
+            CreateMaterialRequest req = new CreateMaterialRequest();
+            req.setCorpName(corpName);
+            req.setMaterialType(materialType);
+            req.setCategoryName(categoryName);
+            req.setTitle(title);
+
+            CreateMaterialWithUploadResponse response =
+                    materialService.createMaterialWithUploadAuto(
+                            companyUserId, tempFile, isVideo, req);
+
+            return AjaxResult.success(response);
+
+        } catch (Exception e) {
+            log.error("上传+创建素材异常", e);
+            return AjaxResult.error(e.getMessage());
+        } finally {
+            if (tempFile != null && tempFile.exists()) {
+                tempFile.delete();
+            }
+        }
+    }
+
+
+
+    @GetMapping("/success")
+    public String success() {
+        return "授权成功";
+    }
+
+    @GetMapping("/error")
+    public String error(String msg) {
+        return "授权失败: " + msg;
+    }
+
+
+
+    private String getSuffix(String fileName) {
+        if (!StringUtils.hasText(fileName)) {
+            return ".tmp";
+        }
+        int index = fileName.lastIndexOf(".");
+        return index > 0 ? fileName.substring(index) : ".tmp";
+    }
+}

+ 113 - 0
fs-admin/src/main/java/com/fs/xiaoshouyi/controller/XsyAccountController.java

@@ -0,0 +1,113 @@
+package com.fs.xiaoshouyi.controller;
+
+import cn.hutool.core.lang.Snowflake;
+import cn.hutool.core.util.IdUtil;
+import cn.hutool.core.util.ObjectUtil;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.utils.StringUtils;
+import com.fs.xiaoshouyi.domain.XsyAccount;
+import com.fs.xiaoshouyi.service.XsyAccountService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 销售易账号管理
+ */
+@RestController
+@RequestMapping("/xsy/account")
+@RequiredArgsConstructor
+public class XsyAccountController extends BaseController {
+
+    private final XsyAccountService xsyAccountService;
+
+
+    /**
+     * 分页查询账号列表(总后台)
+     */
+    @PreAuthorize("@ss.hasPermi('xsy:account:query')")
+    @GetMapping("/list")
+    public TableDataInfo list(XsyAccount query) {
+        startPage();
+        List<XsyAccount> list = xsyAccountService.selectList(query);
+        return getDataTable(list);
+    }
+
+
+
+    /**
+     * 查询详情
+     */
+    @PreAuthorize("@ss.hasPermi('xsy:account:query')")
+    @GetMapping("/get/{id}")
+    public AjaxResult get(@PathVariable Long id) {
+        return AjaxResult.success(xsyAccountService.selectById(id));
+    }
+
+    /**
+     * 新增
+     */
+    @PreAuthorize("@ss.hasPermi('xsy:account:add')")
+    @PostMapping("/add")
+    public AjaxResult add(@RequestBody XsyAccount account) {
+        String uri = account.getRedirectUri();
+        Snowflake snowflake = IdUtil.getSnowflake();
+        Long id = snowflake.nextId();
+        account.setId(id);
+
+        if (StringUtils.isNotEmpty(uri) && ObjectUtil.isNotEmpty(id)) {
+            uri = uri.endsWith("/") ? uri.substring(0, uri.length() - 1) : uri;
+            account.setRedirectUri(uri + "/" + id);
+        }
+        xsyAccountService.insert(account);
+        return AjaxResult.success();
+    }
+
+    /**
+     * 修改
+     */
+    @PreAuthorize("@ss.hasPermi('xsy:account:update')")
+    @PostMapping("/update")
+    public AjaxResult update(@RequestBody XsyAccount account) {
+        xsyAccountService.update(account);
+        return AjaxResult.success();
+    }
+
+    /**
+     * 删除
+     */
+    @PreAuthorize("@ss.hasPermi('xsy:account:delete')")
+    @PostMapping("/delete/{id}")
+    public AjaxResult delete(@PathVariable Long id) {
+        xsyAccountService.deleteById(id);
+        return AjaxResult.success();
+    }
+
+    /**
+     * 启用/禁用
+     */
+    @PreAuthorize("@ss.hasPermi('xsy:account:update')")
+    @PostMapping("/status")
+    public AjaxResult updateStatus(@RequestParam Long id,
+                                   @RequestParam Integer status) {
+        XsyAccount account = new XsyAccount();
+        account.setId(id);
+        account.setStatus(status);
+
+        xsyAccountService.update(account);
+        return AjaxResult.success();
+    }
+
+    /**
+     * 获取授权URL
+     */
+    @PreAuthorize("@ss.hasPermi('xsy:account:query')")
+    @GetMapping("/authUrl/{accountId}")
+    public AjaxResult getAuthUrl(@PathVariable Long accountId) {
+        return AjaxResult.success(xsyAccountService.getAuthUrl(accountId));
+    }
+}

+ 36 - 0
fs-admin/src/main/java/com/fs/xiaoshouyi/controller/XsyCompanyBindController.java

@@ -0,0 +1,36 @@
+package com.fs.xiaoshouyi.controller;
+
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.xiaoshouyi.dto.BindCompanyXsyAccountRequest;
+import com.fs.xiaoshouyi.service.XsyCompanyBindService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/xsy/companyBind")
+@RequiredArgsConstructor
+public class XsyCompanyBindController {
+
+    private final XsyCompanyBindService bindService;
+
+    @PreAuthorize("@ss.hasPermi('xsy:companybind:bind')")
+    @PostMapping("/bind")
+    public AjaxResult bind(@RequestBody BindCompanyXsyAccountRequest req) {
+        bindService.bind(req.getCompanyId(), req.getAccountId());
+        return AjaxResult.success();
+    }
+
+    @PreAuthorize("@ss.hasPermi('xsy:companybind:unbind')")
+    @PostMapping("/unbind")
+    public AjaxResult unbind(@RequestBody BindCompanyXsyAccountRequest req) {
+        bindService.unbind(req.getCompanyId(), req.getAccountId());
+        return AjaxResult.success();
+    }
+
+    @PreAuthorize("@ss.hasPermi('xsy:companybind:query')")
+    @GetMapping("/list")
+    public AjaxResult list(@RequestParam Long companyId) {
+        return AjaxResult.success(bindService.getAccountIdsByCompanyId(companyId));
+    }
+}

+ 3 - 2
fs-company-app/src/main/java/com/fs/app/controller/FsUserCourseVideoController.java

@@ -50,6 +50,7 @@ import org.springframework.web.bind.annotation.*;
 
 import java.io.IOException;
 import java.io.InputStream;
+import java.io.UnsupportedEncodingException;
 import java.util.*;
 import java.util.stream.Collectors;
 
@@ -347,7 +348,7 @@ public class FsUserCourseVideoController extends AppBaseController {
 
     @ApiOperation("会员批量发送课程消息 发课")
     @PostMapping("/batchSendCourse")
-    public OpenImResponseDTO batchSendCourse(@RequestBody BatchSendCourseDTO batchSendCourseDTO) throws JsonProcessingException {
+    public OpenImResponseDTO batchSendCourse(@RequestBody BatchSendCourseDTO batchSendCourseDTO) throws JsonProcessingException, UnsupportedEncodingException {
         // 生成看课短链
         FsCourseLinkCreateParam fsCourseLinkCreateParam = new FsCourseLinkCreateParam();
         BeanUtils.copyProperties(batchSendCourseDTO, fsCourseLinkCreateParam);
@@ -368,7 +369,7 @@ public class FsUserCourseVideoController extends AppBaseController {
     }
     @ApiOperation("会员一键催课")
     @PostMapping("/batchUrgeCourse")
-    public OpenImResponseDTO batchUrgeCourse(@RequestBody BatchUrgeCourseDTO batchUrgeCourseDTO) throws JsonProcessingException {
+    public OpenImResponseDTO batchUrgeCourse(@RequestBody BatchUrgeCourseDTO batchUrgeCourseDTO) throws JsonProcessingException, UnsupportedEncodingException {
         // 查询生成短链需要的内容
         Map<String, Object> params = new HashMap<>();
         params.put("logDetailIds", batchUrgeCourseDTO.getImMsgSendDetailId());

+ 222 - 0
fs-company/src/main/java/com/fs/company/controller/company/CompanyInboundCallManageController.java

@@ -0,0 +1,222 @@
+package com.fs.company.controller.company;
+
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.StringUtils;
+import com.fs.company.mapper.EasyCallInboundLlmMapper;
+import com.fs.company.service.ICompanyInboundCallManageService;
+import com.fs.company.vo.easycall.EasyCallBizGroupVO;
+import com.fs.company.vo.easycall.EasyCallGatewayVO;
+import com.fs.company.vo.easycall.EasyCallInboundLlmVO;
+import com.fs.company.vo.easycall.EasyCallIvrVO;
+import com.fs.company.vo.easycall.EasyCallLlmAccountVO;
+import com.fs.company.vo.easycall.EasyCallVoiceCodeVO;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 呼入大模型配置 Controller
+ *
+ * @author fs
+ */
+@RestController
+@RequestMapping("/company/inboundCallManage")
+public class CompanyInboundCallManageController extends BaseController {
+
+    @Autowired
+    private ICompanyInboundCallManageService inboundCallManageService;
+
+    @Autowired
+    private EasyCallInboundLlmMapper inboundLlmMapper;
+
+    /**
+     * 查询呼入大模型配置列表
+     */
+    @GetMapping("/list")
+    public TableDataInfo list(EasyCallInboundLlmVO vo) {
+        startPage();
+        List<EasyCallInboundLlmVO> list = inboundCallManageService.selectInboundLlmList(vo);
+        TableDataInfo rspData = getDataTable(list);
+        // 填充关联数据
+        @SuppressWarnings("unchecked")
+        List<EasyCallInboundLlmVO> records = (List<EasyCallInboundLlmVO>) rspData.getRows();
+        for (EasyCallInboundLlmVO data : records) {
+            fillRelationData(data);
+        }
+        rspData.setRows(records);
+        return rspData;
+    }
+
+    /**
+     * 获取大模型账户下拉列表
+     */
+    @GetMapping("/llmAccountList")
+    public AjaxResult getLlmAccountList() {
+        List<EasyCallLlmAccountVO> list = inboundLlmMapper.selectLlmAccountList();
+        return AjaxResult.success(list);
+    }
+
+    /**
+     * 获取呼入大模型配置详细信息
+     */
+    @GetMapping(value = "/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Integer id) {
+        return AjaxResult.success(inboundCallManageService.selectInboundLlmById(id));
+    }
+
+    /**
+     * 新增呼入大模型配置
+     */
+    @Log(title = "呼入大模型配置", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody EasyCallInboundLlmVO vo) {
+        return toAjax(inboundCallManageService.insertInboundLlm(vo));
+    }
+
+    /**
+     * 修改呼入大模型配置
+     */
+    @Log(title = "呼入大模型配置", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody EasyCallInboundLlmVO vo) {
+        return toAjax(inboundCallManageService.updateInboundLlm(vo));
+    }
+
+    /**
+     * 删除呼入大模型配置
+     */
+    @Log(title = "呼入大模型配置", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable String ids) {
+        return toAjax(inboundCallManageService.deleteInboundLlmByIds(ids));
+    }
+
+    /**
+     * 校验被叫号码是否唯一
+     */
+    @GetMapping("/checkCallee")
+    public AjaxResult checkCallee(@RequestParam(value = "id", required = false) Integer id,
+                                  @RequestParam("callee") String callee) {
+        List<EasyCallInboundLlmVO> list = inboundCallManageService.selectInboundLlmByCallee(callee);
+        if (list.size() <= 0) {
+            return AjaxResult.success(true);
+        }
+        if (null != id && list.get(0).getId().equals(id)) {
+            return AjaxResult.success(true);
+        }
+        return AjaxResult.success(false);
+    }
+
+    /**
+     * 获取所有AI配置列表
+     */
+    @GetMapping("/ai/all")
+    public AjaxResult getAllAi() {
+        EasyCallInboundLlmVO query = new EasyCallInboundLlmVO();
+        query.setServiceType("ai");
+        List<EasyCallInboundLlmVO> list = inboundCallManageService.selectInboundLlmList(query);
+        return AjaxResult.success(list);
+    }
+
+    /**
+     * 获取ASR提供商列表
+     */
+    @GetMapping("/asrProviderList")
+    public AjaxResult getAsrProviderList() {
+        List<Map<String, String>> list = inboundLlmMapper.selectAsrProviderList();
+        // 转换为Map格式
+        Map<String, String> result = new HashMap<>();
+        for (Map<String, String> item : list) {
+            String key = item.get("key");
+            String value = item.get("value");
+            if (key != null) {
+                result.put(key, value != null ? value : key);
+            }
+        }
+        return AjaxResult.success(result);
+    }
+
+    /**
+     * 获取TTS音色来源列表
+     */
+    @GetMapping("/voiceSourceList")
+    public AjaxResult getVoiceSourceList() {
+        List<Map<String, String>> list = inboundLlmMapper.selectVoiceSourceList();
+        // 转换为Map格式
+        Map<String, String> result = new HashMap<>();
+        for (Map<String, String> item : list) {
+            String key = item.get("key");
+            String value = item.get("value");
+            if (key != null) {
+                result.put(key, value != null ? value : key);
+            }
+        }
+        return AjaxResult.success(result);
+    }
+
+    /**
+     * 根据音色来源获取音色列表
+     */
+    @GetMapping("/voiceList")
+    public AjaxResult getVoiceList(@RequestParam("voiceSource") String voiceSource) {
+        List<EasyCallVoiceCodeVO> list = inboundLlmMapper.selectVoiceListBySource(voiceSource);
+        return AjaxResult.success(list);
+    }
+
+    /**
+     * 获取业务组列表
+     */
+    @GetMapping("/bizGroupList")
+    public AjaxResult getBizGroupList() {
+        List<EasyCallBizGroupVO> list = inboundLlmMapper.selectBizGroupList();
+        return AjaxResult.success(list);
+    }
+
+    /**
+     * 获取出局网关列表
+     */
+    @GetMapping("/gatewayList")
+    public AjaxResult getGatewayList() {
+        List<EasyCallGatewayVO> list = inboundLlmMapper.selectOutboundGatewayList();
+        return AjaxResult.success(list);
+    }
+
+    /**
+     * 获取IVR列表
+     */
+    @GetMapping("/ivrList")
+    public AjaxResult getIvrList() {
+        List<EasyCallIvrVO> list = inboundLlmMapper.selectIvrList();
+        return AjaxResult.success(list);
+    }
+
+    /**
+     * 填充关联数据
+     */
+    private void fillRelationData(EasyCallInboundLlmVO data) {
+        // 填充大模型账户名称
+        if (data.getLlmAccountId() != null && data.getLlmAccountId() > 0) {
+            EasyCallLlmAccountVO llmAccount = inboundLlmMapper.selectLlmAccountById(data.getLlmAccountId());
+            if (llmAccount != null) {
+                data.setLlmAccountName(llmAccount.getName());
+            } else {
+                data.setLlmAccountName("");
+            }
+        }
+        // 填充音色名称
+        if (StringUtils.isNotEmpty(data.getVoiceCode())) {
+            EasyCallVoiceCodeVO voiceCode = inboundLlmMapper.selectVoiceCodeByCode(data.getVoiceCode());
+            if (voiceCode != null) {
+                data.setVoiceSource(voiceCode.getVoiceSource());
+                data.setVoiceName(voiceCode.getVoiceName());
+            }
+        }
+    }
+}

+ 19 - 0
fs-company/src/main/java/com/fs/company/controller/company/CompanyVoiceRoboticController.java

@@ -21,9 +21,11 @@ import com.fs.common.utils.poi.ExcelUtil;
 import com.fs.company.domain.CompanyVoiceRobotic;
 import com.fs.company.domain.CompanyVoiceRoboticCallees;
 import com.fs.company.domain.CompanyVoiceRoboticWx;
+import com.fs.company.service.ICompanyVoiceRoboticCallLogCallphoneService;
 import com.fs.company.service.ICompanyVoiceRoboticCalleesService;
 import com.fs.company.service.ICompanyVoiceRoboticService;
 import com.fs.company.service.ICompanyVoiceRoboticWxService;
+import com.fs.company.vo.CalleeRoboticCallOutCountVO;
 import com.fs.company.vo.CdrBodyVo;
 import com.fs.company.vo.CdrDetailVo;
 import com.fs.company.vo.WorkflowExecRecordVo;
@@ -37,6 +39,8 @@ import org.springframework.web.bind.annotation.*;
 
 import java.util.Arrays;
 import java.util.List;
+import java.util.Map;
+import java.util.Objects;
 import java.util.stream.Collectors;
 
 /**
@@ -60,6 +64,8 @@ public class CompanyVoiceRoboticController extends BaseController
     private ICompanyVoiceRoboticWxService companyVoiceRoboticWxService;
     @Autowired
     private TokenService tokenService;
+    @Autowired
+    private ICompanyVoiceRoboticCallLogCallphoneService companyVoiceRoboticCallLogCallphoneService;
 
     /**
      * 查询机器人外呼任务列表
@@ -88,6 +94,19 @@ public class CompanyVoiceRoboticController extends BaseController
     public TableDataInfo calleesList(Long id){
         startPage();
         List<CompanyVoiceRoboticCallees> list = companyVoiceRoboticCalleesService.selectCompanyVoiceRoboticCalleesListByRoboticId(id);
+        if (list != null && !list.isEmpty() && id != null) {
+            LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+            Long companyId = loginUser.getCompany().getCompanyId();
+            List<Long> calleeIds = list.stream().map(CompanyVoiceRoboticCallees::getId).filter(Objects::nonNull).distinct().collect(Collectors.toList());
+            if (!calleeIds.isEmpty()) {
+                Map<Long, Long> countMap = companyVoiceRoboticCallLogCallphoneService.countRoboticCallOutByCalleeIds(calleeIds, id, companyId).stream()
+                        .collect(Collectors.toMap(CalleeRoboticCallOutCountVO::getCalleeId, CalleeRoboticCallOutCountVO::getCallCount, (a, b) -> a));
+                for (CompanyVoiceRoboticCallees row : list) {
+                    long n = row.getId() == null ? 0L : countMap.getOrDefault(row.getId(), 0L);
+                    row.setRoboticCallOutCount((int) n);
+                }
+            }
+        }
         return getDataTable(list);
     }
 

+ 58 - 0
fs-company/src/main/java/com/fs/company/controller/crm/CrmCustomerController.java

@@ -12,7 +12,9 @@ import com.fs.common.utils.StringUtils;
 import com.fs.common.utils.poi.ExcelUtil;
 import com.fs.company.domain.CompanyUser;
 import com.fs.company.service.ICompanyUserService;
+import com.fs.company.service.ICompanyVoiceRoboticCallLogCallphoneService;
 import com.fs.company.util.OrderUtils;
+import com.fs.company.vo.CustomerRoboticCallOutCountVO;
 import com.fs.crm.domain.CrmCustomer;
 import com.fs.crm.param.*;
 import com.fs.crm.service.ICrmCustomerPropertyService;
@@ -33,6 +35,9 @@ import org.springframework.web.multipart.MultipartFile;
 
 import javax.servlet.http.HttpServletRequest;
 import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
 
 /**
  * 客户Controller
@@ -54,6 +59,8 @@ public class CrmCustomerController extends BaseController
     ICrmCustomerUserService crmCustomerUserService;
     @Autowired
     private ICrmCustomerPropertyService crmCustomerPropertyService;
+    @Autowired
+    private ICompanyVoiceRoboticCallLogCallphoneService companyVoiceRoboticCallLogCallphoneService;
 
     @ApiOperation("获取线索客户")
     @PreAuthorize("@ss.hasPermi('crm:customer:lineList')")
@@ -71,6 +78,7 @@ public class CrmCustomerController extends BaseController
                 }
 
             }
+            fillLineCustomerListRoboticCallOutCount(list, param.getCompanyId());
         }
         return getDataTable(list);
     }
@@ -178,6 +186,7 @@ public class CrmCustomerController extends BaseController
                     vo.setMobile(vo.getMobile().replaceAll("(\\d{3})\\d*(\\d{4})", "$1****$2"));
                 }
             }
+            fillMyCustomerListRoboticCallOutCount(list, param.getCompanyId());
         }
         return getDataTable(list);
 
@@ -204,6 +213,7 @@ public class CrmCustomerController extends BaseController
                     }
                     vo.setProperties( crmCustomerPropertyService.selectCrmCustomerPropertyByCustomerId(vo.getCustomerId()));
                 }
+                fillLineCustomerListRoboticCallOutCount(list1, param.getCompanyId());
             }
             return getDataTable(list1);
         }else {
@@ -216,10 +226,58 @@ public class CrmCustomerController extends BaseController
                     vo.setProperties( crmCustomerPropertyService.selectCrmCustomerPropertyByCustomerId(vo.getCustomerId()));
 
                 }
+                fillCustomerListRoboticCallOutCount(list, param.getCompanyId());
             }
             return getDataTable(list);
         }
     }
+    private void fillCustomerListRoboticCallOutCount(List<CrmCustomerListQueryVO> list, Long companyId) {
+        if (list == null || list.isEmpty()) {
+            return;
+        }
+        List<Long> customerIds = list.stream().map(CrmCustomerListQueryVO::getCustomerId).filter(Objects::nonNull).distinct().collect(Collectors.toList());
+        if (customerIds.isEmpty()) {
+            return;
+        }
+        Map<Long, Long> countMap = companyVoiceRoboticCallLogCallphoneService.countRoboticCallOutByCustomerIds(customerIds, companyId).stream()
+                .collect(Collectors.toMap(CustomerRoboticCallOutCountVO::getCustomerId, CustomerRoboticCallOutCountVO::getCallCount, (a, b) -> a));
+        for (CrmCustomerListQueryVO vo : list) {
+            long n = vo.getCustomerId() == null ? 0L : countMap.getOrDefault(vo.getCustomerId(), 0L);
+            vo.setRoboticCallOutCount((int) n);
+        }
+    }
+
+    private void fillLineCustomerListRoboticCallOutCount(List<CrmLineCustomerListQueryVO> list, Long companyId) {
+        if (list == null || list.isEmpty()) {
+            return;
+        }
+        List<Long> customerIds = list.stream().map(CrmLineCustomerListQueryVO::getCustomerId).filter(Objects::nonNull).distinct().collect(Collectors.toList());
+        if (customerIds.isEmpty()) {
+            return;
+        }
+        Map<Long, Long> countMap = companyVoiceRoboticCallLogCallphoneService.countRoboticCallOutByCustomerIds(customerIds, companyId).stream()
+                .collect(Collectors.toMap(CustomerRoboticCallOutCountVO::getCustomerId, CustomerRoboticCallOutCountVO::getCallCount, (a, b) -> a));
+        for (CrmLineCustomerListQueryVO vo : list) {
+            long n = vo.getCustomerId() == null ? 0L : countMap.getOrDefault(vo.getCustomerId(), 0L);
+            vo.setRoboticCallOutCount((int) n);
+        }
+    }
+
+    private void fillMyCustomerListRoboticCallOutCount(List<CrmMyCustomerListQueryVO> list, Long companyId) {
+        if (list == null || list.isEmpty()) {
+            return;
+        }
+        List<Long> customerIds = list.stream().map(CrmMyCustomerListQueryVO::getCustomerId).filter(Objects::nonNull).distinct().collect(Collectors.toList());
+        if (customerIds.isEmpty()) {
+            return;
+        }
+        Map<Long, Long> countMap = companyVoiceRoboticCallLogCallphoneService.countRoboticCallOutByCustomerIds(customerIds, companyId).stream()
+                .collect(Collectors.toMap(CustomerRoboticCallOutCountVO::getCustomerId, CustomerRoboticCallOutCountVO::getCallCount, (a, b) -> a));
+        for (CrmMyCustomerListQueryVO vo : list) {
+            long n = vo.getCustomerId() == null ? 0L : countMap.getOrDefault(vo.getCustomerId(), 0L);
+            vo.setRoboticCallOutCount((int) n);
+        }
+    }
     @ApiOperation("获取客户详情")
     @GetMapping("/getCustomerDetails")
     @PreAuthorize("@ss.hasPermi('crm:customer:query')")

+ 12 - 7
fs-company/src/main/java/com/fs/company/controller/live/LiveController.java

@@ -89,6 +89,8 @@ public class LiveController extends BaseController
     {
         // 设置企业ID和企业用户ID
         setCompanyId(live);
+        // 普通直播列表不包含「训练营-营期」下的直播间
+        live.setExcludeCampLive(true);
 
         startPage();
         List<Live> list = liveService.selectLiveList(live);
@@ -121,7 +123,7 @@ public class LiveController extends BaseController
     {
         // 设置企业ID和企业用户ID
         setCompanyId(live);
-
+        live.setExcludeCampLive(true);
 
         List<Live> list = liveService.selectLiveList(live);
         ExcelUtil<Live> util = new ExcelUtil<Live>(Live.class);
@@ -202,12 +204,15 @@ public class LiveController extends BaseController
     @PutMapping
     public AjaxResult edit(@RequestBody Live live)
     {
-        return AjaxResult.success();
-//        CompanyUser user = SecurityUtils.getLoginUser().getUser();
-//        live.setCompanyUserId(user.getUserId());
-//        live.setCompanyId(user.getCompanyId());
-//
-//        return toAjax(liveService.updateLive(live));
+        CompanyUser user = SecurityUtils.getLoginUser().getUser();
+        live.setCompanyUserId(user.getUserId());
+        live.setCompanyId(user.getCompanyId());
+        Live db = liveService.selectLiveByLiveIdAndCompanyIdAndCompanyUserId(
+                live.getLiveId(), user.getCompanyId(), user.getUserId());
+        if (db != null && db.getTrainingPeriodId() != null) {
+            live.setIsAudit(0);
+        }
+        return toAjax(liveService.updateLive(live));
     }
 
     /**

+ 58 - 58
fs-company/src/main/java/com/fs/company/controller/live/LiveDataController.java

@@ -49,64 +49,64 @@ public class LiveDataController extends BaseController
     @Autowired
     private TokenService tokenService;
 
-//    /**
-//     * 直播数据统计-数据概览(12项指标)
-//     * @param liveIds 直播间ID列表,前端传入
-//     */
-//    @PreAuthorize("@ss.hasPermi('liveData:liveData:query')")
-//    @PostMapping("/getLiveStatisticsOverview")
-//    public R getLiveStatisticsOverview(@RequestBody List<Long> liveIds) {
-//
-//        return R.ok().put("data",liveDataService.getLiveStatisticsOverview(liveIds != null ? liveIds : Collections.emptyList()));
-//    }
-//
-//    /**
-//     * 直播趋势-进入人数折线图
-//     * 基于 live_user_first_entry 与 live.start_time 计算相对时间,开播前进入的归为"开播前"
-//     * @param liveIds 直播间ID列表
-//     */
-//    @PreAuthorize("@ss.hasPermi('liveData:liveData:query')")
-//    @PostMapping("/getLiveEntryTrend")
-//    public R getLiveEntryTrend(@RequestBody List<Long> liveIds) {
-//        return R.ok().put("data", liveDataService.getLiveEntryTrend(liveIds != null ? liveIds : Collections.emptyList()));
-//    }
-//
-//    /**
-//     * 直播间学员列表(分页,基于 live_user_first_entry)
-//     * 筛选:直播名称(liveIds)、首次访问时间范围
-//     */
-//    @PreAuthorize("@ss.hasPermi('liveData:liveData:query')")
-//    @PostMapping("/listLiveRoomStudents")
-//    public R listLiveRoomStudents(@RequestBody LiveRoomStudentParam param) {
-//        return liveDataService.listLiveRoomStudents(param);
-//    }
-//
-//    /**
-//     * 商品对比统计(商品名称、下单未支付人数、成交人数、成交金额)
-//     */
-//    @PreAuthorize("@ss.hasPermi('liveData:liveData:query')")
-//    @PostMapping("/listProductCompareStats")
-//    public R listProductCompareStats(@RequestBody ProductCompareParam param) {
-//        return liveDataService.listProductCompareStats(param);
-//    }
-//
-//    /**
-//     * 邀课对比-分享人选项列表(基于 live_user_first_entry 中存在的销售)
-//     */
-//    @PreAuthorize("@ss.hasPermi('liveData:liveData:query')")
-//    @PostMapping("/listInviteSalesOptions")
-//    public R listInviteSalesOptions(@RequestBody List<Long> liveIds) {
-//        return R.ok().put("data", liveDataService.listInviteSalesOptions(liveIds != null ? liveIds : Collections.emptyList()));
-//    }
-//
-//    /**
-//     * 邀课对比统计(归属公司、销售名称、邀请人数、已支付订单数、订单总金额)
-//     */
-//    @PreAuthorize("@ss.hasPermi('liveData:liveData:query')")
-//    @PostMapping("/listInviteCompareStats")
-//    public R listInviteCompareStats(@RequestBody InviteCompareParam param) {
-//        return liveDataService.listInviteCompareStats(param);
-//    }
+    /**
+     * 直播数据统计-数据概览(12项指标)
+     * @param liveIds 直播间ID列表,前端传入
+     */
+    @PreAuthorize("@ss.hasPermi('liveData:liveData:query')")
+    @PostMapping("/getLiveStatisticsOverview")
+    public R getLiveStatisticsOverview(@RequestBody List<Long> liveIds) {
+
+        return R.ok().put("data",liveDataService.getLiveStatisticsOverview(liveIds != null ? liveIds : Collections.emptyList()));
+    }
+
+    /**
+     * 直播趋势-进入人数折线图
+     * 基于 live_user_first_entry 与 live.start_time 计算相对时间,开播前进入的归为"开播前"
+     * @param liveIds 直播间ID列表
+     */
+    @PreAuthorize("@ss.hasPermi('liveData:liveData:query')")
+    @PostMapping("/getLiveEntryTrend")
+    public R getLiveEntryTrend(@RequestBody List<Long> liveIds) {
+        return R.ok().put("data", liveDataService.getLiveEntryTrend(liveIds != null ? liveIds : Collections.emptyList()));
+    }
+
+    /**
+     * 直播间学员列表(分页,基于 live_user_first_entry)
+     * 筛选:直播名称(liveIds)、首次访问时间范围
+     */
+    @PreAuthorize("@ss.hasPermi('liveData:liveData:query')")
+    @PostMapping("/listLiveRoomStudents")
+    public R listLiveRoomStudents(@RequestBody LiveRoomStudentParam param) {
+        return liveDataService.listLiveRoomStudents(param);
+    }
+
+    /**
+     * 商品对比统计(商品名称、下单未支付人数、成交人数、成交金额)
+     */
+    @PreAuthorize("@ss.hasPermi('liveData:liveData:query')")
+    @PostMapping("/listProductCompareStats")
+    public R listProductCompareStats(@RequestBody ProductCompareParam param) {
+        return liveDataService.listProductCompareStats(param);
+    }
+
+    /**
+     * 邀课对比-分享人选项列表(基于 live_user_first_entry 中存在的销售)
+     */
+    @PreAuthorize("@ss.hasPermi('liveData:liveData:query')")
+    @PostMapping("/listInviteSalesOptions")
+    public R listInviteSalesOptions(@RequestBody List<Long> liveIds) {
+        return R.ok().put("data", liveDataService.listInviteSalesOptions(liveIds != null ? liveIds : Collections.emptyList()));
+    }
+
+    /**
+     * 邀课对比统计(归属公司、销售名称、邀请人数、已支付订单数、订单总金额)
+     */
+    @PreAuthorize("@ss.hasPermi('liveData:liveData:query')")
+    @PostMapping("/listInviteCompareStats")
+    public R listInviteCompareStats(@RequestBody InviteCompareParam param) {
+        return liveDataService.listInviteCompareStats(param);
+    }
 
     /**
      * 查询直播间详情数据(SQL方式)

+ 69 - 0
fs-company/src/main/java/com/fs/company/controller/live/LiveQuestionLiveController.java

@@ -0,0 +1,69 @@
+package com.fs.company.controller.live;
+
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.company.domain.CompanyUser;
+import com.fs.framework.security.SecurityUtils;
+import com.fs.live.service.ILiveCourseQuestionRelService;
+import com.fs.live.vo.LiveQuestionLiveVO;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 直播间答题配置:关联课程题库 fs_course_question_bank
+ */
+@RestController
+@RequestMapping("/live/liveQuestionLive")
+public class LiveQuestionLiveController extends BaseController {
+
+    @Autowired
+    private ILiveCourseQuestionRelService liveCourseQuestionRelService;
+
+    @PreAuthorize("@ss.hasPermi('live:live:query')")
+    @GetMapping("/list")
+    public TableDataInfo list(@RequestParam Long liveId) {
+        CompanyUser user = SecurityUtils.getLoginUser().getUser();
+        startPage();
+        List<LiveQuestionLiveVO> list = liveCourseQuestionRelService.selectLinkedByLiveId(liveId, user.getCompanyId());
+        return getDataTable(list);
+    }
+
+    /**
+     * 待选:课程题库中尚未关联到本直播间的试题(status=1)
+     */
+    @PreAuthorize("@ss.hasPermi('live:live:query')")
+    @GetMapping("/optionList")
+    public TableDataInfo optionList(@RequestParam Long liveId,
+                                    @RequestParam(required = false) String title,
+                                    @RequestParam(required = false) Integer type) {
+        CompanyUser user = SecurityUtils.getLoginUser().getUser();
+        startPage();
+        List<LiveQuestionLiveVO> list = liveCourseQuestionRelService.selectOptionQuestionBank(
+                liveId, user.getCompanyId(), title, type);
+        return getDataTable(list);
+    }
+
+    @PreAuthorize("@ss.hasPermi('live:live:edit')")
+    @Log(title = "直播间试题", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestParam Long liveId, @RequestParam String questionIds) {
+        CompanyUser user = SecurityUtils.getLoginUser().getUser();
+        int rows = liveCourseQuestionRelService.batchAdd(liveId, user.getCompanyId(), questionIds);
+        return rows > 0 ? AjaxResult.success("添加成功") : AjaxResult.success("无新增(可能已全部存在)");
+    }
+
+    @PreAuthorize("@ss.hasPermi('live:live:edit')")
+    @Log(title = "直播间试题", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{liveId}")
+    public AjaxResult remove(@PathVariable Long liveId, @RequestParam String ids) {
+        CompanyUser user = SecurityUtils.getLoginUser().getUser();
+        int rows = liveCourseQuestionRelService.deleteByRelIds(liveId, user.getCompanyId(), ids);
+        return toAjax(rows);
+    }
+}

+ 182 - 0
fs-company/src/main/java/com/fs/company/controller/live/LiveTrainingCampController.java

@@ -0,0 +1,182 @@
+package com.fs.company.controller.live;
+
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.R;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.StringUtils;
+import com.fs.company.domain.CompanyUser;
+import com.fs.framework.security.SecurityUtils;
+import com.fs.live.domain.Live;
+import com.fs.live.domain.LiveTrainingCamp;
+import com.fs.live.domain.LiveTrainingPeriod;
+import com.fs.live.service.ILiveService;
+import com.fs.live.service.ILiveTrainingCampService;
+import com.fs.live.service.ILiveTrainingPeriodService;
+import com.github.pagehelper.PageInfo;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * 训练营-营期-直播间(与普通直播列表区分,live.training_period_id 非空)
+ */
+@RestController
+@RequestMapping("/live/trainingCamp")
+public class LiveTrainingCampController extends BaseController {
+
+    @Autowired
+    private ILiveTrainingCampService liveTrainingCampService;
+    @Autowired
+    private ILiveTrainingPeriodService liveTrainingPeriodService;
+    @Autowired
+    private ILiveService liveService;
+
+    private static R pageR(List<?> list) {
+        return R.ok().put("rows", list).put("total", new PageInfo(list).getTotal());
+    }
+
+    private static R toR(int rows, String okMsg) {
+        return rows > 0 ? R.ok(okMsg) : R.error("操作失败");
+    }
+
+    // ---------- 训练营 ----------
+
+    @PreAuthorize("@ss.hasPermi('live:trainingCamp:list')")
+    @GetMapping("/camp/list")
+    public R campList(LiveTrainingCamp query) {
+        CompanyUser user = SecurityUtils.getLoginUser().getUser();
+        if (StringUtils.isEmpty(user.getUserName())) {
+            return R.ok().put("rows", Collections.emptyList()).put("total", 0L);
+        }
+        query.setCompanyId(user.getCompanyId());
+        query.setCreateBy(user.getUserName());
+        startPage();
+        List<LiveTrainingCamp> list = liveTrainingCampService.selectLiveTrainingCampList(query);
+        return pageR(list);
+    }
+
+    @PreAuthorize("@ss.hasPermi('live:trainingCamp:query')")
+    @GetMapping("/camp/{campId}")
+    public R getCamp(@PathVariable Long campId) {
+        CompanyUser user = SecurityUtils.getLoginUser().getUser();
+        LiveTrainingCamp c = liveTrainingCampService.selectLiveTrainingCampById(campId, user.getCompanyId(), user.getUserName());
+        return R.ok().put("data", c);
+    }
+
+    @PreAuthorize("@ss.hasPermi('live:trainingCamp:add')")
+    @Log(title = "直播训练营", businessType = BusinessType.INSERT)
+    @PostMapping("/camp")
+    public R addCamp(@RequestBody LiveTrainingCamp camp) {
+        CompanyUser user = SecurityUtils.getLoginUser().getUser();
+        camp.setCompanyId(user.getCompanyId());
+        camp.setCreateBy(user.getUserName());
+        return toR(liveTrainingCampService.insertLiveTrainingCamp(camp), "新增成功");
+    }
+
+    @PreAuthorize("@ss.hasPermi('live:trainingCamp:edit')")
+    @Log(title = "直播训练营", businessType = BusinessType.UPDATE)
+    @PutMapping("/camp")
+    public R editCamp(@RequestBody LiveTrainingCamp camp) {
+        CompanyUser user = SecurityUtils.getLoginUser().getUser();
+        camp.setCompanyId(user.getCompanyId());
+        camp.setUpdateBy(user.getUserName());
+        camp.getParams().put("ownerCreateBy", user.getUserName());
+        return toR(liveTrainingCampService.updateLiveTrainingCamp(camp), "修改成功");
+    }
+
+    @PreAuthorize("@ss.hasPermi('live:trainingCamp:remove')")
+    @Log(title = "直播训练营", businessType = BusinessType.DELETE)
+    @DeleteMapping("/camp/{campIds}")
+    public R removeCamp(@PathVariable Long[] campIds) {
+        CompanyUser user = SecurityUtils.getLoginUser().getUser();
+        return toR(liveTrainingCampService.deleteLiveTrainingCampByIds(campIds, user.getCompanyId(), user.getUserName()), "删除成功");
+    }
+
+    // ---------- 营期 ----------
+
+    @PreAuthorize("@ss.hasPermi('live:trainingCamp:list')")
+    @GetMapping("/period/list")
+    public R periodList(LiveTrainingPeriod query) {
+        CompanyUser user = SecurityUtils.getLoginUser().getUser();
+        if (StringUtils.isEmpty(user.getUserName())) {
+            return R.ok().put("rows", Collections.emptyList()).put("total", 0L);
+        }
+        query.setCompanyId(user.getCompanyId());
+        query.setCreateBy(user.getUserName());
+        startPage();
+        List<LiveTrainingPeriod> list = liveTrainingPeriodService.selectLiveTrainingPeriodList(query);
+        return pageR(list);
+    }
+
+    @PreAuthorize("@ss.hasPermi('live:trainingCamp:query')")
+    @GetMapping("/period/{periodId}")
+    public R getPeriod(@PathVariable Long periodId) {
+        CompanyUser user = SecurityUtils.getLoginUser().getUser();
+        LiveTrainingPeriod p = liveTrainingPeriodService.selectLiveTrainingPeriodById(periodId, user.getCompanyId(), user.getUserName());
+        return R.ok().put("data", p);
+    }
+
+    @PreAuthorize("@ss.hasPermi('live:trainingCamp:add')")
+    @Log(title = "直播训练营营期", businessType = BusinessType.INSERT)
+    @PostMapping("/period")
+    public R addPeriod(@RequestBody LiveTrainingPeriod period) {
+        CompanyUser user = SecurityUtils.getLoginUser().getUser();
+        period.setCompanyId(user.getCompanyId());
+        period.setCreateBy(user.getUserName());
+        return toR(liveTrainingPeriodService.insertLiveTrainingPeriod(period), "新增成功");
+    }
+
+    @PreAuthorize("@ss.hasPermi('live:trainingCamp:edit')")
+    @Log(title = "直播训练营营期", businessType = BusinessType.UPDATE)
+    @PutMapping("/period")
+    public R editPeriod(@RequestBody LiveTrainingPeriod period) {
+        CompanyUser user = SecurityUtils.getLoginUser().getUser();
+        period.setCompanyId(user.getCompanyId());
+        period.setUpdateBy(user.getUserName());
+        period.getParams().put("ownerCreateBy", user.getUserName());
+        return toR(liveTrainingPeriodService.updateLiveTrainingPeriod(period), "修改成功");
+    }
+
+    @PreAuthorize("@ss.hasPermi('live:trainingCamp:remove')")
+    @Log(title = "直播训练营营期", businessType = BusinessType.DELETE)
+    @DeleteMapping("/period/{periodIds}")
+    public R removePeriod(@PathVariable Long[] periodIds) {
+        CompanyUser user = SecurityUtils.getLoginUser().getUser();
+        return toR(liveTrainingPeriodService.deleteLiveTrainingPeriodByIds(periodIds, user.getCompanyId(), user.getUserName()), "删除成功");
+    }
+
+    // ---------- 营期下直播间 ----------
+
+    @PreAuthorize("@ss.hasPermi('live:trainingCamp:list')")
+    @GetMapping("/live/list")
+    public R trainingLiveList(Live query) {
+        CompanyUser user = SecurityUtils.getLoginUser().getUser();
+        query.setCompanyId(user.getCompanyId());
+        query.setCompanyUserId(user.getUserId());
+        if (query.getTrainingPeriodId() == null) {
+            return R.ok().put("rows", Collections.emptyList()).put("total", 0L);
+        }
+        LiveTrainingPeriod p = liveTrainingPeriodService.selectLiveTrainingPeriodById(
+                query.getTrainingPeriodId(), user.getCompanyId(), user.getUserName());
+        if (p == null) {
+            return R.ok().put("rows", Collections.emptyList()).put("total", 0L);
+        }
+        startPage();
+        List<Live> list = liveService.selectLiveList(query);
+        return pageR(list);
+    }
+
+    @PreAuthorize("@ss.hasPermi('live:trainingCamp:live:add')")
+    @Log(title = "训练营直播间", businessType = BusinessType.INSERT)
+    @PostMapping("/live")
+    public R addTrainingLive(@RequestBody Live live) {
+        CompanyUser user = SecurityUtils.getLoginUser().getUser();
+        // live.create_by 列为数值类型(创建人用户ID),不能使用昵称/登录名
+        live.setCreateBy(String.valueOf(user.getUserId()));
+        return toR(liveTrainingPeriodService.insertTrainingLive(live, user.getCompanyId(), user.getUserId(), user.getUserName()), "新增成功");
+    }
+}

+ 5 - 1
fs-company/src/main/java/com/fs/company/controller/qw/QwUserController.java

@@ -553,10 +553,14 @@ public class QwUserController extends BaseController
     public TableDataInfo queryQwList(QwUser qwUser)
     {
         startPage();
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
         if(qwUser.getCompanyId() == null){
-            LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
             qwUser.setCompanyId(loginUser.getCompany().getCompanyId());
         }
+        if (!CompanyUser.isAdmin(loginUser.getUser().getUserType())) {
+            qwUser.setCompanyId(loginUser.getCompany().getCompanyId());
+            qwUser.setCompanyUserId(loginUser.getUser().getUserId());
+        }
         List<QwUser> list = qwUserService.selectQwUserList(qwUser);
         return getDataTable(list);
     }

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

@@ -100,7 +100,7 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter
                 // 过滤请求
                 .authorizeRequests()
                 // 对于登录login 注册register 验证码captchaImage 允许匿名访问
-                .antMatchers("/chat/upload/**","/login", "/register", "/captchaImage","/checkIsNeedCheck","/getWechatQrCode","/checkWechatScan","/callback").anonymous()
+                .antMatchers("/chat/upload/**","/login", "/register", "/captchaImage","/checkIsNeedCheck","/getWechatQrCode","/checkWechatScan","/callback","/xiaoShouYi/**").anonymous()
                 .antMatchers(
                         HttpMethod.GET,
                         "/",

+ 2 - 1
fs-company/src/main/java/com/fs/user/FsUserAdminController.java

@@ -36,6 +36,7 @@ import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 
+import java.io.UnsupportedEncodingException;
 import java.util.Date;
 import java.util.List;
 
@@ -187,7 +188,7 @@ public class FsUserAdminController extends BaseController {
 
     @ApiOperation("后台会员批量发送课程消息")
     @PostMapping("/batchSendCourse")
-    public OpenImResponseDTO batchSendCourse(@RequestBody BatchSendCourseDTO batchSendCourseDTO) throws JsonProcessingException {
+    public OpenImResponseDTO batchSendCourse(@RequestBody BatchSendCourseDTO batchSendCourseDTO) throws JsonProcessingException, UnsupportedEncodingException {
         // 生成看课短链
         FsCourseLinkCreateParam fsCourseLinkCreateParam = new FsCourseLinkCreateParam();
         BeanUtils.copyProperties(batchSendCourseDTO, fsCourseLinkCreateParam);

+ 96 - 0
fs-company/src/main/java/com/fs/xiaoshouyi/client/XiaoShouYiHttpClient.java

@@ -0,0 +1,96 @@
+package com.fs.xiaoshouyi.client;
+
+import cn.hutool.http.HttpRequest;
+import cn.hutool.http.HttpResponse;
+import cn.hutool.json.JSONUtil;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.io.File;
+import java.util.Map;
+
+@Slf4j
+@Component
+public class XiaoShouYiHttpClient {
+
+    private static final int TIMEOUT = 30000;
+
+    public HttpResult postForm(String url, Map<String, Object> form) {
+        try (HttpResponse response = HttpRequest.post(url)
+                .form(form)
+                .timeout(TIMEOUT)
+                .execute()) {
+
+            return new HttpResult(response.getStatus(), response.body());
+        } catch (Exception e) {
+            log.error("销售易postForm请求异常, url={}", url, e);
+            throw new RuntimeException("销售易请求异常: " + e.getMessage(), e);
+        }
+    }
+
+    public HttpResult postJson(String url, String authHeader, Object bodyObj) {
+        String bodyJson = JSONUtil.toJsonStr(bodyObj);
+        try (HttpResponse response = HttpRequest.post(url)
+                .header("Content-Type", "application/json")
+                .header("Authorization", authHeader)
+                .body(bodyJson)
+                .timeout(TIMEOUT)
+                .execute()) {
+
+            return new HttpResult(response.getStatus(), response.body());
+        } catch (Exception e) {
+            log.error("销售易postJson请求异常, url={}, body={}", url, bodyJson, e);
+            throw new RuntimeException("销售易请求异常: " + e.getMessage(), e);
+        }
+    }
+
+    public HttpResult get(String url, String authHeader) {
+        try (HttpResponse response = HttpRequest.get(url)
+                .header("Content-Type", "application/json")
+                .header("Authorization", authHeader)
+                .timeout(TIMEOUT)
+                .execute()) {
+
+            return new HttpResult(response.getStatus(), response.body());
+        } catch (Exception e) {
+            log.error("销售易get请求异常, url={}", url, e);
+            throw new RuntimeException("销售易请求异常: " + e.getMessage(), e);
+        }
+    }
+
+    /**
+     * multipart/form-data 上传文件
+     */
+    public HttpResult postMultipart(String url, String authHeader, File file, Boolean isVideo) {
+        try (HttpResponse response = HttpRequest.post(url)
+                .header("Authorization", authHeader)
+                .form("file", file)
+                .form("isVideo", isVideo == null ? Boolean.FALSE : isVideo)
+                .timeout(TIMEOUT)
+                .execute()) {
+            return new HttpResult(response.getStatus(), response.body());
+        } catch (Exception e) {
+            log.error("销售易postMultipart请求异常, url={}, file={}, isVideo={}",
+                    url, file == null ? null : file.getAbsolutePath(), isVideo, e);
+            throw new RuntimeException("销售易请求异常: " + e.getMessage(), e);
+        }
+    }
+
+    public static class HttpResult {
+        private final int status;
+        private final String body;
+
+        public HttpResult(int status, String body) {
+            this.status = status;
+            this.body = body;
+        }
+
+        public int getStatus() {
+            return status;
+        }
+
+        public String getBody() {
+            return body;
+        }
+    }
+}

+ 259 - 0
fs-company/src/main/java/com/fs/xiaoshouyi/controller/XiaoShouYiController.java

@@ -0,0 +1,259 @@
+package com.fs.xiaoshouyi.controller;
+
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.xiaoshouyi.dto.*;
+import com.fs.xiaoshouyi.service.XiaoShouYiMaterialService;
+import com.fs.xiaoshouyi.service.XiaoShouYiOAuthService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.util.StringUtils;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+import org.springframework.web.servlet.view.RedirectView;
+
+import java.io.File;
+
+@Slf4j
+@RestController
+@RequestMapping("/xiaoShouYi")
+@RequiredArgsConstructor
+public class XiaoShouYiController {
+
+    private final XiaoShouYiOAuthService oAuthService;
+    private final XiaoShouYiMaterialService materialService;
+
+
+    /**
+     * 获取授权URL
+     */
+    @PreAuthorize("@ss.hasPermi('xsy:auth:url')")
+    @GetMapping("/auth/url/{accountId}")
+    public AjaxResult getAuthUrl(@PathVariable Long accountId) {
+        return AjaxResult.success(oAuthService.buildAuthUrl(accountId));
+    }
+
+    /**
+     * 授权回调
+     */
+    @GetMapping("/auth/callback/{accountId}")
+    public RedirectView authCallback(@PathVariable Long accountId,
+                                     @RequestParam("code") String code) {
+
+        try {
+            TokenResponse tokenResp = oAuthService.exchangeCodeForToken(accountId, code);
+
+            if (tokenResp.isSuccess()) {
+                return new RedirectView("/xiaoShouYi/success");
+            }
+
+            return new RedirectView("/xiaoShouYi/error?msg=" + tokenResp.getError());
+
+        } catch (Exception e) {
+            log.error("授权回调异常 accountId={}", accountId, e);
+            return new RedirectView("/xiaoShouYi/error?msg=" + e.getMessage());
+        }
+    }
+
+
+
+    /**
+     * 生成追踪链接
+     */
+    @PostMapping("/generateLink")
+    public AjaxResult generateLink(@RequestParam Long companyUserId,
+                                   @Validated @RequestBody GenerateLinkRequest request) {
+
+        try {
+            if (request.getMaterialIds() == null || request.getMaterialIds().isEmpty()) {
+                return AjaxResult.error("materialIds不能为空");
+            }
+
+            GenerateLinkResponse response = materialService.generateMaterialTrackLink(
+                                                                    companyUserId,
+                                                                    request.getMaterialIds(),
+                                                                    request.getSendUserId());
+
+            return response.isSuccess()
+                    ? AjaxResult.success(response)
+                    : AjaxResult.error(response.getMsg());
+
+        } catch (Exception e) {
+            log.error("生成追踪链接异常", e);
+            return AjaxResult.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 发送确认
+     */
+    @PostMapping("/sendConfirm")
+    public AjaxResult sendConfirm(@RequestParam Long companyUserId,
+                                  @RequestParam Long forwardId,
+                                  @RequestParam Integer forwardType) {
+
+        try {
+            SendConfirmResponse response =
+                    materialService.sendConfirmAuto(companyUserId, forwardId, forwardType);
+
+            return response.isSuccess()
+                    ? AjaxResult.success(response)
+                    : AjaxResult.error(response.getMsg());
+
+        } catch (Exception e) {
+            log.error("发送确认异常", e);
+            return AjaxResult.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 查询素材
+     */
+    @PostMapping("/materials/query")
+    public AjaxResult queryMaterials(@RequestParam Long companyUserId,
+                                     @RequestBody QueryMaterialRequest request) {
+
+        try {
+            QueryMaterialResponse response =
+                    materialService.queryMaterialsAuto(companyUserId, request);
+
+            if (!Boolean.TRUE.equals(response.getSuccess())) {
+                return AjaxResult.error(response.getMsg());
+            }
+
+            return AjaxResult.success(response.getData());
+
+        } catch (Exception e) {
+            log.error("查询素材异常", e);
+            return AjaxResult.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 上传素材文件
+     */
+    @PostMapping("/uploadFile")
+    public AjaxResult uploadFile(@RequestParam Long companyUserId,
+                                 @RequestParam("file") MultipartFile multipartFile,
+                                 @RequestParam(defaultValue = "false") Boolean isVideo) {
+
+        if (multipartFile == null || multipartFile.isEmpty()) {
+            return AjaxResult.error("文件不能为空");
+        }
+
+        File tempFile = null;
+        try {
+            // 保留文件后缀
+            String suffix = getSuffix(multipartFile.getOriginalFilename());
+            tempFile = File.createTempFile("xsy_", suffix);
+
+            multipartFile.transferTo(tempFile);
+
+            UploadMaterialFileResponse response =
+                    materialService.uploadMaterialFileAuto(companyUserId, tempFile, isVideo);
+
+            if (!Boolean.TRUE.equals(response.getSuccess())) {
+                return AjaxResult.error(response.getMsg());
+            }
+
+            return AjaxResult.success(response.getData());
+
+        } catch (Exception e) {
+            log.error("上传素材异常", e);
+            return AjaxResult.error(e.getMessage());
+        } finally {
+            if (tempFile != null && tempFile.exists()) {
+                tempFile.delete();
+            }
+        }
+    }
+
+    /**
+     * 创建素材
+     */
+    @PostMapping("/createMaterial")
+    public AjaxResult createMaterial(@RequestParam Long companyUserId,
+                                     @RequestBody CreateMaterialRequest request) {
+
+        try {
+            CreateMaterialResponse response =
+                    materialService.createMaterialAuto(companyUserId, request);
+
+            return response.getSuccess()
+                    ? AjaxResult.success(response)
+                    : AjaxResult.error(response.getMsg());
+
+        } catch (Exception e) {
+            log.error("创建素材异常", e);
+            return AjaxResult.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 上传 + 创建素材
+     */
+    @PostMapping("/createMaterialWithUpload")
+    public AjaxResult createMaterialWithUpload(@RequestParam Long companyUserId,
+                                               @RequestParam("file") MultipartFile file,
+                                               @RequestParam(defaultValue = "false") Boolean isVideo,
+                                               @RequestParam String corpName,
+                                               @RequestParam Integer materialType,
+                                               @RequestParam String categoryName,
+                                               @RequestParam String title) {
+
+        File tempFile = null;
+
+        try {
+            if (!StringUtils.hasText(corpName) || !StringUtils.hasText(categoryName) || !StringUtils.hasText(title)) {
+                return AjaxResult.error("必填参数不能为空");
+            }
+
+            String suffix = getSuffix(file.getOriginalFilename());
+            tempFile = File.createTempFile("xsy_", suffix);
+            file.transferTo(tempFile);
+
+            CreateMaterialRequest req = new CreateMaterialRequest();
+            req.setCorpName(corpName);
+            req.setMaterialType(materialType);
+            req.setCategoryName(categoryName);
+            req.setTitle(title);
+
+            CreateMaterialWithUploadResponse response =
+                    materialService.createMaterialWithUploadAuto(
+                            companyUserId, tempFile, isVideo, req);
+
+            return AjaxResult.success(response);
+
+        } catch (Exception e) {
+            log.error("上传+创建素材异常", e);
+            return AjaxResult.error(e.getMessage());
+        } finally {
+            if (tempFile != null && tempFile.exists()) {
+                tempFile.delete();
+            }
+        }
+    }
+
+
+
+    @GetMapping("/success")
+    public String success() {
+        return "授权成功";
+    }
+
+    @GetMapping("/error")
+    public String error(String msg) {
+        return "授权失败: " + msg;
+    }
+
+
+
+    private String getSuffix(String fileName) {
+        if (!StringUtils.hasText(fileName)) {
+            return ".tmp";
+        }
+        int index = fileName.lastIndexOf(".");
+        return index > 0 ? fileName.substring(index) : ".tmp";
+    }
+}

+ 131 - 0
fs-company/src/main/java/com/fs/xiaoshouyi/controller/XsyAccountController.java

@@ -0,0 +1,131 @@
+package com.fs.xiaoshouyi.controller;
+
+import cn.hutool.core.lang.Snowflake;
+import cn.hutool.core.util.IdUtil;
+import cn.hutool.core.util.ObjectUtil;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.framework.service.TokenService;
+import com.fs.xiaoshouyi.domain.XsyAccount;
+import com.fs.xiaoshouyi.service.XsyAccountService;
+import com.fs.xiaoshouyi.service.XsyCompanyBindService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 销售易账号管理
+ */
+@RestController
+@RequestMapping("/xsy/account")
+@RequiredArgsConstructor
+public class XsyAccountController extends BaseController {
+
+    private final XsyAccountService xsyAccountService;
+    @Autowired
+    private XsyCompanyBindService xsyCompanyBindService;
+    @Autowired
+    private TokenService tokenService;
+
+    /**
+     * 分页查询账号列表(销售后台)
+     */
+    @PreAuthorize("@ss.hasPermi('xsy:account:query')")
+    @GetMapping("/company/list")
+    public TableDataInfo companyList() {
+        Long companyId = tokenService.getLoginUser(ServletUtils.getRequest()).getCompany().getCompanyId();
+        List<Long> accountIdsByCompanyIds = xsyCompanyBindService.getAccountIdsByCompanyId(companyId);
+        startPage();
+        List<XsyAccount> list = xsyAccountService.selectListByIds(accountIdsByCompanyIds);
+        return getDataTable(list);
+    }
+
+    /**
+     * 分页查询账号列表(查所有)
+     */
+    @PreAuthorize("@ss.hasPermi('xsy:account:query')")
+    @GetMapping("/list")
+    public TableDataInfo list(XsyAccount query) {
+        startPage();
+        List<XsyAccount> list = xsyAccountService.selectList(query);
+        return getDataTable(list);
+    }
+
+    /**
+     * 查询详情
+     */
+    @PreAuthorize("@ss.hasPermi('xsy:account:query')")
+    @GetMapping("/get/{id}")
+    public AjaxResult get(@PathVariable Long id) {
+        return AjaxResult.success(xsyAccountService.selectById(id));
+    }
+
+    /**
+     * 新增
+     */
+    @PreAuthorize("@ss.hasPermi('xsy:account:add')")
+    @PostMapping("/add")
+    public AjaxResult add(@RequestBody XsyAccount account) {
+        String uri = account.getRedirectUri();
+        Snowflake snowflake = IdUtil.getSnowflake();
+        Long id = snowflake.nextId();
+        account.setId(id);
+
+        if (StringUtils.isNotEmpty(uri) && ObjectUtil.isNotEmpty(id)) {
+            uri = uri.endsWith("/") ? uri.substring(0, uri.length() - 1) : uri;
+            account.setRedirectUri(uri + "/" + id);
+        }
+        xsyAccountService.insert(account);
+        return AjaxResult.success();
+    }
+
+    /**
+     * 修改
+     */
+    @PreAuthorize("@ss.hasPermi('xsy:account:update')")
+    @PostMapping("/update")
+    public AjaxResult update(@RequestBody XsyAccount account) {
+        xsyAccountService.update(account);
+        return AjaxResult.success();
+    }
+
+    /**
+     * 删除
+     */
+    @PreAuthorize("@ss.hasPermi('xsy:account:delete')")
+    @PostMapping("/delete/{id}")
+    public AjaxResult delete(@PathVariable Long id) {
+        xsyAccountService.deleteById(id);
+        return AjaxResult.success();
+    }
+
+    /**
+     * 启用/禁用
+     */
+    @PreAuthorize("@ss.hasPermi('xsy:account:update')")
+    @PostMapping("/status")
+    public AjaxResult updateStatus(@RequestParam Long id,
+                                   @RequestParam Integer status) {
+        XsyAccount account = new XsyAccount();
+        account.setId(id);
+        account.setStatus(status);
+
+        xsyAccountService.update(account);
+        return AjaxResult.success();
+    }
+
+    /**
+     * 获取授权URL
+     */
+    @PreAuthorize("@ss.hasPermi('xsy:account:query')")
+    @GetMapping("/authUrl/{accountId}")
+    public AjaxResult getAuthUrl(@PathVariable Long accountId) {
+        return AjaxResult.success(xsyAccountService.getAuthUrl(accountId));
+    }
+}

+ 61 - 0
fs-company/src/main/java/com/fs/xiaoshouyi/controller/XsyBindController.java

@@ -0,0 +1,61 @@
+package com.fs.xiaoshouyi.controller;
+
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.utils.ServletUtils;
+import com.fs.framework.service.TokenService;
+import com.fs.xiaoshouyi.dto.BindXsyAccountRequest;
+import com.fs.xiaoshouyi.service.XsyCompanyBindService;
+import com.fs.xiaoshouyi.service.XsyUserBindService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/xsy/bind")
+@RequiredArgsConstructor
+public class XsyBindController {
+
+    @Autowired
+    private XsyUserBindService bindService;
+    @Autowired
+    private TokenService tokenService;
+    @Autowired
+    private XsyCompanyBindService xsyCompanyBindService;
+
+
+    /**
+     * 绑定销售易账号
+     */
+    @PreAuthorize("@ss.hasPermi('xsy:bind:bind')")
+    @PostMapping("/bind")
+    public AjaxResult bind(@RequestBody BindXsyAccountRequest req) {
+        Long companyId = tokenService.getLoginUser(ServletUtils.getRequest()).getCompany().getCompanyId();
+        boolean account = xsyCompanyBindService.isCompanyBoundAccount(companyId, req.getAccountId());
+        if (!account) {
+            throw new RuntimeException("当前员工所在公司未绑定该销售易账号,不能绑定");
+        }
+        bindService.bind(req.getCompanyUserId(), req.getAccountId());
+        return AjaxResult.success();
+    }
+
+    /**
+     * 解绑
+     */
+    @PreAuthorize("@ss.hasPermi('xsy:bind:unbind')")
+    @PostMapping("/unbind")
+    public AjaxResult unbind(@RequestBody BindXsyAccountRequest req) {
+        bindService.unbind(req.getCompanyUserId());
+        return AjaxResult.success();
+    }
+
+    /**
+     * 查询绑定
+     */
+    @PreAuthorize("@ss.hasPermi('xsy:bind:query')")
+    @GetMapping("/get")
+    public AjaxResult get(@RequestParam Long companyUserId) {
+        Long accountId = bindService.getBindAccountId(companyUserId);
+        return AjaxResult.success(accountId);
+    }
+}

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

@@ -146,6 +146,9 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter
                 .antMatchers("/course/userVideo/videoTranscode").anonymous()
                 .antMatchers("/erp/call/**").anonymous()
                 .antMatchers("/third/weizou/**").anonymous()
+                .antMatchers("/xiaoShouYi/auth/callback/**").anonymous()
+                .antMatchers("/xiaoShouYi/success").anonymous()
+                .antMatchers("/xiaoShouYi/error").anonymous()
                 // 除上面外的所有请求全部需要鉴权认证
                 .anyRequest().authenticated()
                 .and()

+ 94 - 0
fs-live-app/src/main/java/com/fs/live/websocket/service/WebSocketServer.java

@@ -2,6 +2,7 @@ package com.fs.live.websocket.service;
 
 
 import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
 import com.alibaba.fastjson.JSONObject;
 import com.fs.common.constant.LiveKeysConstant;
 import com.fs.common.core.redis.RedisCacheT;
@@ -23,7 +24,10 @@ import com.fs.common.core.domain.R;
 import com.fs.common.core.redis.RedisCache;
 import com.fs.common.utils.StringUtils;
 import com.fs.common.utils.spring.SpringUtils;
+import com.fs.course.domain.FsCourseQuestionBank;
+import com.fs.course.service.IFsCourseQuestionBankService;
 import com.fs.live.domain.*;
+import com.fs.live.mapper.LiveCourseQuestionRelMapper;
 import com.fs.live.service.*;
 import com.fs.live.vo.LiveGoodsVo;
 import com.fs.newAdv.service.ILeadService;
@@ -626,12 +630,102 @@ public class WebSocketServer {
                         delAutoTask(liveId, DateUtils.parseDate(msg.getData(),"yyyy-MM-dd'T'HH:mm:ss.SSSZ").getTime());
                     }
                     break;
+                case "liveQuizStart":
+                    if (userType == 1) {
+                        processLiveQuizStart(liveId, msg);
+                    }
+                    break;
+                case "liveQuizClose":
+                    if (userType == 1) {
+                        processLiveQuizClose(liveId, msg);
+                    }
+                    break;
             }
         } catch (Exception e) {
             log.error("webSocket 消息处理失败 msg: {}", e.getMessage(), e);
         }
     }
 
+    /**
+     * 管理端:向直播间观众广播「开始答题」及题目内容(不含正确答案,仅选项文案)
+     */
+    private void processLiveQuizStart(long liveId, SendMsgVo msg) {
+        try {
+            if (StringUtils.isEmpty(msg.getData())) {
+                return;
+            }
+            JSONObject body = JSON.parseObject(msg.getData());
+            Long relId = body.getLong("relId");
+            if (relId == null) {
+                return;
+            }
+            LiveCourseQuestionRelMapper relMapper = SpringUtils.getBean(LiveCourseQuestionRelMapper.class);
+            Long questionBankId = relMapper.selectQuestionBankIdByLiveAndRel(liveId, relId);
+            if (questionBankId == null) {
+                log.warn("liveQuizStart: 未找到关联 liveId={} relId={}", liveId, relId);
+                return;
+            }
+            IFsCourseQuestionBankService bankService = SpringUtils.getBean(IFsCourseQuestionBankService.class);
+            FsCourseQuestionBank bank = bankService.selectFsCourseQuestionBankById(questionBankId);
+            if (bank == null) {
+                return;
+            }
+            JSONArray optionsOut = new JSONArray();
+            String qStr = bank.getQuestion();
+            if (StringUtils.isNotEmpty(qStr)) {
+                JSONArray arr = JSON.parseArray(qStr);
+                String alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+                for (int i = 0; i < arr.size(); i++) {
+                    JSONObject opt = arr.getJSONObject(i);
+                    JSONObject row = new JSONObject();
+                    row.put("key", i < alphabet.length() ? String.valueOf(alphabet.charAt(i)) : String.valueOf(i + 1));
+                    row.put("name", opt.getString("name"));
+                    optionsOut.add(row);
+                }
+            }
+            JSONObject data = new JSONObject();
+            data.put("relId", relId);
+            data.put("questionBankId", bank.getId());
+            data.put("title", bank.getTitle());
+            data.put("type", bank.getType());
+            data.put("options", optionsOut);
+            SendMsgVo out = new SendMsgVo();
+            out.setLiveId(liveId);
+            out.setUserType(1L);
+            out.setCmd("liveQuizStart");
+            out.setOn(true);
+            out.setData(data.toJSONString());
+            enqueueMessage(liveId, JSONObject.toJSONString(R.ok().put("data", out)), true);
+        } catch (Exception e) {
+            log.error("liveQuizStart 处理异常 liveId={}", liveId, e);
+        }
+    }
+
+    /**
+     * 管理端:广播「结束答题」
+     */
+    private void processLiveQuizClose(long liveId, SendMsgVo msg) {
+        try {
+            JSONObject data = new JSONObject();
+            if (StringUtils.isNotEmpty(msg.getData())) {
+                JSONObject body = JSON.parseObject(msg.getData());
+                Long relId = body.getLong("relId");
+                if (relId != null) {
+                    data.put("relId", relId);
+                }
+            }
+            SendMsgVo out = new SendMsgVo();
+            out.setLiveId(liveId);
+            out.setUserType(1L);
+            out.setCmd("liveQuizClose");
+            out.setOn(true);
+            out.setData(data.toJSONString());
+            enqueueMessage(liveId, JSONObject.toJSONString(R.ok().put("data", out)), true);
+        } catch (Exception e) {
+            log.error("liveQuizClose 处理异常 liveId={}", liveId, e);
+        }
+    }
+
     private void deleteMsg(long liveId,SendMsgVo msg) {
         SendMsgVo sendMsgVo = new SendMsgVo();
         sendMsgVo.setLiveId(liveId);

+ 43 - 0
fs-service/src/main/java/com/fs/company/domain/CompanySiptaskInfo.java

@@ -0,0 +1,43 @@
+package com.fs.company.domain;
+
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.fs.common.annotation.Excel;
+import lombok.Data;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 任务与外呼sip任务关联关系对象 company_siptask_info
+ *
+ * @author fs
+ * @date 2026-04-20
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class CompanySiptaskInfo extends BaseEntity{
+
+    /** 主键id */
+    private Long id;
+
+    /** 工作流id */
+    @Excel(name = "工作流id")
+    private Long workflowId;
+
+    /** 任务id */
+    @Excel(name = "任务id")
+    private Long taskId;
+
+    /** 节点key */
+    @Excel(name = "节点key")
+    private String nodeKey;
+
+    /** 对应sip任务id */
+    @Excel(name = "对应sip任务id")
+    private Long batchId;
+
+    /** sip外呼任务 */
+    @Excel(name = "sip外呼任务")
+    private String taskJson;
+
+
+}

+ 5 - 1
fs-service/src/main/java/com/fs/company/domain/CompanyVoiceRoboticCallees.java

@@ -8,7 +8,7 @@ import lombok.Data;
 
 /**
  * 任务外呼电话对象 company_voice_robotic_callees
- * 
+ *
  * @author fs
  * @date 2024-12-04
  */
@@ -67,4 +67,8 @@ public class CompanyVoiceRoboticCallees{
 
     //是否生成数据(0否,1是)
     private Integer isGenerate;
+
+    /** 本任务下该 callee 的 AI 外呼呼出次数(非表字段,来自 call_log 统计) */
+    @TableField(exist = false)
+    private Integer roboticCallOutCount;
 }

+ 5 - 0
fs-service/src/main/java/com/fs/company/domain/CompanyWxAccount.java

@@ -101,4 +101,9 @@ public class CompanyWxAccount extends BaseEntity
 
     @TableField(exist = false)
     private String companyUserName;
+
+    /**
+     * 微信备注(唯一)
+     */
+    private String wxRemark;
 }

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

@@ -4,6 +4,7 @@ import java.util.List;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.fs.company.domain.CompanyAiWorkflowExec;
 import com.fs.company.vo.WorkflowExecRecordVo;
+import com.fs.wxcid.domain.WxContact;
 import org.apache.ibatis.annotations.Param;
 
 import java.util.List;
@@ -111,4 +112,6 @@ public interface CompanyAiWorkflowExecMapper extends BaseMapper<CompanyAiWorkflo
             @Param("customerPhone") String customerPhone,
             @Param(("onlyCallNode")) Boolean onlyCallNode
     );
+
+    WxContact selectWxContectByWorkflowInstanceId(@Param("workflowInstanceId") String workflowInstanceId);
 }

+ 2 - 1
fs-service/src/main/java/com/fs/company/mapper/CompanyMapper.java

@@ -155,10 +155,11 @@ public interface CompanyMapper
 
 
     @Select({"<script> " +
-            "select c.*,cu.user_name,qu.used_num," +
+            "select c.*,cu.user_name,qu.used_num,xcb.account_id," +
             "CASE WHEN JSON_VALID(t1.config_value) THEN t1.config_value->>'$.mchId' ELSE NULL END as mchId" +
             " FROM company c LEFT JOIN company_user cu ON c.user_id =cu.user_id  " +
             " left join company_config t1 on t1.config_key = 'redPacket:config' and t1.company_id = c.company_id " +
+            "left join xsy_company_bind xcb on xcb.company_id = c.company_id " +
             "LEFT JOIN (select company_id, count(id) as used_num from qw_user where server_id is not null and server_status = 1 group by company_id) qu ON qu.company_id = c.company_id " +
             "where c.is_del=0 " +
             "            <if test=\"companyName != null  and companyName != ''\"> and c.company_name like concat('%', #{companyName}, '%')</if>\n" +

+ 64 - 0
fs-service/src/main/java/com/fs/company/mapper/CompanySiptaskInfoMapper.java

@@ -0,0 +1,64 @@
+package com.fs.company.mapper;
+
+import java.util.List;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.company.domain.CompanySiptaskInfo;
+import org.apache.ibatis.annotations.Param;
+
+/**
+ * 任务与外呼sip任务关联关系Mapper接口
+ * 
+ * @author fs
+ * @date 2026-04-20
+ */
+public interface CompanySiptaskInfoMapper extends BaseMapper<CompanySiptaskInfo>{
+    /**
+     * 查询任务与外呼sip任务关联关系
+     * 
+     * @param id 任务与外呼sip任务关联关系主键
+     * @return 任务与外呼sip任务关联关系
+     */
+    CompanySiptaskInfo selectCompanySiptaskInfoById(Long id);
+
+    /**
+     * 查询任务与外呼sip任务关联关系列表
+     * 
+     * @param companySiptaskInfo 任务与外呼sip任务关联关系
+     * @return 任务与外呼sip任务关联关系集合
+     */
+    List<CompanySiptaskInfo> selectCompanySiptaskInfoList(CompanySiptaskInfo companySiptaskInfo);
+
+    /**
+     * 新增任务与外呼sip任务关联关系
+     * 
+     * @param companySiptaskInfo 任务与外呼sip任务关联关系
+     * @return 结果
+     */
+    int insertCompanySiptaskInfo(CompanySiptaskInfo companySiptaskInfo);
+
+    /**
+     * 修改任务与外呼sip任务关联关系
+     * 
+     * @param companySiptaskInfo 任务与外呼sip任务关联关系
+     * @return 结果
+     */
+    int updateCompanySiptaskInfo(CompanySiptaskInfo companySiptaskInfo);
+
+    /**
+     * 删除任务与外呼sip任务关联关系
+     * 
+     * @param id 任务与外呼sip任务关联关系主键
+     * @return 结果
+     */
+    int deleteCompanySiptaskInfoById(Long id);
+
+    /**
+     * 批量删除任务与外呼sip任务关联关系
+     * 
+     * @param ids 需要删除的数据主键集合
+     * @return 结果
+     */
+    int deleteCompanySiptaskInfoByIds(Long[] ids);
+
+    CompanySiptaskInfo selectSipTaskInfoByTaskIdAndNodeKey(@Param("taskId") Long taskId, @Param("nodeKey") String nodeKey);
+}

+ 22 - 7
fs-service/src/main/java/com/fs/company/mapper/CompanyVoiceRoboticCallLogCallphoneMapper.java

@@ -4,20 +4,22 @@ import java.util.List;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.fs.company.domain.CompanyVoiceRoboticCallLogCallphone;
 import com.fs.company.domain.CompanyVoiceRoboticCallees;
+import com.fs.company.vo.CalleeRoboticCallOutCountVO;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogCallPhoneVO;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogCount;
+import com.fs.company.vo.CustomerRoboticCallOutCountVO;
 import org.apache.ibatis.annotations.Param;
 
 /**
  * 调用日志_ai打电话Mapper接口
- * 
+ *
  * @author fs
  * @date 2026-01-15
  */
 public interface CompanyVoiceRoboticCallLogCallphoneMapper extends BaseMapper<CompanyVoiceRoboticCallLogCallphone>{
     /**
      * 查询调用日志_ai打电话
-     * 
+     *
      * @param logId 调用日志_ai打电话主键
      * @return 调用日志_ai打电话
      */
@@ -25,7 +27,7 @@ public interface CompanyVoiceRoboticCallLogCallphoneMapper extends BaseMapper<Co
 
     /**
      * 查询调用日志_ai打电话列表
-     * 
+     *
      * @param companyVoiceRoboticCallLogCallphone 调用日志_ai打电话
      * @return 调用日志_ai打电话集合
      */
@@ -33,7 +35,7 @@ public interface CompanyVoiceRoboticCallLogCallphoneMapper extends BaseMapper<Co
 
     /**
      * 新增调用日志_ai打电话
-     * 
+     *
      * @param companyVoiceRoboticCallLogCallphone 调用日志_ai打电话
      * @return 结果
      */
@@ -41,7 +43,7 @@ public interface CompanyVoiceRoboticCallLogCallphoneMapper extends BaseMapper<Co
 
     /**
      * 修改调用日志_ai打电话
-     * 
+     *
      * @param companyVoiceRoboticCallLogCallphone 调用日志_ai打电话
      * @return 结果
      */
@@ -49,7 +51,7 @@ public interface CompanyVoiceRoboticCallLogCallphoneMapper extends BaseMapper<Co
 
     /**
      * 删除调用日志_ai打电话
-     * 
+     *
      * @param logId 调用日志_ai打电话主键
      * @return 结果
      */
@@ -57,7 +59,7 @@ public interface CompanyVoiceRoboticCallLogCallphoneMapper extends BaseMapper<Co
 
     /**
      * 批量删除调用日志_ai打电话
-     * 
+     *
      * @param logIds 需要删除的数据主键集合
      * @return 结果
      */
@@ -92,4 +94,17 @@ public interface CompanyVoiceRoboticCallLogCallphoneMapper extends BaseMapper<Co
      * @return 公司ID
      */
     Long selectCompanyIdByBusinessId(@Param("businessId") Long businessId);
+
+    /**
+     * 按客户统计 AI 外呼呼出次数(callphone.caller_id = callees.id,callees.user_id = 客户 customer_id)
+     */
+    List<CustomerRoboticCallOutCountVO> countRoboticCallOutByCustomerIds(@Param("customerIds") List<Long> customerIds,
+                                                                         @Param("companyId") Long companyId);
+
+    /**
+     * 按外呼任务 callee 统计呼出次数(callphone.caller_id = callees.id,且限定当前任务 robotic_id)
+     */
+    List<CalleeRoboticCallOutCountVO> countRoboticCallOutByCalleeIds(@Param("calleeIds") List<Long> calleeIds,
+                                                                     @Param("roboticId") Long roboticId,
+                                                                     @Param("companyId") Long companyId);
 }

+ 2 - 0
fs-service/src/main/java/com/fs/company/mapper/CompanyWxAccountMapper.java

@@ -67,4 +67,6 @@ public interface CompanyWxAccountMapper extends BaseMapper<CompanyWxAccount> {
     List<CompanyUser> companyListAllCompany(CompanyUser companyUser);
 
     CompanyWxAccount selectByCompanyUserAndWxNo(@Param("userId") Long userId, @Param("wxNo") String wxNo);
+
+    CompanyWxAccount selectCompanyWxAccountByWxRemark(@Param("wxRemark") String wxRemark);
 }

+ 2 - 0
fs-service/src/main/java/com/fs/company/mapper/CompanyWxClientMapper.java

@@ -85,4 +85,6 @@ public interface CompanyWxClientMapper extends BaseMapper<CompanyWxClient> {
     List<CompanyWxClient> getQwAddWxList(@Param("accountIdList") List<Long> accountIdList, @Param("isWeCom") Integer isWeCom);
 
     List<CompanyWxClient4WorkFlowVO> getQwAddWxList4Workflow(@Param("accountIdList") List<Long> accountIdList, @Param("execStatus") Integer execStatus, @Param("execNodeType") Integer execNodeType, @Param("cidGroupNo") Integer cidGroupNo);
+
+    List<CompanyWxClient> selectWxV2(@Param("id") Long id, @Param("phone") String phone);
 }

+ 162 - 0
fs-service/src/main/java/com/fs/company/mapper/EasyCallInboundLlmMapper.java

@@ -0,0 +1,162 @@
+package com.fs.company.mapper;
+
+import com.fs.common.annotation.DataSource;
+import com.fs.common.enums.DataSourceType;
+import com.fs.company.vo.easycall.EasyCallBizGroupVO;
+import com.fs.company.vo.easycall.EasyCallGatewayVO;
+import com.fs.company.vo.easycall.EasyCallInboundLlmVO;
+import com.fs.company.vo.easycall.EasyCallIvrVO;
+import com.fs.company.vo.easycall.EasyCallLlmAccountVO;
+import com.fs.company.vo.easycall.EasyCallVoiceCodeVO;
+import org.apache.ibatis.annotations.Param;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 呼入大模型配置 Mapper接口
+ *
+ * @author fs
+ */
+@Repository
+public interface EasyCallInboundLlmMapper {
+
+    /**
+     * 查询呼入大模型配置
+     *
+     * @param id 主键ID
+     * @return 配置信息
+     */
+    @DataSource(DataSourceType.EASYCALL)
+    EasyCallInboundLlmVO selectInboundLlmById(Integer id);
+
+    /**
+     * 查询呼入大模型配置列表
+     *
+     * @param vo 查询条件
+     * @return 配置列表
+     */
+    @DataSource(DataSourceType.EASYCALL)
+    List<EasyCallInboundLlmVO> selectInboundLlmList(EasyCallInboundLlmVO vo);
+
+    /**
+     * 新增呼入大模型配置
+     *
+     * @param vo 配置信息
+     * @return 影响行数
+     */
+    @DataSource(DataSourceType.EASYCALL)
+    int insertInboundLlm(EasyCallInboundLlmVO vo);
+
+    /**
+     * 修改呼入大模型配置
+     *
+     * @param vo 配置信息
+     * @return 影响行数
+     */
+    @DataSource(DataSourceType.EASYCALL)
+    int updateInboundLlm(EasyCallInboundLlmVO vo);
+
+    /**
+     * 删除呼入大模型配置
+     *
+     * @param id 主键ID
+     * @return 影响行数
+     */
+    @DataSource(DataSourceType.EASYCALL)
+    int deleteInboundLlmById(Integer id);
+
+    /**
+     * 批量删除呼入大模型配置
+     *
+     * @param ids ID数组
+     * @return 影响行数
+     */
+    @DataSource(DataSourceType.EASYCALL)
+    int deleteInboundLlmByIds(String[] ids);
+
+    /**
+     * 根据被叫号码查询配置列表
+     *
+     * @param callee 被叫号码
+     * @return 配置列表
+     */
+    @DataSource(DataSourceType.EASYCALL)
+    List<EasyCallInboundLlmVO> selectInboundLlmByCallee(String callee);
+
+    /**
+     * 查询大模型账户列表
+     *
+     * @return 大模型账户列表
+     */
+    @DataSource(DataSourceType.EASYCALL)
+    List<EasyCallLlmAccountVO> selectLlmAccountList();
+
+    /**
+     * 根据ID查询大模型账户
+     *
+     * @param id 大模型账户ID
+     * @return 大模型账户信息
+     */
+    @DataSource(DataSourceType.EASYCALL)
+    EasyCallLlmAccountVO selectLlmAccountById(Integer id);
+
+    /**
+     * 根据voiceCode查询音色信息
+     *
+     * @param voiceCode 音色编号
+     * @return 音色信息
+     */
+    @DataSource(DataSourceType.EASYCALL)
+    EasyCallVoiceCodeVO selectVoiceCodeByCode(String voiceCode);
+
+    /**
+     * 查询ASR提供商列表
+     *
+     * @return ASR提供商列表
+     */
+    @DataSource(DataSourceType.EASYCALL)
+    List<Map<String, String>> selectAsrProviderList();
+
+    /**
+     * 查询TTS音色来源列表
+     *
+     * @return TTS音色来源列表
+     */
+    @DataSource(DataSourceType.EASYCALL)
+    List<Map<String, String>> selectVoiceSourceList();
+
+    /**
+     * 根据音色来源查询音色列表
+     *
+     * @param voiceSource 音色来源
+     * @return 音色列表
+     */
+    @DataSource(DataSourceType.EASYCALL)
+    List<EasyCallVoiceCodeVO> selectVoiceListBySource(String voiceSource);
+
+    /**
+     * 查询业务组列表
+     *
+     * @return 业务组列表
+     */
+    @DataSource(DataSourceType.EASYCALL)
+    List<EasyCallBizGroupVO> selectBizGroupList();
+
+    /**
+     * 查询出局网关列表
+     *
+     * @return 网关列表
+     */
+    @DataSource(DataSourceType.EASYCALL)
+    List<EasyCallGatewayVO> selectOutboundGatewayList();
+
+    /**
+     * 查询IVR列表
+     *
+     * @return IVR列表
+     */
+    @DataSource(DataSourceType.EASYCALL)
+    List<EasyCallIvrVO> selectIvrList();
+}

+ 18 - 0
fs-service/src/main/java/com/fs/company/param/AddWxActionParam.java

@@ -0,0 +1,18 @@
+package com.fs.company.param;
+
+import com.fs.wxcid.vo.wxvo.AddWxVo;
+import lombok.Data;
+
+/**
+ * @author MixLiu
+ * @date 2026/4/22 14:56
+ * @description
+ */
+
+@Data
+public class AddWxActionParam extends AddWxVo {
+
+    private String wxId;
+    private Long serverId;
+
+}

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

@@ -62,4 +62,11 @@ public interface CompanyWorkflowEngine {
      * @param inputData
      */
     void timeDoExecute(String workflowInstanceId, String nodeKey, Map<String, Object> inputData);
+
+    /**
+     * 创建sip任务
+     * @param roboticId
+     * @param workFlowId
+     */
+    Long createSipTask(Long roboticId,Long workFlowId);
 }

+ 69 - 0
fs-service/src/main/java/com/fs/company/service/ICompanyInboundCallManageService.java

@@ -0,0 +1,69 @@
+package com.fs.company.service;
+
+import com.fs.company.vo.easycall.EasyCallInboundLlmVO;
+
+import java.util.List;
+
+/**
+ * 呼入大模型配置 Service接口
+ *
+ * @author fs
+ */
+public interface ICompanyInboundCallManageService {
+
+    /**
+     * 查询呼入大模型配置
+     *
+     * @param id 主键ID
+     * @return 配置信息
+     */
+    EasyCallInboundLlmVO selectInboundLlmById(Integer id);
+
+    /**
+     * 查询呼入大模型配置列表
+     *
+     * @param vo 查询条件
+     * @return 配置列表
+     */
+    List<EasyCallInboundLlmVO> selectInboundLlmList(EasyCallInboundLlmVO vo);
+
+    /**
+     * 根据被叫号码查询配置列表
+     *
+     * @param callee 被叫号码
+     * @return 配置列表
+     */
+    List<EasyCallInboundLlmVO> selectInboundLlmByCallee(String callee);
+
+    /**
+     * 新增呼入大模型配置
+     *
+     * @param vo 配置信息
+     * @return 影响行数
+     */
+    int insertInboundLlm(EasyCallInboundLlmVO vo);
+
+    /**
+     * 修改呼入大模型配置
+     *
+     * @param vo 配置信息
+     * @return 影响行数
+     */
+    int updateInboundLlm(EasyCallInboundLlmVO vo);
+
+    /**
+     * 删除呼入大模型配置
+     *
+     * @param id 主键ID
+     * @return 影响行数
+     */
+    int deleteInboundLlmById(Integer id);
+
+    /**
+     * 批量删除呼入大模型配置
+     *
+     * @param ids ID字符串,逗号分隔
+     * @return 影响行数
+     */
+    int deleteInboundLlmByIds(String ids);
+}

+ 61 - 0
fs-service/src/main/java/com/fs/company/service/ICompanySiptaskInfoService.java

@@ -0,0 +1,61 @@
+package com.fs.company.service;
+
+import java.util.List;
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.fs.company.domain.CompanySiptaskInfo;
+
+/**
+ * 任务与外呼sip任务关联关系Service接口
+ * 
+ * @author fs
+ * @date 2026-04-20
+ */
+public interface ICompanySiptaskInfoService extends IService<CompanySiptaskInfo>{
+    /**
+     * 查询任务与外呼sip任务关联关系
+     * 
+     * @param id 任务与外呼sip任务关联关系主键
+     * @return 任务与外呼sip任务关联关系
+     */
+    CompanySiptaskInfo selectCompanySiptaskInfoById(Long id);
+
+    /**
+     * 查询任务与外呼sip任务关联关系列表
+     * 
+     * @param companySiptaskInfo 任务与外呼sip任务关联关系
+     * @return 任务与外呼sip任务关联关系集合
+     */
+    List<CompanySiptaskInfo> selectCompanySiptaskInfoList(CompanySiptaskInfo companySiptaskInfo);
+
+    /**
+     * 新增任务与外呼sip任务关联关系
+     * 
+     * @param companySiptaskInfo 任务与外呼sip任务关联关系
+     * @return 结果
+     */
+    int insertCompanySiptaskInfo(CompanySiptaskInfo companySiptaskInfo);
+
+    /**
+     * 修改任务与外呼sip任务关联关系
+     * 
+     * @param companySiptaskInfo 任务与外呼sip任务关联关系
+     * @return 结果
+     */
+    int updateCompanySiptaskInfo(CompanySiptaskInfo companySiptaskInfo);
+
+    /**
+     * 批量删除任务与外呼sip任务关联关系
+     * 
+     * @param ids 需要删除的任务与外呼sip任务关联关系主键集合
+     * @return 结果
+     */
+    int deleteCompanySiptaskInfoByIds(Long[] ids);
+
+    /**
+     * 删除任务与外呼sip任务关联关系信息
+     * 
+     * @param id 任务与外呼sip任务关联关系主键
+     * @return 结果
+     */
+    int deleteCompanySiptaskInfoById(Long id);
+}

+ 13 - 7
fs-service/src/main/java/com/fs/company/service/ICompanyVoiceRoboticCallLogCallphoneService.java

@@ -4,19 +4,21 @@ import java.util.List;
 import com.baomidou.mybatisplus.extension.service.IService;
 import com.fs.company.domain.CompanyVoiceRoboticCallLogCallphone;
 import com.fs.company.domain.CompanyVoiceRoboticCallLogSendmsg;
+import com.fs.company.vo.CalleeRoboticCallOutCountVO;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogCallPhoneVO;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogCount;
+import com.fs.company.vo.CustomerRoboticCallOutCountVO;
 
 /**
  * 调用日志_ai打电话Service接口
- * 
+ *
  * @author fs
  * @date 2026-01-15
  */
 public interface ICompanyVoiceRoboticCallLogCallphoneService extends IService<CompanyVoiceRoboticCallLogCallphone>{
     /**
      * 查询调用日志_ai打电话
-     * 
+     *
      * @param logId 调用日志_ai打电话主键
      * @return 调用日志_ai打电话
      */
@@ -24,7 +26,7 @@ public interface ICompanyVoiceRoboticCallLogCallphoneService extends IService<Co
 
     /**
      * 查询调用日志_ai打电话列表
-     * 
+     *
      * @param companyVoiceRoboticCallLogCallphone 调用日志_ai打电话
      * @return 调用日志_ai打电话集合
      */
@@ -32,7 +34,7 @@ public interface ICompanyVoiceRoboticCallLogCallphoneService extends IService<Co
 
     /**
      * 新增调用日志_ai打电话
-     * 
+     *
      * @param companyVoiceRoboticCallLogCallphone 调用日志_ai打电话
      * @return 结果
      */
@@ -40,7 +42,7 @@ public interface ICompanyVoiceRoboticCallLogCallphoneService extends IService<Co
 
     /**
      * 修改调用日志_ai打电话
-     * 
+     *
      * @param companyVoiceRoboticCallLogCallphone 调用日志_ai打电话
      * @return 结果
      */
@@ -48,7 +50,7 @@ public interface ICompanyVoiceRoboticCallLogCallphoneService extends IService<Co
 
     /**
      * 批量删除调用日志_ai打电话
-     * 
+     *
      * @param logIds 需要删除的调用日志_ai打电话主键集合
      * @return 结果
      */
@@ -56,7 +58,7 @@ public interface ICompanyVoiceRoboticCallLogCallphoneService extends IService<Co
 
     /**
      * 删除调用日志_ai打电话信息
-     * 
+     *
      * @param logId 调用日志_ai打电话主键
      * @return 结果
      */
@@ -70,6 +72,10 @@ public interface ICompanyVoiceRoboticCallLogCallphoneService extends IService<Co
      */
     List<CompanyVoiceRoboticCallLogCallphone> selectCompanyVoiceRoboticCallLogCallphoneListData(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone);
 
+    List<CustomerRoboticCallOutCountVO> countRoboticCallOutByCustomerIds(List<Long> customerIds, Long companyId);
+
+    List<CalleeRoboticCallOutCountVO> countRoboticCallOutByCalleeIds(List<Long> calleeIds, Long roboticId, Long companyId);
+
     /**
      * 查询 calleesIds
      * @param customerId

+ 2 - 0
fs-service/src/main/java/com/fs/company/service/ICompanyWxClientService.java

@@ -73,6 +73,8 @@ public interface ICompanyWxClientService extends IService<CompanyWxClient> {
 
     List<CompanyWxClient4WorkFlowVO> getAddWxList4Workflow(List<Long> accountIdList,Integer cidGroupId);
 
+    List<CompanyWxClient4WorkFlowVO> getAddWxList4WorkflowNew(List<Long> accountIdList, Integer cidGroupId);
+
     List<CompanyWxClient> getQwAddWxList(List<Long> accountIdList,Integer isWeCom);
 
     List<CompanyWxClient4WorkFlowVO> getQwAddWxList4Workflow(List<Long> accountIdList,Integer cidGroupNo);

+ 99 - 0
fs-service/src/main/java/com/fs/company/service/impl/CompanyInboundCallManageServiceImpl.java

@@ -0,0 +1,99 @@
+package com.fs.company.service.impl;
+
+import com.fs.common.core.text.Convert;
+import com.fs.company.mapper.EasyCallInboundLlmMapper;
+import com.fs.company.service.ICompanyInboundCallManageService;
+import com.fs.company.vo.easycall.EasyCallInboundLlmVO;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+/**
+ * 呼入大模型配置 Service业务层处理
+ *
+ * @author fs
+ */
+@Service
+public class CompanyInboundCallManageServiceImpl implements ICompanyInboundCallManageService {
+
+    @Autowired
+    private EasyCallInboundLlmMapper inboundLlmMapper;
+
+    /**
+     * 查询呼入大模型配置
+     *
+     * @param id 主键ID
+     * @return 配置信息
+     */
+    @Override
+    public EasyCallInboundLlmVO selectInboundLlmById(Integer id) {
+        return inboundLlmMapper.selectInboundLlmById(id);
+    }
+
+    /**
+     * 查询呼入大模型配置列表
+     *
+     * @param vo 查询条件
+     * @return 配置列表
+     */
+    @Override
+    public List<EasyCallInboundLlmVO> selectInboundLlmList(EasyCallInboundLlmVO vo) {
+        return inboundLlmMapper.selectInboundLlmList(vo);
+    }
+
+    /**
+     * 根据被叫号码查询配置列表
+     *
+     * @param callee 被叫号码
+     * @return 配置列表
+     */
+    @Override
+    public List<EasyCallInboundLlmVO> selectInboundLlmByCallee(String callee) {
+        return inboundLlmMapper.selectInboundLlmByCallee(callee);
+    }
+
+    /**
+     * 新增呼入大模型配置
+     *
+     * @param vo 配置信息
+     * @return 影响行数
+     */
+    @Override
+    public int insertInboundLlm(EasyCallInboundLlmVO vo) {
+        return inboundLlmMapper.insertInboundLlm(vo);
+    }
+
+    /**
+     * 修改呼入大模型配置
+     *
+     * @param vo 配置信息
+     * @return 影响行数
+     */
+    @Override
+    public int updateInboundLlm(EasyCallInboundLlmVO vo) {
+        return inboundLlmMapper.updateInboundLlm(vo);
+    }
+
+    /**
+     * 删除呼入大模型配置
+     *
+     * @param id 主键ID
+     * @return 影响行数
+     */
+    @Override
+    public int deleteInboundLlmById(Integer id) {
+        return inboundLlmMapper.deleteInboundLlmById(id);
+    }
+
+    /**
+     * 批量删除呼入大模型配置
+     *
+     * @param ids ID字符串,逗号分隔
+     * @return 影响行数
+     */
+    @Override
+    public int deleteInboundLlmByIds(String ids) {
+        return inboundLlmMapper.deleteInboundLlmByIds(Convert.toStrArray(ids));
+    }
+}

+ 91 - 0
fs-service/src/main/java/com/fs/company/service/impl/CompanySiptaskInfoServiceImpl.java

@@ -0,0 +1,91 @@
+package com.fs.company.service.impl;
+
+import java.util.List;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import com.fs.company.mapper.CompanySiptaskInfoMapper;
+import com.fs.company.domain.CompanySiptaskInfo;
+import com.fs.company.service.ICompanySiptaskInfoService;
+
+/**
+ * 任务与外呼sip任务关联关系Service业务层处理
+ * 
+ * @author fs
+ * @date 2026-04-20
+ */
+@Service
+public class CompanySiptaskInfoServiceImpl extends ServiceImpl<CompanySiptaskInfoMapper, CompanySiptaskInfo> implements ICompanySiptaskInfoService {
+
+    /**
+     * 查询任务与外呼sip任务关联关系
+     * 
+     * @param id 任务与外呼sip任务关联关系主键
+     * @return 任务与外呼sip任务关联关系
+     */
+    @Override
+    public CompanySiptaskInfo selectCompanySiptaskInfoById(Long id)
+    {
+        return baseMapper.selectCompanySiptaskInfoById(id);
+    }
+
+    /**
+     * 查询任务与外呼sip任务关联关系列表
+     * 
+     * @param companySiptaskInfo 任务与外呼sip任务关联关系
+     * @return 任务与外呼sip任务关联关系
+     */
+    @Override
+    public List<CompanySiptaskInfo> selectCompanySiptaskInfoList(CompanySiptaskInfo companySiptaskInfo)
+    {
+        return baseMapper.selectCompanySiptaskInfoList(companySiptaskInfo);
+    }
+
+    /**
+     * 新增任务与外呼sip任务关联关系
+     * 
+     * @param companySiptaskInfo 任务与外呼sip任务关联关系
+     * @return 结果
+     */
+    @Override
+    public int insertCompanySiptaskInfo(CompanySiptaskInfo companySiptaskInfo)
+    {
+        return baseMapper.insertCompanySiptaskInfo(companySiptaskInfo);
+    }
+
+    /**
+     * 修改任务与外呼sip任务关联关系
+     * 
+     * @param companySiptaskInfo 任务与外呼sip任务关联关系
+     * @return 结果
+     */
+    @Override
+    public int updateCompanySiptaskInfo(CompanySiptaskInfo companySiptaskInfo)
+    {
+        return baseMapper.updateCompanySiptaskInfo(companySiptaskInfo);
+    }
+
+    /**
+     * 批量删除任务与外呼sip任务关联关系
+     * 
+     * @param ids 需要删除的任务与外呼sip任务关联关系主键
+     * @return 结果
+     */
+    @Override
+    public int deleteCompanySiptaskInfoByIds(Long[] ids)
+    {
+        return baseMapper.deleteCompanySiptaskInfoByIds(ids);
+    }
+
+    /**
+     * 删除任务与外呼sip任务关联关系信息
+     * 
+     * @param id 任务与外呼sip任务关联关系主键
+     * @return 结果
+     */
+    @Override
+    public int deleteCompanySiptaskInfoById(Long id)
+    {
+        return baseMapper.deleteCompanySiptaskInfoById(id);
+    }
+}

+ 60 - 13
fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticCallLogCallphoneServiceImpl.java

@@ -5,7 +5,6 @@ import java.math.RoundingMode;
 import java.util.*;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.Executor;
-import java.util.stream.Collectors;
 
 import cn.hutool.json.JSONUtil;
 import com.alibaba.fastjson.JSON;
@@ -16,7 +15,6 @@ import com.fs.aicall.domain.apiresult.Notify;
 import com.fs.aicall.domain.apiresult.PushIIntentionResult;
 import com.fs.aicall.domain.param.getDialogMapDomain;
 import com.fs.aicall.service.AiCallService;
-import com.fs.common.constant.Constants;
 import com.fs.common.core.domain.entity.SysDictData;
 import com.fs.common.core.redis.RedisCache;
 import com.fs.common.utils.DateUtils;
@@ -25,18 +23,14 @@ import com.fs.common.utils.StringUtils;
 import com.fs.company.domain.*;
 import com.fs.company.mapper.*;
 import com.fs.company.service.CompanyWorkflowEngine;
-import com.fs.company.vo.CidConfigVO;
-import com.fs.company.vo.CompanyVoiceRoboticCallLogCallPhoneVO;
-import com.fs.company.vo.CompanyVoiceRoboticCallLogCount;
-import com.fs.company.vo.DictVO;
+import com.fs.company.vo.*;
 import com.fs.company.vo.easycall.EasyCallCallPhoneVO;
+import com.fs.crm.service.ICrmCustomerAnalyzeService;
 import com.fs.crm.service.ICrmCustomerPropertyService;
 import com.fs.qw.domain.QwUser;
 import com.fs.qw.mapper.QwUserMapper;
-import com.fs.store.config.StoreConfig;
 import com.fs.system.service.ISysConfigService;
 import com.fs.system.service.impl.SysDictTypeServiceImpl;
-import com.fs.voice.constant.Constant;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Qualifier;
@@ -77,6 +71,8 @@ public class CompanyVoiceRoboticCallLogCallphoneServiceImpl extends ServiceImpl<
     @Autowired
     QwUserMapper qwUserMapper;
     @Autowired
+    private ICrmCustomerAnalyzeService crmCustomerAnalyzeService;
+    @Autowired
     @Qualifier("cidWorkFlowExecutor")
     private Executor cidWorkFlowExecutor;
 
@@ -252,7 +248,7 @@ public class CompanyVoiceRoboticCallLogCallphoneServiceImpl extends ServiceImpl<
                 baseMapper.updateCompanyVoiceRoboticCallLogCallphone(companyVoiceRoboticCallLog);
 
                 if (StringUtils.isNotBlank(notify.getUserData())) {
-                    JSONObject userData = JSONObject.parseObject(redisCache2.getCacheObject(WORKFLOW_CALL_ONE_REDIS_KEY + notify.getUserData()), JSONObject.class);
+                    JSONObject userData = parseRedisCacheToJsonObject(redisCache2.getCacheObject(WORKFLOW_CALL_ONE_REDIS_KEY + notify.getUserData()));
                     if (null != userData && userData.containsKey("callBackUuid") && userData.containsKey("workflowInstanceId") && userData.containsKey("nodeKey")) {
                         Map<String, Object> param = new HashMap<>();
                         param.put("callBackUuid", userData.getString("callBackUuid"));
@@ -313,16 +309,32 @@ public class CompanyVoiceRoboticCallLogCallphoneServiceImpl extends ServiceImpl<
                 companyVoiceRoboticCallLog.setCallCreateTime(createTime);
                 Long answerTime = result.getCallEndTime();
                 companyVoiceRoboticCallLog.setCallAnswerTime(answerTime);
-                String intention = result.getIntent();
+                String intention = null;
+                if (StringUtils.isNotBlank(result.getDialogue())) {
+                    try {
+                        intention = crmCustomerAnalyzeService.aiIntentionDegree(
+                                result.getDialogue(),
+                                java.time.LocalTime.now().getLong(java.time.temporal.ChronoField.MILLI_OF_SECOND)
+                        );
+                    } catch (Exception e) {
+                        log.error("easyCall回调日志意向度AI解析失败,uuid={}", result.getUuid(), e);
+                    }
+                }
+                // 历史第三方值(当前不启用,保留用于回滚)
+                // String intention = result.getIntent();
                 String intentf = null;
+                final String intentionLabel = intention;
                 List<SysDictData> customerIntentionLevel = sysDictTypeService.selectDictDataByType("customer_intention_level");
-                if (!isPositiveInteger(intention)) {
-                    Optional<SysDictData> firstDict = customerIntentionLevel.stream().filter(e -> e.getDictLabel().equals(intention)).findFirst();
+                if (!isPositiveInteger(intentionLabel)) {
+                    Optional<SysDictData> firstDict = customerIntentionLevel.stream().filter(e -> e.getDictLabel().equals(intentionLabel)).findFirst();
                     if (firstDict.isPresent()) {
                         SysDictData sysDictData = firstDict.get();
                         intentf = sysDictData.getDictValue();
                     }
+                } else {
+                    intentf = intentionLabel;
                 }
+                if (StringUtils.isBlank(intentf)) intentf = "0";
                 companyVoiceRoboticCallLog.setIntention(intentf);
                 companyVoiceRoboticCallLog.setCallTime(Long.valueOf(result.getTimeLen()/1000));
                 BigDecimal callCharge = cidConfigVO.getCallCharge();
@@ -340,7 +352,7 @@ public class CompanyVoiceRoboticCallLogCallphoneServiceImpl extends ServiceImpl<
 
                 if (StringUtils.isNotBlank(result.getBizJson())) {
                     JSONObject bizJson = JSONObject.parseObject(result.getBizJson());
-                    JSONObject userData = JSONObject.parseObject(redisCache2.getCacheObject(EASYCALL_WORKFLOW_REDIS_KEY +  bizJson.getString("callBackUuid")), JSONObject.class);
+                    JSONObject userData = parseRedisCacheToJsonObject(redisCache2.getCacheObject(EASYCALL_WORKFLOW_REDIS_KEY + bizJson.getString("callBackUuid")));
                     if (null != userData && userData.containsKey("callBackUuid") && userData.containsKey("workflowInstanceId") && userData.containsKey("nodeKey")) {
                         Map<String, Object> param = new HashMap<>();
                         param.put("callBackUuid", userData.getString("callBackUuid"));
@@ -380,6 +392,41 @@ public class CompanyVoiceRoboticCallLogCallphoneServiceImpl extends ServiceImpl<
         return result;
     }
 
+    @Override
+    public List<CustomerRoboticCallOutCountVO> countRoboticCallOutByCustomerIds(List<Long> customerIds, Long companyId) {
+        if (customerIds == null || customerIds.isEmpty()) {
+            return Collections.emptyList();
+        }
+        return baseMapper.countRoboticCallOutByCustomerIds(customerIds, companyId);
+    }
+
+    @Override
+    public List<CalleeRoboticCallOutCountVO> countRoboticCallOutByCalleeIds(List<Long> calleeIds, Long roboticId, Long companyId) {
+        if (calleeIds == null || calleeIds.isEmpty() || roboticId == null) {
+            return Collections.emptyList();
+        }
+        return baseMapper.countRoboticCallOutByCalleeIds(calleeIds, roboticId, companyId);
+    }
+    /**
+     * Redis 中 workflow 回调缓存可能是 String 或已反序列化的 JSONObject,避免 ClassCastException。
+     */
+    private static JSONObject parseRedisCacheToJsonObject(Object cacheObj) {
+        if (cacheObj == null) {
+            return null;
+        }
+        if (cacheObj instanceof JSONObject) {
+            return (JSONObject) cacheObj;
+        }
+        if (cacheObj instanceof String) {
+            String s = (String) cacheObj;
+            if (StringUtils.isBlank(s)) {
+                return null;
+            }
+            return JSONObject.parseObject(s);
+        }
+        return JSONObject.parseObject(JSONObject.toJSONString(cacheObj));
+    }
+
     @Override
     public List<Long> getCallerIdsByCustomerId(Long customerId) {
         return companyVoiceRoboticCalleesMapper.getCallerIdsByCustomerId(customerId);

+ 149 - 26
fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticServiceImpl.java

@@ -30,6 +30,7 @@ import com.fs.company.vo.easycall.EasyCallCallPhoneVO;
 import com.fs.crm.domain.CrmCustomer;
 import com.fs.crm.mapper.CrmCustomerMapper;
 import com.fs.crm.param.SmsSendBatchParam;
+import com.fs.crm.service.ICrmCustomerAnalyzeService;
 import com.fs.crm.service.impl.CrmCustomerServiceImpl;
 import com.fs.enums.ExecutionStatusEnum;
 import com.fs.enums.NodeTypeEnum;
@@ -47,12 +48,15 @@ import lombok.Synchronized;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.scheduling.annotation.Async;
 import org.springframework.stereotype.Service;
+
+import java.time.temporal.ChronoField;
 import java.util.*;
 import java.util.function.Function;
 import java.util.stream.Collectors;
 
 import static com.fs.company.service.impl.call.node.AbstractWorkflowNode.companyVoiceRoboticCallLogCallphoneMapper;
 import static com.fs.company.service.impl.call.node.AiCallTaskNode.EASYCALL_WORKFLOW_REDIS_KEY;
+import static java.time.LocalTime.now;
 
 
 /**
@@ -84,6 +88,7 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
     private final CompanyVoiceRoboticWxServiceImpl companyVoiceRoboticWxService;
 
     private final CrmCustomerMapper crmCustomerMapper;
+    private final ICrmCustomerAnalyzeService crmCustomerAnalyzeService;
 
     private final CompanyWxClientServiceImpl companyWxClientServiceImpl;
 
@@ -125,6 +130,24 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
     /** 每次重试等待时长(毫秒) */
     private static final long EASYCALL_INTENT_RETRY_INTERVAL_MS = 30000L;
 
+    /** EasyCall dialogue 对话内容重试队列 Redis key 前缀,value 为已重试次数 */
+    private static final String EASYCALL_DIALOGUE_RETRY_KEY = "easycall:dialogue:retry:";
+    /** dialogue 对话内容等待重试最大次数(每次间隔约30秒,最多等待 5*30=150秒) */
+    private static final int EASYCALL_DIALOGUE_MAX_RETRY = 5;
+    /** dialogue 每次重试等待时长(毫秒) */
+    private static final long EASYCALL_DIALOGUE_RETRY_INTERVAL_MS = 30000L;
+
+    /**
+     * 判断 dialogue 对话内容是否为空(null、空字符串、空数组 "[]" 均视为无对话内容)
+     */
+    private boolean isDialogueEmpty(String dialogue) {
+        if (StringUtils.isBlank(dialogue)) {
+            return true;
+        }
+        String trimmed = dialogue.trim();
+        return "[]".equals(trimmed);
+    }
+
     /**
      * 查询机器人外呼任务
      *
@@ -822,11 +845,11 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
     @Override
     @Async("cidWorkFlowExecutor")
     public void callerResult4EasyCall(CdrDetailVo result) {
-        try {
-            Thread.sleep(20000L);
-        } catch (InterruptedException e) {
-            throw new RuntimeException(e);
-        }
+//        try {
+//            Thread.sleep(3000L);
+//        } catch (InterruptedException e) {
+//            throw new RuntimeException(e);
+//        }
 //        EASYCALL
         log.info("进入easyCall外呼结果回调:{}", JSON.toJSONString(result));
         if (result == null || StringUtils.isBlank(result.getUuid())) return;
@@ -843,6 +866,7 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
             return;
         }
         // intent(意向度)由对方异步评估写入,回调时可能尚未赋值,进入延迟重试队列等待
+        /*
         if (StringUtils.isBlank(callPhoneRes.getIntent())) {
             String retryKey = EASYCALL_INTENT_RETRY_KEY + result.getUuid();
             Integer retryCount = redisCache2.getCacheObject(retryKey);
@@ -864,6 +888,76 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
         // intent 已有值,直接正常处理
         redisCache2.deleteObject(EASYCALL_INTENT_RETRY_KEY + result.getUuid());
         doHandleEasyCallResult(callPhoneRes);
+        */
+
+        // 当前:根据对话内容同步调用自家 AI 计算意向度,不依赖第三方 intent
+//        redisCache2.deleteObject(EASYCALL_INTENT_RETRY_KEY + result.getUuid());
+
+        // dialogue(对话内容)由对方异步写入,回调时可能尚未赋值,进入延迟重试队列等待
+        if (isDialogueEmpty(callPhoneRes.getDialogue())) {
+            String retryKey = EASYCALL_DIALOGUE_RETRY_KEY + result.getUuid();
+            Integer retryCount = redisCache2.getCacheObject(retryKey);
+            if (retryCount == null) {
+                retryCount = 0;
+            }
+            if (retryCount < EASYCALL_DIALOGUE_MAX_RETRY) {
+                redisCache2.setCacheObject(retryKey, retryCount + 1, 10, java.util.concurrent.TimeUnit.MINUTES);
+                log.info("easyCall外呼回调dialogue对话内容暂未写入,uuid={},第{}次放入延迟重试队列", result.getUuid(), retryCount + 1);
+                doRetryDialogue4EasyCall(result, retryCount + 1);
+            } else {
+                // 超过最大重试次数,以 dialogue 为空兜底继续处理
+                log.warn("easyCall外呼回调dialogue对话内容在{}次重试后仍为空,uuid={},以对话为空兜底处理", EASYCALL_DIALOGUE_MAX_RETRY, result.getUuid());
+                redisCache2.deleteObject(retryKey);
+                doHandleEasyCallResult(callPhoneRes);
+            }
+            return;
+        }
+        // dialogue 已有值,直接正常处理
+        redisCache2.deleteObject(EASYCALL_DIALOGUE_RETRY_KEY + result.getUuid());
+        doHandleEasyCallResult(callPhoneRes);
+    }
+
+    /**
+     * 延迟重试处理 EasyCall 外呼回调(等待 dialogue 对话内容异步写入完成)
+     * 每次重试前等待 {@link #EASYCALL_DIALOGUE_RETRY_INTERVAL_MS} 毫秒后重新拉取数据
+     */
+    @Async("cidWorkFlowExecutor")
+    public void doRetryDialogue4EasyCall(CdrDetailVo result, int currentRetry) {
+        try {
+            Thread.sleep(EASYCALL_DIALOGUE_RETRY_INTERVAL_MS);
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            log.warn("easyCall dialogue重试等待被中断, uuid={}", result.getUuid());
+            return;
+        }
+        log.info("easyCall dialogue重试第{}次开始, uuid={}", currentRetry, result.getUuid());
+        EasyCallCallPhoneVO callPhoneRes = easyCallMapper.getCallPhoneInfoByUuid(result.getUuid());
+        if (null == callPhoneRes) {
+            log.error("easyCall dialogue重试时仍未查询到外呼结果, uuid={}", result.getUuid());
+            return;
+        }
+        if (isDialogueEmpty(callPhoneRes.getDialogue())) {
+            // dialogue 仍为空,继续判断是否还有剩余重试次数
+            String retryKey = EASYCALL_DIALOGUE_RETRY_KEY + result.getUuid();
+            Integer retryCount = redisCache2.getCacheObject(retryKey);
+            if (retryCount == null) {
+                retryCount = currentRetry;
+            }
+            if (retryCount < EASYCALL_DIALOGUE_MAX_RETRY) {
+                redisCache2.setCacheObject(retryKey, retryCount + 1, 10, java.util.concurrent.TimeUnit.MINUTES);
+                log.info("easyCall dialogue对话内容仍未写入,uuid={},第{}次继续延迟重试", result.getUuid(), retryCount + 1);
+                doRetryDialogue4EasyCall(result, retryCount + 1);
+            } else {
+                log.warn("easyCall dialogue对话内容在{}次重试后仍为空,uuid={},以对话为空兜底处理", EASYCALL_DIALOGUE_MAX_RETRY, result.getUuid());
+                redisCache2.deleteObject(retryKey);
+                doHandleEasyCallResult(callPhoneRes);
+            }
+            return;
+        }
+        // dialogue 已写入完成,正常处理
+        log.info("easyCall dialogue重试第{}次成功获取到对话内容,uuid={}", currentRetry, result.getUuid());
+        redisCache2.deleteObject(EASYCALL_DIALOGUE_RETRY_KEY + result.getUuid());
+        doHandleEasyCallResult(callPhoneRes);
     }
 
     /**
@@ -916,7 +1010,15 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
     private void doHandleEasyCallResult(EasyCallCallPhoneVO callPhoneRes) {
         //等待数据信息
         JSONObject bizJson = JSONObject.parseObject(callPhoneRes.getBizJson());
-        String cacheString = (String) redisCache2.getCacheObject(EASYCALL_WORKFLOW_REDIS_KEY + bizJson.getString("callBackUuid"));
+        Object cacheObj = redisCache2.getCacheObject(EASYCALL_WORKFLOW_REDIS_KEY + bizJson.getString("callBackUuid"));
+        String cacheString;
+        if (cacheObj instanceof String) {
+            cacheString = (String) cacheObj;
+        } else if (cacheObj instanceof JSONObject) {
+            cacheString = ((JSONObject) cacheObj).toJSONString();
+        } else {
+            cacheString = cacheObj == null ? null : JSONObject.toJSONString(cacheObj);
+        }
         if (StringUtils.isBlank(cacheString)) {
             log.error("easyCall外呼回调缓存信息缺失, uuid={}", callPhoneRes.getUuid());
             return;
@@ -974,7 +1076,23 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
 
     public void pushDialogContent4EasyCall(JSONObject cacheInfo, EasyCallCallPhoneVO callPhoneRes) {
 
-        String intention = getIntention(callPhoneRes.getIntent());
+        String intention = null;
+        String intentionDegree = null;
+        if (StringUtils.isNotBlank(callPhoneRes.getDialogue())) {
+            log.info("【验证】意向度来源=自家AI, uuid={}, dialogueLength={}", callPhoneRes.getUuid(),
+                    StringUtils.isBlank(callPhoneRes.getDialogue()) ? 0 : callPhoneRes.getDialogue().length());
+            try {
+                intentionDegree = crmCustomerAnalyzeService.aiIntentionDegree(
+                        callPhoneRes.getDialogue(),
+                        now().getLong(ChronoField.MILLI_OF_SECOND)
+                );
+                log.info("【验证】意向度结果={}, uuid={}", intentionDegree, callPhoneRes.getUuid());
+                intention = getIntention(intentionDegree);
+            } catch (Exception e) {
+                log.error("easyCall意向度AI解析失败,uuid={},将使用意向未知兜底", callPhoneRes.getUuid(), e);
+            }
+        }
+        // 2) 最终兜底:意向未知
         if (StringUtils.isEmpty(intention)) {
             intention = "0";
         }
@@ -1337,8 +1455,10 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
         companyWxClient.setDialogId(companyVoiceRoboticWx.getWxDialogId());
         if (Integer.valueOf(2).equals(robotic.getIsWeCom())) {
             companyWxClient.setCompanyUserId(qwUser.getCompanyUserId());
+            companyWxClient.setCompanyId(qwUser.getCompanyId());
         } else if (Integer.valueOf(1).equals(robotic.getIsWeCom())) {
             companyWxClient.setCompanyUserId(companyWxAccount.getCompanyUserId());
+            companyWxClient.setCompanyId(companyWxAccount.getCompanyId());
         }
         CrmCustomer crmCustomer = crmCustomerService.selectCrmCustomerById(companyWxClient.getCustomerId());
         companyWxClient.setNickName(crmCustomer.getCustomerName());
@@ -1400,8 +1520,10 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
             companyWxClient.setDialogId(wx.getWxDialogId());
             if (robotic.getIsWeCom() == 2) {
                 companyWxClient.setCompanyUserId(qwMap.get(wx.getAccountId()).getCompanyUserId());
+                companyWxClient.setCompanyId(qwMap.get(wx.getAccountId()).getCompanyId());
             } else {
                 companyWxClient.setCompanyUserId(accountMap.get(wx.getAccountId()).getCompanyUserId());
+                companyWxClient.setCompanyId(accountMap.get(wx.getAccountId()).getCompanyId());
             }
             companyWxClient.setNickName(crmCustomer.getCustomerName());
             companyWxClient.setPhone(crmCustomer.getMobile());
@@ -1473,6 +1595,7 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
             client.setRoboticWxId(companyVoiceRoboticWx.getId());
             client.setCompanyId(companyVoiceRoboticWx.getAccount().getCompanyId());
             client.setCompanyUserId(companyVoiceRoboticWx.getAccount().getCompanyUserId());
+            client.setCompanyId(companyVoiceRoboticWx.getAccount().getCompanyId());
             CompanyWxAccount account = new CompanyWxAccount();
             account.setId(companyVoiceRoboticWx.getAccount().getId());
             account.setAllocateNum(companyVoiceRoboticWx.getAccount().getAllocateNum() + 1);
@@ -1547,25 +1670,25 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
                 .filter(Objects::nonNull)
                 .collect(Collectors.toList());
 
-        if (!businessIds.isEmpty()) {
-            List<CompanyVoiceRoboticBusiness> businesses = companyVoiceRoboticBusinessMapper.selectList(new LambdaQueryWrapper<CompanyVoiceRoboticBusiness>()
-                    .in(CompanyVoiceRoboticBusiness::getId, businessIds));
-            if (ObjectUtil.isNotEmpty(businesses)) {
-                Map<Long, CompanyVoiceRoboticBusiness> businessMap = businesses.stream().collect(Collectors.toMap(CompanyVoiceRoboticBusiness::getId, Function.identity()));
-                records.forEach(record -> {
-                    if (record.getBusinessId() != null && businessMap.containsKey(record.getBusinessId())) {
-                        CompanyVoiceRoboticBusiness business = businessMap.get(record.getBusinessId());
-                        CompanyVoiceRoboticCallLogCallphone callLogCallphone = companyVoiceRoboticCallLogCallphoneMapper.selectOne(new LambdaQueryWrapper<CompanyVoiceRoboticCallLogCallphone>()
-                                .eq(CompanyVoiceRoboticCallLogCallphone::getRoboticId, business.getRoboticId())
-                                .eq(CompanyVoiceRoboticCallLogCallphone::getCallerId, business.getCalleeId()));
-                        if (ObjectUtil.isNotEmpty(callLogCallphone)) {
-                            record.setContentList(callLogCallphone.getContentList());
-                            record.setIntention(callLogCallphone.getIntention());
-                        }
-                    }
-                });
-            }
-        }
+//        if (!businessIds.isEmpty()) {
+//            List<CompanyVoiceRoboticBusiness> businesses = companyVoiceRoboticBusinessMapper.selectList(new LambdaQueryWrapper<CompanyVoiceRoboticBusiness>()
+//                    .in(CompanyVoiceRoboticBusiness::getId, businessIds));
+//            if (ObjectUtil.isNotEmpty(businesses)) {
+//                Map<Long, CompanyVoiceRoboticBusiness> businessMap = businesses.stream().collect(Collectors.toMap(CompanyVoiceRoboticBusiness::getId, Function.identity()));
+//                records.forEach(record -> {
+//                    if (record.getBusinessId() != null && businessMap.containsKey(record.getBusinessId())) {
+//                        CompanyVoiceRoboticBusiness business = businessMap.get(record.getBusinessId());
+//                        CompanyVoiceRoboticCallLogCallphone callLogCallphone = companyVoiceRoboticCallLogCallphoneMapper.selectOne(new LambdaQueryWrapper<CompanyVoiceRoboticCallLogCallphone>()
+//                                .eq(CompanyVoiceRoboticCallLogCallphone::getRoboticId, business.getRoboticId())
+//                                .eq(CompanyVoiceRoboticCallLogCallphone::getCallerId, business.getCalleeId()));
+//                        if (ObjectUtil.isNotEmpty(callLogCallphone)) {
+//                            record.setContentList(callLogCallphone.getContentList());
+//                            record.setIntention(callLogCallphone.getIntention());
+//                        }
+//                    }
+//                });
+//            }
+//        }
 
         if (!instanceIds.isEmpty()) {
             List<CompanyAiWorkflowExecLog> allLogs = companyAiWorkflowExecLogMapper.selectByInstanceIds(instanceIds);

+ 73 - 9
fs-service/src/main/java/com/fs/company/service/impl/CompanyWorkflowEngineImpl.java

@@ -1,23 +1,22 @@
 package com.fs.company.service.impl;
 
 import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fs.common.exception.CustomException;
 import com.fs.common.utils.StringUtils;
-import com.fs.company.domain.CompanyAiWorkflowExec;
-import com.fs.company.domain.CompanyAiWorkflowExecLog;
-import com.fs.company.domain.CompanyWorkflow;
-import com.fs.company.domain.CompanyWorkflowNode;
-import com.fs.company.mapper.CompanyAiWorkflowExecLogMapper;
-import com.fs.company.mapper.CompanyAiWorkflowExecMapper;
-import com.fs.company.mapper.CompanyWorkflowMapper;
-import com.fs.company.mapper.CompanyWorkflowNodeMapper;
+import com.fs.company.domain.*;
+import com.fs.company.mapper.*;
 import com.fs.company.param.ExecutionContext;
 import com.fs.company.service.CompanyWorkflowEngine;
 import com.fs.company.service.IWorkflowNode;
+import com.fs.company.service.easycall.IEasyCallService;
 import com.fs.company.service.impl.call.node.WorkflowNodeFactory;
+import com.fs.company.vo.AiCallConfigVO;
 import com.fs.company.vo.ExecutionResult;
+import com.fs.company.vo.easycall.EasyCallCreateTaskParam;
+import com.fs.company.vo.easycall.EasyCallTaskVO;
 import com.fs.enums.ExecutionStatusEnum;
 import com.fs.enums.NodeTypeEnum;
 import lombok.extern.slf4j.Slf4j;
@@ -60,6 +59,15 @@ public class CompanyWorkflowEngineImpl implements CompanyWorkflowEngine {
     @Autowired
     private CompanyAiWorkflowExecLogMapper companyAiWorkflowExecLogMapper;
 
+    @Autowired
+    CompanyVoiceRoboticMapper companyVoiceRoboticMapper;
+
+    @Autowired
+    IEasyCallService easyCallService;
+
+    @Autowired
+    CompanySiptaskInfoMapper companySiptaskInfoMapper;
+
     /**
      * 初始化工作流
      * 创建工作流实例并保存初始状态
@@ -84,7 +92,8 @@ public class CompanyWorkflowEngineImpl implements CompanyWorkflowEngine {
                     definition.getStartNodeKey(), context, definition);
 
             log.info("工作流初始化成功: {} -> {}", workflowInstanceId, workflowDefinitionId);
-
+            //为任务创建sip任务并存入表数据
+            createSipTask(Long.parseLong(inputVariables.get("roboticId").toString()),workflowDefinitionId);
             return ExecutionResult.success()
                     .nextNodeKey(definition.getStartNodeKey())
                     .workflowInstanceId(workflowInstanceId).build();
@@ -545,4 +554,59 @@ public class CompanyWorkflowEngineImpl implements CompanyWorkflowEngine {
 
     }
 
+    /**
+     * 创建SIP任务
+     * @param roboticId
+     * @param workFlowId
+     */
+    public Long createSipTask(Long roboticId, Long workFlowId) {
+        try {
+            List<String> nodeTypes = Arrays.asList(NodeTypeEnum.AI_CALL_TASK.getCode());
+            CompanyVoiceRobotic robotic = companyVoiceRoboticMapper.selectCompanyVoiceRoboticById(roboticId);
+            List<CompanyWorkflowNode> companyWorkflowNodes = companyWorkflowNodeMapper.selectNodesByWorkflowIdAndTypes(workFlowId, nodeTypes);
+            //为所有外呼节点创建任务的对应sip外呼任务
+            for (CompanyWorkflowNode callNode : companyWorkflowNodes) {
+                String nodeConfig = callNode.getNodeConfig();
+                AiCallConfigVO callConfigVo = JSONObject.parseObject(nodeConfig, AiCallConfigVO.class);
+                EasyCallCreateTaskParam createParam = new EasyCallCreateTaskParam();
+                // 任务名称:使用任务名称_工作流id_节点key
+                createParam.setBatchName(robotic.getName() + "_" + workFlowId + "_" + callNode.getNodeKey());
+                if (null != callConfigVo.getMaxConcurrency()) {
+                    createParam.setThreadNum(Long.valueOf(callConfigVo.getMaxConcurrency()));
+                } else {
+                    createParam.setThreadNum(3L);
+                }
+                // AI 外呼模式
+                createParam.setTaskType(1);
+                // 外呼线路(网关)
+                createParam.setGatewayId(callConfigVo.getGatewayId());
+                // 大模型底座
+                createParam.setLlmAccountId(callConfigVo.getLlmAccountId());
+                // 音色编号
+                createParam.setVoiceCode(callConfigVo.getVoiceCode());
+                // 音色来源(如未配置默认留空,由 EasyCallCenter365 使用默认值)
+                createParam.setVoiceSource(callConfigVo.getVoiceSource());
+                // 技能组(转人工客服分组,可选)
+                createParam.setGroupId(callConfigVo.getBusiGroupId());
+
+                EasyCallTaskVO task = easyCallService.createTask(createParam, null);
+                if (task == null || task.getBatchId() == null) {
+                    log.error("createSipTask: 创建 EasyCall 任务失败 - workflowInstanceId: {}", workFlowId);
+                    throw new RuntimeException("EasyCallCenter365 创建任务失败");
+                }
+                CompanySiptaskInfo sipTaskInfo = new CompanySiptaskInfo();
+                sipTaskInfo.setTaskId(roboticId);
+                sipTaskInfo.setWorkflowId(workFlowId);
+                sipTaskInfo.setNodeKey(callNode.getNodeKey());
+                sipTaskInfo.setBatchId(task.getBatchId());
+                sipTaskInfo.setTaskJson(JSONObject.toJSONString(task));
+                companySiptaskInfoMapper.insertCompanySiptaskInfo(sipTaskInfo);
+                return task.getBatchId();
+            }
+        } catch (Exception ex) {
+            log.error("创建SIP任务失败:{}", ex);
+        }
+        return null;
+    }
+
 }

+ 5 - 0
fs-service/src/main/java/com/fs/company/service/impl/CompanyWxClientServiceImpl.java

@@ -242,6 +242,11 @@ public class CompanyWxClientServiceImpl extends ServiceImpl<CompanyWxClientMappe
         return baseMapper.getAddWxList4Workflow(accountIdList, ExecutionStatusEnum.PAUSED.getValue(), NodeTypeEnum.AI_ADD_WX_TASK.getValue(),cidGroupId);
     }
 
+    @Override
+    public List<CompanyWxClient4WorkFlowVO> getAddWxList4WorkflowNew(List<Long> accountIdList, Integer cidGroupId){
+        return baseMapper.getAddWxList4Workflow(accountIdList, ExecutionStatusEnum.PAUSED.getValue(), NodeTypeEnum.AI_ADD_WX_TASK_NEW.getValue(), cidGroupId);
+    }
+
     @Override
     public List<CompanyWxClient> getQwAddWxList(List<Long> accountIdList, Integer isWeCom) {
         return baseMapper.getQwAddWxList(accountIdList,isWeCom);

+ 57 - 34
fs-service/src/main/java/com/fs/company/service/impl/CompanyWxServiceImpl.java

@@ -1,5 +1,8 @@
 package com.fs.company.service.impl;
 
+import cn.hutool.http.HttpUtil;
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.core.toolkit.CollectionUtils;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
@@ -11,6 +14,7 @@ import com.fs.company.domain.*;
 import com.fs.company.mapper.*;
 import com.fs.company.service.*;
 import com.fs.company.service.impl.call.node.AiAddWxTaskNode;
+import com.fs.company.service.impl.call.node.AiAddWxTaskNewNode;
 import com.fs.enums.ExecutionStatusEnum;
 import com.fs.enums.NodeTypeEnum;
 import com.fs.wxcid.domain.CidIpadServerUser;
@@ -33,7 +37,10 @@ import com.fs.wxcid.service.*;
 import com.fs.wxcid.service.impl.LoginServiceImpl;
 import com.fs.wxcid.service.impl.UserServiceImpl;
 import lombok.extern.slf4j.Slf4j;
+import okhttp3.*;
+import okhttp3.WebSocketListener;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
@@ -101,6 +108,12 @@ public class CompanyWxServiceImpl extends ServiceImpl<CompanyWxAccountMapper, Co
     @Autowired
     CompanyAiWorkflowServerMapper companyAiWorkflowServerMapper;
 
+    @Value("${wx.websocket.url:ws://localhost:7113/app/webSocket}")
+    private String webSocketUrl;
+
+    @Value("${wx.api.url:http://localhost:7113}")
+    private String wxApiUrl;
+
 
     /**
      * 查询企微账号
@@ -141,6 +154,11 @@ public class CompanyWxServiceImpl extends ServiceImpl<CompanyWxAccountMapper, Co
     @Override
     public int insertCompanyWxAccount(CompanyWxAccount companyWxAccount)
     {
+        //校验添加微信前缀唯一性
+        CompanyWxAccount wxAccount = companyWxAccountMapper.selectCompanyWxAccountByWxRemark(companyWxAccount.getWxRemark());
+        if(null != wxAccount){
+            throw new RuntimeException("微信前缀已存在,请更换后重试");
+        }
         companyWxAccount.setCreateTime(DateUtils.getNowDate());
         return companyWxAccountMapper.insertCompanyWxAccount(companyWxAccount);
     }
@@ -332,37 +350,28 @@ public class CompanyWxServiceImpl extends ServiceImpl<CompanyWxAccountMapper, Co
         account.setSyncFriendTime(LocalDateTime.now());
 
         try {
-            // 3. 清理Redis缓存
-            String key = FRIEND_KEY + accountId;
-            friendListRedis.deleteObject(key);
-
-            // 4. 获取好友列表并过滤
-            GetContactListParam param = new GetContactListParam();
-            param.setAccountId(accountId);
-            ContactListResponse response = friendService.getContactListNotKey(param);
-            List<String> friendList = getFilteredFriendList(response);
-            if (CollectionUtils.isEmpty(friendList)) {
-                log.info("账号暂无需要同步的微信好友/群聊,accountId={}", accountId);
-                updateById(account);
-                return;
+            // 3. 调用WebSocket发送同步指令
+            String wxNo = account.getWxNo();
+            if (wxNo != null && !wxNo.isEmpty()) {
+                sendSyncContactCommand(wxNo);
+            } else {
+                log.warn("微信账号wxNo为空,无法发送同步指令,accountId={}", accountId);
             }
 
-            // 5. 缓存好友列表到Redis
-            friendListRedis.setCacheObject(key, friendList);
-
-            // 6. 同步联系人信息
-            syncContactDetails(accountId, account, friendList);
-
-            // 7. 更新账号信息
+            // 4. 更新账号信息
             updateById(account);
 
-            log.info("微信信息同步完成,accountId={},同步联系人数量={}", accountId, friendList.size());
+//            log.info("微信信息同步完成,accountId={},同步联系人数量={}", accountId, friendList.size());
         } catch (Exception e) {
             log.error("同步微信信息异常,accountId={}", accountId, e);
             throw e;
         }
     }
 
+    private void sendSyncContactCommand(String wxNo) {
+        HttpUtil.get(wxApiUrl + "/app/common/syncWx?wxId=" + wxNo);
+    }
+
     /**
      * 获取过滤后的好友列表
      */
@@ -576,17 +585,32 @@ public class CompanyWxServiceImpl extends ServiceImpl<CompanyWxAccountMapper, Co
      * 加微成功后触发工作流继续执行
      * @param wxClientId 加微客户ID
      */
-    private void triggerWorkflowOnAddWxSuccess(Long wxClientId) {
+    public void triggerWorkflowOnAddWxSuccess(Long wxClientId) {
         try {
-            // 查找等待中的加微工作流实例
+            // 先查老类型的等待中工作流实例
             CompanyAiWorkflowExec waitingExec = companyAiWorkflowExecMapper.selectWaitingAddWxWorkflowByWxClientId(
                     wxClientId,
                     ExecutionStatusEnum.WAITING.getValue(),
                     NodeTypeEnum.AI_ADD_WX_TASK.getValue());
+            boolean isNewNodeType = false;
+            // 老类型未找到,再查新类型
+            if (waitingExec == null) {
+                waitingExec = companyAiWorkflowExecMapper.selectWaitingAddWxWorkflowByWxClientId(
+                        wxClientId,
+                        ExecutionStatusEnum.WAITING.getValue(),
+                        NodeTypeEnum.AI_ADD_WX_TASK_NEW.getValue());
+                isNewNodeType = true;
+            }
+
+            if (waitingExec == null) {
+                log.info("未找到等待中的加微工作流实例 - wxClientId: {}", wxClientId);
+                return;
+            }
+
             //查询工作流加微执行日志是否未更新状态
             CompanyAiWorkflowExecLog queryP = new CompanyAiWorkflowExecLog();
             queryP.setWorkflowInstanceId(waitingExec.getWorkflowInstanceId());
-            queryP.setNodeType(NodeTypeEnum.AI_ADD_WX_TASK.getValue());
+            queryP.setNodeType(isNewNodeType ? NodeTypeEnum.AI_ADD_WX_TASK_NEW.getValue() : NodeTypeEnum.AI_ADD_WX_TASK.getValue());
             queryP.setStatus(ExecutionStatusEnum.WAITING.getValue());
             List<CompanyAiWorkflowExecLog> companyAiWorkflowExecLogs = companyAiWorkflowExecLogMapper.selectCompanyAiWorkflowExecLogList(queryP);
             companyAiWorkflowExecLogs.forEach(log -> {
@@ -594,10 +618,6 @@ public class CompanyWxServiceImpl extends ServiceImpl<CompanyWxAccountMapper, Co
                 companyAiWorkflowExecLogMapper.updateById(log);
                 }
             );
-            if (waitingExec == null) {
-                log.info("未找到等待中的加微工作流实例 - wxClientId: {}", wxClientId);
-                return;
-            }
 
             String workflowInstanceId = waitingExec.getWorkflowInstanceId();
             String currentNodeKey = waitingExec.getCurrentNodeKey();
@@ -605,21 +625,24 @@ public class CompanyWxServiceImpl extends ServiceImpl<CompanyWxAccountMapper, Co
             log.info("加微成功回调,尝试触发工作流继续执行 - workflowInstanceId: {}, nodeKey: {}, wxClientId: {}",
                     workflowInstanceId, currentNodeKey, wxClientId);
 
-            // 互斥检查:如果已经被执行过(超时路径或其他回调),则不再执行
-            if (!AiAddWxTaskNode.tryMarkAsExecuted(workflowInstanceId, wxClientId)) {
+            // 互斥检查:根据节点类型使用对应的互斥方法
+            boolean canExecute;
+            if (isNewNodeType) {
+                canExecute = AiAddWxTaskNewNode.tryMarkAsExecuted(workflowInstanceId, wxClientId);
+            } else {
+                canExecute = AiAddWxTaskNode.tryMarkAsExecuted(workflowInstanceId, wxClientId);
+            }
+            if (!canExecute) {
                 log.info("工作流已被其他路径执行,跳过 - workflowInstanceId: {}, wxClientId: {}",
                         workflowInstanceId, wxClientId);
                 return;
             }
 
-            // 清除超时检测Key(回调成功了,不需要超时检测了)
-//            AiAddWxTaskNode.clearTimeoutKey(workflowInstanceId, wxClientId);
-
             // 触发工作流继续执行
             Map<String, Object> inputData = new HashMap<>();
             inputData.put("addWxSuccess", true);
             inputData.put("wxClientId", wxClientId);
-            inputData.put("triggerType", "callback"); // 回调触发
+            inputData.put("triggerType", "callback");
 
             companyWorkflowEngine.resumeFromBlockingNode(workflowInstanceId, currentNodeKey, inputData);
 

+ 23 - 0
fs-service/src/main/java/com/fs/company/service/impl/call/node/AbstractWorkflowNode.java

@@ -13,6 +13,7 @@ import com.fs.company.service.IWorkflowNode;
 import com.fs.company.vo.ExecutionResult;
 import com.fs.enums.ExecutionStatusEnum;
 import com.fs.enums.NodeTypeEnum;
+import com.fs.enums.TaskTypeEnum;
 import lombok.extern.slf4j.Slf4j;
 import org.redisson.api.RLock;
 import org.redisson.api.RedissonClient;
@@ -168,6 +169,8 @@ public abstract class AbstractWorkflowNode implements IWorkflowNode {
                     updateLogStatusIfExist(context,ExecutionStatusEnum.WAITING_DO_CALL,ExecutionStatusEnum.SUCCESS);
                 } else if(getType().equals(NodeTypeEnum.AI_ADD_WX_TASK)){
                     updateLogStatusIfExist(context,ExecutionStatusEnum.WAITING,ExecutionStatusEnum.SUCCESS);
+                } else if(getType().equals(NodeTypeEnum.AI_ADD_WX_TASK_NEW)){
+                    updateLogStatusIfExist(context,ExecutionStatusEnum.WAITING,ExecutionStatusEnum.SUCCESS);
                 } else if (getType().equals(NodeTypeEnum.OUTBOUND_TASK)) {
                     updateLogStatusIfExist(context,ExecutionStatusEnum.WAITING_DO_CALL,ExecutionStatusEnum.SUCCESS);
                 }
@@ -426,6 +429,26 @@ public abstract class AbstractWorkflowNode implements IWorkflowNode {
         companyAiWorkflowExecMapper.updateByWorkflowInstanceId(update);
     }
 
+    /**
+     * 任务完成判定
+     * @param context
+     */
+    public void taskFinish(ExecutionContext context){
+        //判定是否任务完成了更新任务的状态为执行完成
+        CompanyVoiceRoboticBusiness roboticBusiness = getRoboticBusiness(context.getWorkflowInstanceId());
+        if(null != roboticBusiness){
+            Integer i = companyVoiceRoboticBusinessMapper.selectUnfinishedTaskCountByRoboticId(roboticBusiness.getRoboticId(), nodeKey);
+            if(Integer.valueOf(0).equals(i)){
+                CompanyVoiceRobotic robotic = new CompanyVoiceRobotic();
+                CompanyVoiceRobotic currentRobitic = companyVoiceRoboticMapper.selectCompanyVoiceRoboticById(roboticBusiness.getRoboticId());
+                if(currentRobitic.getTaskType().equals(TaskTypeEnum.ORDINARY.getValue())){
+                    robotic.setId(roboticBusiness.getRoboticId());
+                    robotic.setTaskStatus(3);
+                    companyVoiceRoboticMapper.updateById(robotic);
+                }
+            }
+        }
+    }
     public CompanyAiWorkflowExec getWorkflowExec(String workflowInstanceId) {
         CompanyAiWorkflowExec companyAiWorkflowExec = companyAiWorkflowExecMapper.selectByWorkflowInstanceId(workflowInstanceId);
         if (null == companyAiWorkflowExec) {

+ 309 - 0
fs-service/src/main/java/com/fs/company/service/impl/call/node/AiAddWxTaskNewNode.java

@@ -0,0 +1,309 @@
+package com.fs.company.service.impl.call.node;
+
+import com.alibaba.fastjson.JSONObject;
+import com.fs.common.constant.Constants;
+import com.fs.common.core.redis.RedisCacheT;
+import com.fs.common.exception.CustomException;
+import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.http.HttpUtils;
+import com.fs.common.utils.spring.SpringUtils;
+import com.fs.company.domain.*;
+import com.fs.company.mapper.*;
+import com.fs.company.param.AddWxActionParam;
+import com.fs.company.param.ExecutionContext;
+import com.fs.company.util.ObjectPlaceholderResolver;
+import com.fs.company.vo.AiAddWxConfigVO;
+import com.fs.company.vo.AiCallWorkflowConditionVo;
+import com.fs.company.vo.ExecutionResult;
+import com.fs.crm.domain.CrmCustomer;
+import com.fs.crm.service.impl.CrmCustomerServiceImpl;
+import com.fs.enums.ExecutionStatusEnum;
+import com.fs.enums.NodeTypeEnum;
+import com.fs.system.service.ISysConfigService;
+import com.fs.wxcid.domain.CidIpadServer;
+import com.fs.wxcid.domain.WxContact;
+import com.fs.wxcid.mapper.CidIpadServerMapper;
+import com.fs.wxcid.mapper.WxContactMapper;
+import lombok.extern.slf4j.Slf4j;
+
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * @author MixLiu
+ * @date 2026/04/22 13:15
+ * @description AI添加微信任务节点(新)
+ */
+@Slf4j
+public class AiAddWxTaskNewNode extends AbstractWorkflowNode {
+
+    private static final CompanyWxClientMapper companyWxClientMapper = SpringUtils.getBean(CompanyWxClientMapper.class);
+    @SuppressWarnings("unchecked")
+    private static final RedisCacheT<String> redisCache = SpringUtils.getBean(RedisCacheT.class);
+    private static final WxContactMapper wxContactMapper = SpringUtils.getBean(WxContactMapper.class);
+    private static final CompanyWxAccountMapper companyWxAccountMapper = SpringUtils.getBean(CompanyWxAccountMapper.class);
+    public static final String DELAY_ADD_WX_NEW_KEY = "addWxTaskNew:delay:%s:%s:%s:";
+    private static final CompanyWxDialogMapper companyWxDialogMapper = SpringUtils.getBean(CompanyWxDialogMapper.class);
+    private static final CrmCustomerServiceImpl crmCustomerService = SpringUtils.getBean(CrmCustomerServiceImpl.class);
+    private static final ObjectPlaceholderResolver objectPlaceholderResolver = SpringUtils.getBean(ObjectPlaceholderResolver.class);
+    private static final ISysConfigService sysConfigService = SpringUtils.getBean(ISysConfigService.class);
+    private static final CidIpadServerMapper cidIpadServerMapper = SpringUtils.getBean(CidIpadServerMapper.class);
+    private static final String ADDWX_POST_URL = "/app/common/addWxAction";
+    /**
+     * 默认加微超时时间(分钟)
+     */
+    private static final int DEFAULT_ADD_WX_TIMEOUT_MINUTES = 30;
+
+    public AiAddWxTaskNewNode(String nodeKey, String nodeName, Map<String, Object> properties) {
+        super(nodeKey, nodeName, properties);
+    }
+
+    @Override
+    public NodeTypeEnum getType() {
+        return NodeTypeEnum.AI_ADD_WX_TASK_NEW;
+    }
+
+    @Override
+    public Boolean isAsync() {
+        return true;
+    }
+
+    /**
+     * 执行加微节点逻辑(准备数据并发起加微请求)
+     *
+     * @param context 执行上下文
+     * @return 执行结果
+     */
+    @Override
+    protected ExecutionResult doExecute(ExecutionContext context) {
+        if (!isAsync()) {
+            return ExecutionResult.failure().nextNodeKey(null).build();
+        }
+        try {
+            // 设置加微话术
+            CompanyWorkflowNode node = context.getVariable("currentNode", CompanyWorkflowNode.class) == null
+                    ? getNodeByKey(nodeKey)
+                    : context.getVariable("currentNode", CompanyWorkflowNode.class);
+            String nodeConfig = node.getNodeConfig();
+            AiAddWxConfigVO addWxConfig = nodeConfig == null ? null : JSONObject.parseObject(nodeConfig, AiAddWxConfigVO.class);
+
+            if (addWxConfig == null || addWxConfig.getDialogId() == null) {
+                throw new CustomException("加微节点未配置加微话术,执行失败");
+            }
+
+            WxContact wxQuery = companyAiWorkflowExecMapper.selectWxContectByWorkflowInstanceId(context.getWorkflowInstanceId());
+            wxQuery.setNickName(wxQuery.getRemark());
+            wxQuery.setFriends(0);
+            wxContactMapper.insert(wxQuery);
+
+            CompanyVoiceRoboticBusiness roboticBusiness = getRoboticBusiness(context.getWorkflowInstanceId());
+            CompanyWxClient update = new CompanyWxClient();
+            update.setDialogId(addWxConfig.getDialogId());
+            update.setId(roboticBusiness.getWxClientId());
+            companyWxClientMapper.updateCompanyWxClient(update);
+            super.asyncWorkflowForBlockingNode(context.getWorkflowInstanceId(), context.getCurrentNodeKey(), context, ExecutionStatusEnum.PAUSED);
+            pendingAddWx(wxQuery.getAccountId(),wxQuery.getRemark(),wxQuery.getPhone(),addWxConfig.getDialogId(),wxQuery.getCrmUserId());
+            doneAddwx(context.getWorkflowInstanceId());
+            return ExecutionResult.paused()
+                    .outputData(context.getVariables())
+                    .nextNodeKey("").build();
+
+        } catch (Exception e) {
+            log.error("准备加微任务数据异常 流程:{}:节点:{}执行失败,", context.getWorkflowInstanceId(), nodeKey, e);
+            super.updateWorkflowStatus(context.getWorkflowInstanceId(), ExecutionStatusEnum.INTERRUPT);
+            return ExecutionResult.failure().errorMessage("准备加微任务数据异常: " + e.getMessage()).build();
+        }
+    }
+
+    /**
+     * 收到加微回调后,继续判定和执行下一步动作
+     *
+     * @param context 执行上下文
+     * @return 执行结果
+     */
+    @Override
+    protected ExecutionResult doContinue(ExecutionContext context) {
+        CompanyAiWorkflowExec exec = companyAiWorkflowExecMapper.selectByWorkflowInstanceId(context.getWorkflowInstanceId());
+        if (exec == null) {
+            log.warn("doContinue: 工作流执行实例不存在 - workflowInstanceId: {}", context.getWorkflowInstanceId());
+            return null;
+        }
+        List<CompanyWorkflowEdge> edges = companyWorkflowEdgeMapper.selectListByWorkflowIdAndNodeKey(exec.getWorkflowId(), nodeKey);
+        if (edges == null || edges.isEmpty()) {
+            log.warn("doContinue: 未找到出边 - workflowInstanceId: {}, nodeKey: {}", context.getWorkflowInstanceId(), nodeKey);
+            return null;
+        }
+
+        // 获取业务数据
+        CompanyVoiceRoboticBusiness business = super.getRoboticBusiness(context.getWorkflowInstanceId());
+        // 获取加微记录
+        CompanyWxClient wxClient = companyWxClientMapper.selectById(business.getWxClientId());
+
+        log.info("收到加微回调 - workflowInstanceId: {}, wxClientId: {}, isAdd: {}",
+                context.getWorkflowInstanceId(), business.getWxClientId(), wxClient != null ? wxClient.getIsAdd() : null);
+
+        // 判断加微是否成功 (isAdd: 0否 1是 2待添加 3作废)
+        boolean addSuccess = wxClient != null && Integer.valueOf(1).equals(wxClient.getIsAdd());
+        // 回调加微成功,走成功分支出边
+        if (addSuccess) {
+            List<CompanyWorkflowEdge> cList = edges.stream().filter(a -> {
+                if (StringUtils.isBlank(a.getConditionExpr())) {
+                    return false;
+                }
+                List<AiCallWorkflowConditionVo> list = JSONObject.parseArray(a.getConditionExpr(), AiCallWorkflowConditionVo.class);
+                return list != null && !list.isEmpty() && list.get(0).isAdd();
+            }).collect(Collectors.toList());
+            if (null != cList && !cList.isEmpty() && nodeKey.equals(exec.getCurrentNodeKey())) {
+                super.runNextNode(context, cList.get(0));
+            }
+        }
+        return null;
+    }
+
+    /**
+     * 完成加微动作,设置为 WAITING 并处理出边条件(延时/直通)
+     *
+     * @param workflowInstanceId 工作流实例ID
+     */
+    public void doneAddwx(String workflowInstanceId) {
+        ExecutionContext context = createExecutionContext(workflowInstanceId, nodeKey);
+        context.setVariable("lastNodeKey", nodeKey);
+        CompanyAiWorkflowExec exec = companyAiWorkflowExecMapper.selectByWorkflowInstanceId(context.getWorkflowInstanceId());
+        if (exec == null) {
+            log.error("doneAddwx: 工作流执行实例不存在 - workflowInstanceId: {}", workflowInstanceId);
+            return;
+        }
+        if (!exec.getCurrentNodeKey().equals(nodeKey)) {
+            log.error("当前节点已流转 ,目标:{},实际:{}", nodeKey, exec.getCurrentNodeKey());
+            return;
+        }
+        // 更新加微日志执行状态
+        super.updateLogStatusIfExist(context, ExecutionStatusEnum.PAUSED, ExecutionStatusEnum.WAITING);
+        super.asyncWorkflowForBlockingNode(context.getWorkflowInstanceId(), nodeKey, context, ExecutionStatusEnum.WAITING);
+        List<CompanyWorkflowEdge> edges = companyWorkflowEdgeMapper.selectListByWorkflowIdAndNodeKey(exec.getWorkflowId(), nodeKey);
+        if (edges == null) {
+            return;
+        }
+        edges.forEach(edge -> {
+            String conditionExpr = edge.getConditionExpr();
+            List<AiCallWorkflowConditionVo> conditions = StringUtils.isBlank(conditionExpr) ? null : JSONObject.parseArray(conditionExpr, AiCallWorkflowConditionVo.class);
+            if (null == conditions || conditions.isEmpty()) {
+                super.runNextNode(context, edge);
+            } else {
+                AiCallWorkflowConditionVo condition = conditions.get(0);
+                // 节点包含延时条件
+                if (null != condition.getAddTime() && !condition.isAdd()) {
+                    long l = System.currentTimeMillis() + condition.getAddTime() * 60 * 1000;
+                    String redisKey = getDelayAddWxKeyPrefix(exec.getCidGroupNo(), l) + workflowInstanceId;
+                    ExecutionContext nextContext = context.clone();
+                    nextContext.setCurrentNodeKey(edge.getTargetNodeKey());
+                    super.redisCache.setCacheObject(redisKey, nextContext);
+                }
+            }
+        });
+    }
+
+    /**
+     * 检查并标记已执行(互斥控制)
+     * 返回 true 表示当前是第一个执行的,可以继续;
+     * 返回 false 表示已被其他路径执行过,不再执行。
+     *
+     * @param workflowInstanceId 工作流实例ID
+     * @param wxClientId         加微客户ID
+     * @return 是否可以执行
+     */
+    public static boolean tryMarkAsExecuted(String workflowInstanceId, Long wxClientId) {
+        String executedKey = Constants.WORKFLOW_ADD_WX_EXECUTED + "new:" + workflowInstanceId + ":" + wxClientId;
+        String existingValue = redisCache.getCacheObject(executedKey);
+        if (existingValue != null) {
+            return false;
+        }
+        redisCache.setCacheObject(executedKey, "1");
+        redisCache.expire(executedKey, 3600);
+        return true;
+    }
+
+    /**
+     * 生成延时加微 Redis Key 前缀
+     *
+     * @param cidGroupNo CID分组号
+     * @param time       目标时间戳(毫秒),为 null 时取当前时间
+     * @return Redis Key 前缀
+     */
+    public static String getDelayAddWxKeyPrefix(Integer cidGroupNo, Long time) {
+        Date nowDay = new Date();
+        if (null != time) {
+            nowDay = new Date(time);
+        }
+        return String.format(DELAY_ADD_WX_NEW_KEY, cidGroupNo, nowDay.getHours(), nowDay.getMinutes());
+    }
+
+    /**
+     * 从节点配置获取超时时间(分钟)
+     */
+    private int getTimeoutFromProperties() {
+        if (properties != null && properties.containsKey("timeout")) {
+            Object timeout = properties.get("timeout");
+            if (timeout instanceof Number) {
+                return ((Number) timeout).intValue();
+            }
+            if (timeout instanceof String) {
+                try {
+                    return Integer.parseInt((String) timeout);
+                } catch (NumberFormatException e) {
+                    log.warn("解析超时时间失败: {}, 使用默认值: {}", timeout, DEFAULT_ADD_WX_TIMEOUT_MINUTES);
+                }
+            }
+        }
+        return DEFAULT_ADD_WX_TIMEOUT_MINUTES;
+    }
+
+    /**
+     * 发起加微请求
+     * @param accountId 微信账号ID
+     * @param remark 备注
+     * @param phone 手机号
+     * @param dialogId 话术ID
+     * @param crmUserId CRM用户ID
+     */
+    private void pendingAddWx(Long accountId, String remark, String phone, Long dialogId, Long crmUserId) {
+        // 1. 获取基础数据
+        CompanyWxAccount companyWxAccount = companyWxAccountMapper.selectCompanyWxAccountById(accountId);
+        if (companyWxAccount == null) {
+            throw new CustomException("未找到对应的微信账号配置, accountId: " + accountId);
+        }
+
+        CompanyWxDialog dialog = companyWxDialogMapper.selectCompanyWxDialogById(dialogId);
+        if (dialog == null) {
+            throw new CustomException("未找到对应的对话模板, dialogId: " + dialogId);
+        }
+
+        CrmCustomer crmCustomer = crmCustomerService.selectCrmCustomerById(crmUserId);
+
+        // 2. 解析话术模板
+        String newTxt = objectPlaceholderResolver.resolvePlaceholders(crmCustomer, dialog.getTemplateDetails());
+
+        // 3. 构建请求参数
+        AddWxActionParam param = new AddWxActionParam();
+        param.setWxId(companyWxAccount.getWxNo());
+        param.setRemark(remark);
+        param.setPhone(phone);
+        param.setApplyMsg(newTxt);
+        CidIpadServer cidIpadServer = cidIpadServerMapper.selectCidIpadServerById(companyWxAccount.getServerId());
+        if (null == cidIpadServer || StringUtils.isBlank(cidIpadServer.getUrl())) {
+            throw new CustomException("加微接口地址未配置");
+        }
+        // 4. 从系统配置获取加微接口地址
+        String addWxUrl = cidIpadServer.getUrl() + ADDWX_POST_URL;
+
+        // 5. 发送 HTTP 请求
+        try {
+            String result = HttpUtils.sendPost(addWxUrl, JSONObject.toJSONString(param));
+            log.info("pendingAddWx: 加微任务提交成功, phone: {}, result: {}", phone, result);
+        } catch (Exception e) {
+            throw new CustomException("发起加微请求异常, phone: " + phone + ", error: " + e.getMessage());
+        }
+    }
+}

+ 73 - 37
fs-service/src/main/java/com/fs/company/service/impl/call/node/AiCallTaskNode.java

@@ -6,11 +6,10 @@ import com.fs.common.utils.StringUtils;
 import com.fs.common.utils.spring.SpringUtils;
 import com.fs.company.domain.*;
 import com.fs.company.enums.BusinessTypeEnum;
-import com.fs.company.mapper.CompanyVoiceRoboticCallLogCallphoneMapper;
-import com.fs.company.mapper.CompanyVoiceRoboticCalleesMapper;
-import com.fs.company.mapper.CompanyWorkflowNodeMapper;
+import com.fs.company.mapper.*;
 import com.fs.company.param.CompanyVoiceRoboticCallBlacklistCheckParam;
 import com.fs.company.param.ExecutionContext;
+import com.fs.company.service.CompanyWorkflowEngine;
 import com.fs.company.service.ICompanyVoiceRoboticCallBlacklistService;
 import com.fs.company.service.ICompanyVoiceRoboticService;
 import com.fs.company.service.easycall.IEasyCallService;
@@ -27,6 +26,7 @@ import com.fs.crm.domain.CrmCustomer;
 import com.fs.crm.service.ICrmCustomerService;
 import com.fs.enums.ExecutionStatusEnum;
 import com.fs.enums.NodeTypeEnum;
+import com.fs.enums.TaskTypeEnum;
 import com.fs.his.config.CidPhoneConfig;
 import com.fs.system.service.ISysConfigService;
 import lombok.extern.slf4j.Slf4j;
@@ -45,6 +45,9 @@ public class AiCallTaskNode extends AbstractWorkflowNode {
     private static final ICompanyVoiceRoboticService companyVoiceRoboticService = SpringUtils.getBean(ICompanyVoiceRoboticService.class);
     /** EasyCallCenter365 外呼服务 */
     private static final IEasyCallService easyCallService = SpringUtils.getBean(IEasyCallService.class);
+    private static final CompanyConfigMapper companyConfigMapper = SpringUtils.getBean(CompanyConfigMapper.class);
+    private static final CompanySiptaskInfoMapper companySiptaskInfoMapper = SpringUtils.getBean(CompanySiptaskInfoMapper.class);
+    private static final CompanyWorkflowEngine companyWorkflowEngine = SpringUtils.getBean(CompanyWorkflowEngine.class);
     /** 被叫人表 Mapper,用于获取手机号 */
     private static final CompanyVoiceRoboticCalleesMapper companyVoiceRoboticCalleesMapper = SpringUtils.getBean(CompanyVoiceRoboticCalleesMapper.class);
     private static final CompanyVoiceRoboticCallLogCallphoneServiceImpl companyVoiceRoboticCallLogCallphoneService = SpringUtils.getBean(CompanyVoiceRoboticCallLogCallphoneServiceImpl.class);
@@ -156,6 +159,7 @@ public class AiCallTaskNode extends AbstractWorkflowNode {
         //如果没有节点可执行通路 条件均不满足 则标记流程为中断
         if(runnableCount < 1){
             super.updateWorkflowStatus(context.getWorkflowInstanceId(), ExecutionStatusEnum.INTERRUPT);
+            super.taskFinish(context);
         }
 
         return null;
@@ -260,6 +264,16 @@ public class AiCallTaskNode extends AbstractWorkflowNode {
     private void workflowCallPhoneOne4EasyCall(Long roboticId,Long calleeId, ExecutionContext context, AiCallConfigVO callConfigVo) {
 
         CompanyVoiceRobotic robotic = companyVoiceRoboticMapper.selectById(roboticId);
+        String callBackUrl = "";
+        try{
+            CompanyConfig companyConfig = companyConfigMapper.selectCompanyConfigByKey(robotic.getCompanyId(), "cId.config");
+            CidPhoneConfig cidConf = JSONObject.parseObject(companyConfig.getConfigValue(), CidPhoneConfig.class);
+            if(null != cidConf && StringUtils.isNotBlank(cidConf.getCallbackUrl())){
+                callBackUrl = cidConf.getCallbackUrl();
+            }
+        } catch (Exception ex){
+            log.error("获取公司Cid配置失败:{}", ex);
+        }
         // 1. 生成回调唯一标识符,后续回调时通过此 uuid 匹配对应的流程实例
         String callBackUuid = UUID.randomUUID().toString();
         // 将回调信息写入 Redis,保存 1 天
@@ -293,42 +307,44 @@ public class AiCallTaskNode extends AbstractWorkflowNode {
         }
 
         // 3. 构建创建任务参数(AI 外呼模式:taskType=1)
-        EasyCallCreateTaskParam createParam = new EasyCallCreateTaskParam();
-        // 任务名称:使用工作流实例 ID + 被叫人 ID 组合,保证唯一性
-        createParam.setBatchName(robotic.getName() + "_" + context.getWorkflowInstanceId() + "_" + calleeId);
-        if (null != callConfigVo.getMaxConcurrency())
-            createParam.setThreadNum(Long.valueOf(callConfigVo.getMaxConcurrency()));
-        else {
-            createParam.setThreadNum(3L);
-        }
-        // AI 外呼模式
-        createParam.setTaskType(1);
-        // 外呼线路(网关)
-        createParam.setGatewayId(callConfigVo.getGatewayId());
-        // 大模型底座
-        createParam.setLlmAccountId(callConfigVo.getLlmAccountId());
-        // 音色编号
-        createParam.setVoiceCode(callConfigVo.getVoiceCode());
-        // 音色来源(如未配置默认留空,由 EasyCallCenter365 使用默认值)
-        createParam.setVoiceSource(callConfigVo.getVoiceSource());
-        // 技能组(转人工客服分组,可选)
-        createParam.setGroupId(callConfigVo.getBusiGroupId());
-
-        JSONObject runParam = (JSONObject) JSON.toJSON(createParam);
-        runParam.put("companyId", robotic.getCompanyId());
-        CompanyVoiceRoboticCallLogCallphone addLog = CompanyVoiceRoboticCallLogCallphone.initCallLog(
-                runParam.toJSONString(), calleeId, roboticId, robotic.getCompanyId());
+//        EasyCallCreateTaskParam createParam = new EasyCallCreateTaskParam();
+//        // 任务名称:使用工作流实例 ID + 被叫人 ID 组合,保证唯一性
+//        createParam.setBatchName(robotic.getName() + "_" + context.getWorkflowInstanceId() + "_" + calleeId);
+//        if (null != callConfigVo.getMaxConcurrency())
+//            createParam.setThreadNum(Long.valueOf(callConfigVo.getMaxConcurrency()));
+//        else {
+//            createParam.setThreadNum(3L);
+//        }
+//        // AI 外呼模式
+//        createParam.setTaskType(1);
+//        // 外呼线路(网关)
+//        createParam.setGatewayId(callConfigVo.getGatewayId());
+//        // 大模型底座
+//        createParam.setLlmAccountId(callConfigVo.getLlmAccountId());
+//        // 音色编号
+//        createParam.setVoiceCode(callConfigVo.getVoiceCode());
+//        // 音色来源(如未配置默认留空,由 EasyCallCenter365 使用默认值)
+//        createParam.setVoiceSource(callConfigVo.getVoiceSource());
+//        // 技能组(转人工客服分组,可选)
+//        createParam.setGroupId(callConfigVo.getBusiGroupId());
+
+
         // 4. 调用 EasyCallCenter365 创建任务接口
         // companyId 传 null 是因为 EasyCallCenter365 是全局地址,不需要按公司隔离
-        log.info("workflowCallPhoneOne4EasyCall: 创建 EasyCall 任务 - workflowInstanceId: {}, calleeId: {}",
-                context.getWorkflowInstanceId(), calleeId);
-        EasyCallTaskVO task = easyCallService.createTask(createParam, null);
-        if (task == null || task.getBatchId() == null) {
-            log.error("workflowCallPhoneOne4EasyCall: 创建 EasyCall 任务失败 - workflowInstanceId: {}",
-                    context.getWorkflowInstanceId());
-            throw new RuntimeException("EasyCallCenter365 创建任务失败");
+//        log.info("workflowCallPhoneOne4EasyCall: 创建 EasyCall 任务 - workflowInstanceId: {}, calleeId: {}",
+//                context.getWorkflowInstanceId(), calleeId);
+//        EasyCallTaskVO task = easyCallService.createTask(createParam, null);
+//        if (task == null || task.getBatchId() == null) {
+//            log.error("workflowCallPhoneOne4EasyCall: 创建 EasyCall 任务失败 - workflowInstanceId: {}",
+//                    context.getWorkflowInstanceId());
+//            throw new RuntimeException("EasyCallCenter365 创建任务失败");
+//        }
+        Long batchId = getTaskBatchId(robotic.getId(), context.getCurrentNodeKey(), context.getWorkflowInstanceId());
+        if(null == batchId ){
+            log.error("workflowCallPhoneOne4EasyCall: 获取 EasyCall 任务批次ID失败 - workflowInstanceId: {}",context.getWorkflowInstanceId());
+            throw new RuntimeException("任务批次ID失败");
         }
-        Long batchId = task.getBatchId();
+
         log.info("workflowCallPhoneOne4EasyCall: EasyCall 任务创建成功 - batchId: {}", batchId);
 
         // 5. 将被叫号码加入任务名单(使用通用追加接口,支持传入业务数据)
@@ -338,7 +354,7 @@ public class AiCallTaskNode extends AbstractWorkflowNode {
         EasyCallPhoneItemVO phoneItem = new EasyCallPhoneItemVO();
         phoneItem.setPhoneNum(callees.getPhone());
         // bizJson 默认传入客户姓名占位,运行时可根据实际业务填充
-        phoneItem.setBizJson(new JSONObject().fluentPut("custName", callees.getUserName()).fluentPut("callBackUuid",callBackUuid));
+        phoneItem.setBizJson(new JSONObject().fluentPut("custName", callees.getUserName()).fluentPut("callBackUuid",callBackUuid).fluentPut("callBackUrl",callBackUrl));
         addListParam.setPhoneList(Collections.singletonList(phoneItem));
         easyCallService.addCommonCallList(addListParam, null);
         log.info("workflowCallPhoneOne4EasyCall: 名单追加成功 - batchId: {}, phone: {}",
@@ -347,6 +363,10 @@ public class AiCallTaskNode extends AbstractWorkflowNode {
         // 6. 启动外呼任务
         easyCallService.startTask(batchId, null);
         log.info("workflowCallPhoneOne4EasyCall: 任务启动成功 - batchId: {}", batchId);
+        JSONObject runParam = (JSONObject) JSON.toJSON(addListParam);
+        runParam.put("companyId", robotic.getCompanyId());
+        CompanyVoiceRoboticCallLogCallphone addLog = CompanyVoiceRoboticCallLogCallphone.initCallLog(
+                runParam.toJSONString(), calleeId, roboticId, robotic.getCompanyId());
         addLog.setStatus(1);
         addLog.setCallbackUuid(callBackUuid);
         companyVoiceRoboticCallLogCallphoneService.asyncInsertCompanyVoiceRoboticCallLog(addLog);
@@ -374,6 +394,22 @@ public class AiCallTaskNode extends AbstractWorkflowNode {
         return false;
     }
 
+    /**
+     * 获取siptaskid
+     * @param roboticId
+     * @param nodeKey
+     * @param workflowInstanceId
+     * @return
+     */
+    public Long getTaskBatchId(Long roboticId, String nodeKey, String workflowInstanceId){
+        CompanySiptaskInfo sipTaskInfo = companySiptaskInfoMapper.selectSipTaskInfoByTaskIdAndNodeKey(roboticId, nodeKey);
+        if(null != sipTaskInfo && null != sipTaskInfo.getBatchId()){
+            return  sipTaskInfo.getBatchId();
+        }
+        //没有的情况下创建任务并返回
+        CompanyAiWorkflowExec companyAiWorkflowExec = companyAiWorkflowExecMapper.selectByWorkflowInstanceId(workflowInstanceId);
+        return companyWorkflowEngine.createSipTask(roboticId, companyAiWorkflowExec.getWorkflowId());
+    }
 //    @Override
 //    protected void postExecute(ExecutionContext context, ExecutionResult result) {
 //        super.postExecute(context, result);

+ 15 - 14
fs-service/src/main/java/com/fs/company/service/impl/call/node/EndNode.java

@@ -46,19 +46,20 @@ public class EndNode extends AbstractWorkflowNode {
     protected void postExecute(ExecutionContext context, ExecutionResult result) {
       super.postExecute(context, result);
       super.updateWorkflowStatus(context.getWorkflowInstanceId(), ExecutionStatusEnum.SUCCESS);
-      //判定是否任务完成了更新任务的状态为执行完成
-        CompanyVoiceRoboticBusiness roboticBusiness = getRoboticBusiness(context.getWorkflowInstanceId());
-        if(null != roboticBusiness){
-            Integer i = companyVoiceRoboticBusinessMapper.selectUnfinishedTaskCountByRoboticId(roboticBusiness.getRoboticId(), nodeKey);
-            if(Integer.valueOf(0).equals(i)){
-                CompanyVoiceRobotic robotic = new CompanyVoiceRobotic();
-                CompanyVoiceRobotic currentRobitic = companyVoiceRoboticMapper.selectCompanyVoiceRoboticById(roboticBusiness.getRoboticId());
-                if(currentRobitic.getTaskType().equals(TaskTypeEnum.ORDINARY.getValue())){
-                    robotic.setId(roboticBusiness.getRoboticId());
-                    robotic.setTaskStatus(3);
-                    companyVoiceRoboticMapper.updateById(robotic);
-                }
-            }
-        }
+      super.taskFinish(context);
+//      //判定是否任务完成了更新任务的状态为执行完成
+//        CompanyVoiceRoboticBusiness roboticBusiness = getRoboticBusiness(context.getWorkflowInstanceId());
+//        if(null != roboticBusiness){
+//            Integer i = companyVoiceRoboticBusinessMapper.selectUnfinishedTaskCountByRoboticId(roboticBusiness.getRoboticId(), nodeKey);
+//            if(Integer.valueOf(0).equals(i)){
+//                CompanyVoiceRobotic robotic = new CompanyVoiceRobotic();
+//                CompanyVoiceRobotic currentRobitic = companyVoiceRoboticMapper.selectCompanyVoiceRoboticById(roboticBusiness.getRoboticId());
+//                if(currentRobitic.getTaskType().equals(TaskTypeEnum.ORDINARY.getValue())){
+//                    robotic.setId(roboticBusiness.getRoboticId());
+//                    robotic.setTaskStatus(3);
+//                    companyVoiceRoboticMapper.updateById(robotic);
+//                }
+//            }
+//        }
     }
 }

+ 3 - 0
fs-service/src/main/java/com/fs/company/service/impl/call/node/WorkflowNodeFactory.java

@@ -39,6 +39,9 @@ public class WorkflowNodeFactory implements IWorkflowNodeFactory {
             case AI_ADD_WX_TASK:
                 node = new AiAddWxTaskNode(nodeKey, nodeName, properties);
                 break;
+            case AI_ADD_WX_TASK_NEW:
+                node = new AiAddWxTaskNewNode(nodeKey, nodeName, properties);
+                break;
             case AI_QW_ADD_WX_TASK:
                 node = new AiQwAddWxTaskNode(nodeKey, nodeName, properties);
                 break;

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

@@ -0,0 +1,14 @@
+package com.fs.company.vo;
+
+import lombok.Data;
+
+/**
+ * 外呼任务下单个 callee(company_voice_robotic_callees.id)的呼出次数统计
+ */
+@Data
+public class CalleeRoboticCallOutCountVO {
+
+    private Long calleeId;
+
+    private Long callCount;
+}

+ 6 - 0
fs-service/src/main/java/com/fs/company/vo/CompanyUserQwListVO.java

@@ -155,4 +155,10 @@ public class CompanyUserQwListVO extends BaseEntity {
      */
     private  Integer bindStatus;
 
+    /**
+     * 绑定销售易账号
+     */
+    private Long accountId;
+
+
 }

+ 2 - 0
fs-service/src/main/java/com/fs/company/vo/CompanyVO.java

@@ -107,4 +107,6 @@ public class CompanyVO implements Serializable
 
     // 控制休息提示是否打开要暂停  0-关闭 1-打开 null-默认打开
     private Integer isOpenRestReminder;
+
+    private Integer accountId;
 }

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

@@ -0,0 +1,14 @@
+package com.fs.company.vo;
+
+import lombok.Data;
+
+/**
+ * 客户维度 AI 外呼呼出次数统计(按 crm_customer.customer_id,对应 callees.user_id)
+ */
+@Data
+public class CustomerRoboticCallOutCountVO {
+
+    private Long customerId;
+
+    private Long callCount;
+}

+ 14 - 0
fs-service/src/main/java/com/fs/company/vo/easycall/EasyCallBizGroupVO.java

@@ -0,0 +1,14 @@
+package com.fs.company.vo.easycall;
+
+import lombok.Data;
+
+/**
+ * EasyCallCenter365 业务组VO
+ */
+@Data
+public class EasyCallBizGroupVO {
+    /** 业务组ID */
+    private Long groupId;
+    /** 业务组名称 */
+    private String bizGroupName;
+}

+ 75 - 0
fs-service/src/main/java/com/fs/company/vo/easycall/EasyCallInboundLlmVO.java

@@ -0,0 +1,75 @@
+package com.fs.company.vo.easycall;
+
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+import java.io.Serializable;
+
+/**
+ * 呼入大模型配置对象 cc_inbound_llm_account
+ *
+ * @author fs
+ */
+@Data
+@Accessors(chain = true)
+public class EasyCallInboundLlmVO implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    /** 主键id */
+    private Integer id;
+
+    /** 呼入别名/名称 */
+    private String inboundAlias;
+
+    /** 大模型底座id */
+    private Integer llmAccountId;
+
+    /** 被叫号码 */
+    private String callee;
+
+    /** TTS voice code */
+    private String voiceCode;
+
+    /** TTS voice source */
+    private String voiceSource;
+
+    /** 服务类型:ai/acd/ivr */
+    private String serviceType;
+
+    /** ASR提供商 */
+    private String asrProvider;
+
+    /** AI转接类型:acd/extension/gateway */
+    private String aiTransferType;
+
+    /** AI转接数据 */
+    private String aiTransferData;
+
+    /** IVR ID */
+    private String ivrId;
+
+    /** 满意度调查IVR ID */
+    private String satisfSurveyIvrId;
+
+    /************ 以下不是表结构字段 ************/
+    /** 大模型底座名称 */
+    private String llmAccountName;
+
+    /** 音色名称 */
+    private String voiceName;
+
+    /** 分组ID */
+    private Integer groupId;
+
+    /** AI转接技能组ID */
+    private String aiTransferGroupId;
+
+    /** AI转接网关ID */
+    private String aiTransferGatewayId;
+
+    /** AI转接网关目标号码 */
+    private String aiTransferGatewayDestNumber;
+
+    /** AI转接分机号 */
+    private String aiTransferExtNumber;
+}

+ 14 - 0
fs-service/src/main/java/com/fs/company/vo/easycall/EasyCallIvrVO.java

@@ -0,0 +1,14 @@
+package com.fs.company.vo.easycall;
+
+import lombok.Data;
+
+/**
+ * EasyCallCenter365 IVR VO
+ */
+@Data
+public class EasyCallIvrVO {
+    /** IVR ID */
+    private Long id;
+    /** IVR节点名称 */
+    private String ivrNodeName;
+}

+ 5 - 0
fs-service/src/main/java/com/fs/course/config/CourseConfig.java

@@ -54,6 +54,11 @@ public class CourseConfig implements Serializable {
     private Integer isOpen;
     private Boolean completionCountdown;
 
+    /**
+     * 外网通用看课链接域名
+     */
+    private String realLinkGjDomainName;
+
     /**
      * 侧边栏是否仅展示当天课程
      */

+ 16 - 2
fs-service/src/main/java/com/fs/course/param/FsCourseSendRewardUParam.java

@@ -16,26 +16,40 @@ import java.io.Serializable;
 public class FsCourseSendRewardUParam implements Serializable
 {
     private Long userId;
+
     @NotNull(message = "课程参数不能为空")
     private Long videoId;//小节Id
-    @NotBlank(message = "客服参数不能为空")
+
+//    @NotBlank(message = "客服参数不能为空")
     private String qwUserId;
+
     @NotNull(message = "客服参数不能为空")
     private Long companyUserId;
+
     @NotNull(message = "经销商参数不能为空")
     private Long companyId;
+
     @NotNull(message = "课程参数不能为空")
     private Long courseId;
+
     private String corpId;
+
     private Integer linkType;
-    @NotNull(message = "课程参数不能为空")
+
+//    @NotNull(message = "课程参数不能为空")
     private Long qwExternalId;
+
     private Integer source=1;//来源 1:h5  2:小程序 3:app
+
     private Integer isRoom;
+
     private Integer sendType;
+
     private Long periodId;
+
     @NotBlank(message = "小程序参数不能为空")
     private String appId; //前端传来的小程序的appid
+
     private Integer rewardType; //奖励类型 1红包 2积分 3随机转盘 4保底转盘
 
     private String code;

+ 30 - 0
fs-service/src/main/java/com/fs/course/param/LiveQuizSubmitUParam.java

@@ -0,0 +1,30 @@
+package com.fs.course.param;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.List;
+
+/**
+ * 直播答题提交(观众端)
+ */
+@Data
+public class LiveQuizSubmitUParam implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 当前登录用户(由 Controller 写入) */
+    private Long userId;
+
+    private Long liveId;
+
+    private Long relId;
+
+    private Long questionBankId;
+
+    /** 与题库一致:1 单选 2 多选 */
+    private Integer type;
+
+    /** 用户选择的选项 key,如 A、B;多选为多个 */
+    private List<String> answerKeys;
+}

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

@@ -5,6 +5,7 @@ import com.fs.course.domain.FsCourseQuestionBank;
 import com.fs.course.dto.FsCourseQuestionBankImportDTO;
 import com.fs.course.dto.ImportResultDTO;
 import com.fs.course.param.FsCourseQuestionAnswerUParam;
+import com.fs.course.param.LiveQuizSubmitUParam;
 
 import javax.validation.constraints.Size;
 import java.util.List;
@@ -93,4 +94,9 @@ public interface IFsCourseQuestionBankService
     R courseAnswerByFsUser(FsCourseQuestionAnswerUParam param);
 
     R courseAnswerIsOpen(FsCourseQuestionAnswerUParam param, boolean isH5User);
+
+    /**
+     * 直播答题提交:校验直播间-题目关联、题库是否存在,按 course.config 决定是否跳过对错校验(与课程答题 submit 逻辑一致)。
+     */
+    R submitLiveQuiz(LiveQuizSubmitUParam param);
 }

+ 2 - 1
fs-service/src/main/java/com/fs/course/service/IFsUserCourseService.java

@@ -12,6 +12,7 @@ import com.fs.his.vo.OptionsVO;
 import javax.validation.constraints.NotNull;
 import java.io.IOException;
 import java.io.InputStream;
+import java.io.UnsupportedEncodingException;
 import java.util.List;
 import java.util.Map;
 
@@ -137,7 +138,7 @@ public interface IFsUserCourseService {
 
     List<FsUserCourseVideoAppletVO> selectFsUserCourseVideoAppletListByCourseId(Long courseId);
 
-    R createAppCourseSortLink(FsCourseLinkCreateParam fsCourseLinkCreateParam);
+    R createAppCourseSortLink(FsCourseLinkCreateParam fsCourseLinkCreateParam) throws UnsupportedEncodingException;
 
     /**
      * 修改课堂配置

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

@@ -14,8 +14,10 @@ import com.fs.course.dto.FsCourseQuestionBankImportDTO;
 import com.fs.course.dto.ImportResultDTO;
 import com.fs.course.mapper.*;
 import com.fs.course.param.FsCourseQuestionAnswerUParam;
+import com.fs.course.param.LiveQuizSubmitUParam;
 import com.fs.course.service.IFsCourseQuestionBankService;
 import com.fs.course.util.CourseConfigUserAnswerExpose;
+import com.fs.live.mapper.LiveCourseQuestionRelMapper;
 import com.fs.his.domain.FsUser;
 import com.fs.his.mapper.FsUserMapper;
 import com.fs.his.service.IFsStorePaymentService;
@@ -59,6 +61,8 @@ public class FsCourseQuestionBankServiceImpl implements IFsCourseQuestionBankSer
     @Autowired
     private FsCourseWatchLogMapper courseWatchLogMapper;
     @Autowired
+    private LiveCourseQuestionRelMapper liveCourseQuestionRelMapper;
+    @Autowired
     private FsUserCourseCategoryMapper courseCategoryMapper;
     @Value("${cloud_host.company_name}")
     private String signProjectName;
@@ -506,6 +510,84 @@ public class FsCourseQuestionBankServiceImpl implements IFsCourseQuestionBankSer
         }
     }
 
+    @Override
+    public R submitLiveQuiz(LiveQuizSubmitUParam param) {
+        if (param == null) {
+            return R.error("参数不能为空");
+        }
+        if (param.getLiveId() == null || param.getRelId() == null || param.getQuestionBankId() == null) {
+            return R.error("参数不完整");
+        }
+        List<String> keys = param.getAnswerKeys();
+        if (keys == null || keys.isEmpty()) {
+            return R.error("请选择答案");
+        }
+        Long questionBankIdFromRel = liveCourseQuestionRelMapper.selectQuestionBankIdByLiveAndRel(
+                param.getLiveId(), param.getRelId());
+        if (questionBankIdFromRel == null) {
+            return R.error("题目与直播间关联无效");
+        }
+        if (!questionBankIdFromRel.equals(param.getQuestionBankId())) {
+            return R.error("题目信息不一致");
+        }
+        FsCourseQuestionBank bank = fsCourseQuestionBankMapper.selectFsCourseQuestionBankById(param.getQuestionBankId());
+        if (bank == null) {
+            return R.error("题目不存在");
+        }
+        if (param.getType() != null && bank.getType() != null
+                && bank.getType().intValue() != param.getType()) {
+            return R.error("题目类型不匹配");
+        }
+        if (bank.getType() == null) {
+            return R.error("题目数据异常");
+        }
+
+        String json = configService.selectConfigByKey("course.config");
+        boolean skipAnswerValidation = CourseConfigUserAnswerExpose.skipValidateAnswerOnSubmit(json);
+
+        boolean correct;
+        if (skipAnswerValidation) {
+            correct = true;
+        } else {
+            correct = matchLiveQuizUserAnswer(bank, keys);
+        }
+
+        if (correct) {
+            return R.ok("回答正确").put("correct", true);
+        }
+        return R.ok("回答错误").put("correct", false);
+    }
+
+    /**
+     * 与 {@link #courseAnswer} 中单选/多选判分规则一致。
+     */
+    private boolean matchLiveQuizUserAnswer(FsCourseQuestionBank bank, List<String> answerKeys) {
+        if (bank.getAnswer() == null) {
+            return false;
+        }
+        long t = bank.getType();
+        if (t == 1L) {
+            if (answerKeys.size() != 1) {
+                return false;
+            }
+            String u = answerKeys.get(0) == null ? "" : answerKeys.get(0).trim();
+            String c = bank.getAnswer().trim();
+            return u.equals(c);
+        }
+        if (t == 2L) {
+            String[] userAnswers = answerKeys.stream()
+                    .filter(Objects::nonNull)
+                    .map(String::trim)
+                    .filter(s -> !s.isEmpty())
+                    .toArray(String[]::new);
+            String[] correctAnswers = convertStringToArray(bank.getAnswer());
+            Arrays.sort(userAnswers);
+            Arrays.sort(correctAnswers);
+            return Arrays.equals(userAnswers, correctAnswers);
+        }
+        return false;
+    }
+
     /**
      * 题目导入
      *

+ 9 - 3
fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseServiceImpl.java

@@ -7,6 +7,7 @@ import java.math.BigDecimal;
 import java.math.RoundingMode;
 import java.net.URL;
 import java.net.URLConnection;
+import java.net.URLEncoder;
 import java.util.*;
 import java.util.List;
 import java.util.stream.Collectors;
@@ -770,7 +771,7 @@ public class FsUserCourseServiceImpl implements IFsUserCourseService
     }
 
     @Override
-    public R createAppCourseSortLink(FsCourseLinkCreateParam param) {
+    public R createAppCourseSortLink(FsCourseLinkCreateParam param) throws UnsupportedEncodingException {
         String json = configService.selectConfigByKey("course.config");
         CourseConfig config = JSONUtil.toBean(json, CourseConfig.class);
         //短链参数
@@ -796,9 +797,14 @@ public class FsUserCourseServiceImpl implements IFsUserCourseService
         link.setUpdateTime(calendar.getTime());
         int i = fsCourseLinkMapper.insertFsCourseLink(link);
         if (i > 0){
-            if (CloudHostUtils.hasCloudHostName("中康")){
+//            String domainName = getDomainName(param.getCompanyUserId(), config);
+//            String sortLink = domainName + link.getRealLink().replace("/#","");
+//            return R.ok().put("url", sortLink).put("link", random).put("linkId", link.getLinkId());
+            //没人用我先注释了,手动发课 直接用 链接带参数
+            if (CloudHostUtils.hasCloudHostName("中康","蒙牛")){
                 String domainName = getDomainName(param.getCompanyUserId(), config);
-                String sortLink = domainName + link.getRealLink().replace("/#","");
+                String encodedCourseJson = URLEncoder.encode(courseJson, "UTF-8");
+                String sortLink = domainName +appRealLink+ encodedCourseJson;
                 return R.ok().put("url", sortLink).put("link", random).put("linkId", link.getLinkId());
             }
             String domainName = getDomainName(param.getCompanyUserId(), config);

+ 5 - 5
fs-service/src/main/java/com/fs/crm/service/impl/CrmCustomerAnalyzeServiceImpl.java

@@ -399,7 +399,7 @@ public class CrmCustomerAnalyzeServiceImpl extends ServiceImpl<CrmCustomerAnalyz
         requestParam.put("isRepository", "");
         requestParam.put("history", "");
         requestParam.put("aiContent", "");
-        requestParam.put("likeRatio", "");
+        requestParam.put("userIntent", "");
         long startTime = System.currentTimeMillis();
         R aiResponse = CrmCustomerAiTagUtil.callAiService(requestParam, Long.valueOf(param.getChatId()),OTHER_KEY);
         System.out.println(aiResponse);
@@ -545,7 +545,7 @@ public class CrmCustomerAnalyzeServiceImpl extends ServiceImpl<CrmCustomerAnalyz
         requestParam.put("isRepository", "");
         requestParam.put("userContent", "");
         requestParam.put("aiContent", "");
-        requestParam.put("likeRatio", "");
+        requestParam.put("userIntent", "");
         requestParam.put("modelType","客户意向度");
 //        log.info("请求参数:{}", requestParam);
 
@@ -780,7 +780,7 @@ public class CrmCustomerAnalyzeServiceImpl extends ServiceImpl<CrmCustomerAnalyz
         requestParam.put("isRepository", "");
         requestParam.put("userContent", "");
         requestParam.put("aiContent", "");
-        requestParam.put("likeRatio", "");
+        requestParam.put("userIntent", "");
         requestParam.put("modelType","ai标签");
 
         return requestParam;
@@ -896,7 +896,7 @@ public class CrmCustomerAnalyzeServiceImpl extends ServiceImpl<CrmCustomerAnalyz
         requestParam.put("isRepository", "");
         requestParam.put("userContent", "");
         requestParam.put("aiContent", "");
-        requestParam.put("likeRatio", likeRatio);
+        requestParam.put("userIntent", likeRatio);
 
         return requestParam;
     }
@@ -944,7 +944,7 @@ public class CrmCustomerAnalyzeServiceImpl extends ServiceImpl<CrmCustomerAnalyz
         requestParam.put("isRepository", "");
         requestParam.put("userContent", "");
         requestParam.put("aiContent", "");
-        requestParam.put("likeRatio", likeRatio);
+        requestParam.put("userIntent", likeRatio);
 
         return requestParam;
     }

+ 2 - 0
fs-service/src/main/java/com/fs/crm/vo/CrmCustomerListQueryVO.java

@@ -18,6 +18,8 @@ public class CrmCustomerListQueryVO implements Serializable
 
     /** 组织机构代码 */
     private String customerCode;
+    /** AI 外呼呼出次数(company_voice_robotic_call_log_callphone,经 callees.user_id=customerId) */
+    private Integer roboticCallOutCount;
 
     /** 客户名称 */
     private String customerName;

+ 2 - 0
fs-service/src/main/java/com/fs/crm/vo/CrmLineCustomerListQueryVO.java

@@ -17,6 +17,8 @@ public class CrmLineCustomerListQueryVO implements Serializable
 
     /** 组织机构代码 */
     private String customerCode;
+    /** AI 外呼呼出次数(company_voice_robotic_call_log_callphone,经 callees.user_id=customerId) */
+    private Integer roboticCallOutCount;
 
     /** 客户名称 */
     private String customerName;

+ 2 - 0
fs-service/src/main/java/com/fs/crm/vo/CrmMyCustomerListQueryVO.java

@@ -145,4 +145,6 @@ public class CrmMyCustomerListQueryVO implements Serializable
 
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
     private Date visitTime;
+    /** AI 外呼呼出次数(company_voice_robotic_call_log_callphone,经 callees.user_id=customerId) */
+    private Integer roboticCallOutCount;
 }

+ 5 - 1
fs-service/src/main/java/com/fs/enums/NodeTypeEnum.java

@@ -48,7 +48,11 @@ public enum NodeTypeEnum {
     /**
      * AI企微添加个微
      */
-    AI_QW_ADD_WX_TASK("AI_QW_ADD_WX_TASK", "AI企微添加个微",9);
+    AI_QW_ADD_WX_TASK("AI_QW_ADD_WX_TASK", "AI企微添加个微",9),
+    /**
+     * AI添加微信(新)
+     */
+    AI_ADD_WX_TASK_NEW("AI_ADD_WX_TASK_NEW", "AI添加微信(新)", 10);
 
     private final String code;
     private final String description;

+ 10 - 0
fs-service/src/main/java/com/fs/his/config/CidPhoneConfig.java

@@ -43,4 +43,14 @@ public class CidPhoneConfig implements Serializable {
      * 拨打次数
      * **/
     private Long numberCalls;
+
+    /**
+     * 是否允许重复客户
+     */
+    private Boolean allowRepeatCustomer;
+
+    /**
+     * 配置回调地址
+     */
+    private String callbackUrl;
 }

+ 2 - 0
fs-service/src/main/java/com/fs/his/dto/SendResultDetailDTO.java

@@ -2,10 +2,12 @@ package com.fs.his.dto;
 
 import lombok.AllArgsConstructor;
 import lombok.Data;
+import lombok.NoArgsConstructor;
 
 import java.io.Serializable;
 
 @Data
+@NoArgsConstructor
 @AllArgsConstructor
 public class SendResultDetailDTO implements Serializable {
     private boolean success;

+ 2 - 0
fs-service/src/main/java/com/fs/hisStore/vo/StoreOperMainVO.java

@@ -2,8 +2,10 @@ package com.fs.hisStore.vo;
 
 import lombok.AllArgsConstructor;
 import lombok.Data;
+import lombok.NoArgsConstructor;
 
 @Data
+@NoArgsConstructor
 @AllArgsConstructor
 public class StoreOperMainVO {
     private String id;

+ 28 - 0
fs-service/src/main/java/com/fs/ipad/WxIpadSendUtils.java

@@ -0,0 +1,28 @@
+package com.fs.ipad;
+
+
+import com.fs.common.core.redis.RedisCacheT;
+import com.fs.ipad.vo.WxBaseVo;
+import com.fs.ipad.vo.WxTxtVo;
+import com.fs.wxwork.service.WxService;
+import lombok.AllArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+@Slf4j
+@Component
+@AllArgsConstructor
+public class WxIpadSendUtils {
+
+    private final WxService wxService;
+
+    /**
+     * 发送文本
+     */
+    public void sendTxt(WxBaseVo baseVo, String txt){
+        WxTxtVo vo = new WxTxtVo();
+        vo.setBase(baseVo);
+        vo.setContent(txt);
+        wxService.sendTxt(vo);
+    }
+}

+ 20 - 0
fs-service/src/main/java/com/fs/ipad/vo/WxBaseVo.java

@@ -0,0 +1,20 @@
+package com.fs.ipad.vo;
+
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class WxBaseVo {
+
+    private Long id;
+    private Long serverId;
+    private String remark;
+
+
+    public void setBase(WxBaseVo vo){
+        this.id = vo.getId();
+        this.serverId = vo.getServerId();
+        this.remark = vo.getRemark();
+    }
+}

+ 11 - 0
fs-service/src/main/java/com/fs/ipad/vo/WxTxtVo.java

@@ -0,0 +1,11 @@
+package com.fs.ipad.vo;
+
+import lombok.Builder;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@Data
+public class WxTxtVo extends WxBaseVo {
+
+    private String content;
+}

+ 14 - 1
fs-service/src/main/java/com/fs/live/domain/Live.java

@@ -39,6 +39,11 @@ public class   Live extends BaseEntity {
      */
     private Long companyUserId;
 
+    /**
+     * 营期ID(非空表示归属「训练营-营期」直播间,与普通直播列表区分)
+     */
+    private Long trainingPeriodId;
+
     /**
      * 达人ID
      */
@@ -121,7 +126,7 @@ public class   Live extends BaseEntity {
     /** 直播配置 */
     private String configJson;
 
-    /** 直播审核状态,销售端修改后需要总后台审核 0未审核 1已审核*/
+    /** 直播审核状态:0待审核 1已通过 2已驳回(训练营直播间新建/企业修改后需总后台审核;C 端仅展示已通过) */
     private Integer isAudit;
     /** 创建时间 */
     private Date createTime;
@@ -136,4 +141,12 @@ public class   Live extends BaseEntity {
 
     private Integer pageNum;
     private Integer pageSize;
+
+    /** 查询用:为 true 时仅查普通直播(training_period_id 为空),企业端默认排除训练营直播间 */
+    @TableField(exist = false)
+    private Boolean excludeCampLive;
+
+    /** 查询用:为 true 时仅查训练营直播间(training_period_id 非空),总后台审核列表等 */
+    @TableField(exist = false)
+    private Boolean onlyTrainingCampLive;
 }

+ 22 - 0
fs-service/src/main/java/com/fs/live/domain/LiveCourseQuestionRel.java

@@ -0,0 +1,22 @@
+package com.fs.live.domain;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 直播间与课程题库试题关联
+ */
+@Data
+public class LiveCourseQuestionRel {
+
+    private Long relId;
+
+    private Long liveId;
+
+    private Long questionBankId;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+}

+ 37 - 0
fs-service/src/main/java/com/fs/live/domain/LiveTrainingCamp.java

@@ -0,0 +1,37 @@
+package com.fs.live.domain;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 直播训练营
+ *
+ * @author fs
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class LiveTrainingCamp extends BaseEntity {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long campId;
+
+    private Long companyId;
+
+    private String campName;
+
+    private Integer sortOrder;
+
+    /** 0正常 1停用 */
+    private Integer status;
+
+    private Integer isDel;
+
+    private String description;
+
+    /** 关联展示:企业名称(总后台列表) */
+    @TableField(exist = false)
+    private String companyName;
+}

+ 52 - 0
fs-service/src/main/java/com/fs/live/domain/LiveTrainingPeriod.java

@@ -0,0 +1,52 @@
+package com.fs.live.domain;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.util.Date;
+
+/**
+ * 直播训练营营期
+ *
+ * @author fs
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class LiveTrainingPeriod extends BaseEntity {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long periodId;
+
+    private Long campId;
+
+    private String periodName;
+
+    /** 营期封面图 URL */
+    private String periodImgUrl;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private String startTime;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private String endTime;
+
+    private Integer sortOrder;
+
+    /** 0正常 1停用 */
+    private Integer status;
+
+    private Integer isDel;
+
+    private String description;
+
+    /** 查询展示:训练营名称 */
+    private String campName;
+
+    /** 列表/更新权限校验:当前企业 */
+    @TableField(exist = false)
+    private Long companyId;
+}

+ 28 - 0
fs-service/src/main/java/com/fs/live/mapper/LiveCourseQuestionRelMapper.java

@@ -0,0 +1,28 @@
+package com.fs.live.mapper;
+
+import com.fs.live.domain.LiveCourseQuestionRel;
+import com.fs.live.vo.LiveQuestionLiveVO;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 直播间-课程题库关联
+ */
+public interface LiveCourseQuestionRelMapper {
+
+    List<LiveQuestionLiveVO> selectLinkedByLiveId(@Param("liveId") Long liveId);
+
+    List<LiveQuestionLiveVO> selectOptionQuestionBank(@Param("liveId") Long liveId,
+                                                      @Param("title") String title,
+                                                      @Param("type") Integer type);
+
+    int insertIgnore(@Param("liveId") Long liveId, @Param("questionBankId") Long questionBankId);
+
+    int deleteByRelIds(@Param("liveId") Long liveId, @Param("relIds") Long[] relIds);
+
+    /**
+     * 校验关联是否属于该直播间,并解析课程题库主键
+     */
+    Long selectQuestionBankIdByLiveAndRel(@Param("liveId") Long liveId, @Param("relId") Long relId);
+}

+ 8 - 0
fs-service/src/main/java/com/fs/live/mapper/LiveMapper.java

@@ -6,6 +6,7 @@ import com.fs.common.enums.DataSourceType;
 import com.fs.live.domain.Live;
 import com.fs.live.param.LiveDataParam;
 import com.fs.live.vo.LiveListVo;
+import com.fs.live.vo.TrainingLiveAuditVO;
 import org.apache.ibatis.annotations.Param;
 import org.apache.ibatis.annotations.Select;
 import org.apache.ibatis.annotations.Update;
@@ -50,6 +51,8 @@ public interface LiveMapper
      */
     public List<Live> selectLiveList(Live live);
 
+    List<TrainingLiveAuditVO> selectTrainingLiveAuditList(Live live);
+
     /**
      * 新增直播
      *
@@ -235,6 +238,7 @@ public interface LiveMapper
 
     @Select({"<script>" +
             " SELECT * FROM live WHERE is_audit = 1 and is_del = 0 and status in (1,2,4) and live_type in (2,3) " +
+            " and training_period_id is null " +
             "  <if test='live.liveName!=null' > and live_name like concat('%',#{live.liveName},'%') </if> " +
             " order by create_time desc" +
             " </script>"})
@@ -254,8 +258,12 @@ public interface LiveMapper
 
     @Select({"<script>" +
             " SELECT * FROM live WHERE is_audit = 1 and is_del = 0 and status in (1,2,4)" +
+            " and training_period_id is null " +
             "  <if test='live.liveName!=null' > and live_name like concat('%',#{live.liveName},'%') </if> " +
             " order by create_time desc" +
             " </script>"})
     List<Live> listToLiveNoEndNew(@Param("live") Live live);
+
+    @Select("SELECT COUNT(1) FROM live WHERE is_del = 0 AND training_period_id = #{periodId}")
+    int countLiveByTrainingPeriodId(@Param("periodId") Long periodId);
 }

+ 25 - 0
fs-service/src/main/java/com/fs/live/mapper/LiveTrainingCampMapper.java

@@ -0,0 +1,25 @@
+package com.fs.live.mapper;
+
+import com.fs.live.domain.LiveTrainingCamp;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 直播训练营 Mapper
+ */
+public interface LiveTrainingCampMapper {
+
+    LiveTrainingCamp selectLiveTrainingCampById(@Param("campId") Long campId);
+
+    List<LiveTrainingCamp> selectLiveTrainingCampList(LiveTrainingCamp query);
+
+    int insertLiveTrainingCamp(LiveTrainingCamp camp);
+
+    int updateLiveTrainingCamp(LiveTrainingCamp camp);
+
+    int logicDeleteLiveTrainingCampByIds(@Param("campIds") Long[] campIds, @Param("companyId") Long companyId,
+                                         @Param("createBy") String createBy);
+
+    List<LiveTrainingCamp> selectLiveTrainingCampListAdmin(LiveTrainingCamp query);
+}

+ 27 - 0
fs-service/src/main/java/com/fs/live/mapper/LiveTrainingPeriodMapper.java

@@ -0,0 +1,27 @@
+package com.fs.live.mapper;
+
+import com.fs.live.domain.LiveTrainingPeriod;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 直播训练营营期 Mapper
+ */
+public interface LiveTrainingPeriodMapper {
+
+    LiveTrainingPeriod selectLiveTrainingPeriodById(@Param("periodId") Long periodId);
+
+    List<LiveTrainingPeriod> selectLiveTrainingPeriodList(LiveTrainingPeriod query);
+
+    int countPeriodByCampId(@Param("campId") Long campId);
+
+    int insertLiveTrainingPeriod(LiveTrainingPeriod period);
+
+    int updateLiveTrainingPeriod(LiveTrainingPeriod period);
+
+    int logicDeleteLiveTrainingPeriodByIds(@Param("periodIds") Long[] periodIds, @Param("companyId") Long companyId,
+                                          @Param("createBy") String createBy);
+
+    List<LiveTrainingPeriod> selectLiveTrainingPeriodListAdmin(LiveTrainingPeriod query);
+}

+ 18 - 0
fs-service/src/main/java/com/fs/live/param/LiveTrainingLiveAuditBody.java

@@ -0,0 +1,18 @@
+package com.fs.live.param;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+@Data
+public class LiveTrainingLiveAuditBody implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long liveId;
+
+    /** true=审核通过(1) false=驳回(2) */
+    private Boolean passed;
+
+    private String remark;
+}

+ 21 - 0
fs-service/src/main/java/com/fs/live/service/ILiveCourseQuestionRelService.java

@@ -0,0 +1,21 @@
+package com.fs.live.service;
+
+import com.fs.live.vo.LiveQuestionLiveVO;
+
+import java.util.List;
+
+/**
+ * 直播间与课程题库关联
+ */
+public interface ILiveCourseQuestionRelService {
+
+    void checkLiveCompany(Long liveId, Long companyId);
+
+    List<LiveQuestionLiveVO> selectLinkedByLiveId(Long liveId, Long companyId);
+
+    List<LiveQuestionLiveVO> selectOptionQuestionBank(Long liveId, Long companyId, String title, Integer type);
+
+    int batchAdd(Long liveId, Long companyId, String questionIdsCsv);
+
+    int deleteByRelIds(Long liveId, Long companyId, String relIdsCsv);
+}

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

@@ -10,6 +10,7 @@ import com.fs.common.core.domain.R;
 import com.fs.live.domain.Live;
 import com.fs.live.vo.LiveConfigVo;
 import com.fs.live.vo.LiveListVo;
+import com.fs.live.vo.TrainingLiveAuditVO;
 
 import java.util.HashMap;
 import java.util.List;
@@ -232,4 +233,9 @@ public interface ILiveService
     R createAppLink(CompanyUser user, Long liveId, String corpId);
 
     List<Live> listToLiveNoEndNew(Live live);
+
+    /**
+     * 训练营直播间审核列表(含营期/训练营/企业名称)
+     */
+    List<TrainingLiveAuditVO> selectTrainingLiveAuditList(Live query);
 }

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

@@ -0,0 +1,27 @@
+package com.fs.live.service;
+
+import com.fs.live.domain.LiveTrainingCamp;
+
+import java.util.List;
+
+/**
+ * 直播训练营
+ */
+public interface ILiveTrainingCampService {
+
+    List<LiveTrainingCamp> selectLiveTrainingCampList(LiveTrainingCamp query);
+
+    LiveTrainingCamp selectLiveTrainingCampById(Long campId, Long companyId, String operatorUserName);
+
+    int insertLiveTrainingCamp(LiveTrainingCamp camp);
+
+    int updateLiveTrainingCamp(LiveTrainingCamp camp);
+
+    int deleteLiveTrainingCampByIds(Long[] campIds, Long companyId, String createBy);
+
+    /** 总后台列表(不按创建人隔离,含企业名称) */
+    List<LiveTrainingCamp> selectLiveTrainingCampListAdmin(LiveTrainingCamp query);
+
+    /** 总后台详情;companyId 非空时校验归属 */
+    LiveTrainingCamp selectLiveTrainingCampByIdForAdmin(Long campId, Long companyId);
+}

+ 34 - 0
fs-service/src/main/java/com/fs/live/service/ILiveTrainingPeriodService.java

@@ -0,0 +1,34 @@
+package com.fs.live.service;
+
+import com.fs.live.domain.Live;
+import com.fs.live.domain.LiveTrainingPeriod;
+
+import java.util.List;
+
+/**
+ * 直播训练营营期
+ */
+public interface ILiveTrainingPeriodService {
+
+    List<LiveTrainingPeriod> selectLiveTrainingPeriodList(LiveTrainingPeriod query);
+
+    LiveTrainingPeriod selectLiveTrainingPeriodById(Long periodId, Long companyId, String operatorUserName);
+
+    int insertLiveTrainingPeriod(LiveTrainingPeriod period);
+
+    int updateLiveTrainingPeriod(LiveTrainingPeriod period);
+
+    int deleteLiveTrainingPeriodByIds(Long[] periodIds, Long companyId, String createBy);
+
+    /**
+     * 在当前营期下创建直播间(写入 live.training_period_id)
+     */
+    int insertTrainingLive(Live live, Long companyId, Long companyUserId, String operatorUserName);
+
+    List<LiveTrainingPeriod> selectLiveTrainingPeriodListAdmin(LiveTrainingPeriod query);
+
+    LiveTrainingPeriod selectLiveTrainingPeriodByIdForAdmin(Long periodId, Long companyId);
+
+    /** 总后台在营期下创建直播间(不校验营期创建人;默认绑定企业下首个账号为 company_user_id) */
+    int insertTrainingLiveForAdmin(Live live, Long companyId);
+}

+ 90 - 0
fs-service/src/main/java/com/fs/live/service/impl/LiveCourseQuestionRelServiceImpl.java

@@ -0,0 +1,90 @@
+package com.fs.live.service.impl;
+
+import com.fs.common.exception.base.BaseException;
+import com.fs.common.utils.StringUtils;
+import com.fs.live.domain.Live;
+import com.fs.live.mapper.LiveCourseQuestionRelMapper;
+import com.fs.live.mapper.LiveMapper;
+import com.fs.live.service.ILiveCourseQuestionRelService;
+import com.fs.live.vo.LiveQuestionLiveVO;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@Service
+@RequiredArgsConstructor
+public class LiveCourseQuestionRelServiceImpl implements ILiveCourseQuestionRelService {
+
+    private final LiveCourseQuestionRelMapper liveCourseQuestionRelMapper;
+    private final LiveMapper liveMapper;
+
+    @Override
+    public void checkLiveCompany(Long liveId, Long companyId) {
+        if (liveId == null || companyId == null) {
+            throw new BaseException("参数错误");
+        }
+        Live live = liveMapper.selectLiveByLiveIdAndCompanyId(liveId, companyId);
+        if (live == null) {
+            throw new BaseException("直播间不存在或无权限");
+        }
+    }
+
+    @Override
+    public List<LiveQuestionLiveVO> selectLinkedByLiveId(Long liveId, Long companyId) {
+        checkLiveCompany(liveId, companyId);
+        return liveCourseQuestionRelMapper.selectLinkedByLiveId(liveId);
+    }
+
+    @Override
+    public List<LiveQuestionLiveVO> selectOptionQuestionBank(Long liveId, Long companyId, String title, Integer type) {
+        checkLiveCompany(liveId, companyId);
+        return liveCourseQuestionRelMapper.selectOptionQuestionBank(liveId, title, type);
+    }
+
+    @Override
+    public int batchAdd(Long liveId, Long companyId, String questionIdsCsv) {
+        checkLiveCompany(liveId, companyId);
+        if (StringUtils.isEmpty(questionIdsCsv)) {
+            return 0;
+        }
+        String[] parts = questionIdsCsv.split(",");
+        int n = 0;
+        for (String p : parts) {
+            if (StringUtils.isEmpty(p)) {
+                continue;
+            }
+            try {
+                Long qid = Long.parseLong(p.trim());
+                n += liveCourseQuestionRelMapper.insertIgnore(liveId, qid);
+            } catch (NumberFormatException ignored) {
+                // skip invalid
+            }
+        }
+        return n;
+    }
+
+    @Override
+    public int deleteByRelIds(Long liveId, Long companyId, String relIdsCsv) {
+        checkLiveCompany(liveId, companyId);
+        if (StringUtils.isEmpty(relIdsCsv)) {
+            return 0;
+        }
+        List<Long> ids = new ArrayList<>();
+        for (String p : relIdsCsv.split(",")) {
+            if (StringUtils.isEmpty(p)) {
+                continue;
+            }
+            try {
+                ids.add(Long.parseLong(p.trim()));
+            } catch (NumberFormatException ignored) {
+                // skip
+            }
+        }
+        if (ids.isEmpty()) {
+            return 0;
+        }
+        return liveCourseQuestionRelMapper.deleteByRelIds(liveId, ids.toArray(new Long[0]));
+    }
+}

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

@@ -626,6 +626,10 @@ public class LiveServiceImpl implements ILiveService
         return baseMapper.selectLiveList(live);
     }
 
+    @Override
+    public List<TrainingLiveAuditVO> selectTrainingLiveAuditList(Live query) {
+        return baseMapper.selectTrainingLiveAuditList(query);
+    }
 
     /**
      * 查询直播列表

+ 88 - 0
fs-service/src/main/java/com/fs/live/service/impl/LiveTrainingCampServiceImpl.java

@@ -0,0 +1,88 @@
+package com.fs.live.service.impl;
+
+import com.fs.common.exception.base.BaseException;
+import com.fs.common.utils.DateUtils;
+import com.fs.live.domain.LiveTrainingCamp;
+import com.fs.live.mapper.LiveTrainingCampMapper;
+import com.fs.live.mapper.LiveTrainingPeriodMapper;
+import com.fs.live.service.ILiveTrainingCampService;
+import com.fs.common.utils.StringUtils;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+@Service
+@RequiredArgsConstructor
+public class LiveTrainingCampServiceImpl implements ILiveTrainingCampService {
+
+    private final LiveTrainingCampMapper liveTrainingCampMapper;
+    private final LiveTrainingPeriodMapper liveTrainingPeriodMapper;
+
+    @Override
+    public List<LiveTrainingCamp> selectLiveTrainingCampList(LiveTrainingCamp query) {
+        return liveTrainingCampMapper.selectLiveTrainingCampList(query);
+    }
+
+    @Override
+    public LiveTrainingCamp selectLiveTrainingCampById(Long campId, Long companyId, String operatorUserName) {
+        LiveTrainingCamp c = liveTrainingCampMapper.selectLiveTrainingCampById(campId);
+        if (c == null || !companyId.equals(c.getCompanyId())) {
+            return null;
+        }
+        if (StringUtils.isNotEmpty(operatorUserName)
+                && (c.getCreateBy() == null || !operatorUserName.equals(c.getCreateBy()))) {
+            return null;
+        }
+        return c;
+    }
+
+    @Override
+    public int insertLiveTrainingCamp(LiveTrainingCamp camp) {
+        if (camp.getSortOrder() == null) {
+            camp.setSortOrder(0);
+        }
+        if (camp.getStatus() == null) {
+            camp.setStatus(0);
+        }
+        camp.setCreateTime(DateUtils.getNowDate());
+        return liveTrainingCampMapper.insertLiveTrainingCamp(camp);
+    }
+
+    @Override
+    public int updateLiveTrainingCamp(LiveTrainingCamp camp) {
+        camp.setUpdateTime(DateUtils.getNowDate());
+        return liveTrainingCampMapper.updateLiveTrainingCamp(camp);
+    }
+
+    @Override
+    public List<LiveTrainingCamp> selectLiveTrainingCampListAdmin(LiveTrainingCamp query) {
+        return liveTrainingCampMapper.selectLiveTrainingCampListAdmin(query);
+    }
+
+    @Override
+    public LiveTrainingCamp selectLiveTrainingCampByIdForAdmin(Long campId, Long companyId) {
+        LiveTrainingCamp c = liveTrainingCampMapper.selectLiveTrainingCampById(campId);
+        if (c == null) {
+            return null;
+        }
+        if (companyId != null && !companyId.equals(c.getCompanyId())) {
+            return null;
+        }
+        return c;
+    }
+
+    @Override
+    public int deleteLiveTrainingCampByIds(Long[] campIds, Long companyId, String createBy) {
+        if (campIds == null || campIds.length == 0 || companyId == null) {
+            return 0;
+        }
+        for (Long campId : campIds) {
+            int n = liveTrainingPeriodMapper.countPeriodByCampId(campId);
+            if (n > 0) {
+                throw new BaseException("训练营下仍有营期,无法删除");
+            }
+        }
+        return liveTrainingCampMapper.logicDeleteLiveTrainingCampByIds(campIds, companyId, createBy);
+    }
+}

+ 185 - 0
fs-service/src/main/java/com/fs/live/service/impl/LiveTrainingPeriodServiceImpl.java

@@ -0,0 +1,185 @@
+package com.fs.live.service.impl;
+
+import com.fs.common.exception.base.BaseException;
+import com.fs.common.utils.DateUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.company.domain.CompanyUser;
+import com.fs.company.mapper.CompanyUserMapper;
+import com.fs.live.domain.Live;
+import com.fs.live.domain.LiveTrainingCamp;
+import com.fs.live.domain.LiveTrainingPeriod;
+import com.fs.live.mapper.LiveMapper;
+import com.fs.live.mapper.LiveTrainingCampMapper;
+import com.fs.live.mapper.LiveTrainingPeriodMapper;
+import com.fs.live.service.ILiveService;
+import com.fs.live.service.ILiveTrainingPeriodService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.Objects;
+
+@Service
+@RequiredArgsConstructor
+public class LiveTrainingPeriodServiceImpl implements ILiveTrainingPeriodService {
+
+    private final LiveTrainingPeriodMapper liveTrainingPeriodMapper;
+    private final LiveTrainingCampMapper liveTrainingCampMapper;
+    private final LiveMapper liveMapper;
+    private final ILiveService liveService;
+    private final CompanyUserMapper companyUserMapper;
+
+    @Override
+    public List<LiveTrainingPeriod> selectLiveTrainingPeriodList(LiveTrainingPeriod query) {
+        return liveTrainingPeriodMapper.selectLiveTrainingPeriodList(query);
+    }
+
+    @Override
+    public List<LiveTrainingPeriod> selectLiveTrainingPeriodListAdmin(LiveTrainingPeriod query) {
+        return liveTrainingPeriodMapper.selectLiveTrainingPeriodListAdmin(query);
+    }
+
+    @Override
+    public LiveTrainingPeriod selectLiveTrainingPeriodByIdForAdmin(Long periodId, Long companyId) {
+        LiveTrainingPeriod p = liveTrainingPeriodMapper.selectLiveTrainingPeriodById(periodId);
+        if (p == null || (p.getIsDel() != null && p.getIsDel() == 1)) {
+            return null;
+        }
+        LiveTrainingCamp camp = liveTrainingCampMapper.selectLiveTrainingCampById(p.getCampId());
+        if (camp == null || camp.getIsDel() != null && camp.getIsDel() == 1) {
+            return null;
+        }
+        if (companyId != null && !Objects.equals(companyId, camp.getCompanyId())) {
+            return null;
+        }
+        return p;
+    }
+
+    @Override
+    public LiveTrainingPeriod selectLiveTrainingPeriodById(Long periodId, Long companyId, String operatorUserName) {
+        LiveTrainingPeriod p = liveTrainingPeriodMapper.selectLiveTrainingPeriodById(periodId);
+        if (p == null) {
+            return null;
+        }
+        LiveTrainingCamp camp = liveTrainingCampMapper.selectLiveTrainingCampById(p.getCampId());
+        if (camp == null || !companyId.equals(camp.getCompanyId())) {
+            return null;
+        }
+        if (StringUtils.isNotEmpty(operatorUserName)
+                && (p.getCreateBy() == null || !operatorUserName.equals(p.getCreateBy()))) {
+            return null;
+        }
+        return p;
+    }
+
+    @Override
+    public int insertLiveTrainingPeriod(LiveTrainingPeriod period) {
+        LiveTrainingCamp camp = liveTrainingCampMapper.selectLiveTrainingCampById(period.getCampId());
+        if (camp == null || period.getCompanyId() == null || !period.getCompanyId().equals(camp.getCompanyId())) {
+            throw new BaseException("训练营不存在或无权限");
+        }
+        if (StringUtils.isEmpty(period.getCreateBy())
+                || camp.getCreateBy() == null
+                || !period.getCreateBy().equals(camp.getCreateBy())) {
+            throw new BaseException("只能在本人创建的训练营下新增营期");
+        }
+        if (period.getSortOrder() == null) {
+            period.setSortOrder(0);
+        }
+        if (period.getStatus() == null) {
+            period.setStatus(0);
+        }
+        period.setCreateTime(DateUtils.getNowDate());
+        return liveTrainingPeriodMapper.insertLiveTrainingPeriod(period);
+    }
+
+    @Override
+    public int updateLiveTrainingPeriod(LiveTrainingPeriod period) {
+        period.setUpdateTime(DateUtils.getNowDate());
+        return liveTrainingPeriodMapper.updateLiveTrainingPeriod(period);
+    }
+
+    @Override
+    public int deleteLiveTrainingPeriodByIds(Long[] periodIds, Long companyId, String createBy) {
+        if (periodIds == null || periodIds.length == 0 || companyId == null) {
+            return 0;
+        }
+        for (Long pid : periodIds) {
+            int n = liveMapper.countLiveByTrainingPeriodId(pid);
+            if (n > 0) {
+                throw new BaseException("营期下仍有直播间,无法删除");
+            }
+        }
+        return liveTrainingPeriodMapper.logicDeleteLiveTrainingPeriodByIds(periodIds, companyId, createBy);
+    }
+
+    @Override
+    public int insertTrainingLive(Live live, Long companyId, Long companyUserId, String operatorUserName) {
+        if (live.getTrainingPeriodId() == null) {
+            throw new BaseException("请选择营期");
+        }
+        LiveTrainingPeriod p = liveTrainingPeriodMapper.selectLiveTrainingPeriodById(live.getTrainingPeriodId());
+        if (p == null || p.getIsDel() != null && p.getIsDel() == 1) {
+            throw new BaseException("营期不存在");
+        }
+        LiveTrainingCamp camp = liveTrainingCampMapper.selectLiveTrainingCampById(p.getCampId());
+        if (camp == null || !companyId.equals(camp.getCompanyId())) {
+            throw new BaseException("无权限在该营期下创建直播间");
+        }
+        if (StringUtils.isEmpty(operatorUserName)
+                || p.getCreateBy() == null
+                || !operatorUserName.equals(p.getCreateBy())) {
+            throw new BaseException("无权限在该营期下创建直播间");
+        }
+        if (live.getStartTime() == null) {
+            throw new BaseException("请填写计划开播时间");
+        }
+        if (live.getLiveType() == null) {
+            live.setLiveType(1);
+        }
+        if (live.getLiveType() == 2 && StringUtils.isEmpty(live.getVideoUrl())) {
+            throw new BaseException("录播必须上传视频");
+        }
+        live.setCompanyId(companyId);
+        live.setCompanyUserId(companyUserId);
+        if (live.getIsShow() == null) {
+            live.setIsShow(1);
+        }
+        live.setIsAudit(0);
+        return liveService.insertLive(live);
+    }
+
+    @Override
+    public int insertTrainingLiveForAdmin(Live live, Long companyId) {
+        if (live.getTrainingPeriodId() == null || companyId == null) {
+            throw new BaseException("参数错误");
+        }
+        LiveTrainingPeriod p = liveTrainingPeriodMapper.selectLiveTrainingPeriodById(live.getTrainingPeriodId());
+        if (p == null || p.getIsDel() != null && p.getIsDel() == 1) {
+            throw new BaseException("营期不存在");
+        }
+        LiveTrainingCamp camp = liveTrainingCampMapper.selectLiveTrainingCampById(p.getCampId());
+        if (camp == null || !companyId.equals(camp.getCompanyId())) {
+            throw new BaseException("无权限在该营期下创建直播间");
+        }
+        if (live.getStartTime() == null) {
+            throw new BaseException("请填写计划开播时间");
+        }
+        if (live.getLiveType() == null) {
+            live.setLiveType(1);
+        }
+        if (live.getLiveType() == 2 && StringUtils.isEmpty(live.getVideoUrl())) {
+            throw new BaseException("录播必须上传视频");
+        }
+        live.setCompanyId(companyId);
+        List<CompanyUser> users = companyUserMapper.selectCompanyUserByCompanyId(companyId);
+        if (users != null && !users.isEmpty()) {
+            live.setCompanyUserId(users.get(0).getUserId());
+        }
+        if (live.getIsShow() == null) {
+            live.setIsShow(1);
+        }
+        live.setIsAudit(0);
+        return liveService.insertLive(live);
+    }
+}

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

@@ -2,8 +2,10 @@ package com.fs.live.vo;
 
 import lombok.AllArgsConstructor;
 import lombok.Data;
+import lombok.NoArgsConstructor;
 
 @Data
+@NoArgsConstructor
 @AllArgsConstructor
 public class DateRange {
     private String start;

+ 34 - 0
fs-service/src/main/java/com/fs/live/vo/TrainingLiveAuditVO.java

@@ -0,0 +1,34 @@
+package com.fs.live.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 总后台:训练营直播间审核列表
+ */
+@Data
+public class TrainingLiveAuditVO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long liveId;
+    private String liveName;
+    private Long companyId;
+    private String companyName;
+    private Long trainingPeriodId;
+    private String periodName;
+    private String campName;
+    /** 0待审核 1已通过 2已驳回 */
+    private Integer isAudit;
+    private Integer status;
+    private Integer liveType;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date startTime;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+}

+ 2 - 0
fs-service/src/main/java/com/fs/qw/vo/QwSopCourseFinishTempSetting.java

@@ -93,6 +93,8 @@ public class QwSopCourseFinishTempSetting implements Serializable,Cloneable{
         private String linkImageUrl;
         //链接地址
         private String linkUrl;
+        //销售易追踪链接
+        private String xsyLinkUrl;
         //发送app消息的链接
         private String appLinkUrl;
         //文件地址

+ 175 - 0
fs-service/src/main/java/com/fs/sop/service/impl/SopUserLogsInfoServiceImpl.java

@@ -8,6 +8,7 @@ import com.alibaba.fastjson.JSONObject;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.fs.common.config.FSSysConfig;
 import com.fs.common.core.domain.R;
+import com.fs.common.core.redis.RedisCache;
 import com.fs.common.exception.base.BaseException;
 import com.fs.common.utils.CloudHostUtils;
 import com.fs.common.utils.PubFun;
@@ -64,6 +65,10 @@ import com.fs.system.mapper.SysConfigMapper;
 import com.fs.system.service.ISysConfigService;
 import com.fs.utils.ShortCodeGeneratorUtils;
 import com.fs.voice.utils.StringUtil;
+import com.fs.xiaoshouyi.dto.CreateMaterialRequest;
+import com.fs.xiaoshouyi.dto.CreateMaterialWithUploadResponse;
+import com.fs.xiaoshouyi.dto.GenerateLinkResponse;
+import com.fs.xiaoshouyi.service.XiaoShouYiMaterialService;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.BeanUtils;
@@ -80,12 +85,14 @@ import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
 import java.util.*;
 import java.util.concurrent.ThreadLocalRandom;
+import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.function.Consumer;
 import java.util.stream.Collectors;
 
 import static com.fs.course.utils.LinkUtil.generateRandomNumberWithLock;
 import static com.fs.course.utils.LinkUtil.generateRandomStringWithLock;
+import static com.fs.xiaoshouyi.constant.XiaoShouYiRedisKey.TRACKING_LINK_PREFIX;
 
 @Service
 public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
@@ -102,6 +109,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
     private static final String appLiveShortLink = "/pages_live/livingList?link=";
     private static final String h5miniappLink = "/pages_course/shortLink.html?s=";
 //    private static final String miniappRealLink = "/pages/index/index?course=";
+    private static final String gjminiappLink = "/pages/course/learning?course=";
 
     @Autowired
     private ISysConfigService configService;
@@ -196,6 +204,12 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
     @Autowired
     private FsUserMapper fsUserMapper;
 
+    @Autowired
+    private RedisCache redisCache;
+
+    @Autowired
+    private XiaoShouYiMaterialService xiaoShouYiMaterialService;
+
 
 
     @Override
@@ -2066,6 +2080,124 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                 //文字和短链一起
                 case "1":
                 case "3":
+                    if ("3".equals(st.getContentType())) {
+                        addWatchLogIfNeeded(item.getSopId(), param.getVideoId(), param.getCourseId(), item.getFsUserId(), String.valueOf(qwUser.getId()), companyUserId, companyId,
+                                item.getExternalId(), item.getStartTime(), dataTime, 2);
+
+                        String link = createH5GjLinkByMiniApp(st, param.getCorpId(), dataTime, param.getCourseId(), param.getVideoId(),
+                                String.valueOf(qwUser.getId()), companyUserId, companyId, item.getExternalId(), config);;
+                        String cacheKey = buildXsyLinkCacheKey(param.getCorpId(),param.getCourseId(),param.getVideoId(),companyUserId,companyId);
+                        //从redis中查询收否已经存在追踪链接
+                        String xsyTrackUrl = redisCache.getCacheObject(cacheKey).toString();
+                        if (StringUtils.isBlank(xsyTrackUrl)){
+                            // 失败不影响主流程
+                            try {
+                                if (StringUtils.isEmpty(link)) {
+                                    log.warn("销售易素材处理跳过,原因:生成原始链接为空。sopId={}, userId={}, externalId={}",
+                                            item.getSopId(), item.getFsUserId(), item.getExternalId());
+                                } else if (StringUtils.isEmpty(st.getLinkImageUrl())) {
+                                    log.warn("销售易素材处理跳过,原因:imgUrl为空。sopId={}, userId={}, externalId={}, link={}",
+                                            item.getSopId(), item.getFsUserId(), item.getExternalId(), link);
+                                } else {
+                                    String imgUrl = st.getLinkImageUrl();
+
+                                    // 2. 创建素材
+                                    CreateMaterialRequest createMaterialRequest = new CreateMaterialRequest();
+                                    createMaterialRequest.setCorpName("销售易营销云");
+                                    createMaterialRequest.setMaterialType(32);
+                                    createMaterialRequest.setCategoryName("看课");
+                                    createMaterialRequest.setTitle(StringUtils.isEmpty(st.getLinkTitle()) ? "看课素材" : st.getLinkTitle());
+                                    createMaterialRequest.setEnableStatus(1);
+                                    CreateMaterialRequest.MaterialBehaviorTrack materialBehaviorTrack = new CreateMaterialRequest.MaterialBehaviorTrack();
+                                    CreateMaterialRequest.MaterialBehaviorNotify materialBehaviorNotify = new CreateMaterialRequest.MaterialBehaviorNotify();
+                                    materialBehaviorTrack.setDisturbFlg(true);
+                                    materialBehaviorNotify.setNotifyRange(Collections.singletonList("1"));
+                                    materialBehaviorTrack.setMaterialBehaviorNotify(materialBehaviorNotify);
+                                    createMaterialRequest.setMaterialBehaviorTrack(materialBehaviorTrack);
+                                    createMaterialRequest.setSendingText(st.getSmsTemplateTitle());
+                                    createMaterialRequest.setEnableCardTemplateStatus(0);
+                                    createMaterialRequest.setSummary(st.getSmsTemplateContent());
+                                    // 32 外链素材通常不需要 content
+                                    createMaterialRequest.setUrl(link);
+
+                                    CreateMaterialWithUploadResponse materialWithUpload = xiaoShouYiMaterialService.createMaterialWithUploadByUrl(Long.valueOf(companyUserId),imgUrl, false, createMaterialRequest);
+
+                                    if (materialWithUpload == null || materialWithUpload.getMaterialId() == null) {
+                                        throw new RuntimeException("创建销售易素材失败,返回materialId为空");
+                                    }
+
+                                    // 3. 获取追踪链接
+                                    ArrayList<Long> materialIdList = new ArrayList<>();
+                                    materialIdList.add(materialWithUpload.getMaterialId());
+
+                                    GenerateLinkResponse response =
+                                            xiaoShouYiMaterialService.generateMaterialTrackLink(Long.valueOf(companyUserId),materialIdList, 4256378833539950L);
+
+                                    if (response == null) {
+                                        throw new RuntimeException("generateMaterialTrackLink返回为空");
+                                    }
+
+                                    if (!response.isSuccess()) {
+                                        throw new RuntimeException("生成追踪链接失败,msg=" + response.getMsg());
+                                    }
+
+                                    if (response.getData() == null
+                                            || response.getData().getTrackedMaterials() == null
+                                            || response.getData().getTrackedMaterials().isEmpty()
+                                            || response.getData().getTrackedMaterials().get(0).getTrackLink() == null
+                                            || StringUtils.isEmpty(response.getData().getTrackedMaterials().get(0).getTrackLink().getRedirectUrl())) {
+                                        throw new RuntimeException("生成追踪链接成功,但返回redirectUrl为空");
+                                    }
+
+                                    // 4. 覆盖成销售易追踪链接
+                                    String xsyRedirectUrl = response.getData().getTrackedMaterials().get(0).getTrackLink().getRedirectUrl();
+                                    st.setXsyLinkUrl(xsyRedirectUrl);
+                                    st.setLinkUrl(xsyRedirectUrl);
+
+                                    //将追踪链接存入redis
+                                    redisCache.setCacheObject(cacheKey, xsyRedirectUrl, 10, TimeUnit.DAYS);
+                                    log.info("销售易追踪链接写入缓存成功,cacheKey={}, xsyRedirectUrl={}", cacheKey, xsyRedirectUrl);
+
+                                    log.info("销售易素材创建并生成追踪链接成功。sopId={}, userId={}, externalId={}, materialId={}, redirectUrl={}",
+                                            item.getSopId(), item.getFsUserId(), item.getExternalId(),
+                                            materialWithUpload.getMaterialId(), xsyRedirectUrl);
+                                }
+                            } catch (Exception e) {
+                                log.error("销售易素材处理失败,不影响主流程。sopId={}, userId={}, externalId={}, contentType={}, imgUrl={}, rawLink={}",
+                                        item.getSopId(), item.getFsUserId(), item.getExternalId(), st.getContentType(), st.getImgUrl(), link, e);
+
+                                // 保底:销售易失败时,仍然使用原始 link
+                                if (StringUtils.isNotEmpty(link)) {
+                                    st.setLinkUrl(link);
+                                    st.setXsyLinkUrl(link);
+                                }
+                            }
+                        }
+
+                        st.setXsyLinkUrl(xsyTrackUrl);
+                        st.setLinkUrl(xsyTrackUrl);
+
+
+                        if (StringUtils.isNotEmpty(link)) {
+                            if ("3".equals(st.getContentType())) {
+//                                st.setLinkUrl(link);
+//                                st.setXsyLinkUrl(response.getData().getTrackedMaterials().get(0).getTrackLink().getRedirectUrl());
+//                                st.setLinkUrl(link);
+//                                st.setXsyLinkUrl(response.getData().getTrackedMaterials().get(0).getTrackLink().getRedirectUrl());
+                            } else {
+                                String currentValue = st.getValue();
+                                if (currentValue == null) {
+                                    st.setValue(link);
+                                } else {
+                                    st.setValue(currentValue
+                                            .replaceAll("#销售称呼#", StringUtil.strIsNullOrEmpty(qwUser.getWelcomeText()) ? "" : qwUser.getWelcomeText())
+                                            + "\n" + link);
+                                }
+                            }
+                        } else {
+                            log.error("生成短链失败,跳过设置 URL。");
+                        }
+                    }
 //                    if ("1".equals(st.getIsBindUrl())) {
 //
 //                        addWatchLogIfNeeded(item.getSopId(), param.getVideoId(), param.getCourseId(),item.getFsUserId(), String.valueOf(qwUser.getId()), companyUserId,
@@ -3222,4 +3354,47 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
         return link.getRealLink();
     }
 
+    private String createH5GjLinkByMiniApp(QwSopCourseFinishTempSetting.Setting setting, String corpId, Date sendTime,
+                                           Integer courseId, Integer videoId, String qwUserId,
+                                           String companyUserId, String companyId, Long externalId, CourseConfig config) {
+
+        FsCourseLink link = createFsCourseLink(corpId, sendTime, courseId, videoId, Long.valueOf(qwUserId),
+                companyUserId, companyId, externalId, 3, null);
+
+        FsCourseRealLink courseMap = new FsCourseRealLink();
+        BeanUtils.copyProperties(link, courseMap);
+
+        String courseJson = JSON.toJSONString(courseMap);
+
+        String realLinkFull = gjminiappLink + courseJson;
+        if (StringUtils.isNotEmpty(config.getRealLinkGjDomainName())) {
+            realLinkFull = config.getRealLinkGjDomainName() + realLinkFull;
+        }
+        link.setRealLink(realLinkFull);
+
+        Date updateTime = createUpdateTime(setting, sendTime, config);
+
+        link.setUpdateTime(updateTime);
+        //存短链-
+        fsCourseLinkMapper.insertFsCourseLink(link);
+        return link.getRealLink();
+    }
+
+    private String buildXsyLinkCacheKey(String corpId,
+                                        Integer courseId,
+                                        Integer videoId,
+                                        String companyUserId,
+                                        String companyId) {
+        return TRACKING_LINK_PREFIX
+                + safeVal(corpId) + ":"
+                + safeVal(courseId) + ":"
+                + safeVal(videoId) + ":"
+                + safeVal(companyUserId) + ":"
+                + safeVal(companyId);
+    }
+
+    private String safeVal(Object obj) {
+        return obj == null ? "0" : String.valueOf(obj);
+    }
+
 }

+ 6 - 0
fs-service/src/main/java/com/fs/wx/sop/domain/WxSopLogs.java

@@ -2,6 +2,8 @@ package com.fs.wx.sop.domain;
 
 import java.time.LocalDateTime;
 import java.util.Date;
+
+import com.baomidou.mybatisplus.annotation.TableField;
 import com.fasterxml.jackson.annotation.JsonFormat;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.fs.common.annotation.Excel;
@@ -86,6 +88,10 @@ public class WxSopLogs extends BaseEntityTow {
     @JsonFormat(pattern = "yyyy-MM-dd")
     @Excel(name = "消息过期时间", width = 30, dateFormat = "yyyy-MM-dd")
     private LocalDateTime expirationTime;
+    @TableField(exist = false)
+    private boolean send;
+    @TableField(exist = false)
+    private String wxRemark;
 
 
 }

+ 4 - 0
fs-service/src/main/java/com/fs/wx/sop/mapper/WxSopLogsMapper.java

@@ -4,9 +4,11 @@ import java.util.List;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.fs.common.annotation.DataSource;
 import com.fs.common.enums.DataSourceType;
+import com.fs.sop.domain.QwSopLogs;
 import com.fs.wx.sop.domain.WxSopLogs;
 import com.fs.wx.sop.params.WxSopLogsParam;
 import com.fs.wx.sop.vo.WxSopLogsListVO;
+import org.apache.ibatis.annotations.Param;
 
 /**
  * 个微发送记录Mapper接口
@@ -73,4 +75,6 @@ public interface WxSopLogsMapper extends BaseMapper<WxSopLogs>{
 
     @DataSource(DataSourceType.SOP)
     void batchInsertWxSopLogs(List<WxSopLogs> logsToInsert);
+
+    List<WxSopLogs> selectByWxId(@Param("id") Long id);
 }

Vissa filer visades inte eftersom för många filer har ändrats