Pārlūkot izejas kodu

SIP外呼/APP积分提现

三七 2 dienas atpakaļ
vecāks
revīzija
77fd44d56d
100 mainītis faili ar 9819 papildinājumiem un 1645 dzēšanām
  1. 121 0
      fs-admin/src/main/java/com/fs/aiSipCall/AiSipAutoCallRulesController.java
  2. 97 0
      fs-admin/src/main/java/com/fs/aiSipCall/AiSipCallBizGroupController.java
  3. 110 0
      fs-admin/src/main/java/com/fs/aiSipCall/AiSipCallGatewayController.java
  4. 97 0
      fs-admin/src/main/java/com/fs/aiSipCall/AiSipCallLlmAgentAccountController.java
  5. 187 0
      fs-admin/src/main/java/com/fs/aiSipCall/AiSipCallOutboundCdrController.java
  6. 222 0
      fs-admin/src/main/java/com/fs/aiSipCall/AiSipCallPhoneController.java
  7. 244 0
      fs-admin/src/main/java/com/fs/aiSipCall/AiSipCallTaskController.java
  8. 183 0
      fs-admin/src/main/java/com/fs/aiSipCall/AiSipCallUserController.java
  9. 85 0
      fs-admin/src/main/java/com/fs/aiSipCall/AiSipCallVoiceTtsAliyunController.java
  10. 79 0
      fs-admin/src/main/java/com/fs/aiSipCall/sipTask/sipTask.java
  11. 137 0
      fs-admin/src/main/java/com/fs/his/controller/AdProfitDetailController.java
  12. 99 0
      fs-admin/src/main/java/com/fs/his/controller/FsConsecutiveWithdrawRecordController.java
  13. 8 0
      fs-admin/src/main/java/com/fs/his/controller/FsDoctorController.java
  14. 69 0
      fs-admin/src/main/java/com/fs/his/controller/FsIntegralRedPacketLogController.java
  15. 12 1
      fs-admin/src/main/java/com/fs/his/controller/FsUserController.java
  16. 38 0
      fs-admin/src/main/java/com/fs/his/controller/FsUserIntegralLogsController.java
  17. 9 0
      fs-admin/src/main/java/com/fs/his/task/Task.java
  18. 1 1
      fs-company/src/main/java/com/fs/company/controller/aiSipCall/AiSipCallBizGroupController.java
  19. 10 11
      fs-company/src/main/java/com/fs/company/controller/aiSipCall/AiSipCallGatewayController.java
  20. 1 1
      fs-company/src/main/java/com/fs/company/controller/aiSipCall/AiSipCallLlmAgentAccountController.java
  21. 69 41
      fs-company/src/main/java/com/fs/company/controller/aiSipCall/AiSipCallOutboundCdrController.java
  22. 382 57
      fs-company/src/main/java/com/fs/company/controller/aiSipCall/AiSipCallPhoneController.java
  23. 22 24
      fs-company/src/main/java/com/fs/company/controller/aiSipCall/AiSipCallTaskController.java
  24. 17 13
      fs-company/src/main/java/com/fs/company/controller/aiSipCall/AiSipCallUserController.java
  25. 16 14
      fs-company/src/main/java/com/fs/company/controller/aiSipCall/AiSipCallVoiceTtsAliyunController.java
  26. 48 0
      fs-company/src/main/java/com/fs/company/controller/aiSipCall/SopSipCallLogsController.java
  27. 9 0
      fs-qw-task/src/main/java/com/fs/app/controller/CommonController.java
  28. 14 0
      fs-qw-task/src/main/java/com/fs/app/task/qwTask.java
  29. 46 0
      fs-qw-task/src/main/java/com/fs/app/taskService/impl/SopLogsTaskServiceImpl.java
  30. 173 27
      fs-service/src/main/java/com/fs/aiSipCall/RemoteCommon.java
  31. 59 0
      fs-service/src/main/java/com/fs/aiSipCall/domain/AiSipAutoCallRules.java
  32. 2 0
      fs-service/src/main/java/com/fs/aiSipCall/domain/AiSipCallGateway.java
  33. 40 8
      fs-service/src/main/java/com/fs/aiSipCall/domain/AiSipCallOutboundCdr.java
  34. 126 83
      fs-service/src/main/java/com/fs/aiSipCall/domain/AiSipCallPhone.java
  35. 14 0
      fs-service/src/main/java/com/fs/aiSipCall/domain/AiSipCallTask.java
  36. 18 2
      fs-service/src/main/java/com/fs/aiSipCall/domain/AiSipCallUser.java
  37. 8 1
      fs-service/src/main/java/com/fs/aiSipCall/domain/AiSipCallVoiceTtsAliyun.java
  38. 2 2
      fs-service/src/main/java/com/fs/aiSipCall/domain/CcCustCallRecord.java
  39. 2 2
      fs-service/src/main/java/com/fs/aiSipCall/domain/CcCustInfo.java
  40. 62 0
      fs-service/src/main/java/com/fs/aiSipCall/mapper/AiSipAutoCallRulesMapper.java
  41. 7 7
      fs-service/src/main/java/com/fs/aiSipCall/mapper/AiSipCallBizGroupMapper.java
  42. 7 7
      fs-service/src/main/java/com/fs/aiSipCall/mapper/AiSipCallGatewayMapper.java
  43. 7 7
      fs-service/src/main/java/com/fs/aiSipCall/mapper/AiSipCallLlmAgentAccountMapper.java
  44. 23 7
      fs-service/src/main/java/com/fs/aiSipCall/mapper/AiSipCallOutboundCdrMapper.java
  45. 90 15
      fs-service/src/main/java/com/fs/aiSipCall/mapper/AiSipCallPhoneMapper.java
  46. 33 7
      fs-service/src/main/java/com/fs/aiSipCall/mapper/AiSipCallTaskMapper.java
  47. 16 7
      fs-service/src/main/java/com/fs/aiSipCall/mapper/AiSipCallUserMapper.java
  48. 7 7
      fs-service/src/main/java/com/fs/aiSipCall/mapper/AiSipCallVoiceTtsAliyunMapper.java
  49. 19 2
      fs-service/src/main/java/com/fs/aiSipCall/param/ApiCallRecordQueryParams.java
  50. 53 0
      fs-service/src/main/java/com/fs/aiSipCall/param/StatisticalManagementParam.java
  51. 45 0
      fs-service/src/main/java/com/fs/aiSipCall/service/AiSipCallTransactionService.java
  52. 372 0
      fs-service/src/main/java/com/fs/aiSipCall/service/AsyncAddSipCallRecordService.java
  53. 85 0
      fs-service/src/main/java/com/fs/aiSipCall/service/IAiSipAutoCallRulesService.java
  54. 7 7
      fs-service/src/main/java/com/fs/aiSipCall/service/IAiSipCallBizGroupService.java
  55. 7 7
      fs-service/src/main/java/com/fs/aiSipCall/service/IAiSipCallLlmAgentAccountService.java
  56. 11 5
      fs-service/src/main/java/com/fs/aiSipCall/service/IAiSipCallOutboundCdrService.java
  57. 30 6
      fs-service/src/main/java/com/fs/aiSipCall/service/IAiSipCallPhoneService.java
  58. 15 12
      fs-service/src/main/java/com/fs/aiSipCall/service/IAiSipCallTaskService.java
  59. 22 0
      fs-service/src/main/java/com/fs/aiSipCall/service/IAiSipCallUserService.java
  60. 17 0
      fs-service/src/main/java/com/fs/aiSipCall/service/IAiSipCallVoiceTtsAliyunService.java
  61. 16 0
      fs-service/src/main/java/com/fs/aiSipCall/service/IChineseStatutoryHolidayService.java
  62. 311 0
      fs-service/src/main/java/com/fs/aiSipCall/service/ManualPullSyncSipRecordService.java
  63. 850 0
      fs-service/src/main/java/com/fs/aiSipCall/service/impl/AiSipAutoCallRulesServiceImpl.java
  64. 44 64
      fs-service/src/main/java/com/fs/aiSipCall/service/impl/AiSipCallBizGroupServiceImpl.java
  65. 47 78
      fs-service/src/main/java/com/fs/aiSipCall/service/impl/AiSipCallGatewayServiceImpl.java
  66. 15 66
      fs-service/src/main/java/com/fs/aiSipCall/service/impl/AiSipCallLlmAgentAccountServiceImpl.java
  67. 284 406
      fs-service/src/main/java/com/fs/aiSipCall/service/impl/AiSipCallOutboundCdrServiceImpl.java
  68. 371 289
      fs-service/src/main/java/com/fs/aiSipCall/service/impl/AiSipCallPhoneServiceImpl.java
  69. 920 170
      fs-service/src/main/java/com/fs/aiSipCall/service/impl/AiSipCallTaskServiceImpl.java
  70. 558 109
      fs-service/src/main/java/com/fs/aiSipCall/service/impl/AiSipCallUserServiceImpl.java
  71. 73 65
      fs-service/src/main/java/com/fs/aiSipCall/service/impl/AiSipCallVoiceTtsAliyunServiceImpl.java
  72. 239 0
      fs-service/src/main/java/com/fs/aiSipCall/service/impl/ChineseStatutoryHolidayServiceImpl.java
  73. 641 0
      fs-service/src/main/java/com/fs/aiSipCall/support/AiSipAutoCallRuleEvaluator.java
  74. 64 0
      fs-service/src/main/java/com/fs/aiSipCall/support/AiSipShieldRuleReport.java
  75. 242 0
      fs-service/src/main/java/com/fs/aiSipCall/utils/RemoteApiHelper.java
  76. 129 0
      fs-service/src/main/java/com/fs/aiSipCall/utils/SipDESUtil.java
  77. 77 0
      fs-service/src/main/java/com/fs/aiSipCall/utils/SipLogUtil.java
  78. 235 0
      fs-service/src/main/java/com/fs/aiSipCall/utils/VoiceLogUtil.java
  79. 84 0
      fs-service/src/main/java/com/fs/aiSipCall/vo/AiSipCallPhoneInSopVo.java
  80. 2 2
      fs-service/src/main/java/com/fs/aiSipCall/vo/CallPhoneExportVo.java
  81. 9 0
      fs-service/src/main/java/com/fs/aiSipCall/vo/CcExtNumVo.java
  82. 67 0
      fs-service/src/main/java/com/fs/aiSipCall/vo/StatisticalManagementVo.java
  83. 31 0
      fs-service/src/main/java/com/fs/common/RedisKeyCommon.java
  84. 10 0
      fs-service/src/main/java/com/fs/company/domain/Company.java
  85. 0 1
      fs-service/src/main/java/com/fs/company/domain/CompanyRole.java
  86. 12 0
      fs-service/src/main/java/com/fs/company/mapper/CompanyDeptMapper.java
  87. 3 0
      fs-service/src/main/java/com/fs/company/mapper/CompanyMapper.java
  88. 4 0
      fs-service/src/main/java/com/fs/company/mapper/CompanyUserMapper.java
  89. 143 2
      fs-service/src/main/java/com/fs/company/service/impl/CompanyServiceImpl.java
  90. 4 0
      fs-service/src/main/java/com/fs/course/mapper/FsCourseWatchLogMapper.java
  91. 1 1
      fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseOrderServiceImpl.java
  92. 3 1
      fs-service/src/main/java/com/fs/his/config/IntegralConfig.java
  93. 96 0
      fs-service/src/main/java/com/fs/his/domain/AdProfitDetail.java
  94. 56 0
      fs-service/src/main/java/com/fs/his/domain/FsConsecutiveWithdrawRecord.java
  95. 42 0
      fs-service/src/main/java/com/fs/his/domain/FsIntegralExchange.java
  96. 71 0
      fs-service/src/main/java/com/fs/his/domain/FsIntegralRedPacketLog.java
  97. 99 0
      fs-service/src/main/java/com/fs/his/dto/AdProfitDetailDto.java
  98. 1 0
      fs-service/src/main/java/com/fs/his/enums/FsUserIntegralLogTypeEnum.java
  99. 73 0
      fs-service/src/main/java/com/fs/his/mapper/AdProfitDetailMapper.java
  100. 81 0
      fs-service/src/main/java/com/fs/his/mapper/FsConsecutiveWithdrawRecordMapper.java

+ 121 - 0
fs-admin/src/main/java/com/fs/aiSipCall/AiSipAutoCallRulesController.java

@@ -0,0 +1,121 @@
+package com.fs.aiSipCall;
+
+import com.fs.aiSipCall.domain.AiSipAutoCallRules;
+import com.fs.aiSipCall.service.IAiSipAutoCallRulesService;
+import com.fs.common.annotation.Log;
+import com.fs.common.annotation.RepeatSubmit;
+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 org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * sip自动外呼屏蔽规则Controller
+ *
+ * @author fs
+ * @date 2026-07-07
+ */
+@RestController
+@RequestMapping("/aiSipCall/rules")
+public class AiSipAutoCallRulesController extends BaseController
+{
+    @Autowired
+    private IAiSipAutoCallRulesService aiSipAutoCallRulesService;
+    /**
+     * 查询sip自动外呼屏蔽规则列表
+     */
+    @PreAuthorize("@ss.hasPermi('aiSipCall:rules:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(AiSipAutoCallRules aiSipAutoCallRules)
+    {
+        startPage();
+        List<AiSipAutoCallRules> list = aiSipAutoCallRulesService.selectAiSipAutoCallRulesList(aiSipAutoCallRules);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出sip自动外呼屏蔽规则列表
+     */
+    @PreAuthorize("@ss.hasPermi('aiSipCall:rules:export')")
+    @Log(title = "sip自动外呼屏蔽规则", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(AiSipAutoCallRules aiSipAutoCallRules)
+    {
+        List<AiSipAutoCallRules> list = aiSipAutoCallRulesService.selectAiSipAutoCallRulesList(aiSipAutoCallRules);
+        ExcelUtil<AiSipAutoCallRules> util = new ExcelUtil<>(AiSipAutoCallRules.class);
+        return util.exportExcel(list, "sip自动外呼屏蔽规则数据");
+    }
+
+    /**
+     * 获取sip自动外呼屏蔽规则详细信息
+     */
+    @PreAuthorize("@ss.hasPermi('aiSipCall:rules:query')")
+    @GetMapping(value = "/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Long id)
+    {
+        return AjaxResult.success(aiSipAutoCallRulesService.selectAiSipAutoCallRulesById(id));
+    }
+
+    /**
+     * 新增sip自动外呼屏蔽规则
+     */
+    @PreAuthorize("@ss.hasPermi('aiSipCall:rules:add')")
+    @Log(title = "sip自动外呼屏蔽规则", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody AiSipAutoCallRules aiSipAutoCallRules)
+    {
+        return toAjax(aiSipAutoCallRulesService.insertAiSipAutoCallRules(aiSipAutoCallRules));
+    }
+
+    /**
+     * 修改sip自动外呼屏蔽规则
+     */
+    @PreAuthorize("@ss.hasPermi('aiSipCall:rules:edit')")
+    @Log(title = "sip自动外呼屏蔽规则", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody AiSipAutoCallRules aiSipAutoCallRules)
+    {
+        return toAjax(aiSipAutoCallRulesService.updateAiSipAutoCallRules(aiSipAutoCallRules));
+    }
+
+    /**
+     * 删除sip自动外呼屏蔽规则
+     */
+    @PreAuthorize("@ss.hasPermi('aiSipCall:rules:remove')")
+    @Log(title = "sip自动外呼屏蔽规则", businessType = BusinessType.DELETE)
+	@DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids)
+    {
+        return toAjax(aiSipAutoCallRulesService.deleteAiSipAutoCallRulesByIds(ids));
+    }
+
+    /**
+     * 校验所选批次任务是否存在重复
+     * @param batchIds 批次任务ID列表
+     * @param excludeId 排除的批次任务ID
+     * @return 重复的批次任务名称列表
+     */
+    @PostMapping("/checkBatchIds")
+    public AjaxResult checkBatchIds(@RequestBody List<Long> batchIds,
+                                    @RequestParam(value = "excludeId", required = false) Long excludeId)
+    {
+        List<String> duplicateNames = aiSipAutoCallRulesService.checkDuplicateBatchNames(batchIds,excludeId);
+        return AjaxResult.success(duplicateNames);
+    }
+
+    /**
+     * 手动同步规则
+     */
+    @RepeatSubmit
+    @GetMapping("/syncRule")
+    public AjaxResult syncRule()
+    {
+        return AjaxResult.success("执行成功,请查收报告", aiSipAutoCallRulesService.verifySipCallStopAndStart());
+    }
+}

+ 97 - 0
fs-admin/src/main/java/com/fs/aiSipCall/AiSipCallBizGroupController.java

@@ -0,0 +1,97 @@
+package com.fs.aiSipCall;
+
+import com.fs.aiSipCall.domain.AiSipCallBizGroup;
+import com.fs.aiSipCall.service.IAiSipCallBizGroupService;
+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 org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * aiSIP外呼技能组Controller
+ * 
+ * @author fs
+ * @date 2026-03-06
+ */
+@RestController
+@RequestMapping("/his/aiSipCall/bizGroup")
+public class AiSipCallBizGroupController extends BaseController
+{
+    @Autowired
+    private IAiSipCallBizGroupService aiSipCallBizGroupService;
+
+    /**
+     * 查询aiSIP外呼技能组列表
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:bizGroup:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(AiSipCallBizGroup aiSipCallBizGroup)
+    {
+        startPage();
+        List<AiSipCallBizGroup> list = aiSipCallBizGroupService.selectAiSipCallBizGroupList(aiSipCallBizGroup);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出aiSIP外呼技能组列表
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:bizGroup:export')")
+    @Log(title = "aiSIP外呼技能组", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(AiSipCallBizGroup aiSipCallBizGroup)
+    {
+        List<AiSipCallBizGroup> list = aiSipCallBizGroupService.selectAiSipCallBizGroupList(aiSipCallBizGroup);
+        ExcelUtil<AiSipCallBizGroup> util = new ExcelUtil<>(AiSipCallBizGroup.class);
+        return util.exportExcel(list, "aiSIP外呼技能组数据");
+    }
+
+    /**
+     * 获取aiSIP外呼技能组详细信息
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:bizGroup:query')")
+    @GetMapping(value = "/{groupId}")
+    public AjaxResult getInfo(@PathVariable("groupId") Long groupId)
+    {
+        return AjaxResult.success(aiSipCallBizGroupService.selectAiSipCallBizGroupByGroupId(groupId));
+    }
+
+    /**
+     * 新增aiSIP外呼技能组
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:bizGroup:add')")
+    @Log(title = "aiSIP外呼技能组", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody AiSipCallBizGroup aiSipCallBizGroup)
+    {
+        return toAjax(aiSipCallBizGroupService.insertAiSipCallBizGroup(aiSipCallBizGroup));
+    }
+
+    /**
+     * 修改aiSIP外呼技能组
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:bizGroup:edit')")
+    @Log(title = "aiSIP外呼技能组", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody AiSipCallBizGroup aiSipCallBizGroup)
+    {
+        return toAjax(aiSipCallBizGroupService.updateAiSipCallBizGroup(aiSipCallBizGroup));
+    }
+
+    /**
+     * 删除aiSIP外呼技能组
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:bizGroup:remove')")
+    @Log(title = "aiSIP外呼技能组", businessType = BusinessType.DELETE)
+	@DeleteMapping("/{groupIds}")
+    public AjaxResult remove(@PathVariable Long[] groupIds)
+    {
+        return toAjax(aiSipCallBizGroupService.deleteAiSipCallBizGroupByGroupIds(groupIds));
+    }
+}

+ 110 - 0
fs-admin/src/main/java/com/fs/aiSipCall/AiSipCallGatewayController.java

@@ -0,0 +1,110 @@
+package com.fs.aiSipCall;
+
+import com.fs.aiSipCall.domain.AiSipCallGateway;
+import com.fs.aiSipCall.service.IAiSipCallGatewayService;
+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 org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Date;
+import java.util.List;
+
+/**
+ * aiSIP外呼网关Controller
+ * 
+ * @author fs
+ * @date 2026-03-06
+ */
+@RestController
+@RequestMapping("/his/aiSipCall/gateway")
+public class AiSipCallGatewayController extends BaseController
+{
+    @Autowired
+    private IAiSipCallGatewayService aiSipCallGatewayService;
+
+    /**
+     * 查询aiSIP外呼网关列表
+     */
+    @PostMapping("/remoteList")
+    public TableDataInfo remoteList(@RequestBody AiSipCallGateway aiSipCallGateway)
+    {
+        return aiSipCallGatewayService.remoteList(aiSipCallGateway);
+    }
+
+    /**
+     * 查询aiSIP外呼网关列表
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:gateway:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(AiSipCallGateway aiSipCallGateway)
+    {
+        startPage();
+        List<AiSipCallGateway> list = aiSipCallGatewayService.selectAiSipCallGatewayList(aiSipCallGateway);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出aiSIP外呼网关列表
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:gateway:export')")
+    @Log(title = "aiSIP外呼网关", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(AiSipCallGateway aiSipCallGateway)
+    {
+        List<AiSipCallGateway> list = aiSipCallGatewayService.selectAiSipCallGatewayList(aiSipCallGateway);
+        ExcelUtil<AiSipCallGateway> util = new ExcelUtil<>(AiSipCallGateway.class);
+        return util.exportExcel(list, "aiSIP外呼网关数据");
+    }
+
+    /**
+     * 获取aiSIP外呼网关详细信息
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:gateway:query')")
+    @GetMapping(value = "/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Long id)
+    {
+        return AjaxResult.success(aiSipCallGatewayService.selectAiSipCallGatewayById(id));
+    }
+
+    /**
+     * 新增aiSIP外呼网关
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:gateway:add')")
+    @Log(title = "aiSIP外呼网关", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody AiSipCallGateway aiSipCallGateway)
+    {
+        if(aiSipCallGateway.getUpdateTime() == null){
+            aiSipCallGateway.setUpdateTime(new Date());
+        }
+        return toAjax(aiSipCallGatewayService.insertAiSipCallGateway(aiSipCallGateway));
+    }
+
+    /**
+     * 修改aiSIP外呼网关
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:gateway:edit')")
+    @Log(title = "aiSIP外呼网关", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody AiSipCallGateway aiSipCallGateway)
+    {
+        return toAjax(aiSipCallGatewayService.updateAiSipCallGateway(aiSipCallGateway));
+    }
+
+    /**
+     * 删除aiSIP外呼网关
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:gateway:remove')")
+    @Log(title = "aiSIP外呼网关", businessType = BusinessType.DELETE)
+	@DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids)
+    {
+        return toAjax(aiSipCallGatewayService.deleteAiSipCallGatewayByIds(ids));
+    }
+}

+ 97 - 0
fs-admin/src/main/java/com/fs/aiSipCall/AiSipCallLlmAgentAccountController.java

@@ -0,0 +1,97 @@
+package com.fs.aiSipCall;
+
+import com.fs.aiSipCall.domain.AiSipCallLlmAgentAccount;
+import com.fs.aiSipCall.service.IAiSipCallLlmAgentAccountService;
+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 org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * aiSIP外呼大模型Controller
+ * 
+ * @author fs
+ * @date 2026-03-06
+ */
+@RestController
+@RequestMapping("/his/aiSipCall/llmAgentAccount")
+public class AiSipCallLlmAgentAccountController extends BaseController
+{
+    @Autowired
+    private IAiSipCallLlmAgentAccountService aiSipCallLlmAgentAccountService;
+
+    /**
+     * 查询aiSIP外呼大模型列表
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:llmAgentAccount:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(AiSipCallLlmAgentAccount aiSipCallLlmAgentAccount)
+    {
+        startPage();
+        List<AiSipCallLlmAgentAccount> list = aiSipCallLlmAgentAccountService.selectAiSipCallLlmAgentAccountList(aiSipCallLlmAgentAccount);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出aiSIP外呼大模型列表
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:llmAgentAccount:export')")
+    @Log(title = "aiSIP外呼大模型", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(AiSipCallLlmAgentAccount aiSipCallLlmAgentAccount)
+    {
+        List<AiSipCallLlmAgentAccount> list = aiSipCallLlmAgentAccountService.selectAiSipCallLlmAgentAccountList(aiSipCallLlmAgentAccount);
+        ExcelUtil<AiSipCallLlmAgentAccount> util = new ExcelUtil<>(AiSipCallLlmAgentAccount.class);
+        return util.exportExcel(list, "aiSIP外呼大模型数据");
+    }
+
+    /**
+     * 获取aiSIP外呼大模型详细信息
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:llmAgentAccount:query')")
+    @GetMapping(value = "/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Long id)
+    {
+        return AjaxResult.success(aiSipCallLlmAgentAccountService.selectAiSipCallLlmAgentAccountById(id));
+    }
+
+    /**
+     * 新增aiSIP外呼大模型
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:llmAgentAccount:add')")
+    @Log(title = "aiSIP外呼大模型", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody AiSipCallLlmAgentAccount aiSipCallLlmAgentAccount)
+    {
+        return toAjax(aiSipCallLlmAgentAccountService.insertAiSipCallLlmAgentAccount(aiSipCallLlmAgentAccount));
+    }
+
+    /**
+     * 修改aiSIP外呼大模型
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:llmAgentAccount:edit')")
+    @Log(title = "aiSIP外呼大模型", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody AiSipCallLlmAgentAccount aiSipCallLlmAgentAccount)
+    {
+        return toAjax(aiSipCallLlmAgentAccountService.updateAiSipCallLlmAgentAccount(aiSipCallLlmAgentAccount));
+    }
+
+    /**
+     * 删除aiSIP外呼大模型
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:llmAgentAccount:remove')")
+    @Log(title = "aiSIP外呼大模型", businessType = BusinessType.DELETE)
+	@DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids)
+    {
+        return toAjax(aiSipCallLlmAgentAccountService.deleteAiSipCallLlmAgentAccountByIds(ids));
+    }
+}

+ 187 - 0
fs-admin/src/main/java/com/fs/aiSipCall/AiSipCallOutboundCdrController.java

@@ -0,0 +1,187 @@
+package com.fs.aiSipCall;
+
+import com.fs.aiSipCall.domain.AiSipCallOutboundCdr;
+import com.fs.aiSipCall.domain.CcCustInfo;
+import com.fs.aiSipCall.service.IAiSipCallOutboundCdrService;
+import com.fs.aiSipCall.utils.StringUtils;
+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.model.LoginUser;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.poi.ExcelUtil;
+import com.fs.his.domain.FsUser;
+import com.fs.his.service.IFsUserService;
+import com.fs.his.utils.PhoneUtil;
+import com.fs.his.utils.RandomUtil;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * aiSIP手动外呼通话记录Controller
+ *
+ * @author fs
+ * @date 2026-03-19
+ */
+@RestController
+@RequestMapping("/his/aiSipCall/outboundCdr")
+public class AiSipCallOutboundCdrController extends BaseController
+{
+
+    @Autowired
+    private IAiSipCallOutboundCdrService aiSipCallOutboundCdrService;
+
+    @Autowired
+    private IFsUserService iFsUserService;
+
+    /**
+     * 查询aiSIP手动外呼通话记录列表
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:outboundCdr:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(AiSipCallOutboundCdr aiSipCallOutboundCdr)
+    {
+        startPage();
+        List<AiSipCallOutboundCdr> list = aiSipCallOutboundCdrService.selectAiSipCallOutboundCdrList(aiSipCallOutboundCdr);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出aiSIP手动外呼通话记录列表
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:outboundCdr:export')")
+    @Log(title = "aiSIP手动外呼通话记录", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(AiSipCallOutboundCdr aiSipCallOutboundCdr)
+    {
+        List<AiSipCallOutboundCdr> list = aiSipCallOutboundCdrService.selectAiSipCallOutboundCdrList(aiSipCallOutboundCdr);
+        ExcelUtil<AiSipCallOutboundCdr> util = new ExcelUtil<>(AiSipCallOutboundCdr.class);
+        return util.exportExcel(list, "aiSIP手动外呼通话记录数据");
+    }
+
+    /**
+     * 获取aiSIP手动外呼通话记录详细信息
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:outboundCdr:query')")
+    @GetMapping(value = "/{id}")
+    public AjaxResult getInfo(@PathVariable("id") String id)
+    {
+        return AjaxResult.success(aiSipCallOutboundCdrService.selectAiSipCallOutboundCdrById(id));
+    }
+
+    /**
+     * 新增aiSIP手动外呼通话记录
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:outboundCdr:add')")
+    @Log(title = "aiSIP手动外呼通话记录", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody AiSipCallOutboundCdr aiSipCallOutboundCdr)
+    {
+        return toAjax(aiSipCallOutboundCdrService.insertAiSipCallOutboundCdr(aiSipCallOutboundCdr));
+    }
+
+    /**
+     * 修改aiSIP手动外呼通话记录
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:outboundCdr:edit')")
+    @Log(title = "aiSIP手动外呼通话记录", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody AiSipCallOutboundCdr aiSipCallOutboundCdr)
+    {
+        return toAjax(aiSipCallOutboundCdrService.updateAiSipCallOutboundCdr(aiSipCallOutboundCdr));
+    }
+
+    /**
+     * 删除aiSIP手动外呼通话记录
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:outboundCdr:remove')")
+    @Log(title = "aiSIP手动外呼通话记录", businessType = BusinessType.DELETE)
+	@DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable String[] ids)
+    {
+        return toAjax(aiSipCallOutboundCdrService.deleteAiSipCallOutboundCdrByIds(ids));
+    }
+
+    /**
+     * 获取手动外呼客户沟通信息
+     * @param phoneNum 手机号
+     * @param callType 类型  1呼入 2外呼
+     * @param uuid  通话uuid
+     * @param dialMode  明文plaintext  密文encrypted
+     */
+    @GetMapping("/getCustCommunicationInfo")
+    public AjaxResult getCustCommunicationInfo(@RequestParam("phoneNum") String phoneNum,
+                                               @RequestParam("callType") Integer callType,
+                                               @RequestParam("uuid") String uuid,
+                                               @RequestParam(value = "dialMode", required = false) String dialMode
+    ) {
+        return aiSipCallOutboundCdrService.getCustCommunicationInfo(phoneNum,callType,uuid,dialMode);
+    }
+    /**
+     * 新增保存手动外呼沟通记录
+     */
+    @PostMapping("/add/custcallrecord")
+    @ResponseBody
+    public AjaxResult addCustcallrecord(@RequestBody CcCustInfo ccCustInfo)
+    {
+        return aiSipCallOutboundCdrService.addCustcallrecord(ccCustInfo);
+    }
+
+    /**
+     * 前端传输加密和随机字符串进行解密
+     */
+    @PostMapping("/encryptMobile")
+    @ResponseBody
+    public AjaxResult encryptMobile(@RequestBody Map<String,String> request)
+    {
+        String phone = request.get("phone");  // 前端传来的 "原始字符串+随机数"
+        Long id = Long.valueOf(request.get("id"));        // 获取用户ID
+        // 随机数为最后6位
+        int randomLen = 6;
+        String original = phone.substring(0, phone.length() - randomLen);
+        try {
+            String decrypted="";
+            if(original.length() >= 12){
+                //1.先解密再加密
+                 decrypted = PhoneUtil.decryptPhone(original);
+            }else{
+                //根据id 查客户手机号
+                FsUser fsUser = iFsUserService.selectFsUserByUserId(id);
+                decrypted = PhoneUtil.decryptPhone(fsUser.getPhone());
+            }
+
+
+            //测试数据
+//            String decrypted = original;
+            String encrypted = PhoneUtil.xorEncrypt(decrypted);
+            return AjaxResult.success("获取成功",encrypted + RandomUtil.generateRandomCode()) ;
+        } catch (Exception e) {
+            return AjaxResult.error("获取加密手机号失败:" + e);
+        }
+    }
+
+    /**
+     * 根据UUID同步手动外呼通话记录
+     */
+    @PostMapping("/callEndSyncByUuid")
+    @ResponseBody
+    public void callEndSyncByUuid(@RequestBody AiSipCallOutboundCdr request)
+    {
+        if(StringUtils.isNotEmpty(request.getUuid())){
+            LoginUser loginUser = getLoginUser();
+            request.setSourceType("1");
+            //总后台的记录都记录到总部APP运营
+            request.setCompanyId(323L);
+            request.setSysUserId(loginUser.getUser().getUserId());
+            request.setSysUserName(loginUser.getUser().getUserName());
+            aiSipCallOutboundCdrService.callEndSyncByUuid(request);
+        }else{
+            throw new RuntimeException("获取手动外呼通话记录同步失败,uuid为空");
+        }
+    }
+}

+ 222 - 0
fs-admin/src/main/java/com/fs/aiSipCall/AiSipCallPhoneController.java

@@ -0,0 +1,222 @@
+package com.fs.aiSipCall;
+
+import com.fs.aiSipCall.domain.AiSipCallPhone;
+import com.fs.aiSipCall.service.AsyncAddSipCallRecordService;
+import com.fs.aiSipCall.service.IAiSipCallPhoneService;
+import com.fs.aiSipCall.utils.DateUtils;
+import com.fs.aiSipCall.utils.StringUtils;
+import com.fs.aiSipCall.vo.AiSipCallPhoneInSopVo;
+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.github.pagehelper.PageHelper;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.core.io.ByteArrayResource;
+import org.springframework.core.io.Resource;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Calendar;
+import java.util.List;
+
+/**
+ * aiSIP外呼通话记录Controller
+ *
+ * @author fs
+ * @date 2026-03-06
+ */
+@RestController
+@RequestMapping("/his/aiSipCall/phone")
+public class AiSipCallPhoneController extends BaseController
+{
+    @Autowired
+    private IAiSipCallPhoneService aiSipCallPhoneService;
+    @Autowired
+    private AsyncAddSipCallRecordService asyncAddSipCallRecordService;
+
+    /**
+     * 查询aiSIP外呼通话记录列表
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:phone:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(AiSipCallPhone aiSipCallPhone)
+    {
+        if(aiSipCallPhone.getPageNum() == null){
+            aiSipCallPhone.setPageNum(1);
+        }
+        if(aiSipCallPhone.getPageSize() == null){
+            aiSipCallPhone.setPageSize(10);
+        }
+        PageHelper.startPage(aiSipCallPhone.getPageNum(), aiSipCallPhone.getPageSize());
+        List<AiSipCallPhone> list = aiSipCallPhoneService.selectAiSipCallPhoneList(aiSipCallPhone);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出aiSIP外呼通话记录列表
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:phone:export')")
+    @Log(title = "aiSIP外呼通话记录", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(AiSipCallPhone aiSipCallPhone)
+    {
+        List<AiSipCallPhone> list = aiSipCallPhoneService.selectAiSipCallPhoneList(aiSipCallPhone);
+        ExcelUtil<AiSipCallPhone> util = new ExcelUtil<>(AiSipCallPhone.class);
+        return util.exportExcel(list, "aiSIP外呼总后台通话记录数据");
+    }
+
+    /**
+     * 获取aiSIP外呼通话记录详细信息
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:phone:query')")
+    @GetMapping(value = "/{id}")
+    public AjaxResult getInfo(@PathVariable("id") String id)
+    {
+        return AjaxResult.success(aiSipCallPhoneService.selectAiSipCallPhoneById(id));
+    }
+
+    /**
+     * 新增aiSIP外呼通话记录
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:phone:add')")
+    @Log(title = "aiSIP外呼通话记录", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody AiSipCallPhone aiSipCallPhone)
+    {
+        return toAjax(aiSipCallPhoneService.insertAiSipCallPhone(aiSipCallPhone));
+    }
+
+    /**
+     * 修改aiSIP外呼通话记录
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:phone:edit')")
+    @Log(title = "aiSIP外呼通话记录", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody AiSipCallPhone aiSipCallPhone)
+    {
+        return toAjax(aiSipCallPhoneService.updateAiSipCallPhone(aiSipCallPhone));
+    }
+
+    /**
+     * 删除aiSIP外呼通话记录
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:phone:remove')")
+    @Log(title = "aiSIP外呼通话记录", businessType = BusinessType.DELETE)
+	@DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable String[] ids)
+    {
+        return toAjax(aiSipCallPhoneService.deleteAiSipCallPhoneByIds(ids));
+    }
+
+    /**
+     * 手动同步通话记录
+     */
+    @GetMapping("/manualPull")
+    public AjaxResult manualPull()
+    {
+        Calendar calendar = Calendar.getInstance();
+        calendar.set(Calendar.HOUR_OF_DAY, 0);
+        calendar.set(Calendar.MINUTE, 0);
+        calendar.set(Calendar.SECOND, 0);
+        calendar.set(Calendar.MILLISECOND, 0);
+        String startTime = DateUtils.parseDateToStr("yyyy-MM-dd HH:mm:ss", calendar.getTime());
+
+        calendar.set(Calendar.HOUR_OF_DAY, 23);
+        calendar.set(Calendar.MINUTE, 59);
+        calendar.set(Calendar.SECOND, 59);
+        calendar.set(Calendar.MILLISECOND, 999);
+        String endTime = DateUtils.parseDateToStr("yyyy-MM-dd HH:mm:ss", calendar.getTime());
+        asyncAddSipCallRecordService.getAutomaticCallRecord(startTime,endTime,"manualPull");
+        return success();
+    }
+
+    /**
+     * 总后台自动化任务sip执行计划详情列表
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:phone:hisSopList')")
+    @GetMapping("/hisSopList")
+    public TableDataInfo hisSopList(AiSipCallPhone aiSipCallPhone)
+    {
+        if(aiSipCallPhone.getPageNum() == null){
+            aiSipCallPhone.setPageNum(1);
+        }
+        if(aiSipCallPhone.getPageSize() == null){
+            aiSipCallPhone.setPageSize(10);
+        }
+        if(org.apache.commons.lang3.StringUtils.isBlank(aiSipCallPhone.getExecuteType())){
+            aiSipCallPhone.setExecuteType("1");
+        }
+        PageHelper.startPage(aiSipCallPhone.getPageNum(), aiSipCallPhone.getPageSize());
+        List<AiSipCallPhoneInSopVo> list = aiSipCallPhoneService.SopTaskList(aiSipCallPhone);
+        return getDataTable(list);
+    }
+    /**
+     * 根据UUID查询自动外呼对话详情
+     */
+    @PostMapping("/callEndQueryByUuid")
+    @ResponseBody
+    public AjaxResult callEndQueryByUuid(@RequestBody AiSipCallPhone request)
+    {
+        if(StringUtils.isNotEmpty(request.getUuid())){
+            return aiSipCallPhoneService.callEndQueryByUuid(request);
+        }else{
+            throw new RuntimeException("获取自动外呼通话记录失败,uuid为空");
+        }
+    }
+
+    /**
+     * 播放/下载录音:直接调用对端 /recordings/files?filename=,把返回的二进制原样给前端。
+     * 对齐对端 downloadFile 实现,不做格式转换。
+     *
+     * @param aiSipCallPhone 录音相对路径(或列表里的 wavfile / recordings/files?filename=xxx)
+     */
+    @PostMapping("/playAndDownload")
+    public ResponseEntity<Resource> playAndDownload(@RequestBody AiSipCallPhone aiSipCallPhone) {
+        try {
+            String wavfile = aiSipCallPhone == null ? null : aiSipCallPhone.getWavfile();
+            if (org.apache.commons.lang3.StringUtils.isBlank(wavfile)) {
+                return ResponseEntity.badRequest().build();
+            }
+            // 下载仍用完整路径 /recordings/files?filename=xxx(RemoteCommon 拼主机)
+            byte[] bytes = RemoteCommon.downloadRecordingBytes(wavfile.trim());
+            if (bytes == null || bytes.length == 0) {
+                return ResponseEntity.notFound().build();
+            }
+            // 附件名去掉前缀 /recordings/files?filename=,勿用 Paths.get(含 ? 会报错)
+            String downloadName = extractWavFileName(wavfile);
+            return ResponseEntity.ok()
+                    .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + downloadName + "\"")
+                    .body(new ByteArrayResource(bytes));
+        } catch (Exception e) {
+            return ResponseEntity.badRequest().build();
+        }
+    }
+
+    /**
+     * 去掉前缀 /recordings/files?filename=,得到真实相对路径。
+     * 例:/recordings/files?filename=ai_call/2026/07/14/11/xxx.wav → ai_call/2026/07/14/11/xxx.wav
+     */
+    private static String extractWavFileName(String wavfile) {
+        String path = wavfile.trim();
+        String marker = "recordings/files?filename=";
+        int idx = path.indexOf(marker);
+        if (idx >= 0) {
+            path = path.substring(idx + marker.length());
+        } else if (path.startsWith("filename=")) {
+            path = path.substring("filename=".length());
+        }
+        int amp = path.indexOf('&');
+        if (amp >= 0) {
+            path = path.substring(0, amp);
+        }
+        if (path.startsWith("/")) {
+            path = path.substring(1);
+        }
+        return path;
+    }
+}

+ 244 - 0
fs-admin/src/main/java/com/fs/aiSipCall/AiSipCallTaskController.java

@@ -0,0 +1,244 @@
+package com.fs.aiSipCall;
+
+import com.alibaba.fastjson.JSONObject;
+import com.fs.aiSipCall.domain.AiSipCallPhone;
+import com.fs.aiSipCall.domain.AiSipCallTask;
+import com.fs.aiSipCall.dto.CommonPhoneModel;
+import com.fs.aiSipCall.service.IAiSipCallTaskService;
+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.model.LoginUser;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.SecurityUtils;
+import com.fs.common.utils.poi.ExcelUtil;
+import org.apache.poi.ss.usermodel.*;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.core.io.FileSystemResource;
+import org.springframework.core.io.Resource;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * aiSIP外呼任务Controller
+ *
+ * @author fs
+ * @date 2026-03-06
+ */
+@RestController
+@RequestMapping("/his/aiSipCall/task")
+public class AiSipCallTaskController extends BaseController
+{
+    @Autowired
+    private IAiSipCallTaskService aiSipCallTaskService;
+
+    /**
+     * 查询aiSIP外呼任务列表
+     */
+    @GetMapping("/list")
+    public TableDataInfo list(AiSipCallTask aiSipCallTask)
+    {
+        startPage();
+        List<AiSipCallTask> list = aiSipCallTaskService.selectAiSipCallTaskList(aiSipCallTask);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出aiSIP外呼任务列表
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:task:export')")
+    @Log(title = "aiSIP外呼任务", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(AiSipCallTask aiSipCallTask)
+    {
+        List<AiSipCallTask> list = aiSipCallTaskService.selectAiSipCallTaskList(aiSipCallTask);
+        ExcelUtil<AiSipCallTask> util = new ExcelUtil<>(AiSipCallTask.class);
+        return util.exportExcel(list, "aiSIP外呼总后台任务数据");
+    }
+
+    /**
+     * 获取aiSIP外呼任务详细信息
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:task:query')")
+    @GetMapping(value = "/{batchId}")
+    public AjaxResult getInfo(@PathVariable("batchId") Long batchId)
+    {
+        return AjaxResult.success(aiSipCallTaskService.selectAiSipCallTaskByBatchId(batchId));
+    }
+
+    /**
+     * 新增aiSIP外呼任务
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:task:add')")
+    @Log(title = "aiSIP外呼任务", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody AiSipCallTask aiSipCallTask)
+    {
+        aiSipCallTask.setCompanyUserId(SecurityUtils.getUserId());
+        aiSipCallTask.setCreateBy("总后台:"+SecurityUtils.getLoginUser().getUsername());
+        aiSipCallTask.setCreateTime(new Date());
+        // 外呼速率=1/接通率
+        if (null != aiSipCallTask.getConntectRate() && aiSipCallTask.getConntectRate() > 0) {
+            aiSipCallTask.setRate(aiSipCallTask.getConntectRate()/100.0);
+        }
+        return toAjax(aiSipCallTaskService.insertAiSipCallTask(aiSipCallTask));
+    }
+
+    /**
+     * 修改aiSIP外呼任务
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:task:edit')")
+    @Log(title = "aiSIP外呼任务", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody AiSipCallTask aiSipCallTask)
+    {
+        aiSipCallTask.setUpdateBy("总后台:"+SecurityUtils.getLoginUser().getUsername());
+        aiSipCallTask.setUpdateTime(new Date());
+        return toAjax(aiSipCallTaskService.updateAiSipCallTask(aiSipCallTask));
+    }
+
+    /**
+     * 删除aiSIP外呼任务
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:task:remove')")
+    @Log(title = "aiSIP外呼任务", businessType = BusinessType.DELETE)
+	@DeleteMapping("/{batchIds}")
+    public AjaxResult remove(@PathVariable Long[] batchIds)
+    {
+        return toAjax(aiSipCallTaskService.deleteAiSipCallTaskByBatchIds(batchIds));
+    }
+
+    /**
+     * 启动aiSIP外呼任务
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:task:startTask')")
+    @Log(title = "启动aiSIP外呼任务", businessType = BusinessType.UPDATE)
+    @PostMapping("/startTask/{batchId}")
+    public AjaxResult startTask(@PathVariable Long batchId)
+    {
+        return toAjax(aiSipCallTaskService.startTask(batchId));
+    }
+
+    /**
+     * 暂停aiSIP外呼任务
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:task:stopTask')")
+    @Log(title = "暂停aiSIP外呼任务", businessType = BusinessType.UPDATE)
+    @PostMapping("/stopTask/{batchId}")
+    public AjaxResult stopTask(@PathVariable Long batchId)
+    {
+        return toAjax(aiSipCallTaskService.stopTask(batchId));
+    }
+
+    /**
+     * 导入外呼号码模板
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:task:commonImportExcel')")
+    @Log(title = "导入外呼号码", businessType = BusinessType.IMPORT)
+    @PostMapping(value = "/common/importExcel")
+    public AjaxResult commonImportExcel(
+            @RequestParam("batchId") Long batchId,
+            @RequestParam("file") MultipartFile file) throws Exception {
+        List<CommonPhoneModel> phoneList = parseExcelFile(file);
+        return toAjax(aiSipCallTaskService.commonImportExcel(batchId, phoneList,buildCallPhone(),null,null));
+    }
+
+    /**
+     * 导入外呼号码数据
+     */
+    @PostMapping(value = "/common/importData/{batchId}")
+    public AjaxResult commonImportData(
+            @PathVariable("batchId") Long batchId,
+            @RequestBody List<CommonPhoneModel> phoneList){
+        return toAjax(aiSipCallTaskService.commonImportExcel(batchId, phoneList,buildCallPhone(),null,null));
+    }
+
+    /**
+     * 构建页面导入通话基础数据
+     */
+    private AiSipCallPhone buildCallPhone() {
+        AiSipCallPhone callPhone = new AiSipCallPhone();
+        callPhone.setCallType("0");
+        LoginUser loginUser = getLoginUser();
+        callPhone.setSysUserId(loginUser.getUser().getUserId());
+        callPhone.setSysUserName(loginUser.getUser().getUserName());
+        return callPhone;
+    }
+
+    /**
+     * 解析 Excel 文件为电话号码列表
+     */
+    private List<CommonPhoneModel> parseExcelFile(MultipartFile file) throws Exception {
+        List<CommonPhoneModel> phoneList = new ArrayList<>();
+        DataFormatter formatter = new DataFormatter();
+
+        try (InputStream is = file.getInputStream();
+             Workbook workbook = new XSSFWorkbook(is)) {
+
+            Sheet sheet = workbook.getSheetAt(0);
+
+            for (int i = 1; i <= sheet.getLastRowNum(); i++) {
+                Row row = sheet.getRow(i);
+                if (row == null) continue;
+
+                CommonPhoneModel phone = new CommonPhoneModel();
+
+                // 读取客户姓名(第 1 列)
+                Cell nameCell = row.getCell(0);
+                if (nameCell != null) {
+                    String name = formatter.formatCellValue(nameCell);
+                    phone.setNoticeContent(name);
+                    JSONObject bizJson = new JSONObject();
+                    bizJson.put("custName", name);
+                    phone.setBizJson(bizJson);
+                }
+
+                // 读取电话号码(第 2 列)
+                Cell phoneCell = row.getCell(1);
+                if (phoneCell != null) {
+                    phone.setPhoneNum(formatter.formatCellValue(phoneCell));
+                }
+
+                phoneList.add(phone);
+            }
+        }
+        return phoneList;
+    }
+
+    /**
+     * 下载指定类型的模板文件
+     * @param taskType 1 为 ai 外呼
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:task:download:template')")
+    @GetMapping("/download/template/{taskType}")
+    public ResponseEntity<Resource> downloadTemplateByType(@PathVariable("taskType") Integer taskType) {
+        if (taskType == null || taskType != 1) {
+            throw new RuntimeException("不支持的模板类型:" + taskType);
+        }
+
+        String templateName = "AICallList.xlsx";
+        String filePath = "C:\\fs\\AiSipCallTemplate\\" + templateName;
+        Resource resource = new FileSystemResource(filePath);
+
+        if (!resource.exists()) {
+            throw new RuntimeException("模板文件不存在:" + filePath);
+        }
+
+        return ResponseEntity.ok()
+                .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + templateName)
+                .contentType(MediaType.APPLICATION_OCTET_STREAM)
+                .body(resource);
+    }
+
+}

+ 183 - 0
fs-admin/src/main/java/com/fs/aiSipCall/AiSipCallUserController.java

@@ -0,0 +1,183 @@
+package com.fs.aiSipCall;
+
+import com.fs.aiSipCall.domain.AiSipCallUser;
+import com.fs.aiSipCall.service.IAiSipCallUserService;
+import com.fs.aiSipCall.vo.CcExtNumVo;
+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.model.LoginUser;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.poi.ExcelUtil;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * sip用户信息Controller
+ * 
+ * @author fs
+ * @date 2026-03-13
+ */
+@RestController
+@RequestMapping("/his/aiSipCall/aiSipCallUser")
+public class AiSipCallUserController extends BaseController
+{
+    @Autowired
+    private IAiSipCallUserService aiSipCallUserService;
+
+    /**
+     * 查询sip用户信息列表
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:aiSipCallUser:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(AiSipCallUser aiSipCallUser)
+    {
+        startPage();
+        List<AiSipCallUser> list = aiSipCallUserService.selectAiSipCallUserList(aiSipCallUser);
+        return getDataTable(list);
+    }
+
+    /**
+     * 查询aiSIP工具条基础配置参数
+     * @param param 参数
+     * @return AjaxResult 结果
+     */
+    @PostMapping("/getToolbarBasicParam")
+    public AjaxResult getToolbarBasicParam(@RequestBody Map<String,String> param)
+    {
+        String extNum = param.get("extNum");
+        if(extNum == null){
+            return AjaxResult.error("分机号参数缺失");
+        }
+        return aiSipCallUserService.getToolbarBasicParam(param);
+    }
+
+    /**
+     * 导出sip用户信息列表
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:aiSipCallUser:export')")
+    @Log(title = "sip用户信息", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(AiSipCallUser aiSipCallUser)
+    {
+        List<AiSipCallUser> list = aiSipCallUserService.selectAiSipCallUserList(aiSipCallUser);
+        ExcelUtil<AiSipCallUser> util = new ExcelUtil<>(AiSipCallUser.class);
+        return util.exportExcel(list, "sip用户信息数据");
+    }
+
+    /**
+     * 获取sip用户信息详细信息
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:aiSipCallUser:query')")
+    @GetMapping(value = "/{userId}")
+    public AjaxResult getInfo(@PathVariable("userId") Long userId)
+    {
+        return AjaxResult.success(aiSipCallUserService.selectAiSipCallUserByUserId(userId));
+    }
+
+    /**
+     * 新增sip用户信息
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:aiSipCallUser:add')")
+    @Log(title = "sip用户信息", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody AiSipCallUser aiSipCallUser)
+    {
+        aiSipCallUser.setUserSource("1");
+        aiSipCallUser.setCreateTime(new Date());
+        return toAjax(aiSipCallUserService.insertAiSipCallUser(aiSipCallUser));
+    }
+
+    /**
+     * 修改sip用户信息
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:aiSipCallUser:edit')")
+    @Log(title = "sip用户信息", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody AiSipCallUser aiSipCallUser)
+    {
+        LoginUser user = getLoginUser();
+        aiSipCallUser.setUpdateBy(user.getUser().getUserName());
+        aiSipCallUser.setUpdateTime(new Date());
+        aiSipCallUser.setUserSource("1");
+        return toAjax(aiSipCallUserService.updateAiSipCallUser(aiSipCallUser));
+    }
+
+    /**
+     * 删除sip用户信息
+     */
+    @PreAuthorize("@ss.hasPermi('his:aiSipCall:aiSipCallUser:remove')")
+    @Log(title = "sip用户信息", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{userIds}")
+    public AjaxResult remove(@PathVariable Long[] userIds)
+    {
+        return toAjax(aiSipCallUserService.deleteAiSipCallUserByUserIds(userIds));
+    }
+    /**
+     * 获取公司绑定的分机列表
+     */
+    @GetMapping("/getCompanyUnBindExtnum/{companyId}")
+    public AjaxResult getCompanyUnBindExtnum(@PathVariable Long companyId)
+    {
+        if(companyId == null || companyId <= 0){
+            return AjaxResult.error("参数缺失companyId");
+        }
+        return aiSipCallUserService.getCompanyUnBindExtnum(companyId);
+    }
+    /**
+     * 总后台使用获取未绑定的分机列表
+     */
+    @GetMapping("/getUnBindExtnum")
+    public AjaxResult getUnBindExtnum()
+    {
+        return aiSipCallUserService.getUnBindExtnum();
+    }
+    /**
+     * 总后台查询登录用户的sip用户信息列表
+     */
+    @GetMapping("/myCallUser")
+    public AjaxResult myCallUser(AiSipCallUser aiSipCallUser)
+    {
+        LoginUser user = getLoginUser();
+        aiSipCallUser.setSysUserId(user.getUser().getUserId());
+        List<AiSipCallUser> list = aiSipCallUserService.selectAiSipCallUserList(aiSipCallUser);
+        if(!list.isEmpty()){
+            return AjaxResult.success(list.get(0));
+        }else{
+            return AjaxResult.error("未创建sip角色");
+        }
+    }
+
+
+    /**
+     * 公司获取未绑定的分机列表分页
+     */
+    @GetMapping("/getUnBindExtnumPage")
+    public TableDataInfo getUnBindExtnumPage(CcExtNumVo ccExtNum)
+    {
+        return aiSipCallUserService.getUnBindExtnumPage(ccExtNum);
+    }
+    /**
+     * 公司获取分机列表分页
+     */
+    @PostMapping("/getSipExtDetail")
+    public TableDataInfo getSipExtDetail(@RequestBody CcExtNumVo ccExtNum)
+    {
+        return aiSipCallUserService.getSipExtDetail(ccExtNum);
+    }
+
+    /**
+     * 公司批量解绑分机
+     */
+    @PostMapping("/companyBatchUnbindSipExt/{companyId}")
+    public AjaxResult companyBatchUnbindSipExt(@PathVariable Long companyId,@RequestBody List<CcExtNumVo> ccExtNum)
+    {
+        return aiSipCallUserService.companyBatchUnbindSipExt(companyId,ccExtNum);
+    }
+}

+ 85 - 0
fs-admin/src/main/java/com/fs/aiSipCall/AiSipCallVoiceTtsAliyunController.java

@@ -0,0 +1,85 @@
+package com.fs.aiSipCall;
+
+import com.fs.aiSipCall.domain.AiSipCallVoiceTtsAliyun;
+import com.fs.aiSipCall.service.IAiSipCallVoiceTtsAliyunService;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+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.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * aiSIP外呼阿里云音色Controller
+ *
+ * @author fs
+ * @date 2026-03-06
+ */
+@RestController
+@RequestMapping("/his/aiSipCall/voiceTtsAliyun")
+public class AiSipCallVoiceTtsAliyunController extends BaseController
+{
+    @Autowired
+    private IAiSipCallVoiceTtsAliyunService aiSipCallVoiceTtsAliyunService;
+
+    /**
+     * 查询aiSIP外呼阿里云音色列表
+     */
+    @GetMapping("/remoteList")
+    public TableDataInfo remoteList(AiSipCallVoiceTtsAliyun aiSipCallVoiceTtsAliyun)
+    {
+        return aiSipCallVoiceTtsAliyunService.remoteList(aiSipCallVoiceTtsAliyun);
+    }
+
+    /**
+     * 查询全部ttsprovider列表
+     */
+    @GetMapping("/allTtsProvider")
+    public AjaxResult allTtsProvider()
+    {
+        return aiSipCallVoiceTtsAliyunService.allTtsProvider();
+    }
+    /**
+     * 查询全部asrprovider列表
+     */
+    @GetMapping("/allAsrProvider")
+    public AjaxResult allAsrProvider()
+    {
+        return aiSipCallVoiceTtsAliyunService.allAsrProvider();
+    }
+    /**
+     * 根据tts厂商获取支持语言
+     */
+    @GetMapping("/getTtsLanguages")
+    public AjaxResult getTtsLanguages(@RequestParam String voiceSource)
+    {
+        return aiSipCallVoiceTtsAliyunService.getTtsLanguages(voiceSource);
+    }
+    /**
+     * 根据asr厂商获取语言
+     */
+    @GetMapping("/getAsrLanguages")
+    public AjaxResult getAsrLanguages(@RequestParam String asrProvider)
+    {
+        return aiSipCallVoiceTtsAliyunService.getAsrLanguages(asrProvider);
+    }
+
+    /**
+     * 根据asr厂商和语言获取asr模型列表
+     */
+    @GetMapping("/getAsrModels")
+    public AjaxResult getAsrModels(@RequestParam String asrProvider, @RequestParam String asrLanguageCode)
+    {
+        return aiSipCallVoiceTtsAliyunService.getAsrModels(asrProvider,asrLanguageCode);
+    }
+    /**
+     * 根据tts厂商和语言获取asr模型列表
+     */
+    @GetMapping("/getTtsModels")
+    public AjaxResult getTtsModels(@RequestParam String voiceSource, @RequestParam String ttsLanguageCode)
+    {
+        return aiSipCallVoiceTtsAliyunService.getTtsModels(voiceSource,ttsLanguageCode);
+    }
+}

+ 79 - 0
fs-admin/src/main/java/com/fs/aiSipCall/sipTask/sipTask.java

@@ -0,0 +1,79 @@
+package com.fs.aiSipCall.sipTask;
+
+import com.fs.aiSipCall.service.AsyncAddSipCallRecordService;
+import com.fs.aiSipCall.service.IAiSipAutoCallRulesService;
+import com.fs.aiSipCall.utils.DateUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import java.util.Calendar;
+
+/**
+ * @Author:peicj
+ * @Description: sip外呼任务
+ * @Date:2026/4/17 11:30
+ */
+@Component("sipTask")
+public class sipTask {
+
+    @Autowired
+    private AsyncAddSipCallRecordService asyncAddSipCallRecordService;
+    @Autowired
+    private IAiSipAutoCallRulesService aiSipAutoCallRulesService;
+
+    /**
+     * 同步自动外呼电话记录任务(3分钟打电话时长预估)
+     * 如果当前时间是13:05:00,则同步13:01:00-13:02:00的通话记录
+     */
+    public void synchronousCall() {
+        executeSyncTask(-4, -3, "synchronousCall");
+    }
+
+    /**
+     * 补偿机制:每一个小时将上一个小时的待呼叫的记录同步一遍
+     * 如果当前时间是13:00:00,则同步11:00:00-12:00:00的通话记录
+     */
+    public void synchronousCallCompensation() {
+        executeSyncTask(-2, -1, "synchronousCallCompensation", Calendar.HOUR_OF_DAY);
+    }
+
+    /**
+     * 兜底机制:每天凌晨去同步前一天的通话记录
+     * 如果当前时间是04:00:00,则同步前一天的通话记录
+     */
+    public void synchronousCallFallback() {
+        Calendar calendar = Calendar.getInstance();
+        calendar.add(Calendar.DAY_OF_MONTH, -3);
+        calendar.set(Calendar.HOUR_OF_DAY, 23);
+        calendar.set(Calendar.MINUTE, 59);
+        calendar.set(Calendar.SECOND, 59);
+        String endTime = DateUtils.parseDateToStr("yyyy-MM-dd HH:mm:ss", calendar.getTime());
+        calendar.set(Calendar.HOUR_OF_DAY, 0);
+        calendar.set(Calendar.MINUTE, 0);
+        calendar.set(Calendar.SECOND, 0);
+        String startTime = DateUtils.parseDateToStr("yyyy-MM-dd HH:mm:ss", calendar.getTime());
+        asyncAddSipCallRecordService.getAutomaticCallRecord(startTime, endTime, "synchronousCallFallback");
+    }
+
+    /**
+     * 委托屏蔽规则服务执行停启校验。
+     */
+    public String verifySipCallStopAndStart() {
+        return aiSipAutoCallRulesService.verifySipCallStopAndStart();
+    }
+
+    private void executeSyncTask(int startOffset, int endOffset, String taskName) {
+        executeSyncTask(startOffset, endOffset, taskName, Calendar.MINUTE);
+    }
+
+    private void executeSyncTask(int startOffset, int endOffset, String taskName, int timeUnit) {
+        Calendar startCalendar = Calendar.getInstance();
+        startCalendar.add(timeUnit, startOffset);
+        String startTime = DateUtils.parseDateToStr("yyyy-MM-dd HH:mm:ss", startCalendar.getTime());
+
+        Calendar endCalendar = Calendar.getInstance();
+        endCalendar.add(timeUnit, endOffset);
+        String endTime = DateUtils.parseDateToStr("yyyy-MM-dd HH:mm:ss", endCalendar.getTime());
+        asyncAddSipCallRecordService.getAutomaticCallRecord(startTime, endTime, taskName);
+    }
+}

+ 137 - 0
fs-admin/src/main/java/com/fs/his/controller/AdProfitDetailController.java

@@ -0,0 +1,137 @@
+package com.fs.his.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.domain.R;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.poi.ExcelUtil;
+import com.fs.his.domain.AdProfitDetail;
+import com.fs.his.dto.AdProfitDetailDto;
+import com.fs.his.param.AdProfitDetailStatisticsParam;
+import com.fs.his.service.IAdProfitDetailService;
+import com.fs.his.vo.AdProfitDetailStatisticsVo;
+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 2025-11-27
+ */
+@RestController
+@RequestMapping("/adv/profit")
+public class AdProfitDetailController extends BaseController
+{
+    @Autowired
+    private IAdProfitDetailService adProfitDetailService;
+
+    /**
+     * 查询广告分佣列表
+     */
+    @PreAuthorize("@ss.hasPermi('adv:profit:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(AdProfitDetailDto adProfitDetailDto)
+    {
+        startPage();
+        List<AdProfitDetailDto> list = adProfitDetailService.selectAdProfitDetailList(adProfitDetailDto);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出广告分佣列表
+     */
+    @PreAuthorize("@ss.hasPermi('adv:profit:export')")
+    @Log(title = "广告分佣", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(AdProfitDetailDto adProfitDetailDto)
+    {
+        List<AdProfitDetailDto> list = adProfitDetailService.selectAdProfitDetailList(adProfitDetailDto);
+        ExcelUtil<AdProfitDetailDto> util = new ExcelUtil<AdProfitDetailDto>(AdProfitDetailDto.class);
+        return util.exportExcel(list, "广告分佣数据");
+    }
+
+    /**
+     * 获取广告分佣详细信息
+     */
+    @PreAuthorize("@ss.hasPermi('adv:profit:query')")
+    @GetMapping(value = "/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Long id)
+    {
+        return AjaxResult.success(adProfitDetailService.selectAdProfitDetailById(id));
+    }
+
+    /**
+     * 新增广告分佣
+     */
+    @PreAuthorize("@ss.hasPermi('adv:profit:add')")
+    @Log(title = "广告分佣", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody AdProfitDetail adProfitDetail)
+    {
+        return toAjax(adProfitDetailService.insertAdProfitDetail(adProfitDetail));
+    }
+
+    /**
+     * 修改广告分佣
+     */
+    @PreAuthorize("@ss.hasPermi('adv:profit:edit')")
+    @Log(title = "广告分佣", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody AdProfitDetail adProfitDetail)
+    {
+        return toAjax(adProfitDetailService.updateAdProfitDetail(adProfitDetail));
+    }
+
+    /**
+     * 删除广告分佣
+     */
+    @PreAuthorize("@ss.hasPermi('adv:profit:remove')")
+    @Log(title = "广告分佣", businessType = BusinessType.DELETE)
+	@DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids)
+    {
+        return toAjax(adProfitDetailService.deleteAdProfitDetailByIds(ids));
+    }
+
+    /**
+     * 查询用户可提现 和 已提现总金额
+     */
+//    @PreAuthorize("@ss.hasPermi('adv:profit:remove')")
+//    @Log(title = "广告分佣", businessType = BusinessType.DELETE)
+    @GetMapping("/getWithFinishAndMayWithdraw")
+    public R getWithFinishAndMayWithdraw()
+    {
+        return adProfitDetailService.getWithFinishAndMayWithdraw();
+    }
+
+    /**
+     * 统计
+     */
+    @PreAuthorize("@ss.hasPermi('his:statistics:commission')")
+    @GetMapping("/statistics")
+    public R statistics(AdProfitDetailStatisticsParam param)
+    {
+        List<AdProfitDetailStatisticsVo> list = adProfitDetailService.statisticsList(param);
+        return R.ok().put("list",list);
+    }
+
+    /**
+     * 导出统计
+     */
+    @PreAuthorize("@ss.hasPermi('his:statistics:commissionExport')")
+    @GetMapping("/exportCommission")
+    @Log(title = "广告分佣统计导出", businessType = BusinessType.EXPORT)
+    public AjaxResult exportCommission(AdProfitDetailStatisticsParam param)
+    {
+        List<AdProfitDetailStatisticsVo> list = adProfitDetailService.statisticsList(param);
+
+        ExcelUtil<AdProfitDetailStatisticsVo> util = new ExcelUtil<AdProfitDetailStatisticsVo>(AdProfitDetailStatisticsVo.class);
+        return util.exportExcel(list, "广告分佣统计数据");
+    }
+}

+ 99 - 0
fs-admin/src/main/java/com/fs/his/controller/FsConsecutiveWithdrawRecordController.java

@@ -0,0 +1,99 @@
+package com.fs.his.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.poi.ExcelUtil;
+import com.fs.his.domain.FsConsecutiveWithdrawRecord;
+import com.fs.his.param.FsConsecutiveWithdrawRecordParam;
+import com.fs.his.service.IFsConsecutiveWithdrawRecordService;
+import com.fs.his.vo.FsConsecutiveWithdrawRecordVo;
+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-02-04
+ */
+@RestController
+@RequestMapping("/his/consecutiveWithdrawRecord")
+public class FsConsecutiveWithdrawRecordController extends BaseController
+{
+    @Autowired
+    private IFsConsecutiveWithdrawRecordService fsConsecutiveWithdrawRecordService;
+
+    /**
+     * 查询连续提现记录列表
+     */
+    @PreAuthorize("@ss.hasPermi('his:consecutiveWithdrawRecord:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(FsConsecutiveWithdrawRecordParam param)
+    {
+        startPage();
+        List<FsConsecutiveWithdrawRecordVo> list = fsConsecutiveWithdrawRecordService.selectFsConsecutiveWithdrawRecordList(param);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出连续提现记录列表
+     */
+    @PreAuthorize("@ss.hasPermi('his:consecutiveWithdrawRecord:export')")
+    @Log(title = "连续提现记录", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(FsConsecutiveWithdrawRecordParam fsConsecutiveWithdrawRecord)
+    {
+        List<FsConsecutiveWithdrawRecordVo> list = fsConsecutiveWithdrawRecordService.selectFsConsecutiveWithdrawRecordList(fsConsecutiveWithdrawRecord);
+        ExcelUtil<FsConsecutiveWithdrawRecordVo> util = new ExcelUtil<FsConsecutiveWithdrawRecordVo>(FsConsecutiveWithdrawRecordVo.class);
+        return util.exportExcel(list, "连续提现记录数据");
+    }
+
+    /**
+     * 获取连续提现记录详细信息
+     */
+    @PreAuthorize("@ss.hasPermi('his:consecutiveWithdrawRecord:query')")
+    @GetMapping(value = "/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Long id)
+    {
+        return AjaxResult.success(fsConsecutiveWithdrawRecordService.selectFsConsecutiveWithdrawRecordById(id));
+    }
+
+    /**
+     * 新增连续提现记录
+     */
+    @PreAuthorize("@ss.hasPermi('his:consecutiveWithdrawRecord:add')")
+    @Log(title = "连续提现记录", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody FsConsecutiveWithdrawRecord fsConsecutiveWithdrawRecord)
+    {
+        return toAjax(fsConsecutiveWithdrawRecordService.insertFsConsecutiveWithdrawRecord(fsConsecutiveWithdrawRecord));
+    }
+
+    /**
+     * 修改连续提现记录
+     */
+    @PreAuthorize("@ss.hasPermi('his:consecutiveWithdrawRecord:edit')")
+    @Log(title = "连续提现记录", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody FsConsecutiveWithdrawRecord fsConsecutiveWithdrawRecord)
+    {
+        return toAjax(fsConsecutiveWithdrawRecordService.updateFsConsecutiveWithdrawRecord(fsConsecutiveWithdrawRecord));
+    }
+
+    /**
+     * 删除连续提现记录
+     */
+    @PreAuthorize("@ss.hasPermi('his:consecutiveWithdrawRecord:remove')")
+    @Log(title = "连续提现记录", businessType = BusinessType.DELETE)
+	@DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids)
+    {
+        return toAjax(fsConsecutiveWithdrawRecordService.deleteFsConsecutiveWithdrawRecordByIds(ids));
+    }
+}

+ 8 - 0
fs-admin/src/main/java/com/fs/his/controller/FsDoctorController.java

@@ -14,6 +14,7 @@ import com.fs.his.utils.RedisCacheUtil;
 import com.fs.his.vo.*;
 import com.github.pagehelper.PageHelper;
 import com.github.pagehelper.PageInfo;
+import io.swagger.annotations.ApiOperation;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.*;
@@ -337,5 +338,12 @@ public class FsDoctorController extends BaseController
         return getDataTable(list);
     }
 
+    @ApiOperation("获取公司处方医生下拉框列表")
+    @GetMapping("/getCompanyDoctorSelect")
+    public AjaxResult getCompanyDoctorSelect() {
+        List<FsDoctorNamebyId> fsDoctorVO = fsDoctorService.selectFsDoctorVO();
+        return AjaxResult.success(fsDoctorVO);
+    }
+
 
 }

+ 69 - 0
fs-admin/src/main/java/com/fs/his/controller/FsIntegralRedPacketLogController.java

@@ -0,0 +1,69 @@
+package com.fs.his.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.poi.ExcelUtil;
+import com.fs.his.param.FsIntegralRedPacketLogParam;
+import com.fs.his.service.IFsIntegralRedPacketLogService;
+import com.fs.his.vo.FsIntegralRedPacketLogVo;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/**
+ * 积分佣金红包记录Controller
+ *
+ * @author fs
+ * @date 2026-01-22
+ */
+@RestController
+@RequestMapping("/his/integralRedPacketLog")
+public class FsIntegralRedPacketLogController extends BaseController
+{
+    @Autowired
+    private IFsIntegralRedPacketLogService fsIntegralRedPacketLogService;
+
+    /**
+     * 查询积分佣金红包记录列表
+     */
+    @GetMapping("/list")
+    public TableDataInfo list(FsIntegralRedPacketLogParam fsIntegralRedPacketLog)
+    {
+        startPage();
+        List<FsIntegralRedPacketLogVo> list = fsIntegralRedPacketLogService.getList(fsIntegralRedPacketLog);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出积分佣金红包记录列表
+     */
+    @PreAuthorize("@ss.hasPermi('his:integralRedPacketLog:export')")
+    @Log(title = "积分佣金红包记录", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(FsIntegralRedPacketLogParam fsIntegralRedPacketLog)
+    {
+        List<FsIntegralRedPacketLogVo> list = fsIntegralRedPacketLogService.getList(fsIntegralRedPacketLog);
+        ExcelUtil<FsIntegralRedPacketLogVo> util = new ExcelUtil<FsIntegralRedPacketLogVo>(FsIntegralRedPacketLogVo.class);
+        return util.exportExcel(list, "积分佣金红包记录数据");
+    }
+
+    /**
+     * 获取积分佣金红包记录详细信息
+     */
+    @PreAuthorize("@ss.hasPermi('his:integralRedPacketLog:query')")
+    @GetMapping(value = "/{logId}")
+    public AjaxResult getInfo(@PathVariable("logId") Long logId)
+    {
+        return AjaxResult.success(fsIntegralRedPacketLogService.selectFsIntegralRedPacketLogByLogId(logId));
+    }
+
+
+}

+ 12 - 1
fs-admin/src/main/java/com/fs/his/controller/FsUserController.java

@@ -26,6 +26,7 @@ import com.fs.his.enums.FsUserIntegralLogTypeEnum;
 import com.fs.his.mapper.FsUserMapper;
 import com.fs.his.param.FsUserAddIntegralTemplateParam;
 import com.fs.his.param.FsUserAddPointsParam;
+import com.fs.his.param.FsUserDisabledUsersParam;
 import com.fs.his.param.FsUserParam;
 import com.fs.his.service.IFsUserIntegralLogsService;
 import com.fs.his.utils.PhoneUtil;
@@ -408,7 +409,7 @@ public class FsUserController extends BaseController
         integralTemplateParam.setUserId(param.getUserId());
         integralTemplateParam.setLogType(FsUserIntegralLogTypeEnum.TYPE_23.getValue());
         integralTemplateParam.setBusinessId(SecurityUtils.getUserId().toString());
-        integralTemplateParam.setPoints(param.getPoint());
+        integralTemplateParam.setPoints(Long.valueOf(param.getPoint()));
         integralTemplateParam.setRemark(param.getRemark());
         return userIntegralLogsService.addIntegralTemplate(integralTemplateParam);
     }
@@ -441,4 +442,14 @@ public class FsUserController extends BaseController
         return getDataTable(list);
     }
 
+    /**
+     * 修改用户积分
+     */
+    @PreAuthorize("@ss.hasPermi('his:user:disabledUsers')")
+    @Log(title = "批量禁用用户", businessType = BusinessType.UPDATE)
+    @PostMapping("/disabledUsers")
+    public AjaxResult disabledUsers(@RequestBody FsUserDisabledUsersParam param)
+    {
+        return toAjax(fsUserService.disabledUsers(param));
+    }
 }

+ 38 - 0
fs-admin/src/main/java/com/fs/his/controller/FsUserIntegralLogsController.java

@@ -3,14 +3,17 @@ package com.fs.his.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.domain.R;
 import com.fs.common.core.page.TableDataInfo;
 import com.fs.common.enums.BusinessType;
 import com.fs.common.utils.poi.ExcelUtil;
 import com.fs.his.domain.FsUserIntegralLogs;
 import com.fs.his.param.FsUserIntegralLogsParam;
+import com.fs.his.param.FsUserIntegralLogsStatisticsParam;
 import com.fs.his.service.IFsUserIntegralLogsService;
 import com.fs.his.vo.FsTestReportListVO;
 import com.fs.his.vo.FsUserIntegralLogsListVO;
+import com.fs.his.vo.FsUserIntegralLogsStatisticsVo;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
@@ -109,4 +112,39 @@ public class FsUserIntegralLogsController extends BaseController
     {
         return toAjax(fsUserIntegralLogsService.deleteFsUserIntegralLogsByIds(ids));
     }
+
+    /**
+     * 统计
+     */
+    @PreAuthorize("@ss.hasPermi('his:statistics:integral')")
+    @GetMapping("/statistics")
+    public R statistics(FsUserIntegralLogsStatisticsParam param)
+    {
+        if (param.getType() != null && param.getType().equals(2)) {
+            if (param.getUserId() == null || param.getUserId() < 0L) {
+                return R.error("用户维度需要输入用户id");
+            }
+        }
+        List<FsUserIntegralLogsStatisticsVo> list = fsUserIntegralLogsService.statisticsList(param);
+        return R.ok().put("list",list);
+    }
+
+    /**
+     * 导出统计
+     */
+    @PreAuthorize("@ss.hasPermi('his:statistics:commissionExport')")
+    @GetMapping("/exportCommission")
+    @Log(title = "积分统计导出", businessType = BusinessType.EXPORT)
+    public AjaxResult exportCommission(FsUserIntegralLogsStatisticsParam param)
+    {
+        if (param.getType() != null && param.getType().equals(2)) {
+            if (param.getUserId() == null || param.getUserId() < 0L) {
+                return AjaxResult.error("用户维度需要输入用户id");
+            }
+        }
+        List<FsUserIntegralLogsStatisticsVo> list = fsUserIntegralLogsService.statisticsList(param);
+
+        ExcelUtil<FsUserIntegralLogsStatisticsVo> util = new ExcelUtil<FsUserIntegralLogsStatisticsVo>(FsUserIntegralLogsStatisticsVo.class);
+        return util.exportExcel(list, "积分统计数据");
+    }
 }

+ 9 - 0
fs-admin/src/main/java/com/fs/his/task/Task.java

@@ -278,6 +278,8 @@ public class Task {
     @Autowired
     private IFsStoreOrderScrmService  fsStoreOrderScrmService;
 
+    @Autowired
+    private IFsConsecutiveWithdrawRecordService fsConsecutiveWithdrawRecordService;
 
     /**
      * 快递信息同步
@@ -2271,4 +2273,11 @@ public class Task {
             }
         }
     }
+
+    /**
+     * 风控连续提现用户 每天执行一次
+     */
+    public void checkConsecutiveWithdrawUsers(){
+        fsConsecutiveWithdrawRecordService.checkConsecutiveWithdrawUsers();
+    }
 }

+ 1 - 1
fs-company/src/main/java/com/fs/company/controller/aiSipCall/AiSipCallBizGroupController.java

@@ -16,7 +16,7 @@ import java.util.List;
 
 /**
  * aiSIP外呼技能组Controller
- * 
+ *
  * @author fs
  * @date 2026-03-06
  */

+ 10 - 11
fs-company/src/main/java/com/fs/company/controller/aiSipCall/AiSipCallGatewayController.java

@@ -17,7 +17,7 @@ import java.util.List;
 
 /**
  * aiSIP外呼网关Controller
- * 
+ *
  * @author fs
  * @date 2026-03-06
  */
@@ -31,9 +31,8 @@ public class AiSipCallGatewayController extends BaseController
     /**
      * 查询aiSIP外呼网关列表
      */
-    @PreAuthorize("@ss.hasPermi('company:aiSipCall:gateway:list')")
     @PostMapping("/remoteList")
-    public TableDataInfo list(@RequestBody AiSipCallGateway aiSipCallGateway)
+    public TableDataInfo remoteList(@RequestBody AiSipCallGateway aiSipCallGateway)
     {
         return aiSipCallGatewayService.remoteList(aiSipCallGateway);
     }
@@ -41,14 +40,14 @@ public class AiSipCallGatewayController extends BaseController
     /**
      * 查询aiSIP外呼网关列表
      */
-//    @PreAuthorize("@ss.hasPermi('company:aiSipCall:gateway:list')")
-//    @GetMapping("/list")
-//    public TableDataInfo list(AiSipCallGateway aiSipCallGateway)
-//    {
-//        startPage();
-//        List<AiSipCallGateway> list = aiSipCallGatewayService.selectAiSipCallGatewayList(aiSipCallGateway);
-//        return getDataTable(list);
-//    }
+    @PreAuthorize("@ss.hasPermi('company:aiSipCall:gateway:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(AiSipCallGateway aiSipCallGateway)
+    {
+        startPage();
+        List<AiSipCallGateway> list = aiSipCallGatewayService.selectAiSipCallGatewayList(aiSipCallGateway);
+        return getDataTable(list);
+    }
 
     /**
      * 导出aiSIP外呼网关列表

+ 1 - 1
fs-company/src/main/java/com/fs/company/controller/aiSipCall/AiSipCallLlmAgentAccountController.java

@@ -16,7 +16,7 @@ import java.util.List;
 
 /**
  * aiSIP外呼大模型Controller
- * 
+ *
  * @author fs
  * @date 2026-03-06
  */

+ 69 - 41
fs-company/src/main/java/com/fs/company/controller/aiSipCall/AiSipCallOutboundCdrController.java

@@ -2,37 +2,43 @@ package com.fs.company.controller.aiSipCall;
 
 import com.fs.aiSipCall.domain.AiSipCallOutboundCdr;
 import com.fs.aiSipCall.domain.CcCustInfo;
-import com.fs.aiSipCall.param.ApiCallRecordByUuidQueryParams;
 import com.fs.aiSipCall.service.IAiSipCallOutboundCdrService;
-import com.fs.aiSipCall.utils.DateUtils;
+import com.fs.aiSipCall.service.ManualPullSyncSipRecordService;
+import com.fs.aiSipCall.utils.StringUtils;
 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.common.utils.ServletUtils;
 import com.fs.common.utils.poi.ExcelUtil;
-import lombok.extern.slf4j.Slf4j;
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.service.TokenService;
+import com.fs.his.utils.PhoneUtil;
+import com.fs.his.utils.RandomUtil;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 
-import java.util.Date;
 import java.util.List;
+import java.util.Map;
 
 /**
  * aiSIP手动外呼通话记录Controller
- * 
+ *
  * @author fs
  * @date 2026-03-19
  */
-@Slf4j
 @RestController
 @RequestMapping("/company/aiSipCall/outboundCdr")
 public class AiSipCallOutboundCdrController extends BaseController
 {
     @Autowired
     private IAiSipCallOutboundCdrService aiSipCallOutboundCdrService;
+    @Autowired
+    private TokenService tokenService;
+    @Autowired
+    private ManualPullSyncSipRecordService manualPullSyncSipRecordService;
 
     /**
      * 查询aiSIP手动外呼通话记录列表
@@ -41,15 +47,12 @@ public class AiSipCallOutboundCdrController extends BaseController
     @GetMapping("/list")
     public TableDataInfo list(AiSipCallOutboundCdr aiSipCallOutboundCdr)
     {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        aiSipCallOutboundCdr.setCompanyId(loginUser.getUser().getCompanyId());
+        aiSipCallOutboundCdr.setSourceType("0");
         startPage();
         List<AiSipCallOutboundCdr> list = aiSipCallOutboundCdrService.selectAiSipCallOutboundCdrList(aiSipCallOutboundCdr);
-        list.forEach(data -> {
-            data.setStartTimeStr(DateUtils.parseDateToStr("yyyy-MM-dd HH:mm:ss", new Date(data.getStartTime())));
-            data.setAnsweredTimeStr(DateUtils.parseDateToStr("yyyy-MM-dd HH:mm:ss", new Date(data.getAnsweredTime())));
-            data.setEndTimeStr(DateUtils.parseDateToStr("yyyy-MM-dd HH:mm:ss", new Date(data.getEndTime())));
-            data.setTimeLenSec(DateUtils.formatTimeLength(data.getTimeLen()/1000));
-            data.setTimeLenValidStr(DateUtils.formatTimeLength(data.getTimeLenValid()/1000));
-        });
+
         return getDataTable(list);
     }
 
@@ -61,6 +64,9 @@ public class AiSipCallOutboundCdrController extends BaseController
     @GetMapping("/export")
     public AjaxResult export(AiSipCallOutboundCdr aiSipCallOutboundCdr)
     {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        aiSipCallOutboundCdr.setCompanyId(loginUser.getUser().getCompanyId());
+        aiSipCallOutboundCdr.setSourceType("0");
         List<AiSipCallOutboundCdr> list = aiSipCallOutboundCdrService.selectAiSipCallOutboundCdrList(aiSipCallOutboundCdr);
         ExcelUtil<AiSipCallOutboundCdr> util = new ExcelUtil<>(AiSipCallOutboundCdr.class);
         return util.exportExcel(list, "aiSIP手动外呼通话记录数据");
@@ -114,10 +120,15 @@ public class AiSipCallOutboundCdrController extends BaseController
      * @param phoneNum 手机号
      * @param callType 类型  1呼入 2外呼
      * @param uuid  通话uuid
+     * @param dialMode  明文plaintext  密文encrypted
      */
     @GetMapping("/getCustCommunicationInfo")
-    public AjaxResult getCustCommunicationInfo(@RequestParam("phoneNum") String phoneNum, @RequestParam("callType") Integer callType, @RequestParam("uuid") String uuid) {
-        return aiSipCallOutboundCdrService.getCustCommunicationInfo(phoneNum,callType,uuid);
+    public AjaxResult getCustCommunicationInfo(@RequestParam("phoneNum") String phoneNum,
+                                               @RequestParam("callType") Integer callType,
+                                               @RequestParam("uuid") String uuid,
+                                               @RequestParam(value = "dialMode", required = false) String dialMode
+    ) {
+        return aiSipCallOutboundCdrService.getCustCommunicationInfo(phoneNum,callType,uuid,dialMode);
     }
     /**
      * 新增保存手动外呼沟通记录
@@ -129,39 +140,56 @@ public class AiSipCallOutboundCdrController extends BaseController
         return aiSipCallOutboundCdrService.addCustcallrecord(ccCustInfo);
     }
 
-
     /**
-     * 手动拉取今天aiSIP外呼通话记录列表
+     * 前端传输加密和随机字符串进行解密
      */
-    @PreAuthorize("@ss.hasPermi('company:aiSipCall:outboundCdr:manualPull')")
-    @GetMapping("/manualPull")
-    public AjaxResult manualPull()
+    @PostMapping("/encryptMobile")
+    @ResponseBody
+    public AjaxResult encryptMobile(@RequestBody Map<String,String> request)
     {
-        log.info("开始拉取 人工外呼通话记录");
-        long strat = System.currentTimeMillis();
-        String msg = aiSipCallOutboundCdrService.scheduledGetCallRecord().join();
-        log.info("结束拉取 人工外呼通话记录,耗时:{}ms,结果:{}",System.currentTimeMillis()-strat,msg);
-        return AjaxResult.success(msg);
+        String combined = request.get("data"); // 前端传来的 "原始字符串+随机数"
+        // 随机数为最后6位
+        int randomLen = 6;
+        String original = combined.substring(0, combined.length() - randomLen);
+        try {
+            //1.先解密再加密
+            String decrypted = PhoneUtil.decryptPhone(original);
+            //测试数据
+//            String decrypted = original;
+            String encrypted = PhoneUtil.xorEncrypt(decrypted);
+            return AjaxResult.success("获取成功",encrypted + RandomUtil.generateRandomCode()) ;
+        } catch (Exception e) {
+            return AjaxResult.error("获取加密手机号失败:" + e);
+        }
     }
 
-
     /**
-     * 同步aiSIP外呼通话记录
+     * 根据UUID同步手动外呼通话记录
      */
-    @PostMapping("/syncByUuid")
-    public AjaxResult syncByUuid(@RequestBody ApiCallRecordByUuidQueryParams req) {
-        if (req == null || StringUtils.isBlank(req.getUuid())) {
-            return AjaxResult.error("uuid不能为空");
-        }
-        if (StringUtils.isBlank(req.getCallType())) {
-            req.setCallType("03");
-        }
-
-        int rows = aiSipCallOutboundCdrService.syncByUuid(req);
-        if (rows > 0) {
-            return AjaxResult.success("同步成功");
+    @PostMapping("/callEndSyncByUuid")
+    @ResponseBody
+    public void callEndSyncByUuid(@RequestBody AiSipCallOutboundCdr request)
+    {
+        if(StringUtils.isNotEmpty(request.getUuid())){
+            LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+            request.setSourceType("0");
+            request.setCompanyId(loginUser.getCompany().getCompanyId());
+            request.setCompanyUserId(loginUser.getUser().getUserId());
+            request.setCompanyUserName(loginUser.getUser().getUserName());
+            aiSipCallOutboundCdrService.callEndSyncByUuid(request);
+        }else{
+            throw new RuntimeException("获取手动外呼通话记录同步失败,uuid为空");
         }
-        return AjaxResult.error("未查到对应通话记录或同步失败");
     }
 
+    /**
+     * 手动同步人工外呼记录(处理数据用)
+     */
+    @PreAuthorize("@ss.hasPermi('company:aiSipCall:outboundCdr:manualPull')")
+    @GetMapping("/manualPull")
+    @ResponseBody
+    public void manualPull()
+    {
+        manualPullSyncSipRecordService.manualPull("2026-06-07 00:00:00","2026-06-09 00:00:00");
+    }
 }

+ 382 - 57
fs-company/src/main/java/com/fs/company/controller/aiSipCall/AiSipCallPhoneController.java

@@ -1,26 +1,51 @@
 package com.fs.company.controller.aiSipCall;
 
+import com.alibaba.fastjson.JSONObject;
+import com.fs.aiSipCall.RemoteCommon;
 import com.fs.aiSipCall.domain.AiSipCallPhone;
-import com.fs.aiSipCall.domain.AiSipCallTask;
+import com.fs.aiSipCall.param.StatisticalManagementParam;
+import com.fs.aiSipCall.service.AsyncAddSipCallRecordService;
+import com.fs.aiSipCall.service.IAiSipCallOutboundCdrService;
 import com.fs.aiSipCall.service.IAiSipCallPhoneService;
-import com.fs.aiSipCall.service.IAiSipCallTaskService;
 import com.fs.aiSipCall.utils.DateUtils;
+import com.fs.aiSipCall.vo.AiSipCallPhoneInSopVo;
+import com.fs.aiSipCall.vo.StatisticalManagementVo;
 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.common.utils.ServletUtils;
 import com.fs.common.utils.poi.ExcelUtil;
-import lombok.extern.slf4j.Slf4j;
+import com.fs.company.domain.CompanyUser;
+import com.fs.company.mapper.CompanyDeptMapper;
+import com.fs.company.service.ICompanyUserService;
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.service.TokenService;
+import com.fs.his.vo.OptionsVO;
+import com.fs.qw.domain.QwUser;
+import com.fs.qw.dto.QwUserKeyDTO;
+import com.fs.qw.mapper.QwUserMapper;
+import com.fs.qw.service.IQwUserService;
+import com.fs.sop.domain.QwSop;
+import com.fs.sop.params.QwSopLogsParam;
+import com.fs.sop.vo.QwSopLogsListCVO;
+import com.fs.voice.utils.StringUtil;
+import com.github.pagehelper.PageHelper;
+import org.apache.commons.lang3.StringUtils;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.core.io.ByteArrayResource;
+import org.springframework.core.io.Resource;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.ResponseEntity;
 import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.util.CollectionUtils;
 import org.springframework.web.bind.annotation.*;
 
-import java.util.Date;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.*;
+import java.util.stream.Collectors;
 
 /**
  * aiSIP外呼通话记录Controller
@@ -28,7 +53,6 @@ import java.util.Map;
  * @author fs
  * @date 2026-03-06
  */
-@Slf4j
 @RestController
 @RequestMapping("/company/aiSipCall/phone")
 public class AiSipCallPhoneController extends BaseController
@@ -36,7 +60,19 @@ public class AiSipCallPhoneController extends BaseController
     @Autowired
     private IAiSipCallPhoneService aiSipCallPhoneService;
     @Autowired
-    private IAiSipCallTaskService aiSipCallTaskService;
+    private AsyncAddSipCallRecordService asyncAddSipCallRecordService;
+    @Autowired
+    private TokenService tokenService;
+    @Autowired
+    private QwUserMapper qwUserMapper;
+    @Autowired
+    private IQwUserService iQwUserService;
+    @Autowired
+    private ICompanyUserService iCompanyUserService;
+    @Autowired
+    private IAiSipCallOutboundCdrService callOutboundCdrService;
+    @Autowired
+    private CompanyDeptMapper companyDeptMapper;
 
     /**
      * 查询aiSIP外呼通话记录列表
@@ -45,31 +81,15 @@ public class AiSipCallPhoneController extends BaseController
     @GetMapping("/list")
     public TableDataInfo list(AiSipCallPhone aiSipCallPhone)
     {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        aiSipCallPhone.setCompanyId(loginUser.getUser().getCompanyId());
+        aiSipCallPhone.setExecuteType("0");
         startPage();
         List<AiSipCallPhone> list = aiSipCallPhoneService.selectAiSipCallPhoneList(aiSipCallPhone);
-        Map<Long, String> batchNameMap = new HashMap<>();
-        list.forEach(data -> {
-            String batchName = batchNameMap.getOrDefault(data.getBatchId(), "");
-            if (StringUtils.isBlank(batchName)) {
-                AiSipCallTask ccCallTask = aiSipCallTaskService.selectAiSipCallTaskByRemoteBatchId(data.getBatchId());
-                if (null != ccCallTask) {
-                    batchName = ccCallTask.getBatchName();
-                    data.setBatchId(ccCallTask.getBatchId());
-                } else {
-                    batchName = "非本地任务";
-                }
-                batchNameMap.put(data.getBatchId(), batchName);
-            }
-            data.setBatchName(batchName);
-            data.setCallstatusName( AiSipCallPhone.getCallStatusName(data.getCallstatus()));
-            data.setCalloutTimeStr(DateUtils.parseDateToStr("yyyy-MM-dd HH:mm:ss", new Date(data.getCalloutTime())));
-            data.setAnsweredTimeStr(DateUtils.parseDateToStr("yyyy-MM-dd HH:mm:ss", new Date(data.getAnsweredTime())));
-            data.setCallEndTimeStr(DateUtils.parseDateToStr("yyyy-MM-dd HH:mm:ss", new Date(data.getCallEndTime())));
-            data.setTimeLenSec(DateUtils.formatTimeLength(data.getTimeLen()/1000));
-        });
         return getDataTable(list);
     }
 
+
     /**
      * 导出aiSIP外呼通话记录列表
      */
@@ -78,27 +98,10 @@ public class AiSipCallPhoneController extends BaseController
     @GetMapping("/export")
     public AjaxResult export(AiSipCallPhone aiSipCallPhone)
     {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        aiSipCallPhone.setCompanyId(loginUser.getUser().getCompanyId());
+        aiSipCallPhone.setExecuteType("0");
         List<AiSipCallPhone> list = aiSipCallPhoneService.selectAiSipCallPhoneList(aiSipCallPhone);
-        Map<Long, String> batchNameMap = new HashMap<>();
-        list.forEach(data -> {
-            String batchName = batchNameMap.getOrDefault(data.getBatchId(), "");
-            if (StringUtils.isBlank(batchName)) {
-                AiSipCallTask ccCallTask = aiSipCallTaskService.selectAiSipCallTaskByRemoteBatchId(data.getBatchId());
-                if (null != ccCallTask) {
-                    batchName = ccCallTask.getBatchName();
-                    data.setBatchId(ccCallTask.getBatchId());
-                } else {
-                    batchName = "非本地任务";
-                }
-                batchNameMap.put(data.getBatchId(), batchName);
-            }
-            data.setBatchName(batchName);
-            data.setCallstatusName( AiSipCallPhone.getCallStatusName(data.getCallstatus()));
-            data.setCalloutTimeStr(DateUtils.parseDateToStr("yyyy-MM-dd HH:mm:ss", new Date(data.getCalloutTime())));
-            data.setAnsweredTimeStr(DateUtils.parseDateToStr("yyyy-MM-dd HH:mm:ss", new Date(data.getAnsweredTime())));
-            data.setCallEndTimeStr(DateUtils.parseDateToStr("yyyy-MM-dd HH:mm:ss", new Date(data.getCallEndTime())));
-            data.setTimeLenSec(DateUtils.formatTimeLength(data.getTimeLen()/1000));
-        });
         ExcelUtil<AiSipCallPhone> util = new ExcelUtil<>(AiSipCallPhone.class);
         return util.exportExcel(list, "aiSIP外呼通话记录数据");
     }
@@ -147,17 +150,339 @@ public class AiSipCallPhoneController extends BaseController
     }
 
     /**
-     * 手动拉取今天aiSIP外呼通话记录列表
+     * 手动同步记录
      */
-    @PreAuthorize("@ss.hasPermi('company:aiSipCall:phone:manualPull')")
     @GetMapping("/manualPull")
     public AjaxResult manualPull()
     {
-        log.info("开始拉取 Ai 外呼通话记录");
-        long strat = System.currentTimeMillis();
-        String msg = aiSipCallPhoneService.scheduledGetCallRecord().join();
-        log.info("结束拉取 Ai 外呼通话记录,耗时:{}ms,结果:{}",System.currentTimeMillis()-strat,msg);
-        return AjaxResult.success(msg);
+        asyncAddSipCallRecordService.getAutomaticCallRecord("2026-06-01 00:00:00","2026-06-08 00:00:00","manualPull");
+        return success();
     }
 
+    //sip执行详情接口
+    /**
+     * 查询企业微信SOP
+     */
+    @GetMapping("/listCVO")
+    public TableDataInfo listCVO(QwSopLogsParam param)
+    {
+
+        List<String> qwUserIds=null;
+        if (!StringUtil.strIsNullOrEmpty(param.getQwUserName())){
+            qwUserIds = qwUserMapper.selectQwUserByQwUserName(param.getQwUserName(),param.getCorpId());
+        }
+        if (qwUserIds!=null&& !qwUserIds.isEmpty()){
+            param.setQwUseridList(qwUserIds);
+        }
+
+        if (!StringUtil.strIsNullOrEmpty(param.getQwUserName()) && (qwUserIds==null || qwUserIds.isEmpty())){
+            return getDataTable(new ArrayList<>());
+        }
+
+
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        param.setQwUserKeyList(getQwUserKeyList(param,loginUser));
+
+        startPage();
+        List<QwSopLogsListCVO> list = aiSipCallPhoneService.selectQwSipLogsListBySopId(param);
+
+        if (!list.isEmpty()){
+            // 2. 提取不重复的 (qwUserId, corpId) 组合
+            Set<QwUserKeyDTO> userCorpPairs = list.stream()
+                    .filter(log -> log.getQwUserid() != null && log.getCorpId() != null)
+                    .map(log -> new QwUserKeyDTO(log.getQwUserid(), log.getCorpId()))
+                    .collect(Collectors.toSet());
+
+            // 3. 批量查询 qw_user 表
+            List<QwUser> userList = iQwUserService.batchGetQwUser(new ArrayList<>(userCorpPairs));
+
+            // 4. 构建映射表
+            Map<QwUserKeyDTO, String> userInfoMap = userList.stream()
+                    .collect(Collectors.toMap(
+                            user -> new QwUserKeyDTO(user.getQwUserId(), user.getCorpId()),
+                            QwUser::getQwUserName
+                    ));
+
+            // 5. 填充用户名和真实发送时间
+            list.forEach(log -> {
+                QwUserKeyDTO key = new QwUserKeyDTO(log.getQwUserid(), log.getCorpId());
+                log.setQwUserName(userInfoMap.getOrDefault(key, "未知用户"));
+                // 如果有外呼时间,转换为Date类型后设置为真实发送时间
+                if (log.getCalloutTime() != null && log.getCalloutTime() > 0) {
+                    log.setRealSendTime(new Date(log.getCalloutTime()));
+                }
+                Map<String,Object> contentJson = new HashMap<>();
+                contentJson.put("contentType",9);
+                contentJson.put("courseId",log.getCourseId());
+                contentJson.put("videoId",log.getVideoId());
+                contentJson.put("type",log.getCourseType());
+                log.setContentJson(JSONObject.toJSONString(contentJson));
+            });
+        }
+
+        return getDataTable(list);
+    }
+
+    /**
+     * 我的自动化任务 和部门自动化任务 就只显示自己的或者部门的
+     */
+    private List<Long> getQwUserKeyList(QwSopLogsParam param, LoginUser loginUser) {
+
+        switch (param.getFilterSopType()) {
+            case 2:
+                CompanyUser companyUser = iCompanyUserService.selectCompanyUserById(loginUser.getUser().getUserId());
+                return Arrays.stream(companyUser.getQwUserId().split(","))
+                        .map(String::trim)
+                        .filter(s -> !s.isEmpty())
+                        .map(Long::valueOf)
+                        .collect(Collectors.toList());
+
+            case 3:
+                QwSop qwSop = new QwSop();
+                qwSop.setCompanyId(loginUser.getCompany().getCompanyId());
+
+                List<Long> combinedList = new ArrayList<>();
+                // 本部门
+                Long deptId = loginUser.getUser().getDeptId();
+                if (deptId != null) {
+                    combinedList.add(deptId);
+                }
+                // 本部门的下级部门
+                List<Long> deptList = companyDeptMapper.selectCompanyDeptByParentId(deptId);
+                if (!deptList.isEmpty()) {
+                    combinedList.addAll(deptList);
+                }
+                qwSop.setCuDeptIdList(combinedList);
+                qwSop.setUserType(loginUser.getUser().getUserType());
+
+                return iQwUserService.selectQwUserListByCuDeptIdList(qwSop);
+
+            default:
+                return new ArrayList<>(); // 返回空列表而不是null
+        }
+    }
+
+    /**
+     * 统计详情列表
+     * @param param 统计参数
+     *        pageNum  页码
+     *        pageSize 每页数量
+     *        startTime 开始时间 格式:yyyy-MM-dd HH:mm:ss
+     *        endTime 结束时间 格式:yyyy-MM-dd HH:mm:ss
+     *        type 统计类型 0:自动外呼记录统计 1:手动外呼记录统计
+     *        companyId 公司ID
+     *
+     */
+    @PreAuthorize("@ss.hasPermi('company:aiSipCall:phone:statisticalManagement')")
+    @PostMapping("/statisticalManagement")
+    @ResponseBody
+    public TableDataInfo statisticalManagement(@RequestBody StatisticalManagementParam param)
+    {
+        //默认查询参数填充
+        param = param == null ? new StatisticalManagementParam() : param;
+        param.setType(param.getType() == null ? 0 : param.getType());
+        param.setPageNum(param.getPageNum() == null ? 1 : param.getPageNum());
+        param.setPageSize(param.getPageSize() == null ? 10 : param.getPageSize());
+        if (StringUtils.isNotBlank(param.getStartTime())) {
+            param.setStartTimeLong(DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", param.getStartTime()).getTime());
+        }
+        if (StringUtils.isNotBlank(param.getEndTime())) {
+            param.setEndTimeLong(DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", param.getEndTime()).getTime());
+        }
+        if(param.getDeptId() != null){
+            List<Long> deptIds = companyDeptMapper.selectDeptIdsByAncestors(param.getDeptId());
+            if(!CollectionUtils.isEmpty(deptIds)){
+                param.setDeptIds(deptIds);
+            }
+        }
+        //销售只能看自己公司的数据
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        param.setCompanyId(loginUser.getUser().getCompanyId());
+        PageHelper.startPage(param.getPageNum(), param.getPageSize());
+        List<StatisticalManagementVo> list;
+        if(param.getType() == 0){
+            list = aiSipCallPhoneService.statisticalManagement(param);
+        }else if(param.getType() == 1){
+            list = callOutboundCdrService.statisticalManagement(param);
+        }else{
+            throw new RuntimeException("统计类型错误: " + param.getType());
+        }
+        //组装返回数据
+        if (!CollectionUtils.isEmpty(list)) {
+            //2.收集所有部门id,填充部门名称
+            Set<Long> deptIdSet = list.stream().map(StatisticalManagementVo::getDeptId).collect(Collectors.toSet());
+            if(!CollectionUtils.isEmpty(deptIdSet)){
+                List<OptionsVO> companyDeptList = companyDeptMapper.selectCompanyDeptListByDeptIds(deptIdSet);
+                list.forEach(item -> {
+                    item.setDeptName(companyDeptList.stream().filter(dept -> dept.getDictValue().equals(item.getDeptId())).findFirst().map(OptionsVO::getDictLabel).orElse(""));
+                    // 计算接通率(向上取整,保留两位小数)
+                    if (item.getPhoneCount() != null && item.getPhoneCount() > 0 && item.getConnectCount() != null) {
+                        double rate = item.getConnectCount() * 100.0 / item.getPhoneCount();
+                        item.setRealConnectRate(BigDecimal.valueOf(rate).setScale(2, RoundingMode.HALF_UP).doubleValue());
+                    } else {
+                        item.setRealConnectRate(0.00);
+                    }
+
+                    if(item.getTotalTimeLen() != null && item.getTotalTimeLen() > 0){
+                        item.setTotalTimeLen((long) Math.ceil(item.getTotalTimeLen() / 1000.0));
+                    }else{
+                        item.setTotalTimeLen(0L);
+                    }
+                });
+            }
+        }
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出统计详情
+     * @param param 统计参数
+     *        startTime 开始时间 格式:yyyy-MM-dd HH:mm:ss
+     *        endTime 结束时间 格式:yyyy-MM-dd HH:mm:ss
+     *        type 统计类型 0:自动外呼记录统计 1:手动外呼记录统计
+     *
+     */
+    @PreAuthorize("@ss.hasPermi('company:aiSipCall:phone:exportStatistical')")
+    @Log(title = "aiSIP外呼统计数据导出", businessType = BusinessType.EXPORT)
+    @GetMapping("/exportStatisticalManagement")
+    @ResponseBody
+    public AjaxResult exportStatisticalManagement(StatisticalManagementParam param)
+    {
+        //默认查询参数填充
+        param = param == null ? new StatisticalManagementParam() : param;
+        param.setType(param.getType() == null ? 0 : param.getType());
+        if (StringUtils.isNotBlank(param.getStartTime())) {
+            param.setStartTimeLong(DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", param.getStartTime()).getTime());
+        }
+        if (StringUtils.isNotBlank(param.getEndTime())) {
+            param.setEndTimeLong(DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", param.getEndTime()).getTime());
+        }
+        if(param.getDeptId() != null){
+            List<Long> deptIds = companyDeptMapper.selectDeptIdsByAncestors(param.getDeptId());
+            if(!CollectionUtils.isEmpty(deptIds)){
+                param.setDeptIds(deptIds);
+            }
+        }
+        //销售只能看自己公司的数据
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        param.setCompanyId(loginUser.getUser().getCompanyId());
+
+        List<StatisticalManagementVo> list;
+        if(param.getType() == 0){
+            list = aiSipCallPhoneService.statisticalManagement(param);
+        }else if(param.getType() == 1){
+            list = callOutboundCdrService.statisticalManagement(param);
+        }else{
+            throw new RuntimeException("统计类型错误: " + param.getType());
+        }
+
+        //组装返回数据
+        if (!CollectionUtils.isEmpty(list)) {
+            //限制导出数据量
+            if(list.size() > 65536){
+                return AjaxResult.error("导出数据量不能超过65536条");
+            }
+
+            //2.收集所有部门id,填充部门名称
+            Set<Long> deptIdSet = list.stream().map(StatisticalManagementVo::getDeptId).collect(Collectors.toSet());
+            if(!CollectionUtils.isEmpty(deptIdSet)){
+                List<OptionsVO> companyDeptList = companyDeptMapper.selectCompanyDeptListByDeptIds(deptIdSet);
+                list.forEach(item -> {
+                    item.setDeptName(companyDeptList.stream().filter(dept -> dept.getDictValue().equals(item.getDeptId())).findFirst().map(OptionsVO::getDictLabel).orElse(""));
+                    // 计算接通率(向上取整,保留两位小数)
+                    if (item.getPhoneCount() != null && item.getPhoneCount() > 0 && item.getConnectCount() != null) {
+                        double rate = item.getConnectCount() * 100.0 / item.getPhoneCount();
+                        item.setRealConnectRate(BigDecimal.valueOf(rate).setScale(2, RoundingMode.HALF_UP).doubleValue());
+                    } else {
+                        item.setRealConnectRate(0.00);
+                    }
+                    if(item.getTotalTimeLen() != null && item.getTotalTimeLen() > 0){
+                        item.setTotalTimeLen((long) Math.ceil(item.getTotalTimeLen() / 1000.0));
+                    }else{
+                        item.setTotalTimeLen(0L);
+                    }
+                });
+            }
+        }
+
+        ExcelUtil<StatisticalManagementVo> util = new ExcelUtil<>(StatisticalManagementVo.class);
+        if(param.getType() == 0){
+            return util.exportExcel(list, "sip自动外呼统计数据");
+        }else{
+            return util.exportExcel(list, "sip手动外呼统计数据");
+        }
+    }
+
+
+    /**
+     * 自动化任务sip执行计划详情列表
+     */
+    @PreAuthorize("@ss.hasPermi('company:aiSipCall:phone:hisSopList')")
+    @GetMapping("/companySopList")
+    public TableDataInfo companySopList(AiSipCallPhone aiSipCallPhone)
+    {
+        if(aiSipCallPhone.getPageNum() == null){
+            aiSipCallPhone.setPageNum(1);
+        }
+        if(aiSipCallPhone.getPageSize() == null){
+            aiSipCallPhone.setPageSize(10);
+        }
+        if(StringUtils.isBlank(aiSipCallPhone.getExecuteType())){
+            aiSipCallPhone.setExecuteType("0");
+        }
+        PageHelper.startPage(aiSipCallPhone.getPageNum(), aiSipCallPhone.getPageSize());
+        List<AiSipCallPhoneInSopVo> list = aiSipCallPhoneService.SopTaskList(aiSipCallPhone);
+        return getDataTable(list);
+    }
+
+    /**
+     * 播放/下载录音:直接调用对端 /recordings/files?filename=,把返回的二进制原样给前端。
+     * 对齐对端 downloadFile 实现,不做格式转换。
+     *
+     * @param aiSipCallPhone 录音相对路径(或列表里的 wavfile / recordings/files?filename=xxx)
+     */
+    @PostMapping("/playAndDownload")
+    public ResponseEntity<Resource> playAndDownload(@RequestBody AiSipCallPhone aiSipCallPhone) {
+        try {
+            String wavfile = aiSipCallPhone == null ? null : aiSipCallPhone.getWavfile();
+            if (StringUtils.isBlank(wavfile)) {
+                return ResponseEntity.badRequest().build();
+            }
+            // 下载仍用完整路径 /recordings/files?filename=xxx(RemoteCommon 拼主机)
+            byte[] bytes = RemoteCommon.downloadRecordingBytes(wavfile.trim());
+            if (bytes == null || bytes.length == 0) {
+                return ResponseEntity.notFound().build();
+            }
+            // 附件名去掉前缀 /recordings/files?filename=,勿用 Paths.get(含 ? 会报错)
+            String downloadName = extractWavFileName(wavfile);
+            return ResponseEntity.ok()
+                    .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + downloadName + "\"")
+                    .body(new ByteArrayResource(bytes));
+        } catch (Exception e) {
+            return ResponseEntity.badRequest().build();
+        }
+    }
+
+    /**
+     * 去掉前缀 /recordings/files?filename=,得到真实相对路径。
+     * 例:/recordings/files?filename=ai_call/2026/07/14/11/xxx.wav → ai_call/2026/07/14/11/xxx.wav
+     */
+    private static String extractWavFileName(String wavfile) {
+        String path = wavfile.trim();
+        String marker = "recordings/files?filename=";
+        int idx = path.indexOf(marker);
+        if (idx >= 0) {
+            path = path.substring(idx + marker.length());
+        } else if (path.startsWith("filename=")) {
+            path = path.substring("filename=".length());
+        }
+        int amp = path.indexOf('&');
+        if (amp >= 0) {
+            path = path.substring(0, amp);
+        }
+        if (path.startsWith("/")) {
+            path = path.substring(1);
+        }
+        return path;
+    }
 }

+ 22 - 24
fs-company/src/main/java/com/fs/company/controller/aiSipCall/AiSipCallTaskController.java

@@ -1,8 +1,8 @@
 package com.fs.company.controller.aiSipCall;
 
 import com.alibaba.fastjson.JSONObject;
+import com.fs.aiSipCall.domain.AiSipCallPhone;
 import com.fs.aiSipCall.domain.AiSipCallTask;
-import com.fs.aiSipCall.dto.CallTaskStatModel;
 import com.fs.aiSipCall.dto.CommonPhoneModel;
 import com.fs.aiSipCall.service.IAiSipCallTaskService;
 import com.fs.common.annotation.Log;
@@ -14,7 +14,6 @@ import com.fs.common.utils.ServletUtils;
 import com.fs.common.utils.poi.ExcelUtil;
 import com.fs.framework.security.LoginUser;
 import com.fs.framework.service.TokenService;
-import lombok.extern.slf4j.Slf4j;
 import org.apache.poi.ss.usermodel.*;
 import org.apache.poi.xssf.usermodel.XSSFWorkbook;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -38,7 +37,6 @@ import java.util.List;
  * @author fs
  * @date 2026-03-06
  */
-@Slf4j
 @RestController
 @RequestMapping("/company/aiSipCall/task")
 public class AiSipCallTaskController extends BaseController
@@ -54,23 +52,10 @@ public class AiSipCallTaskController extends BaseController
     @GetMapping("/list")
     public TableDataInfo list(AiSipCallTask aiSipCallTask)
     {
-        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
-        aiSipCallTask.setCompanyId(loginUser.getCompany().getCompanyId());
+//        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+//        aiSipCallTask.setCompanyId(loginUser.getUser().getCompanyId());
         startPage();
         List<AiSipCallTask> list = aiSipCallTaskService.selectAiSipCallTaskList(aiSipCallTask);
-        list.forEach(task -> {
-            CallTaskStatModel statModel = aiSipCallTaskService.statByBatchId(task.getBatchId());
-            task.setPhoneCount(statModel.getPhoneCount());
-            task.setCallCount(statModel.getCallCount());
-            task.setNoCallCount(statModel.getPhoneCount() - statModel.getCallCount());
-            task.setConnectCount(statModel.getConnectCount());
-            task.setNoConnectCount(statModel.getCallCount() - statModel.getConnectCount());
-            if (task.getCallCount() > 0) {
-                task.setRealConnectRate(task.getConnectCount()*1.0/task.getCallCount());
-            } else {
-                task.setRealConnectRate(0.0);
-            }
-        });
         return getDataTable(list);
     }
 
@@ -82,11 +67,11 @@ public class AiSipCallTaskController extends BaseController
     @GetMapping("/export")
     public AjaxResult export(AiSipCallTask aiSipCallTask)
     {
-        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
-        aiSipCallTask.setCompanyId(loginUser.getCompany().getCompanyId());
+//        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+//        aiSipCallTask.setCompanyId(loginUser.getUser().getCompanyId());
         List<AiSipCallTask> list = aiSipCallTaskService.selectAiSipCallTaskList(aiSipCallTask);
         ExcelUtil<AiSipCallTask> util = new ExcelUtil<>(AiSipCallTask.class);
-        return util.exportExcel(list, "aiSIP外呼任务数据");
+        return util.exportExcel(list, "aiSIP销售端外呼任务数据");
     }
 
     /**
@@ -108,7 +93,7 @@ public class AiSipCallTaskController extends BaseController
     public AjaxResult add(@RequestBody AiSipCallTask aiSipCallTask)
     {
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
-        aiSipCallTask.setCompanyId(loginUser.getCompany().getCompanyId());
+        aiSipCallTask.setCompanyId(loginUser.getUser().getCompanyId());
         aiSipCallTask.setCompanyUserId(loginUser.getUser().getUserId());
         aiSipCallTask.setCreateBy(loginUser.getUser().getUserName());
         aiSipCallTask.setCreateTime(new Date());
@@ -176,7 +161,7 @@ public class AiSipCallTaskController extends BaseController
             @RequestParam("batchId") Long batchId,
             @RequestParam("file") MultipartFile file) throws Exception {
         List<CommonPhoneModel> phoneList = parseExcelFile(file);
-        return toAjax(aiSipCallTaskService.commonImportExcel(batchId, phoneList));
+        return toAjax(aiSipCallTaskService.commonImportExcel(batchId, phoneList,buildCallPhone(),null,null));
     }
 
     /**
@@ -186,9 +171,22 @@ public class AiSipCallTaskController extends BaseController
     public AjaxResult commonImportData(
             @PathVariable("batchId") Long batchId,
             @RequestBody List<CommonPhoneModel> phoneList){
-        return toAjax(aiSipCallTaskService.commonImportExcel(batchId, phoneList));
+
+        return toAjax(aiSipCallTaskService.commonImportExcel(batchId, phoneList,buildCallPhone(),null,null));
     }
 
+    /**
+     * 构建页面导入通话基础数据
+     */
+    private AiSipCallPhone buildCallPhone() {
+        AiSipCallPhone callPhone = new AiSipCallPhone();
+        callPhone.setCallType("0");
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        callPhone.setCompanyId(loginUser.getUser().getCompanyId());
+        callPhone.setCompanyUserId(loginUser.getUser().getUserId());
+        callPhone.setCompanyUserName(loginUser.getUser().getUserName());
+        return callPhone;
+    }
     /**
      * 解析 Excel 文件为电话号码列表
      */

+ 17 - 13
fs-company/src/main/java/com/fs/company/controller/aiSipCall/AiSipCallUserController.java

@@ -21,7 +21,7 @@ import java.util.Map;
 
 /**
  * sip用户信息Controller
- * 
+ *
  * @author fs
  * @date 2026-03-13
  */
@@ -41,6 +41,8 @@ public class AiSipCallUserController extends BaseController
     @GetMapping("/list")
     public TableDataInfo list(AiSipCallUser aiSipCallUser)
     {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        aiSipCallUser.setCompanyId(loginUser.getUser().getCompanyId());
         startPage();
         List<AiSipCallUser> list = aiSipCallUserService.selectAiSipCallUserList(aiSipCallUser);
         return getDataTable(list);
@@ -54,8 +56,8 @@ public class AiSipCallUserController extends BaseController
     @GetMapping("/export")
     public AjaxResult export(AiSipCallUser aiSipCallUser)
     {
-//        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
-//        aiSipCallUser.setCompanyId(loginUser.getCompany().getCompanyId());
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        aiSipCallUser.setCompanyId(loginUser.getCompany().getCompanyId());
         List<AiSipCallUser> list = aiSipCallUserService.selectAiSipCallUserList(aiSipCallUser);
         ExcelUtil<AiSipCallUser> util = new ExcelUtil<>(AiSipCallUser.class);
         return util.exportExcel(list, "sip用户信息数据");
@@ -79,13 +81,10 @@ public class AiSipCallUserController extends BaseController
     @PostMapping
     public AjaxResult add(@RequestBody AiSipCallUser aiSipCallUser)
     {
-        if(aiSipCallUser.getCompanyUserId() == null){
-            LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
-            aiSipCallUser.setCompanyId(loginUser.getCompany().getCompanyId());
-            aiSipCallUser.setCompanyUserId(loginUser.getUser().getUserId());
-            aiSipCallUser.setCreateBy(loginUser.getUser().getUserName());
-        }
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        aiSipCallUser.setCreateBy(loginUser.getUser().getUserName());
         aiSipCallUser.setCreateTime(new Date());
+        aiSipCallUser.setUserSource("0");
         return toAjax(aiSipCallUserService.insertAiSipCallUser(aiSipCallUser));
     }
 
@@ -100,6 +99,7 @@ public class AiSipCallUserController extends BaseController
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
         aiSipCallUser.setUpdateBy(loginUser.getUser().getUserName());
         aiSipCallUser.setUpdateTime(new Date());
+        aiSipCallUser.setUserSource("0");
         return toAjax(aiSipCallUserService.updateAiSipCallUser(aiSipCallUser));
     }
 
@@ -115,12 +115,15 @@ public class AiSipCallUserController extends BaseController
     }
 
     /**
-     * 获取绑定的分机列表
+     * 获取公司绑定的分机列表
      */
-    @GetMapping("/getUnBindExtnum")
-    public AjaxResult getUnBindExtnum()
+    @GetMapping("/getCompanyUnBindExtnum/{companyId}")
+    public AjaxResult getCompanyUnBindExtnum(@PathVariable Long companyId)
     {
-        return aiSipCallUserService.getUnBindExtnum();
+        if(companyId == null || companyId <= 0){
+            return AjaxResult.error("参数缺失companyId");
+        }
+        return aiSipCallUserService.getCompanyUnBindExtnum(companyId);
     }
 
     /**
@@ -154,4 +157,5 @@ public class AiSipCallUserController extends BaseController
         }
         return aiSipCallUserService.getToolbarBasicParam(param);
     }
+
 }

+ 16 - 14
fs-company/src/main/java/com/fs/company/controller/aiSipCall/AiSipCallVoiceTtsAliyunController.java

@@ -1,24 +1,18 @@
 package com.fs.company.controller.aiSipCall;
 
-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.aiSipCall.domain.AiSipCallVoiceTtsAliyun;
+import com.fs.aiSipCall.service.IAiSipCallVoiceTtsAliyunService;
 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.aiSipCall.domain.AiSipCallVoiceTtsAliyun;
-import com.fs.aiSipCall.service.IAiSipCallVoiceTtsAliyunService;
 import com.fs.common.utils.poi.ExcelUtil;
-import com.fs.common.core.page.TableDataInfo;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
 
 /**
  * aiSIP外呼阿里云音色Controller
@@ -33,6 +27,14 @@ public class AiSipCallVoiceTtsAliyunController extends BaseController
     @Autowired
     private IAiSipCallVoiceTtsAliyunService aiSipCallVoiceTtsAliyunService;
 
+    /**
+     * 查询aiSIP外呼阿里云音色列表
+     */
+    @GetMapping("/remoteList")
+    public TableDataInfo remoteList(AiSipCallVoiceTtsAliyun aiSipCallVoiceTtsAliyun)
+    {
+        return aiSipCallVoiceTtsAliyunService.remoteList(aiSipCallVoiceTtsAliyun);
+    }
     /**
      * 查询aiSIP外呼阿里云音色列表
      */

+ 48 - 0
fs-company/src/main/java/com/fs/company/controller/aiSipCall/SopSipCallLogsController.java

@@ -0,0 +1,48 @@
+package com.fs.company.controller.aiSipCall;
+
+
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.sop.domain.SopSipCallLogs;
+import com.fs.sop.service.ISopSipCallLogsService;
+import com.github.pagehelper.PageHelper;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+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;
+
+/**
+ * 总后台app里sop执行sip任务记录Controller
+ *
+ * @author fs
+ * @date 2026-05-19
+ */
+@RestController
+@RequestMapping("/company/sop/sipCallLogs")
+public class SopSipCallLogsController extends BaseController
+{
+    @Autowired
+    private ISopSipCallLogsService sopSipCallLogsService;
+
+    /**
+     * 查询总后台app里sop执行sip任务记录列表
+     */
+    @PreAuthorize("@ss.hasPermi('company:sop:sipCallLogs:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(SopSipCallLogs sopSipCallLogs)
+    {
+        if(sopSipCallLogs.getPageNum() == null){
+            sopSipCallLogs.setPageNum(1);
+        }
+        if(sopSipCallLogs.getPageSize() == null){
+            sopSipCallLogs.setPageSize(10);
+        }
+        PageHelper.startPage(sopSipCallLogs.getPageNum(), sopSipCallLogs.getPageSize());
+        List<SopSipCallLogs> list = sopSipCallLogsService.selectSopSipCallLogsList(sopSipCallLogs);
+        return getDataTable(list);
+    }
+
+}

+ 9 - 0
fs-qw-task/src/main/java/com/fs/app/controller/CommonController.java

@@ -214,6 +214,15 @@ public class CommonController {
     @Autowired
     private com.fs.app.task.qwTask qwTask;
 
+    @Autowired
+    private ISopSipCallLogsService sipCallLogsService;
+
+
+    @GetMapping("/executeSipCallTask")
+    public void executeSipCallTask(){
+        sipCallLogsService.executeSipCallTask();
+    }
+
     @GetMapping("/test")
     public R test(){
         LiveRedPacketParam param=new LiveRedPacketParam();

+ 14 - 0
fs-qw-task/src/main/java/com/fs/app/task/qwTask.java

@@ -12,6 +12,7 @@ import com.fs.sop.mapper.QwSopLogsMapper;
 import com.fs.sop.mapper.QwSopMapper;
 import com.fs.sop.service.IQwSopLogsService;
 import com.fs.sop.service.IQwSopTagService;
+import com.fs.sop.service.ISopSipCallLogsService;
 import com.fs.sop.service.ISopUserLogsService;
 import com.fs.sop.service.impl.QwSopLogsServiceImpl;
 import com.fs.sop.service.impl.QwSopServiceImpl;
@@ -108,6 +109,11 @@ public class qwTask {
     @Autowired
     private SyncQwExternalContactService syncQwExternalContactService;
 
+
+
+    @Autowired
+    private ISopSipCallLogsService sipCallLogsService;
+
     /**
      * 定时任务:检查SOP规则时间
      * 执行时间:每天凌晨 1:10:00
@@ -128,6 +134,14 @@ public class qwTask {
         qwSopTagService.addTag();
     }
 
+    /**
+     * sop 任务里的sip 外呼
+     */
+    @Scheduled(cron = "0 0/20 * * * ?")
+    public void addSipCall(){
+        sipCallLogsService.executeSipCallTask();
+    }
+
     /**
      * 定时任务:根据营期生成sopLogs待发记录
      * 执行时间:每小时的第5分钟执行

+ 46 - 0
fs-qw-task/src/main/java/com/fs/app/taskService/impl/SopLogsTaskServiceImpl.java

@@ -5,6 +5,7 @@ import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONArray;
 import com.alibaba.fastjson.JSONObject;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.fs.aiSipCall.utils.SipLogUtil;
 import com.fs.app.taskService.SopLogsTaskService;
 import com.fs.common.config.FSSysConfig;
 import com.fs.common.core.redis.RedisCache;
@@ -37,6 +38,7 @@ import com.fs.qw.service.IQwCompanyService;
 import com.fs.qw.service.IQwGroupChatService;
 import com.fs.qw.service.IQwGroupChatUserService;
 import com.fs.qw.service.IQwSopSmsLogsService;
+import com.fs.qw.service.impl.AsyncAddSipCallService;
 import com.fs.qw.service.impl.QwExternalContactServiceImpl;
 import com.fs.qw.vo.GroupUserExternalVo;
 import com.fs.qw.vo.QwSopCourseFinishTempSetting;
@@ -56,6 +58,7 @@ import com.fs.system.service.ISysConfigService;
 import com.fs.utils.ShortCodeGeneratorUtils;
 import com.fs.voice.utils.StringUtil;
 import lombok.extern.slf4j.Slf4j;
+import org.slf4j.Logger;
 import org.springframework.beans.BeanUtils;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Value;
@@ -83,6 +86,8 @@ import static com.fs.course.utils.LinkUtil.generateRandomStringWithLock;
 @Slf4j
 public class SopLogsTaskServiceImpl implements SopLogsTaskService {
 
+    private static final Logger sipLog = SipLogUtil.createSipLogger(SopLogsTaskServiceImpl.class);
+
     private static final String APP_LINK_PREFIX = "/appcourse/pages/course/learning?course=";
     private static final String REAL_LINK_PREFIX = "/courseH5/pages/course/learning?course=";
     private static final String SHORT_LINK_PREFIX = "/courseH5/pages/course/learning?s=";
@@ -180,6 +185,9 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
     @Autowired
     private SysConfigMapper sysConfigMapper;
 
+    @Autowired
+    private AsyncAddSipCallService addSipCallService;
+
 
     // Blocking queues with bounded capacity to implement backpressure
     private final BlockingQueue<QwSopLogs> qwSopLogsQueue = new LinkedBlockingQueue<>(20000);
@@ -800,6 +808,44 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
             }
         }
 
+        if (content.getType() == 8) {
+            try {
+                sipLog.info("开始执行CommonController.testTime()或qwTaskBySopLogs.selectSopUserLogsListByTime()的录入sip外呼任务,营期id:{}", logVo.getId());
+                long newCompanyId = 0;
+                if (StringUtils.isBlank(companyId)) {
+                    sipLog.error("公司id为空丢弃,尝试从ruleTimeVO获取,来自营期:{}", logVo.getId());
+                    if (ruleTimeVO != null && ruleTimeVO.getCompanyId() != null) {
+                        newCompanyId = ruleTimeVO.getCompanyId();
+                    } else {
+                        sipLog.error("公司id为空丢弃,尝试从ruleTimeVO获取还是为空,来自营期:{}", logVo.getId());
+                        return;
+                    }
+                } else {
+                    newCompanyId = Long.parseLong(companyId);
+                }
+                SopSipCallLogs sipCallLogs = new SopSipCallLogs();
+                sipCallLogs.setSipBatchId(content.getAiCustomerRoleId());
+                sipCallLogs.setStatus(1L);
+                sipCallLogs.setSopUserLogsId(logVo.getId());
+                sipCallLogs.setCourseId(content.getCourseId());
+                sipCallLogs.setVideoId(content.getVideoId());
+                sipCallLogs.setSendTime(sendTime);
+                sipCallLogs.setCreateTime(new Date());
+                sipCallLogs.setCourseType(content.getCourseType());
+                sipCallLogs.setCompanyId(newCompanyId);
+                //qwUserId是真id,不是qw_user_id
+                sipCallLogs.setQwUserid(logVo.getQwUserId());
+                sipCallLogs.setSopId(logVo.getSopId());
+                sipCallLogs.setCorpId(logVo.getCorpId());
+                sipCallLogs.setTaskSource(2);
+                addSipCallService.executeAsyncSipCal(sipCallLogs);
+                sipLog.info("结束执行CommonController.testTime()或qwTaskBySopLogs.selectSopUserLogsListByTime()的录入sip外呼任务,营期id:{}", logVo.getId());
+            } catch (Exception e) {
+                sipLog.error("结束执行CommonController.testTime()或qwTaskBySopLogs.selectSopUserLogsListByTime()的录入sip外呼任务,发生异常:" + e);
+            }
+            return;
+        }
+
         if (StringUtils.isNotEmpty(logVo.getChatId())) {
             QwGroupChat groupChat = groupChatMap.get(logVo.getChatId());
             if (groupChat.getChatUserList() != null && !groupChat.getChatUserList().isEmpty()) {

+ 173 - 27
fs-service/src/main/java/com/fs/aiSipCall/RemoteCommon.java

@@ -1,99 +1,245 @@
 package com.fs.aiSipCall;
 
+import cn.hutool.http.HttpRequest;
+import cn.hutool.http.HttpResponse;
 import cn.hutool.http.HttpUtil;
-import lombok.extern.slf4j.Slf4j;
+import com.fs.aiSipCall.utils.SipLogUtil;
+import org.slf4j.Logger;
 import org.springframework.stereotype.Component;
 
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+
 /**
  * @Author:peicj
  * @Description: 远程接口公共参数
  * @Date:2026/3/9 10:50
  */
-@Slf4j
 @Component
 public class RemoteCommon {
+
+    private static final Logger log = SipLogUtil.createSipLogger(RemoteCommon.class);
     /**
      * 远程接口地址前缀
      */
-    public static final String REMOTE_ADDERSS_PREFIX = "http://129.28.164.235:8899";
-//    public static final String REMOTE_ADDERSS_PREFIX = "http://127.0.0.1:8899";
+    public static final String REMOTE_ADDERSS_PREFIX = "http://129.28.164.235:8899/aicall/api";
+//    public static final String REMOTE_ADDERSS_PREFIX = "http://127.0.0.1:8899/aicall/api";
 
     /**
      * 网关列表接口
      */
-    public static final String GATEWAY_LIST_API = "/aicall/api/gateway/myList";
+    public static final String GATEWAY_LIST_API = "/gateway/myList";
     /**
      * 音色列表接口
      */
-    public static final String VOICECODE_LIST_API = "/aicall/api/voicecode/list";
+    public static final String VOICECODE_LIST_API = "/voicecode/list";
+    /**
+     * 音色列表分页接口
+     */
+    public static final String VOICECODE_PAGE_LIST_API = "/voicecodePage";
+    /**
+     * 查询全部ttsprovider列表
+     */
+    public static final String ALL_TTS_PROVIDER_API = "/tts/voiceSource/all";
+    /**
+     * 查询全部ttsprovider列表
+     */
+    public static final String ALL_ASR_PROVIDER_API = "/asr/provider/all";
+    /**
+     * 根据tts厂商获取支持语言
+     */
+    public static final String ALL_TTS_LANGUAGE_API = "/tts/language";
+    /**
+     * 根据asr厂商获取语言
+     */
+    public static final String ALL_ASR_LANGUAGE_API = "/asr/language";
+    /**
+     * 根据asr厂商和语言获取asr模型列表
+     */
+    public static final String ALL_ASR_MODELS_API = "/asr/models";
+    /**
+     * 根据tts厂商和语言获取asr模型列表
+     */
+    public static final String ALL_TTS_MODELS_API = "/tts/models";
     /**
      * 大模型列表接口
      */
-    public static final String LLMACOUNT_LIST_API = "/aicall/api/llmacount/list";
+    public static final String LLMACOUNT_LIST_API = "/llmacount/list";
     /**
      * 技能组列表接口
      */
-    public static final String BUSIGROUP_LIST_API = "/aicall/api/busigroup/list";
+    public static final String BUSIGROUP_LIST_API = "/busigroup/list";
+    /**
+     * 新增任务接口
+     */
+    public static final String CREATE_TASK_API = "/ai/createTask";
+
     /**
      * 查询AI外呼通话记录列表接口
      */
     public static final String CALL_RECORDS_API = "/aicall/api/call/phone/records";
+
     /**
-     * 新增任务接口
+     * 修改任务接口
      */
-    public static final String CREATE_TASK_API = "/aicall/api/ai/createTask";
+    public static final String EDIT_TASK_API = "/editTask";
     /**
-     * 修改任务接口
+     * 删除任务接口
      */
-    public static final String EDIT_TASK_API = "/aicall/api/createTask";
+    public static final String DEL_TASK_API = "/removeTask";
     /**
      * 启动任务接口
      */
-    public static final String START_TASK_API = "/aicall/api/ai/startTask";
+    public static final String START_TASK_API = "/ai/startTask";
     /**
      * 暂停任务接口
      */
-    public static final String STOP_TASK_API = "/aicall/api/ai/stopTask";
+    public static final String STOP_TASK_API = "/ai/stopTask";
     /**
      * AI外呼追加名单接口
      */
-    public static final String ADD_CALL_LIST_API = "/aicall/api/ai/addCallList";
+    public static final String ADD_CALL_LIST_API = "/ai/addCallList";
     /**
      * 通用追加名单接口
      */
-    public static final String COMMON_ADD_CALL_LIST_API = "/aicall/api/common/addCallList";
+    public static final String COMMON_ADD_CALL_LIST_API = "/common/addCallList";
+    /**
+     * 加密传输导入电话数据接口
+     */
+    public static final String IMPORT_AUTOCALL_API = "/import/autoCallData";
+
+
     /**
      * 获取电话工具条的网关列表
      */
-    public static final String PHONEBAR_PARAMS_API = "/aicall/api/myPhoneBar/params";
+    public static final String PHONEBAR_PARAMS_API = "/myPhoneBar/params";
 
     /**
      * 新增用户并绑定分机
      */
-    public static final String ADD_USER_OR_BIND_EXTNUM_API = "/aicall/api/user/addUserOrBindExtNumReturnUser";
+    public static final String ADD_USER_OR_BIND_EXTNUM_API = "/user/addUserOrBindExtNumReturnUser";
     /**
-     * 修改用户
+     * 公司修改用户
      */
-    public static final String EDIT_USER_OR_UNBING_EXTNUM_API = "/aicall/api/user/editUserOrUnBindExtNum";
-
+    public static final String EDIT_USER_OR_UNBING_EXTNUM_API = "/user/editUserOrUnBindExtNum";
+    /**
+     * 删除用户且解绑分机
+     */
+    public static final String DELETE_USER_OR_UNBING_EXTNUM_API = "/deleteUserOrunBindExtNum";
 
     /**
      * 查询未分配的分机
      */
-    public static final String QUERY_UN_BIND_EXTNUM_API = "/aicall/api/extnum/selectUnBindCcExtNumList";
+    public static final String QUERY_UN_BIND_EXTNUM_API = "/extnum/selectUnBindCcExtNumList";
+    /**
+     * 查询公司未分配的分机
+     */
+    public static final String QUERY_COMPANY_UN_BIND_EXTNUM_API = "/extnum/selectCompanyUnBindCcExtNumList";
+    /**
+     * 查询未分配的分机分页
+     */
+    public static final String QUERY_UN_BIND_EXTNUM_PAGE_API = "/extnum/selectUnBindCcExtNumListPage";
+    /**
+     * 新增企业绑定未使用的分机(实际只是公司占位,需销售来绑定)
+     */
+    public static final String QUERY_UN_BIND_EXTNUM_COMPANY_API = "/companyBindExtNum";
+    /**
+     * 公司批量解绑分机
+     */
+    public static final String CONPANY_BATCH_UNBING_EXTNUM_API = "/companyBatchUnbindSipExt";
+    /**
+     * 通用查询分机列表分页
+     */
+    public static final String QUERY_EXTNUM_PAGE_API = "/extnumPage";
 
     /**
      * 获取手动外呼客户沟通信息
      */
-    public static final String GET_CUST_COMMUNICATION_INFO_API = "/aicall/api/getCustCommunicationInfo";
+    public static final String GET_CUST_COMMUNICATION_INFO_API = "/getCustCommunicationInfo";
     /**
      * 新增保存手动外呼沟通记录
      */
-    public static final String ADD_CUSTCALL_RECORD_API = "/aicall/api/add/custcallrecord";
+    public static final String ADD_CUSTCALL_RECORD_API = "/add/custcallrecord";
     /**
      * 查询外呼记录列表
      */
-    public static final String QUERY_OUTBOUNDCDR_LIST_API = "/aicall/api/outboundcdrList";
+    public static final String QUERY_OUTBOUNDCDR_LIST_API = "/outboundcdrList";
+    /**
+     * 根据id查询自动外呼通话记录列表接口
+     */
+    public static final String QUERY_CCCALLPHONE_RECORDS_BYID_API = "/getCcCallPhoneByIds";
+    /**
+     * 根据UUID查询自动外呼对话详情
+     */
+    public static final String QUERY_CCCALLPHONE_RECORDS_BYUUID_API = "/getCcCallPhoneByUuid";
+    /**
+     * 手动同步人工外呼记录测试
+     */
+    public static final String OUTBOUND_MANUALPULL_TEST_API = "/outboundcdr/manualPullTest";
+
+    /** 录音下载超时(毫秒) */
+    private static final int RECORDING_DOWNLOAD_TIMEOUT_MS = 30_000;
+    /** 录音单文件体积上限(防止异常大体量拖垮内存),50MB */
+    private static final int RECORDING_MAX_BYTES = 50 * 1024 * 1024;
+
+    /** 录音下载(对端 GET /recordings/files?filename=) */
+    public static final String RECORDINGS_FILES_API = "/recordings/files";
+
+    /** 远端站点根地址(录音不在 /aicall/api 下) */
+    public static String getRemoteHost() {
+        return "http://129.28.164.235:8899";
+    }
+
+    /**
+     * 请求对端录音接口,返回原始字节。
+     * <p>
+     * 使用 Hutool {@link HttpRequest}(兼容本项目 hutool 5.3.3,无 downloadBytes 方法)。
+     * 失败 / 非 200 / 空体 / 超限 均返回 null。
+     *
+     * @param filename 相对录音目录的文件名(可含子路径,禁止 ..)
+     */
+    public static byte[] downloadRecordingBytes(String filename) {
+        if (filename == null) {
+            return null;
+        }
+        String name = filename.trim();
+        if (name.isEmpty() || name.contains("..") || name.indexOf('\\') >= 0) {
+            log.warn("downloadRecording 非法 filename={}", filename);
+            return null;
+        }
+        HttpResponse response = null;
+        try {
+            String url = getRemoteHost()  + filename;
+            response = HttpRequest.get(url)
+                    .timeout(RECORDING_DOWNLOAD_TIMEOUT_MS)
+                    .execute();
+            if (response == null || !response.isOk()) {
+                log.warn("downloadRecording 非成功响应, filename={}, status={}",
+                        name, response == null ? null : response.getStatus());
+                return null;
+            }
+            byte[] bytes = response.bodyBytes();
+            if (bytes == null || bytes.length == 0) {
+                return null;
+            }
+            if (bytes.length > RECORDING_MAX_BYTES) {
+                log.error("downloadRecording 文件过大已拒绝, filename={}, size={}", name, bytes.length);
+                return null;
+            }
+            return bytes;
+        } catch (Exception e) {
+            log.error("downloadRecording error, filename={}", filename, e);
+            return null;
+        } finally {
+            if (response != null) {
+                try {
+                    response.close();
+                } catch (Exception ignore) {
+                    // ignore
+                }
+            }
+        }
+    }
 
     /**
      * 发送get请求
@@ -102,7 +248,7 @@ public class RemoteCommon {
      */
     public static String sendGet(String url){
         try{
-            return HttpUtil.get(url, 10 * 1000);
+            return HttpUtil.get(url, 15 * 1000);
         }catch (Exception e){
             e.printStackTrace();
             log.info("sendGet error");
@@ -116,7 +262,7 @@ public class RemoteCommon {
      */
     public static String sendPost(String url,String jsonBody){
         try{
-            return HttpUtil.post(url, jsonBody);
+            return HttpUtil.post(url, jsonBody, 15 * 1000);
         }catch (Exception e){
             e.printStackTrace();
             log.info("sendPost error");

+ 59 - 0
fs-service/src/main/java/com/fs/aiSipCall/domain/AiSipAutoCallRules.java

@@ -0,0 +1,59 @@
+package com.fs.aiSipCall.domain;
+
+import com.fs.common.annotation.Excel;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * sip自动外呼屏蔽规则对象 ai_sip_auto_call_rules
+ *
+ * @author fs
+ * @date 2026-07-07
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class AiSipAutoCallRules extends BaseEntity{
+
+    /** 规则ID */
+    private Long id;
+
+    /** 任务批次ID,关联 ai_sip_call_task 表,为空时为全局,多个用逗号拼接 */
+    @Excel(name = "任务批次ID")
+    private String batchIds;
+
+    /** 节假日是否屏蔽:0-不屏蔽,1-屏蔽 */
+    @Excel(name = "节假日是否屏蔽:0-不屏蔽,1-屏蔽")
+    private Integer ruleOne;
+
+    /** 特殊日期屏蔽,多个用逗号拼接,格式为 YYYY-MM-DD,如 "2026-01-01,2026-02-14" */
+    @Excel(name = "特殊日期屏蔽")
+    private String ruleTwo;
+
+    /** 每周屏蔽某天,值:1,2,3,4,5,6,7 */
+    @Excel(name = "每周屏蔽某天,值:1,2,3,4,5,6,7")
+    private String ruleThree;
+
+    /** 每天屏蔽时段,多个用逗号拼接,如 "09:00-12:00,14:00-17:00" */
+    @Excel(name = "每天屏蔽时段")
+    private String ruleFour;
+
+    /** 是否允许删除:0-允许,1-不允许 */
+    @Excel(name = "是否允许删除:0-允许,1-不允许")
+    private Integer isAllowDel;
+
+    /** 状态:0-启用,1-禁用 */
+    @Excel(name = "状态:0-启用,1-禁用")
+    private Integer status;
+
+    /** 备用字段 */
+    @Excel(name = "备用字段")
+    private String reserveField;
+
+    /** 批次ID */
+    private Long batchId;
+    /** 批次名称 */
+    private String batchNames;
+    /** 排除的批次任务ID */
+    private Long notId;
+}

+ 2 - 0
fs-service/src/main/java/com/fs/aiSipCall/domain/AiSipCallGateway.java

@@ -81,5 +81,7 @@ public class AiSipCallGateway extends BaseEntity{
     private Integer pageSize;
 
     private Integer pageNum;
+    /** 是否自动外呼 true是  false否 */
+    private Boolean isAICall;
 
 }

+ 40 - 8
fs-service/src/main/java/com/fs/aiSipCall/domain/AiSipCallOutboundCdr.java

@@ -30,15 +30,12 @@ public class AiSipCallOutboundCdr implements Serializable {
     private String callee;
 
     /** 外呼开始时间 */
-    @Excel(name = "外呼开始时间")
     private Long startTime;
 
     /** 被叫接听时间 */
-    @Excel(name = "被叫接听时间")
     private Long answeredTime;
 
     /** 挂断时间 */
-    @Excel(name = "挂断时间")
     private Long endTime;
 
     /** 通话UUID */
@@ -46,29 +43,50 @@ public class AiSipCallOutboundCdr implements Serializable {
     private String uuid;
 
     /** 音频或视频通话 */
-    @Excel(name = "音频或视频通话")
     private String callType;
 
     /** 通话时长 */
-    @Excel(name = "通话时长")
     private Long timeLen;
 
     /** 有效通话时长 */
-    @Excel(name = "有效通话时长")
     private Long timeLenValid;
 
     /** 录音文件名 */
-    @Excel(name = "录音文件名")
     private String recordFilename;
 
+    private String wavfile;
+
     /** 对话内容 */
-    @Excel(name = "对话内容")
     private String chatContent;
 
     /** 挂断原因 */
     @Excel(name = "挂断原因")
     private String hangupCause;
 
+    /** 外呼类型(0销售后台 1总后台) */
+    @Excel(name = "外呼类型(0销售后台 1总后台)")
+    private String sourceType;
+    /** 销售公司ID */
+    @Excel(name = "销售公司ID")
+    private Long companyId;
+    /** 销售ID */
+    @Excel(name = "销售ID")
+    private Long companyUserId;
+    /** 销售公司名称 */
+    @Excel(name = "销售公司名称")
+    private String companyName;
+    /** 销售账号 */
+    @Excel(name = "销售账号")
+    private String companyUserName;
+    /** 总后台用户ID */
+    @Excel(name = "总后台用户ID")
+    private Long sysUserId;
+    /** 总后台用户账号 */
+    @Excel(name = "总后台用户账号")
+    private String sysUserName;
+    /** 状态(0正常1删除) */
+    private Integer status;
+
     /** 通话总时长起止 */
     private Long timeLenStart;
     private Long timeLenEnd;
@@ -89,9 +107,23 @@ public class AiSipCallOutboundCdr implements Serializable {
     private Long endTimeStartLong;
     private Long endTimeEndLong;
 
+    @Excel(name = "外呼开始时间")
     private String startTimeStr;
+    @Excel(name = "被叫接听时间")
     private String answeredTimeStr;
+    @Excel(name = "挂断时间")
     private String endTimeStr;
+    @Excel(name = "通话时长秒")
     private String timeLenSec;
+    @Excel(name = "有效通话时长秒")
     private String timeLenValidStr;
+    private Integer isEncrypt;
+    private String encryptPhone;
+
+    /** 医生id */
+    private Long doctorId;
+
+    /** 医生名称 */
+    @Excel(name = "医生名称")
+    private String doctorName;
 }

+ 126 - 83
fs-service/src/main/java/com/fs/aiSipCall/domain/AiSipCallPhone.java

@@ -1,12 +1,17 @@
 package com.fs.aiSipCall.domain;
 
+import com.baomidou.mybatisplus.annotation.TableField;
 import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fs.aiSipCall.utils.StringUtils;
 import com.fs.common.annotation.Excel;
 import lombok.Data;
 
 import java.io.Serializable;
 import java.math.BigDecimal;
 import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
 
 /**
  * aiSIP外呼通话记录对象 ai_sip_call_phone
@@ -20,27 +25,28 @@ import java.util.Date;
 @Data
 public class AiSipCallPhone implements Serializable {
 
-    /** $column.columnComment */
+    /** id */
     private String id;
 
-    /** 任务批次id */
-    @Excel(name = "任务批次id")
+    /** 本地任务批次id */
+    private Long localBatchId;
+    /** 远程任务批次id */
     private Long batchId;
     /** 批次名称 */
-    @Excel(name = "批次名称", readConverterExp = "批次名称")
+    @Excel(name = "批次名称")
     private String batchName;
 
-    /** $column.columnComment */
-    @Excel(name = "任务批次id")
+    /** 线路名称 */
+    @Excel(name = "线路名称")
+    @TableField(exist = false)
+    private String gatewayName;
+
+    /** 呼叫电话 */
+    @Excel(name = "呼叫电话")
     private String telephone;
 
     /** 客户称呼 */
-    @Excel(name = "客户称呼")
     private String custName;
-    /** 创建时间 */
-    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
-    @Excel(name = "创建时间")
-    private Date createTime;
 
     /** 0. 未拨打
         1. 已进入呼叫队列
@@ -61,85 +67,64 @@ public class AiSipCallPhone implements Serializable {
         38. 暂时无法接通
         39. 呼叫限制
      */
-    @Excel(name = "呼叫状态", readConverterExp = "如果关闭空号检测")
     private Long callstatus;
 
     /** 外呼时间 */
-    @Excel(name = "外呼时间")
     private Long calloutTime;
 
     /** 呼叫次数 */
-    @Excel(name = "呼叫次数")
     private Long callcount;
 
     /** 呼叫结束时间 */
-    @Excel(name = "呼叫结束时间")
     private Long callEndTime;
 
     /** 通话时长; 秒; */
-    @Excel(name = "通话时长; 秒;")
     private Long timeLen;
 
     /** 人工接听的通话时长; 秒 */
-    @Excel(name = "人工接听的通话时长; 秒")
     private Long validTimeLen;
 
     /** 通话唯一标志 */
-    @Excel(name = "通话唯一标志")
+    @Excel(name = "uuid")
     private String uuid;
 
     /** 通话接通时间 */
-    @Excel(name = "通话接通时间")
     private Long connectedTime;
 
     /** 挂机原因 */
-    @Excel(name = "挂机原因")
     private String hangupCause;
 
     /** 人工坐席应答时间 */
-    @Excel(name = "人工坐席应答时间")
     private Long answeredTime;
 
     /** 对话内容 */
-    @Excel(name = "对话内容")
     private String dialogue;
 
     /** 全程通话录音文件名 */
-    @Excel(name = "全程通话录音文件名")
     private String wavfile;
 
-
-
     /** 录音文件路径前缀 */
-    @Excel(name = "录音文件路径前缀")
     private String recordServerUrl;
 
     /** 业务json数据 */
-    @Excel(name = "业务json数据")
     private String bizJson;
 
     /** 交互轮次(一问一答算一轮交互) */
-    @Excel(name = "交互轮次", readConverterExp = "一=问一答算一轮交互")
     private Long dialogueCount;
 
     /** 人工坐席工号 */
-    @Excel(name = "人工坐席工号")
     private String acdOpnum;
 
     /** 加入转人工排队的时间;  */
-    @Excel(name = "加入转人工排队的时间; ")
     private Long acdQueueTime;
 
     /** 人工排队等待时长,秒 */
-    @Excel(name = "人工排队等待时长,秒")
     private Long acdWaitTime;
 
     /** 语音通话通知的TTS文本 */
-    @Excel(name = "语音通话通知的TTS文本")
     private String ttsText;
 
     /** $column.columnComment */
-    @Excel(name = "语音通话通知的TTS文本")
     private String emptyNumberDetectionText;
 
     /** 客户意向 */
@@ -147,51 +132,98 @@ public class AiSipCallPhone implements Serializable {
     private String intent;
 
     /** asr时长(秒) */
-    @Excel(name = "asr时长", readConverterExp = "秒=")
     private Long asrSeconds;
 
     /** tts调用次数(次) */
-    @Excel(name = "tts调用次数", readConverterExp = "次=")
     private Long ttsTimes;
 
     /** 大模型tts的字符数(字符) */
-    @Excel(name = "大模型tts的字符数", readConverterExp = "字=符")
     private Long ttsFlowTokens;
 
     /** 总输入token数 */
-    @Excel(name = "总输入token数")
     private Long inputTokens;
 
     /** 总输出token数 */
-    @Excel(name = "总输出token数")
     private Long outputTokens;
 
     /** 总调用费用(asr+tts+大模型) */
-    @Excel(name = "总调用费用", readConverterExp = "a=sr+tts+大模型")
     private BigDecimal totalCost;
 
     /** 计费状态(1:已计费、0:未计费) */
-    @Excel(name = "计费状态", readConverterExp = "1=:已计费、0:未计费")
     private Long billingStatus;
 
     /** 主叫号码 */
-    @Excel(name = "主叫号码")
     private String callerNumber;
 
     /** 客户输入的DTMF按键 */
-    @Excel(name = "客户输入的DTMF按键")
     private String ivrDtmfDigits;
 
     /** 人工坐席应答时间 */
-    @Excel(name = "人工坐席应答时间")
     private Long manualAnsweredTime;
 
     /** 人工坐席服务时长 */
-    @Excel(name = "人工坐席服务时长")
     private Long manualAnsweredTimeLen;
+
+    /** 外呼类型(0模板导入 1系统集成) */
+    private String callType;
+    /** 销售公司ID */
+    @Excel(name = "销售公司ID")
+    private Long companyId;
+    /** 销售ID */
+    private Long companyUserId;
+    /** 销售账号 */
+    private String companyUserName;
+    /** 总后台用户ID */
+    private Long sysUserId;
+    /** 总后台用户账号 */
+    private String sysUserName;
+    /** 外呼平台创建时间 */
+    private Long createtime;
+    /** 状态(0正常1删除) */
+    private Integer status;
+    /** 销售企业微信账号 */
+    private String qwUserid;
+    /** 客户字符串账号 */
+    private String externalUserId;
+    /** 客户信息主键 */
+    private String externalId;
+    /** 客户名称 */
+    private String externalUserName;
+    /** 预计拨号时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm")
+    private Date sendTime;
+    /** sop任务id */
+    private String sopId;
+    /** 企业微信公司 */
+    private String corpId;
+    /** 小程序id */
+    private Long fsUserId;
+    private List<Long> fsUserIds;
+    private String fsUserNickName;
+    /** 发送类型  1企微发送 2 Ai接口发送  3:完课发送  4:AI对话 5一键群发 6客户群群发 7欢迎语补发 8AI 9清除草稿 10发送草稿 13sop任务模板定时生成*/
+    private Long sendType;
+    private Long courseId;
+    private Long videoId;
+    private Integer courseType;
+
+    /** 执行类型(0销售端企微sop 1总后台客服sop 2总后台app催课看板) */
+    private String executeType;
+    /** 任务id 当前执行类型0和执行类型1都是用自增,查询时需带上执行类型才可查询到*/
+    private Long sipTaskId;
+    /** 客服表(app_customer_id)主键id */
+    private Long appCustomerId;
+
+    private Integer isEncrypt;
+    private String encryptPhone;
+
+
+
     /** 录音文件url访问地址 */
     private String wavFileUrl;
 
+    @Excel(name = "销售公司")
+    private String companyName;
+
     /** 通话总时长起止 */
     private Long timeLenStart;
     private Long timeLenEnd;
@@ -210,61 +242,72 @@ public class AiSipCallPhone implements Serializable {
     private String callEndTimeEnd;
     private Long callEndTimeStartLong;
     private Long callEndTimeEndLong;
+    /** 创建时间起止 */
+    private String createtimeStart;
+    private String createtimeEnd;
+    private Long createtimeStartLong;
+    private Long createtimeEndLong;
+
 
+    @Excel(name = "呼叫状态")
     private String callstatusName;
+    @Excel(name = "外呼时间")
     private String calloutTimeStr;
+    @Excel(name = "通话接通时间")
     private String answeredTimeStr;
+    @Excel(name = "呼叫结束时间")
     private String callEndTimeStr;
+    @Excel(name = "外呼平台创建时间")
+    private String createtimeStr;
+    @Excel(name = "通话时长")
     private String timeLenSec;
 
+    @TableField(exist = false)
+    private Integer sipType;
+
+    private Integer pageSize;
+
+    private Integer pageNum;
+
+    /**
+     * 呼叫状态码与状态名称映射
+     */
+    private static final Map<Integer, String> CALL_STATUS_MAP = new HashMap<>();
+
+    static {
+        CALL_STATUS_MAP.put(1, "已进入呼叫队列");
+        CALL_STATUS_MAP.put(2, "正在拨号");
+        CALL_STATUS_MAP.put(3, "未接通(如果关闭空号检测)");
+        CALL_STATUS_MAP.put(4, "已接通");
+        CALL_STATUS_MAP.put(5, "通话中断");
+        CALL_STATUS_MAP.put(6, "成功转人工或 AI");
+        CALL_STATUS_MAP.put(7, "线路故障");
+        CALL_STATUS_MAP.put(30, "未接通");
+        CALL_STATUS_MAP.put(31, "客户正在通话中");
+        CALL_STATUS_MAP.put(32, "关机");
+        CALL_STATUS_MAP.put(33, "空号");
+        CALL_STATUS_MAP.put(34, "无人接听");
+        CALL_STATUS_MAP.put(35, "停机");
+        CALL_STATUS_MAP.put(36, "网络忙");
+        CALL_STATUS_MAP.put(37, "语音助手");
+        CALL_STATUS_MAP.put(38, "暂时无法接通");
+        CALL_STATUS_MAP.put(39, "呼叫限制");
+    }
 
     /**
      * 根据呼叫状态码获取状态名称
      * @param status 状态码
+     * @param uuid 通话会话
      * @return 状态名称
      */
-    public static String getCallStatusName(Long status) {
+    public static String getCallStatusName(Long status, String uuid) {
         if (status == null) {
             return "未知";
         }
-        switch (status.intValue()) {
-            case 0:
-                return "未拨打";
-            case 1:
-                return "已进入呼叫队列";
-            case 2:
-                return "正在拨号";
-            case 3:
-            case 30:
-                return "未接通";
-            case 4:
-                return "已接通";
-            case 5:
-                return "通话中断";
-            case 6:
-                return "成功转人工或 AI";
-            case 7:
-                return "线路故障";
-            case 31:
-                return "客户正在通话中";
-            case 32:
-                return "关机";
-            case 33:
-                return "空号";
-            case 34:
-                return "无人接听";
-            case 35:
-                return "停机";
-            case 36:
-                return "网络忙";
-            case 37:
-                return "语音助手";
-            case 38:
-                return "暂时无法接通";
-            case 39:
-                return "呼叫限制";
-            default:
-                return "未知状态";
+        // 特殊处理状态0
+        if (status == 0) {
+            return StringUtils.isNotBlank(uuid) ? "未接听" : "待呼叫";
         }
+        return CALL_STATUS_MAP.getOrDefault(status.intValue(), "未知状态");
     }
 }

+ 14 - 0
fs-service/src/main/java/com/fs/aiSipCall/domain/AiSipCallTask.java

@@ -116,6 +116,20 @@ public class AiSipCallTask extends BaseEntity{
     @Excel(name = "远程任务ID")
     private Long remoteBatchId;
 
+    /** 状态(0正常1删除) */
+    @Excel(name = "状态(0正常1删除)")
+    private Integer status;
+
+    /** Language of the asr */
+    private String asrLanguageCode;
+    /** Language of the tts */
+    private String ttsLanguageCode;
+    /** Models of the asr */
+    private String asrModels;
+    /** Models of the tts */
+    private String ttsModels;
+
+
     private Long companyId;
     private Long companyUserId;
 

+ 18 - 2
fs-service/src/main/java/com/fs/aiSipCall/domain/AiSipCallUser.java

@@ -94,9 +94,25 @@ public class AiSipCallUser extends BaseEntity{
     /** 网关字符串 */
     @Excel(name = "网关字符串")
     private String gatewayIds;
-
+    /** 员工对应的销售公司 */
     private Long companyId;
     private Long companyUserId;
-
+    /** 用户来源0销售 1总后台 */
+    private String userSource;
+    /** 总后台用户ID */
+    private Long sysUserId;
+    /** 总后台用户账号 */
+    private String sysUserName;
+    /** 分机密码 */
+    private String extPass;
+    /** 分机绑定用户 */
+    private String userCode;
+    /** 销售公司 */
+    private String companyName;
+
+    /** 医生id */
+    private Long doctorId;
+    /** 医生名称 */
+    private String doctorName;
 
 }

+ 8 - 1
fs-service/src/main/java/com/fs/aiSipCall/domain/AiSipCallVoiceTtsAliyun.java

@@ -46,5 +46,12 @@ public class AiSipCallVoiceTtsAliyun extends BaseEntity{
     @Excel(name = "远程音色ID")
     private Long remoteVoiceTtsAliyunId;
 
-
+    /** Language of the voice */
+    private String languageCode;
+    /** Language name of the voice */
+    private String languageName;
+    /** Models of the voice */
+    private String ttsModels;
+    private Integer pageNum;
+    private Integer pageSize;
 }

+ 2 - 2
fs-service/src/main/java/com/fs/aiSipCall/domain/CcCustCallRecord.java

@@ -1,6 +1,6 @@
 package com.fs.aiSipCall.domain;
 
-//import cn.afterturn.easypoi.excel.annotation.Excel;
+
 import com.fasterxml.jackson.annotation.JsonFormat;
 import com.fs.common.annotation.Excel;
 import lombok.Data;
@@ -11,7 +11,7 @@ import java.util.Date;
 
 /**
  * 沟通记录对象 cc_cust_call_record
- * 
+ *
  * @author ruoyi
  * @date 2025-01-03
  */

+ 2 - 2
fs-service/src/main/java/com/fs/aiSipCall/domain/CcCustInfo.java

@@ -1,7 +1,7 @@
 package com.fs.aiSipCall.domain;
 
 
-import com.fasterxml.jackson.annotation.JsonFormat;
+import com.baidu.dev2.thirdparty.jackson.annotation.JsonFormat;
 import com.fs.common.annotation.Excel;
 import lombok.Data;
 import lombok.experimental.Accessors;
@@ -12,7 +12,7 @@ import java.util.List;
 
 /**
  * 客户信息对象 cc_cust_info
- * 
+ *
  * @author ruoyi
  * @date 2025-01-03
  */

+ 62 - 0
fs-service/src/main/java/com/fs/aiSipCall/mapper/AiSipAutoCallRulesMapper.java

@@ -0,0 +1,62 @@
+package com.fs.aiSipCall.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.aiSipCall.domain.AiSipAutoCallRules;
+
+import java.util.List;
+
+/**
+ * sip自动外呼屏蔽规则Mapper接口
+ *
+ * @author fs
+ * @date 2026-07-07
+ */
+public interface AiSipAutoCallRulesMapper extends BaseMapper<AiSipAutoCallRules>{
+    /**
+     * 查询sip自动外呼屏蔽规则
+     *
+     * @param id sip自动外呼屏蔽规则主键
+     * @return sip自动外呼屏蔽规则
+     */
+    AiSipAutoCallRules selectAiSipAutoCallRulesById(Long id);
+
+    /**
+     * 查询sip自动外呼屏蔽规则列表
+     *
+     * @param aiSipAutoCallRules sip自动外呼屏蔽规则
+     * @return sip自动外呼屏蔽规则集合
+     */
+    List<AiSipAutoCallRules> selectAiSipAutoCallRulesList(AiSipAutoCallRules aiSipAutoCallRules);
+
+    /**
+     * 新增sip自动外呼屏蔽规则
+     *
+     * @param aiSipAutoCallRules sip自动外呼屏蔽规则
+     * @return 结果
+     */
+    int insertAiSipAutoCallRules(AiSipAutoCallRules aiSipAutoCallRules);
+
+    /**
+     * 修改sip自动外呼屏蔽规则
+     *
+     * @param aiSipAutoCallRules sip自动外呼屏蔽规则
+     * @return 结果
+     */
+    int updateAiSipAutoCallRules(AiSipAutoCallRules aiSipAutoCallRules);
+
+    /**
+     * 删除sip自动外呼屏蔽规则
+     *
+     * @param id sip自动外呼屏蔽规则主键
+     * @return 结果
+     */
+    int deleteAiSipAutoCallRulesById(Long id);
+
+    /**
+     * 批量删除sip自动外呼屏蔽规则
+     *
+     * @param ids 需要删除的数据主键集合
+     * @return 结果
+     */
+    int deleteAiSipAutoCallRulesByIds(Long[] ids);
+}

+ 7 - 7
fs-service/src/main/java/com/fs/aiSipCall/mapper/AiSipCallBizGroupMapper.java

@@ -7,14 +7,14 @@ import java.util.List;
 
 /**
  * aiSIP外呼技能组Mapper接口
- * 
+ *
  * @author fs
  * @date 2026-03-06
  */
 public interface AiSipCallBizGroupMapper extends BaseMapper<AiSipCallBizGroup>{
     /**
      * 查询aiSIP外呼技能组
-     * 
+     *
      * @param groupId aiSIP外呼技能组主键
      * @return aiSIP外呼技能组
      */
@@ -22,7 +22,7 @@ public interface AiSipCallBizGroupMapper extends BaseMapper<AiSipCallBizGroup>{
 
     /**
      * 查询aiSIP外呼技能组列表
-     * 
+     *
      * @param aiSipCallBizGroup aiSIP外呼技能组
      * @return aiSIP外呼技能组集合
      */
@@ -30,7 +30,7 @@ public interface AiSipCallBizGroupMapper extends BaseMapper<AiSipCallBizGroup>{
 
     /**
      * 新增aiSIP外呼技能组
-     * 
+     *
      * @param aiSipCallBizGroup aiSIP外呼技能组
      * @return 结果
      */
@@ -38,7 +38,7 @@ public interface AiSipCallBizGroupMapper extends BaseMapper<AiSipCallBizGroup>{
 
     /**
      * 修改aiSIP外呼技能组
-     * 
+     *
      * @param aiSipCallBizGroup aiSIP外呼技能组
      * @return 结果
      */
@@ -46,7 +46,7 @@ public interface AiSipCallBizGroupMapper extends BaseMapper<AiSipCallBizGroup>{
 
     /**
      * 删除aiSIP外呼技能组
-     * 
+     *
      * @param groupId aiSIP外呼技能组主键
      * @return 结果
      */
@@ -54,7 +54,7 @@ public interface AiSipCallBizGroupMapper extends BaseMapper<AiSipCallBizGroup>{
 
     /**
      * 批量删除aiSIP外呼技能组
-     * 
+     *
      * @param groupIds 需要删除的数据主键集合
      * @return 结果
      */

+ 7 - 7
fs-service/src/main/java/com/fs/aiSipCall/mapper/AiSipCallGatewayMapper.java

@@ -7,14 +7,14 @@ import java.util.List;
 
 /**
  * aiSIP外呼网关Mapper接口
- * 
+ *
  * @author fs
  * @date 2026-03-06
  */
 public interface AiSipCallGatewayMapper extends BaseMapper<AiSipCallGateway>{
     /**
      * 查询aiSIP外呼网关
-     * 
+     *
      * @param id aiSIP外呼网关主键
      * @return aiSIP外呼网关
      */
@@ -22,7 +22,7 @@ public interface AiSipCallGatewayMapper extends BaseMapper<AiSipCallGateway>{
 
     /**
      * 查询aiSIP外呼网关列表
-     * 
+     *
      * @param aiSipCallGateway aiSIP外呼网关
      * @return aiSIP外呼网关集合
      */
@@ -30,7 +30,7 @@ public interface AiSipCallGatewayMapper extends BaseMapper<AiSipCallGateway>{
 
     /**
      * 新增aiSIP外呼网关
-     * 
+     *
      * @param aiSipCallGateway aiSIP外呼网关
      * @return 结果
      */
@@ -38,7 +38,7 @@ public interface AiSipCallGatewayMapper extends BaseMapper<AiSipCallGateway>{
 
     /**
      * 修改aiSIP外呼网关
-     * 
+     *
      * @param aiSipCallGateway aiSIP外呼网关
      * @return 结果
      */
@@ -46,7 +46,7 @@ public interface AiSipCallGatewayMapper extends BaseMapper<AiSipCallGateway>{
 
     /**
      * 删除aiSIP外呼网关
-     * 
+     *
      * @param id aiSIP外呼网关主键
      * @return 结果
      */
@@ -54,7 +54,7 @@ public interface AiSipCallGatewayMapper extends BaseMapper<AiSipCallGateway>{
 
     /**
      * 批量删除aiSIP外呼网关
-     * 
+     *
      * @param ids 需要删除的数据主键集合
      * @return 结果
      */

+ 7 - 7
fs-service/src/main/java/com/fs/aiSipCall/mapper/AiSipCallLlmAgentAccountMapper.java

@@ -7,14 +7,14 @@ import java.util.List;
 
 /**
  * aiSIP外呼大模型Mapper接口
- * 
+ *
  * @author fs
  * @date 2026-03-06
  */
 public interface AiSipCallLlmAgentAccountMapper extends BaseMapper<AiSipCallLlmAgentAccount>{
     /**
      * 查询aiSIP外呼大模型
-     * 
+     *
      * @param id aiSIP外呼大模型主键
      * @return aiSIP外呼大模型
      */
@@ -22,7 +22,7 @@ public interface AiSipCallLlmAgentAccountMapper extends BaseMapper<AiSipCallLlmA
 
     /**
      * 查询aiSIP外呼大模型列表
-     * 
+     *
      * @param aiSipCallLlmAgentAccount aiSIP外呼大模型
      * @return aiSIP外呼大模型集合
      */
@@ -30,7 +30,7 @@ public interface AiSipCallLlmAgentAccountMapper extends BaseMapper<AiSipCallLlmA
 
     /**
      * 新增aiSIP外呼大模型
-     * 
+     *
      * @param aiSipCallLlmAgentAccount aiSIP外呼大模型
      * @return 结果
      */
@@ -38,7 +38,7 @@ public interface AiSipCallLlmAgentAccountMapper extends BaseMapper<AiSipCallLlmA
 
     /**
      * 修改aiSIP外呼大模型
-     * 
+     *
      * @param aiSipCallLlmAgentAccount aiSIP外呼大模型
      * @return 结果
      */
@@ -46,7 +46,7 @@ public interface AiSipCallLlmAgentAccountMapper extends BaseMapper<AiSipCallLlmA
 
     /**
      * 删除aiSIP外呼大模型
-     * 
+     *
      * @param id aiSIP外呼大模型主键
      * @return 结果
      */
@@ -54,7 +54,7 @@ public interface AiSipCallLlmAgentAccountMapper extends BaseMapper<AiSipCallLlmA
 
     /**
      * 批量删除aiSIP外呼大模型
-     * 
+     *
      * @param ids 需要删除的数据主键集合
      * @return 结果
      */

+ 23 - 7
fs-service/src/main/java/com/fs/aiSipCall/mapper/AiSipCallOutboundCdrMapper.java

@@ -2,6 +2,8 @@ package com.fs.aiSipCall.mapper;
 
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.fs.aiSipCall.domain.AiSipCallOutboundCdr;
+import com.fs.aiSipCall.param.StatisticalManagementParam;
+import com.fs.aiSipCall.vo.StatisticalManagementVo;
 import org.apache.ibatis.annotations.Param;
 import org.apache.ibatis.annotations.Select;
 
@@ -9,14 +11,14 @@ import java.util.List;
 
 /**
  * aiSIP手动外呼通话记录Mapper接口
- * 
+ *
  * @author fs
  * @date 2026-03-19
  */
 public interface AiSipCallOutboundCdrMapper extends BaseMapper<AiSipCallOutboundCdr>{
     /**
      * 查询aiSIP手动外呼通话记录
-     * 
+     *
      * @param id aiSIP手动外呼通话记录主键
      * @return aiSIP手动外呼通话记录
      */
@@ -24,7 +26,7 @@ public interface AiSipCallOutboundCdrMapper extends BaseMapper<AiSipCallOutbound
 
     /**
      * 查询aiSIP手动外呼通话记录列表
-     * 
+     *
      * @param aiSipCallOutboundCdr aiSIP手动外呼通话记录
      * @return aiSIP手动外呼通话记录集合
      */
@@ -32,7 +34,7 @@ public interface AiSipCallOutboundCdrMapper extends BaseMapper<AiSipCallOutbound
 
     /**
      * 新增aiSIP手动外呼通话记录
-     * 
+     *
      * @param aiSipCallOutboundCdr aiSIP手动外呼通话记录
      * @return 结果
      */
@@ -40,7 +42,7 @@ public interface AiSipCallOutboundCdrMapper extends BaseMapper<AiSipCallOutbound
 
     /**
      * 修改aiSIP手动外呼通话记录
-     * 
+     *
      * @param aiSipCallOutboundCdr aiSIP手动外呼通话记录
      * @return 结果
      */
@@ -48,7 +50,7 @@ public interface AiSipCallOutboundCdrMapper extends BaseMapper<AiSipCallOutbound
 
     /**
      * 删除aiSIP手动外呼通话记录
-     * 
+     *
      * @param id aiSIP手动外呼通话记录主键
      * @return 结果
      */
@@ -56,7 +58,7 @@ public interface AiSipCallOutboundCdrMapper extends BaseMapper<AiSipCallOutbound
 
     /**
      * 批量删除aiSIP手动外呼通话记录
-     * 
+     *
      * @param ids 需要删除的数据主键集合
      * @return 结果
      */
@@ -68,4 +70,18 @@ public interface AiSipCallOutboundCdrMapper extends BaseMapper<AiSipCallOutbound
      */
     @Select("SELECT * FROM ai_sip_call_outbound_cdr WHERE start_time >= #{startTime} AND start_time <= #{endTime}")
     List<AiSipCallOutboundCdr> selectCurrentDayCallRecords(@Param("startTime") Long startTime, @Param("endTime") Long endTime);
+
+    List<Long> getTodayChangeUserIds();
+
+    List<AiSipCallOutboundCdr> findWaitEncryptPhones(@Param("currentDateMillis") Long currentDateMillis, @Param("batchSize") Integer batchSize);
+
+    int updateEncryptPhone(@Param("target") List<AiSipCallOutboundCdr> target);
+
+    List<StatisticalManagementVo> statisticalManagement(StatisticalManagementParam param);
+
+    @Select("SELECT id FROM ai_sip_call_outbound_cdr where start_time >= #{startTime} AND start_time <= #{endTime} ORDER BY id LIMIT #{offset}, #{pageSize}")
+    List<String> selectIdListPage(@Param("offset") int offset, @Param("pageSize") int pageSize,@Param("startTime") Long startTime,@Param("endTime") Long endTime);
+
+
+    int batchManualPullUpdate(@Param("list") List<AiSipCallOutboundCdr> list);
 }

+ 90 - 15
fs-service/src/main/java/com/fs/aiSipCall/mapper/AiSipCallPhoneMapper.java

@@ -4,21 +4,27 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.fs.aiSipCall.domain.AiSipCallPhone;
 import com.fs.aiSipCall.dto.CallTaskStatModel;
 import com.fs.aiSipCall.dto.CommonPhoneModel;
+import com.fs.aiSipCall.param.StatisticalManagementParam;
+import com.fs.aiSipCall.vo.AiSipCallPhoneInSopVo;
+import com.fs.aiSipCall.vo.StatisticalManagementVo;
+import com.fs.sop.params.QwSopLogsParam;
+import com.fs.sop.vo.QwSopLogsListCVO;
 import org.apache.ibatis.annotations.Param;
 import org.apache.ibatis.annotations.Select;
+import org.apache.ibatis.annotations.Update;
 
 import java.util.List;
 
 /**
  * aiSIP外呼通话记录Mapper接口
- * 
+ *
  * @author fs
  * @date 2026-03-06
  */
 public interface AiSipCallPhoneMapper extends BaseMapper<AiSipCallPhone>{
     /**
      * 查询aiSIP外呼通话记录
-     * 
+     *
      * @param id aiSIP外呼通话记录主键
      * @return aiSIP外呼通话记录
      */
@@ -26,7 +32,7 @@ public interface AiSipCallPhoneMapper extends BaseMapper<AiSipCallPhone>{
 
     /**
      * 查询aiSIP外呼通话记录列表
-     * 
+     *
      * @param aiSipCallPhone aiSIP外呼通话记录
      * @return aiSIP外呼通话记录集合
      */
@@ -34,7 +40,7 @@ public interface AiSipCallPhoneMapper extends BaseMapper<AiSipCallPhone>{
 
     /**
      * 新增aiSIP外呼通话记录
-     * 
+     *
      * @param aiSipCallPhone aiSIP外呼通话记录
      * @return 结果
      */
@@ -42,7 +48,7 @@ public interface AiSipCallPhoneMapper extends BaseMapper<AiSipCallPhone>{
 
     /**
      * 修改aiSIP外呼通话记录
-     * 
+     *
      * @param aiSipCallPhone aiSIP外呼通话记录
      * @return 结果
      */
@@ -50,7 +56,7 @@ public interface AiSipCallPhoneMapper extends BaseMapper<AiSipCallPhone>{
 
     /**
      * 删除aiSIP外呼通话记录
-     * 
+     *
      * @param id aiSIP外呼通话记录主键
      * @return 结果
      */
@@ -58,11 +64,12 @@ public interface AiSipCallPhoneMapper extends BaseMapper<AiSipCallPhone>{
 
     /**
      * 批量删除aiSIP外呼通话记录
-     * 
+     *
      * @param ids 需要删除的数据主键集合
      * @return 结果
      */
     int deleteAiSipCallPhoneByIds(String[] ids);
+
     /**
      * 根据batchId统计外呼数据
      * @param batchId 任务id
@@ -71,15 +78,22 @@ public interface AiSipCallPhoneMapper extends BaseMapper<AiSipCallPhone>{
     CallTaskStatModel statByBatchId(Long batchId);
 
     /**
-     * 验证是否重复录入
-     * @param batchId   任务Id
+     * 根据batchId统计外呼数据
+     * @param batchIds 任务ids
+     * @return 结果
+     */
+    List<CallTaskStatModel> statByBatchIds(List<Long> batchIds);
+
+    /**
+     * 验证是否重复录入(按本地批次 id;已呼叫/已导入均视为占用,避免同号码重复外呼)
+     * @param batchId   本地任务Id(对应 local_batch_id)
      * @param phoneList 电话号码
      * @return List<String> 重复的电话
      */
     @Select("<script>" +
             "SELECT DISTINCT telephone FROM ai_sip_call_phone " +
-            "WHERE batch_id = #{batchId} " +
-            "AND callstatus = 0 " +
+            "WHERE local_batch_id = #{batchId} " +
+            "AND STATUS = 0 " +
             "AND telephone IN " +
             "<foreach item='phone' collection='phoneList' open='(' separator=',' close=')'>" +
             "#{phone.phoneNum}" +
@@ -88,9 +102,70 @@ public interface AiSipCallPhoneMapper extends BaseMapper<AiSipCallPhone>{
     List<String> isDuplicateEntry(@Param("batchId") Long batchId,@Param("phoneList")  List<CommonPhoneModel> phoneList);
 
     /**
-     * 查询当天的外呼记录 ID 列表
-     * @return 当天的外呼记录 ID 列表
+     * 查询本地批次下已存在的 fs_user_id(status=0 有效记录),用于同批次防重复外呼
+     */
+    @Select("SELECT DISTINCT fs_user_id FROM ai_sip_call_phone " +
+            "WHERE local_batch_id = #{localBatchId} " +
+            "AND STATUS = 0 " +
+            "AND fs_user_id IS NOT NULL AND fs_user_id != 0")
+    List<Long> selectDistinctFsUserIdsByLocalBatchId(@Param("localBatchId") Long localBatchId);
+
+    /**
+     * 查询时间段内呼叫状态为待呼叫,没有通话uuid且数据正常的外呼记录
+     * @return 外呼记录
+     */
+    @Select("SELECT * FROM ai_sip_call_phone " +
+            "WHERE " +
+            "callstatus IN ( 0, 1, 2 ) " +
+            "AND STATUS = 0 " +
+            "AND createtime >= #{startMillis} AND createtime <= #{endMillis}")
+    List<AiSipCallPhone> selectCurrentDayCallRecords(@Param("startMillis") Long startMillis, @Param("endMillis") Long endMillis);
+
+    @Update("<script>" +
+            "update ai_sip_call_phone set status = 1 where local_batch_id in " +
+            "<foreach collection='batchIds' item='id' open='(' separator=',' close=')'>" +
+            "#{id}" +
+            "</foreach>" +
+            "</script>")
+    int logicalDeletionByBatchIds(@Param("batchIds") Long[] batchIds);
+
+    List<QwSopLogsListCVO> selectQwSipLogsListBySopId(@Param("map") QwSopLogsParam param);
+
+    List<Long> getTodayChangeUserIds();
+
+    List<AiSipCallPhoneInSopVo> hisSopList(AiSipCallPhone aiSipCallPhone);
+
+    List<StatisticalManagementVo> statisticalManagement(StatisticalManagementParam param);
+
+    List<AiSipCallPhone> findWaitEncryptPhones(@Param("currentDateMillis") Long currentDateMillis, @Param("batchSize") Integer batchSize);
+
+    int updateEncryptPhone(@Param("target") List<AiSipCallPhone> target);
+
+    /**
+     * 查询指定 App SOP 任务下缺失 fsUserId 的外呼记录
+     */
+    @Select("SELECT id, telephone, fs_user_id FROM ai_sip_call_phone " +
+            "WHERE execute_type = '1' AND sip_task_id = #{sipTaskId} AND app_customer_id = #{appCustomerId} " +
+            "AND (fs_user_id IS NULL OR fs_user_id = 0)")
+    List<AiSipCallPhone> selectMissingFsUserIdPhones(@Param("sipTaskId") Long sipTaskId,
+                                                     @Param("appCustomerId") Long appCustomerId);
+
+    /**
+     * 按任务查询缺失 fsUserId 的外呼记录(不限制 app_customer_id,用于 syncPeriod 兜底)
+     */
+    @Select("SELECT id, telephone, fs_user_id FROM ai_sip_call_phone " +
+            "WHERE execute_type = '1' AND sip_task_id = #{sipTaskId} " +
+            "AND (fs_user_id IS NULL OR fs_user_id = 0)")
+    List<AiSipCallPhone> selectMissingFsUserIdPhonesByTaskId(@Param("sipTaskId") Long sipTaskId);
+
+    /**
+     * 批量回填 fs_user_id(仅更新仍为空的记录,避免并发覆盖)
+     */
+    int batchUpdateFsUserId(@Param("list") List<AiSipCallPhone> list);
+    /**
+     * 根据 SIP 任务 ID和执行类型查询外呼记录
      */
-    @Select("SELECT * FROM ai_sip_call_phone WHERE callout_time >= #{startTime} AND callout_time <= #{endTime}")
-    List<AiSipCallPhone> selectCurrentDayCallRecords(@Param("startTime") Long startTime, @Param("endTime") Long endTime);
+    @Select("SELECT id FROM ai_sip_call_phone WHERE execute_type = #{executeType} AND sip_task_id = #{sipTaskId}")
+    List<AiSipCallPhone> selectBySipTaskId(@Param("sipTaskId") Long sipTaskId,
+                                   @Param("executeType") String executeType);
 }

+ 33 - 7
fs-service/src/main/java/com/fs/aiSipCall/mapper/AiSipCallTaskMapper.java

@@ -4,19 +4,20 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.fs.aiSipCall.domain.AiSipCallTask;
 import org.apache.ibatis.annotations.Param;
 import org.apache.ibatis.annotations.Select;
+import org.apache.ibatis.annotations.Update;
 
 import java.util.List;
 
 /**
  * aiSIP外呼任务Mapper接口
- * 
+ *
  * @author fs
  * @date 2026-03-06
  */
 public interface AiSipCallTaskMapper extends BaseMapper<AiSipCallTask>{
     /**
      * 查询aiSIP外呼任务
-     * 
+     *
      * @param batchId aiSIP外呼任务主键
      * @return aiSIP外呼任务
      */
@@ -24,7 +25,7 @@ public interface AiSipCallTaskMapper extends BaseMapper<AiSipCallTask>{
 
     /**
      * 查询aiSIP外呼任务列表
-     * 
+     *
      * @param aiSipCallTask aiSIP外呼任务
      * @return aiSIP外呼任务集合
      */
@@ -32,7 +33,7 @@ public interface AiSipCallTaskMapper extends BaseMapper<AiSipCallTask>{
 
     /**
      * 新增aiSIP外呼任务
-     * 
+     *
      * @param aiSipCallTask aiSIP外呼任务
      * @return 结果
      */
@@ -40,7 +41,7 @@ public interface AiSipCallTaskMapper extends BaseMapper<AiSipCallTask>{
 
     /**
      * 修改aiSIP外呼任务
-     * 
+     *
      * @param aiSipCallTask aiSIP外呼任务
      * @return 结果
      */
@@ -48,7 +49,7 @@ public interface AiSipCallTaskMapper extends BaseMapper<AiSipCallTask>{
 
     /**
      * 删除aiSIP外呼任务
-     * 
+     *
      * @param batchId aiSIP外呼任务主键
      * @return 结果
      */
@@ -56,12 +57,37 @@ public interface AiSipCallTaskMapper extends BaseMapper<AiSipCallTask>{
 
     /**
      * 批量删除aiSIP外呼任务
-     * 
+     *
      * @param batchIds 需要删除的数据主键集合
      * @return 结果
      */
     int deleteAiSipCallTaskByBatchIds(Long[] batchIds);
 
+    @Update("<script>" +
+            "update ai_sip_call_task set status = 1 where batch_id in " +
+            "<foreach collection='batchIds' item='id' open='(' separator=',' close=')'>" +
+            "#{id}" +
+            "</foreach>" +
+            "</script>")
+    int logicalDeletionByBatchIds(@Param("batchIds") Long[] batchIds);
+
+    @Select("<script>" +
+            "select * from ai_sip_call_task where batch_id in " +
+            "<foreach collection='batchIds' item='id' open='(' separator=',' close=')'>" +
+            "#{id}" +
+            "</foreach>" +
+            "</script>")
+    List<AiSipCallTask> selectAiSipCallTaskByBatchIds(@Param("batchIds") List<Long> batchIds);
+
+    @Select("<script>" +
+            "select * from ai_sip_call_task where batch_id in " +
+            "<foreach collection='list' item='id' open='(' separator=',' close=')'>" +
+            "#{id}" +
+            "</foreach>" +
+            "</script>")
+    List<AiSipCallTask> selectByIds(@Param("list") List<Long> localBatchIds);
+
+
     @Select("select * from ai_sip_call_task where remote_batch_id = #{remoteBatchId} limit 1")
     AiSipCallTask selectAiSipCallTaskByRemoteBatchId(@Param("remoteBatchId") Long remoteBatchId);
 }

+ 16 - 7
fs-service/src/main/java/com/fs/aiSipCall/mapper/AiSipCallUserMapper.java

@@ -2,19 +2,21 @@ package com.fs.aiSipCall.mapper;
 
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.fs.aiSipCall.domain.AiSipCallUser;
+import org.apache.ibatis.annotations.Param;
 
 import java.util.List;
+import java.util.Set;
 
 /**
  * sip用户信息Mapper接口
- * 
+ *
  * @author fs
  * @date 2026-03-13
  */
 public interface AiSipCallUserMapper extends BaseMapper<AiSipCallUser>{
     /**
      * 查询sip用户信息
-     * 
+     *
      * @param userId sip用户信息主键
      * @return sip用户信息
      */
@@ -22,7 +24,7 @@ public interface AiSipCallUserMapper extends BaseMapper<AiSipCallUser>{
 
     /**
      * 查询sip用户信息列表
-     * 
+     *
      * @param aiSipCallUser sip用户信息
      * @return sip用户信息集合
      */
@@ -30,7 +32,7 @@ public interface AiSipCallUserMapper extends BaseMapper<AiSipCallUser>{
 
     /**
      * 新增sip用户信息
-     * 
+     *
      * @param aiSipCallUser sip用户信息
      * @return 结果
      */
@@ -38,7 +40,7 @@ public interface AiSipCallUserMapper extends BaseMapper<AiSipCallUser>{
 
     /**
      * 修改sip用户信息
-     * 
+     *
      * @param aiSipCallUser sip用户信息
      * @return 结果
      */
@@ -46,7 +48,7 @@ public interface AiSipCallUserMapper extends BaseMapper<AiSipCallUser>{
 
     /**
      * 删除sip用户信息
-     * 
+     *
      * @param userId sip用户信息主键
      * @return 结果
      */
@@ -54,9 +56,16 @@ public interface AiSipCallUserMapper extends BaseMapper<AiSipCallUser>{
 
     /**
      * 批量删除sip用户信息
-     * 
+     *
      * @param userIds 需要删除的数据主键集合
      * @return 结果
      */
     int deleteAiSipCallUserByUserIds(Long[] userIds);
+
+    /**
+     * 查询分机号对应的坐席id
+     * @param extNumList 分机号集合
+     * @return 坐席id集合
+     */
+    List<Long> selectByExtNumList(@Param("extNumList") Set<Long> extNumList);
 }

+ 7 - 7
fs-service/src/main/java/com/fs/aiSipCall/mapper/AiSipCallVoiceTtsAliyunMapper.java

@@ -7,14 +7,14 @@ import java.util.List;
 
 /**
  * aiSIP外呼阿里云音色Mapper接口
- * 
+ *
  * @author fs
  * @date 2026-03-06
  */
 public interface AiSipCallVoiceTtsAliyunMapper extends BaseMapper<AiSipCallVoiceTtsAliyun>{
     /**
      * 查询aiSIP外呼阿里云音色
-     * 
+     *
      * @param id aiSIP外呼阿里云音色主键
      * @return aiSIP外呼阿里云音色
      */
@@ -22,7 +22,7 @@ public interface AiSipCallVoiceTtsAliyunMapper extends BaseMapper<AiSipCallVoice
 
     /**
      * 查询aiSIP外呼阿里云音色列表
-     * 
+     *
      * @param aiSipCallVoiceTtsAliyun aiSIP外呼阿里云音色
      * @return aiSIP外呼阿里云音色集合
      */
@@ -30,7 +30,7 @@ public interface AiSipCallVoiceTtsAliyunMapper extends BaseMapper<AiSipCallVoice
 
     /**
      * 新增aiSIP外呼阿里云音色
-     * 
+     *
      * @param aiSipCallVoiceTtsAliyun aiSIP外呼阿里云音色
      * @return 结果
      */
@@ -38,7 +38,7 @@ public interface AiSipCallVoiceTtsAliyunMapper extends BaseMapper<AiSipCallVoice
 
     /**
      * 修改aiSIP外呼阿里云音色
-     * 
+     *
      * @param aiSipCallVoiceTtsAliyun aiSIP外呼阿里云音色
      * @return 结果
      */
@@ -46,7 +46,7 @@ public interface AiSipCallVoiceTtsAliyunMapper extends BaseMapper<AiSipCallVoice
 
     /**
      * 删除aiSIP外呼阿里云音色
-     * 
+     *
      * @param id aiSIP外呼阿里云音色主键
      * @return 结果
      */
@@ -54,7 +54,7 @@ public interface AiSipCallVoiceTtsAliyunMapper extends BaseMapper<AiSipCallVoice
 
     /**
      * 批量删除aiSIP外呼阿里云音色
-     * 
+     *
      * @param ids 需要删除的数据主键集合
      * @return 结果
      */

+ 19 - 2
fs-service/src/main/java/com/fs/aiSipCall/param/ApiCallRecordQueryParams.java

@@ -10,7 +10,8 @@ public class ApiCallRecordQueryParams implements Serializable {
 
     private Integer pageNum;
     private Integer pageSize;
-
+    /** uuid */
+    private String uuid;
     /** 类型(01:呼入, 02:AI外呼, 03:人工外呼) */
     private String callType;
     /** 任务ID */
@@ -28,9 +29,25 @@ public class ApiCallRecordQueryParams implements Serializable {
     /** 接听时间起止 */
     private String answeredTimeStart;
     private String answeredTimeEnd;
-    /** 挂机时间起止 */
+    /** 自动外呼挂机时间起止 */
     private String callEndTimeStart;
     private String callEndTimeEnd;
+    /** 手动外呼挂机时间起止 */
+    private String endTimeStart;
+    private String endTimeEnd;
+
+    /** 创建时间起止 */
+    private String createtimeStart;
+    private String createtimeEnd;
+
     /** 分机号 */
     private String extnum;
+
+    /** callType=02时可用 */
+    /** 0. 未拨打;1. 排队中;2. 正在拨打 ;3. 未接通 ;6. 成功转接;7. 线路故障 */
+    private Integer callstatus;
+
+    /** callType=02时可用 */
+    /** 主叫号码 */
+    private String callerNumber;
 }

+ 53 - 0
fs-service/src/main/java/com/fs/aiSipCall/param/StatisticalManagementParam.java

@@ -0,0 +1,53 @@
+package com.fs.aiSipCall.param;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * @Author:peicj
+ * @Description: 统计sip通话记录查询参数
+ * @Date:2026/5/26 9:53
+ */
+@Data
+public class StatisticalManagementParam {
+
+    /**
+     * 页码
+     */
+    private Integer pageNum;
+    /**
+     * 每页数量
+     */
+    private Integer pageSize;
+    /**
+     * 开始时间 格式:yyyy-MM-dd
+     */
+    private String startTime;
+    private Long startTimeLong;
+    /**
+     * 结束时间 格式:yyyy-MM-dd
+     */
+    private String endTime;
+    private Long endTimeLong;
+    /**
+     * 统计类型 0:自动外呼记录统计 1:手动外呼记录统计
+     */
+    private Integer type;
+    /**
+     * 公司ID
+     */
+    private Long companyId;
+    /**
+     * 公司销售ID
+     */
+    private Long companyUserId;
+    /**
+     * 部门ID
+     */
+    private Long deptId;
+    /**
+     * 部门ID集合
+     */
+    private List<Long> deptIds;
+}

+ 45 - 0
fs-service/src/main/java/com/fs/aiSipCall/service/AiSipCallTransactionService.java

@@ -0,0 +1,45 @@
+package com.fs.aiSipCall.service;
+
+import com.fs.aiSipCall.domain.AiSipCallPhone;
+import com.fs.aiSipCall.service.impl.AiSipCallPhoneServiceImpl;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+/**
+ * @Author:peicj
+ * @Description: SIP通话记录事务服务(用于解决同类方法调用事务不生效问题)
+ * @Date:2026/4/16
+ */
+@Slf4j
+@Service
+public class AiSipCallTransactionService {
+
+    @Lazy
+    @Autowired
+    private AiSipCallPhoneServiceImpl callPhoneServiceImpl;
+
+    /**
+     * 批量更新通话记录(带事务)
+     * @param dataList 待更新的数据列表
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public void batchUpdateCallRecords(List<AiSipCallPhone> dataList) {
+        if (dataList == null || dataList.isEmpty()) {
+            log.warn("批量更新通话记录:数据列表为空,跳过更新");
+            return;
+        }
+        
+        try {
+            callPhoneServiceImpl.updateBatchById(dataList);
+            log.info("批量更新通话记录成功,共{}条", dataList.size());
+        } catch (Exception e) {
+            log.error("批量更新通话记录失败,共{}条,将触发事务回滚", dataList.size(), e);
+            throw e; // 抛出异常触发事务回滚
+        }
+    }
+}

+ 372 - 0
fs-service/src/main/java/com/fs/aiSipCall/service/AsyncAddSipCallRecordService.java

@@ -0,0 +1,372 @@
+package com.fs.aiSipCall.service;
+
+import com.alibaba.fastjson.JSONObject;
+import com.fs.aiSipCall.RemoteCommon;
+import com.fs.aiSipCall.domain.AiSipCallPhone;
+import com.fs.aiSipCall.service.impl.AiSipCallPhoneServiceImpl;
+import com.fs.aiSipCall.utils.SipLogUtil;
+import lombok.AllArgsConstructor;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
+
+import java.net.URLDecoder;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * @Author:peicj
+ * @Description: 异步执行sip获取记录
+ * @Date:2026/4/15 17:29
+ */
+@Service
+@AllArgsConstructor
+public class AsyncAddSipCallRecordService {
+
+    private static final Logger log = SipLogUtil.createSipLogger(AsyncAddSipCallRecordService.class);
+
+    private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+    @Autowired
+    private AiSipCallPhoneServiceImpl callPhoneServiceImpl;
+
+    @Autowired
+    private AiSipCallTransactionService transactionService;
+
+    /**
+     * 将字符串时间转化为long类型时间戳(毫秒)
+     * @param timeStr 时间字符串,支持格式: yyyy-MM-dd HH:mm:ss
+     * @return 时间戳(毫秒),转换失败返回null
+     */
+    private Long parseTimeToMillis(String timeStr) {
+        if (StringUtils.isBlank(timeStr)) {
+            return null;
+        }
+        try {
+            LocalDateTime localDateTime = LocalDateTime.parse(timeStr.trim(), FORMATTER);
+            return localDateTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
+        } catch (Exception e) {
+            log.error("sip同步电话时间字符串转换失败: {}", timeStr, e);
+            return null;
+        }
+    }
+
+    /**
+     * 异步拉取通话记录
+     * @param startTime 字符串时间参数起始位
+     * @param endTime   字符串时间参数停止位
+     * @param type   哪里调用的函数
+     */
+    @Async("scheduledExecutorService")
+    public void getAutomaticCallRecord(String startTime, String endTime,String type){
+        // 默认自动外呼
+        String callTypeStr = "自动外呼";
+        long currentStartTime = System.currentTimeMillis();
+        StringBuilder logBuilder = new StringBuilder();
+        boolean hasData;
+
+        try {
+            // 参数校验
+            if (StringUtils.isBlank(startTime) || StringUtils.isBlank(endTime)) {
+                log.error("sip{}同步电话 异步任务执行:{} 失败,时间参数为空: startTime={}, endTime={}", callTypeStr,type,  startTime, endTime);
+                return;
+            }
+
+            // 获取本地当前时间段内通话记录
+            Long startMillis = parseTimeToMillis(startTime);
+            Long endMillis = parseTimeToMillis(endTime);
+
+            if (startMillis == null || endMillis == null) {
+                log.error("sip{}同步电话 异步任务执行:{} 失败,时间格式转换失败: startTime={}, endTime={}", callTypeStr,type,  startTime, endTime);
+                return;
+            }
+            // 获取本地当前时间段内待呼叫的自动外呼记录
+            List<AiSipCallPhone> localList = callPhoneServiceImpl.selectCurrentDayCallRecords(startMillis, endMillis);
+            if (CollectionUtils.isEmpty(localList)) {
+                return ;
+            }
+            safeProcessAutoCallRecords(localList, callTypeStr, type, logBuilder);
+            hasData = !logBuilder.toString().contains("本地无数据") && !logBuilder.toString().contains("本地无有效ID");
+            long costTime = System.currentTimeMillis() - currentStartTime;
+
+            // 仅在有数据或发生错误时打印info日志,避免无数据时大量日志
+            if (hasData) {
+                log.info("sip{}同步电话 [{}] 执行完成 | {} | 总耗时:{}ms", callTypeStr, type, logBuilder, costTime);
+            }
+        } catch (Exception e) {
+            long costTime = System.currentTimeMillis() - currentStartTime;
+            log.error("sip{}同步电话 异步任务执行:{} 异常,耗时:{}ms", callTypeStr, type, costTime, e);
+        }
+    }
+
+    /**
+     * 安全调用 processAutoCallRecords,异常不向上抛出。
+     */
+    public void safeProcessAutoCallRecords(List<AiSipCallPhone> localList, String callTypeStr, String scene) {
+        safeProcessAutoCallRecords(localList, callTypeStr, scene, new StringBuilder());
+    }
+
+    /**
+     * 安全调用 processAutoCallRecords,异常不向上抛出,可选写入日志摘要。
+     */
+    public void safeProcessAutoCallRecords(List<AiSipCallPhone> localList, String callTypeStr, String scene,
+                                           StringBuilder logBuilder) {
+        if (CollectionUtils.isEmpty(localList)) {
+            if (logBuilder != null) {
+                logBuilder.append("本地无数据");
+            }
+            log.info("sip{}同步电话 [{}] 无待同步记录", callTypeStr, scene);
+            return;
+        }
+        StringBuilder builder = logBuilder != null ? logBuilder : new StringBuilder();
+        try {
+            processAutoCallRecords(localList, callTypeStr, builder);
+            log.info("sip{}同步电话 [{}] {}", callTypeStr, scene, builder);
+        } catch (Exception e) {
+            builder.append(" | 执行异常:").append(e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName());
+            log.error("sip{}同步电话 [{}] 执行异常", callTypeStr, scene, e);
+        }
+    }
+
+    /**
+     * 处理自动外呼记录
+     */
+    public String processAutoCallRecords(List<AiSipCallPhone> localList, String callTypeStr, StringBuilder logBuilder) {
+        if (CollectionUtils.isEmpty(localList)) {
+            if (logBuilder != null) {
+                logBuilder.append("本地无数据");
+            }
+            return "sip" + callTypeStr + "本地无数据";
+        }
+        if (logBuilder == null) {
+            logBuilder = new StringBuilder();
+        }
+        int localSize = localList.size();
+        log.info("sip{}同步电话 本地数据量:{}", callTypeStr, localSize);
+
+        // 收集本地的所有id
+        List<String> localIdList = localList.stream()
+                .map(AiSipCallPhone::getId)
+                .filter(StringUtils::isNotBlank)
+                .distinct()
+                .collect(Collectors.toList());
+
+        if (localIdList.isEmpty()) {
+            logBuilder.append(" | 本地无有效ID");
+            return "sip" + callTypeStr + "本地无有效ID";
+        }
+
+        // 将本地ID按200条分批查询远程接口
+        int batchSize = 200;
+        List<AiSipCallPhone> allRemoteData = new ArrayList<>();
+        int maxRetryCount = 3; // 最大重试次数,防止死循环
+
+        for (int i = 0; i < localIdList.size(); i += batchSize) {
+            int toIndex = Math.min(i + batchSize, localIdList.size());
+            List<String> batchIds = localIdList.subList(i, toIndex);
+
+            // 获取本批次远程数据,带重试机制
+            List<AiSipCallPhone> batchRemoteList = null;
+            int retryCount = 0;
+            while (retryCount < maxRetryCount) {
+                try {
+                    batchRemoteList = fetchSinglePageRecords(batchIds, callTypeStr);
+                    break; // 成功则跳出重试循环
+                } catch (Exception e) {
+                    retryCount++;
+                    log.error("sip{}同步电话 第{}批查询异常,重试次数:{}/{}", callTypeStr, (i / batchSize + 1), retryCount, maxRetryCount, e);
+                    if (retryCount >= maxRetryCount) {
+                        log.error("sip{}同步电话 第{}批查询达到最大重试次数,跳过本批次", callTypeStr, (i / batchSize + 1));
+                        batchRemoteList = Collections.emptyList();
+                    } else {
+                        try {
+                            Thread.sleep(500L * retryCount); // 递增等待时间
+                        } catch (InterruptedException ie) {
+                            Thread.currentThread().interrupt();
+                            log.error("sip{}同步电话 重试等待被中断", callTypeStr);
+                            batchRemoteList = Collections.emptyList();
+                            break;
+                        }
+                    }
+                }
+            }
+
+            if (batchRemoteList != null && !batchRemoteList.isEmpty()) {
+                allRemoteData.addAll(batchRemoteList);
+            }
+
+            // 避免频繁请求,适当休眠(最后一批不休眠)
+            if (toIndex < localIdList.size()) {
+                try {
+                    Thread.sleep(100);
+                } catch (InterruptedException e) {
+                    Thread.currentThread().interrupt();
+                    log.error("sip{}同步电话 分批查询被中断,已处理批次:{}/{}", callTypeStr, (i / batchSize + 1), (localIdList.size() + batchSize - 1) / batchSize);
+                    break; // 强制退出循环
+                }
+            }
+        }
+
+        if (allRemoteData.isEmpty()) {
+            logBuilder.append(" | 远程无数据");
+            return logBuilder.toString();
+        }else{
+            //处理一下电话字段数据
+            allRemoteData.forEach(record -> {
+                if (record != null) {
+                    if(StringUtils.isNotBlank(record.getTelephone())){
+                        try {
+                            record.setTelephone(URLDecoder.decode(record.getTelephone(), "UTF-8").replaceAll("=+$", ""));
+                        } catch (Throwable e) {
+                            log.error("同步自动外呼记录id:{}时处理telephone编码失败:{}",record.getId(),e.getMessage());
+                            record.setTelephone("");
+                        }
+                    }
+                    if(StringUtils.isNotBlank(record.getCallerNumber())){
+                        try {
+                            record.setCallerNumber(URLDecoder.decode(record.getCallerNumber(), "UTF-8").replaceAll("=+$", ""));
+                        } catch (Throwable e) {
+                            log.error("同步自动外呼记录id:{}时处理callerNumber编码失败:{}",record.getId(),e.getMessage());
+                            record.setCallerNumber("");
+                        }
+                    }
+                }
+            });
+        }
+
+        int remoteSize = allRemoteData.size();
+        logBuilder.append(" | 开始处理远程数据:").append(remoteSize).append("条");
+        return processDataWithRetry(allRemoteData, callTypeStr, logBuilder);
+    }
+
+
+
+    /**
+     * 获取数据
+     * @param batchIds 查询参数
+     * @return 通话记录列表
+     */
+    private List<AiSipCallPhone> fetchSinglePageRecords(List<String> batchIds,String callTypeStr) {
+        try {
+            String result = RemoteCommon.sendPost(
+                    RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.QUERY_CCCALLPHONE_RECORDS_BYID_API,
+                    JSONObject.toJSONString(batchIds)
+            );
+
+            if (StringUtils.isBlank(result)) {
+                log.error("sip{}同步电话 查询失败:接口返回为空,请求参数数量:{}", callTypeStr, batchIds != null ? batchIds.size() : 0);
+                return Collections.emptyList();
+            }
+
+            JSONObject jsonObject = JSONObject.parseObject(result);
+            Integer code = jsonObject.getInteger("code");
+
+            if (code == null || code != 0) {
+                String errorMsg = jsonObject.getString("msg") != null
+                        ? jsonObject.getString("msg") : "未知错误";
+                log.error("sip{}同步电话 查询失败,code:{}, msg:{}, 请求参数数量:{}", callTypeStr, code, errorMsg, batchIds != null ? batchIds.size() : 0);
+                return Collections.emptyList();
+            }
+
+            List<AiSipCallPhone> records;
+            String data = jsonObject.getString("data");
+            if(StringUtils.isNotBlank(data)){
+                records = JSONObject.parseArray(data, AiSipCallPhone.class);
+                return records;
+            }else{
+                log.error("sip{}同步电话 解析失败:data为空,完整响应:{}", callTypeStr, result);
+            }
+        } catch (Exception e) {
+            log.error("sip{}同步电话 查询异常,请求参数数量:{}", callTypeStr, batchIds != null ? batchIds.size() : 0, e);
+        }
+        return Collections.emptyList();
+    }
+
+    /**
+     * 处理批量数据并统计成功失败信息(支持失败重试)
+     * @param dataList 数据列表
+     * @return 处理结果信息
+     */
+    private String processDataWithRetry(List<AiSipCallPhone> dataList, String callTypeStr, StringBuilder logBuilder) {
+        int batchSize = 200;
+        int totalSize = dataList.size();
+        int successCount = 0;
+        int failCount = 0;
+        List<String> failedIds = new ArrayList<>();
+        List<String> successIds = new ArrayList<>();
+        int maxRetryPerRecord = 2; // 单条记录最大重试次数,防止死循环
+
+        for (int i = 0; i < dataList.size(); i += batchSize) {
+            int toIndex = Math.min(i + batchSize, dataList.size());
+            List<AiSipCallPhone> batchList = dataList.subList(i, toIndex);
+            int batchNum = i / batchSize + 1;
+
+            try {
+                // 批量更新
+                transactionService.batchUpdateCallRecords(batchList);
+                successCount += batchList.size();
+                batchList.forEach(record -> {
+                    if (record != null && StringUtils.isNotBlank(record.getId())) {
+                        successIds.add(record.getId());
+                    }
+                });
+            } catch (Exception e) {
+                log.error("sip{}同步电话 第{}批批量更新失败,开始逐条重试,数量:{}, 错误信息:{}",
+                        callTypeStr, batchNum, batchList.size(), e.getMessage());
+
+                // 逐条重试
+                for (AiSipCallPhone record : batchList) {
+                    if (record == null || StringUtils.isBlank(record.getId())) {
+                        failCount++;
+                        log.warn("sip{}同步电话 记录为空或ID为空,跳过", callTypeStr);
+                        continue;
+                    }
+
+                    boolean success = false;
+                    int retryCount = 0;
+                    while (!success && retryCount < maxRetryPerRecord) {
+                        try {
+                            transactionService.batchUpdateCallRecords(Collections.singletonList(record));
+                            successCount++;
+                            successIds.add(record.getId());
+                            success = true;
+                        } catch (Exception retryEx) {
+                            retryCount++;
+                            log.error("sip{}同步电话 记录ID:{} 更新失败,重试次数:{}/{}",
+                                    callTypeStr, record.getId(), retryCount, maxRetryPerRecord, retryEx);
+                            if (retryCount >= maxRetryPerRecord) {
+                                failCount++;
+                                failedIds.add(record.getId());
+                                log.error("sip{}同步电话 记录ID:{} 达到最大重试次数,标记为失败", callTypeStr, record.getId());
+                            }
+                        }
+                    }
+                }
+            }
+        }
+
+        // 构建结果信息
+        logBuilder.append(" | 处理结果:总").append(totalSize)
+                .append("/成功").append(successCount)
+                .append("/失败").append(failCount);
+
+        if (!successIds.isEmpty()) {
+            logBuilder.append(" | 最终成功ID数:").append(successIds.size());
+        }
+
+        if (!failedIds.isEmpty()) {
+            logBuilder.append(" | 最终失败ID数:").append(failedIds.size()).append(",最终失败ID列表:").append(String.join(",", failedIds));
+        }
+
+        return logBuilder.toString();
+    }
+
+}

+ 85 - 0
fs-service/src/main/java/com/fs/aiSipCall/service/IAiSipAutoCallRulesService.java

@@ -0,0 +1,85 @@
+package com.fs.aiSipCall.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.fs.aiSipCall.domain.AiSipAutoCallRules;
+
+import java.util.List;
+
+/**
+ * sip自动外呼屏蔽规则Service接口
+ *
+ * @author fs
+ * @date 2026-07-07
+ */
+public interface IAiSipAutoCallRulesService extends IService<AiSipAutoCallRules>{
+    /**
+     * 查询sip自动外呼屏蔽规则
+     *
+     * @param id sip自动外呼屏蔽规则主键
+     * @return sip自动外呼屏蔽规则
+     */
+    AiSipAutoCallRules selectAiSipAutoCallRulesById(Long id);
+
+    /**
+     * 查询sip自动外呼屏蔽规则列表
+     *
+     * @param aiSipAutoCallRules sip自动外呼屏蔽规则
+     * @return sip自动外呼屏蔽规则集合
+     */
+    List<AiSipAutoCallRules> selectAiSipAutoCallRulesList(AiSipAutoCallRules aiSipAutoCallRules);
+
+    /**
+     * 新增sip自动外呼屏蔽规则
+     *
+     * @param aiSipAutoCallRules sip自动外呼屏蔽规则
+     * @return 结果
+     */
+    int insertAiSipAutoCallRules(AiSipAutoCallRules aiSipAutoCallRules);
+
+    /**
+     * 修改sip自动外呼屏蔽规则
+     *
+     * @param aiSipAutoCallRules sip自动外呼屏蔽规则
+     * @return 结果
+     */
+    int updateAiSipAutoCallRules(AiSipAutoCallRules aiSipAutoCallRules);
+
+    /**
+     * 批量删除sip自动外呼屏蔽规则
+     *
+     * @param ids 需要删除的sip自动外呼屏蔽规则主键集合
+     * @return 结果
+     */
+    int deleteAiSipAutoCallRulesByIds(Long[] ids);
+
+    /**
+     * 删除sip自动外呼屏蔽规则信息
+     *
+     * @param id sip自动外呼屏蔽规则主键
+     * @return 结果
+     */
+    int deleteAiSipAutoCallRulesById(Long id);
+
+    /**
+     * 校验批次ID是否已被其他规则使用,返回重复的批次名称
+     *
+     * @param batchIds 待校验的批次ID集合
+     * @return 重复的批次名称列表
+     */
+    List<String> checkDuplicateBatchNames(List<Long> batchIds,Long excludeId);
+
+    /**
+     * 按屏蔽规则评估外呼任务并远程停呼/启呼。
+     *
+     * @return 友好换行的执行报告文本(供前端弹窗展示)
+     */
+    String verifySipCallStopAndStart();
+    /**
+     * 判断本地 batchId 当前是否处于屏蔽时段(单批次定向查规则 + 评估,不读写 Redis)。
+     * 专属规则优先(status=0 + find_in_set),否则用 id=0 启用兜底;异常返回 false。
+     *
+     * @param localBatchId 本地任务批次 ID
+     * @return true=屏蔽时段;false=不屏蔽或异常
+     */
+    boolean isBatchIdInShieldTime(Long localBatchId);
+}

+ 7 - 7
fs-service/src/main/java/com/fs/aiSipCall/service/IAiSipCallBizGroupService.java

@@ -7,14 +7,14 @@ import java.util.List;
 
 /**
  * aiSIP外呼技能组Service接口
- * 
+ *
  * @author fs
  * @date 2026-03-06
  */
 public interface IAiSipCallBizGroupService extends IService<AiSipCallBizGroup>{
     /**
      * 查询aiSIP外呼技能组
-     * 
+     *
      * @param groupId aiSIP外呼技能组主键
      * @return aiSIP外呼技能组
      */
@@ -22,7 +22,7 @@ public interface IAiSipCallBizGroupService extends IService<AiSipCallBizGroup>{
 
     /**
      * 查询aiSIP外呼技能组列表
-     * 
+     *
      * @param aiSipCallBizGroup aiSIP外呼技能组
      * @return aiSIP外呼技能组集合
      */
@@ -30,7 +30,7 @@ public interface IAiSipCallBizGroupService extends IService<AiSipCallBizGroup>{
 
     /**
      * 新增aiSIP外呼技能组
-     * 
+     *
      * @param aiSipCallBizGroup aiSIP外呼技能组
      * @return 结果
      */
@@ -38,7 +38,7 @@ public interface IAiSipCallBizGroupService extends IService<AiSipCallBizGroup>{
 
     /**
      * 修改aiSIP外呼技能组
-     * 
+     *
      * @param aiSipCallBizGroup aiSIP外呼技能组
      * @return 结果
      */
@@ -46,7 +46,7 @@ public interface IAiSipCallBizGroupService extends IService<AiSipCallBizGroup>{
 
     /**
      * 批量删除aiSIP外呼技能组
-     * 
+     *
      * @param groupIds 需要删除的aiSIP外呼技能组主键集合
      * @return 结果
      */
@@ -54,7 +54,7 @@ public interface IAiSipCallBizGroupService extends IService<AiSipCallBizGroup>{
 
     /**
      * 删除aiSIP外呼技能组信息
-     * 
+     *
      * @param groupId aiSIP外呼技能组主键
      * @return 结果
      */

+ 7 - 7
fs-service/src/main/java/com/fs/aiSipCall/service/IAiSipCallLlmAgentAccountService.java

@@ -7,14 +7,14 @@ import java.util.List;
 
 /**
  * aiSIP外呼大模型Service接口
- * 
+ *
  * @author fs
  * @date 2026-03-06
  */
 public interface IAiSipCallLlmAgentAccountService extends IService<AiSipCallLlmAgentAccount>{
     /**
      * 查询aiSIP外呼大模型
-     * 
+     *
      * @param id aiSIP外呼大模型主键
      * @return aiSIP外呼大模型
      */
@@ -22,7 +22,7 @@ public interface IAiSipCallLlmAgentAccountService extends IService<AiSipCallLlmA
 
     /**
      * 查询aiSIP外呼大模型列表
-     * 
+     *
      * @param aiSipCallLlmAgentAccount aiSIP外呼大模型
      * @return aiSIP外呼大模型集合
      */
@@ -30,7 +30,7 @@ public interface IAiSipCallLlmAgentAccountService extends IService<AiSipCallLlmA
 
     /**
      * 新增aiSIP外呼大模型
-     * 
+     *
      * @param aiSipCallLlmAgentAccount aiSIP外呼大模型
      * @return 结果
      */
@@ -38,7 +38,7 @@ public interface IAiSipCallLlmAgentAccountService extends IService<AiSipCallLlmA
 
     /**
      * 修改aiSIP外呼大模型
-     * 
+     *
      * @param aiSipCallLlmAgentAccount aiSIP外呼大模型
      * @return 结果
      */
@@ -46,7 +46,7 @@ public interface IAiSipCallLlmAgentAccountService extends IService<AiSipCallLlmA
 
     /**
      * 批量删除aiSIP外呼大模型
-     * 
+     *
      * @param ids 需要删除的aiSIP外呼大模型主键集合
      * @return 结果
      */
@@ -54,7 +54,7 @@ public interface IAiSipCallLlmAgentAccountService extends IService<AiSipCallLlmA
 
     /**
      * 删除aiSIP外呼大模型信息
-     * 
+     *
      * @param id aiSIP外呼大模型主键
      * @return 结果
      */

+ 11 - 5
fs-service/src/main/java/com/fs/aiSipCall/service/IAiSipCallOutboundCdrService.java

@@ -3,11 +3,11 @@ package com.fs.aiSipCall.service;
 import com.baomidou.mybatisplus.extension.service.IService;
 import com.fs.aiSipCall.domain.AiSipCallOutboundCdr;
 import com.fs.aiSipCall.domain.CcCustInfo;
-import com.fs.aiSipCall.param.ApiCallRecordByUuidQueryParams;
+import com.fs.aiSipCall.param.StatisticalManagementParam;
+import com.fs.aiSipCall.vo.StatisticalManagementVo;
 import com.fs.common.core.domain.AjaxResult;
 
 import java.util.List;
-import java.util.concurrent.CompletableFuture;
 
 /**
  * aiSIP手动外呼通话记录Service接口
@@ -64,11 +64,17 @@ public interface IAiSipCallOutboundCdrService extends IService<AiSipCallOutbound
      */
     int deleteAiSipCallOutboundCdrById(String id);
 
-    AjaxResult getCustCommunicationInfo(String phoneNum, Integer callType, String uuid);
+    AjaxResult getCustCommunicationInfo(String phoneNum, Integer callType, String uuid, String dialMode);
 
     AjaxResult addCustcallrecord(CcCustInfo ccCustInfo);
 
-    CompletableFuture<String> scheduledGetCallRecord();
+    void callEndSyncByUuid(AiSipCallOutboundCdr request);
 
-    int syncByUuid(ApiCallRecordByUuidQueryParams req);
+    List<Long> getTodayChangeUserIds();
+
+    List<AiSipCallOutboundCdr> findWaitEncryptPhones(Long currentDateMillis, Integer batchSize);
+
+    int updateEncryptPhone(List<AiSipCallOutboundCdr> target);
+
+    List<StatisticalManagementVo> statisticalManagement(StatisticalManagementParam param);
 }

+ 30 - 6
fs-service/src/main/java/com/fs/aiSipCall/service/IAiSipCallPhoneService.java

@@ -4,9 +4,14 @@ import com.baomidou.mybatisplus.extension.service.IService;
 import com.fs.aiSipCall.domain.AiSipCallPhone;
 import com.fs.aiSipCall.dto.CallTaskStatModel;
 import com.fs.aiSipCall.dto.CommonPhoneModel;
+import com.fs.aiSipCall.param.StatisticalManagementParam;
+import com.fs.aiSipCall.vo.AiSipCallPhoneInSopVo;
+import com.fs.aiSipCall.vo.StatisticalManagementVo;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.sop.params.QwSopLogsParam;
+import com.fs.sop.vo.QwSopLogsListCVO;
 
 import java.util.List;
-import java.util.concurrent.CompletableFuture;
 
 /**
  * aiSIP外呼通话记录Service接口
@@ -63,11 +68,11 @@ public interface IAiSipCallPhoneService extends IService<AiSipCallPhone>{
      */
     int deleteAiSipCallPhoneById(String id);
     /**
-     * 根据batchId统计外呼数据
-     * @param batchId 任务id
+     * 根据batchIds统计外呼数据
+     * @param batchIds 任务ids
      * @return 结果
      */
-    CallTaskStatModel statByBatchId(Long batchId);
+    List<CallTaskStatModel> statByBatchIds(List<Long> batchIds);
 
     /**
      * 验证是否重复录入
@@ -78,7 +83,26 @@ public interface IAiSipCallPhoneService extends IService<AiSipCallPhone>{
     List<String> isDuplicateEntry(Long batchId, List<CommonPhoneModel> phoneList);
 
     /**
-     * 拉取当天记录
+     * 查询本地批次下已存在的 fs_user_id(有效记录)
      */
-    CompletableFuture<String> scheduledGetCallRecord();
+    List<Long> selectDistinctFsUserIdsByLocalBatchId(Long localBatchId);
+
+    /**
+     * 批量逻辑删除
+     */
+    int logicalDeletionByBatchIds(Long[] batchIds);
+
+    List<QwSopLogsListCVO> selectQwSipLogsListBySopId(QwSopLogsParam param);
+
+    List<Long> getTodayChangeUserIds();
+
+    List<AiSipCallPhoneInSopVo> SopTaskList(AiSipCallPhone aiSipCallPhone);
+
+    AjaxResult callEndQueryByUuid(AiSipCallPhone request);
+
+    List<StatisticalManagementVo> statisticalManagement(StatisticalManagementParam param);
+
+    List<AiSipCallPhone> findWaitEncryptPhones(Long currentDateMillis, Integer batchSize);
+
+    int updateEncryptPhone(List<AiSipCallPhone> target);
 }

+ 15 - 12
fs-service/src/main/java/com/fs/aiSipCall/service/IAiSipCallTaskService.java

@@ -1,22 +1,26 @@
 package com.fs.aiSipCall.service;
 
 import com.baomidou.mybatisplus.extension.service.IService;
+import com.fs.aiSipCall.domain.AiSipCallPhone;
 import com.fs.aiSipCall.domain.AiSipCallTask;
-import com.fs.aiSipCall.dto.CallTaskStatModel;
 import com.fs.aiSipCall.dto.CommonPhoneModel;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.sop.domain.SopUserLogsInfo;
 
 import java.util.List;
+import java.util.Map;
+import java.util.Set;
 
 /**
  * aiSIP外呼任务Service接口
- * 
+ *
  * @author fs
  * @date 2026-03-06
  */
 public interface IAiSipCallTaskService extends IService<AiSipCallTask>{
     /**
      * 查询aiSIP外呼任务
-     * 
+     *
      * @param batchId aiSIP外呼任务主键
      * @return aiSIP外呼任务
      */
@@ -24,7 +28,7 @@ public interface IAiSipCallTaskService extends IService<AiSipCallTask>{
 
     /**
      * 查询aiSIP外呼任务列表
-     * 
+     *
      * @param aiSipCallTask aiSIP外呼任务
      * @return aiSIP外呼任务集合
      */
@@ -32,7 +36,7 @@ public interface IAiSipCallTaskService extends IService<AiSipCallTask>{
 
     /**
      * 新增aiSIP外呼任务
-     * 
+     *
      * @param aiSipCallTask aiSIP外呼任务
      * @return 结果
      */
@@ -40,7 +44,7 @@ public interface IAiSipCallTaskService extends IService<AiSipCallTask>{
 
     /**
      * 修改aiSIP外呼任务
-     * 
+     *
      * @param aiSipCallTask aiSIP外呼任务
      * @return 结果
      */
@@ -48,7 +52,7 @@ public interface IAiSipCallTaskService extends IService<AiSipCallTask>{
 
     /**
      * 批量删除aiSIP外呼任务
-     * 
+     *
      * @param batchIds 需要删除的aiSIP外呼任务主键集合
      * @return 结果
      */
@@ -56,7 +60,7 @@ public interface IAiSipCallTaskService extends IService<AiSipCallTask>{
 
     /**
      * 删除aiSIP外呼任务信息
-     * 
+     *
      * @param batchId aiSIP外呼任务主键
      * @return 结果
      */
@@ -66,9 +70,8 @@ public interface IAiSipCallTaskService extends IService<AiSipCallTask>{
 
     int stopTask(Long batchId);
 
-    int commonImportExcel(Long batchId,List<CommonPhoneModel> phoneList);
+    int commonImportExcel(Long batchId, List<CommonPhoneModel> phoneList, AiSipCallPhone callPhone,
+                          List<SopUserLogsInfo> userLogsInfos, Map<Long, String> phoneMap);
 
-    CallTaskStatModel statByBatchId(Long batchId);
-
-    AiSipCallTask selectAiSipCallTaskByRemoteBatchId(Long remoteBatchId);
+    AjaxResult appUrgentClasImportData(Long batchId, Set<Long> fsUserIds, Long userId, String userName);
 }

+ 22 - 0
fs-service/src/main/java/com/fs/aiSipCall/service/IAiSipCallUserService.java

@@ -2,7 +2,10 @@ package com.fs.aiSipCall.service;
 
 import com.baomidou.mybatisplus.extension.service.IService;
 import com.fs.aiSipCall.domain.AiSipCallUser;
+import com.fs.aiSipCall.vo.CcExtNumVo;
 import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.company.domain.Company;
 
 import java.util.List;
 import java.util.Map;
@@ -71,4 +74,23 @@ public interface IAiSipCallUserService extends IService<AiSipCallUser>{
      * @return AjaxResult 结果
      */
     AjaxResult getToolbarBasicParam(Map<String,String> param);
+
+    TableDataInfo getUnBindExtnumPage(CcExtNumVo ccExtNum);
+
+    /**
+     * 企业绑定分机号
+     * @param companyName 销售公司
+     * @param sipExtNumIds 分机号集合
+     */
+    AjaxResult companyBindExtNum(String companyName,String sipExtNumIds);
+
+    TableDataInfo getSipExtDetail(CcExtNumVo ccExtNum);
+
+    AjaxResult companyUnbindExtNum(List<Company> companyList);
+
+    AjaxResult getCompanyUnBindExtnum(Long companyId);
+    /**
+     * 公司批量解绑分机
+     */
+    AjaxResult companyBatchUnbindSipExt(Long companyId,List<CcExtNumVo> ccExtNum);
 }

+ 17 - 0
fs-service/src/main/java/com/fs/aiSipCall/service/IAiSipCallVoiceTtsAliyunService.java

@@ -2,6 +2,8 @@ package com.fs.aiSipCall.service;
 
 import com.baomidou.mybatisplus.extension.service.IService;
 import com.fs.aiSipCall.domain.AiSipCallVoiceTtsAliyun;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
 
 import java.util.List;
 
@@ -12,6 +14,9 @@ import java.util.List;
  * @date 2026-03-06
  */
 public interface IAiSipCallVoiceTtsAliyunService extends IService<AiSipCallVoiceTtsAliyun>{
+
+
+    TableDataInfo remoteList(AiSipCallVoiceTtsAliyun aiSipCallVoiceTtsAliyun);
     /**
      * 查询aiSIP外呼阿里云音色
      *
@@ -59,4 +64,16 @@ public interface IAiSipCallVoiceTtsAliyunService extends IService<AiSipCallVoice
      * @return 结果
      */
     int deleteAiSipCallVoiceTtsAliyunById(Long id);
+
+    AjaxResult allTtsProvider();
+
+    AjaxResult allAsrProvider();
+
+    AjaxResult getTtsLanguages(String voiceSource);
+
+    AjaxResult getAsrLanguages(String asrProvider);
+
+    AjaxResult getAsrModels(String asrProvider, String asrLanguageCode);
+
+    AjaxResult getTtsModels(String voiceSource, String ttsLanguageCode);
 }

+ 16 - 0
fs-service/src/main/java/com/fs/aiSipCall/service/IChineseStatutoryHolidayService.java

@@ -0,0 +1,16 @@
+package com.fs.aiSipCall.service;
+
+import java.util.Calendar;
+
+/**
+ * 中国法定节假日查询(动态获取,按年缓存)。
+ */
+public interface IChineseStatutoryHolidayService {
+
+    /**
+     * 判断指定日期是否为法定节假日(仅 holiday=true 的放假日期,不含补班)。
+     * <p>
+     * 若当年数据无法获取(网络异常且无缓存),返回 false,不误屏蔽外呼。
+     */
+    boolean isStatutoryHoliday(Calendar calendar);
+}

+ 311 - 0
fs-service/src/main/java/com/fs/aiSipCall/service/ManualPullSyncSipRecordService.java

@@ -0,0 +1,311 @@
+package com.fs.aiSipCall.service;
+
+import com.alibaba.fastjson.JSONObject;
+import com.alibaba.fastjson.TypeReference;
+import com.fs.aiSipCall.RemoteCommon;
+import com.fs.aiSipCall.domain.AiSipCallOutboundCdr;
+import com.fs.aiSipCall.mapper.AiSipCallOutboundCdrMapper;
+import com.fs.aiSipCall.utils.SipLogUtil;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+import org.springframework.util.CollectionUtils;
+
+import java.net.URLDecoder;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.*;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.stream.Collectors;
+
+@Component
+public class ManualPullSyncSipRecordService {
+
+    private static final Logger log = SipLogUtil.createSipLogger(ManualPullSyncSipRecordService.class);
+
+    @Autowired
+    private AiSipCallOutboundCdrMapper baseMapper;
+
+    // 远程接口最大并发数(根据对方承受能力调整,此处设为5)
+    private static final int MAX_CONCURRENT_BATCHES = 5;
+    // 每批查询的ID数量(与远程接口批次大小一致)
+    private static final int PAGE_SIZE = 500;
+    // 远程调用重试配置
+    private static final int REMOTE_RETRY_COUNT = 3;
+    private static final long REMOTE_RETRY_INTERVAL_MS = 1000;
+    // 数据库更新重试配置
+    private static final int DB_RETRY_COUNT = 2;
+    private static final long DB_RETRY_INTERVAL_MS = 500;
+    // 整体超时时间(30分钟)
+    private static final long TOTAL_TIMEOUT_MINUTES = 30;
+    // 失败ID打印最大数量(避免日志过大)
+    private static final int MAX_FAILED_IDS_PRINT = 1000;
+
+    /**
+     * 手动拉取CDR数据(使用频率极低,每次调用临时创建线程池,用后立即销毁,避免资源浪费)
+     */
+    public void manualPull(String sTime, String eTime) {
+        long startTime = System.currentTimeMillis();
+        log.info("manualPull 开始执行");
+
+        // 临时线程池:核心线程0,最大线程5,空闲60秒自动回收,队列容量适度(防止堆积过多任务)
+        ThreadPoolExecutor executor = new ThreadPoolExecutor(
+                0, MAX_CONCURRENT_BATCHES,
+                60L, TimeUnit.SECONDS,
+                new LinkedBlockingQueue<>(100),
+                new ThreadPoolExecutor.CallerRunsPolicy()  // 队列满时由调用线程执行,形成背压
+        );
+        executor.allowCoreThreadTimeOut(true);
+
+        try {
+            // 统计计数器
+            AtomicLong totalProcessedCount = new AtomicLong(0);   // 分页查询到的ID总数
+            AtomicLong totalSuccessCount = new AtomicLong(0);     // 成功更新记录数
+            AtomicLong totalFailCount = new AtomicLong(0);        // 最终失败记录数
+
+            // 收集失败详情(使用线程安全集合,由各批次自行添加,最后统一处理)
+            List<String> allFailedIds = Collections.synchronizedList(new ArrayList<>());
+
+            int offset = 0;
+            List<CompletableFuture<Void>> futures = new ArrayList<>();
+
+            // 分页获取待同步的ID列表
+            while (true) {
+                List<String> ids = baseMapper.selectIdListPage(offset, PAGE_SIZE,convertToTimestamp(sTime),convertToTimestamp(eTime));
+                if (ids == null || ids.isEmpty()) {
+                    break;
+                }
+                totalProcessedCount.addAndGet(ids.size());
+
+                CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
+                    processBatch(ids, totalSuccessCount, totalFailCount, allFailedIds);
+                }, executor);
+                futures.add(future);
+
+                offset += PAGE_SIZE;
+            }
+
+            // 等待所有批次完成(整体超时控制)
+            CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
+                    .get(TOTAL_TIMEOUT_MINUTES, TimeUnit.MINUTES);
+
+            long cost = System.currentTimeMillis() - startTime;
+            log.info("manualPull 执行完成,总计处理ID数: {}, 成功更新记录数: {}, 失败记录数: {}, 耗时: {} ms",
+                    totalProcessedCount.get(), totalSuccessCount.get(), totalFailCount.get(), cost);
+
+            // 打印失败ID明细(限制数量)
+            if (!allFailedIds.isEmpty()) {
+                if (allFailedIds.size() <= MAX_FAILED_IDS_PRINT) {
+                    log.error("manualPull 失败记录明细(ID列表): 数量={}, IDs={}", allFailedIds.size(), allFailedIds);
+                } else {
+                    log.error("manualPull 失败记录过多,数量={},仅展示前{}个: IDs={}",
+                            allFailedIds.size(), MAX_FAILED_IDS_PRINT, allFailedIds.subList(0, MAX_FAILED_IDS_PRINT));
+                }
+            }
+        } catch (Exception e) {
+            log.error("manualPull 执行过程中发生异常: {}", e.getMessage(), e);
+        } finally {
+            // 确保线程池关闭,释放资源
+            executor.shutdown();
+            try {
+                if (!executor.awaitTermination(1, TimeUnit.MINUTES)) {
+                    executor.shutdownNow();
+                    log.warn("manualPull 强制关闭线程池");
+                }
+            } catch (InterruptedException ie) {
+                executor.shutdownNow();
+                Thread.currentThread().interrupt();
+                log.warn("manualPull 关闭线程池时被中断");
+            }
+        }
+    }
+
+    /**
+     * 处理单个批次:远程调用 -> 解密/加密 -> 批量更新数据库
+     *
+     * @param ids            当前批次的ID列表
+     * @param successCounter 成功计数器
+     * @param failCounter    失败计数器
+     * @param failedIds      全局失败ID收集器(线程安全)
+     */
+    private void processBatch(List<String> ids,
+                              AtomicLong successCounter,
+                              AtomicLong failCounter,
+                              List<String> failedIds) {
+        if (CollectionUtils.isEmpty(ids)) {
+            return;
+        }
+        log.debug("开始处理批次,ID数量: {}", ids.size());
+
+        // 1. 远程调用(带重试)获取原始数据
+        List<AiSipCallOutboundCdr> remoteDataList = remoteCallWithRetry(ids);
+        if (remoteDataList == null) {
+            // 远程调用最终失败,整个批次标记失败
+            log.error("批次 {} 远程调用最终失败,跳过本批次", ids);
+            failedIds.addAll(ids);
+            failCounter.addAndGet(ids.size());
+            return;
+        }
+
+        // 2. 检查缺失的ID(远程未返回数据的ID)
+        Set<String> returnedIds = remoteDataList.stream()
+                .map(AiSipCallOutboundCdr::getId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+        List<String> missingIds = ids.stream()
+                .filter(id -> !returnedIds.contains(id))
+                .collect(Collectors.toList());
+        if (!missingIds.isEmpty()) {
+            log.warn("批次中以下ID未在远程返回数据中: {}", missingIds);
+            failedIds.addAll(missingIds);
+            failCounter.addAndGet(missingIds.size());
+        }
+
+        // 3. 对返回的数据进行加解密处理(每个记录独立处理,异常时保留字段为空)
+        if (!CollectionUtils.isEmpty(remoteDataList)) {
+            // 由于每批最多500条,顺序处理即可,避免parallelStream带来的线程竞争
+            remoteDataList.forEach(this::processPhoneFields);
+        }
+
+        // 4. 批量更新数据库(带重试)
+        if (!CollectionUtils.isEmpty(remoteDataList)) {
+            boolean updateSuccess = updateDatabaseWithRetry(remoteDataList);
+            if (updateSuccess) {
+                int successCount = remoteDataList.size();
+                successCounter.addAndGet(successCount);
+                log.info("批次更新成功,成功记录数: {}", successCount);
+            } else {
+                // 数据库更新失败,整个批次数据视为失败
+                List<String> batchFailedIds = remoteDataList.stream()
+                        .map(AiSipCallOutboundCdr::getId)
+                        .collect(Collectors.toList());
+                failedIds.addAll(batchFailedIds);
+                failCounter.addAndGet(batchFailedIds.size());
+                log.error("批次数据库更新失败,本批次 {} 条记录标记为失败", batchFailedIds.size());
+            }
+        }
+    }
+
+    /**
+     * 远程调用(带重试)
+     *
+     * @param ids 请求的ID列表
+     * @return 远程返回的数据列表,若最终失败则返回null
+     */
+    private List<AiSipCallOutboundCdr> remoteCallWithRetry(List<String> ids) {
+        Exception lastException = null;
+        for (int attempt = 1; attempt <= REMOTE_RETRY_COUNT; attempt++) {
+            try {
+                String result = RemoteCommon.sendPost(
+                        RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.OUTBOUND_MANUALPULL_TEST_API,
+                        JSONObject.toJSONString(ids));
+                if (StringUtils.isBlank(result)) {
+                    throw new RuntimeException("远程接口返回空响应");
+                }
+                JSONObject jsonObject = JSONObject.parseObject(result);
+                Integer code = jsonObject.getInteger("code");
+                if (code == null || code != 0) {
+                    throw new RuntimeException("远程接口返回失败: code=" + code + ", msg=" + jsonObject.getString("msg"));
+                }
+                String data = jsonObject.getString("data");
+                if (StringUtils.isBlank(data)) {
+                    throw new RuntimeException("远程接口返回data为空");
+                }
+                return JSONObject.parseObject(data, new TypeReference<List<AiSipCallOutboundCdr>>() {});
+            } catch (Exception e) {
+                lastException = e;
+                log.warn("批次 {} 远程调用失败,第{}次重试,原因: {}", ids, attempt, e.getMessage());
+                if (attempt < REMOTE_RETRY_COUNT) {
+                    try {
+                        Thread.sleep(REMOTE_RETRY_INTERVAL_MS);
+                    } catch (InterruptedException ie) {
+                        Thread.currentThread().interrupt();
+                        log.warn("批次 {} 远程调用重试被中断", ids);
+                        break;
+                    }
+                }
+            }
+        }
+        log.error("批次 {} 远程调用最终失败,原因: {}", ids, lastException.getMessage());
+        return null;
+    }
+
+    /**
+     * 批量更新数据库(带重试)
+     *
+     * @param dataList 待更新实体列表
+     * @return true-更新成功,false-最终失败
+     */
+    private boolean updateDatabaseWithRetry(List<AiSipCallOutboundCdr> dataList) {
+        for (int attempt = 1; attempt <= DB_RETRY_COUNT; attempt++) {
+            try {
+                // 每批最多500条,直接调用Mapper方法,无需再次分片
+                baseMapper.batchManualPullUpdate(dataList);
+                return true;
+            } catch (Exception e) {
+                log.warn("数据库批量更新失败,第{}次重试,原因: {}", attempt, e.getMessage());
+                if (attempt < DB_RETRY_COUNT) {
+                    try {
+                        Thread.sleep(DB_RETRY_INTERVAL_MS);
+                    } catch (InterruptedException ie) {
+                        Thread.currentThread().interrupt();
+                        break;
+                    }
+                }
+            }
+        }
+        log.error("数据库批量更新最终失败");
+        return false;
+    }
+
+    /**
+     * 处理单个实体的主叫和被叫号码:解密(远程加密格式) -> 再加密(本地存储格式)
+     * 异常时置为空字符串,避免影响其他字段
+     */
+    private void processPhoneFields(AiSipCallOutboundCdr cdr) {
+        // 处理被叫
+        if (StringUtils.isNotBlank(cdr.getCallee())) {
+            try {
+                cdr.setCallee(URLDecoder.decode(cdr.getCallee(), "UTF-8").replaceAll("=+$", ""));
+            } catch (Throwable e) {
+                log.error("解密被叫号码失败,id: {}, 原始号码: {}, 错误: {}",
+                        cdr.getId(), cdr.getCallee(), e.getMessage(), e);
+                cdr.setCallee("");
+            }
+        }
+        // 处理主叫
+        if (StringUtils.isNotBlank(cdr.getCaller())) {
+            try {
+                cdr.setCaller(URLDecoder.decode(cdr.getCaller(), "UTF-8").replaceAll("=+$", ""));
+            } catch (Throwable e) {
+                log.error("解密主叫号码失败,id: {}, 原始号码: {}, 错误: {}",
+                        cdr.getId(), cdr.getCaller(), e.getMessage(), e);
+                cdr.setCaller("");
+            }
+        }else{
+            cdr.setCaller("");
+        }
+        // 对opnum字段进行URL解码,防止乱码
+        if(StringUtils.isNotBlank(cdr.getOpnum())){
+            try {
+                cdr.setOpnum(URLDecoder.decode(cdr.getOpnum(), "UTF-8"));
+            } catch (Exception e) {
+                log.error("opnum字段URL解码失败,原值:{}", cdr.getOpnum(), e);
+            }
+        }
+    }
+
+    public static long convertToTimestamp(String dateTimeStr) {
+        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+        LocalDateTime localDateTime = LocalDateTime.parse(dateTimeStr, formatter);
+        ZonedDateTime zonedDateTime = localDateTime.atZone(ZoneId.systemDefault());
+        return zonedDateTime.toInstant().toEpochMilli();
+    }
+}

+ 850 - 0
fs-service/src/main/java/com/fs/aiSipCall/service/impl/AiSipAutoCallRulesServiceImpl.java

@@ -0,0 +1,850 @@
+package com.fs.aiSipCall.service.impl;
+
+import com.alibaba.fastjson.JSONObject;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+
+import com.fs.aiSipCall.RemoteCommon;
+import com.fs.aiSipCall.domain.AiSipAutoCallRules;
+import com.fs.aiSipCall.domain.AiSipCallTask;
+import com.fs.aiSipCall.mapper.AiSipAutoCallRulesMapper;
+import com.fs.aiSipCall.mapper.AiSipCallTaskMapper;
+import com.fs.aiSipCall.service.IAiSipAutoCallRulesService;
+import com.fs.aiSipCall.support.AiSipAutoCallRuleEvaluator;
+import com.fs.aiSipCall.support.AiSipShieldRuleReport;
+import com.fs.aiSipCall.utils.SipLogUtil;
+import com.fs.common.RedisKeyCommon;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.utils.DateUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
+
+import java.util.*;
+import java.util.concurrent.*;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
+
+/**
+ * sip自动外呼屏蔽规则Service业务层处理
+ *
+ * @author fs
+ * @date 2026-07-07
+ */
+@Service
+public class AiSipAutoCallRulesServiceImpl extends ServiceImpl<AiSipAutoCallRulesMapper, AiSipAutoCallRules>
+        implements IAiSipAutoCallRulesService {
+
+    private static final Logger log = SipLogUtil.createSipLogger(AiSipAutoCallRulesServiceImpl.class);
+    private static final String LOG_PREFIX = "[自动外呼任务屏蔽规则处理]";
+
+    /** Redis Hash value:1=停呼,0=启呼 */
+    private static final String STATE_BLOCKED = "1";
+    private static final String STATE_UNBLOCKED = "0";
+    /** 状态 Map 整体 TTL(天) */
+    private static final int REDIS_STATE_DAYS = 1;
+    /** 兜底规则固定 id=0 */
+    private static final long FALLBACK_RULE_ID = 0L;
+    private static final int REMOTE_RETRY_TIMES = 3;
+    private static final long REMOTE_RETRY_INTERVAL_MS = 20L;
+    private static final long RULE_EVAL_WARN_MS = 10_000L;
+    /** 远程停启并行度上限 */
+    private static final int REMOTE_PARALLELISM = 8;
+    /** 整批远程等待上限,超时强制打断,防止卡死 */
+    private static final long REMOTE_BATCH_TIMEOUT_SECONDS = 60L;
+
+    /**
+     * 远程停启专用线程池(daemon,有界队列 + CallerRuns),进程内复用,避免反复创建销毁。
+     * <ul>
+     *   <li>核心/最大线程:{@link #REMOTE_PARALLELISM}</li>
+     *   <li>空闲回收:60 秒</li>
+     *   <li>队列满时由调用线程执行,形成背压,不会无限堆积</li>
+     * </ul>
+     */
+    private static final ExecutorService REMOTE_EXECUTOR;
+
+    static {
+        ThreadPoolExecutor pool = new ThreadPoolExecutor(
+                REMOTE_PARALLELISM,
+                REMOTE_PARALLELISM,
+                60L,
+                TimeUnit.SECONDS,
+                new LinkedBlockingQueue<>(256),
+                r -> {
+                    Thread t = new Thread(r, "sip-shield-remote");
+                    t.setDaemon(true);
+                    return t;
+                },
+                new ThreadPoolExecutor.CallerRunsPolicy());
+        // 允许核心线程超时回收,避免长期空闲占线程
+        pool.allowCoreThreadTimeOut(true);
+        REMOTE_EXECUTOR = pool;
+    }
+
+    @Autowired
+    private AiSipCallTaskMapper aiSipCallTaskMapper;
+    @Autowired
+    private RedisCache redisCache;
+    @Autowired
+    private AiSipAutoCallRuleEvaluator ruleEvaluator;
+
+    @Override
+    public AiSipAutoCallRules selectAiSipAutoCallRulesById(Long id) {
+        return baseMapper.selectAiSipAutoCallRulesById(id);
+    }
+
+    @Override
+    public List<AiSipAutoCallRules> selectAiSipAutoCallRulesList(AiSipAutoCallRules aiSipAutoCallRules) {
+        List<AiSipAutoCallRules> list = baseMapper.selectAiSipAutoCallRulesList(aiSipAutoCallRules);
+        if (CollectionUtils.isEmpty(list)) {
+            return list;
+        }
+        Set<Long> allBatchIds = list.stream()
+                .map(AiSipAutoCallRules::getBatchIds)
+                .filter(StringUtils::isNotBlank)
+                .flatMap(batchIds -> Arrays.stream(batchIds.split(",")))
+                .map(String::trim)
+                .map(Long::valueOf)
+                .collect(Collectors.toSet());
+        if (allBatchIds.isEmpty()) {
+            return list;
+        }
+        List<AiSipCallTask> taskList = aiSipCallTaskMapper.selectByIds(new ArrayList<>(allBatchIds));
+        if (CollectionUtils.isEmpty(taskList)) {
+            return list;
+        }
+        Map<Long, String> batchNameMap = taskList.stream()
+                .filter(task -> task.getBatchName() != null)
+                .collect(Collectors.toMap(AiSipCallTask::getBatchId, AiSipCallTask::getBatchName, (a, b) -> a));
+        list.stream()
+                .filter(rules -> StringUtils.isNotBlank(rules.getBatchIds()))
+                .forEach(rules -> rules.setBatchNames(
+                        Arrays.stream(rules.getBatchIds().split(","))
+                                .map(String::trim)
+                                .map(Long::valueOf)
+                                .map(batchNameMap::get)
+                                .filter(Objects::nonNull)
+                                .collect(Collectors.joining(","))));
+        return list;
+    }
+
+    @Override
+    public int insertAiSipAutoCallRules(AiSipAutoCallRules aiSipAutoCallRules) {
+        aiSipAutoCallRules.setCreateTime(DateUtils.getNowDate());
+        return baseMapper.insertAiSipAutoCallRules(aiSipAutoCallRules);
+    }
+
+    @Override
+    public int updateAiSipAutoCallRules(AiSipAutoCallRules aiSipAutoCallRules) {
+        aiSipAutoCallRules.setUpdateTime(DateUtils.getNowDate());
+        return baseMapper.updateAiSipAutoCallRules(aiSipAutoCallRules);
+    }
+
+    @Override
+    public int deleteAiSipAutoCallRulesByIds(Long[] ids) {
+        return baseMapper.deleteAiSipAutoCallRulesByIds(ids);
+    }
+
+    @Override
+    public int deleteAiSipAutoCallRulesById(Long id) {
+        return baseMapper.deleteAiSipAutoCallRulesById(id);
+    }
+
+    @Override
+    public List<String> checkDuplicateBatchNames(List<Long> batchIds, Long excludeId) {
+        if (CollectionUtils.isEmpty(batchIds)) {
+            return Collections.emptyList();
+        }
+        AiSipAutoCallRules query = new AiSipAutoCallRules();
+        query.setStatus(0);
+        query.setNotId(excludeId);
+        List<AiSipAutoCallRules> allRules = baseMapper.selectAiSipAutoCallRulesList(query);
+        if (CollectionUtils.isEmpty(allRules)) {
+            return Collections.emptyList();
+        }
+        Set<Long> existingBatchIds = allRules.stream()
+                .map(AiSipAutoCallRules::getBatchIds)
+                .filter(StringUtils::isNotBlank)
+                .flatMap(ids -> Arrays.stream(ids.split(",")))
+                .map(String::trim)
+                .map(Long::valueOf)
+                .collect(Collectors.toSet());
+        Set<Long> duplicateIds = batchIds.stream()
+                .filter(existingBatchIds::contains)
+                .collect(Collectors.toSet());
+        if (duplicateIds.isEmpty()) {
+            return Collections.emptyList();
+        }
+        List<AiSipCallTask> taskList = aiSipCallTaskMapper.selectByIds(new ArrayList<>(duplicateIds));
+        if (CollectionUtils.isEmpty(taskList)) {
+            return Collections.emptyList();
+        }
+        return taskList.stream()
+                .map(AiSipCallTask::getBatchName)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toList());
+    }
+
+    /**
+     * 按屏蔽规则评估外呼任务,并按需远程停呼/启呼,返回前端弹窗报告文本。
+     * <p>
+     * <b>ID 勿混用</b>
+     * <ul>
+     *   <li>{@code batchId}:本地任务批次 ID,用于规则 batch_ids 匹配、报告展示</li>
+     *   <li>{@code remoteBatchId}:远端平台任务 ID,用于远程停启接口参数、Redis 状态 Map 的 key</li>
+     * </ul>
+     * <p>
+     * <b>Redis 状态结构(数据量小,一次读写整表)</b><br>
+     * key = {@code sip_auto_call_rule_evaluator:states}<br>
+     * value = {@code Map&lt;remoteBatchId, "1"|"0"&gt;}(1=停呼,0=启呼)<br>
+     * 读:{@link #loadShieldStateMap()} 一次拉取整份 Map;写:合并变更后 {@link #saveShieldStateMap(Map)} 一次落盘。
+     * <p>
+     * <b>执行顺序</b>:查任务 → 加载规则 → 读状态 Map → 评估归类 → 有界并行停启 → 回写状态 Map → 输出报告。<br>
+     * <b>安全</b>:单任务异常不影响整批;远程失败仍把目标状态写入 Map,避免下次重复打接口。
+     */
+    @Override
+    public String verifySipCallStopAndStart() {
+        long startMs = System.currentTimeMillis();
+        AiSipShieldRuleReport report = new AiSipShieldRuleReport();
+        List<AiSipShieldRuleReport.TaskReportItem> taskItems = new ArrayList<>();
+        log.info("{} 开始", LOG_PREFIX);
+
+        try {
+            // ---------- 1. 查询有效外呼任务(status=0) ----------
+            AiSipCallTask taskQuery = new AiSipCallTask();
+            taskQuery.setStatus(0);
+            List<AiSipCallTask> tasks = aiSipCallTaskMapper.selectAiSipCallTaskList(taskQuery);
+            if (tasks == null) {
+                tasks = Collections.emptyList();
+            }
+            if (tasks.isEmpty()) {
+                report.setSummary("当前没有需要检查的外呼任务");
+                report.setTotalMs(System.currentTimeMillis() - startMs);
+                return printReport(report);
+            }
+
+            // ---------- 2. 加载规则:专属 batchId→规则;id=0 为兜底 ----------
+            Map<Long, AiSipAutoCallRuleEvaluator.ParsedRule> batchRuleMap = new HashMap<>();
+            AiSipAutoCallRuleEvaluator.ParsedRule fallbackRule = null;
+            AiSipAutoCallRules ruleQuery = new AiSipAutoCallRules();
+            ruleQuery.setStatus(0);
+            List<AiSipAutoCallRules> enabledRules = baseMapper.selectAiSipAutoCallRulesList(ruleQuery);
+            if (enabledRules != null) {
+                for (AiSipAutoCallRules rule : enabledRules) {
+                    if (rule == null || rule.getId() == null) {
+                        continue;
+                    }
+                    if (FALLBACK_RULE_ID == rule.getId()) {
+                        fallbackRule = ruleEvaluator.parseRule(rule);
+                        continue;
+                    }
+                    if (StringUtils.isBlank(rule.getBatchIds())) {
+                        continue;
+                    }
+                    AiSipAutoCallRuleEvaluator.ParsedRule parsed = ruleEvaluator.parseRule(rule);
+                    for (String idStr : AiSipAutoCallRuleEvaluator.splitMultiValue(rule.getBatchIds())) {
+                        try {
+                            batchRuleMap.put(Long.valueOf(idStr), parsed);
+                        } catch (NumberFormatException e) {
+                            log.error("{} 规则 batchIds 非法, ruleId={}, value={}", LOG_PREFIX, rule.getId(), idStr);
+                        }
+                    }
+                }
+            }
+
+            // ---------- 3. 一次读取整份停启状态 Map(非 multiGet) ----------
+            Map<String, String> stateMap = loadShieldStateMap();
+            // 本轮远程写回的增量(并发安全),结束后合并进 stateMap 再整表保存
+            ConcurrentHashMap<String, String> stateUpdates = new ConcurrentHashMap<>();
+
+            // ---------- 4. 逐任务评估并归类(单任务异常隔离) ----------
+            Calendar now = Calendar.getInstance();
+            List<AiSipCallTask> needPause = new ArrayList<>();
+            List<AiSipCallTask> needResume = new ArrayList<>();
+            Map<Long, AiSipShieldRuleReport.TaskReportItem> itemByBatchId =
+                    new HashMap<>(Math.max(16, tasks.size() * 2));
+            int skippedCount = 0;
+            int invalidCount = 0;
+            long evalStart = System.currentTimeMillis();
+
+            for (AiSipCallTask task : tasks) {
+                AiSipShieldRuleReport.TaskReportItem item = new AiSipShieldRuleReport.TaskReportItem();
+                taskItems.add(item);
+                try {
+                    if (task == null || task.getBatchId() == null || task.getRemoteBatchId() == null) {
+                        invalidCount++;
+                        fillInvalidItem(item, task, "任务数据不完整");
+                        continue;
+                    }
+                    Long batchId = task.getBatchId();
+                    Long remoteBatchId = task.getRemoteBatchId();
+                    item.setBatchId(batchId);
+                    item.setTaskName(blankToDash(task.getBatchName()));
+                    itemByBatchId.put(batchId, item);
+
+                    // 有专属规则用专属,否则用 id=0 兜底
+                    AiSipAutoCallRuleEvaluator.ParsedRule matched =
+                            batchRuleMap.getOrDefault(batchId, fallbackRule);
+                    AiSipAutoCallRuleEvaluator.ShieldDecision decision =
+                            ruleEvaluator.evaluate(matched, now);
+
+                    String cached = stateMap.get(String.valueOf(remoteBatchId));
+                    boolean blocked = decision.isBlocked();
+                    String target = blocked ? STATE_BLOCKED : STATE_UNBLOCKED;
+                    String prevLabel = toCallState(cached);
+                    String targetLabel = toCallState(target);
+                    item.setPrevCallState(prevLabel);
+                    item.setNeedPause(blocked);
+                    item.setLimitDetail(blocked ? limitText(decision) : "无");
+                    item.setStateChange(prevLabel + "->" + targetLabel);
+
+                    // 本地缓存状态已与目标一致 → 跳过远程
+                    if (Objects.equals(target, cached)) {
+                        skippedCount++;
+                        item.setActionResult("无需执行");
+                        item.setCurrentState(targetLabel);
+                        continue;
+                    }
+                    if (blocked) {
+                        needPause.add(task);
+                    } else {
+                        needResume.add(task);
+                    }
+                } catch (Exception e) {
+                    invalidCount++;
+                    fillInvalidItem(item, task, "单任务处理异常");
+                    log.error("{} 单任务异常 batchId={}", LOG_PREFIX,
+                            task == null ? null : task.getBatchId(), e);
+                }
+            }
+            if (System.currentTimeMillis() - evalStart > RULE_EVAL_WARN_MS) {
+                log.error("{} 规则评估超时告警", LOG_PREFIX);
+            }
+
+            // ---------- 5. 有界并行远程停启(结果写入 stateUpdates,不直接打 Redis) ----------
+            int[] pauseResult = runRemoteBatch(needPause, true, itemByBatchId, stateUpdates);
+            int[] resumeResult = runRemoteBatch(needResume, false, itemByBatchId, stateUpdates);
+
+            // ---------- 6. 合并增量并一次回写整份状态 Map ----------
+            if (!stateUpdates.isEmpty()) {
+                stateMap.putAll(stateUpdates);
+                saveShieldStateMap(stateMap);
+            }
+
+            // ---------- 7. 汇总报告 ----------
+            report.setTotalTasks(tasks.size());
+            report.setPausedCount(pauseResult[0]);
+            report.setPauseFailedCount(pauseResult[1]);
+            report.setResumedCount(resumeResult[0]);
+            report.setResumeFailedCount(resumeResult[1]);
+            report.setSkippedCount(skippedCount);
+            report.setInvalidCount(invalidCount);
+            report.setTaskItems(taskItems);
+            report.setSummary(buildSummary(pauseResult[0], resumeResult[0], skippedCount,
+                    pauseResult[1], resumeResult[1]));
+            report.setTotalMs(System.currentTimeMillis() - startMs);
+            log.info("{} 完成 检查{}个 停呼成功{} 停呼失败{} 启呼成功{} 启呼失败{} 跳过{} 无效{} 耗时{}ms",
+                    LOG_PREFIX, tasks.size(), pauseResult[0], pauseResult[1], resumeResult[0],
+                    resumeResult[1], skippedCount, invalidCount, report.getTotalMs());
+        } catch (Exception e) {
+            report.setSuccess(false);
+            report.setSummary("执行异常: " + e.getMessage());
+            report.setTotalMs(System.currentTimeMillis() - startMs);
+            report.setTaskItems(taskItems);
+            log.error("{} 整体异常, 耗时{}ms", LOG_PREFIX, report.getTotalMs(), e);
+        }
+        return printReport(report);
+    }
+
+    /**
+     * 判断本地 batchId 当前是否处于屏蔽时段
+     * <p>
+     * 命中路径尽量短:
+     * <ol>
+     *   <li>{@code status=0 + find_in_set(batchId)} 定向查专属规则(复用现有 Mapper 条件)</li>
+     *   <li>无专属则按主键取 id=0,且要求仍为启用状态,作为兜底</li>
+     *   <li>仅对命中的一条规则 parse + evaluate 当前时刻</li>
+     * </ol>
+     * 相对「一次全表拉启用规则再内存匹配」,单批次通常 1 次(最多 2 次)轻量查询,避免全量加载与遍历。<br>
+     * 入参非法 / 无规则 / 异常一律返回 {@code false},避免误阻断业务。
+     */
+    @Override
+    public boolean isBatchIdInShieldTime(Long localBatchId) {
+        if (localBatchId == null) {
+            return false;
+        }
+        try {
+            return Optional.ofNullable(resolveEnabledRuleForBatch(localBatchId))
+                    .map(ruleEvaluator::parseRule)
+                    .map(parsed -> ruleEvaluator.evaluate(parsed, Calendar.getInstance()))
+                    .map(AiSipAutoCallRuleEvaluator.ShieldDecision::isBlocked)
+                    .orElse(false);
+        } catch (Exception e) {
+            log.error("{} isBatchIdInShieldTime 异常, batchId={}", LOG_PREFIX, localBatchId, e);
+            return false;
+        }
+    }
+
+    /**
+     * 解析单批次应使用的启用规则:专属优先,否则 id=0 兜底。
+     *
+     * @return 启用规则;都没有则 null
+     */
+    private AiSipAutoCallRules resolveEnabledRuleForBatch(Long localBatchId) {
+        // 1) 专属:status=0 且 batch_ids 含该 batchId(SQL find_in_set,短路径)
+        AiSipAutoCallRules query = new AiSipAutoCallRules();
+        query.setStatus(0);
+        query.setBatchId(localBatchId);
+        AiSipAutoCallRules dedicated = Optional.ofNullable(baseMapper.selectAiSipAutoCallRulesList(query))
+                .orElse(Collections.emptyList())
+                .stream()
+                .filter(Objects::nonNull)
+                // 排除误命中的兜底行(兜底一般无 batch_ids;防御性过滤)
+                .filter(rule -> !Objects.equals(rule.getId(), FALLBACK_RULE_ID))
+                .findFirst()
+                .orElse(null);
+        if (dedicated != null) {
+            return dedicated;
+        }
+
+        // 2) 兜底:主键 id=0,且必须是启用状态
+        AiSipAutoCallRules fallback = baseMapper.selectAiSipAutoCallRulesById(FALLBACK_RULE_ID);
+        return fallback != null && Objects.equals(fallback.getStatus(), 0) ? fallback : null;
+    }
+
+    // ========================= Redis 状态读写(抽出,供主流程调用) =========================
+
+    /**
+     * 一次读取整份停启状态 Map。
+     * <p>
+     * Redis Hash:key={@link RedisKeyCommon#SIP_AUTO_CALL_RULE_EVALUATOR_STATE_HASH_KEY},
+     * field=remoteBatchId(字符串),value=1停呼 / 0启呼。<br>
+     * 任务量不大时整表加载比 multiGet 更简单;异常返回空 Map(按「未知」处理)。
+     */
+    private Map<String, String> loadShieldStateMap() {
+        try {
+            Map<String, Object> raw = redisCache.getCacheMap(
+                    RedisKeyCommon.SIP_AUTO_CALL_RULE_EVALUATOR_STATE_HASH_KEY);
+            if (raw == null || raw.isEmpty()) {
+                return new HashMap<>(16);
+            }
+            Map<String, String> result = new HashMap<>(Math.max(16, raw.size() * 2));
+            raw.forEach((field, value) -> {
+                if (StringUtils.isNotBlank(field) && value != null) {
+                    String state = String.valueOf(value);
+                    // 只接受合法状态值,脏数据忽略
+                    if (STATE_BLOCKED.equals(state) || STATE_UNBLOCKED.equals(state)) {
+                        result.put(field, state);
+                    }
+                }
+            });
+            return result;
+        } catch (Exception e) {
+            log.error("{} 读取停启状态 Map 失败,按空Map处理", LOG_PREFIX, e);
+            return new HashMap<>(16);
+        }
+    }
+
+    /**
+     * 一次写入整份停启状态 Map(覆盖 Redis Hash),并设置 TTL。
+     * <p>
+     * 先删旧 Hash 再 putAll,保证已下线的 remoteBatchId 不会永久残留;失败仅打日志。
+     */
+    private void saveShieldStateMap(Map<String, String> stateMap) {
+        if (stateMap == null) {
+            return;
+        }
+        String key = RedisKeyCommon.SIP_AUTO_CALL_RULE_EVALUATOR_STATE_HASH_KEY;
+        try {
+            // 用可写副本,过滤非法条目
+            Map<String, String> toSave = stateMap.entrySet().stream()
+                    .filter(e -> StringUtils.isNotBlank(e.getKey()))
+                    .filter(e -> STATE_BLOCKED.equals(e.getValue()) || STATE_UNBLOCKED.equals(e.getValue()))
+                    .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (a, b) -> b));
+            redisCache.deleteObject(key);
+            if (!toSave.isEmpty()) {
+                redisCache.setCacheMap(key, toSave);
+                redisCache.expire(key, REDIS_STATE_DAYS, TimeUnit.DAYS);
+            }
+        } catch (Exception e) {
+            log.error("{} 保存停启状态 Map 失败, size={}", LOG_PREFIX, stateMap.size(), e);
+        }
+    }
+
+    // ========================= 远程并行停启 =========================
+
+    /**
+     * 有界并行调用远程停/启接口。
+     * <p>
+     * <b>线程安全</b>:使用进程内复用的 {@link #REMOTE_EXECUTOR},不在本方法里 create/shutdown,避免泄漏。<br>
+     * <b>防卡死</b>:{@code allOf.get(timeout)},超时 cancel 未完成任务并返回已统计结果。<br>
+     * <b>Redis</b>:不在此处直接写 Redis,只把目标状态写入 {@code stateUpdates},由主流程统一 {@link #saveShieldStateMap}。
+     *
+     * @return int[2]:{成功数, 失败数}
+     */
+    private int[] runRemoteBatch(List<AiSipCallTask> tasks, boolean stop,
+                                 Map<Long, AiSipShieldRuleReport.TaskReportItem> itemByBatchId,
+                                 ConcurrentHashMap<String, String> stateUpdates) {
+        if (CollectionUtils.isEmpty(tasks)) {
+            return new int[]{0, 0};
+        }
+        AtomicInteger success = new AtomicInteger();
+        AtomicInteger failed = new AtomicInteger();
+        String actionLabel = stop ? "停呼" : "启呼";
+        String targetState = stop ? STATE_BLOCKED : STATE_UNBLOCKED;
+        String apiPath = stop ? RemoteCommon.STOP_TASK_API : RemoteCommon.START_TASK_API;
+
+        List<CompletableFuture<Void>> futures = tasks.stream()
+                .map(task -> CompletableFuture.runAsync(() ->
+                                processOneRemote(task, targetState, apiPath, actionLabel,
+                                        itemByBatchId, stateUpdates, success, failed),
+                        REMOTE_EXECUTOR))
+                .collect(Collectors.toList());
+
+        try {
+            @SuppressWarnings("unchecked")
+            CompletableFuture<Void>[] arr = futures.toArray(new CompletableFuture[0]);
+            CompletableFuture.allOf(arr).get(REMOTE_BATCH_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+        } catch (TimeoutException e) {
+            log.error("{} 远程{}批处理超时{}秒,取消未完成任务", LOG_PREFIX, actionLabel, REMOTE_BATCH_TIMEOUT_SECONDS);
+            futures.forEach(f -> f.cancel(true));
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            futures.forEach(f -> f.cancel(true));
+            log.error("{} 远程{}批处理被中断", LOG_PREFIX, actionLabel, e);
+        } catch (Exception e) {
+            futures.forEach(f -> f.cancel(true));
+            log.error("{} 远程{}批处理异常", LOG_PREFIX, actionLabel, e);
+        }
+        return new int[]{success.get(), failed.get()};
+    }
+
+    /** 单任务远程停启 + 重试;结果写入报告项与 stateUpdates,异常自行消化 */
+    private void processOneRemote(AiSipCallTask task, String targetState, String apiPath,
+                                  String actionLabel,
+                                  Map<Long, AiSipShieldRuleReport.TaskReportItem> itemByBatchId,
+                                  ConcurrentHashMap<String, String> stateUpdates,
+                                  AtomicInteger success, AtomicInteger failed) {
+        Long batchId = task.getBatchId();
+        Long remoteBatchId = task.getRemoteBatchId();
+        AiSipShieldRuleReport.TaskReportItem item = itemByBatchId.get(batchId);
+        String prev = item != null ? item.getPrevCallState() : "未知";
+        String change = prev + "->" + toCallState(targetState);
+        String limit = item != null ? item.getLimitDetail() : "无";
+        String name = blankToDash(task.getBatchName());
+        boolean ok = false;
+        try {
+            for (int attempt = 1; attempt <= REMOTE_RETRY_TIMES; attempt++) {
+                try {
+                    if (remoteBatchId != null && callRemoteOk(remoteBatchId, apiPath)) {
+                        ok = true;
+                        if (attempt > 1) {
+                            log.warn("{} batchId={} remoteId={} {}第{}次重试成功",
+                                    LOG_PREFIX, batchId, remoteBatchId, actionLabel, attempt);
+                        }
+                        break;
+                    }
+                    log.warn("{} batchId={} remoteId={} {}第{}/{}次失败",
+                            LOG_PREFIX, batchId, remoteBatchId, actionLabel, attempt, REMOTE_RETRY_TIMES);
+                } catch (Exception e) {
+                    log.error("{} batchId={} remoteId={} {}第{}/{}次异常",
+                            LOG_PREFIX, batchId, remoteBatchId, actionLabel, attempt, REMOTE_RETRY_TIMES, e);
+                }
+                if (attempt < REMOTE_RETRY_TIMES) {
+                    try {
+                        Thread.sleep(REMOTE_RETRY_INTERVAL_MS);
+                    } catch (InterruptedException ie) {
+                        Thread.currentThread().interrupt();
+                        break;
+                    }
+                }
+            }
+            // 成功/失败都记录目标状态,主流程统一落 Redis
+            if (remoteBatchId != null) {
+                stateUpdates.put(String.valueOf(remoteBatchId), targetState);
+            }
+            if (item != null) {
+                synchronized (item) {
+                    item.setStateChange(change);
+                    item.setActionResult(ok ? "成功" : "失败");
+                    item.setCurrentState(toCallState(targetState));
+                }
+            }
+            if (ok) {
+                success.incrementAndGet();
+            } else {
+                failed.incrementAndGet();
+                log.error("{} batchId={} name={} remoteId={} {} 限制={} 结果=失败(重试{}次,已记入状态Map)",
+                        LOG_PREFIX, batchId, name, remoteBatchId, change, limit, REMOTE_RETRY_TIMES);
+            }
+        } catch (Exception e) {
+            failed.incrementAndGet();
+            if (remoteBatchId != null) {
+                stateUpdates.put(String.valueOf(remoteBatchId), targetState);
+            }
+            if (item != null) {
+                synchronized (item) {
+                    item.setActionResult("失败");
+                }
+            }
+            log.error("{} 远程{}异常 batchId={}", LOG_PREFIX, actionLabel, batchId, e);
+        }
+    }
+
+    /** 调用远程停启接口,code=0 为成功 */
+    private boolean callRemoteOk(Long remoteBatchId, String apiPath) {
+        String url = RemoteCommon.REMOTE_ADDERSS_PREFIX + apiPath + "?batchId=" + remoteBatchId;
+        String result = RemoteCommon.sendGet(url);
+        if (StringUtils.isBlank(result)) {
+            return false;
+        }
+        JSONObject json = JSONObject.parseObject(result);
+        return json != null && Objects.equals(0, json.getInteger("code"));
+    }
+
+    private String printReport(AiSipShieldRuleReport report) {
+        String text = formatReportText(report);
+        log.info("{}\n{}", LOG_PREFIX, text);
+        return text;
+    }
+
+    /**
+     * 前端报告:概况 + 分栏明细。
+     * <p>
+     * 弹窗宽度有限,每条任务拆成两行,控制单行显示宽度,避免折行导致错位。<br>
+     * 前端建议:{@code white-space: pre; font-family: "Consolas","Microsoft YaHei Mono",monospace;}
+     */
+    private static String formatReportText(AiSipShieldRuleReport report) {
+        StringBuilder sb = new StringBuilder(Math.max(768, sizeOf(report.getTaskItems()) * 160));
+        String cost = report.getTotalMs() < 1000
+                ? report.getTotalMs() + " 毫秒"
+                : String.format(Locale.CHINA, "%.1f 秒", report.getTotalMs() / 1000.0);
+        sb.append("【外呼屏蔽规则同步】执行").append(report.isSuccess() ? "成功" : "失败")
+                .append(",耗时 ").append(cost).append('\n');
+        sb.append(formatStatsLine(report)).append('\n');
+        if (StringUtils.isNotBlank(report.getSummary())) {
+            sb.append(report.getSummary()).append('\n');
+        }
+        sb.append('\n');
+
+        List<AiSipShieldRuleReport.TaskReportItem> items = report.getTaskItems();
+        if (CollectionUtils.isEmpty(items)) {
+            sb.append("(暂无任务明细)");
+            return sb.toString();
+        }
+
+        // 单行显示宽约 70:适配常见 Element 确认框,减少折行
+        // 行1:编号 + 任务名称 + 需暂停 + 限制说明
+        // 行2:缩进 + 状态变更 + 执行结果 + 当前状态
+        final int W_ID = 6;
+        final int W_NAME = 26;
+        final int W_PAUSE = 6;
+        final int W_LIMIT = 26;
+        final int W_CHANGE = 14;
+        final int W_RESULT = 10;
+        final int W_STATE = 8;
+        final String INDENT = pad("", W_ID + 2);
+
+        sb.append(pad("编号", W_ID)).append("  ")
+                .append(pad("任务名称", W_NAME)).append("  ")
+                .append(pad("需暂停", W_PAUSE)).append("  ")
+                .append(pad("限制说明", W_LIMIT)).append('\n');
+        sb.append(INDENT)
+                .append(pad("状态变更", W_CHANGE)).append("  ")
+                .append(pad("执行结果", W_RESULT)).append("  ")
+                .append(pad("当前状态", W_STATE)).append('\n');
+        sb.append(repeatChar('-', W_ID + W_NAME + W_PAUSE + W_LIMIT + 6)).append('\n');
+
+        for (AiSipShieldRuleReport.TaskReportItem item : items) {
+            sb.append(pad(item.getBatchId() != null ? String.valueOf(item.getBatchId()) : "-", W_ID)).append("  ")
+                    .append(pad(StringUtils.defaultIfBlank(item.getTaskName(), "-"), W_NAME)).append("  ")
+                    .append(pad(formatNeedPause(item.getNeedPause()), W_PAUSE)).append("  ")
+                    .append(pad(StringUtils.defaultIfBlank(item.getLimitDetail(), "无"), W_LIMIT)).append('\n');
+            sb.append(INDENT)
+                    .append(pad(StringUtils.defaultIfBlank(item.getStateChange(), "-"), W_CHANGE)).append("  ")
+                    .append(pad(StringUtils.defaultIfBlank(item.getActionResult(), "-"), W_RESULT)).append("  ")
+                    .append(pad(StringUtils.defaultIfBlank(item.getCurrentState(), "未知"), W_STATE)).append('\n');
+        }
+        return sb.toString();
+    }
+
+    private static String formatStatsLine(AiSipShieldRuleReport report) {
+        if (report.getTotalTasks() <= 0) {
+            return "本次未检查到外呼任务";
+        }
+        List<String> parts = new ArrayList<>();
+        parts.add("共检查 " + report.getTotalTasks() + " 个外呼任务");
+        if (report.getPausedCount() > 0) {
+            parts.add("新暂停拨打(停呼成功) " + report.getPausedCount() + " 个");
+        }
+        if (report.getResumedCount() > 0) {
+            parts.add("新恢复拨打(启呼成功) " + report.getResumedCount() + " 个");
+        }
+        if (report.getSkippedCount() > 0) {
+            parts.add("状态未变、无需停启 " + report.getSkippedCount() + " 个");
+        }
+        int failCount = report.getPauseFailedCount() + report.getResumeFailedCount();
+        if (failCount > 0) {
+            parts.add("停启操作失败 " + failCount + " 个");
+        }
+        if (report.getInvalidCount() > 0) {
+            parts.add("任务数据异常或单任务失败 " + report.getInvalidCount() + " 个");
+        }
+        return parts.size() == 1 ? parts.get(0) + ",本次均无变更" : String.join(",", parts);
+    }
+
+    private static String buildSummary(int pausedCount, int resumedCount, int skippedCount,
+                                       int pauseFailedCount, int resumeFailedCount) {
+        int failCount = pauseFailedCount + resumeFailedCount;
+        if (failCount > 0) {
+            return "有 " + failCount + " 个任务停启操作失败,请查看明细并排查";
+        }
+        if (pausedCount > 0 && resumedCount > 0) {
+            return "部分任务因命中限制已停呼,部分任务已恢复启呼,详见下方明细";
+        }
+        if (resumedCount > 0 && pausedCount == 0 && skippedCount == 0) {
+            return "当前未命中限制规则,相关任务均已恢复为启呼(可拨打)";
+        }
+        if (pausedCount > 0 && resumedCount == 0) {
+            return "当前命中限制规则,相关任务已切换为停呼(暂停拨打)";
+        }
+        if (skippedCount > 0) {
+            return "所有任务当前状态与规则评估一致,无需重复停启";
+        }
+        return "本次检查已完成,任务状态无变化";
+    }
+
+    private static void fillInvalidItem(AiSipShieldRuleReport.TaskReportItem item,
+                                        AiSipCallTask task, String reason) {
+        if (task != null) {
+            item.setBatchId(task.getBatchId());
+            item.setTaskName(blankToDash(task.getBatchName()));
+        } else {
+            item.setTaskName("未知");
+        }
+        item.setNeedPause(null);
+        item.setLimitDetail(reason);
+        item.setPrevCallState("未知");
+        item.setStateChange("无法处理");
+        item.setActionResult("失败");
+        item.setCurrentState("未知");
+    }
+
+    private static String limitText(AiSipAutoCallRuleEvaluator.ShieldDecision decision) {
+        if (decision == null) {
+            return "未知限制";
+        }
+        if (StringUtils.isNotBlank(decision.getReason())) {
+            return decision.getReason();
+        }
+        String matched = decision.getMatchedRule();
+        if ("rule_one".equals(matched)) {
+            return "法定节假日";
+        }
+        if ("rule_two".equals(matched)) {
+            return "特殊日期";
+        }
+        if ("rule_three".equals(matched)) {
+            return "星期屏蔽";
+        }
+        if ("rule_four".equals(matched)) {
+            return "时段屏蔽";
+        }
+        return StringUtils.defaultIfBlank(matched, "未知限制");
+    }
+
+    private static String toCallState(String redisState) {
+        if (STATE_BLOCKED.equals(redisState)) {
+            return "停呼";
+        }
+        if (STATE_UNBLOCKED.equals(redisState)) {
+            return "启呼";
+        }
+        return "未知";
+    }
+
+    private static String formatNeedPause(Boolean needPause) {
+        if (needPause == null) {
+            return "未知";
+        }
+        return needPause ? "是" : "否";
+    }
+
+    private static String blankToDash(String value) {
+        return StringUtils.isBlank(value) ? "-" : value;
+    }
+
+    private static int sizeOf(List<?> list) {
+        return list == null ? 0 : list.size();
+    }
+
+    /** 按显示宽度补齐空格(非 ASCII 按 2 宽),超长截断加省略号,便于弹窗对齐 */
+    private static String pad(String text, int displayWidth) {
+        if (displayWidth <= 0) {
+            return "";
+        }
+        String clipped = clipToDisplayWidth(text, displayWidth);
+        int width = displayWidthOf(clipped);
+        StringBuilder out = new StringBuilder(clipped);
+        while (width < displayWidth) {
+            out.append(' ');
+            width++;
+        }
+        return out.toString();
+    }
+
+    private static String clipToDisplayWidth(String text, int maxWidth) {
+        String src = text == null ? "-" : text;
+        if (maxWidth <= 0) {
+            return "";
+        }
+        int width = 0;
+        StringBuilder out = new StringBuilder();
+        for (int i = 0; i < src.length(); i++) {
+            char c = src.charAt(i);
+            int w = charDisplayWidth(c);
+            if (width + w > maxWidth) {
+                // 预留 1 显示宽给省略号
+                while (width > maxWidth - 1 && out.length() > 0) {
+                    char last = out.charAt(out.length() - 1);
+                    out.deleteCharAt(out.length() - 1);
+                    width -= charDisplayWidth(last);
+                }
+                if (maxWidth >= 1 && width < maxWidth) {
+                    out.append('…');
+                }
+                break;
+            }
+            out.append(c);
+            width += w;
+        }
+        return out.toString();
+    }
+
+    private static int displayWidthOf(String text) {
+        if (text == null || text.isEmpty()) {
+            return 0;
+        }
+        int width = 0;
+        for (int i = 0; i < text.length(); i++) {
+            width += charDisplayWidth(text.charAt(i));
+        }
+        return width;
+    }
+
+    private static int charDisplayWidth(char c) {
+        return c > 0x7F ? 2 : 1;
+    }
+
+    private static String repeatChar(char c, int count) {
+        if (count <= 0) {
+            return "";
+        }
+        StringBuilder sb = new StringBuilder(count);
+        for (int i = 0; i < count; i++) {
+            sb.append(c);
+        }
+        return sb.toString();
+    }
+}

+ 44 - 64
fs-service/src/main/java/com/fs/aiSipCall/service/impl/AiSipCallBizGroupServiceImpl.java

@@ -1,110 +1,90 @@
 package com.fs.aiSipCall.service.impl;
 
-import com.alibaba.fastjson.JSONObject;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.fs.aiSipCall.RemoteCommon;
 import com.fs.aiSipCall.domain.AiSipCallBizGroup;
 import com.fs.aiSipCall.mapper.AiSipCallBizGroupMapper;
 import com.fs.aiSipCall.service.IAiSipCallBizGroupService;
+import com.fs.aiSipCall.utils.RemoteApiHelper;
 import com.fs.common.utils.DateUtils;
-import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.lang3.StringUtils;
 import org.springframework.stereotype.Service;
 
+import java.util.Collections;
 import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
 
 /**
  * aiSIP外呼技能组Service业务层处理
- * 
+ *
  * @author fs
  * @date 2026-03-06
  */
-@Slf4j
 @Service
 public class AiSipCallBizGroupServiceImpl extends ServiceImpl<AiSipCallBizGroupMapper, AiSipCallBizGroup> implements IAiSipCallBizGroupService {
 
-    /**
-     * 查询aiSIP外呼技能组
-     * 
-     * @param groupId aiSIP外呼技能组主键
-     * @return aiSIP外呼技能组
-     */
+    private static final String API_DESC = "获取技能组";
+
     @Override
-    public AiSipCallBizGroup selectAiSipCallBizGroupByGroupId(Long groupId)
-    {
+    public AiSipCallBizGroup selectAiSipCallBizGroupByGroupId(Long groupId) {
         return baseMapper.selectAiSipCallBizGroupByGroupId(groupId);
     }
 
-    /**
-     * 查询aiSIP外呼技能组列表
-     * 
-     * @param aiSipCallBizGroup aiSIP外呼技能组
-     * @return aiSIP外呼技能组
-     */
     @Override
-    public List<AiSipCallBizGroup> selectAiSipCallBizGroupList(AiSipCallBizGroup aiSipCallBizGroup)
-    {
-        //先使用远程技能组
-        String result = RemoteCommon.sendGet(RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.BUSIGROUP_LIST_API);
-        if(StringUtils.isNotBlank(result)){
-            JSONObject jsonObject = JSONObject.parseObject(result);
-            if(jsonObject.getInteger("code") == 0){
-                String data = jsonObject.getString("data");
-                return JSONObject.parseArray(data, AiSipCallBizGroup.class);
-            }else{
-                log.error("获取技能组接口失败:{}", jsonObject.getString("msg"));
-            }
+    public List<AiSipCallBizGroup> selectAiSipCallBizGroupList(AiSipCallBizGroup aiSipCallBizGroup) {
+        List<AiSipCallBizGroup> remoteList = RemoteApiHelper.invoke(
+                () -> RemoteCommon.sendGet(RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.BUSIGROUP_LIST_API),
+                result -> RemoteApiHelper.parseArrayData(result, AiSipCallBizGroup.class, API_DESC),
+                API_DESC
+        );
+        return filterBizGroupList(remoteList, aiSipCallBizGroup);
+    }
+
+    private List<AiSipCallBizGroup> filterBizGroupList(List<AiSipCallBizGroup> source, AiSipCallBizGroup query) {
+        List<AiSipCallBizGroup> list = Optional.ofNullable(source).orElse(Collections.emptyList());
+        if (query == null) {
+            return list;
+        }
+        Stream<AiSipCallBizGroup> stream = list.stream();
+        if (StringUtils.isNotBlank(query.getBizGroupName())) {
+            String keyword = query.getBizGroupName();
+            stream = stream.filter(item -> StringUtils.containsIgnoreCase(item.getBizGroupName(), keyword));
+        }
+        if (query.getSortNo() != null) {
+            stream = stream.filter(item -> Objects.equals(query.getSortNo(), item.getSortNo()));
+        }
+        if (StringUtils.isNotBlank(query.getNotes())) {
+            stream = stream.filter(item -> Objects.equals(query.getNotes(), item.getNotes()));
+        }
+        if (query.getRemoteGroupId() != null) {
+            stream = stream.filter(item -> Objects.equals(query.getRemoteGroupId(), item.getRemoteGroupId())
+                    || Objects.equals(query.getRemoteGroupId(), item.getGroupId()));
         }
-        return null;
-//        return baseMapper.selectAiSipCallBizGroupList(aiSipCallBizGroup);
+        return stream.collect(Collectors.toList());
     }
 
-    /**
-     * 新增aiSIP外呼技能组
-     * 
-     * @param aiSipCallBizGroup aiSIP外呼技能组
-     * @return 结果
-     */
     @Override
-    public int insertAiSipCallBizGroup(AiSipCallBizGroup aiSipCallBizGroup)
-    {
+    public int insertAiSipCallBizGroup(AiSipCallBizGroup aiSipCallBizGroup) {
+        Objects.requireNonNull(aiSipCallBizGroup, "aiSipCallBizGroup");
         aiSipCallBizGroup.setCreateTime(DateUtils.getNowDate());
         return baseMapper.insertAiSipCallBizGroup(aiSipCallBizGroup);
     }
 
-    /**
-     * 修改aiSIP外呼技能组
-     * 
-     * @param aiSipCallBizGroup aiSIP外呼技能组
-     * @return 结果
-     */
     @Override
-    public int updateAiSipCallBizGroup(AiSipCallBizGroup aiSipCallBizGroup)
-    {
+    public int updateAiSipCallBizGroup(AiSipCallBizGroup aiSipCallBizGroup) {
         return baseMapper.updateAiSipCallBizGroup(aiSipCallBizGroup);
     }
 
-    /**
-     * 批量删除aiSIP外呼技能组
-     * 
-     * @param groupIds 需要删除的aiSIP外呼技能组主键
-     * @return 结果
-     */
     @Override
-    public int deleteAiSipCallBizGroupByGroupIds(Long[] groupIds)
-    {
+    public int deleteAiSipCallBizGroupByGroupIds(Long[] groupIds) {
         return baseMapper.deleteAiSipCallBizGroupByGroupIds(groupIds);
     }
 
-    /**
-     * 删除aiSIP外呼技能组信息
-     * 
-     * @param groupId aiSIP外呼技能组主键
-     * @return 结果
-     */
     @Override
-    public int deleteAiSipCallBizGroupByGroupId(Long groupId)
-    {
+    public int deleteAiSipCallBizGroupByGroupId(Long groupId) {
         return baseMapper.deleteAiSipCallBizGroupByGroupId(groupId);
     }
 }

+ 47 - 78
fs-service/src/main/java/com/fs/aiSipCall/service/impl/AiSipCallGatewayServiceImpl.java

@@ -6,13 +6,15 @@ import com.fs.aiSipCall.RemoteCommon;
 import com.fs.aiSipCall.domain.AiSipCallGateway;
 import com.fs.aiSipCall.mapper.AiSipCallGatewayMapper;
 import com.fs.aiSipCall.service.IAiSipCallGatewayService;
+import com.fs.aiSipCall.utils.RemoteApiHelper;
 import com.fs.common.core.page.TableDataInfo;
 import com.fs.common.utils.DateUtils;
-import lombok.extern.slf4j.Slf4j;
-import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
 
 import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
 
 /**
  * aiSIP外呼网关Service业务层处理
@@ -20,109 +22,76 @@ import java.util.List;
  * @author fs
  * @date 2026-03-06
  */
-@Slf4j
 @Service
 public class AiSipCallGatewayServiceImpl extends ServiceImpl<AiSipCallGatewayMapper, AiSipCallGateway> implements IAiSipCallGatewayService {
 
-    /**
-     * 查询aiSIP外呼网关
-     *
-     * @param id aiSIP外呼网关主键
-     * @return aiSIP外呼网关
-     */
+    private static final String API_DESC = "获取网关";
+
+    /** 是否使用自己线路 */
+    @Value("${sip.call.myGateway:false}")
+    private boolean isMyGateway;
+    /** 使用自动线路前缀 */
+    @Value("${sip.call.automaticGatewayPrefix:automatic}")
+    private String automaticGatewayPrefix;
+    /** 使用手动线路前缀 */
+    @Value("${sip.call.manualGatewayPrefix:weizhi}")
+    private String manualGatewayPrefix;
+    /** 使用公共线路前缀 */
+    @Value("${sip.call.publicGatewayPrefix:outbound}")
+    private String publicGatewayPrefix;
+
     @Override
-    public AiSipCallGateway selectAiSipCallGatewayById(Long id)
-    {
+    public AiSipCallGateway selectAiSipCallGatewayById(Long id) {
         return baseMapper.selectAiSipCallGatewayById(id);
     }
 
+    @Override
+    public TableDataInfo remoteList(AiSipCallGateway aiSipCallGateway) {
+        AiSipCallGateway query = Optional.ofNullable(aiSipCallGateway).orElseGet(AiSipCallGateway::new);
+        fillGatewayName(query);
+        String body = JSONObject.toJSONString(query);
+        return RemoteApiHelper.toTableDataInfo(
+                () -> RemoteCommon.sendPost(RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.GATEWAY_LIST_API, body),
+                API_DESC
+        );
+    }
+
     /**
-     * 查询aiSIP外呼网关列表
-     *
-     * @param aiSipCallGateway aiSIP外呼网关
-     * @return aiSIP外呼网关
+     * 按配置填充网关名称前缀
      */
-    @Override
-    public List<AiSipCallGateway> selectAiSipCallGatewayList(AiSipCallGateway aiSipCallGateway)
-    {
-        //先使用远程网关
-        String result = RemoteCommon.sendPost(RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.GATEWAY_LIST_API, JSONObject.toJSONString(aiSipCallGateway));
-        if(StringUtils.isNotBlank(result)){
-            JSONObject jsonObject = JSONObject.parseObject(result);
-            if(jsonObject.getInteger("code") == 0){
-                String data = jsonObject.getString("data");
-                return JSONObject.parseArray(data, AiSipCallGateway.class);
-            }else{
-                log.error("获取网关接口失败:{}", jsonObject.getString("msg"));
-            }
+    private void fillGatewayName(AiSipCallGateway gateway) {
+        if (isMyGateway) {
+            boolean aiCall = Boolean.TRUE.equals(gateway.getIsAICall());
+            gateway.setGwName(aiCall ? automaticGatewayPrefix : manualGatewayPrefix);
+        } else {
+            gateway.setGwName(publicGatewayPrefix);
         }
-        return null;
-
+    }
 
-//        return baseMapper.selectAiSipCallGatewayList(aiSipCallGateway);
+    @Override
+    public List<AiSipCallGateway> selectAiSipCallGatewayList(AiSipCallGateway aiSipCallGateway) {
+        return baseMapper.selectAiSipCallGatewayList(aiSipCallGateway);
     }
 
-    /**
-     * 新增aiSIP外呼网关
-     *
-     * @param aiSipCallGateway aiSIP外呼网关
-     * @return 结果
-     */
     @Override
-    public int insertAiSipCallGateway(AiSipCallGateway aiSipCallGateway)
-    {
+    public int insertAiSipCallGateway(AiSipCallGateway aiSipCallGateway) {
         return baseMapper.insertAiSipCallGateway(aiSipCallGateway);
     }
 
-    /**
-     * 修改aiSIP外呼网关
-     *
-     * @param aiSipCallGateway aiSIP外呼网关
-     * @return 结果
-     */
     @Override
-    public int updateAiSipCallGateway(AiSipCallGateway aiSipCallGateway)
-    {
+    public int updateAiSipCallGateway(AiSipCallGateway aiSipCallGateway) {
+        Objects.requireNonNull(aiSipCallGateway, "aiSipCallGateway");
         aiSipCallGateway.setUpdateTime(DateUtils.getNowDate());
         return baseMapper.updateAiSipCallGateway(aiSipCallGateway);
     }
 
-    /**
-     * 批量删除aiSIP外呼网关
-     *
-     * @param ids 需要删除的aiSIP外呼网关主键
-     * @return 结果
-     */
     @Override
-    public int deleteAiSipCallGatewayByIds(Long[] ids)
-    {
+    public int deleteAiSipCallGatewayByIds(Long[] ids) {
         return baseMapper.deleteAiSipCallGatewayByIds(ids);
     }
 
-    /**
-     * 删除aiSIP外呼网关信息
-     *
-     * @param id aiSIP外呼网关主键
-     * @return 结果
-     */
     @Override
-    public int deleteAiSipCallGatewayById(Long id)
-    {
+    public int deleteAiSipCallGatewayById(Long id) {
         return baseMapper.deleteAiSipCallGatewayById(id);
     }
-
-    @Override
-    public TableDataInfo remoteList(AiSipCallGateway aiSipCallGateway) {
-        //先使用远程网关
-        String result = RemoteCommon.sendPost(RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.GATEWAY_LIST_API, JSONObject.toJSONString(aiSipCallGateway));
-        if(StringUtils.isNotBlank(result)){
-            JSONObject jsonObject = JSONObject.parseObject(result);
-            if(jsonObject.getInteger("code") == 0){
-                return JSONObject.parseObject(result, TableDataInfo.class);
-            }else{
-                log.error("获取网关接口失败:{}", jsonObject.getString("msg"));
-            }
-        }
-        return null;
-    }
 }

+ 15 - 66
fs-service/src/main/java/com/fs/aiSipCall/service/impl/AiSipCallLlmAgentAccountServiceImpl.java

@@ -1,108 +1,57 @@
 package com.fs.aiSipCall.service.impl;
 
-import com.alibaba.fastjson.JSONObject;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.fs.aiSipCall.RemoteCommon;
 import com.fs.aiSipCall.domain.AiSipCallLlmAgentAccount;
 import com.fs.aiSipCall.mapper.AiSipCallLlmAgentAccountMapper;
 import com.fs.aiSipCall.service.IAiSipCallLlmAgentAccountService;
-import lombok.extern.slf4j.Slf4j;
-import org.apache.commons.lang3.StringUtils;
+import com.fs.aiSipCall.utils.RemoteApiHelper;
 import org.springframework.stereotype.Service;
 
 import java.util.List;
 
 /**
  * aiSIP外呼大模型Service业务层处理
- * 
+ *
  * @author fs
  * @date 2026-03-06
  */
-@Slf4j
 @Service
 public class AiSipCallLlmAgentAccountServiceImpl extends ServiceImpl<AiSipCallLlmAgentAccountMapper, AiSipCallLlmAgentAccount> implements IAiSipCallLlmAgentAccountService {
 
-    /**
-     * 查询aiSIP外呼大模型
-     * 
-     * @param id aiSIP外呼大模型主键
-     * @return aiSIP外呼大模型
-     */
+    private static final String API_DESC = "获取大模型";
+
     @Override
-    public AiSipCallLlmAgentAccount selectAiSipCallLlmAgentAccountById(Long id)
-    {
+    public AiSipCallLlmAgentAccount selectAiSipCallLlmAgentAccountById(Long id) {
         return baseMapper.selectAiSipCallLlmAgentAccountById(id);
     }
 
-    /**
-     * 查询aiSIP外呼大模型列表
-     * 
-     * @param aiSipCallLlmAgentAccount aiSIP外呼大模型
-     * @return aiSIP外呼大模型
-     */
     @Override
-    public List<AiSipCallLlmAgentAccount> selectAiSipCallLlmAgentAccountList(AiSipCallLlmAgentAccount aiSipCallLlmAgentAccount)
-    {
-        //先使用远程大模型
-        String result = RemoteCommon.sendGet(RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.LLMACOUNT_LIST_API);
-        if(StringUtils.isNotBlank(result)){
-            JSONObject jsonObject = JSONObject.parseObject(result);
-            if(jsonObject.getInteger("code") == 0){
-                String data = jsonObject.getString("data");
-                return JSONObject.parseArray(data, AiSipCallLlmAgentAccount.class);
-            }else{
-                log.error("获取大模型接口失败:{}", jsonObject.getString("msg"));
-            }
-        }
-        return null;
-//        return baseMapper.selectAiSipCallLlmAgentAccountList(aiSipCallLlmAgentAccount);
+    public List<AiSipCallLlmAgentAccount> selectAiSipCallLlmAgentAccountList(AiSipCallLlmAgentAccount aiSipCallLlmAgentAccount) {
+        return RemoteApiHelper.invoke(
+                () -> RemoteCommon.sendGet(RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.LLMACOUNT_LIST_API),
+                result -> RemoteApiHelper.parseArrayData(result, AiSipCallLlmAgentAccount.class, API_DESC),
+                API_DESC
+        );
     }
 
-    /**
-     * 新增aiSIP外呼大模型
-     * 
-     * @param aiSipCallLlmAgentAccount aiSIP外呼大模型
-     * @return 结果
-     */
     @Override
-    public int insertAiSipCallLlmAgentAccount(AiSipCallLlmAgentAccount aiSipCallLlmAgentAccount)
-    {
+    public int insertAiSipCallLlmAgentAccount(AiSipCallLlmAgentAccount aiSipCallLlmAgentAccount) {
         return baseMapper.insertAiSipCallLlmAgentAccount(aiSipCallLlmAgentAccount);
     }
 
-    /**
-     * 修改aiSIP外呼大模型
-     * 
-     * @param aiSipCallLlmAgentAccount aiSIP外呼大模型
-     * @return 结果
-     */
     @Override
-    public int updateAiSipCallLlmAgentAccount(AiSipCallLlmAgentAccount aiSipCallLlmAgentAccount)
-    {
+    public int updateAiSipCallLlmAgentAccount(AiSipCallLlmAgentAccount aiSipCallLlmAgentAccount) {
         return baseMapper.updateAiSipCallLlmAgentAccount(aiSipCallLlmAgentAccount);
     }
 
-    /**
-     * 批量删除aiSIP外呼大模型
-     * 
-     * @param ids 需要删除的aiSIP外呼大模型主键
-     * @return 结果
-     */
     @Override
-    public int deleteAiSipCallLlmAgentAccountByIds(Long[] ids)
-    {
+    public int deleteAiSipCallLlmAgentAccountByIds(Long[] ids) {
         return baseMapper.deleteAiSipCallLlmAgentAccountByIds(ids);
     }
 
-    /**
-     * 删除aiSIP外呼大模型信息
-     * 
-     * @param id aiSIP外呼大模型主键
-     * @return 结果
-     */
     @Override
-    public int deleteAiSipCallLlmAgentAccountById(Long id)
-    {
+    public int deleteAiSipCallLlmAgentAccountById(Long id) {
         return baseMapper.deleteAiSipCallLlmAgentAccountById(id);
     }
 }

+ 284 - 406
fs-service/src/main/java/com/fs/aiSipCall/service/impl/AiSipCallOutboundCdrServiceImpl.java

@@ -1,88 +1,148 @@
 package com.fs.aiSipCall.service.impl;
 
-import cn.hutool.core.util.ObjectUtil;
-import cn.hutool.json.JSONUtil;
 import com.alibaba.fastjson.JSONObject;
-import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
-import com.fasterxml.jackson.core.type.TypeReference;
-import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fs.aiSipCall.RemoteCommon;
 import com.fs.aiSipCall.domain.AiSipCallOutboundCdr;
 import com.fs.aiSipCall.domain.CcCustInfo;
 import com.fs.aiSipCall.mapper.AiSipCallOutboundCdrMapper;
-import com.fs.aiSipCall.param.ApiCallRecordByUuidQueryParams;
-import com.fs.aiSipCall.param.ApiCallRecordQueryParams;
+import com.fs.aiSipCall.param.StatisticalManagementParam;
 import com.fs.aiSipCall.service.IAiSipCallOutboundCdrService;
 import com.fs.aiSipCall.utils.DateUtils;
+import com.fs.aiSipCall.utils.RemoteApiHelper;
+import com.fs.aiSipCall.utils.SipLogUtil;
+import com.fs.aiSipCall.vo.StatisticalManagementVo;
 import com.fs.common.core.domain.AjaxResult;
-import com.fs.common.core.redis.RedisCache;
-import com.fs.company.domain.CompanyAiWorkflowExec;
-import com.fs.company.domain.CompanyVoiceRoboticBusiness;
-import com.fs.company.domain.CompanyVoiceRoboticCallLogCallphone;
-import com.fs.company.mapper.CompanyAiWorkflowExecMapper;
-import com.fs.company.mapper.CompanyVoiceRoboticBusinessMapper;
-import com.fs.company.mapper.CompanyVoiceRoboticCallLogCallphoneMapper;
-import com.fs.company.mapper.EasyCallMapper;
-import com.fs.company.service.CompanyWorkflowEngine;
-import com.fs.company.vo.CidConfigVO;
-import com.fs.company.vo.easycall.EasyCallOutBoundVO;
-import com.fs.system.service.ISysConfigService;
-import lombok.extern.slf4j.Slf4j;
+import com.fs.common.core.domain.entity.SysUser;
+import com.fs.company.domain.CompanyUser;
+import com.fs.company.mapper.CompanyMapper;
+import com.fs.company.mapper.CompanyUserMapper;
+import com.fs.his.domain.FsDoctor;
+import com.fs.his.mapper.FsDoctorMapper;
+import com.fs.his.utils.PhoneUtil;
+import com.fs.his.vo.OptionsVO;
+import com.fs.system.mapper.SysUserMapper;
 import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
 import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.scheduling.annotation.Async;
 import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
 
-import java.math.BigDecimal;
-import java.math.RoundingMode;
+import java.net.URLDecoder;
 import java.util.*;
-import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.stream.Collectors;
 
-import static com.fs.company.service.impl.call.node.AiCallTaskNode.EASYCALL_WORKFLOW_REDIS_KEY;
-
 /**
  * aiSIP手动外呼通话记录Service业务层处理
  *
  * @author fs
  * @date 2026-03-19
  */
-@Slf4j
 @Service
 public class AiSipCallOutboundCdrServiceImpl extends ServiceImpl<AiSipCallOutboundCdrMapper, AiSipCallOutboundCdr> implements IAiSipCallOutboundCdrService {
 
-    @Autowired
-    private CompanyVoiceRoboticCallLogCallphoneMapper companyVoiceRoboticCallLogCallphoneMapper;
-    @Autowired
-    private RedisCache redisCache;
+    private static final Logger log = SipLogUtil.createSipLogger(AiSipCallOutboundCdrServiceImpl.class);
 
     @Autowired
-    private EasyCallMapper easyCallMapper;
-
+    private CompanyMapper companyMapper;
     @Autowired
-    private CompanyAiWorkflowExecMapper currentExecutionMapper;
-
+    private CompanyUserMapper companyUserMapper;
     @Autowired
-    private ObjectMapper objectMapper;
+    private SysUserMapper sysUserMapper;
     @Autowired
-    private CompanyVoiceRoboticBusinessMapper companyVoiceRoboticBusinessMapper;
+    private FsDoctorMapper fsDoctorMapper;
 
-    @Autowired
-    private CompanyWorkflowEngine companyWorkflowEngine;
-    @Autowired
-    private ISysConfigService configService;
+    @Override
+    public AiSipCallOutboundCdr selectAiSipCallOutboundCdrById(String id) {
+        return baseMapper.selectAiSipCallOutboundCdrById(id);
+    }
+
+    @Override
+    public void callEndSyncByUuid(AiSipCallOutboundCdr request) {
+        if (request == null) {
+            log.error("同步手动外呼记录失败:请求参数为空");
+            return;
+        }
+        String body = JSONObject.toJSONString(request);
+        Optional<JSONObject> jsonOpt = RemoteApiHelper.tryParseSuccess(
+                RemoteCommon.sendPost(RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.QUERY_OUTBOUNDCDR_LIST_API, body),
+                "同步手动外呼记录"
+        );
+        if (!jsonOpt.isPresent()) {
+            return;
+        }
+        List<AiSipCallOutboundCdr> list = RemoteApiHelper.toList(jsonOpt.get().get("rows"), AiSipCallOutboundCdr.class);
+        if (list.isEmpty()) {
+            log.error("同步手动外呼记录失败:rows为空");
+            return;
+        }
+        AiSipCallOutboundCdr data = list.get(0);
+        data.setSourceType(request.getSourceType());
+        data.setCompanyId(request.getCompanyId());
+        data.setCompanyUserId(request.getCompanyUserId());
+        data.setCompanyUserName(request.getCompanyUserName());
+        data.setStatus(0);
+        data.setSysUserId(request.getSysUserId());
+        data.setSysUserName(request.getSysUserName());
+        data.setDoctorId(request.getDoctorId());
+        decodeOpnum(data);
+        encryptPhoneNumberSaveDatabase(data);
+        this.save(data);
+    }
 
-    private static final BigDecimal DEFAULT_CALL_CHARGE = new BigDecimal("0.12");
-    private static final BigDecimal ONE_MINUTES_SECOND = new BigDecimal("60");
+    private void decodeOpnum(AiSipCallOutboundCdr data) {
+        if (StringUtils.isBlank(data.getOpnum())) {
+            return;
+        }
+        try {
+            data.setOpnum(URLDecoder.decode(data.getOpnum(), "UTF-8"));
+        } catch (Exception e) {
+            log.error("opnum字段URL解码失败,原值:{}", data.getOpnum(), e);
+        }
+    }
 
+    @Override
+    public List<Long> getTodayChangeUserIds() {
+        return baseMapper.getTodayChangeUserIds();
+    }
 
+    @Override
+    public List<AiSipCallOutboundCdr> findWaitEncryptPhones(Long currentDateMillis, Integer batchSize) {
+        return baseMapper.findWaitEncryptPhones(currentDateMillis, batchSize);
+    }
 
     @Override
-    public AiSipCallOutboundCdr selectAiSipCallOutboundCdrById(String id) {
-        return baseMapper.selectAiSipCallOutboundCdrById(id);
+    public int updateEncryptPhone(List<AiSipCallOutboundCdr> target) {
+        return baseMapper.updateEncryptPhone(target);
     }
+
+    @Override
+    public List<StatisticalManagementVo> statisticalManagement(StatisticalManagementParam param) {
+        List<StatisticalManagementVo> list = baseMapper.statisticalManagement(param);
+        if(!CollectionUtils.isEmpty(list)){
+            List<Long> companyIds = list.stream()
+                    .map(StatisticalManagementVo::getCompanyId)
+                    .filter(Objects::nonNull)
+                    .distinct()
+                    .collect(Collectors.toList());
+            if (!companyIds.isEmpty()) {
+                List<OptionsVO> companyOptions = Optional.ofNullable(companyMapper.selectByIds(companyIds))
+                        .orElse(Collections.emptyList());
+                Map<Long, String> companyNameMap = companyOptions.stream()
+                        .filter(Objects::nonNull)
+                        .filter(opt -> opt.getDictValue() != null)
+                        .collect(Collectors.toMap(OptionsVO::getDictValue, OptionsVO::getDictLabel, (v1, v2) -> v1));
+                list.forEach(data -> {
+                    if (data.getCompanyId() != null) {
+                        data.setCompanyName(companyNameMap.getOrDefault(data.getCompanyId(), "未知"));
+                    }
+                });
+            }
+        }
+
+        return list;
+    }
+
     /**
      * 查询aiSIP手动外呼通话记录列表
      *
@@ -92,32 +152,41 @@ public class AiSipCallOutboundCdrServiceImpl extends ServiceImpl<AiSipCallOutbou
     @Override
     public List<AiSipCallOutboundCdr> selectAiSipCallOutboundCdrList(AiSipCallOutboundCdr aiSipCallOutboundCdr)
     {
-        if (aiSipCallOutboundCdr.getTimeLenStart() != null) {
-            aiSipCallOutboundCdr.setTimeLenStart(aiSipCallOutboundCdr.getTimeLenStart() * 60 * 1000L);
+        AiSipCallOutboundCdr query = Optional.ofNullable(aiSipCallOutboundCdr).orElseGet(AiSipCallOutboundCdr::new);
+        if (query.getTimeLenStart() != null) {
+            query.setTimeLenStart(query.getTimeLenStart() * 60 * 1000L);
         }
-        if (aiSipCallOutboundCdr.getTimeLenEnd() != null) {
-            aiSipCallOutboundCdr.setTimeLenEnd(aiSipCallOutboundCdr.getTimeLenEnd() * 60 * 1000L);
+        if (query.getTimeLenEnd() != null) {
+            query.setTimeLenEnd(query.getTimeLenEnd() * 60 * 1000L);
         }
-        if (StringUtils.isNotBlank(aiSipCallOutboundCdr.getStartTimeStart())) {
-            aiSipCallOutboundCdr.setStartTimeStartLong(DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", aiSipCallOutboundCdr.getStartTimeStart()).getTime());
+        if (StringUtils.isNotBlank(query.getStartTimeStart())) {
+            query.setStartTimeStartLong(DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", query.getStartTimeStart()).getTime());
         }
-        if (StringUtils.isNotBlank(aiSipCallOutboundCdr.getStartTimeEnd())) {
-            aiSipCallOutboundCdr.setStartTimeEndLong(DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", aiSipCallOutboundCdr.getStartTimeEnd()).getTime());
+        if (StringUtils.isNotBlank(query.getStartTimeEnd())) {
+            query.setStartTimeEndLong(DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", query.getStartTimeEnd()).getTime());
         }
-        if (StringUtils.isNotBlank(aiSipCallOutboundCdr.getAnsweredTimeStart())) {
-            aiSipCallOutboundCdr.setAnsweredTimeStartLong(DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", aiSipCallOutboundCdr.getAnsweredTimeStart()).getTime());
+        if (StringUtils.isNotBlank(query.getAnsweredTimeStart())) {
+            query.setAnsweredTimeStartLong(DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", query.getAnsweredTimeStart()).getTime());
         }
-        if (StringUtils.isNotBlank(aiSipCallOutboundCdr.getAnsweredTimeEnd())) {
-            aiSipCallOutboundCdr.setAnsweredTimeEndLong(DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", aiSipCallOutboundCdr.getAnsweredTimeEnd()).getTime());
+        if (StringUtils.isNotBlank(query.getAnsweredTimeEnd())) {
+            query.setAnsweredTimeEndLong(DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", query.getAnsweredTimeEnd()).getTime());
         }
 
-        if (StringUtils.isNotBlank(aiSipCallOutboundCdr.getEndTimeStart())) {
-            aiSipCallOutboundCdr.setEndTimeStartLong(DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", aiSipCallOutboundCdr.getEndTimeStart()).getTime());
+        if (StringUtils.isNotBlank(query.getEndTimeStart())) {
+            query.setEndTimeStartLong(DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", query.getEndTimeStart()).getTime());
         }
-        if (StringUtils.isNotBlank(aiSipCallOutboundCdr.getEndTimeEnd())) {
-            aiSipCallOutboundCdr.setEndTimeEndLong(DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", aiSipCallOutboundCdr.getEndTimeEnd()).getTime());
+        if (StringUtils.isNotBlank(query.getEndTimeEnd())) {
+            query.setEndTimeEndLong(DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", query.getEndTimeEnd()).getTime());
         }
-        return baseMapper.selectAiSipCallOutboundCdrList(aiSipCallOutboundCdr);
+        List<AiSipCallOutboundCdr> list = baseMapper.selectAiSipCallOutboundCdrList(query);
+        if (!CollectionUtils.isEmpty(list)) {
+            Map<Long, String> companyNameMap = buildCompanyNameMap(list);
+            Map<Long, String> companyUserNameMap = buildCompanyUserNameMap(list);
+            Map<Long, String> sysUserNameMap = buildSysUserNameMap(list);
+            Map<Long, String> doctorNameMap = buildDoctorNameMap(list);
+            list.forEach(data -> processCallRecord(data, companyNameMap, companyUserNameMap, sysUserNameMap, doctorNameMap));
+        }
+        return list;
     }
 
     /**
@@ -169,396 +238,205 @@ public class AiSipCallOutboundCdrServiceImpl extends ServiceImpl<AiSipCallOutbou
     }
 
     @Override
-    public AjaxResult getCustCommunicationInfo(String phoneNum, Integer callType, String uuid) {
-        String paramStr = "?phoneNum=" + phoneNum + "&callType=" + callType + "&uuid=" + uuid;
-        String result = RemoteCommon.sendGet(RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.GET_CUST_COMMUNICATION_INFO_API + paramStr);
-        if (StringUtils.isNotBlank(result)) {
-            JSONObject jsonObject = JSONObject.parseObject(result);
-            if (jsonObject.getInteger("code") == 0) {
-                return JSONObject.parseObject(result, AjaxResult.class);
-            } else {
-                log.error("取手动外呼客户沟通信息失败:{}", jsonObject.getString("msg"));
-            }
-        } else {
-            log.error("取手动外呼客户沟通信息失败:{}", "接口返回为空");
-        }
-        return AjaxResult.error();
+    public AjaxResult getCustCommunicationInfo(String phoneNum, Integer callType, String uuid, String dialMode) {
+        String realPhone = "encrypted".equals(dialMode) ? PhoneUtil.decryptPhone(phoneNum) : phoneNum;
+        String url = RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.GET_CUST_COMMUNICATION_INFO_API
+                + "?phoneNum=" + RemoteApiHelper.urlEncode(realPhone)
+                + "&callType=" + (callType == null ? "" : callType)
+                + "&uuid=" + RemoteApiHelper.urlEncode(uuid);
+        return RemoteApiHelper.toAjaxResult(() -> RemoteCommon.sendGet(url), "获取手动外呼客户沟通信息");
     }
 
     @Override
     public AjaxResult addCustcallrecord(CcCustInfo ccCustInfo) {
-        String result = RemoteCommon.sendPost(RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.ADD_CUSTCALL_RECORD_API,JSONObject.toJSONString(ccCustInfo));
-        if (StringUtils.isNotBlank(result)) {
-            JSONObject jsonObject = JSONObject.parseObject(result);
-            if (jsonObject.getInteger("code") == 0) {
-                return JSONObject.parseObject(result, AjaxResult.class);
-            } else {
-                log.error("取手动外呼客户沟通信息失败:{}", jsonObject.getString("msg"));
-            }
-        } else {
-            log.error("取手动外呼客户沟通信息失败:{}", "接口返回为空");
+        if (ccCustInfo == null) {
+            return AjaxResult.error("新增手动外呼客户沟通信息失败:参数不能为空");
         }
-        return AjaxResult.error();
+        String body = JSONObject.toJSONString(ccCustInfo);
+        return RemoteApiHelper.toAjaxResult(
+                () -> RemoteCommon.sendPost(RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.ADD_CUSTCALL_RECORD_API, body),
+                "新增手动外呼客户沟通信息"
+        );
     }
 
-    private final AtomicBoolean isRunning = new AtomicBoolean(false);
-    @Override
-    @Async
-    public CompletableFuture<String> scheduledGetCallRecord() {
-        if (!isRunning.compareAndSet(false, true)) {
-            log.error("scheduledGetCallRecord 任务正在执行中,请稍后再试");
-            return CompletableFuture.completedFuture("任务正在执行中,请稍后再试");
-        }
 
-        try {
-            log.info("开始执行 scheduledGetCallRecord 异步任务");
-            String todayStartStr = DateUtils.parseDateToStr(DateUtils.YYYY_MM_DD, new Date()) + " 00:00:00";
-            String now = DateUtils.parseDateToStr(DateUtils.YYYY_MM_DD_HH_MM_SS, new Date());
-            long startTime = System.currentTimeMillis();
-
-            // 构建查询参数
-            List<ApiCallRecordQueryParams> paramsList = buildDayQueryParams(todayStartStr,now);
-
-            // 获取远程数据
-            List<AiSipCallOutboundCdr> remoteList = fetchAllRemoteCallRecords(paramsList);
-            if (remoteList.isEmpty()) {
-                log.info("scheduledGetCallRecord 异步任务完成,耗时:{}ms, 结果:当天无最新数据", System.currentTimeMillis() - startTime);
-                return CompletableFuture.completedFuture("当天无最新数据");
-            }
 
-            // 获取本地当天所有外呼记录
-            List<AiSipCallOutboundCdr> localList = baseMapper.selectCurrentDayCallRecords(DateUtils.toStartTime(), System.currentTimeMillis());
-
-            // 筛选并处理新增和更新数据
-            String result = processAndSaveData(remoteList, localList);
-            log.info("scheduledGetCallRecord 异步任务完成,耗时:{}ms, 结果:{}", System.currentTimeMillis() - startTime, result);
-            return CompletableFuture.completedFuture(result);
-        } catch (Exception e) {
-            log.error("scheduledGetCallRecord 异步任务执行失败", e);
-            return CompletableFuture.completedFuture("任务执行失败:" + e.getMessage());
-        } finally {
-            isRunning.set(false);
-        }
-    }
 
     /**
-     * 构建当天查询参数 - 使用当天 00:00:00 到当前时间
-     * @return 查询参数对象列表(支持多时段查询)
+     * 构建公司名称映射
      */
-    private List<ApiCallRecordQueryParams> buildDayQueryParams(String todayStartStr,String now) {
-
-        List<ApiCallRecordQueryParams> paramsList = new ArrayList<>();
-
-        // 如果时间跨度超过 12 小时,分段查询避免遗漏
-        Date today = DateUtils.parseDate(todayStartStr);
-        long hoursDiff = (System.currentTimeMillis() - today.getTime()) / (1000 * 60 * 60);
-
-        if (hoursDiff > 12) {
-            // 分两段查询:00:00-12:00 和 12:00-当前时间
-            ApiCallRecordQueryParams params1 = createSingleParam(todayStartStr, todayStartStr.substring(0, 10) + " 12:00:00");
-            ApiCallRecordQueryParams params2 = createSingleParam(todayStartStr.substring(0, 10) + " 12:00:00", now);
-            paramsList.add(params1);
-            paramsList.add(params2);
-        } else {
-            // 单段查询:00:00-当前时间
-            paramsList.add(createSingleParam(todayStartStr, now));
+    private Map<Long, String> buildCompanyNameMap(List<AiSipCallOutboundCdr> list) {
+        List<Long> companyIds = list.stream()
+                .map(AiSipCallOutboundCdr::getCompanyId)
+                .filter(Objects::nonNull)
+                .distinct()
+                .collect(Collectors.toList());
+
+        if (companyIds.isEmpty()) {
+            return Collections.emptyMap();
         }
 
-        return paramsList;
+        List<OptionsVO> companyOptions = Optional.ofNullable(companyMapper.selectByIds(companyIds))
+                .orElse(Collections.emptyList());
+        return companyOptions.stream()
+                .filter(Objects::nonNull)
+                .filter(opt -> opt.getDictValue() != null)
+                .collect(Collectors.toMap(OptionsVO::getDictValue, OptionsVO::getDictLabel, (v1, v2) -> v1));
     }
 
     /**
-     * 创建单个查询参数对象
+     * 构建销售用户名称映射(sourceType=0)
      */
-    private ApiCallRecordQueryParams createSingleParam(String startTime, String endTime) {
-        ApiCallRecordQueryParams params = new ApiCallRecordQueryParams();
-        params.setPageNum(1);
-        params.setPageSize(1000); // 减小单次请求量,提升响应速度
-        params.setCallType("03");
-        params.setCalloutTimeStart(startTime);
-        params.setCalloutTimeEnd(endTime);
-        return params;
+    private Map<Long, String> buildCompanyUserNameMap(List<AiSipCallOutboundCdr> list) {
+        Set<Long> companyUserIds = list.stream()
+                .filter(data -> "0".equals(data.getSourceType()))
+                .map(AiSipCallOutboundCdr::getCompanyUserId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+        if (companyUserIds.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        List<CompanyUser> users = Optional.ofNullable(companyUserMapper.selectCompanyUserByIds(companyUserIds))
+                .orElse(Collections.emptyList());
+        return users.stream()
+                .filter(Objects::nonNull)
+                .filter(user -> user.getUserId() != null)
+                .collect(Collectors.toMap(CompanyUser::getUserId,
+                        user -> StringUtils.defaultIfBlank(user.getUserName(), ""),
+                        (v1, v2) -> v1));
     }
 
     /**
-     * 分页轮询获取所有远程通话记录 (带去重和失败重试)
-     * @param paramsList 查询参数列表
-     * @return 通话记录列表 (已去重)
+     * 构建总后台用户名称映射(sourceType=1)
      */
-    private List<AiSipCallOutboundCdr> fetchAllRemoteCallRecords(List<ApiCallRecordQueryParams> paramsList) {
-        List<AiSipCallOutboundCdr> allRecords = new ArrayList<>();
-        for (ApiCallRecordQueryParams params : paramsList) {
-            int currentPage = 1;
-            boolean hasMore = true;
-
-            while (hasMore) {
-                params.setPageNum(currentPage);
-                List<AiSipCallOutboundCdr> pageData = fetchSinglePageRecords(params);
-
-                // 失败时直接结束
-                if (pageData == null) {
-                    log.error("sip手动外呼同步电话 分页查询第{}页失败,已丢弃该页数据,停止查询", currentPage);
-                    break;
-                }
-
-                if (pageData.isEmpty()) {
-                    log.error("sip手动外呼同步电话 第{}页无数据,查询结束", currentPage);
-                    hasMore = false;
-                } else {
-                    allRecords.addAll(pageData);
-                    log.error("sip手动外呼同步电话 第{}页数据:{},累计总数:{}", currentPage, pageData.size(), allRecords.size());
-
-                    // 如果返回数据少于页大小,说明已是最后一页
-                    if (pageData.size() < params.getPageSize()) {
-                        hasMore = false;
-                    }
-                    currentPage++;
-                }
-
-                // 安全限制:最多拉取 50 页
-                if (currentPage > 50) {
-                    log.error("sip手动外呼同步电话 已达到最大页数限制 50 页,停止查询。已获取数据量:{}", allRecords.size());
-                    hasMore = false;
-                }
-            }
+    private Map<Long, String> buildSysUserNameMap(List<AiSipCallOutboundCdr> list) {
+        long[] sysUserIds = list.stream()
+                .filter(data -> "1".equals(data.getSourceType()))
+                .map(AiSipCallOutboundCdr::getSysUserId)
+                .filter(Objects::nonNull)
+                .mapToLong(Long::longValue)
+                .distinct()
+                .toArray();
+        if (sysUserIds.length == 0) {
+            return Collections.emptyMap();
         }
-
-        log.error("sip手动外呼同步电话 远程数据获取完成,总计:{} 条", allRecords.size());
-        return allRecords.isEmpty() ? Collections.emptyList() : allRecords;
+        List<SysUser> users = Optional.ofNullable(sysUserMapper.selectUserByIds(sysUserIds))
+                .orElse(Collections.emptyList());
+        return users.stream()
+                .filter(Objects::nonNull)
+                .filter(user -> user.getUserId() != null)
+                .collect(Collectors.toMap(SysUser::getUserId,
+                        user -> StringUtils.defaultIfBlank(user.getNickName(),
+                                StringUtils.defaultString(user.getUserName())),
+                        (v1, v2) -> v1));
     }
 
     /**
-     * 获取单页数据
-     * @param params 查询参数(需预先设置页码)
-     * @return 单页通话记录列表
+     * 构建医生名称映射(sourceType=2)
      */
-    private List<AiSipCallOutboundCdr> fetchSinglePageRecords(ApiCallRecordQueryParams params) {
-        try {
-            String result = RemoteCommon.sendPost(
-                    RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.CALL_RECORDS_API,
-                    JSONObject.toJSONString(params)
-            );
-
-            if (StringUtils.isBlank(result)) {
-                log.error("查询第{}页失败:接口返回为空", params.getPageNum());
-                return null;
-            }
-
-            JSONObject jsonObject = JSONObject.parseObject(result);
-            Integer code = jsonObject.getInteger("code");
-
-            if (code == null || code != 0) {
-                String errorMsg = jsonObject.getString("msg") != null
-                        ? jsonObject.getString("msg") : "未知错误";
-                log.error("查询第{}页失败:{}", params.getPageNum(), errorMsg);
-                return null;
-            }
-
-            Long total = jsonObject.getLong("total");
-            if (total == null || total <= 0) {
-                return Collections.emptyList();
-            }
-
-            String rows = jsonObject.getString("rows");
-            if (StringUtils.isBlank(rows)) {
-                return Collections.emptyList();
-            }
-
-            return JSONObject.parseArray(rows, AiSipCallOutboundCdr.class);
-        } catch (Exception e) {
-            log.error("查询第{}页异常", params.getPageNum(), e);
-            return null;
+    private Map<Long, String> buildDoctorNameMap(List<AiSipCallOutboundCdr> list) {
+        long[] doctorIds = list.stream()
+                .filter(data -> "2".equals(data.getSourceType()))
+                .map(AiSipCallOutboundCdr::getDoctorId)
+                .filter(Objects::nonNull)
+                .mapToLong(Long::longValue)
+                .distinct()
+                .toArray();
+        if (doctorIds.length == 0) {
+            return Collections.emptyMap();
         }
+        List<FsDoctor> doctors = Optional.ofNullable(fsDoctorMapper.selectFsDoctorListByDoctorIds(doctorIds))
+                .orElse(Collections.emptyList());
+        return doctors.stream()
+                .filter(Objects::nonNull)
+                .filter(doctor -> doctor.getDoctorId() != null)
+                .collect(Collectors.toMap(FsDoctor::getDoctorId,
+                        doctor -> StringUtils.defaultIfBlank(doctor.getDoctorName(), ""),
+                        (v1, v2) -> v1));
     }
 
     /**
-     * 处理并保存数据 (新增和更新)
-     * @param remoteList 远程数据列表
-     * @param localList 本地数据列表
-     * @return 处理结果信息
+     * 处理单条通话记录
      */
-    private String processAndSaveData(List<AiSipCallOutboundCdr> remoteList, List<AiSipCallOutboundCdr> localList) {
-        if (remoteList == null || remoteList.isEmpty()) {
-            return "远程数据列表为空,无需处理";
+    private void processCallRecord(AiSipCallOutboundCdr data,
+                                   Map<Long, String> companyNameMap,
+                                   Map<Long, String> companyUserNameMap,
+                                   Map<Long, String> sysUserNameMap,
+                                   Map<Long, String> doctorNameMap) {
+        if (data.getCompanyId() != null) {
+            data.setCompanyName(companyNameMap.getOrDefault(data.getCompanyId(), ""));
         }
-
-        if (localList == null || localList.isEmpty()) {
-            log.warn("本地数据列表为空,所有远程数据都将作为新增处理");
-            return processDataWithStats(remoteList, "新增");
-        }
-
-        // 构建本地数据的唯一标识映射
-        Set<String> localIdSet = localList.stream()
-                .map(AiSipCallOutboundCdr::getId)
-                .collect(Collectors.toSet());
-
-        // 分类数据:新增和更新
-        List<AiSipCallOutboundCdr> insertList = new ArrayList<>();
-        List<AiSipCallOutboundCdr> updateList = new ArrayList<>();
-
-        for (AiSipCallOutboundCdr remote : remoteList) {
-            if (StringUtils.isBlank(remote.getId())) {
-                continue;
+        String sourceType = data.getSourceType();
+        if ("0".equals(sourceType)) {
+            if (data.getCompanyUserId() != null) {
+                data.setCompanyUserName(companyUserNameMap.getOrDefault(data.getCompanyUserId(),
+                        StringUtils.defaultString(data.getCompanyUserName())));
             }
-            if (localIdSet.contains(remote.getId())) {
-                updateList.add(remote);
-            } else {
-                insertList.add(remote);
+        } else if ("1".equals(sourceType)) {
+            if (data.getSysUserId() != null) {
+                data.setSysUserName(sysUserNameMap.getOrDefault(data.getSysUserId(),
+                        StringUtils.defaultString(data.getSysUserName())));
             }
-        }
-
-        log.info("数据筛选完成 - 预计新增:{},预计更新:{}", insertList.size(), updateList.size());
-
-        StringBuilder resultMsg = new StringBuilder();
-        if (!insertList.isEmpty()) {
-            resultMsg.append(processDataWithStats(insertList, "新增"));
-        }
-
-        if (!updateList.isEmpty()) {
-            if (resultMsg.length() > 0) {
-                resultMsg.append(",");
+        } else if ("2".equals(sourceType)) {
+            if (data.getDoctorId() != null) {
+                data.setDoctorName(doctorNameMap.getOrDefault(data.getDoctorId(), ""));
             }
-            resultMsg.append(processDataWithStats(updateList, "更新"));
         }
-
-        return resultMsg.toString();
+        // 格式化时间字段
+        formatTimestampField(data.getStartTime(), data::setStartTimeStr);
+        formatTimestampField(data.getAnsweredTime(), data::setAnsweredTimeStr);
+        formatTimestampField(data.getEndTime(), data::setEndTimeStr);
+        long timeLenValid = Optional.ofNullable(data.getTimeLenValid()).orElse(0L);
+        long timeLen = Optional.ofNullable(data.getTimeLen()).orElse(0L);
+        data.setTimeLenValidStr(DateUtils.formatTimeLength(timeLenValid / 1000));
+        data.setTimeLenSec(DateUtils.formatTimeLength(timeLen / 1000));
+        // 处理录音文件路径
+        formatWavfilePath(data);
     }
-
     /**
-     * 处理批量数据并统计成功失败信息
-     * @param dataList 数据列表
-     * @param operationType 操作类型:"新增" 或 "更新"
-     * @return 处理结果信息
+     * 格式化时间戳字段
      */
-    private String processDataWithStats(List<AiSipCallOutboundCdr> dataList,String operationType) {
-        log.info("开始处理{}Ai 外呼记录数据,数量:{}", operationType, dataList.size());
-        if (dataList.isEmpty()) {
-            return "无有效数据,无需处理";
-        }
-
-        int batchSize = 500;
-        int totalSize = dataList.size();
-        int batchCount = (totalSize + batchSize - 1) / batchSize;
-        int successCount = 0;
-        int failCount = 0;
-        List<int[]> failedBatchRanges = new ArrayList<>();
-
-        for (int i = 0; i < batchCount; i++) {
-            int fromIndex = i * batchSize;
-            int toIndex = Math.min(fromIndex + batchSize, totalSize);
-            List<AiSipCallOutboundCdr> batchList = dataList.subList(fromIndex, toIndex);
-
-            try {
-                if ("新增".equals(operationType)) {
-                    this.saveBatch(batchList);
-                } else if ("更新".equals(operationType)) {
-                    this.updateBatchById(batchList);
-                }
-                successCount += batchList.size();
-                log.debug("第{}/{}批{}成功,本批数量:{}", i + 1, batchCount, operationType, batchList.size());
-            } catch (Exception e) {
-                failCount += batchList.size();
-                int[] range = {fromIndex, toIndex - 1};
-                failedBatchRanges.add(range);
-                log.error("第{}批数据{}失败,本批数量:{},起始位:{},结束位:{}",
-                        i + 1, operationType, batchList.size(), fromIndex, toIndex - 1, e);
-            }
+    private void formatTimestampField(Long timestamp, java.util.function.Consumer<String> setter) {
+        if (timestamp != null && timestamp != 0) {
+            setter.accept(DateUtils.parseDateToStr("yyyy-MM-dd HH:mm:ss", new Date(timestamp)));
         }
-
-        StringBuilder result = new StringBuilder();
-        result.append(operationType).append("数据处理完成,总计:").append(totalSize).append("条");
-        result.append(",成功:").append(successCount).append("条");
-
-        if (failCount > 0) {
-            result.append(",失败:").append(failCount).append("条");
-            result.append(",失败数据位置:");
-            for (int i = 0; i < failedBatchRanges.size(); i++) {
-                int[] range = failedBatchRanges.get(i);
-                if (i > 0) result.append(";");
-                result.append("[").append(range[0]).append("-").append(range[1]).append("]");
-            }
-        }
-
-        return result.toString();
     }
 
-    @Override
-    public int syncByUuid(ApiCallRecordByUuidQueryParams req) {
-        if (req == null || StringUtils.isBlank(req.getUuid())) {
-//            throw new ServiceException("uuid不能为空");
+    /**
+     * 格式化录音文件路径
+     */
+    private void formatWavfilePath(AiSipCallOutboundCdr data) {
+        if (StringUtils.isNotBlank(data.getRecordFilename())) {
+            String filename = data.getRecordFilename().startsWith("/") ? data.getRecordFilename().substring(1) : data.getRecordFilename();
+            data.setWavfile("/recordings/files?filename=" + filename);
         }
+    }
 
-        String callType = StringUtils.isBlank(req.getCallType()) ? "03" : req.getCallType();
-
-//        EasyCallCallPhoneVO callPhoneRes = easyCallMapper.getCallPhoneInfoByUuid(req.getUuid());
-        EasyCallOutBoundVO callPhoneRes = easyCallMapper.getOutBoundInfoByUuid(req.getUuid());
-        String callBackUuid = UUID.randomUUID().toString();
-        CompanyAiWorkflowExec record = currentExecutionMapper.selectByWorkflowInstanceId(req.getWorkflowInstanceId());
-        CompanyVoiceRoboticBusiness business = companyVoiceRoboticBusinessMapper.selectOne(new LambdaQueryWrapper<CompanyVoiceRoboticBusiness>()
-                .eq(CompanyVoiceRoboticBusiness::getId, record.getBusinessKey()));
-        try {
-            Map<String, Object> variablesMap;
-            if (record.getVariables() == null || record.getVariables().isEmpty()) {
-                variablesMap = new HashMap<>();
-            } else {
-                variablesMap = objectMapper.readValue(record.getVariables(), new TypeReference<Map<String, Object>>() {});
+    /**
+     * 加密电话号码
+     */
+    private void encryptPhoneNumberSaveDatabase(AiSipCallOutboundCdr data) {
+        if (StringUtils.isNotBlank(data.getCallee())) {
+            //先解密
+            try {
+                //直接存des的密文,后续定时任务去加密成项目需要使用的密文
+                data.setCallee(URLDecoder.decode(data.getCallee(), "UTF-8").replaceAll("=+$", ""));
+            } catch (Throwable e) {
+                log.error("同步手动外呼记录id:{}时处理callee编码失败:{}",data.getId(),e.getMessage());
+                data.setCallee("");
             }
-            variablesMap.put("callBackUuid", callBackUuid);
-            record.setVariables(objectMapper.writeValueAsString(variablesMap));
-
-        } catch (Exception e) {
-            log.error("反序列化上下文变量失败", e);
+        }else{
+            data.setCallee("");
         }
-
-
-        CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone = new CompanyVoiceRoboticCallLogCallphone();
-        companyVoiceRoboticCallLogCallphone.setCallbackUuid(callBackUuid);
-        companyVoiceRoboticCallLogCallphone.setRoboticId(req.getRoboticId());
-        if (ObjectUtil.isNotEmpty(business)) {
-            companyVoiceRoboticCallLogCallphone.setCallerId(business.getCalleeId());
-        }
-        companyVoiceRoboticCallLogCallphone.setRunTime(new Date(callPhoneRes.getStartTime()));
-        companyVoiceRoboticCallLogCallphone.setRunParam(null);
-        companyVoiceRoboticCallLogCallphone.setResult(null);
-        companyVoiceRoboticCallLogCallphone.setStatus(req.getStatus());
-        companyVoiceRoboticCallLogCallphone.setRecordPath(callPhoneRes.getRecordFilename());
-        companyVoiceRoboticCallLogCallphone.setContentList(callPhoneRes.getChatContent());
-        companyVoiceRoboticCallLogCallphone.setCallerNum(callPhoneRes.getCallee());
-        companyVoiceRoboticCallLogCallphone.setCalleeNum(callPhoneRes.getCaller());
-        companyVoiceRoboticCallLogCallphone.setUuid(req.getUuid());
-        companyVoiceRoboticCallLogCallphone.setCallCreateTime(callPhoneRes.getStartTime());
-        companyVoiceRoboticCallLogCallphone.setCallAnswerTime(callPhoneRes.getAnsweredTime());
-        companyVoiceRoboticCallLogCallphone.setIntention(req.getIntent());
-        companyVoiceRoboticCallLogCallphone.setCompanyId(req.getCompanyId());
-        companyVoiceRoboticCallLogCallphone.setCompanyUserId(req.getCompanyUserId());
-        companyVoiceRoboticCallLogCallphone.setCallTime(Long.valueOf(callPhoneRes.getTimeLen()));
-        companyVoiceRoboticCallLogCallphone.setCallType(Integer.valueOf(callType));
-
-        String json = configService.selectConfigByKey("cid.config");
-        CidConfigVO cidConfigVO = JSONUtil.toBean(json, CidConfigVO.class);
-        BigDecimal callCharge = cidConfigVO.getCallCharge();
-        //
-        if (null == callCharge) {
-            callCharge = DEFAULT_CALL_CHARGE;
+        if (StringUtils.isNotBlank(data.getCaller())) {
+            try {
+                data.setCaller(URLDecoder.decode(data.getCaller(), "UTF-8").replaceAll("=+$", ""));
+            } catch (Throwable e) {
+                log.error("同步手动外呼记录id:{}时处理caller编码失败:{}", data.getId(), e.getMessage());
+                data.setCaller("");
+            }
+        } else {
+            data.setCaller("");
         }
-        //向上取整分钟数
-        BigDecimal divide = new BigDecimal(companyVoiceRoboticCallLogCallphone.getCallTime()).divide(ONE_MINUTES_SECOND, 0, RoundingMode.CEILING);
-        BigDecimal multiply = divide.multiply(callCharge);
-        companyVoiceRoboticCallLogCallphone.setCost(multiply);
-
-
-        int i = companyVoiceRoboticCallLogCallphoneMapper.insertCompanyVoiceRoboticCallLogCallphone(companyVoiceRoboticCallLogCallphone);
-
-        Map<String, Object> param = new HashMap<>();
-        param.put("callBackUuid", callBackUuid);
-        param.put("callSource", "callBack");
-        companyWorkflowEngine.resumeFromBlockingNode(req.getWorkflowInstanceId(),record.getCurrentNodeKey(),param);
-
-        redisCache.deleteObject(EASYCALL_WORKFLOW_REDIS_KEY +  callBackUuid);
-
-
-        return i;
     }
-
-
 }

+ 371 - 289
fs-service/src/main/java/com/fs/aiSipCall/service/impl/AiSipCallPhoneServiceImpl.java

@@ -3,22 +3,39 @@ package com.fs.aiSipCall.service.impl;
 import com.alibaba.fastjson.JSONObject;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.fs.aiSipCall.RemoteCommon;
+import com.fs.aiSipCall.domain.AiSipCallGateway;
+import com.fs.aiSipCall.domain.AiSipCallOutboundCdr;
 import com.fs.aiSipCall.domain.AiSipCallPhone;
+import com.fs.aiSipCall.domain.AiSipCallTask;
 import com.fs.aiSipCall.dto.CallTaskStatModel;
 import com.fs.aiSipCall.dto.CommonPhoneModel;
 import com.fs.aiSipCall.mapper.AiSipCallPhoneMapper;
-import com.fs.aiSipCall.param.ApiCallRecordQueryParams;
+import com.fs.aiSipCall.mapper.AiSipCallTaskMapper;
+import com.fs.aiSipCall.param.StatisticalManagementParam;
+import com.fs.aiSipCall.service.IAiSipCallGatewayService;
+import com.fs.aiSipCall.service.IAiSipCallOutboundCdrService;
 import com.fs.aiSipCall.service.IAiSipCallPhoneService;
 import com.fs.aiSipCall.utils.DateUtils;
-import lombok.extern.slf4j.Slf4j;
+import com.fs.aiSipCall.utils.RemoteApiHelper;
+import com.fs.aiSipCall.vo.AiSipCallPhoneInSopVo;
+import com.fs.aiSipCall.vo.StatisticalManagementVo;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.exception.ServiceException;
+import com.fs.company.mapper.CompanyMapper;
+import com.fs.his.domain.FsUser;
+import com.fs.his.mapper.FsUserMapper;
+import com.fs.his.vo.OptionsVO;
+import com.fs.sop.params.QwSopLogsParam;
+import com.fs.sop.vo.QwSopLogsListCVO;
 import org.apache.commons.lang3.StringUtils;
-import org.springframework.scheduling.annotation.Async;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
 
 import java.util.*;
-import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.stream.Collectors;
+import java.util.stream.Stream;
 
 /**
  * aiSIP外呼通话记录Service业务层处理
@@ -26,10 +43,21 @@ import java.util.stream.Collectors;
  * @author fs
  * @date 2026-03-06
  */
-@Slf4j
 @Service
 public class AiSipCallPhoneServiceImpl extends ServiceImpl<AiSipCallPhoneMapper, AiSipCallPhone> implements IAiSipCallPhoneService {
 
+    @Autowired
+    private CompanyMapper companyMapper;
+    @Autowired
+    private AiSipCallTaskMapper callTaskMapper;
+    @Autowired
+    private FsUserMapper fsUserMapper;
+    @Autowired
+    private IAiSipCallOutboundCdrService aiSipCallOutboundCdrService;
+    @Autowired
+    private IAiSipCallGatewayService aiSipCallGatewayService;
+
+
     /**
      * 查询aiSIP外呼通话记录
      *
@@ -41,6 +69,160 @@ public class AiSipCallPhoneServiceImpl extends ServiceImpl<AiSipCallPhoneMapper,
         return baseMapper.selectAiSipCallPhoneById(id);
     }
 
+    @Override
+    public int logicalDeletionByBatchIds(Long[] batchIds) {
+        return baseMapper.logicalDeletionByBatchIds(batchIds);
+    }
+
+    @Override
+    public List<QwSopLogsListCVO> selectQwSipLogsListBySopId(QwSopLogsParam param) {
+        return baseMapper.selectQwSipLogsListBySopId(param);
+    }
+
+    @Override
+    public List<AiSipCallPhoneInSopVo> SopTaskList(AiSipCallPhone aiSipCallPhone) {
+        AiSipCallPhone query = Optional.ofNullable(aiSipCallPhone).orElseGet(AiSipCallPhone::new);
+        List<AiSipCallPhoneInSopVo> list = baseMapper.hisSopList(query);
+        if (CollectionUtils.isEmpty(list)) {
+            return list;
+        }
+        Map<Long, String> batchNameMap = buildBatchNameMap(null, list);
+        Map<Long, String> fsUserIdMap = "1".equals(query.getExecuteType())
+                ? buildFsUserIdMap(null, list)
+                : Collections.emptyMap();
+        list.forEach(data -> processHisSopCallRecord(data, batchNameMap, fsUserIdMap));
+        return list;
+    }
+
+    @Override
+    public AjaxResult callEndQueryByUuid(AiSipCallPhone request) {
+        if (request == null || StringUtils.isBlank(request.getUuid())) {
+            return AjaxResult.error("据UUID查询自动外呼对话详情失败:参数不能为空");
+        }
+        Integer sipType = request.getSipType();
+        boolean autoCall = sipType == null || sipType == 0;
+        boolean manualCall = Integer.valueOf(1).equals(sipType);
+
+        if (autoCall) {
+            AiSipCallPhone one = this.lambdaQuery()
+                    .select(AiSipCallPhone::getDialogue)
+                    .eq(AiSipCallPhone::getUuid, request.getUuid())
+                    .last(" LIMIT 1")
+                    .one();
+            if (one != null && StringUtils.isNotBlank(one.getDialogue())) {
+                return AjaxResult.success(one.getDialogue());
+            }
+        } else if (manualCall) {
+            AiSipCallOutboundCdr one = this.aiSipCallOutboundCdrService.lambdaQuery()
+                    .select(AiSipCallOutboundCdr::getChatContent)
+                    .eq(AiSipCallOutboundCdr::getUuid, request.getUuid())
+                    .last(" LIMIT 1")
+                    .one();
+            if (one != null && StringUtils.isNotBlank(one.getChatContent())) {
+                return AjaxResult.success(one.getChatContent());
+            }
+        }
+
+        JSONObject json;
+        try {
+            json = RemoteApiHelper.parseSuccess(
+                    RemoteCommon.sendPost(
+                            RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.QUERY_CCCALLPHONE_RECORDS_BYUUID_API,
+                            JSONObject.toJSONString(request)
+                    ),
+                    "据UUID查询自动外呼对话详情"
+            );
+        } catch (ServiceException e) {
+            return AjaxResult.error(e.getMessage());
+        }
+
+        String dialogue = extractDialogueContent(json);
+        // 仅在有有效内容时回写,避免用空值覆盖已有对话
+        if (StringUtils.isNotBlank(dialogue)) {
+            if (autoCall) {
+                this.lambdaUpdate()
+                        .set(AiSipCallPhone::getDialogue, dialogue)
+                        .eq(AiSipCallPhone::getUuid, request.getUuid())
+                        .update();
+            } else if (manualCall) {
+                this.aiSipCallOutboundCdrService.lambdaUpdate()
+                        .set(AiSipCallOutboundCdr::getChatContent, dialogue)
+                        .eq(AiSipCallOutboundCdr::getUuid, request.getUuid())
+                        .update();
+            }
+        }
+        return JSONObject.parseObject(json.toJSONString(), AjaxResult.class);
+    }
+
+    /**
+     * 提取对话内容:优先 msg,其次 data(字符串)
+     */
+    private String extractDialogueContent(JSONObject json) {
+        if (json == null) {
+            return null;
+        }
+        String msg = json.getString("msg");
+        if (StringUtils.isNotBlank(msg)) {
+            return msg;
+        }
+        Object data = json.get("data");
+        return data instanceof String ? (String) data : null;
+    }
+
+    @Override
+    public List<StatisticalManagementVo> statisticalManagement(StatisticalManagementParam param) {
+        List<StatisticalManagementVo> list = baseMapper.statisticalManagement(param);
+        if (!CollectionUtils.isEmpty(list)) {
+            List<Long> companyIds = list.stream()
+                    .map(StatisticalManagementVo::getCompanyId)
+                    .filter(Objects::nonNull)
+                    .distinct()
+                    .collect(Collectors.toList());
+
+            if (!companyIds.isEmpty()) {
+                List<OptionsVO> companyOptions = Optional.ofNullable(companyMapper.selectByIds(companyIds))
+                        .orElse(Collections.emptyList());
+                Map<Long, String> companyNameMap = companyOptions.stream()
+                        .filter(Objects::nonNull)
+                        .filter(opt -> opt.getDictValue() != null)
+                        .collect(Collectors.toMap(OptionsVO::getDictValue, OptionsVO::getDictLabel, (v1, v2) -> v1));
+                list.forEach(data -> {
+                    if (data.getCompanyId() != null) {
+                        data.setCompanyName(companyNameMap.getOrDefault(data.getCompanyId(), "未知"));
+                    }
+                });
+            }
+        }
+        return list;
+    }
+
+    @Override
+    public List<AiSipCallPhone> findWaitEncryptPhones(Long currentDateMillis, Integer batchSize) {
+        return this.baseMapper.findWaitEncryptPhones(currentDateMillis, batchSize);
+    }
+
+    @Override
+    public int updateEncryptPhone(List<AiSipCallPhone> target) {
+        return this.baseMapper.updateEncryptPhone(target);
+    }
+
+    private void processHisSopCallRecord(AiSipCallPhoneInSopVo data, Map<Long, String> batchNameMap, Map<Long, String> fsUserIdMap) {
+        if (data.getLocalBatchId() != null) {
+            data.setBatchName(batchNameMap.getOrDefault(data.getLocalBatchId(), ""));
+        }
+        if (data.getFsUserId() != null && !CollectionUtils.isEmpty(fsUserIdMap)) {
+            data.setFsUserNickName(fsUserIdMap.getOrDefault(data.getFsUserId(), ""));
+        }
+        data.setCallstatusName(AiSipCallPhone.getCallStatusName(data.getCallstatus(), data.getUuid()));
+        formatTimestampField(data.getCalloutTime(), data::setCalloutTimeStr);
+        formatTimestampField(data.getCreatetime(), data::setCreatetimeStr);
+    }
+
+    @Override
+    public List<Long> getTodayChangeUserIds() {
+        return baseMapper.getTodayChangeUserIds();
+    }
+
     /**
      * 查询aiSIP外呼通话记录列表
      *
@@ -49,33 +231,45 @@ public class AiSipCallPhoneServiceImpl extends ServiceImpl<AiSipCallPhoneMapper,
      */
     @Override
     public List<AiSipCallPhone> selectAiSipCallPhoneList(AiSipCallPhone aiSipCallPhone) {
+        AiSipCallPhone query = Optional.ofNullable(aiSipCallPhone).orElseGet(AiSipCallPhone::new);
+        fillTimeRangeQuery(query);
 
-        if (aiSipCallPhone.getTimeLenStart() != null) {
-            aiSipCallPhone.setTimeLenStart(aiSipCallPhone.getTimeLenStart() * 60 * 1000L);
-        }
-        if (aiSipCallPhone.getTimeLenEnd() != null) {
-            aiSipCallPhone.setTimeLenEnd(aiSipCallPhone.getTimeLenEnd() * 60 * 1000L);
-        }
-        if (StringUtils.isNotBlank(aiSipCallPhone.getCalloutTimeStart())) {
-            aiSipCallPhone.setCalloutTimeStartLong(DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", aiSipCallPhone.getCalloutTimeStart()).getTime());
-        }
-        if (StringUtils.isNotBlank(aiSipCallPhone.getCalloutTimeEnd())) {
-            aiSipCallPhone.setCalloutTimeEndLong(DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", aiSipCallPhone.getCalloutTimeEnd()).getTime());
+        List<AiSipCallPhone> list = baseMapper.selectAiSipCallPhoneList(query);
+        if (CollectionUtils.isEmpty(list)) {
+            return list;
         }
-        if (StringUtils.isNotBlank(aiSipCallPhone.getAnsweredTimeStart())) {
-            aiSipCallPhone.setAnsweredTimeStartLong(DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", aiSipCallPhone.getAnsweredTimeStart()).getTime());
+        Map<Long, String> companyNameMap = buildCompanyNameMap(list);
+        Map<Long, String> batchNameMap = buildBatchNameMap(list, null);
+        Map<Long, String> batchGatewayNameMap = buildBatchGatewayNameMap(list);
+        Map<Long, String> fsUserIdMap = buildFsUserIdMap(list, null);
+        list.forEach(data -> processCallRecord(data, companyNameMap, batchNameMap, batchGatewayNameMap, fsUserIdMap));
+        return list;
+    }
+
+    /**
+     * 填充时长/时间范围查询条件(分钟转毫秒、字符串转时间戳)
+     */
+    private void fillTimeRangeQuery(AiSipCallPhone query) {
+        if (query.getTimeLenStart() != null) {
+            query.setTimeLenStart(query.getTimeLenStart() * 60 * 1000L);
         }
-        if (StringUtils.isNotBlank(aiSipCallPhone.getAnsweredTimeEnd())) {
-            aiSipCallPhone.setAnsweredTimeEndLong(DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", aiSipCallPhone.getAnsweredTimeEnd()).getTime());
+        if (query.getTimeLenEnd() != null) {
+            query.setTimeLenEnd(query.getTimeLenEnd() * 60 * 1000L);
         }
+        setTimeLong(query.getCalloutTimeStart(), query::setCalloutTimeStartLong);
+        setTimeLong(query.getCalloutTimeEnd(), query::setCalloutTimeEndLong);
+        setTimeLong(query.getAnsweredTimeStart(), query::setAnsweredTimeStartLong);
+        setTimeLong(query.getAnsweredTimeEnd(), query::setAnsweredTimeEndLong);
+        setTimeLong(query.getCallEndTimeStart(), query::setCallEndTimeStartLong);
+        setTimeLong(query.getCallEndTimeEnd(), query::setCallEndTimeEndLong);
+        setTimeLong(query.getCreatetimeStart(), query::setCreatetimeStartLong);
+        setTimeLong(query.getCreatetimeEnd(), query::setCreatetimeEndLong);
+    }
 
-        if (StringUtils.isNotBlank(aiSipCallPhone.getCallEndTimeStart())) {
-            aiSipCallPhone.setCallEndTimeStartLong(DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", aiSipCallPhone.getCallEndTimeStart()).getTime());
+    private void setTimeLong(String timeStr, java.util.function.Consumer<Long> setter) {
+        if (StringUtils.isNotBlank(timeStr)) {
+            setter.accept(DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", timeStr).getTime());
         }
-        if (StringUtils.isNotBlank(aiSipCallPhone.getCallEndTimeEnd())) {
-            aiSipCallPhone.setCallEndTimeEndLong(DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", aiSipCallPhone.getCallEndTimeEnd()).getTime());
-        }
-        return baseMapper.selectAiSipCallPhoneList(aiSipCallPhone);
     }
 
     /**
@@ -123,18 +317,24 @@ public class AiSipCallPhoneServiceImpl extends ServiceImpl<AiSipCallPhoneMapper,
     }
 
     @Override
-    public CallTaskStatModel statByBatchId(Long batchId) {
-        CallTaskStatModel callTaskStatModel = baseMapper.statByBatchId(batchId);
-        if (null == callTaskStatModel.getPhoneCount()) {
-            callTaskStatModel.setPhoneCount(0);
-        }
-        if (null == callTaskStatModel.getCallCount()) {
-            callTaskStatModel.setCallCount(0);
+    public List<CallTaskStatModel> statByBatchIds(List<Long> batchIds) {
+        if (CollectionUtils.isEmpty(batchIds)) {
+            return Collections.emptyList();
         }
-        if (null == callTaskStatModel.getConnectCount()) {
-            callTaskStatModel.setConnectCount(0);
-        }
-        return callTaskStatModel;
+        List<CallTaskStatModel> callTaskStatModels = Optional.ofNullable(baseMapper.statByBatchIds(batchIds))
+                .orElse(Collections.emptyList());
+        callTaskStatModels.forEach(stat -> {
+            if (stat.getPhoneCount() == null) {
+                stat.setPhoneCount(0);
+            }
+            if (stat.getCallCount() == null) {
+                stat.setCallCount(0);
+            }
+            if (stat.getConnectCount() == null) {
+                stat.setConnectCount(0);
+            }
+        });
+        return callTaskStatModels;
     }
 
     @Override
@@ -142,287 +342,169 @@ public class AiSipCallPhoneServiceImpl extends ServiceImpl<AiSipCallPhoneMapper,
         return baseMapper.isDuplicateEntry(batchId, phoneList);
     }
 
-    private final AtomicBoolean isRunning = new AtomicBoolean(false);
-
     @Override
-    @Async
-    public CompletableFuture<String> scheduledGetCallRecord() {
-        if (!isRunning.compareAndSet(false, true)) {
-            log.warn("scheduledGetCallRecord 任务正在执行中,请稍后再试");
-            return CompletableFuture.completedFuture("任务正在执行中,请稍后再试");
+    public List<Long> selectDistinctFsUserIdsByLocalBatchId(Long localBatchId) {
+        if (localBatchId == null) {
+            return Collections.emptyList();
         }
+        List<Long> list = baseMapper.selectDistinctFsUserIdsByLocalBatchId(localBatchId);
+        return list == null ? Collections.emptyList() : list;
+    }
 
-        try {
-            log.info("开始执行 scheduledGetCallRecord 异步任务");
-            String todayStartStr = DateUtils.parseDateToStr(DateUtils.YYYY_MM_DD, new Date()) + " 00:00:00";
-            String now = DateUtils.parseDateToStr(DateUtils.YYYY_MM_DD_HH_MM_SS, new Date());
-            long startTime = System.currentTimeMillis();
-
-            // 构建查询参数
-            List<ApiCallRecordQueryParams> paramsList = buildDayQueryParams(todayStartStr,now);
-
-            // 获取远程数据
-            List<AiSipCallPhone> remoteList = fetchAllRemoteCallRecords(paramsList);
-            if (remoteList.isEmpty()) {
-                log.info("scheduledGetCallRecord 异步任务完成,耗时:{}ms, 结果:当天无最新数据", System.currentTimeMillis() - startTime);
-                return CompletableFuture.completedFuture("当天无最新数据");
-            }
-
-            // 获取本地当天所有外呼记录
-            List<AiSipCallPhone> localList = baseMapper.selectCurrentDayCallRecords(DateUtils.toStartTime(), System.currentTimeMillis());
-
-            // 筛选并处理新增和更新数据
-            String result = processAndSaveData(remoteList, localList);
-            log.info("scheduledGetCallRecord 异步任务完成,耗时:{}ms, 结果:{}", System.currentTimeMillis() - startTime, result);
-            return CompletableFuture.completedFuture(result);
-        } catch (Exception e) {
-            log.error("scheduledGetCallRecord 异步任务执行失败", e);
-            return CompletableFuture.completedFuture("任务执行失败:" + e.getMessage());
-        } finally {
-            isRunning.set(false);
-        }
+    public List<AiSipCallPhone> selectCurrentDayCallRecords(Long startMillis, Long endMillis) {
+        return baseMapper.selectCurrentDayCallRecords(startMillis, endMillis);
     }
-        
+
+
+
+
     /**
-     * 构建当天查询参数 - 使用当天 00:00:00 到当前时间
-     * @return 查询参数对象列表(支持多时段查询)
+     * 构建公司名称映射
      */
-    private List<ApiCallRecordQueryParams> buildDayQueryParams(String todayStartStr,String now) {
-        
-        List<ApiCallRecordQueryParams> paramsList = new ArrayList<>();
-        
-        // 如果时间跨度超过 12 小时,分段查询避免遗漏
-        Date today = DateUtils.parseDate(todayStartStr);
-        long hoursDiff = (System.currentTimeMillis() - today.getTime()) / (1000 * 60 * 60);
-        
-        if (hoursDiff > 12) {
-            // 分两段查询:00:00-12:00 和 12:00-当前时间
-            ApiCallRecordQueryParams params1 = createSingleParam(todayStartStr, todayStartStr.substring(0, 10) + " 12:00:00");
-            ApiCallRecordQueryParams params2 = createSingleParam(todayStartStr.substring(0, 10) + " 12:00:00", now);
-            paramsList.add(params1);
-            paramsList.add(params2);
-        } else {
-            // 单段查询:00:00-当前时间
-            paramsList.add(createSingleParam(todayStartStr, now));
+    private Map<Long, String> buildCompanyNameMap(List<AiSipCallPhone> list) {
+        List<Long> companyIds = list.stream()
+                .map(AiSipCallPhone::getCompanyId)
+                .filter(Objects::nonNull)
+                .distinct()
+                .collect(Collectors.toList());
+
+        if (companyIds.isEmpty()) {
+            return Collections.emptyMap();
         }
-        
-        return paramsList;
-    }
-    
-    /**
-     * 创建单个查询参数对象
-     */
-    private ApiCallRecordQueryParams createSingleParam(String startTime, String endTime) {
-        ApiCallRecordQueryParams params = new ApiCallRecordQueryParams();
-        params.setPageNum(1);
-        params.setPageSize(1000); // 减小单次请求量,提升响应速度
-        params.setCallType("02");
-        params.setCalloutTimeStart(startTime);
-        params.setCalloutTimeEnd(endTime);
-        return params;
+
+        List<OptionsVO> companyOptions = Optional.ofNullable(companyMapper.selectByIds(companyIds))
+                .orElse(Collections.emptyList());
+        return companyOptions.stream()
+                .filter(Objects::nonNull)
+                .filter(opt -> opt.getDictValue() != null)
+                .collect(Collectors.toMap(OptionsVO::getDictValue, OptionsVO::getDictLabel, (v1, v2) -> v1));
     }
-        
+
     /**
-     * 分页轮询获取所有远程通话记录 (带去重和失败重试)
-     * @param paramsList 查询参数列表
-     * @return 通话记录列表 (已去重)
+     * 构建批次网关名称映射(localBatchId -> gatewayName)
      */
-    private List<AiSipCallPhone> fetchAllRemoteCallRecords(List<ApiCallRecordQueryParams> paramsList) {
-        List<AiSipCallPhone> allRecords = new ArrayList<>();
-        for (ApiCallRecordQueryParams params : paramsList) {
-            int currentPage = 1;
-            boolean hasMore = true;
-
-            while (hasMore) {
-                params.setPageNum(currentPage);
-                List<AiSipCallPhone> pageData = fetchSinglePageRecords(params);
-
-                // 失败时直接结束
-                if (pageData == null) {
-                    log.error("sip自动外呼同步电话 分页查询第{}页失败,已丢弃该页数据,停止查询", currentPage);
-                    break;
-                }
-
-                if (pageData.isEmpty()) {
-                    log.error("sip自动外呼同步电话 第{}页无数据,查询结束", currentPage);
-                    hasMore = false;
-                } else {
-                    allRecords.addAll(pageData);
-                    log.error("sip自动外呼同步电话 第{}页数据:{},累计总数:{}", currentPage, pageData.size(), allRecords.size());
-
-                    // 如果返回数据少于页大小,说明已是最后一页
-                    if (pageData.size() < params.getPageSize()) {
-                        hasMore = false;
-                    }
-                    currentPage++;
-                }
-
-                // 安全限制:最多拉取 50 页
-                if (currentPage > 50) {
-                    log.error("sip自动外呼同步电话 已达到最大页数限制 50 页,停止查询。已获取数据量:{}", allRecords.size());
-                    hasMore = false;
-                }
-            }
+    private Map<Long, String> buildBatchGatewayNameMap(List<AiSipCallPhone> list) {
+        List<Long> localBatchIds = list.stream()
+                .map(AiSipCallPhone::getLocalBatchId)
+                .filter(Objects::nonNull)
+                .distinct()
+                .collect(Collectors.toList());
+        if (localBatchIds.isEmpty()) {
+            return Collections.emptyMap();
         }
 
-        log.error("sip自动外呼同步电话 远程数据获取完成,总计:{} 条", allRecords.size());
-        return allRecords.isEmpty() ? Collections.emptyList() : allRecords;
-    }
-    
-    /**
-     * 获取单页数据
-     * @param params 查询参数(需预先设置页码)
-     * @return 单页通话记录列表
-     */
-    private List<AiSipCallPhone> fetchSinglePageRecords(ApiCallRecordQueryParams params) {
-        try {
-            String result = RemoteCommon.sendPost(
-                RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.CALL_RECORDS_API, 
-                JSONObject.toJSONString(params)
-            );
-                
-            if (StringUtils.isBlank(result)) {
-                log.error("查询第{}页失败:接口返回为空", params.getPageNum());
-                return null;
-            }
-                
-            JSONObject jsonObject = JSONObject.parseObject(result);
-            Integer code = jsonObject.getInteger("code");
-                
-            if (code == null || code != 0) {
-                String errorMsg = jsonObject.getString("msg") != null
-                    ? jsonObject.getString("msg") : "未知错误";
-                log.error("查询第{}页失败:{}", params.getPageNum(), errorMsg);
-                return null;
-            }
-                
-            Long total = jsonObject.getLong("total");
-            if (total == null || total <= 0) {
-                return Collections.emptyList();
-            }
+        List<AiSipCallTask> tasks = callTaskMapper.selectByIds(localBatchIds);
+        if (CollectionUtils.isEmpty(tasks)) {
+            return Collections.emptyMap();
+        }
 
-            String rows = jsonObject.getString("rows");
-            if (StringUtils.isBlank(rows)) {
-                return Collections.emptyList();
-            }
+        Map<Long, String> gatewayNameById = loadRemoteGatewayNameMap();
+        return tasks.stream()
+                .filter(task -> task.getBatchId() != null && task.getGatewayId() != null)
+                .collect(Collectors.toMap(
+                        AiSipCallTask::getBatchId,
+                        task -> gatewayNameById.getOrDefault(task.getGatewayId(), ""),
+                        (v1, v2) -> v1
+                ));
+    }
 
-            return JSONObject.parseArray(rows, AiSipCallPhone.class);
-        } catch (Exception e) {
-            log.error("查询第{}页异常", params.getPageNum(), e);
-            return null;
+    private Map<Long, String> loadRemoteGatewayNameMap() {
+        AiSipCallGateway query = new AiSipCallGateway();
+        query.setPageNum(1);
+        query.setPageSize(100);
+        query.setIsAICall(Boolean.TRUE);
+        Map<String, Object> params = new HashMap<>(1);
+        params.put("purposes", Arrays.asList(2, 3));
+        query.setParams(params);
+
+        TableDataInfo tableDataInfo = aiSipCallGatewayService.remoteList(query);
+        if (tableDataInfo == null || !RemoteApiHelper.isSuccess(tableDataInfo.getCode())
+                || CollectionUtils.isEmpty(tableDataInfo.getRows())) {
+            return Collections.emptyMap();
         }
+
+        return tableDataInfo.getRows().stream()
+                .map(row -> JSONObject.parseObject(JSONObject.toJSONString(row), AiSipCallGateway.class))
+                .filter(Objects::nonNull)
+                .filter(gateway -> gateway.getId() != null && StringUtils.isNotBlank(gateway.getGwName()))
+                .collect(Collectors.toMap(AiSipCallGateway::getId, AiSipCallGateway::getGwName, (v1, v2) -> v1));
     }
 
-    /**
-     * 处理并保存数据 (新增和更新)
-     * @param remoteList 远程数据列表
-     * @param localList 本地数据列表
-     * @return 处理结果信息
-     */
-    private String processAndSaveData(List<AiSipCallPhone> remoteList, List<AiSipCallPhone> localList) {
-        if (remoteList == null || remoteList.isEmpty()) {
-            return "远程数据列表为空,无需处理";
+    private Map<Long, String> buildBatchNameMap(List<AiSipCallPhone> list, List<AiSipCallPhoneInSopVo> sopVos) {
+        List<Long> localBatchIds = extractIds(list, AiSipCallPhone::getLocalBatchId, sopVos, AiSipCallPhoneInSopVo::getLocalBatchId);
+        if (localBatchIds.isEmpty()) {
+            return Collections.emptyMap();
         }
-    
-        if (localList == null || localList.isEmpty()) {
-            log.warn("本地数据列表为空,所有远程数据都将作为新增处理");
-            return processDataWithStats(remoteList, "新增");
+        List<AiSipCallTask> tasks = callTaskMapper.selectByIds(localBatchIds);
+        if (CollectionUtils.isEmpty(tasks)) {
+            return Collections.emptyMap();
         }
+        return tasks.stream()
+                .filter(task -> task.getBatchId() != null)
+                .collect(Collectors.toMap(AiSipCallTask::getBatchId, AiSipCallTask::getBatchName, (v1, v2) -> v1));
+    }
 
-        // 构建本地数据的唯一标识映射
-        Set<String> localIdSet = localList.stream()
-                .map(AiSipCallPhone::getId)
-                .collect(Collectors.toSet());
-            
-        // 分类数据:新增和更新
-        List<AiSipCallPhone> insertList = new ArrayList<>();
-        List<AiSipCallPhone> updateList = new ArrayList<>();
-            
-        for (AiSipCallPhone remote : remoteList) {
-            if (StringUtils.isBlank(remote.getId())) {
-                continue;
-            }
-            if (localIdSet.contains(remote.getId())) {
-                updateList.add(remote);
-            } else {
-                insertList.add(remote);
-            }
+    private Map<Long, String> buildFsUserIdMap(List<AiSipCallPhone> list, List<AiSipCallPhoneInSopVo> sopVos) {
+        List<Long> fsUserIds = extractIds(list, AiSipCallPhone::getFsUserId, sopVos, AiSipCallPhoneInSopVo::getFsUserId);
+        if (fsUserIds.isEmpty()) {
+            return Collections.emptyMap();
         }
+        List<FsUser> users = fsUserMapper.findUsersByIds(fsUserIds);
+        if (CollectionUtils.isEmpty(users)) {
+            return Collections.emptyMap();
+        }
+        return users.stream()
+                .filter(user -> user.getUserId() != null)
+                .collect(Collectors.toMap(FsUser::getUserId, FsUser::getNickName, (v1, v2) -> v1));
+    }
+
+    private <A, B> List<Long> extractIds(List<A> listA, java.util.function.Function<A, Long> mapperA,
+                                         List<B> listB, java.util.function.Function<B, Long> mapperB) {
+        Stream<Long> stream = CollectionUtils.isEmpty(listA)
+                ? Optional.ofNullable(listB).orElse(Collections.emptyList()).stream().map(mapperB)
+                : listA.stream().map(mapperA);
+        return stream.filter(Objects::nonNull).distinct().collect(Collectors.toList());
+    }
 
-        log.info("数据筛选完成 - 预计新增:{},预计更新:{}", insertList.size(), updateList.size());
-    
-        StringBuilder resultMsg = new StringBuilder();
-        if (!insertList.isEmpty()) {
-            resultMsg.append(processDataWithStats(insertList, "新增"));
+    private void processCallRecord(AiSipCallPhone data, Map<Long, String> companyNameMap, Map<Long, String> batchNameMap,
+                                   Map<Long, String> batchGatewayNameMap, Map<Long, String> fsUserIdMap) {
+        if (data.getCompanyId() != null) {
+            data.setCompanyName(companyNameMap.getOrDefault(data.getCompanyId(), ""));
         }
-            
-        if (!updateList.isEmpty()) {
-            if (resultMsg.length() > 0) {
-                resultMsg.append(",");
-            }
-            resultMsg.append(processDataWithStats(updateList, "更新"));
+        if (data.getLocalBatchId() != null) {
+            data.setBatchName(batchNameMap.getOrDefault(data.getLocalBatchId(), ""));
+            data.setGatewayName(batchGatewayNameMap.getOrDefault(data.getLocalBatchId(), ""));
+        }
+        if (data.getFsUserId() != null) {
+            data.setFsUserNickName(fsUserIdMap.getOrDefault(data.getFsUserId(), ""));
         }
-            
-        return resultMsg.toString();
+        data.setCallstatusName(AiSipCallPhone.getCallStatusName(data.getCallstatus(), data.getUuid()));
+        formatTimestampField(data.getCalloutTime(), data::setCalloutTimeStr);
+        formatTimestampField(data.getConnectedTime(), data::setAnsweredTimeStr);
+        formatTimestampField(data.getCallEndTime(), data::setCallEndTimeStr);
+        formatTimestampField(data.getCreatetime(), data::setCreatetimeStr);
+        long timeLen = Optional.ofNullable(data.getTimeLen()).orElse(0L);
+        data.setTimeLenSec(DateUtils.formatTimeLength(timeLen / 1000));
+        formatWavfilePath(data);
     }
 
     /**
-     * 处理批量数据并统计成功失败信息
-     * @param dataList 数据列表
-     * @param operationType 操作类型:"新增" 或 "更新"
-     * @return 处理结果信息
+     * 格式化时间戳字段
      */
-    private String processDataWithStats(List<AiSipCallPhone> dataList,String operationType) {
-        log.info("开始处理{}Ai 外呼记录数据,数量:{}", operationType, dataList.size());
-        if (dataList.isEmpty()) {
-            return "无有效数据,无需处理";
-        }
-    
-        int batchSize = 500;
-        int totalSize = dataList.size();
-        int batchCount = (totalSize + batchSize - 1) / batchSize;
-        int successCount = 0;
-        int failCount = 0;
-        List<int[]> failedBatchRanges = new ArrayList<>();
-    
-        for (int i = 0; i < batchCount; i++) {
-            int fromIndex = i * batchSize;
-            int toIndex = Math.min(fromIndex + batchSize, totalSize);
-            List<AiSipCallPhone> batchList = dataList.subList(fromIndex, toIndex);
-    
-            try {
-                if ("新增".equals(operationType)) {
-                    this.saveBatch(batchList);
-                } else if ("更新".equals(operationType)) {
-                    this.updateBatchById(batchList);
-                }
-                successCount += batchList.size();
-                log.debug("第{}/{}批{}成功,本批数量:{}", i + 1, batchCount, operationType, batchList.size());
-            } catch (Exception e) {
-                failCount += batchList.size();
-                int[] range = {fromIndex, toIndex - 1};
-                failedBatchRanges.add(range);
-                log.error("第{}批数据{}失败,本批数量:{},起始位:{},结束位:{}", 
-                         i + 1, operationType, batchList.size(), fromIndex, toIndex - 1, e);
-            }
-        }
-    
-        StringBuilder result = new StringBuilder();
-        result.append(operationType).append("数据处理完成,总计:").append(totalSize).append("条");
-        result.append(",成功:").append(successCount).append("条");
-            
-        if (failCount > 0) {
-            result.append(",失败:").append(failCount).append("条");
-            result.append(",失败数据位置:");
-            for (int i = 0; i < failedBatchRanges.size(); i++) {
-                int[] range = failedBatchRanges.get(i);
-                if (i > 0) result.append(";");
-                result.append("[").append(range[0]).append("-").append(range[1]).append("]");
-            }
+    private void formatTimestampField(Long timestamp, java.util.function.Consumer<String> setter) {
+        if (timestamp != null && timestamp != 0) {
+            setter.accept(DateUtils.parseDateToStr("yyyy-MM-dd HH:mm:ss", new Date(timestamp)));
         }
-            
-        return result.toString();
     }
 
+    /**
+     * 格式化录音文件路径
+     */
+    private void formatWavfilePath(AiSipCallPhone data) {
+        if (StringUtils.isNotBlank(data.getWavfile())) {
+            String filename = data.getWavfile().startsWith("/") ? data.getWavfile().substring(1) : data.getWavfile();
+            data.setWavfile("/recordings/files?filename=" + filename);
+        }
+    }
 
 }

+ 920 - 170
fs-service/src/main/java/com/fs/aiSipCall/service/impl/AiSipCallTaskServiceImpl.java

@@ -1,43 +1,74 @@
 package com.fs.aiSipCall.service.impl;
 
 import com.alibaba.fastjson.JSONObject;
+import com.alibaba.fastjson.TypeReference;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.fs.aiSipCall.RemoteCommon;
+import com.fs.aiSipCall.domain.AiSipCallPhone;
 import com.fs.aiSipCall.domain.AiSipCallTask;
 import com.fs.aiSipCall.dto.CallTaskStatModel;
 import com.fs.aiSipCall.dto.CommonCallListModel;
 import com.fs.aiSipCall.dto.CommonPhoneModel;
 import com.fs.aiSipCall.mapper.AiSipCallTaskMapper;
+import com.fs.aiSipCall.service.IAiSipAutoCallRulesService;
 import com.fs.aiSipCall.service.IAiSipCallPhoneService;
 import com.fs.aiSipCall.service.IAiSipCallTaskService;
-import lombok.extern.slf4j.Slf4j;
+import com.fs.aiSipCall.utils.RemoteApiHelper;
+import com.fs.aiSipCall.utils.SipDESUtil;
+import com.fs.aiSipCall.utils.SipLogUtil;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.exception.ServiceException;
+import com.fs.his.domain.FsUser;
+import com.fs.his.service.IFsUserService;
+import com.fs.his.utils.PhoneUtil;
+import com.fs.sop.domain.SopUserLogsInfo;
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheBuilder;
 import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
 import org.springframework.beans.BeanUtils;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Lazy;
 import org.springframework.stereotype.Service;
 import org.springframework.util.CollectionUtils;
 
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.net.URLDecoder;
+import java.net.URLEncoder;
+import java.util.*;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
 
 /**
  * aiSIP外呼任务Service业务层处理
- * 
+ *
  * @author fs
  * @date 2026-03-06
  */
-@Slf4j
 @Service
 public class AiSipCallTaskServiceImpl extends ServiceImpl<AiSipCallTaskMapper, AiSipCallTask> implements IAiSipCallTaskService {
 
+    private static final Logger log = SipLogUtil.createSipLogger(AiSipCallTaskServiceImpl.class);
+
+    /** 命中自动外呼屏蔽时段时的提示 */
+    private static final String SHIELD_TIME_FAIL_REASON = "当前处于自动外呼屏蔽时段,无法导入外呼";
 
     @Autowired
     private IAiSipCallPhoneService aiSipCallPhoneService;
+    @Autowired
+    private IAiSipAutoCallRulesService aiSipAutoCallRulesService;
+    /** 使用手动线路项目前缀 */
+    @Value(value = "${sip.call.manualGatewayPrefix:weizhi}")
+    private String manualGatewayPrefix;
+    @Lazy
+    @Autowired
+    private IFsUserService fsUserService;
 
     /**
      * 查询aiSIP外呼任务
-     * 
+     *
      * @param batchId aiSIP外呼任务主键
      * @return aiSIP外呼任务
      */
@@ -49,230 +80,949 @@ public class AiSipCallTaskServiceImpl extends ServiceImpl<AiSipCallTaskMapper, A
 
     /**
      * 查询aiSIP外呼任务列表
-     * 
+     *
      * @param aiSipCallTask aiSIP外呼任务
      * @return aiSIP外呼任务
      */
     @Override
     public List<AiSipCallTask> selectAiSipCallTaskList(AiSipCallTask aiSipCallTask)
     {
-        return baseMapper.selectAiSipCallTaskList(aiSipCallTask);
+        List<AiSipCallTask> list = baseMapper.selectAiSipCallTaskList(aiSipCallTask);
+        if (CollectionUtils.isEmpty(list)) {
+            return list;
+        }
+        List<Long> remoteBatchIds = list.stream()
+                .map(AiSipCallTask::getRemoteBatchId)
+                .filter(Objects::nonNull)
+                .distinct()
+                .collect(Collectors.toList());
+        Map<Long, CallTaskStatModel> statModelMap = Collections.emptyMap();
+        if (!remoteBatchIds.isEmpty()) {
+            List<CallTaskStatModel> statModels = aiSipCallPhoneService.statByBatchIds(remoteBatchIds);
+            if (!CollectionUtils.isEmpty(statModels)) {
+                statModelMap = statModels.stream()
+                        .filter(stat -> stat.getBatchId() != null)
+                        .collect(Collectors.toMap(CallTaskStatModel::getBatchId, v -> v, (k1, k2) -> k1));
+            }
+        }
+        Map<Long, CallTaskStatModel> finalStatMap = statModelMap;
+        list.forEach(task -> fillTaskStat(task, finalStatMap.get(task.getRemoteBatchId())));
+        return list;
     }
 
+
     /**
-     * 新增aiSIP外呼任务
-     * 
-     * @param aiSipCallTask aiSIP外呼任务
-     * @return 结果
+     * 填充任务统计字段
      */
-    @Override
-    public int insertAiSipCallTask(AiSipCallTask aiSipCallTask)
-    {
-        //同步新增远程接口
-        String result = RemoteCommon.sendPost(RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.CREATE_TASK_API, JSONObject.toJSONString(aiSipCallTask));
-        if(StringUtils.isNotBlank(result)){
-            JSONObject jsonObject = JSONObject.parseObject(result);
-            if(jsonObject.getInteger("code") == 0){
-                String data = jsonObject.getString("data");
-                JSONObject jsonObjectAiSipCallTask = JSONObject.parseObject(data);
-                Long batchId = jsonObjectAiSipCallTask.getLong("batchId");
-                aiSipCallTask.setRemoteBatchId(batchId);
-                //特殊处理字段
-                processField(aiSipCallTask);
-                return baseMapper.insertAiSipCallTask(aiSipCallTask);
-            }else{
-                log.error("新增aiSIP外呼任务失败:{}", jsonObject.getString("msg"));
-            }
-        }else{
-            log.error("新增aiSIP外呼任务失败:{}", "接口返回为空");
+    private void fillTaskStat(AiSipCallTask task, CallTaskStatModel statModel) {
+        if (statModel == null) {
+            task.setPhoneCount(0);
+            task.setCallCount(0);
+            task.setNoCallCount(0);
+            task.setConnectCount(0);
+            task.setNoConnectCount(0);
+            task.setRealConnectRate(0.00);
+            return;
+        }
+        int phoneCount = Optional.ofNullable(statModel.getPhoneCount()).orElse(0);
+        int callCount = Optional.ofNullable(statModel.getCallCount()).orElse(0);
+        int connectCount = Optional.ofNullable(statModel.getConnectCount()).orElse(0);
+        task.setPhoneCount(phoneCount);
+        task.setCallCount(callCount);
+        task.setNoCallCount(Math.max(phoneCount - callCount, 0));
+        task.setConnectCount(connectCount);
+        task.setNoConnectCount(Math.max(phoneCount - connectCount, 0));
+        if (phoneCount > 0) {
+            task.setRealConnectRate(BigDecimal.valueOf(connectCount * 100.0 / phoneCount)
+                    .setScale(2, RoundingMode.HALF_UP).doubleValue());
+        } else {
+            task.setRealConnectRate(0.00);
         }
-        return 0;
     }
 
-    /**
-     * 修改aiSIP外呼任务
-     * 
-     * @param aiSipCallTask aiSIP外呼任务
-     * @return 结果
-     */
+
     @Override
-    public int updateAiSipCallTask(AiSipCallTask aiSipCallTask)
-    {
+    public int insertAiSipCallTask(AiSipCallTask aiSipCallTask) {
+        Objects.requireNonNull(aiSipCallTask, "aiSipCallTask");
+        AiSipCallTask remoteSipCallTask = new AiSipCallTask();
+        BeanUtils.copyProperties(aiSipCallTask, remoteSipCallTask);
+        remoteSipCallTask.setBatchName(manualGatewayPrefix + "_" + aiSipCallTask.getBatchName());
+
+        JSONObject resp = RemoteApiHelper.parseSuccess(
+                RemoteCommon.sendPost(RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.CREATE_TASK_API,
+                        JSONObject.toJSONString(remoteSipCallTask)),
+                "新增aiSIP外呼任务"
+        );
+        Long remoteBatchId = Optional.ofNullable(RemoteApiHelper.toObject(resp.get("data"), JSONObject.class))
+                .map(obj -> obj.getLong("batchId"))
+                .orElseThrow(() -> new ServiceException("新增aiSIP外呼任务失败:远程返回batchId为空"));
+        aiSipCallTask.setRemoteBatchId(remoteBatchId);
+        processField(aiSipCallTask);
+        return baseMapper.insertAiSipCallTask(aiSipCallTask);
+    }
+
+    @Override
+    public int updateAiSipCallTask(AiSipCallTask aiSipCallTask) {
+        Objects.requireNonNull(aiSipCallTask, "aiSipCallTask");
         AiSipCallTask remoteTask = new AiSipCallTask();
-        BeanUtils.copyProperties(aiSipCallTask,remoteTask);
+        BeanUtils.copyProperties(aiSipCallTask, remoteTask);
         remoteTask.setBatchId(aiSipCallTask.getRemoteBatchId());
-        //同步修改远程接口
-        String result = RemoteCommon.sendPost(RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.EDIT_TASK_API, JSONObject.toJSONString(remoteTask));
-        if(StringUtils.isNotBlank(result)){
-            JSONObject jsonObject = JSONObject.parseObject(result);
-            if(jsonObject.getInteger("code") == 0){
-                //特殊处理字段
-                processField(aiSipCallTask);
-                return baseMapper.updateAiSipCallTask(aiSipCallTask);
-            }else{
-                log.error("新增aiSIP外呼任务失败:{}", jsonObject.getString("msg"));
-            }
-        }else{
-            log.error("新增aiSIP外呼任务失败:{}", "接口返回为空");
+        if (StringUtils.isNotBlank(aiSipCallTask.getBatchName())
+                && !aiSipCallTask.getBatchName().startsWith(manualGatewayPrefix + "_")) {
+            remoteTask.setBatchName(manualGatewayPrefix + "_" + aiSipCallTask.getBatchName());
         }
-        return 0;
+
+        RemoteApiHelper.parseSuccess(
+                RemoteCommon.sendPost(RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.EDIT_TASK_API,
+                        JSONObject.toJSONString(remoteTask)),
+                "修改aiSIP外呼任务"
+        );
+        processField(aiSipCallTask);
+        return baseMapper.updateAiSipCallTask(aiSipCallTask);
     }
 
     /**
      * 处理字段
-     * @param aiSipCallTask 字段
      */
     private void processField(AiSipCallTask aiSipCallTask) {
-        if(aiSipCallTask.getAiTransferType().equals("extension")){
+        String transferType = StringUtils.defaultString(aiSipCallTask.getAiTransferType());
+        if ("extension".equals(transferType)) {
             aiSipCallTask.setAiTransferData(aiSipCallTask.getAiTransferExtNumber());
-        }else if(aiSipCallTask.getAiTransferType().equals("gateway")){
-            Map<String,String> map = new HashMap<>();
-            map.put("destNumber",aiSipCallTask.getAiTransferGatewayDestNumber());
-            map.put("gatewayId",aiSipCallTask.getAiTransferGatewayId());
+        } else if ("gateway".equals(transferType)) {
+            Map<String, String> map = new HashMap<>(2);
+            map.put("destNumber", aiSipCallTask.getAiTransferGatewayDestNumber());
+            map.put("gatewayId", aiSipCallTask.getAiTransferGatewayId());
             aiSipCallTask.setAiTransferData(JSONObject.toJSONString(map));
-        }else{
+        } else {
             aiSipCallTask.setAiTransferData(aiSipCallTask.getAiTransferGroupId());
         }
     }
 
-    /**
-     * 批量删除aiSIP外呼任务
-     * 
-     * @param batchIds 需要删除的aiSIP外呼任务主键
-     * @return 结果
-     */
     @Override
-    public int deleteAiSipCallTaskByBatchIds(Long[] batchIds)
-    {
-        return baseMapper.deleteAiSipCallTaskByBatchIds(batchIds);
+    public int deleteAiSipCallTaskByBatchIds(Long[] batchIds) {
+        if (batchIds == null || batchIds.length == 0) {
+            throw new ServiceException("删除aiSIP外呼任务失败:任务ID为空");
+        }
+        List<AiSipCallTask> aiSipCallTasks = baseMapper.selectAiSipCallTaskByBatchIds(Arrays.asList(batchIds));
+        if (CollectionUtils.isEmpty(aiSipCallTasks)) {
+            throw new ServiceException("删除aiSIP外呼任务失败:未找到该任务");
+        }
+        Long[] remoteBatchIds = aiSipCallTasks.stream()
+                .map(AiSipCallTask::getRemoteBatchId)
+                .filter(Objects::nonNull)
+                .toArray(Long[]::new);
+        if (remoteBatchIds.length == 0) {
+            throw new ServiceException("删除aiSIP外呼任务失败:无有效远程任务ID");
+        }
+
+        Map<String, Long[]> paramMap = new HashMap<>(1);
+        paramMap.put("batchIds", remoteBatchIds);
+        RemoteApiHelper.parseSuccess(
+                RemoteCommon.sendPost(RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.DEL_TASK_API,
+                        JSONObject.toJSONString(paramMap)),
+                "删除aiSIP外呼任务"
+        );
+        aiSipCallPhoneService.logicalDeletionByBatchIds(batchIds);
+        return baseMapper.logicalDeletionByBatchIds(batchIds);
     }
 
-    /**
-     * 删除aiSIP外呼任务信息
-     * 
-     * @param batchId aiSIP外呼任务主键
-     * @return 结果
-     */
     @Override
-    public int deleteAiSipCallTaskByBatchId(Long batchId)
-    {
+    public int deleteAiSipCallTaskByBatchId(Long batchId) {
         return baseMapper.deleteAiSipCallTaskByBatchId(batchId);
     }
 
     @Override
     public int startTask(Long batchId) {
-        AiSipCallTask ccCallTask = baseMapper.selectAiSipCallTaskByBatchId(batchId);
-        if(null == ccCallTask){
-            return 0;
-        }
-        String result = RemoteCommon.sendGet(RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.START_TASK_API + "?batchId=" + ccCallTask.getRemoteBatchId());
-        if(StringUtils.isNotBlank(result)){
-            JSONObject jsonObject = JSONObject.parseObject(result);
-            if(jsonObject.getInteger("code") == 0){
-                // 启动成功
-                ccCallTask.setIfcall(1L);
-                ccCallTask.setExecuting(0L);
-                ccCallTask.setStopTime(0L);
-                baseMapper.updateAiSipCallTask(ccCallTask);
-                return 1;
-            }else{
-                log.error("启动外呼失败:{}", jsonObject.getString("msg"));
-            }
-        }
-        return 0;
+        AiSipCallTask ccCallTask = requireTask(batchId, "启动外呼");
+        RemoteApiHelper.parseSuccess(
+                RemoteCommon.sendGet(RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.START_TASK_API
+                        + "?batchId=" + ccCallTask.getRemoteBatchId()),
+                "启动外呼"
+        );
+        ccCallTask.setIfcall(1L);
+        ccCallTask.setExecuting(0L);
+        ccCallTask.setStopTime(0L);
+        baseMapper.updateAiSipCallTask(ccCallTask);
+        return 1;
     }
 
     @Override
     public int stopTask(Long batchId) {
-        AiSipCallTask ccCallTask = baseMapper.selectAiSipCallTaskByBatchId(batchId);
-        if(null == ccCallTask){
-            return 0;
+        AiSipCallTask ccCallTask = requireTask(batchId, "停止外呼");
+        RemoteApiHelper.parseSuccess(
+                RemoteCommon.sendGet(RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.STOP_TASK_API
+                        + "?batchId=" + ccCallTask.getRemoteBatchId()),
+                "停止外呼"
+        );
+        ccCallTask.setIfcall(0L);
+        ccCallTask.setExecuting(0L);
+        ccCallTask.setStopTime(System.currentTimeMillis());
+        baseMapper.updateAiSipCallTask(ccCallTask);
+        return 1;
+    }
+
+    private AiSipCallTask requireTask(Long batchId, String apiDesc) {
+        if (batchId == null) {
+            throw new ServiceException(apiDesc + "失败:任务ID为空");
+        }
+        AiSipCallTask task = baseMapper.selectAiSipCallTaskByBatchId(batchId);
+        if (task == null) {
+            throw new ServiceException(apiDesc + "失败:任务不存在");
+        }
+        if (task.getRemoteBatchId() == null) {
+            throw new ServiceException(apiDesc + "失败:远程任务ID为空");
         }
-        String result = RemoteCommon.sendGet(RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.STOP_TASK_API + "?batchId=" + ccCallTask.getRemoteBatchId());
-        if(StringUtils.isNotBlank(result)){
-            JSONObject jsonObject = JSONObject.parseObject(result);
-            if(jsonObject.getInteger("code") == 0){
-                // 停止成功
-                ccCallTask.setIfcall(0L);
-                ccCallTask.setExecuting(0L);
-                ccCallTask.setStopTime(System.currentTimeMillis());
-                baseMapper.updateAiSipCallTask(ccCallTask);
+        return task;
+    }
+
+    @Override
+    public int commonImportExcel(Long batchId, List<CommonPhoneModel> phoneList, AiSipCallPhone initCallPhone,
+                                 List<SopUserLogsInfo> userLogsInfos, Map<Long, String> phoneMap) {
+        final long startTime = System.currentTimeMillis();
+        log.info("========== 开始执行commonImportExcel方法, batchId={}, 原始数据量={} ==========", batchId, phoneList == null ? 0 : phoneList.size());
+
+        try {
+            // 1. 参数校验和任务验证
+            if (batchId == null || CollectionUtils.isEmpty(phoneList)) {
+                log.error("导入外呼任务失败: {}", batchId == null ? "batchId不能为空" : "导入数据不能为空");
+                return 0;
+            }
+
+            AiSipCallTask task = validateTask(batchId);
+            if (task == null) return 0;
+
+            final Long remoteBatchId = task.getRemoteBatchId();
+            final int originalSize = phoneList.size();
+
+            // 2. 过滤无效和重复数据(号码维度)
+            List<CommonPhoneModel> validPhoneList = filterValidPhones(batchId, phoneList);
+            if (validPhoneList.isEmpty()) {
+                return 0;
+            }
+
+            // 2.1 同批次 fsUserId 去重(相对 DB 已有记录),防止重复外呼客户
+            Map<Long, String> workingPhoneMap = phoneMap == null ? new HashMap<>() : new HashMap<>(phoneMap);
+            List<CommonPhoneModel> uniquePhoneList = excludeExistingFsUserPhones(batchId, validPhoneList, workingPhoneMap);
+            if (uniquePhoneList.isEmpty()) {
+                log.info("commonImportExcel batchId={} 用户均已在批次中,跳过导入(防重复外呼)", batchId);
                 return 1;
-            }else{
-                log.error("停止外呼失败:{}", jsonObject.getString("msg"));
             }
+            validPhoneList = uniquePhoneList;
+
+            // 3. 预处理用户信息映射(使用当前任务独立的 phoneMap,避免并发共享缓存互相干扰)
+            Map<String, SopUserLogsInfo> phoneUserInfoMap = buildPhoneUserInfoMap(userLogsInfos, workingPhoneMap);
+
+            // 4. 分批调用远程接口并保存
+            ImportResult result = importPhonesInBatches(batchId, remoteBatchId, validPhoneList,
+                (remoteList, batchIndex, totalBatches) -> {
+                    // 填充字段回调
+                    for (AiSipCallPhone callPhone : remoteList) {
+                        fillCallPhoneFields(callPhone, initCallPhone, batchId, phoneUserInfoMap);
+                    }
+                });
+
+            final long duration = System.currentTimeMillis() - startTime;
+            log.info("========== commonImportExcel方法执行完成, batchId={}, 原始数据={}, 有效数据={}, 总处理数={}, 总成功数={}, 失败批次={}, 耗时={}ms ==========",
+                batchId, originalSize, validPhoneList.size(), result.totalProcessed, result.totalSuccessCount, result.failedBatchCount, duration);
+
+            return result.totalSuccessCount > 0 ? 1 : 0;
+        } catch (Exception e) {
+            final long duration = System.currentTimeMillis() - startTime;
+            log.error("========== commonImportExcel方法执行异常, batchId={}, 耗时={}ms ==========", batchId, duration, e);
+            return 0;
         }
-        return 0;
     }
 
     @Override
-    public int commonImportExcel(Long batchId,List<CommonPhoneModel> phoneList) {
-        if (batchId == null) {
-            throw new RuntimeException("参数 batchId 不能为空");
+    public AjaxResult appUrgentClasImportData(Long batchId, Set<Long> fsUserIds, Long userId, String userName) {
+        final long startTime = System.currentTimeMillis();
+        log.info("========== 开始执行appUrgentClasImportData方法, batchId={}, 用户ID数量={} ==========",
+            batchId, fsUserIds == null ? 0 : fsUserIds.size());
+
+        try {
+            // 1. 参数校验
+            if (batchId == null || CollectionUtils.isEmpty(fsUserIds) || userId == null) {
+                return AjaxResult.error("参数不能为空");
+            }
+
+            // 1.1 屏蔽时段校验:命中则直接拒绝,不导入、不启动
+            if (aiSipAutoCallRulesService.isBatchIdInShieldTime(batchId)) {
+                log.info("appUrgentClasImportData batchId={} 处于屏蔽时段,拒绝导入", batchId);
+                return AjaxResult.error(SHIELD_TIME_FAIL_REASON);
+            }
+
+            // 2. 校验任务存在
+            AiSipCallTask task = validateTask(batchId);
+            if (task == null) {
+                return AjaxResult.error("任务不存在");
+            }
+
+            final Long remoteBatchId = task.getRemoteBatchId();
+
+            // 3. 批量获取用户手机号并构建外呼数据模型
+            List<CommonPhoneModel> phoneModels = buildPhoneModelsFromUsers(batchId, fsUserIds);
+            if (phoneModels == null || phoneModels.isEmpty()) {
+                return AjaxResult.error("无有效外呼数据");
+            }
+
+            final int originalSize = phoneModels.size();
+
+            // 4. 过滤无效和重复数据
+            List<CommonPhoneModel> validPhoneModels = filterValidPhones(batchId, phoneModels);
+            if (validPhoneModels.isEmpty()) {
+                return AjaxResult.error("导入外呼任务失败: 过滤后无有效数据, batchId=" + batchId);
+            }
+
+            // 5. 构建手机号→fsUserId反向映射(提前构建,用于回调)
+            Map<Long, String> phoneMap = batchGetUserPhones(new ArrayList<>(fsUserIds));
+            Map<String, Long> phoneToFsUserIdMap = buildPhoneToUserIdMap(phoneMap);
+            final Long localBatchId = task.getBatchId();
+
+            // 6. 分批调用远程接口并保存
+            ImportResult result = importPhonesInBatches(batchId, remoteBatchId, validPhoneModels,
+                (remoteList, batchIndex, totalBatches) -> {
+                    // 填充字段回调
+                    fillAppUrgentFields(remoteList, localBatchId, userId, userName, phoneToFsUserIdMap);
+                });
+
+            if(result.totalSuccessCount <= 0){
+                return AjaxResult.error("导入外呼任务失败: batchId="+batchId+", 导入数量=0");
+            }
+            if (result.failedBatchCount > 0) {
+                log.error("存在失败批次: batchId={}, 失败批次数={}", batchId, result.failedBatchCount);
+            }
+
+            // 7. 启动外呼任务
+            int startTaskCount = this.startTask(batchId);
+            if (startTaskCount <= 0) {
+                log.error("成功导入但启动任务失败, batchId={}, 导入数量={}", batchId, result.totalSuccessCount);
+                return AjaxResult.error("成功导入但启动任务失败, batchId="+ batchId);
+            }
+
+            final long duration = System.currentTimeMillis() - startTime;
+            log.info("========== appUrgentClasImportData方法执行成功, batchId={}, 原始数据={}, 有效数据={}, 导入数量={}, 耗时={}ms ==========",
+                batchId, originalSize, validPhoneModels.size(), result.totalSuccessCount, duration);
+
+            return AjaxResult.success("成功导入并启动任务: batchId="+batchId+", 追加"+result.totalSuccessCount+"个名单");
+        } catch (Exception e) {
+            final long duration = System.currentTimeMillis() - startTime;
+            log.error("========== appUrgentClasImportData方法执行异常, batchId={}, 耗时={}ms ==========", batchId, duration, e);
+            return AjaxResult.error("batchId="+batchId+" 执行外呼导入和启动异常: "+ e.getMessage());
         }
+    }
 
+    /**
+     * 验证任务存在性
+     */
+    private AiSipCallTask validateTask(Long batchId) {
         AiSipCallTask task = this.selectAiSipCallTaskByBatchId(batchId);
         if (task == null) {
-            throw new RuntimeException("该任务不存在,请输入正确的 batchId");
+            log.error("导入外呼任务失败: 任务不存在, batchId={}", batchId);
+            return null;
+        }
+        if (task.getRemoteBatchId() == null) {
+            log.error("导入外呼任务失败: 远程任务ID为空, batchId={}", batchId);
+            return null;
         }
+        return task;
+    }
 
-        if (CollectionUtils.isEmpty(phoneList)) {
-            throw new RuntimeException("导入的数据不能为空");
-        }
-
-//        // 验证并过滤重复数据(丢到外呼那边去处理)
-//        List<String> duplicateList = aiSipCallPhoneService.isDuplicateEntry(batchId, phoneList);
-//        if (!CollectionUtils.isEmpty(duplicateList)) {
-//            phoneList = phoneList.stream()
-//                    .filter(p -> !duplicateList.contains(p.getPhoneNum()))
-//                    .collect(Collectors.toList());
-//        }
-//
-//        if (CollectionUtils.isEmpty(phoneList)) {
-//            throw new RuntimeException("过滤重复数据后无有效数据,重复号码:" + String.join(",", duplicateList));
-//        }
-        CommonCallListModel commonCallListModel = new CommonCallListModel();
-        commonCallListModel.setBatchId(task.getRemoteBatchId());
-        commonCallListModel.setPhoneList(phoneList);
-        String result = RemoteCommon.sendPost(RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.COMMON_ADD_CALL_LIST_API, JSONObject.toJSONString(commonCallListModel));
-        if(StringUtils.isNotBlank(result)){
-            JSONObject jsonObject = JSONObject.parseObject(result);
-            if(jsonObject.getInteger("code") == 0){
-                // 追加名单成功 是先创建外呼记录还是直接取拉取完整数据
-//                int successCount = 0;
-//                List <AiSipCallPhone> callPhoneList = new ArrayList<>();
-//                for (CommonPhoneModel commonPhoneModel : commonCallListModel.getPhoneList()) {
-//                    if (StringUtils.isBlank(commonPhoneModel.getPhoneNum())) {
-//                        continue;
-//                    }
-//                    AiSipCallPhone callPhone = buildCcCallPhone(batchId, commonPhoneModel.getPhoneNum(), commonPhoneModel.getBizJson());
-//                    callPhone.setTtsText(commonPhoneModel.getNoticeContent());
-//                    callPhoneList.add(callPhone);
-//                    successCount ++;
-//                    if (callPhoneList.size() >= 200) {
-//                        aiSipCallPhoneService.saveBatch(callPhoneList);
-//                        callPhoneList = new ArrayList<>();
-//                    }
-//                }
-//                if (!callPhoneList.isEmpty()) {
-//                    aiSipCallPhoneService.saveBatch(callPhoneList);
-//                }
-//                log.info("成功追加" + successCount + "个名单");
-                return 1;
+    /**
+     * 过滤有效电话号码(去重 + 去除无效号码)
+     */
+    private List<CommonPhoneModel> filterValidPhones(Long batchId, List<CommonPhoneModel> phoneList) {
+        // 查询重复数据
+        List<String> duplicateList = aiSipCallPhoneService.isDuplicateEntry(batchId, phoneList);
+        Set<String> duplicateSet = CollectionUtils.isEmpty(duplicateList) ? Collections.emptySet() : new HashSet<>(duplicateList);
+
+        // 一次遍历完成过滤:排除库内重复 + 本批列表内号码去重
+        List<CommonPhoneModel> validList = new ArrayList<>(phoneList.size());
+        Set<String> seenInList = new HashSet<>(Math.max(16, phoneList.size() * 2));
+        for (CommonPhoneModel model : phoneList) {
+            if (model == null) continue;
+            String phoneNum = model.getPhoneNum();
+            if (StringUtils.isBlank(phoneNum) || "null".equalsIgnoreCase(phoneNum)) continue;
+            String normalized = phoneNum.trim();
+            if (!duplicateSet.isEmpty() && duplicateSet.contains(phoneNum)) continue;
+            if (!duplicateSet.isEmpty() && duplicateSet.contains(normalized)) continue;
+            if (!seenInList.add(normalized)) continue;
+            validList.add(model);
+        }
+
+        if (validList.isEmpty()) {
+            String dupStr = duplicateList != null && !duplicateList.isEmpty()
+                ? String.join(",", duplicateList) : "无";
+            log.error("导入外呼任务失败: 过滤后无有效数据, batchId={}, 原始数量={}, 重复号码={}",
+                batchId, phoneList.size(), dupStr);
+        }
+
+        return validList;
+    }
+
+    /**
+     * 剔除本批次 DB 中已存在 fsUserId 对应的号码,并同步收缩 workingPhoneMap。
+     */
+    private List<CommonPhoneModel> excludeExistingFsUserPhones(Long batchId,
+                                                               List<CommonPhoneModel> phoneList,
+                                                               Map<Long, String> workingPhoneMap) {
+        if (CollectionUtils.isEmpty(phoneList) || workingPhoneMap == null || workingPhoneMap.isEmpty()) {
+            return phoneList;
+        }
+        List<Long> existedList = aiSipCallPhoneService.selectDistinctFsUserIdsByLocalBatchId(batchId);
+        if (CollectionUtils.isEmpty(existedList)) {
+            return phoneList;
+        }
+        Set<Long> existed = new HashSet<>(existedList);
+        Set<String> removedPhones = new HashSet<>();
+        workingPhoneMap.entrySet().removeIf(entry -> {
+            if (entry.getKey() != null && existed.contains(entry.getKey())) {
+                if (StringUtils.isNotBlank(entry.getValue())) {
+                    removedPhones.add(entry.getValue().trim());
+                }
+                return true;
+            }
+            return false;
+        });
+        if (removedPhones.isEmpty()) {
+            return phoneList;
+        }
+        List<CommonPhoneModel> kept = new ArrayList<>(phoneList.size());
+        for (CommonPhoneModel model : phoneList) {
+            if (model == null || StringUtils.isBlank(model.getPhoneNum())) {
+                continue;
+            }
+            if (removedPhones.contains(model.getPhoneNum().trim())) {
+                continue;
+            }
+            kept.add(model);
+        }
+        log.info("commonImportExcel batchId={} 按 fsUserId 剔除已存在号码 {} 个,剩余 {}",
+                batchId, phoneList.size() - kept.size(), kept.size());
+        return kept;
+    }
+
+    /**
+     * 从用户ID集合构建外呼数据模型
+     */
+    private List<CommonPhoneModel> buildPhoneModelsFromUsers(Long batchId, Set<Long> fsUserIds) {
+        Map<Long, String> phoneMap = batchGetUserPhones(new ArrayList<>(fsUserIds));
+        if (phoneMap.isEmpty()) {
+            log.error("导入外呼任务失败: 未获取到有效手机号, batchId={}, 用户数={}", batchId, fsUserIds.size());
+            return Collections.emptyList();
+        }
+        return buildPhoneModels(phoneMap);
+    }
+
+    /**
+     * 构建手机号到用户ID的映射
+     */
+    private Map<String, Long> buildPhoneToUserIdMap(Map<Long, String> phoneMap) {
+        Map<String, Long> phoneToUserIdMap = new HashMap<>(phoneMap.size());
+        for (Map.Entry<Long, String> entry : phoneMap.entrySet()) {
+            phoneToUserIdMap.putIfAbsent(entry.getValue(), entry.getKey());
+        }
+        return phoneToUserIdMap;
+    }
+
+    /**
+     * 填充appUrgentClasImportData特有字段
+     */
+    private void fillAppUrgentFields(List<AiSipCallPhone> remoteCallPhoneList, Long localBatchId,
+                                     Long userId, String userName, Map<String, Long> phoneToFsUserIdMap) {
+        final Long companyId = 323L;
+        final String executeType = "2";
+        final String callType = "1";
+
+        for (AiSipCallPhone callPhone : remoteCallPhoneList) {
+            callPhone.setLocalBatchId(localBatchId);
+            callPhone.setExecuteType(executeType);
+            callPhone.setCallType(callType);
+            callPhone.setCompanyId(companyId);
+            callPhone.setSysUserId(userId);
+            callPhone.setSysUserName(userName);
+
+            try {
+                String decryptPhone = SipDESUtil.decrypt(callPhone.getTelephone());
+                if (StringUtils.isNotBlank(decryptPhone)) {
+                    Long fsUserId = phoneToFsUserIdMap.get(decryptPhone);
+                    if (fsUserId != null) {
+                        callPhone.setFsUserId(fsUserId);
+                    }
+                }
+            } catch (Throwable e) {
+                log.error("导入自动外呼记录id:{}时处理telephone解密失败:{}",callPhone.getId(),e.getMessage());
+            }
+            //处理电话
+            try {
+                if(StringUtils.isNotBlank(callPhone.getTelephone())){
+                    callPhone.setTelephone(SipDESUtil.decrypt(URLDecoder.decode(callPhone.getTelephone(), "UTF-8").replaceAll("=+$", "")));
+                }else{
+                    callPhone.setTelephone("");
+                }
+                if(StringUtils.isNotBlank(callPhone.getCallerNumber())){
+                    callPhone.setCallerNumber(SipDESUtil.decrypt(URLDecoder.decode(callPhone.getCallerNumber(), "UTF-8").replaceAll("=+$", "")));
+                }else{
+                    callPhone.setCallerNumber("");
+                }
+            } catch (Throwable e) {
+                callPhone.setTelephone("");
+                callPhone.setCallerNumber("");
+            }
+        }
+    }
+
+    @FunctionalInterface
+    private interface FieldFiller {
+        void fill(List<AiSipCallPhone> remoteList, int batchIndex, int totalBatches);
+    }
+
+    private static class ImportResult {
+        int totalSuccessCount = 0;
+        int totalProcessed = 0;
+        int failedBatchCount = 0;
+    }
+
+    private ImportResult importPhonesInBatches(Long batchId, Long remoteBatchId,
+                                               List<CommonPhoneModel> validPhoneList, FieldFiller fieldFiller) {
+        // 优化批量大小:考虑到DES加密+Base64编码会使数据膨胀约33%
+        // 为保证HTTP请求体不超过500KB(安全阈值),每批500条最合适
+        // 计算:500条 * 360字节/条 * 1.33(加密膨胀) ≈ 240KB << 500KB安全线
+        final int batchSize = 500;
+        ImportResult result = new ImportResult();
+        final int totalBatches = (validPhoneList.size() + batchSize - 1) / batchSize;
+
+        log.info("开始批量导入外呼任务, batchId={}, 有效数据量={}, 总批次={}", batchId, validPhoneList.size(), totalBatches);
+
+        for (int i = 0; i < validPhoneList.size(); i += batchSize) {
+            final int batchIndex = i / batchSize + 1;
+            final int endIndex = Math.min(i + batchSize, validPhoneList.size());
+            final List<CommonPhoneModel> batchPhoneList = validPhoneList.subList(i, endIndex);
+
+            if (log.isInfoEnabled()) {
+                log.info("开始处理第{}/{}批数据, batchId={}, 当前批次大小={}", batchIndex, totalBatches, batchId, batchPhoneList.size());
+            }
+
+            // 调用远程接口(带重试)
+            List<AiSipCallPhone> remoteList = callRemoteApiWithRetry(batchId, remoteBatchId, batchPhoneList, batchIndex, totalBatches);
+
+            if (remoteList != null && !remoteList.isEmpty()) {
+                // 填充字段
+                fieldFiller.fill(remoteList, batchIndex, totalBatches);
+
+                // 批量保存
+                int successCount = saveBatchPhones(remoteList);
+                result.totalSuccessCount += successCount;
+                result.totalProcessed += batchPhoneList.size();
+
+                if (log.isInfoEnabled()) {
+                    log.info("第{}/{}批数据处理成功, batchId={}, 本批成功数={}, 累计成功数={}",
+                        batchIndex, totalBatches, batchId, successCount, result.totalSuccessCount);
+                }
+            } else {
+                result.failedBatchCount++;
+                log.error("批次[{}/{}]处理失败,该批次{}条数据未能导入, batchId={}",
+                    batchIndex, totalBatches, batchPhoneList.size(), batchId);
+            }
+        }
+
+        // 数据完整性检查
+        if (result.totalProcessed != validPhoneList.size()) {
+            log.info("数据完整性警告: batchId={}, 预期处理={}, 实际处理={}, 丢失={}条",
+                batchId, validPhoneList.size(), result.totalProcessed, validPhoneList.size() - result.totalProcessed);
+        }
+
+        if (result.failedBatchCount > 0) {
+            log.error("存在失败批次: batchId={}, 失败批次数={}, 总批次数={}",
+                batchId, result.failedBatchCount, totalBatches);
+        }
+
+        return result;
+    }
+
+    /**
+     * 调用远程接口(带重试机制)
+     * @return 成功返回解析后的列表,失败返回null
+     */
+    private List<AiSipCallPhone> callRemoteApiWithRetry(Long batchId, Long remoteBatchId,
+                                                        List<CommonPhoneModel> phoneList,
+                                                        int batchIndex, int totalBatches) {
+        int maxRetries = 3;
+
+        for (int retry = 0; retry < maxRetries; retry++) {
+            try {
+                CommonCallListModel callListModel = new CommonCallListModel();
+                callListModel.setBatchId(remoteBatchId);
+                callListModel.setPhoneList(phoneList);
+
+                String datajson = JSONObject.toJSONString(callListModel);
+                try {
+                    datajson = SipDESUtil.encrypt(datajson);
+                } catch (Throwable e) {
+                    log.error("batchId={}, 导入电话时数据加密失败", batchId, e);
+                    return null;
+                }
+                String result = RemoteCommon.sendPost(RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.IMPORT_AUTOCALL_API,
+                        URLEncoder.encode(datajson, "UTF-8"));
+
+                if (StringUtils.isBlank(result)) {
+                    logRetryWarn(batchId, batchIndex, totalBatches, retry, maxRetries, "远程接口返回为空");
+                    if (!doRetry(retry, maxRetries)) continue;
+                    return null;
+                }
+
+                JSONObject jsonObject = JSONObject.parseObject(result);
+                if (!RemoteApiHelper.isSuccess(jsonObject.getInteger("code"))) {
+                    logRetryError(batchId, batchIndex, totalBatches, retry, maxRetries,
+                        "远程接口返回失败: code=" + jsonObject.getInteger("code") + ", msg=" + jsonObject.getString("msg"));
+                    if (!doRetry(retry, maxRetries)) continue;
+                    return null;
+                }
+
+                String data = jsonObject.getString("data");
+                if (StringUtils.isBlank(data)) {
+                    logRetryError(batchId, batchIndex, totalBatches, retry, maxRetries, "远程接口返回data为空");
+                    if (!doRetry(retry, maxRetries)) continue;
+                    return null;
+                }
+
+                List<AiSipCallPhone> remoteCallPhoneList = JSONObject.parseObject(data, new TypeReference<List<AiSipCallPhone>>(){});
+                if (CollectionUtils.isEmpty(remoteCallPhoneList)) {
+                    logRetryError(batchId, batchIndex, totalBatches, retry, maxRetries, "转化返回数据为空");
+                    if (!doRetry(retry, maxRetries)) continue;
+                    return null;
+                }
+
+                if (batchIndex > 0) { // 只有分批时才记录成功日志
+                    log.info("远程接口调用成功, batchId={}, 批次=[{}/{}], 返回数据量={}",
+                        batchId, batchIndex, totalBatches, remoteCallPhoneList.size());
+                }
+                return remoteCallPhoneList;
+
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                log.error("远程接口调用被中断, batchId={}, 批次=[{}/{}]", batchId, batchIndex, totalBatches, e);
+                return null;
+            } catch (Exception e) {
+                if (retry < maxRetries - 1) {
+                    log.warn("远程接口调用异常, batchId={}, 批次=[{}/{}], 重试次数={}/{}, 错误: {}",
+                        batchId, batchIndex, totalBatches, retry + 1, maxRetries, e.getMessage());
+                    try {
+                        Thread.sleep(1000 * (retry + 1));
+                    } catch (InterruptedException ie) {
+                        Thread.currentThread().interrupt();
+                        return null;
+                    }
+                } else {
+                    log.error("远程接口调用异常且重试耗尽, batchId={}, 批次=[{}/{}], 错误: {}",
+                        batchId, batchIndex, totalBatches, e.getMessage(), e);
+                    return null;
+                }
+            }
+        }
+        return null;
+    }
+
+    /**
+     * 判断是否继续重试
+     */
+    private boolean doRetry(int retry, int maxRetries) throws InterruptedException {
+        if (retry < maxRetries - 1) {
+            Thread.sleep(1000 * (retry + 1));
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * 记录重试警告日志
+     */
+    private void logRetryWarn(Long batchId, int batchIndex, int totalBatches, int retry, int maxRetries, String message) {
+        if (batchIndex > 0) {
+            log.warn("{}, batchId={}, 批次=[{}/{}], 重试次数={}/{}", message, batchId, batchIndex, totalBatches, retry + 1, maxRetries);
+        } else {
+            log.warn("{}, batchId={}, 重试次数={}/{}", message, batchId, retry + 1, maxRetries);
+        }
+    }
+
+    /**
+     * 记录重试错误日志
+     */
+    private void logRetryError(Long batchId, int batchIndex, int totalBatches, int retry, int maxRetries, String message) {
+        if (log.isErrorEnabled()) {
+            if (batchIndex > 0) {
+                log.error("{}, batchId={}, 批次=[{}/{}], 重试次数={}/{}", message, batchId, batchIndex, totalBatches, retry + 1, maxRetries);
+            } else {
+                log.error("{}, batchId={}, 重试次数={}/{}", message, batchId, retry + 1, maxRetries);
+            }
+        }
+    }
+
+    /**
+     * 构建手机号到用户信息的映射(基于当前任务独立的 userId->phone 映射,保证并发安全)
+     */
+    private Map<String, SopUserLogsInfo> buildPhoneUserInfoMap(List<SopUserLogsInfo> userLogsInfos,
+                                                               Map<Long, String> phoneMap) {
+        Map<String, SopUserLogsInfo> phoneUserInfoMap = new HashMap<>();
+
+        if (CollectionUtils.isEmpty(userLogsInfos) || phoneMap == null || phoneMap.isEmpty()) {
+            return phoneUserInfoMap;
+        }
+
+        Map<Long, SopUserLogsInfo> userIdToInfoMap = userLogsInfos.stream()
+                .filter(info -> info != null && info.getFsUserId() != null)
+                .collect(Collectors.toMap(SopUserLogsInfo::getFsUserId, info -> info, (k1, k2) -> k1));
+
+        for (Map.Entry<Long, String> entry : phoneMap.entrySet()) {
+            Long userId = entry.getKey();
+            String phone = entry.getValue();
+            if (userId == null || StringUtils.isBlank(phone)) {
+                continue;
+            }
+            SopUserLogsInfo userInfo = userIdToInfoMap.get(userId);
+            if (userInfo != null) {
+                phoneUserInfoMap.putIfAbsent(phone.trim(), userInfo);
+            }
+        }
+
+        return phoneUserInfoMap;
+    }
+
+    /**
+     * 填充通话记录字段
+     */
+    private void fillCallPhoneFields(AiSipCallPhone callPhone, AiSipCallPhone initCallPhone, Long batchId, Map<String, SopUserLogsInfo> phoneUserInfoMap) {
+        // 复制基础字段
+        callPhone.setCallType(initCallPhone.getCallType());
+        callPhone.setCompanyId(initCallPhone.getCompanyId());
+        callPhone.setCompanyUserId(initCallPhone.getCompanyUserId());
+        callPhone.setCompanyUserName(initCallPhone.getCompanyUserName());
+        callPhone.setSysUserId(initCallPhone.getSysUserId());
+        callPhone.setSysUserName(initCallPhone.getSysUserName());
+        callPhone.setLocalBatchId(batchId);
+        callPhone.setQwUserid(initCallPhone.getQwUserid());
+        callPhone.setSendTime(initCallPhone.getSendTime());
+        callPhone.setSopId(initCallPhone.getSopId());
+        callPhone.setCorpId(initCallPhone.getCorpId());
+        callPhone.setSendType(initCallPhone.getSendType());
+        callPhone.setCourseId(initCallPhone.getCourseId());
+        callPhone.setVideoId(initCallPhone.getVideoId());
+        callPhone.setCourseType(initCallPhone.getCourseType());
+        callPhone.setExecuteType(initCallPhone.getExecuteType());
+        callPhone.setSipTaskId(initCallPhone.getSipTaskId());
+        callPhone.setAppCustomerId(initCallPhone.getAppCustomerId());
+
+        // 设置用户关联信息
+        if (StringUtils.isNotBlank(callPhone.getTelephone()) && !phoneUserInfoMap.isEmpty()) {
+            try {
+                String decryptPhone = SipDESUtil.decrypt(callPhone.getTelephone());
+                if (StringUtils.isNotBlank(decryptPhone)) {
+                    SopUserLogsInfo userInfo = phoneUserInfoMap.get(decryptPhone.trim());
+                    if (userInfo != null) {
+                        callPhone.setFsUserId(userInfo.getFsUserId());
+                        if (userInfo.getExternalId() != null) {
+                            callPhone.setExternalId(String.valueOf(userInfo.getExternalId()));
+                            callPhone.setExternalUserId(String.valueOf(userInfo.getExternalId()));
+                        }
+                        if (StringUtils.isNotBlank(userInfo.getExternalUserName())) {
+                            callPhone.setExternalUserName(userInfo.getExternalUserName());
+                        }
+                    }
+                }
+            } catch (Throwable e) {
+                log.error("导入自动外呼记录id:{}时处理telephone解密失败:{}",callPhone.getId(),e.getMessage());
+            }
+        }
+        //处理电话
+        try{
+            if(StringUtils.isNotEmpty(callPhone.getTelephone())){
+                callPhone.setTelephone(URLEncoder.encode(callPhone.getTelephone(), "UTF-8"));
             }else{
-                log.error("任务:{}添加名单失败:{}",batchId, jsonObject.getString("msg"));
+                callPhone.setTelephone("");
             }
+        }catch (Throwable e){
+            log.error("导入自动外呼记录id:{}时处理telephone编码失败:{}",callPhone.getId(),e.getMessage());
+            callPhone.setTelephone("");
+        }
+        try{
+            if(StringUtils.isNotEmpty(callPhone.getCallerNumber())){
+                callPhone.setCallerNumber(URLEncoder.encode(callPhone.getCallerNumber(), "UTF-8"));
+            }else{
+                callPhone.setCallerNumber("");
+            }
+        }catch (Throwable e){
+            log.error("导入自动外呼记录id:{}时处理callerNumber编码失败:{}",callPhone.getId(),e.getMessage());
+            callPhone.setCallerNumber("");
         }
-        return 0;
     }
 
-    @Override
-    public CallTaskStatModel statByBatchId(Long batchId) {
-        return aiSipCallPhoneService.statByBatchId(batchId);
+    /**
+     * 批量保存通话记录(每200条提交一次,带异常处理和重试)
+     */
+    private int saveBatchPhones(List<AiSipCallPhone> phoneList) {
+        if (CollectionUtils.isEmpty(phoneList)) {
+            return 0;
+        }
+
+        final int batchSize = 200;
+        int totalSaved = 0;
+        int failedBatchCount = 0;
+        List<AiSipCallPhone> batchList = new ArrayList<>(batchSize);
+
+        for (AiSipCallPhone phone : phoneList) {
+            batchList.add(phone);
+            if (batchList.size() >= batchSize) {
+                // 带重试的批量保存
+                boolean saved = saveBatchWithRetry(batchList, 3);
+                if (saved) {
+                    totalSaved += batchList.size();
+                } else {
+                    failedBatchCount++;
+                    log.error("批量保存失败,该批次{}条数据未能保存", batchList.size());
+                }
+                batchList.clear(); // 复用列表,避免重复创建
+            }
+        }
+
+        // 保存剩余数据
+        if (!batchList.isEmpty()) {
+            boolean saved = saveBatchWithRetry(batchList, 3);
+            if (saved) {
+                totalSaved += batchList.size();
+            } else {
+                failedBatchCount++;
+                log.error("批量保存失败,最后批次{}条数据未能保存", batchList.size());
+            }
+        }
+
+        if (failedBatchCount > 0) {
+            log.error("批量保存存在失败批次: 总批次={}, 失败批次={}, 成功保存={}",
+                (phoneList.size() + batchSize - 1) / batchSize, failedBatchCount, totalSaved);
+        }
+
+        return totalSaved;
     }
 
-    @Override
-    public AiSipCallTask selectAiSipCallTaskByRemoteBatchId(Long remoteBatchId) {
-        return baseMapper.selectAiSipCallTaskByRemoteBatchId(remoteBatchId);
+    /**
+     * 带重试机制的批量保存
+     * @param batchList 待保存的数据列表
+     * @param maxRetries 最大重试次数
+     * @return 是否保存成功
+     */
+    private boolean saveBatchWithRetry(List<AiSipCallPhone> batchList, int maxRetries) {
+        for (int retry = 0; retry < maxRetries; retry++) {
+            try {
+                aiSipCallPhoneService.saveBatch(batchList);
+                return true;
+            } catch (Exception e) {
+                if (retry < maxRetries - 1) {
+                    log.warn("批量保存失败,重试次数={}/{}, 错误: {}", retry + 1, maxRetries, e.getMessage());
+                    try {
+                        Thread.sleep(500 * (retry + 1)); // 递增延迟:500ms, 1s, 1.5s
+                    } catch (InterruptedException ie) {
+                        Thread.currentThread().interrupt();
+                        log.error("重试等待时被中断", ie);
+                        return false;
+                    }
+                } else {
+                    log.error("批量保存失败且重试耗尽, 批次大小={}, 错误: {}", batchList.size(), e.getMessage(), e);
+                    return false;
+                }
+            }
+        }
+        return false;
+    }
+
+    // 手机号缓存(长期缓存,跨任务周期共享)
+    private final Cache<Long, String> phoneCache = CacheBuilder.newBuilder()
+            .expireAfterWrite(1, TimeUnit.HOURS)
+            .maximumSize(100000)
+            .build();
+    /**
+     * 批量获取用户手机号(高性能版本)
+     */
+    private Map<Long, String> batchGetUserPhones(List<Long> userIds) {
+        if (userIds == null || userIds.isEmpty()) {
+            return Collections.emptyMap();
+        }
+
+        // 预分配容量,避免扩容
+        Map<Long, String> phoneMap = new HashMap<>(userIds.size());
+        Set<Long> missingUserIds = new HashSet<>();
+
+        // 先从缓存获取
+        for (Long userId : userIds) {
+            if (userId == null) {
+                continue;
+            }
+            String phone = phoneCache.getIfPresent(userId);
+            if (phone != null) {
+                phoneMap.put(userId, phone);
+            } else {
+                missingUserIds.add(userId);
+            }
+        }
+
+        // 批量查询缺失的手机号
+        if (!missingUserIds.isEmpty()) {
+            int batchSize = 300;
+            List<Long> missingUserIdList = new ArrayList<>(missingUserIds);
+
+            for (int i = 0; i < missingUserIdList.size(); i += batchSize) {
+                int endIndex = Math.min(i + batchSize, missingUserIdList.size());
+                List<Long> batchUserIds = missingUserIdList.subList(i, endIndex);
+
+                try {
+                    List<FsUser> userList = fsUserService.selectUserListByUserIds(batchUserIds);
+                    if (userList != null && !userList.isEmpty()) {
+                        for (FsUser user : userList) {
+                            if (user != null && user.getUserId() != null && user.getPhone() != null) {
+                                try {
+                                    //测试数据
+//                                    String phone ="15779601354";
+                                    String phone = PhoneUtil.decryptPhone(user.getPhone());
+                                    if (phone != null && !phone.trim().isEmpty()) {
+                                        phoneMap.put(user.getUserId(), phone);
+                                        phoneCache.put(user.getUserId(), phone);
+                                    }
+                                } catch (Exception e) {
+                                    log.warn("解密用户 {} 手机号失败: {}", user.getUserId(), e.getMessage());
+                                }
+                            }
+                        }
+                    }
+                } catch (Exception e) {
+                    log.error("批量查询用户手机号失败,批次大小: {}", batchUserIds.size(), e);
+                }
+            }
+        }
+
+        return phoneMap;
+    }
+    /**
+     * 构建外呼数据模型(脱敏处理,避免敏感信息泄露)
+     */
+    private List<CommonPhoneModel> buildPhoneModels(Map<Long, String> phoneMap) {
+        if (phoneMap == null || phoneMap.isEmpty()) {
+            return Collections.emptyList();
+        }
+
+        List<CommonPhoneModel> phoneList = new ArrayList<>(phoneMap.size());
+
+        // 从 phoneMap 中提取所有的手机号(value)
+        for (String phone : phoneMap.values()) {
+            if (phone != null && !phone.trim().isEmpty()) {
+                CommonPhoneModel model = new CommonPhoneModel();
+                model.setPhoneNum(phone);
+                model.setNoticeContent("runtian"); // 提醒内容
+
+                JSONObject bizJson = new JSONObject();
+                bizJson.put("custName", "runtian");
+                model.setBizJson(bizJson);
+
+                phoneList.add(model);
+            }
+        }
+
+        return phoneList;
     }
 }

+ 558 - 109
fs-service/src/main/java/com/fs/aiSipCall/service/impl/AiSipCallUserServiceImpl.java

@@ -6,32 +6,72 @@ import com.fs.aiSipCall.RemoteCommon;
 import com.fs.aiSipCall.domain.AiSipCallUser;
 import com.fs.aiSipCall.mapper.AiSipCallUserMapper;
 import com.fs.aiSipCall.service.IAiSipCallUserService;
+import com.fs.aiSipCall.utils.RemoteApiHelper;
+import com.fs.aiSipCall.utils.SipLogUtil;
+import com.fs.aiSipCall.vo.CcExtNumVo;
 import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.exception.ServiceException;
+import com.fs.company.domain.Company;
+import com.fs.company.mapper.CompanyMapper;
 import com.fs.company.mapper.CompanyUserMapper;
-import lombok.extern.slf4j.Slf4j;
+import com.fs.his.mapper.FsDoctorMapper;
+import com.fs.system.mapper.SysUserMapper;
 import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
 
-import java.util.List;
-import java.util.Map;
+import java.util.*;
+import java.util.stream.Collectors;
 
 /**
  * sip用户信息Service业务层处理
- *
+ * 
  * @author fs
  * @date 2026-03-13
  */
-@Slf4j
 @Service
 public class AiSipCallUserServiceImpl extends ServiceImpl<AiSipCallUserMapper, AiSipCallUser> implements IAiSipCallUserService {
 
+    private static final Logger log = SipLogUtil.createSipLogger(AiSipCallUserServiceImpl.class);
+
     @Autowired
     private CompanyUserMapper companyUserMapper;
+    @Autowired
+    private SysUserMapper sysUserMapper;
+    @Autowired
+    private CompanyMapper companyMapper;
+    @Autowired
+    private FsDoctorMapper fsDoctorMapper;
+    /** 是否使用自己线路 */
+    @Value(value = "${sip.call.myGateway:false}")
+    private boolean isMyGateway;
+    /** 使用手动线路前缀 */
+    @Value(value = "${sip.call.manualGatewayPrefix:weizhi}")
+    private String manualGatewayPrefix;
+    /** 使用公共线路前缀 */
+    @Value(value = "${sip.call.publicGatewayPrefix:outbound}")
+    private String publicGatewayPrefix;
+
+    /** 总后台用户前缀 */
+    @Value(value = "${sip.call.adminUserPrefix:zt}")
+    private String adminUserPrefix;
+    /** 销售公司前缀 */
+    @Value(value = "${sip.call.companyPrefix:xg}")
+    private String companyPrefix;
+    /** 销售前缀 */
+    @Value(value = "${sip.call.companyUserPrefix:xs}")
+    private String companyUserPrefix;
+    /** 医生前缀 */
+    @Value(value = "${sip.call.doctorPrefix:dt}")
+    private String doctorPrefix;
 
     /**
      * 查询sip用户信息
-     *
+     * 
      * @param userId sip用户信息主键
      * @return sip用户信息
      */
@@ -43,7 +83,7 @@ public class AiSipCallUserServiceImpl extends ServiceImpl<AiSipCallUserMapper, A
 
     /**
      * 查询sip用户信息列表
-     *
+     * 
      * @param aiSipCallUser sip用户信息
      * @return sip用户信息
      */
@@ -53,132 +93,541 @@ public class AiSipCallUserServiceImpl extends ServiceImpl<AiSipCallUserMapper, A
         return baseMapper.selectAiSipCallUserList(aiSipCallUser);
     }
 
-    /**
-     * 新增sip用户信息
-     *
-     * @param aiSipCallUser sip用户信息
-     * @return 结果
-     */
     @Override
-    public int insertAiSipCallUser(AiSipCallUser aiSipCallUser)
-    {
-        //同步新增远程接口
-        String result = RemoteCommon.sendPost(RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.ADD_USER_OR_BIND_EXTNUM_API, JSONObject.toJSONString(aiSipCallUser));
-
-        if(StringUtils.isNotBlank(result)){
-            JSONObject jsonObject = JSONObject.parseObject(result);
-            if(jsonObject.getInteger("code") == 0){
-                String data = jsonObject.getString("data");
-                AiSipCallUser remoteAiSipCallUser = JSONObject.parseObject(data, AiSipCallUser.class);
-                if(remoteAiSipCallUser != null){
-                    //新增
-                    aiSipCallUser.setUserId(remoteAiSipCallUser.getUserId());
-                    if(StringUtils.isNotBlank(remoteAiSipCallUser.getPhonenumber())){
-                        aiSipCallUser.setPhonenumber(remoteAiSipCallUser.getPhonenumber());
-                    }
-                    if(StringUtils.isNotBlank(remoteAiSipCallUser.getPassword())){
-                        aiSipCallUser.setPassword(remoteAiSipCallUser.getPassword());
-                    }
-                    if(remoteAiSipCallUser.getCreateTime() != null){
-                        aiSipCallUser.setCreateTime(remoteAiSipCallUser.getCreateTime());
-                    }
-                    int i = baseMapper.insertAiSipCallUser(aiSipCallUser);
-                    if( i> 0){
-                        //绑定companyUser的aiSIP外呼用户
-                        return companyUserMapper.updateCompanyUserByAiSipCall(aiSipCallUser.getCompanyUserId(),remoteAiSipCallUser.getUserId());
-                    }else{
-                        log.error("绑定aiSIP外呼用户失败");
-                    }
-                }else{
-                    log.error("新增时解析aiSIP外呼用户数据为空");
-                }
-            }else{
-                log.error("新增aiSIP外呼任务失败:{}", jsonObject.getString("msg"));
-            }
-        }else{
-            log.error("新增aiSIP外呼任务失败:{}", "接口返回为空");
+    public int insertAiSipCallUser(AiSipCallUser aiSipCallUser) {
+        Objects.requireNonNull(aiSipCallUser, "aiSipCallUser");
+        String userSource = StringUtils.defaultString(aiSipCallUser.getUserSource());
+        String namePrefix = resolveLoginNamePrefix(userSource);
+        String loginName = StringUtils.defaultString(aiSipCallUser.getLoginName());
+        if (!loginName.startsWith(namePrefix)) {
+            aiSipCallUser.setLoginName(namePrefix + loginName);
+        }
+
+        AiSipCallUser remoteUser = RemoteApiHelper.parseObjectData(
+                RemoteCommon.sendPost(RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.ADD_USER_OR_BIND_EXTNUM_API,
+                        JSONObject.toJSONString(aiSipCallUser)),
+                AiSipCallUser.class,
+                "新增aiSIP外呼用户"
+        );
+        if (remoteUser == null || remoteUser.getUserId() == null) {
+            throw new ServiceException("新增aiSIP外呼用户失败:远程返回数据为空");
+        }
+
+        aiSipCallUser.setUserId(remoteUser.getUserId());
+        if (StringUtils.isNotBlank(remoteUser.getPhonenumber())) {
+            aiSipCallUser.setPhonenumber(remoteUser.getPhonenumber());
+        }
+        if (remoteUser.getCreateTime() != null) {
+            aiSipCallUser.setCreateTime(remoteUser.getCreateTime());
         }
-        return 0;
+        if (StringUtils.isNotBlank(remoteUser.getExtPass())) {
+            aiSipCallUser.setExtPass(remoteUser.getExtPass());
+        }
+
+        if (bindBizUser(aiSipCallUser, remoteUser.getUserId()) <= 0) {
+            throw new ServiceException("新增aiSIP外呼用户失败:绑定业务用户失败");
+        }
+        return baseMapper.insertAiSipCallUser(aiSipCallUser);
     }
 
-    /**
-     * 修改sip用户信息
-     *
-     * @param aiSipCallUser sip用户信息
-     * @return 结果
-     */
     @Override
-    public int updateAiSipCallUser(AiSipCallUser aiSipCallUser)
-    {
-        //同步修改远程接口
-        String result = RemoteCommon.sendPost(RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.EDIT_USER_OR_UNBING_EXTNUM_API, JSONObject.toJSONString(aiSipCallUser));
-        if(StringUtils.isNotBlank(result)){
-            JSONObject jsonObject = JSONObject.parseObject(result);
-            if(jsonObject.getInteger("code") == 0){
-                return baseMapper.updateAiSipCallUser(aiSipCallUser);
-            }else{
-                log.error("修改aiSIP外呼任务失败:{}", jsonObject.getString("msg"));
-            }
-        }else{
-            log.error("修改aiSIP外呼任务失败:{}", "接口返回为空");
+    public int updateAiSipCallUser(AiSipCallUser aiSipCallUser) {
+        Objects.requireNonNull(aiSipCallUser, "aiSipCallUser");
+        if ("0".equals(aiSipCallUser.getUserSource())) {
+            aiSipCallUser.setCompanyName(buildCompanyUserCode(aiSipCallUser.getCompanyId(), "修改aiSIP外呼用户"));
+        }
+        if (StringUtils.isBlank(aiSipCallUser.getGatewayIds())) {
+            aiSipCallUser.setGatewayIds("");
+        }
+
+        JSONObject json = RemoteApiHelper.parseSuccess(
+                RemoteCommon.sendPost(RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.EDIT_USER_OR_UNBING_EXTNUM_API,
+                        JSONObject.toJSONString(aiSipCallUser)),
+                "修改aiSIP外呼用户"
+        );
+        // 与远程保持一致:无论 extPass 是否为空都同步,避免本地密码残留
+        AiSipCallUser remoteUser = RemoteApiHelper.toObject(json.get("data"), AiSipCallUser.class);
+        if (remoteUser != null) {
+            aiSipCallUser.setExtPass(remoteUser.getExtPass());
         }
-        return 0;
+        return baseMapper.updateAiSipCallUser(aiSipCallUser);
     }
 
-    /**
-     * 批量删除sip用户信息
-     *
-     * @param userIds 需要删除的sip用户信息主键
-     * @return 结果
-     */
     @Override
-    public int deleteAiSipCallUserByUserIds(Long[] userIds)
-    {
-        return baseMapper.deleteAiSipCallUserByUserIds(userIds);
+    public int deleteAiSipCallUserByUserIds(Long[] userIds) {
+        if (userIds == null || userIds.length == 0 || userIds[0] == null) {
+            throw new ServiceException("删除aiSIP外呼用户失败:用户ID为空");
+        }
+        AiSipCallUser aiSipCallUser = this.selectAiSipCallUserByUserId(userIds[0]);
+        if (aiSipCallUser == null) {
+            throw new ServiceException("删除aiSIP外呼用户失败:用户不存在,用户id=" + userIds[0]);
+        }
+
+        if ("0".equals(aiSipCallUser.getUserSource())) {
+            aiSipCallUser.setUserCode(buildCompanyUserCode(aiSipCallUser.getCompanyId(), "删除aiSIP外呼用户"));
+        } else {
+            aiSipCallUser.setUserCode("");
+        }
+
+        // 远程删除成功后,必须清理本地数据,避免本地残留无效坐席
+        RemoteApiHelper.parseSuccess(
+                RemoteCommon.sendPost(RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.DELETE_USER_OR_UNBING_EXTNUM_API,
+                        JSONObject.toJSONString(aiSipCallUser)),
+                "删除aiSIP外呼用户"
+        );
+
+        int deleted = baseMapper.deleteAiSipCallUserByUserId(userIds[0]);
+        int unbound = unbindBizUser(aiSipCallUser);
+        if (unbound <= 0) {
+            log.error("删除aiSIP外呼用户后解绑业务用户失败, userId={}, userSource={}",
+                    userIds[0], aiSipCallUser.getUserSource());
+        }
+        if (deleted <= 0) {
+            throw new ServiceException("删除aiSIP外呼用户失败:本地删除失败");
+        }
+        return deleted;
     }
 
-    /**
-     * 删除sip用户信息信息
-     *
-     * @param userId sip用户信息主键
-     * @return 结果
-     */
     @Override
-    public int deleteAiSipCallUserByUserId(Long userId)
-    {
+    public int deleteAiSipCallUserByUserId(Long userId) {
         return baseMapper.deleteAiSipCallUserByUserId(userId);
     }
 
     @Override
     public AjaxResult getUnBindExtnum() {
-        //获取未使用的分机号远程接口
-        String result = RemoteCommon.sendGet(RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.QUERY_UN_BIND_EXTNUM_API);
-        if(StringUtils.isNotBlank(result)){
-            JSONObject jsonObject = JSONObject.parseObject(result);
-            if(jsonObject.getInteger("code") == 0){
-                return JSONObject.parseObject(result, AjaxResult.class);
-            }else{
-                log.error("查询aiSIP外呼未绑定分机失败:{}", jsonObject.getString("msg"));
+        return RemoteApiHelper.toAjaxResult(
+                () -> RemoteCommon.sendGet(RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.QUERY_UN_BIND_EXTNUM_API),
+                "查询aiSIP外呼未绑定分机"
+        );
+    }
+
+    @Override
+    public AjaxResult getToolbarBasicParam(Map<String, String> param) {
+        // 复制为可变 Map,避免外部传入不可变集合时 put 失败
+        Map<String, String> safeParam = new HashMap<>();
+        if (param != null) {
+            safeParam.putAll(param);
+        }
+        if (StringUtils.isBlank(safeParam.get("myGateway"))) {
+            safeParam.put("wgName", isMyGateway ? manualGatewayPrefix : publicGatewayPrefix);
+        }
+        String body = JSONObject.toJSONString(safeParam);
+        return RemoteApiHelper.toAjaxResult(
+                () -> RemoteCommon.sendPost(RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.PHONEBAR_PARAMS_API, body),
+                "获取电话工具条网关列表"
+        );
+    }
+
+    @Override
+    public TableDataInfo getUnBindExtnumPage(CcExtNumVo ccExtNum) {
+        String body = JSONObject.toJSONString(ccExtNum);
+        return RemoteApiHelper.toTableDataInfo(
+                () -> RemoteCommon.sendPost(RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.QUERY_UN_BIND_EXTNUM_PAGE_API, body),
+                "查询aiSIP外呼未绑定分机分页"
+        );
+    }
+
+    @Override
+    public AjaxResult companyBindExtNum(String companyName, String sipExtNumIds) {
+        Map<String, String> param = new HashMap<>(2);
+        param.put("companyName", manualGatewayPrefix + "_" + companyPrefix + "_" + companyName);
+        param.put("sipExtNumIds", sipExtNumIds);
+        String body = JSONObject.toJSONString(param);
+        return RemoteApiHelper.toAjaxResult(
+                () -> RemoteCommon.sendPost(RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.QUERY_UN_BIND_EXTNUM_COMPANY_API, body),
+                "公司绑定分机号"
+        );
+    }
+
+    @Override
+    public TableDataInfo getSipExtDetail(CcExtNumVo ccExtNum) {
+        Objects.requireNonNull(ccExtNum, "ccExtNum");
+        List<Long> sipExtNumList = ccExtNum.getSipExtNumList();
+        Set<Long> extNumSet = CollectionUtils.isEmpty(sipExtNumList) ? null : new HashSet<>(sipExtNumList);
+
+        if (StringUtils.isNotBlank(ccExtNum.getUserCode())) {
+            ccExtNum.setSipExtNumList(null);
+        }
+
+        TableDataInfo dataInfo = RemoteApiHelper.toTableDataInfo(
+                () -> RemoteCommon.sendPost(RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.QUERY_EXTNUM_PAGE_API,
+                        JSONObject.toJSONString(ccExtNum)),
+                "查询分机详细信息"
+        );
+
+        if (StringUtils.isNotBlank(ccExtNum.getUserCode()) && !CollectionUtils.isEmpty(extNumSet)
+                && !CollectionUtils.isEmpty(dataInfo.getRows())) {
+            List<Object> filteredRows = dataInfo.getRows().stream()
+                    .filter(row -> {
+                        Long extNum = extractExtNum(row);
+                        return extNum != null && extNumSet.contains(extNum);
+                    })
+                    .collect(Collectors.toList());
+            dataInfo.setRows(filteredRows);
+            dataInfo.setTotal(filteredRows.size());
+        }
+        return dataInfo;
+    }
+
+    @Override
+    public AjaxResult getCompanyUnBindExtnum(Long companyId) {
+        CcExtNumVo ccExtNum = new CcExtNumVo();
+        ccExtNum.setUserCode(buildCompanyUserCode(companyId, "查询公司分机号"));
+        String body = JSONObject.toJSONString(ccExtNum);
+        return RemoteApiHelper.toAjaxResult(
+                () -> RemoteCommon.sendPost(RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.QUERY_COMPANY_UN_BIND_EXTNUM_API, body),
+                "查询公司分机号"
+        );
+    }
+    @Override
+    public AjaxResult companyUnbindExtNum(List<Company> companyList) {
+        if (CollectionUtils.isEmpty(companyList)) {
+            log.error("批量解绑分机:公司列表为空");
+            return AjaxResult.error("公司列表不能为空");
+        }
+
+        List<Map<String, Object>> successResults = new ArrayList<>();
+        List<String> failedCompanies = new ArrayList<>();
+        int totalExtNumCount = 0;
+
+        log.info("开始批量解绑分机,共 {} 家公司", companyList.size());
+
+        for (Company company : companyList) {
+            if (company == null) {
+                failedCompanies.add("未知公司(对象为空)");
+                continue;
+            }
+            String companyName = company.getCompanyName();
+            Long companyId = company.getCompanyId();
+            
+            try {
+                // 跳过未绑定分机的公司
+                if (StringUtils.isBlank(company.getSipExtNumIds())) {
+                    log.info("公司 [{}] (ID:{}) 未绑定分机号,跳过", companyName, companyId);
+                    continue;
+                }
+
+                // 解析分机号集合
+                Set<Long> extNumSet = parseExtNumIds(company.getSipExtNumIds());
+                if (extNumSet.isEmpty()) {
+                    log.info("公司 [{}] (ID:{}) 没有有效的分机号", companyName, companyId);
+                    failedCompanies.add(companyName + "(无有效分机号)");
+                    continue;
+                }
+
+                log.info("处理公司 [{}] (ID:{}),待解绑分机数: {}", companyName, companyId, extNumSet.size());
+
+                // 调用远程接口释放分机
+                try {
+                    RemoteApiHelper.parseSuccess(
+                            RemoteCommon.sendPost(
+                                    RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.CONPANY_BATCH_UNBING_EXTNUM_API,
+                                    JSONObject.toJSONString(extNumSet)
+                            ),
+                            "公司解绑分机"
+                    );
+                } catch (ServiceException e) {
+                    failedCompanies.add(companyName + "(" + e.getMessage() + ")");
+                    continue;
+                }
+
+                // 处理销售坐席解绑(传入分机号,而非用户ID)
+                handleSalesUnbind(extNumSet);
+
+                // 更新公司分机号信息
+                AjaxResult updateResult = updateCompanyExtNums(companyId, company.getSipExtNumIds(), extNumSet);
+                if (isAjaxSuccess(updateResult)) {
+                    Map<String, Object> successInfo = new HashMap<>();
+                    successInfo.put("companyName", companyName);
+                    successInfo.put("companyId", companyId);
+                    successInfo.put("removedCount", extNumSet.size());
+                    successResults.add(successInfo);
+                    totalExtNumCount += extNumSet.size();
+                    log.info("公司 [{}] (ID:{}) 解绑成功,移除 {} 个分机", companyName, companyId, extNumSet.size());
+                } else {
+                    String msg = (String) updateResult.get(AjaxResult.MSG_TAG);
+                    log.info("公司 [{}] (ID:{}) 更新数据库失败:{}", companyName, companyId, msg);
+                    failedCompanies.add(companyName + "(更新数据库失败)");
+                }
+                
+            } catch (Exception e) {
+                log.info("公司 [{}] (ID:{}) 解绑分机时发生异常", companyName, companyId, e);
+                failedCompanies.add(companyName + "(异常: " + e.getMessage() + ")");
             }
-        }else{
-            log.error("查询aiSIP外呼未绑定分机失败:{}", "接口返回为空");
         }
-        return AjaxResult.error();
+
+        // 构建返回结果
+        Map<String, Object> resultMap = new HashMap<>();
+        resultMap.put("successResults", successResults);
+        resultMap.put("failedCompanies", failedCompanies);
+        resultMap.put("totalProcessed", companyList.size());
+        resultMap.put("successCount", successResults.size());
+        resultMap.put("failedCount", failedCompanies.size());
+        resultMap.put("totalRemovedExtNums", totalExtNumCount);
+
+        String summary = String.format("批量解绑完成:总计%d家,成功%d家(共移除%d个分机),失败%d家",
+            companyList.size(), successResults.size(), totalExtNumCount, failedCompanies.size());
+        log.info(summary);
+
+        return failedCompanies.isEmpty() 
+            ? AjaxResult.success("所有公司解绑成功", resultMap)
+            : AjaxResult.success(summary, resultMap);
     }
 
     @Override
-    public AjaxResult getToolbarBasicParam(Map<String,String> param) {
-        //先使用远程网关
-        String result = RemoteCommon.sendPost(RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.PHONEBAR_PARAMS_API,JSONObject.toJSONString(param));
-        if(StringUtils.isNotBlank(result)){
-            JSONObject jsonObject = JSONObject.parseObject(result);
-            if(jsonObject.getInteger("code") == 0){
-                return JSONObject.parseObject(result, AjaxResult.class);
-            }else{
-                log.error("获取电话工具条的网关列表接口失败:{}", jsonObject.getString("msg"));
+    public AjaxResult companyBatchUnbindSipExt(Long companyId, List<CcExtNumVo> ccExtNums) {
+        // 参数校验
+        if (companyId == null) {
+            log.error("批量解绑分机:公司ID为空");
+            return AjaxResult.error("公司ID不能为空");
+        }
+        if (CollectionUtils.isEmpty(ccExtNums)) {
+            log.error("批量解绑分机:公司ID={},分机列表为空", companyId);
+            return AjaxResult.error("分机列表不能为空");
+        }
+
+        log.info("开始批量解绑分机:公司ID={},待处理分机数={}", companyId, ccExtNums.size());
+
+        // 获取公司信息并验证
+        Company company = companyMapper.selectCompanyById(companyId);
+        if (company == null) {
+            log.error("批量解绑分机失败:公司ID={} 不存在", companyId);
+            return AjaxResult.error("公司不存在");
+        }
+        if (StringUtils.isBlank(company.getSipExtNumIds())) {
+            log.error("批量解绑分机失败:公司 [{}] (ID:{}) 未绑定分机", company.getCompanyName(), companyId);
+            return AjaxResult.error("公司未绑定分机");
+        }
+
+        // 收集所有待解绑的分机号(去重)
+        Set<Long> extNumSet = ccExtNums.stream()
+                .filter(vo -> vo != null && vo.getExtNum() != null)
+                .map(CcExtNumVo::getExtNum)
+                .collect(Collectors.toSet());
+
+        if (extNumSet.isEmpty()) {
+            log.error("批量解绑分机:公司 [{}] (ID:{}) 没有有效的分机号", company.getCompanyName(), companyId);
+            return AjaxResult.error("没有有效的分机号");
+        }
+
+        log.info("公司 [{}] (ID:{}) 待解绑分机数:{},分机列表:{}", 
+            company.getCompanyName(), companyId, extNumSet.size(), extNumSet);
+
+        // 调用远程接口释放公司分机
+        try {
+            RemoteApiHelper.parseSuccess(
+                    RemoteCommon.sendPost(
+                            RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.CONPANY_BATCH_UNBING_EXTNUM_API,
+                            JSONObject.toJSONString(extNumSet)
+                    ),
+                    "解绑分机"
+            );
+        } catch (ServiceException e) {
+            return AjaxResult.error(e.getMessage());
+        }
+
+        log.info("公司 [{}] (ID:{}) 远程接口解绑成功", company.getCompanyName(), companyId);
+
+        // 处理销售坐席解绑
+        Set<Long> salesExtNums = ccExtNums.stream()
+                .filter(vo -> vo != null && StringUtils.isNotBlank(vo.getUserCode()) && vo.getExtNum() != null)
+                .map(CcExtNumVo::getExtNum)
+                .collect(Collectors.toSet());
+
+        if (!salesExtNums.isEmpty()) {
+            log.info("公司 [{}] (ID:{}) 发现 {} 个销售绑定的分机,开始清理", 
+                company.getCompanyName(), companyId, salesExtNums.size());
+            handleSalesUnbind(salesExtNums);
+        }
+
+        // 更新公司分机号信息
+        AjaxResult updateResult = updateCompanyExtNums(companyId, company.getSipExtNumIds(), extNumSet);
+        if (isAjaxSuccess(updateResult)) {
+            log.info("公司 [{}] (ID:{}) 批量解绑成功,共移除 {} 个分机",
+                    company.getCompanyName(), companyId, extNumSet.size());
+            return AjaxResult.success("批量解绑成功,共移除 " + extNumSet.size() + " 个分机");
+        }
+        String msg = (String) updateResult.get(AjaxResult.MSG_TAG);
+        log.info("公司 [{}] (ID:{}) 更新数据库失败:{}", company.getCompanyName(), companyId, msg);
+        return AjaxResult.error("更新分机信息失败:" + msg);
+    }
+    
+    /**
+     * 处理销售坐席解绑
+     */
+    private void handleSalesUnbind(Set<Long> salesExtNums) {
+        if (CollectionUtils.isEmpty(salesExtNums)) {
+            return;
+        }
+        // 查询销售绑定的坐席ID列表
+        List<Long> aiSipCallUserIds = baseMapper.selectByExtNumList(salesExtNums);
+        if (CollectionUtils.isEmpty(aiSipCallUserIds)) {
+            log.info("未找到销售绑定的分机号对应的坐席id, extNums={}", salesExtNums);
+            return;
+        }
+        log.info("开始清理销售坐席绑定, 坐席数={}", aiSipCallUserIds.size());
+
+        // 清空companyUser的坐席字段
+        int cleanCount = companyUserMapper.cleanBindAiSipCallUserId(aiSipCallUserIds);
+        if (cleanCount <= 0) {
+            log.error("清空销售坐席绑定关系失败, userIds={}", aiSipCallUserIds);
+            return;
+        }
+
+        // 删除销售坐席
+        Long[] userIdsArray = aiSipCallUserIds.toArray(new Long[0]);
+        int deleteCount = baseMapper.deleteAiSipCallUserByUserIds(userIdsArray);
+        if (deleteCount <= 0) {
+            log.error("删除销售坐席失败, userIds={}", aiSipCallUserIds);
+        }
+    }
+    
+    /**
+     * 更新公司分机号信息
+     */
+    private AjaxResult updateCompanyExtNums(Long companyId, String currentExtNumIds, Set<Long> unbindExtNums) {
+        if (companyId == null) {
+            log.error("更新公司分机号失败:公司ID为空");
+            return AjaxResult.error("公司ID不能为空");
+        }
+        if (StringUtils.isBlank(currentExtNumIds)) {
+            log.error("更新公司分机号:公司ID={} 当前未绑定分机", companyId);
+            return AjaxResult.success("公司未绑定分机,无需更新");
+        }
+        if (CollectionUtils.isEmpty(unbindExtNums)) {
+            log.error("更新公司分机号:公司ID={} 待解绑分机集合为空", companyId);
+            return AjaxResult.success("没有需要解绑的分机");
+        }
+
+        // 解析当前公司分机号
+        Set<Long> companyExtNumSet = parseExtNumIds(currentExtNumIds);
+        
+        // 过滤出剩余的分机号并排序
+        List<Long> remainingExtNums = companyExtNumSet.stream()
+                .filter(extNum -> !unbindExtNums.contains(extNum))
+                .sorted()
+                .collect(Collectors.toList());
+        
+        // 构建更新后的分机号字符串
+        String remainingExtNumIds = remainingExtNums.isEmpty() 
+                ? null 
+                : remainingExtNums.stream()
+                        .map(String::valueOf)
+                        .collect(Collectors.joining(","));
+
+        log.info("公司ID={} 更新分机号:原{}个,解绑{}个,剩余{}个", 
+            companyId, companyExtNumSet.size(), unbindExtNums.size(), remainingExtNums.size());
+        
+        // 更新数据库
+        int updateCount = companyMapper.updateCompanyBindSipExtNum(companyId, remainingExtNumIds);
+        if (updateCount > 0) {
+            log.info("公司ID={} 更新分机号成功,剩余分机:{}", companyId, remainingExtNumIds);
+            return AjaxResult.success("批量解绑公司分机成功");
+        } else {
+            log.info("公司ID={} 更新分机号失败,SQL影响行数为0", companyId);
+            return AjaxResult.error("批量解绑公司分机号失败");
+        }
+    }
+
+
+
+
+
+    /**
+     * 解析分机号ID字符串为Set集合
+     */
+    private Set<Long> parseExtNumIds(String extNumIds) {
+        if (StringUtils.isBlank(extNumIds)) {
+            return Collections.emptySet();
+        }
+        return Arrays.stream(extNumIds.split(","))
+                .filter(StringUtils::isNotBlank)
+                .map(String::trim)
+                .map(id -> {
+                    try {
+                        return Long.parseLong(id);
+                    } catch (NumberFormatException e) {
+                        log.warn("非法分机号忽略: {}", id);
+                        return null;
+                    }
+                })
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+    }
+
+    /**
+     * 从行对象中提取分机号(兼容 JSONObject / Map / VO)
+     */
+    private Long extractExtNum(Object row) {
+        if (row instanceof CcExtNumVo) {
+            return ((CcExtNumVo) row).getExtNum();
+        }
+        if (row instanceof JSONObject) {
+            return ((JSONObject) row).getLong("extNum");
+        }
+        if (row instanceof Map) {
+            Object value = ((Map<?, ?>) row).get("extNum");
+            if (value == null) {
+                return null;
             }
+            if (value instanceof Number) {
+                return ((Number) value).longValue();
+            }
+            try {
+                return Long.parseLong(String.valueOf(value));
+            } catch (NumberFormatException e) {
+                return null;
+            }
+        }
+        return null;
+    }
+
+    private String resolveLoginNamePrefix(String userSource) {
+        if ("0".equals(userSource)) {
+            return manualGatewayPrefix + "_" + companyUserPrefix + "_";
+        }
+        if ("2".equals(userSource)) {
+            return manualGatewayPrefix + "_" + doctorPrefix + "_";
+        }
+        return manualGatewayPrefix + "_" + adminUserPrefix + "_";
+    }
+
+    private String buildCompanyUserCode(Long companyId, String apiDesc) {
+        if (companyId == null) {
+            throw new ServiceException(apiDesc + "失败:公司ID为空");
+        }
+        Company company = companyMapper.selectCompanyById(companyId);
+        if (company == null || StringUtils.isBlank(company.getCompanyName())) {
+            throw new ServiceException(apiDesc + "失败:当前员工未绑定销售公司");
         }
-        return AjaxResult.error();
+        return manualGatewayPrefix + "_" + companyPrefix + "_" + company.getCompanyName();
     }
 
+    private int bindBizUser(AiSipCallUser user, Long remoteUserId) {
+        String userSource = StringUtils.defaultString(user.getUserSource());
+        if ("0".equals(userSource)) {
+            return companyUserMapper.updateCompanyUserByAiSipCall(user.getCompanyUserId(), remoteUserId);
+        }
+        if ("2".equals(userSource)) {
+            return fsDoctorMapper.updateDoctorByAiSipCall(user.getDoctorId(), remoteUserId);
+        }
+        return sysUserMapper.updateSysUserByAiSipCall(user.getSysUserId(), remoteUserId);
+    }
+
+    private int unbindBizUser(AiSipCallUser user) {
+        String userSource = StringUtils.defaultString(user.getUserSource());
+        if ("0".equals(userSource)) {
+            return companyUserMapper.updateCompanyUserByAiSipCall(user.getCompanyUserId(), null);
+        }
+        if ("2".equals(userSource)) {
+            return fsDoctorMapper.updateDoctorByAiSipCall(user.getDoctorId(), null);
+        }
+        return sysUserMapper.updateSysUserByAiSipCall(user.getSysUserId(), null);
+    }
+
+    /** 兼容远程 code=0 与本地 AjaxResult.success(code=200) */
+    private boolean isAjaxSuccess(AjaxResult result) {
+        Integer code = result == null ? null : (Integer) result.get(AjaxResult.CODE_TAG);
+        return RemoteApiHelper.isSuccess(code) || Integer.valueOf(200).equals(code);
+    }
 }

+ 73 - 65
fs-service/src/main/java/com/fs/aiSipCall/service/impl/AiSipCallVoiceTtsAliyunServiceImpl.java

@@ -6,103 +6,111 @@ import com.fs.aiSipCall.RemoteCommon;
 import com.fs.aiSipCall.domain.AiSipCallVoiceTtsAliyun;
 import com.fs.aiSipCall.mapper.AiSipCallVoiceTtsAliyunMapper;
 import com.fs.aiSipCall.service.IAiSipCallVoiceTtsAliyunService;
-import lombok.extern.slf4j.Slf4j;
-import org.apache.commons.lang3.StringUtils;
+import com.fs.aiSipCall.utils.RemoteApiHelper;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
 import org.springframework.stereotype.Service;
 
 import java.util.List;
+import java.util.Optional;
 
 /**
  * aiSIP外呼阿里云音色Service业务层处理
- * 
+ *
  * @author fs
  * @date 2026-03-06
  */
-@Slf4j
 @Service
 public class AiSipCallVoiceTtsAliyunServiceImpl extends ServiceImpl<AiSipCallVoiceTtsAliyunMapper, AiSipCallVoiceTtsAliyun> implements IAiSipCallVoiceTtsAliyunService {
 
-    /**
-     * 查询aiSIP外呼阿里云音色
-     * 
-     * @param id aiSIP外呼阿里云音色主键
-     * @return aiSIP外呼阿里云音色
-     */
+    private static final String PREFIX = RemoteCommon.REMOTE_ADDERSS_PREFIX;
+
+    @Override
+    public TableDataInfo remoteList(AiSipCallVoiceTtsAliyun aiSipCallVoiceTtsAliyun) {
+        AiSipCallVoiceTtsAliyun query = Optional.ofNullable(aiSipCallVoiceTtsAliyun).orElseGet(AiSipCallVoiceTtsAliyun::new);
+        // 公用音色且是启用状态
+        query.setPriority(1L);
+        query.setVoiceEnabled(1L);
+        String body = JSONObject.toJSONString(query);
+        return RemoteApiHelper.toTableDataInfo(
+                () -> RemoteCommon.sendPost(PREFIX + RemoteCommon.VOICECODE_PAGE_LIST_API, body),
+                "获取音色分页"
+        );
+    }
+
+    @Override
+    public AjaxResult allTtsProvider() {
+        return remoteGet(RemoteCommon.ALL_TTS_PROVIDER_API, "查询全部ttsprovider列表");
+    }
+
+    @Override
+    public AjaxResult allAsrProvider() {
+        return remoteGet(RemoteCommon.ALL_ASR_PROVIDER_API, "查询全部asrprovider列表");
+    }
+
+    @Override
+    public AjaxResult getTtsLanguages(String voiceSource) {
+        return remoteGet(RemoteCommon.ALL_TTS_LANGUAGE_API + "?voiceSource=" + RemoteApiHelper.urlEncode(voiceSource),
+                "根据tts厂商获取支持语言");
+    }
+
+    @Override
+    public AjaxResult getAsrLanguages(String asrProvider) {
+        return remoteGet(RemoteCommon.ALL_ASR_LANGUAGE_API + "?asrProvider=" + RemoteApiHelper.urlEncode(asrProvider),
+                "根据asr厂商获取语言");
+    }
+
+    @Override
+    public AjaxResult getAsrModels(String asrProvider, String asrLanguageCode) {
+        String api = RemoteCommon.ALL_ASR_MODELS_API
+                + "?asrProvider=" + RemoteApiHelper.urlEncode(asrProvider)
+                + "&asrLanguageCode=" + RemoteApiHelper.urlEncode(asrLanguageCode);
+        return remoteGet(api, "根据asr厂商和语言获取asr模型列表");
+    }
+
+    @Override
+    public AjaxResult getTtsModels(String voiceSource, String ttsLanguageCode) {
+        String api = RemoteCommon.ALL_TTS_MODELS_API
+                + "?voiceSource=" + RemoteApiHelper.urlEncode(voiceSource)
+                + "&ttsLanguageCode=" + RemoteApiHelper.urlEncode(ttsLanguageCode);
+        return remoteGet(api, "根据tts厂商和语言获取tts模型列表");
+    }
+
     @Override
-    public AiSipCallVoiceTtsAliyun selectAiSipCallVoiceTtsAliyunById(Long id)
-    {
+    public AiSipCallVoiceTtsAliyun selectAiSipCallVoiceTtsAliyunById(Long id) {
         return baseMapper.selectAiSipCallVoiceTtsAliyunById(id);
     }
 
-    /**
-     * 查询aiSIP外呼阿里云音色列表
-     * 
-     * @param aiSipCallVoiceTtsAliyun aiSIP外呼阿里云音色
-     * @return aiSIP外呼阿里云音色
-     */
     @Override
-    public List<AiSipCallVoiceTtsAliyun> selectAiSipCallVoiceTtsAliyunList(AiSipCallVoiceTtsAliyun aiSipCallVoiceTtsAliyun)
-    {
-        //先使用远程网关
-        String result = RemoteCommon.sendGet(RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.VOICECODE_LIST_API);
-        if(StringUtils.isNotBlank(result)){
-            JSONObject jsonObject = JSONObject.parseObject(result);
-            if(jsonObject.getInteger("code") == 0){
-                String data = jsonObject.getString("data");
-                return JSONObject.parseArray(data, AiSipCallVoiceTtsAliyun.class);
-            }else{
-                log.error("获取音色接口失败:{}", jsonObject.getString("msg"));
-            }
-        }
-        return null;
-//        return baseMapper.selectAiSipCallVoiceTtsAliyunList(aiSipCallVoiceTtsAliyun);
+    public List<AiSipCallVoiceTtsAliyun> selectAiSipCallVoiceTtsAliyunList(AiSipCallVoiceTtsAliyun aiSipCallVoiceTtsAliyun) {
+        return RemoteApiHelper.invoke(
+                () -> RemoteCommon.sendGet(PREFIX + RemoteCommon.VOICECODE_LIST_API),
+                result -> RemoteApiHelper.parseArrayData(result, AiSipCallVoiceTtsAliyun.class, "获取音色"),
+                "获取音色"
+        );
     }
 
-    /**
-     * 新增aiSIP外呼阿里云音色
-     * 
-     * @param aiSipCallVoiceTtsAliyun aiSIP外呼阿里云音色
-     * @return 结果
-     */
     @Override
-    public int insertAiSipCallVoiceTtsAliyun(AiSipCallVoiceTtsAliyun aiSipCallVoiceTtsAliyun)
-    {
+    public int insertAiSipCallVoiceTtsAliyun(AiSipCallVoiceTtsAliyun aiSipCallVoiceTtsAliyun) {
         return baseMapper.insertAiSipCallVoiceTtsAliyun(aiSipCallVoiceTtsAliyun);
     }
 
-    /**
-     * 修改aiSIP外呼阿里云音色
-     * 
-     * @param aiSipCallVoiceTtsAliyun aiSIP外呼阿里云音色
-     * @return 结果
-     */
     @Override
-    public int updateAiSipCallVoiceTtsAliyun(AiSipCallVoiceTtsAliyun aiSipCallVoiceTtsAliyun)
-    {
+    public int updateAiSipCallVoiceTtsAliyun(AiSipCallVoiceTtsAliyun aiSipCallVoiceTtsAliyun) {
         return baseMapper.updateAiSipCallVoiceTtsAliyun(aiSipCallVoiceTtsAliyun);
     }
 
-    /**
-     * 批量删除aiSIP外呼阿里云音色
-     * 
-     * @param ids 需要删除的aiSIP外呼阿里云音色主键
-     * @return 结果
-     */
     @Override
-    public int deleteAiSipCallVoiceTtsAliyunByIds(Long[] ids)
-    {
+    public int deleteAiSipCallVoiceTtsAliyunByIds(Long[] ids) {
         return baseMapper.deleteAiSipCallVoiceTtsAliyunByIds(ids);
     }
 
-    /**
-     * 删除aiSIP外呼阿里云音色信息
-     * 
-     * @param id aiSIP外呼阿里云音色主键
-     * @return 结果
-     */
     @Override
-    public int deleteAiSipCallVoiceTtsAliyunById(Long id)
-    {
+    public int deleteAiSipCallVoiceTtsAliyunById(Long id) {
         return baseMapper.deleteAiSipCallVoiceTtsAliyunById(id);
     }
+
+    private AjaxResult remoteGet(String apiPath, String apiDesc) {
+        return RemoteApiHelper.toAjaxResult(() -> RemoteCommon.sendGet(PREFIX + apiPath), apiDesc);
+    }
 }

+ 239 - 0
fs-service/src/main/java/com/fs/aiSipCall/service/impl/ChineseStatutoryHolidayServiceImpl.java

@@ -0,0 +1,239 @@
+package com.fs.aiSipCall.service.impl;
+
+import cn.hutool.http.HttpUtil;
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.fs.aiSipCall.service.IChineseStatutoryHolidayService;
+import com.fs.common.RedisKeyCommon;
+import com.fs.common.core.redis.RedisCache;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.text.SimpleDateFormat;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.time.format.ResolverStyle;
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+/**
+ * 通过公开 API 动态获取中国法定节假日,Redis + 本地内存按年缓存。
+ * <p>
+ * Redis 存按日期升序排列的 JSON 数组,便于人工查看;内存用 Set 做 O(1) 命中判断。
+ */
+@Slf4j
+@Service
+public class ChineseStatutoryHolidayServiceImpl implements IChineseStatutoryHolidayService {
+
+    private static final String HOLIDAY_API_URL_TEMPLATE = "https://timor.tech/api/holiday/year/%d";
+    private static final int REDIS_CACHE_DAYS = 30;
+    private static final int API_TIMEOUT_MS = 30_000;
+    private static final int MIN_YEAR = 2000;
+    private static final int MAX_YEAR = 2100;
+
+    private static final Pattern DATE_TEXT_PATTERN = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}$");
+    private static final DateTimeFormatter STRICT_DATE_FORMATTER =
+            DateTimeFormatter.ofPattern("uuuu-MM-dd").withResolverStyle(ResolverStyle.STRICT);
+
+    private static final ThreadLocal<SimpleDateFormat> DATE_FORMAT =
+            ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd", Locale.CHINA));
+
+    private final ConcurrentHashMap<Integer, Set<String>> memoryCache = new ConcurrentHashMap<>();
+
+    @Autowired
+    private RedisCache redisCache;
+
+    @Override
+    public boolean isStatutoryHoliday(Calendar calendar) {
+        if (calendar == null) {
+            return false;
+        }
+        int year = calendar.get(Calendar.YEAR);
+        if (!isValidYear(year)) {
+            return false;
+        }
+        String dateStr = formatDate(calendar);
+        if (StringUtils.isBlank(dateStr) || !belongsToYear(dateStr, year)) {
+            // 防跨年:日期字符串年份必须与当前校验年份一致
+            return false;
+        }
+        return getHolidayDatesOfYear(year).contains(dateStr);
+    }
+
+    private Set<String> getHolidayDatesOfYear(int year) {
+        if (!isValidYear(year)) {
+            log.warn("[法定节假日] 年份非法: {}", year);
+            return Collections.emptySet();
+        }
+
+        Set<String> cached = memoryCache.get(year);
+        if (cached != null) {
+            return cached;
+        }
+
+        String redisKey = redisKeyOfYear(year);
+        List<String> sortedFromRedis = filterByYear(readSortedListFromRedis(redisKey), year);
+        if (!sortedFromRedis.isEmpty()) {
+            Set<String> holidaySet = toUnmodifiableSet(sortedFromRedis);
+            memoryCache.put(year, holidaySet);
+            return holidaySet;
+        }
+
+        List<String> sortedFromApi = filterByYear(fetchSortedDatesFromApi(year), year);
+        if (sortedFromApi.isEmpty()) {
+            log.warn("[法定节假日] {} 年数据获取失败且无缓存,法定节假日规则暂不生效", year);
+            return Collections.emptySet();
+        }
+
+        Set<String> holidaySet = toUnmodifiableSet(sortedFromApi);
+        memoryCache.put(year, holidaySet);
+        writeSortedListToRedis(redisKey, sortedFromApi, year);
+        return holidaySet;
+    }
+
+    /** 仅保留属于指定年份的日期,避免跨年缓存错误命中 */
+    private List<String> filterByYear(List<String> dates, int year) {
+        if (dates == null || dates.isEmpty()) {
+            return Collections.emptyList();
+        }
+        String yearPrefix = year + "-";
+        List<String> filtered = dates.stream()
+                .filter(StringUtils::isNotBlank)
+                .filter(d -> d.startsWith(yearPrefix))
+                .filter(this::isValidDateText)
+                .distinct()
+                .sorted()
+                .collect(Collectors.toList());
+        if (filtered.size() != dates.size()) {
+            log.warn("[法定节假日] {} 年数据含跨年或非法日期,已过滤 {} -> {}",
+                    year, dates.size(), filtered.size());
+        }
+        return filtered;
+    }
+
+    private static boolean belongsToYear(String dateStr, int year) {
+        return dateStr != null && dateStr.length() >= 4 && dateStr.startsWith(year + "-");
+    }
+
+    private List<String> readSortedListFromRedis(String redisKey) {
+        try {
+            String json = redisCache.getCacheObject(redisKey);
+            if (StringUtils.isBlank(json)) {
+                return Collections.emptyList();
+            }
+            JSONArray array = JSON.parseArray(json);
+            if (array == null || array.isEmpty()) {
+                return Collections.emptyList();
+            }
+            return sortValidDates(IntStream.range(0, array.size())
+                    .mapToObj(array::getString)
+                    .collect(Collectors.toList()));
+        } catch (Exception e) {
+            log.warn("[法定节假日] 读取 Redis 缓存失败, key={}", redisKey, e);
+            return Collections.emptyList();
+        }
+    }
+
+    private void writeSortedListToRedis(String redisKey, List<String> sortedDates, int year) {
+        try {
+            redisCache.setCacheObject(redisKey, JSON.toJSONString(sortedDates), REDIS_CACHE_DAYS, TimeUnit.DAYS);
+            log.info("[法定节假日] {} 年已缓存 {} 个放假日期(升序): {}", year, sortedDates.size(), sortedDates);
+        } catch (Exception e) {
+            log.warn("[法定节假日] {} 年数据写入 Redis 失败", year, e);
+        }
+    }
+
+    private List<String> fetchSortedDatesFromApi(int year) {
+        String url = String.format(HOLIDAY_API_URL_TEMPLATE, year);
+        try {
+            String response = HttpUtil.get(url, API_TIMEOUT_MS);
+            if (StringUtils.isBlank(response)) {
+                log.warn("[法定节假日] API 响应为空, year={}, url={}", year, url);
+                return Collections.emptyList();
+            }
+            JSONObject root = JSON.parseObject(response);
+            if (root == null || root.getIntValue("code") != 0) {
+                log.warn("[法定节假日] API 返回异常, year={}, response={}", year, abbreviate(response));
+                return Collections.emptyList();
+            }
+            JSONObject holiday = root.getJSONObject("holiday");
+            if (holiday == null || holiday.isEmpty()) {
+                return Collections.emptyList();
+            }
+            List<String> sortedDates = holiday.values().stream()
+                    .filter(JSONObject.class::isInstance)
+                    .map(JSONObject.class::cast)
+                    .filter(day -> Boolean.TRUE.equals(day.getBoolean("holiday")))
+                    .map(day -> day.getString("date"))
+                    .filter(StringUtils::isNotBlank)
+                    .filter(this::isValidDateText)
+                    .distinct()
+                    .sorted()
+                    .collect(Collectors.toList());
+            log.info("[法定节假日] {} 年从 API 加载 {} 个放假日期(升序)", year, sortedDates.size());
+            return sortedDates;
+        } catch (Exception e) {
+            log.warn("[法定节假日] 调用 API 失败, year={}, url={}", year, url, e);
+            return Collections.emptyList();
+        }
+    }
+
+    private List<String> sortValidDates(List<String> dates) {
+        if (dates == null || dates.isEmpty()) {
+            return Collections.emptyList();
+        }
+        return dates.stream()
+                .filter(StringUtils::isNotBlank)
+                .filter(this::isValidDateText)
+                .distinct()
+                .sorted()
+                .collect(Collectors.toList());
+    }
+
+    private static Set<String> toUnmodifiableSet(List<String> sortedDates) {
+        return Collections.unmodifiableSet(new HashSet<>(sortedDates));
+    }
+
+    private boolean isValidDateText(String dateText) {
+        if (!DATE_TEXT_PATTERN.matcher(dateText).matches()) {
+            return false;
+        }
+        try {
+            LocalDate.parse(dateText, STRICT_DATE_FORMATTER);
+            return true;
+        } catch (DateTimeParseException e) {
+            return false;
+        }
+    }
+
+    private static boolean isValidYear(int year) {
+        return year >= MIN_YEAR && year <= MAX_YEAR;
+    }
+
+    private static String redisKeyOfYear(int year) {
+        return RedisKeyCommon.CHINESE_STATUTORY_HOLIDAY_YEAR_KEY + year;
+    }
+
+    private static String formatDate(Calendar cal) {
+        try {
+            return DATE_FORMAT.get().format(cal.getTime());
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    private static String abbreviate(String text) {
+        if (text == null) {
+            return null;
+        }
+        return text.length() <= 200 ? text : text.substring(0, 200) + "...";
+    }
+}

+ 641 - 0
fs-service/src/main/java/com/fs/aiSipCall/support/AiSipAutoCallRuleEvaluator.java

@@ -0,0 +1,641 @@
+package com.fs.aiSipCall.support;
+
+import com.fs.aiSipCall.domain.AiSipAutoCallRules;
+import com.fs.aiSipCall.service.IChineseStatutoryHolidayService;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import java.text.SimpleDateFormat;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.time.format.ResolverStyle;
+import java.util.*;
+import java.util.regex.Pattern;
+
+/**
+ * SIP 自动外呼屏蔽规则评估器。
+ * <p>
+ * 按 rule_one → rule_two → rule_three → rule_four 顺序评估,命中任一即屏蔽:
+ * <ul>
+ *   <li>rule_one:法定节假日屏蔽,0-不启用,1-启用(节假日数据动态获取)</li>
+ *   <li>rule_two:特殊日期屏蔽,多值逗号分隔,格式 YYYY-MM-DD</li>
+ *   <li>rule_three:每周屏蔽,多值逗号分隔,1=周一 … 7=周日</li>
+ *   <li>rule_four:每日时段屏蔽,多值逗号分隔,如 "09:00-12:00,14:00-17:00"(闭区间,不支持跨天)</li>
+ * </ul>
+ */
+@Slf4j
+@Component
+public class AiSipAutoCallRuleEvaluator {
+
+    private static final Pattern DATE_TEXT_PATTERN = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}$");
+    private static final Pattern TIME_TEXT_PATTERN = Pattern.compile("^\\d{1,2}:\\d{1,2}$");
+    private static final DateTimeFormatter STRICT_DATE_FORMATTER =
+            DateTimeFormatter.ofPattern("uuuu-MM-dd").withResolverStyle(ResolverStyle.STRICT);
+
+    private static final ThreadLocal<SimpleDateFormat> DATE_FORMAT =
+            ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd", Locale.CHINA));
+    private static final ThreadLocal<SimpleDateFormat> DATETIME_FORMAT =
+            ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.CHINA));
+
+    private static final String[] WEEK_DAY_NAMES = {"", "周一", "周二", "周三", "周四", "周五", "周六", "周日"};
+
+    @Autowired
+    private IChineseStatutoryHolidayService holidayService;
+
+    public ParsedRule parseRule(AiSipAutoCallRules rule) {
+        ParsedRule parsed = new ParsedRule();
+        parsed.source = rule;
+        if (rule == null) {
+            return parsed;
+        }
+        parsed.holidayEnabled = Integer.valueOf(1).equals(rule.getRuleOne());
+        parsed.specialDates = parseSpecialDates(rule.getRuleTwo(), rule.getId());
+        parsed.weekDays = parseWeekDays(rule.getRuleThree(), rule.getId());
+        parsed.timeRanges = parseTimeRanges(rule.getRuleFour(), rule.getId());
+        return parsed;
+    }
+
+    public ShieldDecision evaluate(ParsedRule parsedRule, Calendar now) {
+        ShieldDecision decision = new ShieldDecision();
+        decision.blocked = false;
+        if (parsedRule == null || parsedRule.source == null || now == null) {
+            return decision;
+        }
+
+        if (parsedRule.holidayEnabled && holidayService.isStatutoryHoliday(now)) {
+            decision.blocked = true;
+            decision.matchedRule = "rule_one";
+            decision.reason = "法定节假日屏蔽";
+            return decision;
+        }
+
+        if (!parsedRule.specialDates.isEmpty()) {
+            String today = formatDate(now);
+            if (StringUtils.isNotBlank(today) && parsedRule.specialDates.contains(today)) {
+                decision.blocked = true;
+                decision.matchedRule = "rule_two";
+                decision.reason = "特殊日期屏蔽(" + today + ")";
+                return decision;
+            }
+        }
+
+        if (!parsedRule.weekDays.isEmpty()) {
+            int weekDay = toChineseWeekDay(now);
+            if (weekDay > 0 && parsedRule.weekDays.contains(weekDay)) {
+                decision.blocked = true;
+                decision.matchedRule = "rule_three";
+                decision.reason = "每周屏蔽(星期" + weekDay + ")";
+                return decision;
+            }
+        }
+
+        if (!parsedRule.timeRanges.isEmpty()) {
+            int currentMinutes = now.get(Calendar.HOUR_OF_DAY) * 60 + now.get(Calendar.MINUTE);
+            for (TimeRange range : parsedRule.timeRanges) {
+                if (range.contains(currentMinutes)) {
+                    decision.blocked = true;
+                    decision.matchedRule = "rule_four";
+                    decision.reason = "时段屏蔽(" + range.label + ")";
+                    return decision;
+                }
+            }
+        }
+        return decision;
+    }
+
+    public static List<String> splitMultiValue(String value) {
+        if (StringUtils.isBlank(value)) {
+            return Collections.emptyList();
+        }
+        String normalized = value.replace(',', ',').replace(';', ';');
+        String[] parts = normalized.split("[,;]");
+        List<String> result = new ArrayList<>(parts.length);
+        for (String part : parts) {
+            if (StringUtils.isNotBlank(part)) {
+                result.add(part.trim());
+            }
+        }
+        return result;
+    }
+
+    private Set<Integer> parseWeekDays(String ruleThree, Long ruleId) {
+        List<String> items = splitMultiValue(ruleThree);
+        if (items.isEmpty()) {
+            return Collections.emptySet();
+        }
+        Set<Integer> weekDays = new HashSet<>();
+        for (String item : items) {
+            if (!item.matches("^\\d+$")) {
+                log.warn("[外呼屏蔽规则] rule_three 配置非法(非整数), ruleId={}, value={}", ruleId, item);
+                continue;
+            }
+            try {
+                int day = Integer.parseInt(item);
+                if (day >= 1 && day <= 7) {
+                    weekDays.add(day);
+                } else {
+                    log.warn("[外呼屏蔽规则] rule_three 星期超出范围(1-7), ruleId={}, value={}", ruleId, item);
+                }
+            } catch (NumberFormatException e) {
+                log.warn("[外呼屏蔽规则] rule_three 配置非法, ruleId={}, value={}", ruleId, item);
+            }
+        }
+        return weekDays;
+    }
+
+    private Set<String> parseSpecialDates(String ruleTwo, Long ruleId) {
+        List<String> items = splitMultiValue(ruleTwo);
+        if (items.isEmpty()) {
+            return Collections.emptySet();
+        }
+        Set<String> dates = new HashSet<>();
+        for (String item : items) {
+            if (!DATE_TEXT_PATTERN.matcher(item).matches()) {
+                log.warn("[外呼屏蔽规则] rule_two 日期格式非法(需 YYYY-MM-DD), ruleId={}, value={}", ruleId, item);
+                continue;
+            }
+            try {
+                LocalDate.parse(item, STRICT_DATE_FORMATTER);
+                dates.add(item);
+            } catch (DateTimeParseException e) {
+                log.warn("[外呼屏蔽规则] rule_two 日期无效, ruleId={}, value={}", ruleId, item);
+            }
+        }
+        return dates;
+    }
+
+    private List<TimeRange> parseTimeRanges(String ruleFour, Long ruleId) {
+        List<String> items = splitMultiValue(ruleFour);
+        if (items.isEmpty()) {
+            return Collections.emptyList();
+        }
+        List<TimeRange> ranges = new ArrayList<>();
+        for (String item : items) {
+            String[] parts = item.split("\\s*-\\s*");
+            if (parts.length != 2) {
+                log.warn("[外呼屏蔽规则] rule_four 时段格式非法(需 HH:mm-HH:mm), ruleId={}, value={}", ruleId, item);
+                continue;
+            }
+            String startText = parts[0].trim();
+            String endText = parts[1].trim();
+            if (!TIME_TEXT_PATTERN.matcher(startText).matches() || !TIME_TEXT_PATTERN.matcher(endText).matches()) {
+                log.warn("[外呼屏蔽规则] rule_four 时间格式非法, ruleId={}, value={}", ruleId, item);
+                continue;
+            }
+            int start = toMinutes(startText);
+            int end = toMinutes(endText);
+            if (start < 0 || end < 0) {
+                log.warn("[外呼屏蔽规则] rule_four 时间值非法, ruleId={}, value={}", ruleId, item);
+                continue;
+            }
+            if (end < start) {
+                log.warn("[外呼屏蔽规则] rule_four 结束时间早于开始时间(不支持跨天), ruleId={}, value={}", ruleId, item);
+                continue;
+            }
+            ranges.add(new TimeRange(start, end, startText + "-" + endText));
+        }
+        return ranges;
+    }
+
+    private static int toMinutes(String hhmm) {
+        if (StringUtils.isBlank(hhmm)) {
+            return -1;
+        }
+        String[] parts = hhmm.split(":");
+        if (parts.length != 2) {
+            return -1;
+        }
+        try {
+            int hour = Integer.parseInt(parts[0].trim());
+            int minute = Integer.parseInt(parts[1].trim());
+            if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
+                return -1;
+            }
+            return hour * 60 + minute;
+        } catch (NumberFormatException e) {
+            return -1;
+        }
+    }
+
+    /** 1=周一 … 7=周日 */
+    private static int toChineseWeekDay(Calendar cal) {
+        switch (cal.get(Calendar.DAY_OF_WEEK)) {
+            case Calendar.MONDAY:
+                return 1;
+            case Calendar.TUESDAY:
+                return 2;
+            case Calendar.WEDNESDAY:
+                return 3;
+            case Calendar.THURSDAY:
+                return 4;
+            case Calendar.FRIDAY:
+                return 5;
+            case Calendar.SATURDAY:
+                return 6;
+            case Calendar.SUNDAY:
+                return 7;
+            default:
+                return 0;
+        }
+    }
+
+    private static String formatDate(Calendar cal) {
+        try {
+            return DATE_FORMAT.get().format(cal.getTime());
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    static Calendar calendarOf(int year, int month, int day, int hour, int minute) {
+        Calendar cal = Calendar.getInstance(Locale.CHINA);
+        cal.set(Calendar.YEAR, year);
+        cal.set(Calendar.MONTH, month - 1);
+        cal.set(Calendar.DAY_OF_MONTH, day);
+        cal.set(Calendar.HOUR_OF_DAY, hour);
+        cal.set(Calendar.MINUTE, minute);
+        cal.set(Calendar.SECOND, 0);
+        cal.set(Calendar.MILLISECOND, 0);
+        return cal;
+    }
+
+    /**
+     * 本地自测入口:在 IDE 中直接运行此类 main 方法,校验四套屏蔽规则。
+     * <p>
+     * 可选自定义参数(7 个):{@code yyyy-MM-dd HH:mm ruleOne ruleTwo ruleThree ruleFour}
+     * <br>示例:{@code 2026-01-01 10:00 1 null null null}
+     */
+    public static void main(String[] args) {
+        AiSipAutoCallRuleEvaluator evaluator = new AiSipAutoCallRuleEvaluator();
+        // 模拟法定节假日:2026-01-01 元旦
+        evaluator.holidayService = calendar -> "2026-01-01".equals(formatDate(calendar));
+
+        if (args != null && args.length >= 2) {
+            runCustomCase(evaluator, args);
+            return;
+        }
+
+        System.out.println("========== 外呼屏蔽规则自测开始 ==========");
+        int pass = 0;
+        int fail = 0;
+
+        // rule_one:法定节假日屏蔽
+        if (check("rule_one 节假日命中", evaluator,
+                ruleOf(1L, 1, null, null, null),
+                calendarOf(2026, 1, 1, 10, 0), true, "rule_one")) {
+            pass++;
+        } else {
+            fail++;
+        }
+        if (check("rule_one 未启用不屏蔽", evaluator,
+                ruleOf(2L, 0, null, null, null),
+                calendarOf(2026, 1, 1, 10, 0), false, null)) {
+            pass++;
+        } else {
+            fail++;
+        }
+        if (check("rule_one 非节假日不屏蔽", evaluator,
+                ruleOf(3L, 1, null, null, null),
+                calendarOf(2026, 3, 10, 10, 0), false, null)) {
+            pass++;
+        } else {
+            fail++;
+        }
+
+        // rule_two:特殊日期屏蔽
+        if (check("rule_two 特殊日期命中", evaluator,
+                ruleOf(4L, 0, "2026-05-20", null, null),
+                calendarOf(2026, 5, 20, 15, 0), true, "rule_two")) {
+            pass++;
+        } else {
+            fail++;
+        }
+        if (check("rule_two 非特殊日期不屏蔽", evaluator,
+                ruleOf(5L, 0, "2026-05-20", null, null),
+                calendarOf(2026, 5, 21, 15, 0), false, null)) {
+            pass++;
+        } else {
+            fail++;
+        }
+        if (check("rule_two 非法日期被忽略", evaluator,
+                ruleOf(6L, 0, "2026-13-40,2026-05-20", null, null),
+                calendarOf(2026, 5, 20, 9, 0), true, "rule_two")) {
+            pass++;
+        } else {
+            fail++;
+        }
+
+        // rule_three:每周星期屏蔽(2026-03-14 为周六,weekDay=6)
+        if (check("rule_three 周六命中", evaluator,
+                ruleOf(7L, 0, null, "6", null),
+                calendarOf(2026, 3, 14, 11, 0), true, "rule_three")) {
+            pass++;
+        } else {
+            fail++;
+        }
+        if (check("rule_three 周一不命中", evaluator,
+                ruleOf(8L, 0, null, "6", null),
+                calendarOf(2026, 3, 16, 11, 0), false, null)) {
+            pass++;
+        } else {
+            fail++;
+        }
+        if (check("rule_three 非法星期被忽略", evaluator,
+                ruleOf(9L, 0, null, "8,abc", null),
+                calendarOf(2026, 3, 14, 11, 0), false, null)) {
+            pass++;
+        } else {
+            fail++;
+        }
+
+        // rule_four:每日时段屏蔽
+        if (check("rule_four 时段内命中", evaluator,
+                ruleOf(10L, 0, null, null, "09:00-12:00"),
+                calendarOf(2026, 6, 10, 10, 30), true, "rule_four")) {
+            pass++;
+        } else {
+            fail++;
+        }
+        if (check("rule_four 时段外不屏蔽", evaluator,
+                ruleOf(11L, 0, null, null, "09:00-12:00"),
+                calendarOf(2026, 6, 10, 14, 0), false, null)) {
+            pass++;
+        } else {
+            fail++;
+        }
+        if (check("rule_four 跨天时段被忽略", evaluator,
+                ruleOf(12L, 0, null, null, "22:00-06:00"),
+                calendarOf(2026, 6, 10, 23, 0), false, null)) {
+            pass++;
+        } else {
+            fail++;
+        }
+
+        // 评估优先级:rule_one 优先于 rule_two
+        if (check("优先级 rule_one 先于 rule_two", evaluator,
+                ruleOf(13L, 1, "2026-01-01", null, null),
+                calendarOf(2026, 1, 1, 10, 0), true, "rule_one")) {
+            pass++;
+        } else {
+            fail++;
+        }
+
+        System.out.println("========================================");
+        System.out.println("外呼屏蔽规则自测完成:通过 " + pass + ",失败 " + fail);
+        if (fail > 0) {
+            System.exit(1);
+        }
+    }
+
+    private static AiSipAutoCallRules ruleOf(Long id, Integer ruleOne, String ruleTwo,
+                                            String ruleThree, String ruleFour) {
+        AiSipAutoCallRules rule = new AiSipAutoCallRules();
+        rule.setId(id);
+        rule.setRuleOne(ruleOne);
+        rule.setRuleTwo(ruleTwo);
+        rule.setRuleThree(ruleThree);
+        rule.setRuleFour(ruleFour);
+        return rule;
+    }
+
+    private static boolean check(String caseName, AiSipAutoCallRuleEvaluator evaluator,
+                                 AiSipAutoCallRules rule, Calendar now,
+                                 boolean expectBlocked, String expectMatchedRule) {
+        ParsedRule parsedRule = evaluator.parseRule(rule);
+        ShieldDecision decision = evaluator.evaluate(parsedRule, now);
+
+        System.out.println();
+        System.out.println("------------------------------------------------------------");
+        System.out.println("用例: " + caseName);
+        printTargetTime(now);
+        printRuleConfig(rule);
+        printParsedRule(parsedRule);
+        printRuleHitDetail(evaluator, parsedRule, now);
+        printFinalResult(decision, expectBlocked, expectMatchedRule);
+
+        boolean matchedRuleOk = expectMatchedRule == null
+                || expectMatchedRule.equals(decision.getMatchedRule());
+        boolean ok = decision.isBlocked() == expectBlocked && matchedRuleOk;
+        System.out.println("结论: " + (ok ? "[PASS]" : "[FAIL]"));
+        return ok;
+    }
+
+    private static void runCustomCase(AiSipAutoCallRuleEvaluator evaluator, String[] args) {
+        System.out.println("========== 自定义规则测试 ==========");
+        try {
+            String[] dateParts = args[0].split("-");
+            String[] timeParts = args[1].split(":");
+            Calendar now = calendarOf(
+                    Integer.parseInt(dateParts[0]),
+                    Integer.parseInt(dateParts[1]),
+                    Integer.parseInt(dateParts[2]),
+                    Integer.parseInt(timeParts[0]),
+                    Integer.parseInt(timeParts[1])
+            );
+            Integer ruleOne = args.length > 2 && !"null".equalsIgnoreCase(args[2]) ? Integer.valueOf(args[2]) : null;
+            String ruleTwo = args.length > 3 && !"null".equalsIgnoreCase(args[3]) ? args[3] : null;
+            String ruleThree = args.length > 4 && !"null".equalsIgnoreCase(args[4]) ? args[4] : null;
+            String ruleFour = args.length > 5 && !"null".equalsIgnoreCase(args[5]) ? args[5] : null;
+
+            AiSipAutoCallRules rule = ruleOf(99L, ruleOne, ruleTwo, ruleThree, ruleFour);
+            ParsedRule parsedRule = evaluator.parseRule(rule);
+            ShieldDecision decision = evaluator.evaluate(parsedRule, now);
+
+            System.out.println("用例: 自定义参数");
+            printTargetTime(now);
+            printRuleConfig(rule);
+            printParsedRule(parsedRule);
+            printRuleHitDetail(evaluator, parsedRule, now);
+            printFinalResult(decision, null, null);
+            System.out.println("结论: 自定义测试完成,请根据上方命中明细判断是否符合预期");
+        } catch (Exception e) {
+            System.err.println("自定义参数解析失败,格式: yyyy-MM-dd HH:mm ruleOne ruleTwo ruleThree ruleFour");
+            System.err.println("示例: 2026-01-01 10:00 1 null null null");
+            e.printStackTrace();
+            System.exit(1);
+        }
+    }
+
+    private static void printTargetTime(Calendar now) {
+        int weekDay = toChineseWeekDay(now);
+        System.out.println("目标时间: " + formatDateTime(now)
+                + " (" + WEEK_DAY_NAMES[weekDay] + ", weekDay=" + weekDay + ")");
+    }
+
+    private static void printRuleConfig(AiSipAutoCallRules rule) {
+        System.out.println("规则配置: id=" + rule.getId()
+                + ", rule_one(节假日)=" + display(rule.getRuleOne())
+                + ", rule_two(特殊日期)=" + display(rule.getRuleTwo())
+                + ", rule_three(星期)=" + display(rule.getRuleThree())
+                + ", rule_four(时段)=" + display(rule.getRuleFour()));
+    }
+
+    private static void printParsedRule(ParsedRule parsedRule) {
+        System.out.println("解析结果: 节假日启用=" + parsedRule.holidayEnabled
+                + ", 特殊日期=" + parsedRule.specialDates
+                + ", 屏蔽星期=" + formatWeekDays(parsedRule.weekDays)
+                + ", 屏蔽时段=" + formatTimeRanges(parsedRule.timeRanges));
+    }
+
+    private static void printRuleHitDetail(AiSipAutoCallRuleEvaluator evaluator,
+                                           ParsedRule parsedRule, Calendar now) {
+        System.out.println("逐条评估:");
+        String today = formatDate(now);
+        int weekDay = toChineseWeekDay(now);
+        int currentMinutes = now.get(Calendar.HOUR_OF_DAY) * 60 + now.get(Calendar.MINUTE);
+        String currentTime = String.format("%02d:%02d",
+                now.get(Calendar.HOUR_OF_DAY), now.get(Calendar.MINUTE));
+
+        // rule_one
+        if (!parsedRule.holidayEnabled) {
+            System.out.println("  [rule_one] 未启用 -> 未命中");
+        } else {
+            boolean isHoliday = evaluator.holidayService.isStatutoryHoliday(now);
+            System.out.println("  [rule_one] 法定节假日: "
+                    + (isHoliday ? "命中 (当日为法定节假日: " + today + ")" : "未命中 (当日非法定节假日: " + today + ")"));
+        }
+
+        // rule_two
+        if (parsedRule.specialDates.isEmpty()) {
+            System.out.println("  [rule_two] 未配置 -> 未命中");
+        } else {
+            boolean hit = StringUtils.isNotBlank(today) && parsedRule.specialDates.contains(today);
+            System.out.println("  [rule_two] 特殊日期: "
+                    + (hit ? "命中 (当日 " + today + " 在配置列表中)" : "未命中 (当日 " + today + " 不在 " + parsedRule.specialDates + ")"));
+        }
+
+        // rule_three
+        if (parsedRule.weekDays.isEmpty()) {
+            System.out.println("  [rule_three] 未配置 -> 未命中");
+        } else {
+            boolean hit = weekDay > 0 && parsedRule.weekDays.contains(weekDay);
+            System.out.println("  [rule_three] 每周屏蔽: "
+                    + (hit ? "命中 (当前为" + WEEK_DAY_NAMES[weekDay] + ")" : "未命中 (当前为" + WEEK_DAY_NAMES[weekDay]
+                    + ", 配置屏蔽=" + formatWeekDays(parsedRule.weekDays) + ")"));
+        }
+
+        // rule_four
+        if (parsedRule.timeRanges.isEmpty()) {
+            System.out.println("  [rule_four] 未配置 -> 未命中");
+        } else {
+            StringBuilder hitRanges = new StringBuilder();
+            for (TimeRange range : parsedRule.timeRanges) {
+                if (range.contains(currentMinutes)) {
+                    if (hitRanges.length() > 0) {
+                        hitRanges.append(", ");
+                    }
+                    hitRanges.append(range.label);
+                }
+            }
+            if (hitRanges.length() > 0) {
+                System.out.println("  [rule_four] 时段屏蔽: 命中 (当前时刻 " + currentTime + " 落在 " + hitRanges + ")");
+            } else {
+                System.out.println("  [rule_four] 时段屏蔽: 未命中 (当前时刻 " + currentTime
+                        + ", 配置时段=" + formatTimeRanges(parsedRule.timeRanges) + ")");
+            }
+        }
+    }
+
+    private static void printFinalResult(ShieldDecision decision, Boolean expectBlocked, String expectMatchedRule) {
+        System.out.println("最终结果: " + (decision.isBlocked() ? "屏蔽" : "不屏蔽")
+                + ", 命中规则=" + display(decision.getMatchedRule())
+                + ", 原因=" + display(decision.getReason()));
+        if (expectBlocked != null) {
+            System.out.println("期望结果: " + (expectBlocked ? "屏蔽" : "不屏蔽")
+                    + ", 命中规则=" + display(expectMatchedRule));
+        }
+    }
+
+    private static String formatDateTime(Calendar cal) {
+        try {
+            return DATETIME_FORMAT.get().format(cal.getTime());
+        } catch (Exception e) {
+            return "无效时间";
+        }
+    }
+
+    private static String formatWeekDays(Set<Integer> weekDays) {
+        if (weekDays == null || weekDays.isEmpty()) {
+            return "[]";
+        }
+        List<String> names = new ArrayList<>();
+        weekDays.stream().sorted().forEach(day -> {
+            if (day >= 1 && day <= 7) {
+                names.add(WEEK_DAY_NAMES[day]);
+            } else {
+                names.add(String.valueOf(day));
+            }
+        });
+        return names.toString();
+    }
+
+    private static String formatTimeRanges(List<TimeRange> timeRanges) {
+        if (timeRanges == null || timeRanges.isEmpty()) {
+            return "[]";
+        }
+        List<String> labels = new ArrayList<>(timeRanges.size());
+        for (TimeRange range : timeRanges) {
+            labels.add(range.label);
+        }
+        return labels.toString();
+    }
+
+    private static String display(Object value) {
+        if (value == null) {
+            return "null";
+        }
+        if (value instanceof String && StringUtils.isBlank((String) value)) {
+            return "空";
+        }
+        return String.valueOf(value);
+    }
+
+    public static final class ParsedRule {
+        private AiSipAutoCallRules source;
+        private boolean holidayEnabled;
+        private Set<Integer> weekDays = Collections.emptySet();
+        private Set<String> specialDates = Collections.emptySet();
+        private List<TimeRange> timeRanges = Collections.emptyList();
+
+        public Long getRuleId() {
+            return source == null ? null : source.getId();
+        }
+    }
+
+    public static final class ShieldDecision {
+        private boolean blocked;
+        private String matchedRule;
+        private String reason;
+
+        public boolean isBlocked() {
+            return blocked;
+        }
+
+        public String getMatchedRule() {
+            return matchedRule;
+        }
+
+        public String getReason() {
+            return reason;
+        }
+    }
+
+    private static final class TimeRange {
+        private final int startMinutes;
+        private final int endMinutes;
+        private final String label;
+
+        private TimeRange(int startMinutes, int endMinutes, String label) {
+            this.startMinutes = startMinutes;
+            this.endMinutes = endMinutes;
+            this.label = label;
+        }
+
+        private boolean contains(int currentMinutes) {
+            return currentMinutes >= startMinutes && currentMinutes <= endMinutes;
+        }
+    }
+}

+ 64 - 0
fs-service/src/main/java/com/fs/aiSipCall/support/AiSipShieldRuleReport.java

@@ -0,0 +1,64 @@
+package com.fs.aiSipCall.support;
+
+import lombok.Data;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * SIP 外呼屏蔽规则校验执行报告。
+ */
+@Data
+public class AiSipShieldRuleReport {
+
+    /** 是否执行成功(未发生整体致命异常) */
+    private boolean success = true;
+
+    /** 结果说明 */
+    private String summary;
+
+    /** 总耗时(毫秒) */
+    private long totalMs;
+
+    /** 有效任务总数 */
+    private int totalTasks;
+
+    /** 本次停呼成功数 */
+    private int pausedCount;
+
+    /** 本次启呼成功数 */
+    private int resumedCount;
+
+    /** 跳过数 */
+    private int skippedCount;
+
+    /** 停呼失败数 */
+    private int pauseFailedCount;
+
+    /** 启呼失败数 */
+    private int resumeFailedCount;
+
+    /** 无效/单任务异常数 */
+    private int invalidCount;
+
+    /** 逐条任务明细 */
+    private List<TaskReportItem> taskItems = new ArrayList<>();
+
+    @Data
+    public static class TaskReportItem {
+        private Long batchId;
+        private String taskName;
+        /** 是否需暂停外呼:true=是,false=否,null=无法判断 */
+        private Boolean needPause;
+        /** 限制说明(含具体规则) */
+        private String limitDetail;
+        /** 变更前状态:停呼 / 启呼 / 未知 */
+        private String prevCallState;
+        /** 状态变更:启呼->停呼 */
+        private String stateChange;
+        /** 成功 / 失败 / 无需执行 */
+        private String actionResult;
+        /** 当前状态:停呼 / 启呼 / 未知 */
+        private String currentState;
+    }
+}

+ 242 - 0
fs-service/src/main/java/com/fs/aiSipCall/utils/RemoteApiHelper.java

@@ -0,0 +1,242 @@
+package com.fs.aiSipCall.utils;
+
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.exception.ServiceException;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+/**
+ * 远程接口响应解析公共方法。
+ * <p>
+ * 硬失败(抛 {@link ServiceException}):适用于返回 List/int 等、需把错误透传前端的场景。<br>
+ * 软失败(返回 AjaxResult/TableDataInfo 错误体):适用于接口本身以该类型回传前端的场景。
+ */
+public final class RemoteApiHelper {
+
+    private static final Logger log = SipLogUtil.createSipLogger(RemoteApiHelper.class);
+
+    /** 远程接口成功码 */
+    public static final Integer SUCCESS_CODE = 0;
+
+    private static final int LOCAL_ERROR_CODE = 500;
+
+    private RemoteApiHelper() {
+    }
+
+    public static boolean isSuccess(Integer code) {
+        return SUCCESS_CODE.equals(code);
+    }
+
+    /**
+     * URL 参数编码,避免特殊字符导致请求参数丢失或注入
+     */
+    public static String urlEncode(String value) {
+        if (value == null) {
+            return "";
+        }
+        try {
+            return URLEncoder.encode(value, StandardCharsets.UTF_8.name());
+        } catch (Exception e) {
+            log.warn("urlEncode失败, value={}", value, e);
+            return value;
+        }
+    }
+
+    /**
+     * 校验远程响应成功,返回完整 JSON;失败抛 ServiceException
+     */
+    public static JSONObject parseSuccess(String result, String apiDesc) {
+        if (StringUtils.isBlank(result)) {
+            throw fail(apiDesc + "失败:接口返回为空", null);
+        }
+        JSONObject json;
+        try {
+            json = JSONObject.parseObject(result);
+        } catch (Exception e) {
+            String preview = result.length() > 200 ? result.substring(0, 200) + "..." : result;
+            log.error("{}失败:响应解析异常, resultPreview={}", apiDesc, preview, e);
+            throw fail(apiDesc + "失败:响应解析异常", null);
+        }
+        if (json == null) {
+            throw fail(apiDesc + "失败:响应解析为空", null);
+        }
+        Integer code = json.getInteger("code");
+        if (!isSuccess(code)) {
+            String remoteMsg = StringUtils.defaultIfBlank(json.getString("msg"), "未知错误");
+            throw fail(apiDesc + "失败:" + remoteMsg, code);
+        }
+        return json;
+    }
+
+    /**
+     * 尝试解析成功响应;失败仅记日志并返回 empty(适用于后台同步/重试场景)
+     */
+    public static Optional<JSONObject> tryParseSuccess(String result, String apiDesc) {
+        try {
+            return Optional.of(parseSuccess(result, apiDesc));
+        } catch (ServiceException e) {
+            return Optional.empty();
+        }
+    }
+
+    /**
+     * 解析 data 为列表
+     */
+    public static <T> List<T> parseArrayData(String result, Class<T> clazz, String apiDesc) {
+        return parseArrayField(result, "data", clazz, apiDesc);
+    }
+
+    /**
+     * 解析指定 JSON 字段为列表(如 data / rows),兼容字段为数组或字符串
+     */
+    public static <T> List<T> parseArrayField(String result, String field, Class<T> clazz, String apiDesc) {
+        JSONObject json = parseSuccess(result, apiDesc);
+        return toList(json.get(field), clazz);
+    }
+
+    /**
+     * 解析 data 为单个对象,兼容字段为对象或字符串
+     */
+    public static <T> T parseObjectData(String result, Class<T> clazz, String apiDesc) {
+        JSONObject json = parseSuccess(result, apiDesc);
+        return toObject(json.get("data"), clazz);
+    }
+
+    /**
+     * 将 JSON 字段安全转为列表
+     */
+    public static <T> List<T> toList(Object raw, Class<T> clazz) {
+        if (raw == null) {
+            return Collections.emptyList();
+        }
+        if (raw instanceof JSONArray) {
+            List<T> list = JSONObject.parseArray(((JSONArray) raw).toJSONString(), clazz);
+            return list != null ? list : Collections.emptyList();
+        }
+        if (raw instanceof String) {
+            String text = ((String) raw).trim();
+            if (text.isEmpty()) {
+                return Collections.emptyList();
+            }
+            List<T> list = JSONObject.parseArray(text, clazz);
+            return list != null ? list : Collections.emptyList();
+        }
+        List<T> list = JSONObject.parseArray(JSONObject.toJSONString(raw), clazz);
+        return list != null ? list : Collections.emptyList();
+    }
+
+    /**
+     * 将 JSON 字段安全转为对象
+     */
+    public static <T> T toObject(Object raw, Class<T> clazz) {
+        if (raw == null) {
+            return null;
+        }
+        if (clazz.isInstance(raw)) {
+            return clazz.cast(raw);
+        }
+        if (raw instanceof JSONObject) {
+            return JSONObject.parseObject(((JSONObject) raw).toJSONString(), clazz);
+        }
+        if (raw instanceof String) {
+            String text = ((String) raw).trim();
+            return text.isEmpty() ? null : JSONObject.parseObject(text, clazz);
+        }
+        return JSONObject.parseObject(JSONObject.toJSONString(raw), clazz);
+    }
+
+    /**
+     * 解析整包为指定类型(成功时)
+     */
+    public static <T> T parseWhole(String result, Class<T> clazz, String apiDesc) {
+        parseSuccess(result, apiDesc);
+        return JSONObject.parseObject(result, clazz);
+    }
+
+    /**
+     * 请求并硬失败转换
+     */
+    public static <T> T invoke(Supplier<String> request, Function<String, T> converter, String apiDesc) {
+        return converter.apply(doRequest(request, apiDesc));
+    }
+
+    /**
+     * 请求并转为 AjaxResult(软失败,不抛异常)
+     */
+    public static AjaxResult toAjaxResult(Supplier<String> request, String apiDesc) {
+        try {
+            return parseWhole(doRequest(request, apiDesc), AjaxResult.class, apiDesc);
+        } catch (ServiceException e) {
+            return AjaxResult.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 请求并转为 TableDataInfo(软失败,不抛异常)
+     */
+    public static TableDataInfo toTableDataInfo(Supplier<String> request, String apiDesc) {
+        try {
+            return parseWhole(doRequest(request, apiDesc), TableDataInfo.class, apiDesc);
+        } catch (ServiceException e) {
+            return errorTable(e.getMessage(), e.getCode());
+        }
+    }
+
+    /**
+     * 从已有响应字符串转 AjaxResult(软失败)
+     */
+    public static AjaxResult toAjaxResult(String result, String apiDesc) {
+        try {
+            return parseWhole(result, AjaxResult.class, apiDesc);
+        } catch (ServiceException e) {
+            return AjaxResult.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 从已有响应字符串转 TableDataInfo(软失败)
+     */
+    public static TableDataInfo toTableDataInfo(String result, String apiDesc) {
+        try {
+            return parseWhole(result, TableDataInfo.class, apiDesc);
+        } catch (ServiceException e) {
+            return errorTable(e.getMessage(), e.getCode());
+        }
+    }
+
+    public static TableDataInfo errorTable(String msg, Integer code) {
+        TableDataInfo info = new TableDataInfo();
+        info.setCode(code != null ? code : LOCAL_ERROR_CODE);
+        info.setMsg(StringUtils.defaultIfBlank(msg, "未知错误"));
+        info.setRows(Collections.emptyList());
+        info.setTotal(0);
+        return info;
+    }
+
+    private static String doRequest(Supplier<String> request, String apiDesc) {
+        Objects.requireNonNull(request, "request");
+        try {
+            return request.get();
+        } catch (Exception e) {
+            log.error("{}失败:请求异常", apiDesc, e);
+            throw fail(apiDesc + "失败:" + StringUtils.defaultIfBlank(e.getMessage(), "请求异常"), null);
+        }
+    }
+
+    private static ServiceException fail(String message, Integer code) {
+        log.error(message);
+        return code == null ? new ServiceException(message) : new ServiceException(message, code);
+    }
+}

+ 129 - 0
fs-service/src/main/java/com/fs/aiSipCall/utils/SipDESUtil.java

@@ -0,0 +1,129 @@
+package com.fs.aiSipCall.utils;
+
+import javax.crypto.Cipher;
+import javax.crypto.SecretKeyFactory;
+import javax.crypto.spec.DESKeySpec;
+import javax.crypto.spec.IvParameterSpec;
+import java.security.Key;
+import java.util.Base64;
+
+public class SipDESUtil {
+
+    /**
+     * 密钥算法
+     */
+    private static final String ALGORITHM = "DES";
+    /**
+     * 加密/解密算法-工作模式-填充模式
+     */
+    private static final String CIPHER_ALGORITHM = "DES/CBC/PKCS5Padding";
+    /**
+     * 默认编码
+     */
+    private static final String CHARSET = "utf-8";
+    /**
+     * 默认加密key
+     */
+    private static final String DEFAULT_KEY = "telebo@t";
+
+    /**
+     * 生成key
+     *
+     * @param password
+     * @return
+     * @throws Exception
+     */
+    private static Key generateKey(String password) throws Exception {
+        DESKeySpec dks = new DESKeySpec(password.getBytes(CHARSET));
+        SecretKeyFactory keyFactory = SecretKeyFactory.getInstance(ALGORITHM);
+        return keyFactory.generateSecret(dks);
+    }
+
+    /**
+     * 使用默认key加密
+     * @param data
+     * @return
+     */
+    public static String encrypt(String data) {
+        return encrypt(DEFAULT_KEY, data);
+    }
+
+    /**
+     * 使用默认key解密
+     * @param data
+     * @return
+     */
+    public static String decrypt(String data) throws Throwable {
+        //做下处理 如果有空字符替换成+
+        data = data.replace(" ", "+");
+        return decrypt(DEFAULT_KEY, data);
+    }
+
+    /**
+     * DES加密字符串
+     *
+     * @param password 加密密码,长度不能够小于8位
+     * @param data 待加密字符串
+     * @return 加密后内容
+     */
+    public static String encrypt(String password, String data) {
+        if (password== null || password.length() < 8) {
+            throw new RuntimeException("加密失败,key不能小于8位");
+        }
+        if (data == null) {
+            return null;
+        }
+        try {
+            Key secretKey = generateKey(password);
+            Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM);
+            IvParameterSpec iv = new IvParameterSpec(password.getBytes(CHARSET));
+            cipher.init(Cipher.ENCRYPT_MODE, secretKey, iv);
+            byte[] bytes = cipher.doFinal(data.getBytes(CHARSET));
+
+            //JDK1.8及以上可直接使用Base64,JDK1.7及以下可以使用BASE64Encoder
+            //Android平台可以使用android.util.Base64
+            return new String(Base64.getEncoder().encode(bytes));
+
+        } catch (Exception e) {
+            e.printStackTrace();
+            return data;
+        }
+    }
+
+    /**
+     * DES解密字符串
+     *
+     * @param password 解密密码,长度不能够小于8位
+     * @param data 待解密字符串
+     * @return 解密后内容
+     */
+    public static String decrypt(String password, String data) throws Throwable {
+        if (password == null || password.length() < 8) {
+            throw new RuntimeException("加密失败,key不能小于8位");
+        }
+        if (data == null) {
+            return null;
+        }
+
+        Key secretKey = generateKey(password);
+        Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM);
+        IvParameterSpec iv = new IvParameterSpec(password.getBytes(CHARSET));
+        cipher.init(Cipher.DECRYPT_MODE, secretKey, iv);
+        return new String(cipher.doFinal(Base64.getDecoder().decode(data.getBytes(CHARSET))), CHARSET);
+    }
+
+    public static void main(String[] args)  throws  Throwable {
+
+
+        String key = "telebo@t";
+        String output = encrypt("15779601354");
+        System.out.println(output);
+
+        String output2 = decrypt( "0ThIK+lYRMlSa9Q/s+jbgg==");
+        System.out.println(output2);
+
+    }
+
+
+
+}

+ 77 - 0
fs-service/src/main/java/com/fs/aiSipCall/utils/SipLogUtil.java

@@ -0,0 +1,77 @@
+package com.fs.aiSipCall.utils;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * SIP 日志工具类(高性能精简版)
+ * <ul>
+ *   <li>若配置了名为 "sip-info" 的 Logger 且其(或根 Logger)存在 Appender,则所有日志使用该统一 Logger;</li>
+ *   <li>否则降级为调用类对应的 Logger(遵循主配置)。</li>
+ * </ul>
+ * 无锁、无额外缓存,利用 SLF4J/Logback 自身的 Logger 缓存,开销极低,线程安全。
+ *
+ * @author peicj (重构)
+ * @version 2.0
+ */
+public final class SipLogUtil {
+
+    private static final String SIP_INFO_LOGGER_NAME = "sip-info";
+    private static final Logger SIP_LOGGER = LoggerFactory.getLogger(SIP_INFO_LOGGER_NAME);
+
+    /**
+     * 是否启用 sip-info Logger(仅在类加载时检测一次)
+     */
+    private static final boolean USE_SIP_LOGGER = checkSipLoggerUsable();
+
+    private SipLogUtil() {
+        // 工具类禁止实例化
+    }
+
+    /**
+     * 创建 SIP 日志记录器
+     * 若 sip-info 可用,则返回统一的 SIP_LOGGER;否则返回 clazz 对应的 Logger
+     *
+     * @param clazz 调用类(非空)
+     * @return Logger 实例
+     * @throws IllegalArgumentException 如果 clazz 为 null
+     */
+    public static Logger createSipLogger(Class<?> clazz) {
+        if (clazz == null) {
+            throw new IllegalArgumentException("Class parameter cannot be null");
+        }
+        return USE_SIP_LOGGER ? SIP_LOGGER : LoggerFactory.getLogger(clazz);
+    }
+
+    /**
+     * 检查 sip-info Logger 是否可用(自身或根 Logger 至少有一个 Appender)
+     * <p>仅在类加载时执行一次,避免运行时重复检查。</p>
+     *
+     * @return true 表示可用
+     */
+    private static boolean checkSipLoggerUsable() {
+        try {
+            if (!(SIP_LOGGER instanceof ch.qos.logback.classic.Logger)) {
+                return false;
+            }
+            ch.qos.logback.classic.Logger logbackLogger = (ch.qos.logback.classic.Logger) SIP_LOGGER;
+
+            // 检查自身是否有 Appender
+            if (logbackLogger.iteratorForAppenders().hasNext()) {
+                return true;
+            }
+
+            // 检查根 Logger 是否有 Appender(若自身无,通常会向上继承)
+            ch.qos.logback.classic.LoggerContext context = logbackLogger.getLoggerContext();
+            if (context != null) {
+                ch.qos.logback.classic.Logger root = context.getLogger(Logger.ROOT_LOGGER_NAME);
+                if (root != null && root.iteratorForAppenders().hasNext()) {
+                    return true;
+                }
+            }
+        } catch (Exception ignored) {
+            // 任何异常均视为不可用,降级处理
+        }
+        return false;
+    }
+}

+ 235 - 0
fs-service/src/main/java/com/fs/aiSipCall/utils/VoiceLogUtil.java

@@ -0,0 +1,235 @@
+package com.fs.aiSipCall.utils;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.locks.ReentrantLock;
+
+/**
+ * @Author:peicj
+ * @Description: 天天外呼回调日志处理(高性能优化版 - 高并发安全)
+ * @Date:2026/4/21 9:28
+ */
+public class VoiceLogUtil {
+
+    private static final String SIP_INFO_LOGGER_NAME = "voice-info";
+
+    // 缓存最大容量,防止内存泄漏和DoS攻击
+    private static final int MAX_CACHE_SIZE = 1000;
+
+    // 使用LRU缓存策略,自动淘汰最久未使用的entry
+    // LinkedHashMap(accessOrder=true)保证访问顺序,最近访问的放在最后
+    private static final LRUCache<Class<?>, Logger> LOGGER_CACHE = new LRUCache<>(MAX_CACHE_SIZE);
+
+    // 使用可重入锁保证线程安全(简化锁机制,避免读写锁的复杂性)
+    // LinkedHashMap的get()在accessOrder=true时会修改结构,必须用独占锁
+    private static final ReentrantLock CACHE_LOCK = new ReentrantLock(true); // 公平锁
+
+    // 预初始化的SIP logger
+    private static final Logger SIP_LOGGER = LoggerFactory.getLogger(SIP_INFO_LOGGER_NAME);
+
+    // 缓存sip-info是否有有效appender的结果
+    private static final boolean HAS_VALID_APPENDER = checkHasValidAppender();
+
+    // 监控指标:缓存命中次数
+    private static final AtomicLong CACHE_HIT_COUNT = new AtomicLong(0);
+
+    // 监控指标:缓存未命中次数
+    private static final AtomicLong CACHE_MISS_COUNT = new AtomicLong(0);
+
+    // 监控指标:异常次数
+    private static final AtomicLong ERROR_COUNT = new AtomicLong(0);
+
+    /**
+     * 创建SIP日志记录器(高性能版本 - LRU缓存 + 高并发安全)
+     * 如果检测到没有voice-info的配置,则使用默认logger
+     * 使用LRU缓存机制避免重复检查,自动淘汰不常用的entry
+     *
+     * @param clazz 调用类,不能为null
+     * @return Logger实例
+     * @throws IllegalArgumentException 如果clazz为null
+     */
+    public static Logger createSipLogger(Class<?> clazz) {
+        // 参数校验,避免NPE和安全风险
+        if (clazz == null) {
+            ERROR_COUNT.incrementAndGet();
+            throw new IllegalArgumentException("Class parameter cannot be null");
+        }
+
+        // 先尝试从缓存获取(需要独占锁,因为LinkedHashMap.get()会修改访问顺序)
+        CACHE_LOCK.lock();
+        try {
+            Logger cachedLogger = LOGGER_CACHE.get(clazz);
+            if (cachedLogger != null) {
+                CACHE_HIT_COUNT.incrementAndGet();
+                return cachedLogger;
+            }
+
+            CACHE_MISS_COUNT.incrementAndGet();
+
+            // 缓存未命中,创建新的Logger
+            Logger resultLogger;
+            try {
+                resultLogger = HAS_VALID_APPENDER ? SIP_LOGGER : LoggerFactory.getLogger(clazz);
+            } catch (Exception e) {
+                // Logger创建失败时的降级处理
+                ERROR_COUNT.incrementAndGet();
+                // 使用备用方案:直接返回SIP_LOGGER或根logger
+                resultLogger = SIP_LOGGER != null ? SIP_LOGGER : LoggerFactory.getLogger(VoiceLogUtil.class);
+            }
+
+            // 放入LRU缓存(自动处理容量限制和淘汰)
+            LOGGER_CACHE.put(clazz, resultLogger);
+
+            return resultLogger;
+        } finally {
+            CACHE_LOCK.unlock();
+        }
+    }
+
+    /**
+     * 检查voice-info logger是否有有效的appender配置
+     * 只在类加载时执行一次
+     *
+     * @return 是否有有效的appender
+     */
+    private static boolean checkHasValidAppender() {
+        try {
+            // 类型安全检查
+            if (!(SIP_LOGGER instanceof ch.qos.logback.classic.Logger)) {
+                return false;
+            }
+
+            ch.qos.logback.classic.Logger logbackLogger = (ch.qos.logback.classic.Logger) SIP_LOGGER;
+
+            // 直接检查当前logger是否有appender
+            if (logbackLogger.iteratorForAppenders().hasNext()) {
+                return true;
+            }
+
+            // 通过LoggerContext获取根logger并检查
+            ch.qos.logback.classic.LoggerContext loggerContext = logbackLogger.getLoggerContext();
+            if (loggerContext != null) {
+                ch.qos.logback.classic.Logger rootLogger = loggerContext.getLogger(ch.qos.logback.classic.Logger.ROOT_LOGGER_NAME);
+                return rootLogger != null && rootLogger.iteratorForAppenders().hasNext();
+            }
+
+            return false;
+        } catch (SecurityException e) {
+            // 安全管理器阻止访问,记录异常计数
+            ERROR_COUNT.incrementAndGet();
+            return false;
+        } catch (Exception e) {
+            // 记录异常计数,避免频繁I/O操作
+            ERROR_COUNT.incrementAndGet();
+            return false;
+        }
+    }
+
+    /**
+     * 获取缓存命中率(用于监控)
+     * 注意:返回值是瞬时快照,高并发下可能存在微小误差
+     *
+     * @return 缓存命中率(0.0-1.0)
+     */
+    public static double getCacheHitRate() {
+        long hitCount = CACHE_HIT_COUNT.get();
+        long missCount = CACHE_MISS_COUNT.get();
+        long total = hitCount + missCount;
+        return total > 0 ? (double) hitCount / total : 0.0;
+    }
+
+    /**
+     * 获取缓存统计快照(用于监控和调试)
+     * 所有指标在同一时刻采集,保证数据一致性
+     *
+     * @return 统计信息字符串
+     */
+    public static String getCacheStats() {
+        // 原子性地获取所有计数,减少时间窗口误差
+        long hitCount = CACHE_HIT_COUNT.get();
+        long missCount = CACHE_MISS_COUNT.get();
+        long errorCount = ERROR_COUNT.get();
+        long evictCount = LOGGER_CACHE.getEvictCount();
+        int cacheSize = LOGGER_CACHE.size();
+
+        long total = hitCount + missCount;
+        double hitRate = total > 0 ? (double) hitCount / total * 100 : 0.0;
+
+        return String.format(
+                "VoiceLogUtil Stats [cacheSize=%d, maxSize=%d, hits=%d, misses=%d, evictions=%d, errors=%d, hitRate=%.2f%%]",
+                cacheSize, MAX_CACHE_SIZE, hitCount, missCount, evictCount, errorCount, hitRate
+        );
+    }
+
+    /**
+     * 清空缓存(用于测试或特殊场景)
+     * 注意:生产环境慎用,会短暂阻塞所有线程
+     */
+    public static void clearCache() {
+        CACHE_LOCK.lock();
+        try {
+            LOGGER_CACHE.clear();
+            LOGGER_CACHE.resetEvictCount();
+            CACHE_HIT_COUNT.set(0);
+            CACHE_MISS_COUNT.set(0);
+            ERROR_COUNT.set(0);
+        } finally {
+            CACHE_LOCK.unlock();
+        }
+    }
+
+    /**
+     * LRU(Least Recently Used)缓存实现
+     * 基于LinkedHashMap实现,支持自动淘汰最久未使用的entry
+     * 线程不安全,需要外部同步控制
+     *
+     * @param <K> key类型
+     * @param <V> value类型
+     */
+    private static class LRUCache<K, V> extends LinkedHashMap<K, V> {
+        private static final long serialVersionUID = 1L;
+        private final int maxSize;
+
+        // 淘汰计数器,用于监控
+        private final AtomicLong evictCount = new AtomicLong(0);
+
+        public LRUCache(int maxSize) {
+            // initialCapacity: 初始容量
+            // loadFactor: 负载因子
+            // accessOrder: true表示按访问顺序排序(LRU),false表示按插入顺序
+            super(maxSize, 0.75f, true);
+            this.maxSize = maxSize;
+        }
+
+        /**
+         * 判断是否移除最老的entry
+         * 当缓存大小超过maxSize时,移除最久未使用的entry
+         */
+        @Override
+        protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
+            boolean shouldRemove = size() > maxSize;
+            if (shouldRemove) {
+                evictCount.incrementAndGet();
+            }
+            return shouldRemove;
+        }
+
+        /**
+         * 获取淘汰次数(用于监控)
+         */
+        public long getEvictCount() {
+            return evictCount.get();
+        }
+
+        /**
+         * 重置淘汰计数
+         */
+        public void resetEvictCount() {
+            evictCount.set(0);
+        }
+    }
+}

+ 84 - 0
fs-service/src/main/java/com/fs/aiSipCall/vo/AiSipCallPhoneInSopVo.java

@@ -0,0 +1,84 @@
+package com.fs.aiSipCall.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * @Author:peicj
+ * @Description: 总后台的自动化任务sip执行计划详情输出
+ * @Date:2026/5/21 17:38
+ */
+@Data
+public class AiSipCallPhoneInSopVo implements Serializable {
+
+    /** id */
+    private String id;
+
+    /** 本地任务批次id */
+    private Long localBatchId;
+    /** 远程任务批次id */
+    private Long batchId;
+    /** 0. 未拨打
+     1. 已进入呼叫队列
+     2. 正在拨号
+     3. 未接通(如果关闭空号检测)
+     4. 已接通
+     5. 通话中断(转人工前挂断)
+     6. 成功转人工或AI
+     7. 线路故障
+     30. 未接通
+     31. 客户正在通话中
+     32. 关机
+     33. 空号
+     34. 无人接听
+     35. 停机
+     36. 网络忙
+     37. 语音助手
+     38. 暂时无法接通
+     39. 呼叫限制
+     */
+    private Long callstatus;
+    /** 外呼时间 */
+    private Long calloutTime;
+    /** 通话唯一标志 */
+    private String uuid;
+    /** 挂机原因 */
+    private String hangupCause;
+    /** 客户意向 */
+    private String intent;
+    /** 外呼平台创建时间 */
+    private Long createtime;
+    /** 状态(0正常1删除) */
+    private Integer status;
+    /** 预计拨号时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm")
+    private Date sendTime;
+    /** 小程序id */
+    private Long fsUserId;
+    /** 执行类型(0销售端企微sop 1总后台客服sop) */
+    private String executeType;
+    /** sop任务id */
+    private Long sipTaskId;
+
+
+    /** 批次名称 */
+    private String batchName;
+    /** 小程序用户名称 */
+    private String fsUserNickName;
+    /** 呼叫状态名称 */
+    private String callstatusName;
+    /** 外呼时间 */
+    private String calloutTimeStr;
+    /** 外呼平台创建时间 */
+    private String createtimeStr;
+
+
+    /** 客户名称 */
+    private String externalUserName;
+
+    private Integer pageSize;
+    private Integer pageNum;
+}

+ 2 - 2
fs-service/src/main/java/com/fs/aiSipCall/vo/CallPhoneExportVo.java

@@ -1,6 +1,6 @@
 package com.fs.aiSipCall.vo;
 
-//import cn.afterturn.easypoi.excel.annotation.Excel;
+
 import com.fs.common.annotation.Excel;
 import lombok.Data;
 
@@ -42,4 +42,4 @@ public class CallPhoneExportVo implements Serializable {
     @Excel(name = "通话时长")
     private String timeLenSec;
 
-}
+}

+ 9 - 0
fs-service/src/main/java/com/fs/aiSipCall/vo/CcExtNumVo.java

@@ -3,6 +3,7 @@ package com.fs.aiSipCall.vo;
 import lombok.Data;
 
 import java.io.Serializable;
+import java.util.List;
 
 /**
  * @Author:peicj
@@ -27,4 +28,12 @@ public class CcExtNumVo implements Serializable {
 
     private Long pageNum;
     private Long pageSize;
+
+    /** 分机起 */
+    private Long startExtNum;
+    /** 分机止 */
+    private Long endExtNum;
+    /** 分机号集合 */
+    private List<Long> sipExtNumList;
+
 }

+ 67 - 0
fs-service/src/main/java/com/fs/aiSipCall/vo/StatisticalManagementVo.java

@@ -0,0 +1,67 @@
+package com.fs.aiSipCall.vo;
+
+import com.fs.common.annotation.Excel;
+import lombok.Data;
+
+/**
+ * @Author:peicj
+ * @Description: 统计sip通话记录管理vo
+ * @Date:2026/5/26 9:42
+ */
+
+@Data
+public class StatisticalManagementVo {
+    /**
+     * 公司id
+     */
+    private Long companyId;
+    /**
+     * 公司名称
+     */
+    @Excel(name = "公司名称")
+    private String companyName;
+
+    /**
+     * 公司销售ID
+     */
+    private String companyUserId;
+    /**
+     * 销售名称
+     */
+    @Excel(name = "销售名称")
+    private String companyNickUserName;
+    /**
+     * 部门ID
+     */
+    private Long deptId;
+    /**
+     * 部门名称
+     */
+    @Excel(name = "部门名称")
+    private String deptName;
+    /**
+     * 总电话数
+     */
+    @Excel(name = "总电话数")
+    private Long phoneCount;
+    /**
+     * 接通数
+     */
+    @Excel(name = "接通数")
+    private Long connectCount;
+    /**
+     * 未接通数
+     */
+    @Excel(name = "未接通数")
+    private Long noConnectCount;
+    /**
+     * 接通率
+     */
+    @Excel(name = "接通率")
+    private Double realConnectRate;
+    /**
+     * 总通话时长
+     */
+    @Excel(name = "总时长(分钟)")
+    private Long totalTimeLen;
+}

+ 31 - 0
fs-service/src/main/java/com/fs/common/RedisKeyCommon.java

@@ -0,0 +1,31 @@
+package com.fs.common;
+
+public class RedisKeyCommon {
+
+    /**
+     * 小程序替换key + {yyyy-MM-dd},vue为当天所有更换兜底小程序的id集合,不包含最新的兜底小程序id
+     */
+    public static final String QW_MINAPP_REPLACE_KEY = "fs_qw_miniapp_replace:";
+
+    /**
+     * 视频资源转码key + fileKey,objectUrl为视频资源的url
+     */
+    public static final String VIDEO_RESOURCE_TRANSCODING_KEY = "video_resource_transcoding:";
+    /**
+     * 外呼规则屏蔽状态(整表 Map 一次读写):
+     * key = sip_auto_call_rule_evaluator:states
+     * Hash field = remoteBatchId
+     * Hash value = 1停呼 / 0启呼
+     */
+    public static final String SIP_AUTO_CALL_RULE_EVALUATOR_STATE_HASH_KEY = "sip_auto_call_rule_evaluator:states";
+
+    /**
+     * App SOP 外呼任务 fsUserId 同步防重(后缀 + 任务记录 id)
+     */
+    public static final String APP_SOP_SIP_CALL_SYNC_PERIOD_KEY = "app_sop_sip_call_sync_period:";
+
+    /**
+     * 中国法定节假日按年缓存(后缀 + 年份,如 2026)
+     */
+    public static final String CHINESE_STATUTORY_HOLIDAY_YEAR_KEY = "sip_auto_call_chinese_statutory:year:";
+}

+ 10 - 0
fs-service/src/main/java/com/fs/company/domain/Company.java

@@ -151,4 +151,14 @@ public class Company extends BaseEntity
      * 分账子商户id
      */
     private Long subMerchantId;
+
+    /**
+     * 绑定新sip分机
+     */
+    private String sipExtNumIds;
+
+    /**
+     * 绑定旧sip分机
+     */
+    private String originalSipExtNumIds;
 }

+ 0 - 1
fs-service/src/main/java/com/fs/company/domain/CompanyRole.java

@@ -21,7 +21,6 @@ public class CompanyRole extends BaseEntity
     private Long roleId;
 
     /** 销售公司ID */
-    @NotNull(message="销售公司id必传")
     private Long companyId;
 
     /** 角色名称 */

+ 12 - 0
fs-service/src/main/java/com/fs/company/mapper/CompanyDeptMapper.java

@@ -2,11 +2,13 @@ package com.fs.company.mapper;
 
 import com.fs.company.domain.CompanyDept;
 import com.fs.course.vo.DeptFullPathVO;
+import com.fs.his.vo.OptionsVO;
 import org.apache.ibatis.annotations.Param;
 import org.apache.ibatis.annotations.Select;
 
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
 /**
  * 部门Mapper接口
@@ -135,4 +137,14 @@ public interface CompanyDeptMapper
     List<DeptFullPathVO> getDeptFullPathList(@Param("companyId") Long companyId);
 
     List<Long> selectDeptIdsByAncestors(@Param("deptId") Long deptId);
+
+    @Select("<script>" +
+            "SELECT dept_id AS dictValue,dept_name AS dictLabel FROM company_dept " +
+            "WHERE del_flag =0 and dept_id IN " +
+            "<foreach item='deptId' collection='deptIdSet' separator=',' open='(' close=')' index='index'> " +
+            "#{deptId} " +
+            "</foreach>" +
+            "</script>"
+    )
+    List<OptionsVO> selectCompanyDeptListByDeptIds(@Param("deptIdSet") Set<Long> deptIdSet);
 }

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

@@ -258,4 +258,7 @@ public interface CompanyMapper
     String getGateWayList(@Param("companyId") Long companyId);
 
     List<Company> queryCompanyListByCompanyIds(@Param("companyIds") List<Long> companyIds);
+
+    @Update("update company set sip_ext_num_ids = #{remainingExtNumIds} where company_id = #{companyId}")
+    int updateCompanyBindSipExtNum(@Param("companyId") Long companyId,@Param("remainingExtNumIds") String remainingExtNumIds);
 }

+ 4 - 0
fs-service/src/main/java/com/fs/company/mapper/CompanyUserMapper.java

@@ -388,4 +388,8 @@ public interface CompanyUserMapper
 
     List<CompanyUser> findCompanyUserOptions(@Param("keyword") String keyword, @Param("selectedId") Long selectedId,
                                              @Param("pageNumber") Integer pageNumber,  @Param("pageSize") Integer pageSize);
+
+    int cleanBindAiSipCallUserId(@Param("aiSipCallUserIds") List<Long> aiSipCallUserIds);
+
+    List<Long> selectUserIdsByCompanyId(Long companyId);
 }

+ 143 - 2
fs-service/src/main/java/com/fs/company/service/impl/CompanyServiceImpl.java

@@ -4,12 +4,16 @@ import java.math.BigDecimal;
 import java.math.RoundingMode;
 import java.time.LocalTime;
 import java.util.*;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
 import java.util.concurrent.TimeUnit;
 import java.util.stream.Collectors;
 
 import cn.hutool.json.JSONUtil;
 import com.alibaba.fastjson.JSON;
+import com.fs.aiSipCall.service.IAiSipCallUserService;
 import com.fs.common.constant.FsConstants;
+import com.fs.common.core.domain.AjaxResult;
 import com.fs.common.core.domain.R;
 import com.fs.common.core.redis.RedisCache;
 import com.fs.common.exception.CustomException;
@@ -44,6 +48,7 @@ import com.fs.hisStore.domain.FsStoreOrderScrm;
 import com.fs.hisStore.domain.FsStorePaymentScrm;
 import com.fs.hisStore.mapper.FsStoreAfterSalesScrmMapper;
 import com.fs.hisStore.mapper.FsStoreOrderScrmMapper;
+import com.fs.im.service.OpenIMService;
 import com.fs.live.domain.LiveOrder;
 import com.fs.live.mapper.LiveOrderMapper;
 import com.fs.live.service.ILiveService;
@@ -64,6 +69,7 @@ import com.github.binarywang.wxpay.service.WxPayService;
 import com.github.binarywang.wxpay.service.impl.WxPayServiceImpl;
 import com.github.pagehelper.PageHelper;
 import com.google.gson.Gson;
+import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.collections4.CollectionUtils;
 import org.redisson.api.RLock;
 import org.redisson.api.RedissonClient;
@@ -87,6 +93,7 @@ import static com.fs.company.service.impl.CompanyMiniappServiceImpl.GET_MINI_APP
  * @author fs
  * @date 2021-10-04
  */
+@Slf4j
 @Service
 public class CompanyServiceImpl implements ICompanyService
 {
@@ -164,6 +171,12 @@ public class CompanyServiceImpl implements ICompanyService
     @Lazy
     private FsUserCourseServiceImpl fsUserCourseService;
 
+    @Autowired
+    private OpenIMService openIMService;
+
+    @Autowired
+    private IAiSipCallUserService aiSipCallUserService;
+
     @Value("${cloud_host.company_name}")
     private String companyName;
 
@@ -438,6 +451,22 @@ public class CompanyServiceImpl implements ICompanyService
         if(userMapper.checkUserName(company.getUserName())>0){
             return R.error("管理员帐号已存在");
         }
+
+        String followDoctorIds = company.getFollowDoctorIds();
+        if(followDoctorIds != null){
+            followDoctorIds = followDoctorIds.replace("\\","").replace("\"","").replace("'","");
+            company.setFollowDoctorIds(followDoctorIds);
+        }
+        //处理sip分机号
+        if(StringUtils.isNotEmpty(company.getSipExtNumIds())){
+            AjaxResult result = aiSipCallUserService.companyBindExtNum(company.getCompanyName(),company.getSipExtNumIds());
+            Integer code = (Integer) result.get(AjaxResult.CODE_TAG);
+            if(code != null && code != 0){
+                String msg = (String) result.get(AjaxResult.MSG_TAG);
+                throw new RuntimeException(StringUtils.isNotEmpty( msg) ? msg : "新增SIP分机号绑定失败");
+            }
+        }
+
         company.setCreateTime(DateUtils.getNowDate());
         if(companyMapper.insertCompany(company)>0){
             //写入帐号信息
@@ -568,11 +597,123 @@ public class CompanyServiceImpl implements ICompanyService
     public int updateCompany(Company company)
     {
         company.setUpdateTime(DateUtils.getNowDate());
+        String followDoctorIds = company.getFollowDoctorIds();
+        if(followDoctorIds != null){
+            followDoctorIds = followDoctorIds.replace("\\","").replace("\"","").replace("'","");
+            company.setFollowDoctorIds(followDoctorIds);
+        }
+        //双击确认保存的情况下 会有多引号问题处理 前端已经同步处理了重复点击问题 增加了loading 这个代码用来修复存在这种情况的老旧数据
+        String packageCateIds = company.getPackageCateIds();
+        if(packageCateIds != null){
+            packageCateIds = packageCateIds.replace("\\","").replace("\"","").replace("'","");
+            company.setPackageCateIds(packageCateIds);
+        }
+        try {
+            Long companyId = company.getCompanyId();
+
+            // 删除套餐分类缓存
+            String cateKey = "packageCateIds:" + companyId;
+            redisCache.deleteObject(cateKey);
+            log.info("已删除缓存: {}", cateKey);
+
+            // 删除套餐列表缓存
+            String listKey = "packageList:" + companyId;
+            redisCache.deleteObject(listKey);
+            log.info("已删除缓存: {}", listKey);
+
+        } catch (Exception e) {
+            log.warn("清理缓存失败", e);
+        }
+        Company company1 = selectCompanyById(company.getCompanyId());
+        // 医生和客服添加好友
+        String doctorIdsNew = company.getDoctorIds();
+        String doctorIdsOld = company1.getDoctorIds();
+
+        if (StringUtils.isNotEmpty(doctorIdsNew)) {
+            // 拆分成集合
+            Set<String> newSet = new HashSet<>(Arrays.asList(doctorIdsNew.split(",")));
+            Set<String> oldSet = new HashSet<>();
+            if (StringUtils.isNotEmpty(doctorIdsOld)) {
+                oldSet = new HashSet<>(Arrays.asList(doctorIdsOld.split(",")));
+            }
+
+            // 比较两个集合是否相同(无序)
+            if (!newSet.equals(oldSet)&&newSet.size()>=oldSet.size()) {
+                // 查询该公司的所有客服
+                List<Long> userIds = userMapper.selectUserIdsByCompanyId(company.getCompanyId());
+
+                ArrayList<String> friendIds = new ArrayList<>();
+                for (Long userId : userIds) {
+                    friendIds.add("C"+userId);
+
+                }
+                // 计算需要新增的医生(在新集合中但不在旧集合中的)
+                Set<String> toAddSet = new HashSet<>(newSet);
+                toAddSet.removeAll(oldSet);
+                // 只对新增的医生执行添加好友操作
+                int threadCount = Math.min(toAddSet.size(), 10);
+                ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
+                for (String s : toAddSet) {
+                    executorService.submit(() -> {
+                        try {
+                            long startTime = System.currentTimeMillis();
+                            openIMService.importFriend("D" + s, friendIds);
+                            long endTime = System.currentTimeMillis();
+                            long durationMillis = endTime - startTime;
+                            log.info("importFriend 方法执行耗时: {} 毫秒, doctorId={}", durationMillis, s);
+                        } catch (Exception e) {
+                            log.error("导入医生好友失败:doctorId={}, error={}", s, e.getMessage(), e);
+                        }
+                    });
+                }
+                executorService.shutdown();
+                try {
+                    if (!executorService.awaitTermination(5, TimeUnit.MINUTES)) {
+                        log.warn("导入医生好友任务未在规定时间内完成");
+                        executorService.shutdownNow();
+                    }
+                } catch (InterruptedException e) {
+                    log.error("导入医生好友任务被中断", e);
+                    executorService.shutdownNow();
+                    Thread.currentThread().interrupt();
+                }
+
+            }
+        }
         if(company.isUpdateMiniApp()){
             bindMiniApp(company);
         }
-        if(null != company.getShowGatewayIds() && !company.getShowGatewayIds().isEmpty()){
-            handleCompanyBindGateway(company.getCompanyId(),company.getShowGatewayIds());
+
+        //处理sip分机号
+        if(StringUtils.isNotEmpty(company.getSipExtNumIds())){
+            AjaxResult result;
+            if(StringUtils.isNotEmpty(company.getOriginalSipExtNumIds())){
+                //如果有旧值比较
+                // 将原始分机号和新分机号都转换为Set进行比较
+                Set<String> originalSet = new HashSet<>(Arrays.asList(company.getOriginalSipExtNumIds().split(",")));
+                Set<String> newSet = new HashSet<>(Arrays.asList(company.getSipExtNumIds().split(",")));
+
+                // 计算需要新增的分机号(在新集合中但不在原始集合中的)
+                Set<String> toAddSet = new HashSet<>(newSet);
+                toAddSet.removeAll(originalSet);
+
+                // 只绑定新增的分机号(去重后的数据)
+                if (!toAddSet.isEmpty()) {
+                    String toAddStr = String.join(",", toAddSet);
+                    result = aiSipCallUserService.companyBindExtNum(company.getCompanyName(), toAddStr);
+                } else {
+                    // 如果没有需要新增的,创建一个成功的结果
+                    result = new AjaxResult(0, "操作成功", null);
+                }
+            }else{
+                //否则直接新增
+                result = aiSipCallUserService.companyBindExtNum(company.getCompanyName(), company.getSipExtNumIds());
+            }
+            Integer code = (Integer) result.get(AjaxResult.CODE_TAG);
+            if(code != null && code != 0){
+                String msg = (String) result.get(AjaxResult.MSG_TAG);
+                throw new RuntimeException(StringUtils.isNotEmpty( msg) ? msg : "修改SIP分机号绑定失败");
+            }
         }
         return companyMapper.updateCompany(company);
     }

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

@@ -975,4 +975,8 @@ public interface FsCourseWatchLogMapper extends BaseMapper<FsCourseWatchLog> {
             "ORDER BY update_time DESC " +
             "LIMIT 1")
     FsCourseWatchLog selectLatestWatchLogByUserId(@Param("userId") Long userId);
+
+
+    @DataSource(DataSourceType.SLAVE)
+    List<FsCourseWatchLog> selectWatchLogByUserIdsVideoId(@Param("userIds") List<Long> userIds,@Param("videoId") Long videoId);
 }

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

@@ -943,7 +943,7 @@ public class FsUserCourseOrderServiceImpl implements IFsUserCourseOrderService
             integralParam.setUserId(order.getUserId());
             integralParam.setLogType(FsUserIntegralLogTypeEnum.TYPE_24.getValue());
             integralParam.setBusinessId(order.getOrderId().toString());
-            integralParam.setPoints(order.getPayMoney().intValue());
+            integralParam.setPoints(order.getPayMoney().longValue());
             userIntegralLogsService.addIntegralTemplate(integralParam);
 
             return R.ok();

+ 3 - 1
fs-service/src/main/java/com/fs/his/config/IntegralConfig.java

@@ -61,10 +61,12 @@ public class IntegralConfig implements Serializable {
     /** 积分流水类型展示端配置,key=logType(dict_value),value=app / mini / app,mini */
     private Map<String, String> logTypeDisplayScope;
 
-    //观看团队科普选择直接获得芳华币限制数量 33
+    //观看团队科普选择直接获得积分限制数量 33
     private Integer WatchTeamPopularizationFangHuaRestrictions;
 
     //观看团队科普选择大礼品抽奖限制次数
     private Integer WatchTeamPopularizationRestrictions;
 
+
+
 }

+ 96 - 0
fs-service/src/main/java/com/fs/his/domain/AdProfitDetail.java

@@ -0,0 +1,96 @@
+package com.fs.his.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fs.common.annotation.Excel;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+* <p>
+* ad_profit_detail 实体类
+* </p>
+*/
+@Getter
+@Setter
+@TableName("ad_profit_detail")
+public class AdProfitDetail implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    /**
+    * 主键
+    */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /**
+    * 广告回调唯一交易ID
+    */
+    @Excel(name = "广告回调唯一交易ID")
+    private String adTransId;
+
+    /**
+    * 广告任务id
+    */
+    @Excel(name = "广告任务id")
+    private String adTaskId;
+
+    /**
+    * 广告平台结算总金额
+    */
+    @Excel(name = "广告平台结算总金额")
+    private BigDecimal sumMoney;
+
+    /**
+    * 用户id
+    */
+    @Excel(name = "用户id")
+    private Long userId;
+
+    /**
+    * 用户分润
+    */
+    @Excel(name = "用户分润")
+    private BigDecimal userMoney;
+
+    /**
+    * 公司id
+    */
+    @Excel(name = "公司id")
+    private Long companyId;
+
+    /**
+    * 公司分润
+    */
+    @Excel(name = "公司分润")
+    private BigDecimal companyMoney;
+
+    /**
+    * 互医分润
+    */
+    @Excel(name = "互医分润")
+    private BigDecimal huyiMoney;
+
+    /**
+    * 云联分润
+    */
+    @Excel(name = "云联分润")
+    private BigDecimal yunlianMoney;
+
+    /**
+     * 广告原始回调参数
+     */
+    private String originParam;
+
+    /**
+    * 创建时间
+    */
+    private LocalDateTime createTime;
+
+
+}

+ 56 - 0
fs-service/src/main/java/com/fs/his/domain/FsConsecutiveWithdrawRecord.java

@@ -0,0 +1,56 @@
+package com.fs.his.domain;
+
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fs.common.annotation.Excel;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+/**
+ * 连续提现记录对象 fs_consecutive_withdraw_record
+ *
+ * @author fs
+ * @date 2026-02-04
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class FsConsecutiveWithdrawRecord extends BaseEntity{
+
+    @TableId
+    private Long id;
+
+    /** 用户ID */
+    @Excel(name = "用户ID",sort = 1)
+    private Long userId;
+
+    /** 连续天数 */
+    @Excel(name = "连续天数")
+    private Integer consecutiveDays;
+
+    /** 连续开始日期 */
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    @Excel(name = "连续开始日期", width = 30, dateFormat = "yyyy-MM-dd")
+    private Date startDate;
+
+    /** 连续结束日期 */
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    @Excel(name = "连续结束日期", width = 30, dateFormat = "yyyy-MM-dd")
+    private Date endDate;
+
+    /** 连续期间提现次数 */
+    @Excel(name = "连续期间提现次数")
+    private Integer withdrawCount;
+
+    /** 连续期间提现总金额 */
+    @Excel(name = "连续期间提现总金额")
+    private BigDecimal totalAmount;
+
+    /** 状态:1-有效 0-无效 */
+    private Long status;
+
+
+}

+ 42 - 0
fs-service/src/main/java/com/fs/his/domain/FsIntegralExchange.java

@@ -0,0 +1,42 @@
+package com.fs.his.domain;
+
+import com.fs.common.annotation.Excel;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 用户积分提现佣金记录对象 fs_integral_exchange
+ *
+ * @author fs
+ * @date 2026-01-09
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class FsIntegralExchange extends BaseEntity{
+
+    /** $column.columnComment */
+    private Long id;
+
+    /** $column.columnComment */
+    @Excel(name = "${comment}", readConverterExp = "$column.readConverterExp()")
+    private Long userId;
+
+    /** 积分 正数表示增加积分,负数表示减少积分 */
+    @Excel(name = "积分 正数表示增加积分,负数表示减少积分")
+    private Long integral;
+
+    /** $column.columnComment */
+    @Excel(name = "积分 正数表示增加积分,负数表示减少积分")
+    private Integer status;
+
+    /** $column.columnComment */
+    @Excel(name = "积分 正数表示增加积分,负数表示减少积分")
+    private String phone;
+
+    /** $column.columnComment */
+    @Excel(name = "积分 正数表示增加积分,负数表示减少积分")
+    private String nickName;
+
+
+}

+ 71 - 0
fs-service/src/main/java/com/fs/his/domain/FsIntegralRedPacketLog.java

@@ -0,0 +1,71 @@
+package com.fs.his.domain;
+
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.fs.common.annotation.Excel;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.math.BigDecimal;
+
+/**
+ * 积分佣金红包记录对象 fs_integral_red_packet_log
+ *
+ * @author fs
+ * @date 2026-01-22
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class FsIntegralRedPacketLog extends BaseEntity{
+
+    /** 日志Id */
+    @TableId
+    private Long logId;
+
+    /** 用户id */
+    @Excel(name = "会员ID",sort = 1)
+    private Long userId;
+
+    /** 转帐金额 */
+    @Excel(name = "转帐金额")
+    private BigDecimal amount;
+
+    /** 批次单号 */
+    @Excel(name = "批次单号")
+    private String outBatchNo;
+
+    /** 微信批次单号 */
+    @Excel(name = "微信批次单号")
+    private String batchId;
+
+    /** 状态
+     * -4 用户订单退款,回退抵扣金额
+     * -3取消中
+     * -2取消
+     * -1失败
+     * 0 发送中
+     * 1 已发送
+     * 2 转账已受理
+     * 3 转账处理中
+     * 4 待收款用户确认,可拉起微信收款确认页面进行收款确认
+     * 5 转账结果尚未明确,可拉起微信收款确认页面再次重试确认收款
+     */
+    @Excel(name = "状态",dictType = "wx_merchant_pay_status")
+    private String status;
+
+    /** appId */
+    private String appId;
+
+    /** 唤起收款参数 */
+    private String packageInfo;
+
+    /** 商户号 */
+    private String mchId;
+
+    /** 佣金退回状态 0:否 1:是 */
+    @Excel(name = "退佣金状态  0:否 1:是")
+    private Integer returnedStatus;
+
+    //提现类型 1.订单抵扣 0.提现记录   新增字段,默认为0提现记录,新增了支付抵扣也记录到该表里
+    private Integer type;
+}

+ 99 - 0
fs-service/src/main/java/com/fs/his/dto/AdProfitDetailDto.java

@@ -0,0 +1,99 @@
+package com.fs.his.dto;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fs.common.annotation.Excel;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.Date;
+
+@Getter
+@Setter
+public class AdProfitDetailDto implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 主键
+     */
+    private Long id;
+
+    /**
+     * 广告回调唯一交易ID
+     */
+    @Excel(name = "回调ID")
+    private String adTransId;
+
+    /**
+     * 广告任务id
+     */
+    private String adTaskId;
+
+    /**
+     * 广告平台结算总金额
+     */
+    @Excel(name = "结算总金额")
+    private BigDecimal sumMoney;
+
+    /**
+     * 用户id
+     */
+    @Excel(name = "用户id")
+    private Long userId;
+
+    /**
+     * 用户分润
+     */
+    @Excel(name = "用户分润")
+    private BigDecimal userMoney;
+
+    /**
+     * 公司id
+     */
+    @Excel(name = "公司id")
+    private Long companyId;
+
+    /**
+     * 公司分润
+     */
+    @Excel(name = "公司分润")
+    private BigDecimal companyMoney;
+
+    /**
+     * 互医分润
+     */
+    @Excel(name = "互医分润")
+    private BigDecimal huyiMoney;
+
+    /**
+     * 云联分润
+     */
+    @Excel(name = "云联分润")
+    private BigDecimal yunlianMoney;
+
+    /**
+     * 创建时间
+     */
+    @Excel(name = "创建时间")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime createTime;
+
+    /**
+     * 公司名称
+     */
+    @Excel(name = "公司名称")
+    private String companyName;
+
+    /**
+     * 用户昵称
+     */
+    @Excel(name = "用户昵称")
+    private String nickName;
+
+    @JsonFormat(pattern = "yyyy-MM-dd ")
+    private Date sTime;
+    @JsonFormat(pattern = "yyyy-MM-dd ")
+    private Date eTime;
+}

+ 1 - 0
fs-service/src/main/java/com/fs/his/enums/FsUserIntegralLogTypeEnum.java

@@ -43,6 +43,7 @@ public enum FsUserIntegralLogTypeEnum {
     TYPE_33(33, "内容留言积分"),
     TYPE_34(34, "管理员后台扣除"),
     TYPE_35(35, "分享首页视频积分"),
+    TYPE_36(36, "积分兑换佣金"),
     ;
 
 

+ 73 - 0
fs-service/src/main/java/com/fs/his/mapper/AdProfitDetailMapper.java

@@ -0,0 +1,73 @@
+package com.fs.his.mapper;
+
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.his.domain.AdProfitDetail;
+import com.fs.his.dto.AdProfitDetailDto;
+import com.fs.his.param.AdProfitDetailStatisticsParam;
+import com.fs.his.vo.AdProfitDetailStatisticsVo;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+* <p>
+* ad_profit_detail Mapper 接口
+* </p>
+*/
+public interface AdProfitDetailMapper extends BaseMapper<AdProfitDetail>  {
+    /**
+     * 查询广告分佣
+     *
+     * @param id 广告分佣主键
+     * @return 广告分佣
+     */
+    AdProfitDetail selectAdProfitDetailById(Long id);
+
+    /**
+     * 查询广告分佣列表
+     *
+     * @param adProfitDetailDto 广告分佣
+     * @return 广告分佣集合
+     */
+    List<AdProfitDetailDto> selectAdProfitDetailList(AdProfitDetailDto adProfitDetailDto);
+
+    /**
+     * 新增广告分佣
+     *
+     * @param adProfitDetail 广告分佣
+     * @return 结果
+     */
+    int insertAdProfitDetail(AdProfitDetail adProfitDetail);
+
+    /**
+     * 修改广告分佣
+     *
+     * @param adProfitDetail 广告分佣
+     * @return 结果
+     */
+    int updateAdProfitDetail(AdProfitDetail adProfitDetail);
+
+    /**
+     * 删除广告分佣
+     *
+     * @param id 广告分佣主键
+     * @return 结果
+     */
+    int deleteAdProfitDetailById(Long id);
+
+    /**
+     * 批量删除广告分佣
+     *
+     * @param ids 需要删除的数据主键集合
+     * @return 结果
+     */
+    int deleteAdProfitDetailByIds(Long[] ids);
+
+    String getCompanyByUserId(String userId);
+
+    Map<String, Object> getWithFinishAndMayWithdraw();
+
+    List<AdProfitDetailStatisticsVo> statisticsList(@Param("param")AdProfitDetailStatisticsParam param);
+}

+ 81 - 0
fs-service/src/main/java/com/fs/his/mapper/FsConsecutiveWithdrawRecordMapper.java

@@ -0,0 +1,81 @@
+package com.fs.his.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.his.domain.FsConsecutiveWithdrawRecord;
+import com.fs.his.param.FsConsecutiveWithdrawRecordParam;
+import com.fs.his.vo.FsConsecutiveWithdrawRecordVo;
+import org.apache.ibatis.annotations.Param;
+
+import java.time.LocalDate;
+import java.util.List;
+
+/**
+ * 连续提现记录Mapper接口
+ * 
+ * @author fs
+ * @date 2026-02-04
+ */
+public interface FsConsecutiveWithdrawRecordMapper extends BaseMapper<FsConsecutiveWithdrawRecord>{
+    /**
+     * 查询连续提现记录
+     * 
+     * @param id 连续提现记录主键
+     * @return 连续提现记录
+     */
+    FsConsecutiveWithdrawRecord selectFsConsecutiveWithdrawRecordById(Long id);
+
+    /**
+     * 查询连续提现记录列表
+     * 
+     * @param param 连续提现记录
+     * @return 连续提现记录集合
+     */
+    List<FsConsecutiveWithdrawRecordVo> selectFsConsecutiveWithdrawRecordList(FsConsecutiveWithdrawRecordParam param);
+
+    /**
+     * 新增连续提现记录
+     * 
+     * @param fsConsecutiveWithdrawRecord 连续提现记录
+     * @return 结果
+     */
+    int insertFsConsecutiveWithdrawRecord(FsConsecutiveWithdrawRecord fsConsecutiveWithdrawRecord);
+
+    /**
+     * 修改连续提现记录
+     * 
+     * @param fsConsecutiveWithdrawRecord 连续提现记录
+     * @return 结果
+     */
+    int updateFsConsecutiveWithdrawRecord(FsConsecutiveWithdrawRecord fsConsecutiveWithdrawRecord);
+
+    /**
+     * 删除连续提现记录
+     * 
+     * @param id 连续提现记录主键
+     * @return 结果
+     */
+    int deleteFsConsecutiveWithdrawRecordById(Long id);
+
+    /**
+     * 批量删除连续提现记录
+     * 
+     * @param ids 需要删除的数据主键集合
+     * @return 结果
+     */
+    int deleteFsConsecutiveWithdrawRecordByIds(Long[] ids);
+
+    /**
+     * 检查用户在某时间段是否已记录连续提现
+     */
+    boolean existsRecordInPeriod(@Param("userId") Long userId,
+                                 @Param("startDate") LocalDate startDate,
+                                 @Param("endDate") LocalDate endDate,
+                                 @Param("thresholdDays") int thresholdDays);
+
+    Integer countByUserAndPeriod(@Param("userId") Long userId,
+                                 @Param("startDate") LocalDate startDate,
+                                 @Param("endDate") LocalDate endDate);
+
+    FsConsecutiveWithdrawRecord selectCountByUserIdAndStartTime(@Param("userId") Long userId,
+                                                                @Param("startDate") LocalDate startDate);
+}

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels