Explorar o código

Merge remote-tracking branch 'origin/master'

yjwang hai 6 días
pai
achega
85e64f85e3
Modificáronse 100 ficheiros con 5003 adicións e 242 borrados
  1. 28 1
      fs-admin/src/main/java/com/fs/company/controller/CompanyVoiceRoboticController.java
  2. 70 0
      fs-admin/src/main/java/com/fs/decoration/controller/DecorationComponentController.java
  3. 28 0
      fs-admin/src/main/java/com/fs/decoration/controller/DecorationComponentTypeController.java
  4. 53 0
      fs-admin/src/main/java/com/fs/decoration/controller/DecorationTemplateController.java
  5. 103 0
      fs-admin/src/main/java/com/fs/fastGpt/FastgptChatQuestionController.java
  6. 2 0
      fs-admin/src/main/java/com/fs/live/controller/LiveController.java
  7. 89 0
      fs-admin/src/main/java/com/fs/live/controller/LiveQuestionLiveController.java
  8. 224 0
      fs-admin/src/main/java/com/fs/live/controller/LiveTrainingCampAdminController.java
  9. 258 0
      fs-admin/src/main/java/com/fs/xiaoshouyi/controller/XiaoShouYiController.java
  10. 113 0
      fs-admin/src/main/java/com/fs/xiaoshouyi/controller/XsyAccountController.java
  11. 36 0
      fs-admin/src/main/java/com/fs/xiaoshouyi/controller/XsyCompanyBindController.java
  12. 18 1
      fs-common/src/main/java/com/fs/common/config/FSSysConfig.java
  13. 14 0
      fs-common/src/main/java/com/fs/common/utils/PhoneUtils.java
  14. 3 2
      fs-company-app/src/main/java/com/fs/app/controller/FsUserCourseVideoController.java
  15. 222 0
      fs-company/src/main/java/com/fs/company/controller/company/CompanyInboundCallManageController.java
  16. 19 0
      fs-company/src/main/java/com/fs/company/controller/company/CompanyVoiceRoboticController.java
  17. 40 0
      fs-company/src/main/java/com/fs/company/controller/company/WxContactController.java
  18. 40 0
      fs-company/src/main/java/com/fs/company/controller/company/WxMsgLogController.java
  19. 58 0
      fs-company/src/main/java/com/fs/company/controller/crm/CrmCustomerController.java
  20. 97 0
      fs-company/src/main/java/com/fs/company/controller/fastGpt/FastgptChatQuestionController.java
  21. 103 0
      fs-company/src/main/java/com/fs/company/controller/fastGpt/FastgptChatQuestionStatisticsController.java
  22. 12 7
      fs-company/src/main/java/com/fs/company/controller/live/LiveController.java
  23. 58 58
      fs-company/src/main/java/com/fs/company/controller/live/LiveDataController.java
  24. 69 0
      fs-company/src/main/java/com/fs/company/controller/live/LiveQuestionLiveController.java
  25. 182 0
      fs-company/src/main/java/com/fs/company/controller/live/LiveTrainingCampController.java
  26. 5 1
      fs-company/src/main/java/com/fs/company/controller/qw/QwUserController.java
  27. 1 1
      fs-company/src/main/java/com/fs/framework/config/SecurityConfig.java
  28. 2 1
      fs-company/src/main/java/com/fs/user/FsUserAdminController.java
  29. 96 0
      fs-company/src/main/java/com/fs/xiaoshouyi/client/XiaoShouYiHttpClient.java
  30. 258 0
      fs-company/src/main/java/com/fs/xiaoshouyi/controller/XiaoShouYiController.java
  31. 131 0
      fs-company/src/main/java/com/fs/xiaoshouyi/controller/XsyAccountController.java
  32. 61 0
      fs-company/src/main/java/com/fs/xiaoshouyi/controller/XsyBindController.java
  33. 3 0
      fs-framework/src/main/java/com/fs/framework/config/SecurityConfig.java
  34. 94 0
      fs-live-app/src/main/java/com/fs/live/websocket/service/WebSocketServer.java
  35. 43 0
      fs-service/src/main/java/com/fs/company/domain/CompanySiptaskInfo.java
  36. 5 1
      fs-service/src/main/java/com/fs/company/domain/CompanyVoiceRoboticCallees.java
  37. 5 0
      fs-service/src/main/java/com/fs/company/domain/CompanyWxAccount.java
  38. 5 0
      fs-service/src/main/java/com/fs/company/mapper/CompanyAiWorkflowExecMapper.java
  39. 2 1
      fs-service/src/main/java/com/fs/company/mapper/CompanyMapper.java
  40. 64 0
      fs-service/src/main/java/com/fs/company/mapper/CompanySiptaskInfoMapper.java
  41. 22 7
      fs-service/src/main/java/com/fs/company/mapper/CompanyVoiceRoboticCallLogCallphoneMapper.java
  42. 2 0
      fs-service/src/main/java/com/fs/company/mapper/CompanyWxAccountMapper.java
  43. 2 0
      fs-service/src/main/java/com/fs/company/mapper/CompanyWxClientMapper.java
  44. 162 0
      fs-service/src/main/java/com/fs/company/mapper/EasyCallInboundLlmMapper.java
  45. 21 0
      fs-service/src/main/java/com/fs/company/param/AddWxActionParam.java
  46. 7 0
      fs-service/src/main/java/com/fs/company/service/CompanyWorkflowEngine.java
  47. 69 0
      fs-service/src/main/java/com/fs/company/service/ICompanyInboundCallManageService.java
  48. 61 0
      fs-service/src/main/java/com/fs/company/service/ICompanySiptaskInfoService.java
  49. 13 7
      fs-service/src/main/java/com/fs/company/service/ICompanyVoiceRoboticCallLogCallphoneService.java
  50. 2 0
      fs-service/src/main/java/com/fs/company/service/ICompanyWxClientService.java
  51. 16 0
      fs-service/src/main/java/com/fs/company/service/easycall/EasyCallServiceImpl.java
  52. 99 0
      fs-service/src/main/java/com/fs/company/service/impl/CompanyInboundCallManageServiceImpl.java
  53. 91 0
      fs-service/src/main/java/com/fs/company/service/impl/CompanySiptaskInfoServiceImpl.java
  54. 60 13
      fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticCallLogCallphoneServiceImpl.java
  55. 150 26
      fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticServiceImpl.java
  56. 155 9
      fs-service/src/main/java/com/fs/company/service/impl/CompanyWorkflowEngineImpl.java
  57. 5 0
      fs-service/src/main/java/com/fs/company/service/impl/CompanyWxClientServiceImpl.java
  58. 64 42
      fs-service/src/main/java/com/fs/company/service/impl/CompanyWxServiceImpl.java
  59. 23 0
      fs-service/src/main/java/com/fs/company/service/impl/call/node/AbstractWorkflowNode.java
  60. 318 0
      fs-service/src/main/java/com/fs/company/service/impl/call/node/AiAddWxTaskNewNode.java
  61. 73 37
      fs-service/src/main/java/com/fs/company/service/impl/call/node/AiCallTaskNode.java
  62. 15 14
      fs-service/src/main/java/com/fs/company/service/impl/call/node/EndNode.java
  63. 3 0
      fs-service/src/main/java/com/fs/company/service/impl/call/node/WorkflowNodeFactory.java
  64. 2 0
      fs-service/src/main/java/com/fs/company/vo/AiCallConfigVO.java
  65. 14 0
      fs-service/src/main/java/com/fs/company/vo/CalleeRoboticCallOutCountVO.java
  66. 6 0
      fs-service/src/main/java/com/fs/company/vo/CompanyUserQwListVO.java
  67. 2 0
      fs-service/src/main/java/com/fs/company/vo/CompanyVO.java
  68. 14 0
      fs-service/src/main/java/com/fs/company/vo/CustomerRoboticCallOutCountVO.java
  69. 14 0
      fs-service/src/main/java/com/fs/company/vo/easycall/EasyCallBizGroupVO.java
  70. 2 0
      fs-service/src/main/java/com/fs/company/vo/easycall/EasyCallCreateTaskParam.java
  71. 75 0
      fs-service/src/main/java/com/fs/company/vo/easycall/EasyCallInboundLlmVO.java
  72. 14 0
      fs-service/src/main/java/com/fs/company/vo/easycall/EasyCallIvrVO.java
  73. 4 0
      fs-service/src/main/java/com/fs/company/vo/easycall/EasyCallVoiceCodeVO.java
  74. 5 0
      fs-service/src/main/java/com/fs/course/config/CourseConfig.java
  75. 18 2
      fs-service/src/main/java/com/fs/course/param/FsCourseSendRewardUParam.java
  76. 30 0
      fs-service/src/main/java/com/fs/course/param/LiveQuizSubmitUParam.java
  77. 6 0
      fs-service/src/main/java/com/fs/course/service/IFsCourseQuestionBankService.java
  78. 2 1
      fs-service/src/main/java/com/fs/course/service/IFsUserCourseService.java
  79. 82 0
      fs-service/src/main/java/com/fs/course/service/impl/FsCourseQuestionBankServiceImpl.java
  80. 10 4
      fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseServiceImpl.java
  81. 7 1
      fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java
  82. 5 5
      fs-service/src/main/java/com/fs/crm/service/impl/CrmCustomerAnalyzeServiceImpl.java
  83. 2 0
      fs-service/src/main/java/com/fs/crm/vo/CrmCustomerListQueryVO.java
  84. 2 0
      fs-service/src/main/java/com/fs/crm/vo/CrmLineCustomerListQueryVO.java
  85. 2 0
      fs-service/src/main/java/com/fs/crm/vo/CrmMyCustomerListQueryVO.java
  86. 41 0
      fs-service/src/main/java/com/fs/decoration/domain/DecorationComponent.java
  87. 32 0
      fs-service/src/main/java/com/fs/decoration/domain/DecorationComponentType.java
  88. 38 0
      fs-service/src/main/java/com/fs/decoration/domain/DecorationTemplate.java
  89. 19 0
      fs-service/src/main/java/com/fs/decoration/dto/DecorationComponentQuery.java
  90. 34 0
      fs-service/src/main/java/com/fs/decoration/dto/DecorationComponentSaveReq.java
  91. 16 0
      fs-service/src/main/java/com/fs/decoration/dto/DecorationComponentStatusReq.java
  92. 31 0
      fs-service/src/main/java/com/fs/decoration/dto/DecorationTemplateSaveReq.java
  93. 43 0
      fs-service/src/main/java/com/fs/decoration/mapper/DecorationComponentMapper.java
  94. 15 0
      fs-service/src/main/java/com/fs/decoration/mapper/DecorationComponentTypeMapper.java
  95. 37 0
      fs-service/src/main/java/com/fs/decoration/mapper/DecorationTemplateMapper.java
  96. 38 0
      fs-service/src/main/java/com/fs/decoration/service/IDecorationComponentService.java
  97. 16 0
      fs-service/src/main/java/com/fs/decoration/service/IDecorationComponentTypeService.java
  98. 32 0
      fs-service/src/main/java/com/fs/decoration/service/IDecorationTemplateService.java
  99. 117 0
      fs-service/src/main/java/com/fs/decoration/service/impl/DecorationComponentServiceImpl.java
  100. 29 0
      fs-service/src/main/java/com/fs/decoration/service/impl/DecorationComponentTypeServiceImpl.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);
     }
 

+ 70 - 0
fs-admin/src/main/java/com/fs/decoration/controller/DecorationComponentController.java

@@ -0,0 +1,70 @@
+package com.fs.decoration.controller;
+
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.decoration.dto.DecorationComponentQuery;
+import com.fs.decoration.dto.DecorationComponentSaveReq;
+import com.fs.decoration.dto.DecorationComponentStatusReq;
+import com.fs.decoration.service.IDecorationComponentService;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+
+/**
+ * 组件定义控制器
+ */
+@RestController
+@RequestMapping("/decoration/component")
+public class DecorationComponentController {
+
+    @Resource
+    private IDecorationComponentService decorationComponentService;
+
+    /**
+     * 查询组件列表
+     */
+    @GetMapping("/list")
+    public AjaxResult list(DecorationComponentQuery query) {
+        return AjaxResult.success(decorationComponentService.selectList(query));
+    }
+
+    /**
+     * 查询组件详情
+     */
+    @GetMapping("/{id}")
+    public AjaxResult getInfo(@PathVariable Long id) {
+        return AjaxResult.success(decorationComponentService.selectById(id));
+    }
+
+    /**
+     * 新增/修改组件
+     */
+    @PostMapping("/save")
+    public AjaxResult save(@RequestBody DecorationComponentSaveReq req) {
+        Long id = decorationComponentService.saveComponent(req);
+        return AjaxResult.success("保存成功", id);
+    }
+
+    /**
+     * 删除组件
+     */
+    @DeleteMapping("/{id}")
+    public AjaxResult remove(@PathVariable Long id) {
+        decorationComponentService.deleteById(id);
+        return AjaxResult.success("删除成功");
+    }
+
+    /**
+     * 修改组件状态
+     */
+    @PostMapping("/changeStatus")
+    public AjaxResult changeStatus(@RequestBody DecorationComponentStatusReq req) {
+        if (req.getId() == null) {
+            return AjaxResult.error("组件ID不能为空");
+        }
+        if (req.getStatus() == null) {
+            return AjaxResult.error("状态不能为空");
+        }
+        decorationComponentService.updateStatus(req.getId(), req.getStatus());
+        return AjaxResult.success("状态修改成功");
+    }
+}

+ 28 - 0
fs-admin/src/main/java/com/fs/decoration/controller/DecorationComponentTypeController.java

@@ -0,0 +1,28 @@
+package com.fs.decoration.controller;
+
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.decoration.service.IDecorationComponentTypeService;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+
+/**
+ * 组件类型控制器
+ */
+@RestController
+@RequestMapping("/decoration/componentType")
+public class DecorationComponentTypeController {
+
+    @Resource
+    private IDecorationComponentTypeService decorationComponentTypeService;
+
+    /**
+     * 查询组件类型下拉
+     */
+    @GetMapping("/options")
+    public AjaxResult options() {
+        return AjaxResult.success(decorationComponentTypeService.listOptions());
+    }
+}

+ 53 - 0
fs-admin/src/main/java/com/fs/decoration/controller/DecorationTemplateController.java

@@ -0,0 +1,53 @@
+package com.fs.decoration.controller;
+
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.decoration.dto.DecorationTemplateSaveReq;
+import com.fs.decoration.service.IDecorationTemplateService;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+
+/**
+ * 模板控制器
+ */
+@RestController
+@RequestMapping("/decoration/template")
+public class DecorationTemplateController {
+
+    @Resource
+    private IDecorationTemplateService decorationTemplateService;
+
+    /**
+     * 查询模板列表
+     */
+    @GetMapping("/list")
+    public AjaxResult list() {
+        return AjaxResult.success(decorationTemplateService.selectList());
+    }
+
+    /**
+     * 查询模板详情
+     */
+    @GetMapping("/{id}")
+    public AjaxResult getInfo(@PathVariable Long id) {
+        return AjaxResult.success(decorationTemplateService.selectById(id));
+    }
+
+    /**
+     * 新增/修改模板
+     */
+    @PostMapping("/save")
+    public AjaxResult save(@RequestBody DecorationTemplateSaveReq req) {
+        Long id = decorationTemplateService.saveTemplate(req);
+        return AjaxResult.success("保存成功", id);
+    }
+
+    /**
+     * 删除模板
+     */
+    @DeleteMapping("/{id}")
+    public AjaxResult remove(@PathVariable Long id) {
+        decorationTemplateService.deleteById(id);
+        return AjaxResult.success("删除成功");
+    }
+}

+ 103 - 0
fs-admin/src/main/java/com/fs/fastGpt/FastgptChatQuestionController.java

@@ -0,0 +1,103 @@
+package com.fs.fastGpt;
+
+import java.util.List;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.enums.BusinessType;
+import com.fs.fastGpt.domain.FastgptChatQuestion;
+import com.fs.fastGpt.service.IFastgptChatQuestionService;
+import com.fs.common.utils.poi.ExcelUtil;
+import com.fs.common.core.page.TableDataInfo;
+
+/**
+ * 聊天问题收集Controller
+ *
+ * @author fs
+ * @date 2026-04-20
+ */
+@RestController
+@RequestMapping("/fastGpt/fastgptChatQuestion")
+public class FastgptChatQuestionController extends BaseController
+{
+    @Autowired
+    private IFastgptChatQuestionService fastgptChatQuestionService;
+
+    /**
+     * 查询聊天问题收集列表
+     */
+    @PreAuthorize("@ss.hasPermi('fastGpt:fastgptChatQuestion:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(FastgptChatQuestion fastgptChatQuestion)
+    {
+        startPage();
+        List<FastgptChatQuestion> list = fastgptChatQuestionService.selectFastgptChatQuestionList(fastgptChatQuestion);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出聊天问题收集列表
+     */
+    @PreAuthorize("@ss.hasPermi('fastGpt:fastgptChatQuestion:export')")
+    @Log(title = "聊天问题收集", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(FastgptChatQuestion fastgptChatQuestion)
+    {
+        List<FastgptChatQuestion> list = fastgptChatQuestionService.selectFastgptChatQuestionList(fastgptChatQuestion);
+        ExcelUtil<FastgptChatQuestion> util = new ExcelUtil<FastgptChatQuestion>(FastgptChatQuestion.class);
+        return util.exportExcel(list, "聊天问题收集数据");
+    }
+
+    /**
+     * 获取聊天问题收集详细信息
+     */
+    @PreAuthorize("@ss.hasPermi('fastGpt:fastgptChatQuestion:query')")
+    @GetMapping(value = "/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Long id)
+    {
+        return AjaxResult.success(fastgptChatQuestionService.selectFastgptChatQuestionById(id));
+    }
+
+    /**
+     * 新增聊天问题收集
+     */
+    @PreAuthorize("@ss.hasPermi('fastGpt:fastgptChatQuestion:add')")
+    @Log(title = "聊天问题收集", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody FastgptChatQuestion fastgptChatQuestion)
+    {
+        return toAjax(fastgptChatQuestionService.insertFastgptChatQuestion(fastgptChatQuestion));
+    }
+
+    /**
+     * 修改聊天问题收集
+     */
+    @PreAuthorize("@ss.hasPermi('fastGpt:fastgptChatQuestion:edit')")
+    @Log(title = "聊天问题收集", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody FastgptChatQuestion fastgptChatQuestion)
+    {
+        return toAjax(fastgptChatQuestionService.updateFastgptChatQuestion(fastgptChatQuestion));
+    }
+
+    /**
+     * 删除聊天问题收集
+     */
+    @PreAuthorize("@ss.hasPermi('fastGpt:fastgptChatQuestion:remove')")
+    @Log(title = "聊天问题收集", businessType = BusinessType.DELETE)
+	@DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids)
+    {
+        return toAjax(fastgptChatQuestionService.deleteFastgptChatQuestionByIds(ids));
+    }
+}

+ 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));
+    }
+}

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

@@ -0,0 +1,258 @@
+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());
+
+            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));
+    }
+}

+ 18 - 1
fs-common/src/main/java/com/fs/common/config/FSSysConfig.java

@@ -49,9 +49,26 @@ public class FSSysConfig
 //    private String cwarehouseCode;
 //    private String cwarehouseName;
 
+    String commonApi;
 
+    String accessKeyID;
+    String accessKeySecret;
 
-    String commonApi;
+    public String getAccessKeyID() {
+        return accessKeyID;
+    }
+
+    public void setAccessKeyID(String accessKeyID) {
+        this.accessKeyID = accessKeyID;
+    }
+
+    public String getAccessKeySecret() {
+        return accessKeySecret;
+    }
+
+    public void setAccessKeySecret(String accessKeySecret) {
+        this.accessKeySecret = accessKeySecret;
+    }
 
     public String getKdnId() {
         return kdnId;

+ 14 - 0
fs-common/src/main/java/com/fs/common/utils/PhoneUtils.java

@@ -0,0 +1,14 @@
+package com.fs.common.utils;
+
+import cn.hutool.core.util.StrUtil;
+
+public class PhoneUtils {
+    public static String getLastFourNum(String phone) {
+
+            String lastFourNumber = phone;
+            if (lastFourNumber.length() == 11) {
+                lastFourNumber = StrUtil.sub(lastFourNumber, lastFourNumber.length(), -4);
+            }
+            return lastFourNumber;
+    }
+}

+ 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);
     }
 

+ 40 - 0
fs-company/src/main/java/com/fs/company/controller/company/WxContactController.java

@@ -0,0 +1,40 @@
+package com.fs.company.controller.company;
+
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.wxcid.domain.WxContact;
+import com.fs.wxcid.service.IWxContactService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/**
+ * 个微联系人Controller
+ *
+ * @author fs
+ * @date 2026-04-23
+ */
+@RestController
+@RequestMapping("/company/wxContact")
+public class WxContactController extends BaseController
+{
+    @Autowired
+    private IWxContactService iWxContactService;
+
+    /**
+     * 查询个微联系人列表
+     */
+    @GetMapping("/list")
+    public TableDataInfo list(WxContact wxContact)
+    {
+        // 开启分页
+        startPage();
+        // 查询联系人列表
+        List<WxContact> list = iWxContactService.selectWxContactList(wxContact);
+        // 返回分页数据
+        return getDataTable(list);
+    }
+}

+ 40 - 0
fs-company/src/main/java/com/fs/company/controller/company/WxMsgLogController.java

@@ -0,0 +1,40 @@
+package com.fs.company.controller.company;
+
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.wxcid.domain.WxMsgLog;
+import com.fs.wxcid.service.IWxMsgLogService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/**
+ * 个微聊天记录Controller
+ *
+ * @author fs
+ * @date 2026-04-23
+ */
+@RestController
+@RequestMapping("/company/wxMsgLog")
+public class WxMsgLogController extends BaseController
+{
+    @Autowired
+    private IWxMsgLogService iWxMsgLogService;
+
+    /**
+     * 查询个微聊天记录列表
+     */
+    @GetMapping("/list")
+    public TableDataInfo list(WxMsgLog wxMsgLog)
+    {
+        // 开启分页
+        startPage();
+        // 查询聊天记录列表
+        List<WxMsgLog> list = iWxMsgLogService.selectWxMsgLogList(wxMsgLog);
+        // 返回分页数据
+        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')")

+ 97 - 0
fs-company/src/main/java/com/fs/company/controller/fastGpt/FastgptChatQuestionController.java

@@ -0,0 +1,97 @@
+package com.fs.company.controller.fastGpt;
+
+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.poi.ExcelUtil;
+import com.fs.fastGpt.domain.FastgptChatQuestion;
+import com.fs.fastGpt.service.IFastgptChatQuestionService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 聊天问题收集Controller
+ *
+ * @author fs
+ * @date 2026-04-20
+ */
+@RestController
+@RequestMapping("/fastGpt/fastgptChatQuestion")
+public class FastgptChatQuestionController extends BaseController
+{
+    @Autowired
+    private IFastgptChatQuestionService fastgptChatQuestionService;
+
+    /**
+     * 查询聊天问题收集列表
+     */
+    @PreAuthorize("@ss.hasPermi('fastGpt:fastgptChatQuestion:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(FastgptChatQuestion fastgptChatQuestion)
+    {
+        startPage();
+        List<FastgptChatQuestion> list = fastgptChatQuestionService.selectFastgptChatQuestionList(fastgptChatQuestion);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出聊天问题收集列表
+     */
+    @PreAuthorize("@ss.hasPermi('fastGpt:fastgptChatQuestion:export')")
+    @Log(title = "聊天问题收集", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(FastgptChatQuestion fastgptChatQuestion)
+    {
+        List<FastgptChatQuestion> list = fastgptChatQuestionService.selectFastgptChatQuestionList(fastgptChatQuestion);
+        ExcelUtil<FastgptChatQuestion> util = new ExcelUtil<FastgptChatQuestion>(FastgptChatQuestion.class);
+        return util.exportExcel(list, "聊天问题收集数据");
+    }
+
+    /**
+     * 获取聊天问题收集详细信息
+     */
+    @PreAuthorize("@ss.hasPermi('fastGpt:fastgptChatQuestion:query')")
+    @GetMapping(value = "/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Long id)
+    {
+        return AjaxResult.success(fastgptChatQuestionService.selectFastgptChatQuestionById(id));
+    }
+
+    /**
+     * 新增聊天问题收集
+     */
+    @PreAuthorize("@ss.hasPermi('fastGpt:fastgptChatQuestion:add')")
+    @Log(title = "聊天问题收集", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody FastgptChatQuestion fastgptChatQuestion)
+    {
+        return toAjax(fastgptChatQuestionService.insertFastgptChatQuestion(fastgptChatQuestion));
+    }
+
+    /**
+     * 修改聊天问题收集
+     */
+    @PreAuthorize("@ss.hasPermi('fastGpt:fastgptChatQuestion:edit')")
+    @Log(title = "聊天问题收集", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody FastgptChatQuestion fastgptChatQuestion)
+    {
+        return toAjax(fastgptChatQuestionService.updateFastgptChatQuestion(fastgptChatQuestion));
+    }
+
+    /**
+     * 删除聊天问题收集
+     */
+    @PreAuthorize("@ss.hasPermi('fastGpt:fastgptChatQuestion:remove')")
+    @Log(title = "聊天问题收集", businessType = BusinessType.DELETE)
+	@DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids)
+    {
+        return toAjax(fastgptChatQuestionService.deleteFastgptChatQuestionByIds(ids));
+    }
+}

+ 103 - 0
fs-company/src/main/java/com/fs/company/controller/fastGpt/FastgptChatQuestionStatisticsController.java

@@ -0,0 +1,103 @@
+package com.fs.company.controller.fastGpt;
+
+import java.util.List;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.enums.BusinessType;
+import com.fs.fastGpt.domain.FastgptChatQuestionStatistics;
+import com.fs.fastGpt.service.IFastgptChatQuestionStatisticsService;
+import com.fs.common.utils.poi.ExcelUtil;
+import com.fs.common.core.page.TableDataInfo;
+
+/**
+ * 高频聊天问题统计Controller
+ *
+ * @author fs
+ * @date 2026-04-22
+ */
+@RestController
+@RequestMapping("/fastGpt/fastGptChatQuestionStatistics")
+public class FastgptChatQuestionStatisticsController extends BaseController
+{
+    @Autowired
+    private IFastgptChatQuestionStatisticsService fastgptChatQuestionStatisticsService;
+
+    /**
+     * 查询高频聊天问题统计列表
+     */
+    @PreAuthorize("@ss.hasPermi('fastGpt:fastGptChatQuestionStatistics:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(FastgptChatQuestionStatistics fastgptChatQuestionStatistics)
+    {
+        startPage();
+        List<FastgptChatQuestionStatistics> list = fastgptChatQuestionStatisticsService.selectFastgptChatQuestionStatisticsList(fastgptChatQuestionStatistics);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出高频聊天问题统计列表
+     */
+    @PreAuthorize("@ss.hasPermi('fastGpt:fastGptChatQuestionStatistics:export')")
+    @Log(title = "高频聊天问题统计", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(FastgptChatQuestionStatistics fastgptChatQuestionStatistics)
+    {
+        List<FastgptChatQuestionStatistics> list = fastgptChatQuestionStatisticsService.selectFastgptChatQuestionStatisticsList(fastgptChatQuestionStatistics);
+        ExcelUtil<FastgptChatQuestionStatistics> util = new ExcelUtil<FastgptChatQuestionStatistics>(FastgptChatQuestionStatistics.class);
+        return util.exportExcel(list, "高频聊天问题统计数据");
+    }
+
+    /**
+     * 获取高频聊天问题统计详细信息
+     */
+    @PreAuthorize("@ss.hasPermi('fastGpt:fastGptChatQuestionStatistics:query')")
+    @GetMapping(value = "/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Long id)
+    {
+        return AjaxResult.success(fastgptChatQuestionStatisticsService.selectFastgptChatQuestionStatisticsById(id));
+    }
+
+    /**
+     * 新增高频聊天问题统计
+     */
+    @PreAuthorize("@ss.hasPermi('fastGpt:fastGptChatQuestionStatistics:add')")
+    @Log(title = "高频聊天问题统计", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody FastgptChatQuestionStatistics fastgptChatQuestionStatistics)
+    {
+        return toAjax(fastgptChatQuestionStatisticsService.insertFastgptChatQuestionStatistics(fastgptChatQuestionStatistics));
+    }
+
+    /**
+     * 修改高频聊天问题统计
+     */
+    @PreAuthorize("@ss.hasPermi('fastGpt:fastGptChatQuestionStatistics:edit')")
+    @Log(title = "高频聊天问题统计", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody FastgptChatQuestionStatistics fastgptChatQuestionStatistics)
+    {
+        return toAjax(fastgptChatQuestionStatisticsService.updateFastgptChatQuestionStatistics(fastgptChatQuestionStatistics));
+    }
+
+    /**
+     * 删除高频聊天问题统计
+     */
+    @PreAuthorize("@ss.hasPermi('fastGpt:fastGptChatQuestionStatistics:remove')")
+    @Log(title = "高频聊天问题统计", businessType = BusinessType.DELETE)
+	@DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids)
+    {
+        return toAjax(fastgptChatQuestionStatisticsService.deleteFastgptChatQuestionStatisticsByIds(ids));
+    }
+}

+ 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;
+        }
+    }
+}

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

@@ -0,0 +1,258 @@
+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());
+
+            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;
 }

+ 5 - 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,8 @@ public interface CompanyAiWorkflowExecMapper extends BaseMapper<CompanyAiWorkflo
             @Param("customerPhone") String customerPhone,
             @Param(("onlyCallNode")) Boolean onlyCallNode
     );
+
+    WxContact selectWxContectByWorkflowInstanceId(@Param("workflowInstanceId") String workflowInstanceId);
+
+    Long selectWxClientIdByWorkflowInstanceId(@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();
+}

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

@@ -0,0 +1,21 @@
+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);

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

@@ -1,13 +1,16 @@
 package com.fs.company.service.easycall;
 
+import cn.hutool.json.JSONUtil;
 import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONArray;
 import com.alibaba.fastjson.JSONObject;
 import com.fs.common.utils.StringUtils;
 import com.fs.company.mapper.CompanyMapper;
+import com.fs.company.vo.CidConfigVO;
 import com.fs.company.vo.easycall.*;
 import cn.hutool.http.HttpRequest;
 import cn.hutool.http.HttpUtil;
+import com.fs.system.service.ISysConfigService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Value;
@@ -31,6 +34,8 @@ import java.util.stream.Collectors;
 @Slf4j
 public class EasyCallServiceImpl implements IEasyCallService {
 
+    @Autowired
+    private ISysConfigService configService;
     /**
      * EasyCallCenter365 服务器基础地址,从配置文件 easycall.base-url 读取
      */
@@ -104,8 +109,19 @@ public class EasyCallServiceImpl implements IEasyCallService {
             if(StringUtils.isNotBlank(gateWayList)){
                 List<Long> collect = Arrays.stream(gateWayList.split(",")).map(item -> Long.valueOf(item.trim())).collect(Collectors.toList());
                 resList = resList.stream().filter(item -> collect.contains(item.getId())).collect(Collectors.toList());
+            }else{
+                String json = configService.selectConfigByKey("cid.config");
+                if (StringUtils.isNotBlank(json)) {
+                    JSONObject obj = JSONObject.parseObject(json);
+                    if(null != obj && obj.containsKey("showGatewayIds")){
+                        List<Long> showGatewayIds = obj.getJSONArray("showGatewayIds").stream().map(item -> Long.valueOf(item.toString())).collect(Collectors.toList());
+                        resList = resList.stream().filter(item -> showGatewayIds.contains(item.getId())).collect(Collectors.toList());
+                    }
+                }
             }
         }
+
+
         return resList;
     }
 

+ 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);

+ 150 - 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);
+    }
+
     /**
      * 查询机器人外呼任务
      *
@@ -202,6 +225,7 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
             client.setRoboticId(companyVoiceRobotic.getId());
             client.setCustomerId(Long.parseLong(e));
             client.setIsWeCom(isWeCom);
+            client.setCreateTime(new Date());
             return client;
         }).collect(Collectors.toList());
         companyWxClientServiceImpl.saveBatch(clients);
@@ -822,11 +846,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 +867,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 +889,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()) && !"未接通".equals(callPhoneRes.getIntent())) {
+            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()) && !"未接通".equals(callPhoneRes.getIntent())) {
+            // 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 +1011,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 +1077,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 +1456,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 +1521,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 +1596,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 +1671,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);

+ 155 - 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,21 @@ public class CompanyWorkflowEngineImpl implements CompanyWorkflowEngine {
     @Autowired
     private CompanyAiWorkflowExecLogMapper companyAiWorkflowExecLogMapper;
 
+    @Autowired
+    CompanyVoiceRoboticMapper companyVoiceRoboticMapper;
+
+    @Autowired
+    IEasyCallService easyCallService;
+
+    @Autowired
+    CompanySiptaskInfoMapper companySiptaskInfoMapper;
+
+    @Autowired
+    CompanyWxAccountMapper companyWxAccountMapper;
+
+    @Autowired
+    CompanyWxClientMapper companyWxClientMapper;
+
     /**
      * 初始化工作流
      * 创建工作流实例并保存初始状态
@@ -84,7 +98,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 +560,135 @@ 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());
+                // 模型参数
+                createParam.setTtsModels(callConfigVo.getTtsModels());
+
+                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;
+    }
+
+    /**
+     * 加微成功后流程唤醒操作
+     * @param workflowInstanceId
+     * @param nodeKey
+     * @param accountId
+     * @param remark
+     */
+    @Async("cidWorkFlowExecutor")
+    public void addWxSuccess(String workflowInstanceId, String nodeKey,Long accountId,String remark){
+        if(StringUtils.isBlank(remark) || StringUtils.isBlank(workflowInstanceId) || StringUtils.isBlank(nodeKey) || accountId == null){
+            log.error("addWxSuccess: 参数错误,workflowInstanceId:{},nodeKey:{},accountId:{},remark:{}", workflowInstanceId, nodeKey, accountId, remark);
+            return;
+        }
+        CompanyWxAccount companyWxAccount = companyWxAccountMapper.selectCompanyWxAccountById(accountId);
+        if(null != companyWxAccount && !remark.equals(companyWxAccount.getRemark())){
+            companyWxAccount.setRemark( remark);
+            companyWxAccountMapper.updateCompanyWxAccount(companyWxAccount);
+        }
+
+        // 加载当前执行记录
+        CompanyAiWorkflowExec currentExec = currentExecutionMapper.selectByWorkflowInstanceId(workflowInstanceId);
+
+        if (currentExec == null) {
+            throw new CustomException("工作流实例不存在: " + workflowInstanceId);
+        }
+        //更新加微结果到wxClient表
+        updateWxClientSuccessByWorkflowInstanceId(workflowInstanceId);
+        // 验证当前节点是否匹配
+        if (!nodeKey.equals(currentExec.getCurrentNodeKey())) {
+            log.error("节点不匹配 - 期望: {}, 实际: {}", nodeKey, currentExec.getCurrentNodeKey());
+            return;
+        }
+
+        // 检查当前工作流是否处于暂停状态
+        if (!Integer.valueOf(ExecutionStatusEnum.PAUSED.getValue()).equals(currentExec.getStatus()) &&
+                !Integer.valueOf(ExecutionStatusEnum.WAITING.getValue()).equals(currentExec.getStatus())) {
+            log.error("工作流未处于暂停状态,无法唤醒::{} " , workflowInstanceId);
+        }
+
+        // 反序列化执行上下文并合并新的输入数据
+        ExecutionContext context = deserializeContext(currentExec);
+
+        // 加载工作流定义
+        CompanyWorkflow definition = loadCompanyWorkflow(currentExec.getWorkflowId());
+
+        // 创建节点实例
+        IWorkflowNode node = createNode(definition, nodeKey);
+        if (node == null) {
+            throw new CustomException("节点不存在: " + nodeKey);
+        }
+
+        // 继续执行节点逻辑
+        ExecutionResult result = node.continueExecute(context);
+
+    }
+
+    /**
+     * 更新加微结果到wxClient表
+     * @param workflowInstanceId
+     */
+    public void updateWxClientSuccessByWorkflowInstanceId(String workflowInstanceId){
+        Long id = currentExecutionMapper.selectWxClientIdByWorkflowInstanceId(workflowInstanceId);
+        CompanyWxClient client = companyWxClientMapper.selectCompanyWxClientById(id);
+        if(null != client && !Integer.valueOf(1).equals(client.getIsAdd())){
+            //更新wxClient表
+            CompanyWxClient companyWxClient = new CompanyWxClient();
+            companyWxClient.setId(id);
+            companyWxClient.setIsAdd(1);
+            companyWxClient.setSuccessAddTime(LocalDateTime.now());
+            companyWxClientMapper.updateCompanyWxClient(companyWxClient);
+        }
+
+    }
+
 }

+ 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);

+ 64 - 42
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,8 +14,10 @@ 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.CidIpadServer;
 import com.fs.wxcid.domain.CidIpadServerUser;
 import com.fs.wxcid.domain.WxContact;
 import com.fs.wxcid.dto.friend.ContactItem;
@@ -33,7 +38,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 +109,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 +155,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);
     }
@@ -259,22 +278,20 @@ public class CompanyWxServiceImpl extends ServiceImpl<CompanyWxAccountMapper, Co
         if ( cidServerId==null ){
             return R.error("请先绑定cid服务");
         }
-        CompanyAiWorkflowServer cidServer = companyAiWorkflowServerMapper.selectCompanyAiWorkflowServerById(companyUser.getCidServerId());
-
-        Long serverId = cidIpadServerService.selectQwIpadServerByAddressId(addressId,cidServer.getGroupNo());
-        if (serverId==null){
-            return  R.error(501,"该地区服务器剩余数量不足");
+        CidIpadServer count = cidIpadServerService.getOne(new QueryWrapper<CidIpadServer>().gt("count", 0).last(" limit 1"));
+        if (count==null){
+            return  R.error(501,"回调服务器不足,请联系管理员");
         }
-        account.setServerId(serverId);
+        account.setServerId(count.getId());
         account.setServerStatus(1);
         updateById(account);
 
-//        cidIpadServerService.subtractServer(serverId);
+        cidIpadServerService.subtractServer(count.getId());
         CidIpadServerUser qwIpadServerUser = new CidIpadServerUser();
         qwIpadServerUser.setCompanyUserId(companyUser.getUserId());
         qwIpadServerUser.setCompanyId(companyUser.getCompanyId());
         qwIpadServerUser.setCreateTime(new Date());
-        qwIpadServerUser.setServerId(serverId);
+        qwIpadServerUser.setServerId(count.getId());
         qwIpadServerUser.setQwUserId(account.getId());
         cidIpadServerUserService.save(qwIpadServerUser);
         return null;
@@ -332,37 +349,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 +584,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 +617,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 +624,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) {

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

@@ -0,0 +1,318 @@
+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.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.WxContact;
+import com.fs.wxcid.mapper.CidIpadServerMapper;
+import com.fs.wxcid.mapper.WxContactMapper;
+import com.fs.wxwork.service.WxIpadService;
+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 WxIpadService wxService = SpringUtils.getBean(WxIpadService.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(),
+                    context.getWorkflowInstanceId(),
+                    context.getCurrentNodeKey());
+            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
+     * @param remark
+     * @param phone
+     * @param dialogId
+     * @param crmUserId
+     * @param instanceId
+     * @param nodeKey
+     */
+    private void pendingAddWx(Long accountId, String remark, String phone, Long dialogId, Long crmUserId,String instanceId,String nodeKey) {
+        try {
+            // 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);
+            param.setServerId(companyWxAccount.getServerId());
+            JSONObject bizJson = new JSONObject()
+                    .fluentPut("instanceId",instanceId)
+                    .fluentPut("nodeKey",nodeKey)
+                    .fluentPut("accountId",companyWxAccount.getId());
+            param.setBizJson(bizJson.toJSONString());
+            wxService.addWx(param);
+        } catch (Exception ex) {
+            throw new CustomException("发起加微请求异常, phone: " + phone + ", error: " + ex.getMessage());
+        }
+    }
+
+    @Override
+    protected void postExecute(ExecutionContext context, ExecutionResult result) {
+        super.postExecute(context, result);
+        doneAddwx(context.getWorkflowInstanceId());
+    }
+}

+ 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;

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

@@ -55,6 +55,8 @@ public class AiCallConfigVO {
      * 音色来源(音色列表接口返回的 voiceSource,如 aliyun_tts)
      */
     private String voiceSource;
+
+    private String ttsModels;
     /**
      * tts厂商 / 技能组 id(技能组列表接口返回的 groupId)
      */

+ 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;
+}

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

@@ -33,4 +33,6 @@ public class EasyCallCreateTaskParam {
     private Double avgCallTalkTimeLen;
     /** 平均事后处理时长(taskType=0时必填,单位秒) */
     private Double avgCallEndProcessTimeLen;
+
+    private String ttsModels;
 }

+ 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;
+}

+ 4 - 0
fs-service/src/main/java/com/fs/company/vo/easycall/EasyCallVoiceCodeVO.java

@@ -13,4 +13,8 @@ public class EasyCallVoiceCodeVO {
     private String voiceName;
     /** 声音源:aliyun_tts */
     private String voiceSource;
+    /**
+     * 音色模型
+     */
+    private String ttsModels;
 }

+ 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;
+
     /**
      * 侧边栏是否仅展示当天课程
      */

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

@@ -16,28 +16,44 @@ 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;
 
+    private Long watchLogId;
+
 }

+ 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;
+    }
+
     /**
      * 题目导入
      *

+ 10 - 4
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;
@@ -139,7 +140,7 @@ public class FsUserCourseServiceImpl implements IFsUserCourseService
 
     private static final String userRealLink = "/pages/user/users/becomeVIP?";
 
-    private static final String appRealLink = "/courseH5/pages_course/videovip?course=";
+    private static final String appRealLink = "/appcourse/pages_course/videovip?course=";
     public static final String appShortLink = "/courseH5/pages_course/videovip?s=";
 
     /**
@@ -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);

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

@@ -1607,7 +1607,13 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
             switch (config.getRewardType()) {
                 // 红包奖励
                 case 1:
-                    return sendRedPacketReward(param, user, watchLog, video, config);
+                    if (param.getSource() == 3){
+                        param.setWatchLogId(watchLog.getLogId());
+                        return withdrawal(param);
+                    } else {
+                        return sendRedPacketReward(param, user, watchLog, video, config);
+                    }
+//                    return sendRedPacketReward(param, user, watchLog, video, config);
                 // 积分奖励
                 case 2:
                     return sendIntegralReward(param, user, watchLog, 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;
 }

+ 41 - 0
fs-service/src/main/java/com/fs/decoration/domain/DecorationComponent.java

@@ -0,0 +1,41 @@
+package com.fs.decoration.domain;
+
+import lombok.Data;
+import java.util.Date;
+
+/**
+ * 组件定义实体
+ */
+@Data
+public class DecorationComponent {
+
+    /** 主键ID */
+    private Long id;
+
+    /** 组件编码,例如 banner、goodsList */
+    private String componentCode;
+
+    /** 组件名称,例如 顶部轮播 */
+    private String componentName;
+
+    /** 组件类型编码,例如 basic、goods */
+    private String componentTypeCode;
+
+    /** 图标 */
+    private String icon;
+
+    /** 缩略图 */
+    private String previewImage;
+
+    /** 状态:1启用,0停用 */
+    private Integer status;
+
+    /** 备注 */
+    private String remark;
+
+    /** 创建时间 */
+    private Date createTime;
+
+    /** 更新时间 */
+    private Date updateTime;
+}

+ 32 - 0
fs-service/src/main/java/com/fs/decoration/domain/DecorationComponentType.java

@@ -0,0 +1,32 @@
+package com.fs.decoration.domain;
+
+import lombok.Data;
+import java.util.Date;
+
+/**
+ * 组件类型实体
+ */
+@Data
+public class DecorationComponentType {
+
+    /** 主键ID */
+    private Long id;
+
+    /** 类型编码,例如 basic */
+    private String typeCode;
+
+    /** 类型名称,例如 基础组件 */
+    private String typeName;
+
+    /** 状态:1启用,0停用 */
+    private Integer status;
+
+    /** 备注 */
+    private String remark;
+
+    /** 创建时间 */
+    private Date createTime;
+
+    /** 更新时间 */
+    private Date updateTime;
+}

+ 38 - 0
fs-service/src/main/java/com/fs/decoration/domain/DecorationTemplate.java

@@ -0,0 +1,38 @@
+package com.fs.decoration.domain;
+
+import lombok.Data;
+import java.util.Date;
+
+/**
+ * 模板实体
+ */
+@Data
+public class DecorationTemplate {
+
+    /** 主键ID */
+    private Long id;
+
+    /** 模板名称 */
+    private String templateName;
+
+    /** 模板类型 */
+    private String templateType;
+
+    /** 模板封面 */
+    private String coverUrl;
+
+    /** 模板JSON */
+    private String templateData;
+
+    /** 状态:1启用,0停用 */
+    private Integer status;
+
+    /** 备注 */
+    private String remark;
+
+    /** 创建时间 */
+    private Date createTime;
+
+    /** 更新时间 */
+    private Date updateTime;
+}

+ 19 - 0
fs-service/src/main/java/com/fs/decoration/dto/DecorationComponentQuery.java

@@ -0,0 +1,19 @@
+package com.fs.decoration.dto;
+
+import lombok.Data;
+
+/**
+ * 组件查询参数
+ */
+@Data
+public class DecorationComponentQuery {
+
+    /** 组件名称,模糊查询 */
+    private String componentName;
+
+    /** 组件类型编码 */
+    private String componentTypeCode;
+
+    /** 状态 */
+    private Integer status;
+}

+ 34 - 0
fs-service/src/main/java/com/fs/decoration/dto/DecorationComponentSaveReq.java

@@ -0,0 +1,34 @@
+package com.fs.decoration.dto;
+
+import lombok.Data;
+
+/**
+ * 组件新增/修改参数
+ */
+@Data
+public class DecorationComponentSaveReq {
+
+    /** 主键ID,新增时为空 */
+    private Long id;
+
+    /** 组件编码 */
+    private String componentCode;
+
+    /** 组件名称 */
+    private String componentName;
+
+    /** 组件类型编码 */
+    private String componentTypeCode;
+
+    /** 图标 */
+    private String icon;
+
+    /** 缩略图 */
+    private String previewImage;
+
+    /** 状态 */
+    private Integer status;
+
+    /** 备注 */
+    private String remark;
+}

+ 16 - 0
fs-service/src/main/java/com/fs/decoration/dto/DecorationComponentStatusReq.java

@@ -0,0 +1,16 @@
+package com.fs.decoration.dto;
+
+import lombok.Data;
+
+/**
+ * 组件状态修改参数
+ */
+@Data
+public class DecorationComponentStatusReq {
+
+    /** 组件ID */
+    private Long id;
+
+    /** 状态:1启用,0停用 */
+    private Integer status;
+}

+ 31 - 0
fs-service/src/main/java/com/fs/decoration/dto/DecorationTemplateSaveReq.java

@@ -0,0 +1,31 @@
+package com.fs.decoration.dto;
+
+import lombok.Data;
+
+/**
+ * 模板保存参数
+ */
+@Data
+public class DecorationTemplateSaveReq {
+
+    /** 主键ID,新增时为空 */
+    private Long id;
+
+    /** 模板名称 */
+    private String templateName;
+
+    /** 模板类型 */
+    private String templateType;
+
+    /** 模板封面 */
+    private String coverUrl;
+
+    /** 模板JSON */
+    private String templateData;
+
+    /** 状态 */
+    private Integer status;
+
+    /** 备注 */
+    private String remark;
+}

+ 43 - 0
fs-service/src/main/java/com/fs/decoration/mapper/DecorationComponentMapper.java

@@ -0,0 +1,43 @@
+package com.fs.decoration.mapper;
+
+import com.fs.decoration.domain.DecorationComponent;
+import com.fs.decoration.dto.DecorationComponentQuery;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 组件定义 Mapper
+ */
+public interface DecorationComponentMapper {
+
+    /**
+     * 查询组件列表
+     */
+    List<DecorationComponent> selectList(DecorationComponentQuery query);
+
+    /**
+     * 根据ID查询组件
+     */
+    DecorationComponent selectById(@Param("id") Long id);
+
+    /**
+     * 根据组件编码查询组件
+     */
+    DecorationComponent selectByComponentCode(@Param("componentCode") String componentCode);
+
+    /**
+     * 新增组件
+     */
+    int insert(DecorationComponent entity);
+
+    /**
+     * 修改组件
+     */
+    int updateById(DecorationComponent entity);
+
+    /**
+     * 删除组件
+     */
+    int deleteById(@Param("id") Long id);
+}

+ 15 - 0
fs-service/src/main/java/com/fs/decoration/mapper/DecorationComponentTypeMapper.java

@@ -0,0 +1,15 @@
+package com.fs.decoration.mapper;
+
+import com.fs.decoration.domain.DecorationComponentType;
+import java.util.List;
+
+/**
+ * 组件类型 Mapper
+ */
+public interface DecorationComponentTypeMapper {
+
+    /**
+     * 查询启用的组件类型列表
+     */
+    List<DecorationComponentType> selectList();
+}

+ 37 - 0
fs-service/src/main/java/com/fs/decoration/mapper/DecorationTemplateMapper.java

@@ -0,0 +1,37 @@
+package com.fs.decoration.mapper;
+
+import com.fs.decoration.domain.DecorationTemplate;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 模板 Mapper
+ */
+public interface DecorationTemplateMapper {
+
+    /**
+     * 查询模板列表
+     */
+    List<DecorationTemplate> selectList();
+
+    /**
+     * 根据ID查询模板
+     */
+    DecorationTemplate selectById(@Param("id") Long id);
+
+    /**
+     * 新增模板
+     */
+    int insert(DecorationTemplate entity);
+
+    /**
+     * 修改模板
+     */
+    int updateById(DecorationTemplate entity);
+
+    /**
+     * 删除模板
+     */
+    int deleteById(@Param("id") Long id);
+}

+ 38 - 0
fs-service/src/main/java/com/fs/decoration/service/IDecorationComponentService.java

@@ -0,0 +1,38 @@
+package com.fs.decoration.service;
+
+import com.fs.decoration.domain.DecorationComponent;
+import com.fs.decoration.dto.DecorationComponentQuery;
+import com.fs.decoration.dto.DecorationComponentSaveReq;
+
+import java.util.List;
+
+/**
+ * 组件定义 Service
+ */
+public interface IDecorationComponentService {
+
+    /**
+     * 查询组件列表
+     */
+    List<DecorationComponent> selectList(DecorationComponentQuery query);
+
+    /**
+     * 查询组件详情
+     */
+    DecorationComponent selectById(Long id);
+
+    /**
+     * 新增/修改组件
+     */
+    Long saveComponent(DecorationComponentSaveReq req);
+
+    /**
+     * 删除组件
+     */
+    int deleteById(Long id);
+
+    /**
+     * 修改状态
+     */
+    int updateStatus(Long id, Integer status);
+}

+ 16 - 0
fs-service/src/main/java/com/fs/decoration/service/IDecorationComponentTypeService.java

@@ -0,0 +1,16 @@
+package com.fs.decoration.service;
+
+import com.fs.decoration.vo.OptionVO;
+
+import java.util.List;
+
+/**
+ * 组件类型 Service
+ */
+public interface IDecorationComponentTypeService {
+
+    /**
+     * 查询组件类型下拉
+     */
+    List<OptionVO> listOptions();
+}

+ 32 - 0
fs-service/src/main/java/com/fs/decoration/service/IDecorationTemplateService.java

@@ -0,0 +1,32 @@
+package com.fs.decoration.service;
+
+import com.fs.decoration.domain.DecorationTemplate;
+import com.fs.decoration.dto.DecorationTemplateSaveReq;
+
+import java.util.List;
+
+/**
+ * 模板 Service
+ */
+public interface IDecorationTemplateService {
+
+    /**
+     * 查询模板列表
+     */
+    List<DecorationTemplate> selectList();
+
+    /**
+     * 查询模板详情
+     */
+    DecorationTemplate selectById(Long id);
+
+    /**
+     * 新增/修改模板
+     */
+    Long saveTemplate(DecorationTemplateSaveReq req);
+
+    /**
+     * 删除模板
+     */
+    int deleteById(Long id);
+}

+ 117 - 0
fs-service/src/main/java/com/fs/decoration/service/impl/DecorationComponentServiceImpl.java

@@ -0,0 +1,117 @@
+package com.fs.decoration.service.impl;
+
+import com.fs.decoration.domain.DecorationComponent;
+import com.fs.decoration.dto.DecorationComponentQuery;
+import com.fs.decoration.dto.DecorationComponentSaveReq;
+import com.fs.decoration.mapper.DecorationComponentMapper;
+import com.fs.decoration.service.IDecorationComponentService;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.List;
+
+/**
+ * 组件定义 Service 实现
+ */
+@Service
+public class DecorationComponentServiceImpl implements IDecorationComponentService {
+
+    @Resource
+    private DecorationComponentMapper decorationComponentMapper;
+
+    @Override
+    public List<DecorationComponent> selectList(DecorationComponentQuery query) {
+        return decorationComponentMapper.selectList(query);
+    }
+
+    @Override
+    public DecorationComponent selectById(Long id) {
+        return decorationComponentMapper.selectById(id);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Long saveComponent(DecorationComponentSaveReq req) {
+        // 基础校验
+        if (req.getComponentCode() == null || req.getComponentCode().trim().isEmpty()) {
+            throw new RuntimeException("组件编码不能为空");
+        }
+        if (req.getComponentName() == null || req.getComponentName().trim().isEmpty()) {
+            throw new RuntimeException("组件名称不能为空");
+        }
+        if (req.getComponentTypeCode() == null || req.getComponentTypeCode().trim().isEmpty()) {
+            throw new RuntimeException("组件类型不能为空");
+        }
+
+        // 根据组件编码检查是否已存在
+        DecorationComponent exist = decorationComponentMapper.selectByComponentCode(req.getComponentCode());
+
+        // 新增
+        if (req.getId() == null) {
+            if (exist != null) {
+                throw new RuntimeException("组件编码已存在");
+            }
+
+            DecorationComponent entity = new DecorationComponent();
+            entity.setComponentCode(req.getComponentCode());
+            entity.setComponentName(req.getComponentName());
+            entity.setComponentTypeCode(req.getComponentTypeCode());
+            entity.setIcon(req.getIcon());
+            entity.setPreviewImage(req.getPreviewImage());
+            entity.setStatus(req.getStatus() == null ? 1 : req.getStatus());
+            entity.setRemark(req.getRemark());
+
+            decorationComponentMapper.insert(entity);
+            return entity.getId();
+        }
+
+        // 修改
+        DecorationComponent old = decorationComponentMapper.selectById(req.getId());
+        if (old == null) {
+            throw new RuntimeException("组件不存在");
+        }
+
+        // 如果存在同编码记录,但不是自己,说明编码冲突
+        if (exist != null && !exist.getId().equals(req.getId())) {
+            throw new RuntimeException("组件编码已存在");
+        }
+
+        DecorationComponent entity = new DecorationComponent();
+        entity.setId(req.getId());
+        entity.setComponentCode(req.getComponentCode());
+        entity.setComponentName(req.getComponentName());
+        entity.setComponentTypeCode(req.getComponentTypeCode());
+        entity.setIcon(req.getIcon());
+        entity.setPreviewImage(req.getPreviewImage());
+        entity.setStatus(req.getStatus() == null ? old.getStatus() : req.getStatus());
+        entity.setRemark(req.getRemark());
+
+        decorationComponentMapper.updateById(entity);
+        return entity.getId();
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public int deleteById(Long id) {
+        DecorationComponent old = decorationComponentMapper.selectById(id);
+        if (old == null) {
+            throw new RuntimeException("组件不存在");
+        }
+        return decorationComponentMapper.deleteById(id);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public int updateStatus(Long id, Integer status) {
+        DecorationComponent old = decorationComponentMapper.selectById(id);
+        if (old == null) {
+            throw new RuntimeException("组件不存在");
+        }
+
+        DecorationComponent entity = new DecorationComponent();
+        entity.setId(id);
+        entity.setStatus(status);
+        return decorationComponentMapper.updateById(entity);
+    }
+}

+ 29 - 0
fs-service/src/main/java/com/fs/decoration/service/impl/DecorationComponentTypeServiceImpl.java

@@ -0,0 +1,29 @@
+package com.fs.decoration.service.impl;
+
+import com.fs.decoration.domain.DecorationComponentType;
+import com.fs.decoration.mapper.DecorationComponentTypeMapper;
+import com.fs.decoration.service.IDecorationComponentTypeService;
+import com.fs.decoration.vo.OptionVO;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * 组件类型 Service 实现
+ */
+@Service
+public class DecorationComponentTypeServiceImpl implements IDecorationComponentTypeService {
+
+    @Resource
+    private DecorationComponentTypeMapper decorationComponentTypeMapper;
+
+    @Override
+    public List<OptionVO> listOptions() {
+        List<DecorationComponentType> list = decorationComponentTypeMapper.selectList();
+        return list.stream()
+                .map(item -> new OptionVO(item.getTypeCode(), item.getTypeName()))
+                .collect(Collectors.toList());
+    }
+}

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio