فهرست منبع

合并:拉取线上代码,解决 LobsterNodeCapabilityRegistry.java 冲突(保留本地版本)

云联一号 8 ساعت پیش
والد
کامیت
5df84a0f5b
80فایلهای تغییر یافته به همراه1882 افزوده شده و 434 حذف شده
  1. 2 2
      fs-admin/src/main/java/com/fs/admin/controller/AdminCommGatewayLogController.java
  2. 0 24
      fs-comm-gateway/src/main/java/com/fs/comm/config/CommFilterConfig.java
  3. 57 6
      fs-comm-gateway/src/main/java/com/fs/comm/controller/CommCallbackController.java
  4. 4 0
      fs-comm-gateway/src/main/java/com/fs/comm/dto/CommApiResult.java
  5. 9 0
      fs-comm-gateway/src/main/java/com/fs/comm/exception/CommGlobalExceptionHandler.java
  6. 30 8
      fs-comm-gateway/src/main/java/com/fs/comm/security/CommTokenAuthFilter.java
  7. 12 1
      fs-comm-gateway/src/main/java/com/fs/comm/service/CommCallService.java
  8. 27 0
      fs-comm-gateway/src/main/java/com/fs/comm/service/CommGatewayApiLogRecorder.java
  9. 4 0
      fs-comm-gateway/src/main/java/com/fs/comm/sms/MyCommSmsProvider.java
  10. 1 0
      fs-comm-gateway/src/main/java/com/fs/framework/config/SecurityConfig.java
  11. 44 0
      fs-company/src/main/java/com/fs/company/controller/company/CompanyVoiceRoboticCallLogCallphoneController.java
  12. 14 2
      fs-company/src/main/java/com/fs/company/controller/company/CompanyVoiceRoboticController.java
  13. 12 0
      fs-company/src/main/java/com/fs/company/controller/crm/CustomerAllController.java
  14. 10 10
      fs-company/src/main/java/com/fs/company/controller/workflow/LobsterE2eController.java
  15. 20 47
      fs-service/src/main/java/com/fs/aiSipCall/service/impl/AiSipCallOutboundCdrServiceImpl.java
  16. 1 1
      fs-service/src/main/java/com/fs/baidu/service/impl/BdApiServiceImpl.java
  17. 8 1
      fs-service/src/main/java/com/fs/comm/client/CommGatewayClient.java
  18. 74 0
      fs-service/src/main/java/com/fs/comm/exception/CommBlacklistRejectException.java
  19. 28 0
      fs-service/src/main/java/com/fs/comm/model/CallBalanceDeductionResult.java
  20. 5 0
      fs-service/src/main/java/com/fs/comm/model/CommCallSendParam.java
  21. 105 0
      fs-service/src/main/java/com/fs/comm/service/CallBalanceDeductionService.java
  22. 65 15
      fs-service/src/main/java/com/fs/comm/service/CommCallSendService.java
  23. 52 0
      fs-service/src/main/java/com/fs/comm/support/CompanySmsMasterDataSourceHelper.java
  24. 113 29
      fs-service/src/main/java/com/fs/common/service/impl/SmsServiceImpl.java
  25. 17 0
      fs-service/src/main/java/com/fs/company/domain/CompanyVoiceRoboticCallLogCallphone.java
  26. 12 0
      fs-service/src/main/java/com/fs/company/domain/CrmCustomerCallLog.java
  27. 9 0
      fs-service/src/main/java/com/fs/company/mapper/CompanyVoiceRoboticCallLogCallphoneMapper.java
  28. 8 0
      fs-service/src/main/java/com/fs/company/mapper/EasyCallMapper.java
  29. 15 0
      fs-service/src/main/java/com/fs/company/service/ICompanyAiCallDataSyncService.java
  30. 1 1
      fs-service/src/main/java/com/fs/company/service/ICompanyVoiceRoboticCallBlacklistInterceptLogService.java
  31. 8 0
      fs-service/src/main/java/com/fs/company/service/ICompanyVoiceRoboticCallLogCallphoneService.java
  32. 141 0
      fs-service/src/main/java/com/fs/company/service/easycall/EasyCallCallbackContextHelper.java
  33. 89 0
      fs-service/src/main/java/com/fs/company/service/impl/CompanyAiCallDataSyncServiceImpl.java
  34. 4 2
      fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticCallBlacklistInterceptLogServiceImpl.java
  35. 13 3
      fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticCallBlacklistServiceImpl.java
  36. 75 56
      fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticCallLogCallphoneServiceImpl.java
  37. 73 79
      fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticServiceImpl.java
  38. 7 1
      fs-service/src/main/java/com/fs/company/service/impl/call/node/AbstractWorkflowNode.java
  39. 7 3
      fs-service/src/main/java/com/fs/company/service/impl/call/node/AiAddWxTaskNewNode.java
  40. 7 3
      fs-service/src/main/java/com/fs/company/service/impl/call/node/AiAddWxTaskNode.java
  41. 11 4
      fs-service/src/main/java/com/fs/company/service/impl/call/node/AiCallTaskNode.java
  42. 7 3
      fs-service/src/main/java/com/fs/company/service/impl/call/node/AiQwAddWxTaskNode.java
  43. 1 1
      fs-service/src/main/java/com/fs/company/service/impl/call/node/AiSendMsgTaskNode.java
  44. 110 0
      fs-service/src/main/java/com/fs/company/service/impl/call/node/WorkflowExecErrorMessages.java
  45. 10 0
      fs-service/src/main/java/com/fs/company/vo/CompanyVoiceRoboticCallBlacklistCheckVO.java
  46. 3 0
      fs-service/src/main/java/com/fs/company/vo/CompanyVoiceRoboticCallLogCallPhoneVO.java
  47. 24 0
      fs-service/src/main/java/com/fs/company/vo/CompanyVoiceRoboticCallLogDetailSummary.java
  48. 13 9
      fs-service/src/main/java/com/fs/company/vo/ExecutionResult.java
  49. 4 0
      fs-service/src/main/java/com/fs/crm/param/CrmCustomeRecoverParam.java
  50. 2 0
      fs-service/src/main/java/com/fs/crm/service/ICrmCustomerService.java
  51. 34 0
      fs-service/src/main/java/com/fs/crm/service/impl/CrmCustomerServiceImpl.java
  52. 3 0
      fs-service/src/main/java/com/fs/proxy/domain/CompanySmsApi.java
  53. 19 0
      fs-service/src/main/java/com/fs/proxy/enums/ConsumeServiceResult.java
  54. 8 0
      fs-service/src/main/java/com/fs/proxy/mapper/CompanySmsApiMapper.java
  55. 4 0
      fs-service/src/main/java/com/fs/proxy/mapper/CompanySmsApiPortMapper.java
  56. 4 0
      fs-service/src/main/java/com/fs/proxy/mapper/CompanySmsApiTenantMapper.java
  57. 3 0
      fs-service/src/main/java/com/fs/proxy/mapper/TenantConsumeRecordMapper.java
  58. 25 0
      fs-service/src/main/java/com/fs/proxy/model/ConsumeServiceOutcome.java
  59. 16 0
      fs-service/src/main/java/com/fs/proxy/model/UnitPricePair.java
  60. 18 0
      fs-service/src/main/java/com/fs/proxy/service/BalanceService.java
  61. 4 0
      fs-service/src/main/java/com/fs/proxy/service/ICompanySmsPortService.java
  62. 212 83
      fs-service/src/main/java/com/fs/proxy/service/impl/BalanceServiceImpl.java
  63. 49 23
      fs-service/src/main/java/com/fs/proxy/service/impl/CompanySmsPortServiceImpl.java
  64. 1 0
      fs-service/src/main/resources/db/tenant-initTable.sql
  65. 3 0
      fs-service/src/main/resources/mapper/comm/CommGatewayApiLogMapper.xml
  66. 24 5
      fs-service/src/main/resources/mapper/company/CompanyVoiceRoboticCallLogCallphoneMapper.xml
  67. 4 0
      fs-service/src/main/resources/mapper/company/CrmCustomerCallLogMapper.xml
  68. 10 0
      fs-service/src/main/resources/mapper/company/EasyCallMapper.xml
  69. 7 0
      fs-service/src/main/resources/mapper/proxy/CompanySmsApiMapper.xml
  70. 6 0
      fs-service/src/main/resources/mapper/proxy/TenantConsumeRecordMapper.xml
  71. 1 0
      fs-wx-task/src/main/java/com/fs/app/service/WxTaskService.java
  72. 4 4
      fs-wx-task/src/main/java/com/fs/app/task/WxTask.java
  73. 2 2
      scripts/fix_dict.py
  74. 1 1
      set-java17.bat
  75. 37 0
      sql/add_comm_gateway_log_menu.sql
  76. 5 0
      sql/add_tenant_consume_record_order_unique.sql
  77. 5 0
      sql/company_sms_temp_sms_api_ids_patch.sql
  78. 1 1
      sql/fix_tenant_sys_menu_other_parent.sql
  79. 2 2
      sql/fix_tenant_sys_menu_paths.sql
  80. 2 2
      sql/organize_tenant_sys_menu_subtree.sql

+ 2 - 2
fs-admin/src/main/java/com/fs/admin/controller/AdminCommGatewayLogController.java

@@ -26,7 +26,7 @@ public class AdminCommGatewayLogController extends BaseController {
     @Autowired(required = false)
     private ICommGatewayApiLogService commGatewayApiLogService;
 
-    @PreAuthorize("@ss.hasPermi('platform:companyVoiceConfig:list')")
+    @PreAuthorize("@ss.hasAnyPermi('platform:commGatewayCallLog:list,platform:commGatewaySmsLog:list')")
     @GetMapping("/list")
     public TableDataInfo list(CommGatewayApiLog query) {
         DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
@@ -37,7 +37,7 @@ public class AdminCommGatewayLogController extends BaseController {
         return getDataTable(list);
     }
 
-    @PreAuthorize("@ss.hasPermi('platform:companyVoiceConfig:list')")
+    @PreAuthorize("@ss.hasAnyPermi('platform:commGatewayCallLog:query,platform:commGatewaySmsLog:query')")
     @GetMapping("/{logId}")
     public AjaxResult getInfo(@PathVariable Long logId) {
         DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());

+ 0 - 24
fs-comm-gateway/src/main/java/com/fs/comm/config/CommFilterConfig.java

@@ -1,24 +0,0 @@
-package com.fs.comm.config;
-
-import com.fs.comm.security.CommTokenAuthFilter;
-import org.springframework.boot.web.servlet.FilterRegistrationBean;
-import org.springframework.context.annotation.Bean;
-import org.springframework.context.annotation.Configuration;
-import org.springframework.core.Ordered;
-
-/**
- * 通讯网关鉴权过滤器(Servlet Filter 注册,避免与 SecurityConfig 形成 AuthenticationManager 循环依赖)
- */
-@Configuration
-public class CommFilterConfig {
-
-    @Bean
-    public FilterRegistrationBean<CommTokenAuthFilter> commTokenAuthFilterRegistration(CommTokenAuthFilter filter) {
-        FilterRegistrationBean<CommTokenAuthFilter> registration = new FilterRegistrationBean<>();
-        registration.setFilter(filter);
-        registration.addUrlPatterns("/comm/*");
-        registration.setName("commTokenAuthFilter");
-        registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 20);
-        return registration;
-    }
-}

+ 57 - 6
fs-comm-gateway/src/main/java/com/fs/comm/controller/CommCallbackController.java

@@ -1,13 +1,21 @@
 package com.fs.comm.controller;
 
+import com.alibaba.fastjson.JSONObject;
 import com.fs.comm.service.CommCallbackService;
-import com.fs.common.annotation.CallbackIpCheck;
+import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.http.HttpHelper;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.PostMapping;
-import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
 import org.springframework.web.bind.annotation.RestController;
 
+import javax.servlet.http.HttpServletRequest;
+import java.io.IOException;
+import java.util.Map;
+
+@Slf4j
 @RestController
 @RequestMapping("/comm/callback")
 public class CommCallbackController {
@@ -15,14 +23,57 @@ public class CommCallbackController {
     @Autowired
     private CommCallbackService commCallbackService;
 
-    @PostMapping("/easycall")
-    @CallbackIpCheck
-    public String easyCall(@RequestBody String cdrStr) {
+    /**
+     * EasyCall 外呼结果回调(免 Token、免 IP 白名单)
+     * 支持 GET(探活/部分平台回调)与 POST(JSON 正文)
+     */
+    @RequestMapping(value = "/easycall", method = {RequestMethod.GET, RequestMethod.POST})
+    public String easyCall(HttpServletRequest request) throws IOException {
+        String cdrStr = resolveEasyCallPayload(request);
+        if (StringUtils.isBlank(cdrStr)) {
+            log.debug("EasyCall 回调无业务体, method={}, uri={}", request.getMethod(), request.getRequestURI());
+            return "success";
+        }
+        log.info("EasyCall 回调: method={}, body={}", request.getMethod(), cdrStr);
         return commCallbackService.handleEasyCallCallback(cdrStr);
     }
 
     @PostMapping("/sms")
-    public String sms(@RequestBody String json) {
+    public String sms(HttpServletRequest request) throws IOException {
+        String json = HttpHelper.getBodyString(request);
         return commCallbackService.handleSmsCallback(json);
     }
+
+    private String resolveEasyCallPayload(HttpServletRequest request) throws IOException {
+        if ("POST".equalsIgnoreCase(request.getMethod())) {
+            return HttpHelper.getBodyString(request);
+        }
+        Map<String, String[]> paramMap = request.getParameterMap();
+        if (paramMap == null || paramMap.isEmpty()) {
+            return null;
+        }
+        String uuid = request.getParameter("uuid");
+        String cdrType = request.getParameter("cdrType");
+        String cdrBody = request.getParameter("cdrBody");
+        if (StringUtils.isNotBlank(uuid) || StringUtils.isNotBlank(cdrType) || StringUtils.isNotBlank(cdrBody)) {
+            JSONObject obj = new JSONObject();
+            if (StringUtils.isNotBlank(uuid)) {
+                obj.put("uuid", uuid);
+            }
+            if (StringUtils.isNotBlank(cdrType)) {
+                obj.put("cdrType", cdrType);
+            }
+            if (StringUtils.isNotBlank(cdrBody)) {
+                obj.put("cdrBody", cdrBody);
+            }
+            return obj.toJSONString();
+        }
+        for (String key : paramMap.keySet()) {
+            String value = request.getParameter(key);
+            if (StringUtils.isNotBlank(value) && value.trim().startsWith("{")) {
+                return value.trim();
+            }
+        }
+        return null;
+    }
 }

+ 4 - 0
fs-comm-gateway/src/main/java/com/fs/comm/dto/CommApiResult.java

@@ -24,4 +24,8 @@ public class CommApiResult<T> {
     public static <T> CommApiResult<T> error(int code, String msg) {
         return CommApiResult.<T>builder().code(code).msg(msg).build();
     }
+
+    public static <T> CommApiResult<T> error(int code, String msg, T data) {
+        return CommApiResult.<T>builder().code(code).msg(msg).data(data).build();
+    }
 }

+ 9 - 0
fs-comm-gateway/src/main/java/com/fs/comm/exception/CommGlobalExceptionHandler.java

@@ -6,10 +6,19 @@ import lombok.extern.slf4j.Slf4j;
 import org.springframework.web.bind.annotation.ExceptionHandler;
 import org.springframework.web.bind.annotation.RestControllerAdvice;
 
+import java.util.Map;
+
 @Slf4j
 @RestControllerAdvice(basePackages = "com.fs.comm")
 public class CommGlobalExceptionHandler {
 
+    @ExceptionHandler(CommBlacklistRejectException.class)
+    public CommApiResult<Map<String, Object>> handleBlacklistReject(CommBlacklistRejectException e) {
+        log.warn("黑名单拦截: {}", e.getMessage());
+        int code = e.getCode() != null ? e.getCode() : CommBlacklistRejectException.BLACKLIST_REJECT_CODE;
+        return CommApiResult.error(code, e.getMessage(), e.toResponseData());
+    }
+
     @ExceptionHandler(ServiceException.class)
     public CommApiResult<Void> handleServiceException(ServiceException e) {
         log.warn("业务异常: {}", e.getMessage());

+ 30 - 8
fs-comm-gateway/src/main/java/com/fs/comm/security/CommTokenAuthFilter.java

@@ -19,6 +19,8 @@ import com.fs.system.mapper.SysConfigMapper;
 import com.fs.wxcid.utils.TenantHelper;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Value;
+import org.springframework.core.annotation.Order;
+import org.springframework.core.Ordered;
 import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
 import org.springframework.security.core.context.SecurityContextHolder;
 import org.springframework.stereotype.Component;
@@ -31,7 +33,12 @@ import javax.servlet.http.HttpServletResponse;
 import java.io.IOException;
 import java.util.Collections;
 
+/**
+ * 通讯网关 Token / 内部调用鉴权。
+ * 回调接口(/comm/callback/**)与 Token 签发接口(/comm/auth/**)不做鉴权拦截。
+ */
 @Component
+@Order(Ordered.HIGHEST_PRECEDENCE + 20)
 public class CommTokenAuthFilter extends OncePerRequestFilter {
 
     public static final String HEADER_INTERNAL_SECRET = "X-Comm-Internal-Secret";
@@ -54,9 +61,9 @@ public class CommTokenAuthFilter extends OncePerRequestFilter {
     @Override
     protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
             throws ServletException, IOException {
-        String path = request.getRequestURI();
+        String path = resolveRequestPath(request);
         try {
-            CommSession session = resolveSession(request);
+            CommSession session = resolveSession(request, path);
             if (session != null) {
                 commTokenService.verifySession(session);
                 if (session.getTenantId() != null) {
@@ -88,9 +95,8 @@ public class CommTokenAuthFilter extends OncePerRequestFilter {
         }
     }
 
-    private CommSession resolveSession(HttpServletRequest request) {
-        String path = request.getRequestURI();
-        if (path.startsWith("/comm/auth/token") || path.startsWith("/comm/callback/")) {
+    private CommSession resolveSession(HttpServletRequest request, String path) {
+        if (isPublicCommPath(path)) {
             return null;
         }
         CommSession session = commTokenService.getSession(request);
@@ -112,8 +118,24 @@ public class CommTokenAuthFilter extends OncePerRequestFilter {
         if (!path.startsWith("/comm/")) {
             return false;
         }
-        return !path.startsWith("/comm/auth/token")
-                && !path.startsWith("/comm/callback/");
+        return !isPublicCommPath(path);
+    }
+
+    private boolean isPublicCommPath(String path) {
+        return path.startsWith("/comm/auth/") || path.startsWith("/comm/callback/");
+    }
+
+    private String resolveRequestPath(HttpServletRequest request) {
+        String servletPath = request.getServletPath();
+        if (StringUtils.isNotBlank(servletPath)) {
+            return servletPath;
+        }
+        String uri = request.getRequestURI();
+        String contextPath = request.getContextPath();
+        if (StringUtils.isNotBlank(contextPath) && uri.startsWith(contextPath)) {
+            return uri.substring(contextPath.length());
+        }
+        return uri;
     }
 
     private boolean isInternalRequest(HttpServletRequest request) {
@@ -149,7 +171,7 @@ public class CommTokenAuthFilter extends OncePerRequestFilter {
 
     @Override
     protected boolean shouldNotFilter(HttpServletRequest request) {
-        return false;
+        return isPublicCommPath(resolveRequestPath(request));
     }
 
     public static void writeUnauthorized(HttpServletResponse response, String msg) throws IOException {

+ 12 - 1
fs-comm-gateway/src/main/java/com/fs/comm/service/CommCallService.java

@@ -2,6 +2,7 @@ package com.fs.comm.service;
 
 import com.fs.comm.context.CommAuthContext;
 import com.fs.comm.dto.CommCallSendRequest;
+import com.fs.comm.exception.CommBlacklistRejectException;
 import com.fs.comm.metrics.CommMetricsService;
 import com.fs.comm.model.CommCallSendParam;
 import com.fs.comm.model.CommCallSendResult;
@@ -10,6 +11,7 @@ import com.fs.comm.support.CommTenantDataSourceHelper;
 import com.fs.common.exception.ServiceException;
 import com.fs.common.utils.StringUtils;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 
@@ -41,6 +43,10 @@ public class CommCallService {
     @Autowired
     private CommTenantDataSourceHelper commTenantDataSourceHelper;
 
+    /** EasyCall 外呼回调地址,由 comm-gateway 的 application.yml 固定配置 */
+    @Value("${easycall.callback-url}")
+    private String callbackUrl;
+
     public Map<String, Object> sendCall(CommCallSendRequest request) {
         long startMs = System.currentTimeMillis();
         Long companyId = CommAuthContext.getCompanyId();
@@ -72,8 +78,9 @@ public class CommCallService {
                     .workflowInstanceId(request.getWorkflowInstanceId())
                     .companyId(companyId)
                     .tenantId(tenantId)
-                    .callbackUrl(request.getCallbackUrl())
+                    .callbackUrl(callbackUrl)
                     .phone(request.getPhone())
+                    .companyUserId(request.getCompanyUserId())
                     .bizParams(request.getBizParams())
                     .build());
 
@@ -93,6 +100,10 @@ public class CommCallService {
             recordResult = commGatewayApiLogRecorder.buildSuccess(response, calleePhone, callerKey, gatewayId);
             commMetricsService.increment("call.success");
             return response;
+        } catch (CommBlacklistRejectException ex) {
+            recordResult = commGatewayApiLogRecorder.buildBlacklistFailure(ex, calleePhone, callerKey, gatewayId);
+            commMetricsService.increment("call.blacklist");
+            throw ex;
         } catch (ServiceException ex) {
             boolean limitHit = CommGatewayApiLogRecorder.isLimitFailure(ex);
             recordResult = limitHit

+ 27 - 0
fs-comm-gateway/src/main/java/com/fs/comm/service/CommGatewayApiLogRecorder.java

@@ -8,6 +8,7 @@ import com.fs.comm.dto.CommCallSendRequest;
 import com.fs.comm.dto.CommSmsSendRequest;
 import com.fs.comm.model.CommGatewayBillingQuote;
 import com.fs.comm.support.CommTenantDataSourceHelper;
+import com.fs.comm.exception.CommBlacklistRejectException;
 import com.fs.common.exception.ServiceException;
 import com.fs.common.utils.ServletUtils;
 import com.fs.common.utils.StringUtils;
@@ -223,6 +224,32 @@ public class CommGatewayApiLogRecorder {
         return message.contains("限制") || message.contains("超限") || message.contains("频率");
     }
 
+    /** 是否为黑名单拦截失败 */
+    public static boolean isBlacklistFailure(Throwable ex) {
+        return ex instanceof CommBlacklistRejectException;
+    }
+
+    public CommApiRecordResult buildBlacklistFailure(CommBlacklistRejectException ex,
+                                                     String calleePhone,
+                                                     String callerPhone,
+                                                     Long gatewayId) {
+        Map<String, Object> body = new HashMap<>();
+        int code = ex.getCode() != null ? ex.getCode() : CommBlacklistRejectException.BLACKLIST_REJECT_CODE;
+        body.put("code", code);
+        body.put("msg", ex.getMessage());
+        body.put("data", ex.toResponseData());
+        return CommApiRecordResult.builder()
+                .success(false)
+                .limitHit(false)
+                .resultCode(code)
+                .resultMsg(ex.getMessage())
+                .responseBody(JSON.toJSONString(body))
+                .calleePhone(calleePhone)
+                .callerPhone(callerPhone)
+                .gatewayId(gatewayId)
+                .build();
+    }
+
     public CommApiRecordResult buildLimitFailure(ServiceException ex, String calleePhone, String callerPhone, Long gatewayId) {
         Map<String, Object> body = new HashMap<>();
         body.put("code", 500);

+ 4 - 0
fs-comm-gateway/src/main/java/com/fs/comm/sms/MyCommSmsProvider.java

@@ -72,6 +72,10 @@ public class MyCommSmsProvider implements CommSmsProvider {
         if (!Integer.valueOf(1).equals(request.getTempType()) && !Integer.valueOf(2).equals(request.getTempType())) {
             return "UNSUPPORTED_TEMP_TYPE";
         }
+        if (StringUtils.isBlank(request.getSign()) && StringUtils.isNotBlank(request.getContent())
+                && request.getContent().contains("${sms.sign}")) {
+            return "SMS_SIGN_MISSING";
+        }
         return null;
     }
 

+ 1 - 0
fs-comm-gateway/src/main/java/com/fs/framework/config/SecurityConfig.java

@@ -20,6 +20,7 @@ public class SecurityConfig {
     public SecurityFilterChain commGatewaySecurityFilterChain(HttpSecurity http) throws Exception {
         http.csrf().disable()
                 .authorizeRequests()
+                .antMatchers("/comm/callback/**", "/comm/auth/**").permitAll()
                 .antMatchers("/**").permitAll();
         return http.build();
     }

+ 44 - 0
fs-company/src/main/java/com/fs/company/controller/company/CompanyVoiceRoboticCallLogCallphoneController.java

@@ -9,6 +9,7 @@ import com.fs.common.utils.ServletUtils;
 import com.fs.common.utils.poi.ExcelUtil;
 import com.fs.common.utils.SecurityUtils;
 import com.fs.company.domain.CompanyVoiceRoboticCallLogCallphone;
+import com.fs.company.service.ICompanyAiCallDataSyncService;
 import com.fs.company.service.ICompanyVoiceRoboticCallLogCallphoneService;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogCallPhoneDecryptExportVO;
 import com.fs.crm.domain.CrmCustomer;
@@ -17,6 +18,7 @@ import com.fs.company.vo.CompanyVoiceRoboticCallLogCallPhoneVO;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogCount;
 import com.fs.framework.security.LoginUser;
 import com.fs.framework.service.TokenService;
+import com.fs.wxcid.utils.TenantHelper;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
@@ -41,6 +43,25 @@ public class CompanyVoiceRoboticCallLogCallphoneController extends BaseControlle
     private ICrmCustomerService crmCustomerService;
     @Autowired
     private TokenService tokenService;
+
+    @Autowired
+    private ICompanyAiCallDataSyncService companyAiCallDataSyncService;
+
+    /**
+     * 异步同步 AI 外呼数据(补偿 EasyCall 已完成但未回调的记录)
+     */
+    @PreAuthorize("@ss.hasPermi('company:callphoneDetail:list')")
+    @Log(title = "同步AI外呼数据", businessType = BusinessType.OTHER)
+    @PostMapping("/syncAiCallData")
+    public AjaxResult syncAiCallData() {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        Long companyId = loginUser.getCompany().getCompanyId();
+        TenantHelper.setTenantId(loginUser.getTenantId());
+        if (!companyAiCallDataSyncService.tryStartSync(companyId)) {
+            return AjaxResult.error("正在同步中,请勿重复操作");
+        }
+        return AjaxResult.success("正在同步中");
+    }
     /**
      * 查询调用日志_ai打电话列表
      */
@@ -77,6 +98,29 @@ public class CompanyVoiceRoboticCallLogCallphoneController extends BaseControlle
 
     }
 
+    /**
+     * AI外呼记录明细列表
+     */
+    @PreAuthorize("@ss.hasPermi('company:callphoneDetail:list')")
+    @GetMapping("/detailList")
+    public TableDataInfo detailList(CompanyVoiceRoboticCallLogCallphone param) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        companyVoiceRoboticCallLogCallphoneService.prepareDetailListQuery(param, loginUser.getCompany().getCompanyId());
+        startPage();
+        List<CompanyVoiceRoboticCallLogCallPhoneVO> list = companyVoiceRoboticCallLogCallphoneService.selectDetailList(param);
+        return getDataTable(list);
+    }
+
+    /**
+     * AI外呼记录明细统计
+     */
+    @PreAuthorize("@ss.hasPermi('company:callphoneDetail:list')")
+    @GetMapping("/detailSummary")
+    public AjaxResult detailSummary(CompanyVoiceRoboticCallLogCallphone param) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        companyVoiceRoboticCallLogCallphoneService.prepareDetailListQuery(param, loginUser.getCompany().getCompanyId());
+        return AjaxResult.success(companyVoiceRoboticCallLogCallphoneService.selectDetailSummary(param));
+    }
 
     /**
      * 查询调用日志_发送短信列表(按照任务id分组,任务id-任务名称-查询总任务数量-成功数量)

+ 14 - 2
fs-company/src/main/java/com/fs/company/controller/company/CompanyVoiceRoboticController.java

@@ -115,6 +115,20 @@ public class CompanyVoiceRoboticController extends BaseController
         companyVoiceRobotic.setCompanyId(loginUser.getCompany().getCompanyId());
         return R.ok().put("data", companyVoiceRoboticService.selectCompanyVoiceRoboticListCompany(companyVoiceRobotic));
     }
+
+    /**
+     * 外呼任务下拉选项(分页 + 名称模糊搜索,按当前登录公司过滤)
+     */
+    @PreAuthorize("@ss.hasPermi('company:callphoneDetail:list')")
+    @GetMapping("/selectOptions")
+    public TableDataInfo selectOptions(CompanyVoiceRobotic companyVoiceRobotic) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        companyVoiceRobotic.setCompanyId(loginUser.getCompany().getCompanyId());
+        startPage();
+        List<CompanyVoiceRobotic> list = companyVoiceRoboticService.selectCompanyVoiceRoboticListCompany(companyVoiceRobotic);
+        return getDataTable(list);
+    }
+
     @PreAuthorize("@ss.hasPermi('system:companyVoiceRobotic:list')")
     @GetMapping("/calleesList")
     public TableDataInfo calleesList(Long id){
@@ -267,10 +281,8 @@ public class CompanyVoiceRoboticController extends BaseController
     public R qwUserList(){
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
         Long companyId = loginUser.getCompany().getCompanyId();
-        Long userId = loginUser.getUser().getUserId();
         CompanyVoiceRobotic robotic = new CompanyVoiceRobotic();
         robotic.setCompanyId(companyId);
-        robotic.setCompanyUserId(userId);
         return R.ok().put("data", companyVoiceRoboticService.qwUserListCompany(robotic));
     }
 

+ 12 - 0
fs-company/src/main/java/com/fs/company/controller/crm/CustomerAllController.java

@@ -62,4 +62,16 @@ public class CustomerAllController extends BaseController {
         param.setCompanyUserId(loginUser.getUser().getUserId());
         return crmCustomerService.recover(param, operName);
     }
+
+    @ApiOperation("批量回收公海")
+    @PreAuthorize("@ss.hasPermi('crm:customer:recover')")
+    @PostMapping("/batchRecover")
+    public R batchRecover(@RequestBody CrmCustomeRecoverParam param)
+    {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        String operName = loginUser.getUsername();
+        param.setCompanyId(loginUser.getCompany().getCompanyId());
+        param.setCompanyUserId(loginUser.getUser().getUserId());
+        return crmCustomerService.batchRecover(param, operName);
+    }
 }

+ 10 - 10
fs-company/src/main/java/com/fs/company/controller/workflow/LobsterE2eController.java

@@ -42,7 +42,7 @@ public class LobsterE2eController extends BaseController {
     @PreAuthorize("@ss.hasPermi('workflow:lobster:query')")
     @PostMapping("/workflow/lobster/e2e/run")
     public AjaxResult e2eRun(@RequestBody Map<String, Object> body) {
-        if (e2eTestService == null) return AjaxResult.error("E2E ���Է���δ����");
+        if (e2eTestService == null) return AjaxResult.error("E2E Էδ");
         LobsterE2eTestService.E2eRequest req = buildE2eRequest(body, currentCompanyId());
         return AjaxResult.success(e2eTestService.runE2e(req));
     }
@@ -50,7 +50,7 @@ public class LobsterE2eController extends BaseController {
     @PreAuthorize("@ss.hasPermi('workflow:lobster:query')")
     @GetMapping("/workflow/lobster/e2e/report/{runId}")
     public AjaxResult e2eReport(@PathVariable String runId) {
-        if (e2eTestService == null) return AjaxResult.error("E2E ���Է���δ����");
+        if (e2eTestService == null) return AjaxResult.error("E2E Էδ");
         return AjaxResult.success(e2eTestService.getReport(runId));
     }
 
@@ -65,7 +65,7 @@ public class LobsterE2eController extends BaseController {
     @PreAuthorize("@ss.hasPermi('workflow:lobster:exec')")
     @PostMapping("/workflow/lobster-exec/step-next/{instanceId}")
     public AjaxResult stepNext(@PathVariable Long instanceId, @RequestBody Map<String, Object> body) {
-        if (e2eTestService == null) return AjaxResult.error("E2E ���Է���δ����");
+        if (e2eTestService == null) return AjaxResult.error("E2E Էδ");
         String userInput = body != null ? (String) body.get("userInput") : null;
         return AjaxResult.success(e2eTestService.stepNext(currentCompanyId(), instanceId, userInput));
     }
@@ -73,7 +73,7 @@ public class LobsterE2eController extends BaseController {
     @PreAuthorize("@ss.hasPermi('workflow:lobster:exec')")
     @PostMapping("/workflow/lobster/chat/multi-turn")
     public AjaxResult multiTurn(@RequestBody Map<String, Object> body) {
-        if (e2eTestService == null) return AjaxResult.error("E2E ���Է���δ����");
+        if (e2eTestService == null) return AjaxResult.error("E2E Էδ");
         List<String> inputs = parseStringList(body.get("userInputs"));
         return AjaxResult.success(e2eTestService.multiTurn(
                 currentCompanyId(),
@@ -94,14 +94,14 @@ public class LobsterE2eController extends BaseController {
     @PreAuthorize("@ss.hasPermi('workflow:lobster:query')")
     @GetMapping("/workflow/lobster/scenario/{id}")
     public AjaxResult scenarioGet(@PathVariable Long id) {
-        if (testScenarioService == null) return AjaxResult.error("�������");
+        if (testScenarioService == null) return AjaxResult.error("籾δ");
         return AjaxResult.success(testScenarioService.getScenario(id));
     }
 
     @PreAuthorize("@ss.hasPermi('workflow:lobster:edit')")
     @PostMapping("/workflow/lobster/scenario/save")
     public AjaxResult scenarioSave(@RequestBody Map<String, Object> body) {
-        if (testScenarioService == null) return AjaxResult.error("�������");
+        if (testScenarioService == null) return AjaxResult.error("籾δ");
         body.putIfAbsent("companyId", currentCompanyId());
         Object idObj = body.get("id");
         if (idObj == null) {
@@ -121,7 +121,7 @@ public class LobsterE2eController extends BaseController {
     @PreAuthorize("@ss.hasPermi('workflow:lobster:exec')")
     @PostMapping("/workflow/lobster/scenario/{id}/run")
     public AjaxResult scenarioRunNow(@PathVariable Long id) {
-        if (testScenarioService == null) return AjaxResult.error("�������");
+        if (testScenarioService == null) return AjaxResult.error("籾δ");
         String runId = testScenarioService.runScenarioNow(id);
         Map<String, Object> r = new HashMap<>();
         r.put("runId", runId);
@@ -131,7 +131,7 @@ public class LobsterE2eController extends BaseController {
     @PreAuthorize("@ss.hasPermi('workflow:lobster:exec')")
     @PostMapping("/workflow/lobster/scenario/run-all")
     public AjaxResult scenarioRunAll() {
-        if (testScenarioService == null) return AjaxResult.error("�������");
+        if (testScenarioService == null) return AjaxResult.error("籾δ");
         Map<String, Object> r = new HashMap<>();
         r.put("triggered", testScenarioService.runAllEnabledScenarios());
         return AjaxResult.success(r);
@@ -147,7 +147,7 @@ public class LobsterE2eController extends BaseController {
     @PreAuthorize("@ss.hasPermi('workflow:lobster:edit')")
     @PostMapping("/workflow/lobster/dynamic-impl/{id}/approve")
     public AjaxResult dynamicImplApprove(@PathVariable Long id) {
-        if (dynamicNodeImplService == null) return AjaxResult.error("�������");
+        if (dynamicNodeImplService == null) return AjaxResult.error("δ");
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
         dynamicNodeImplService.approve(id, loginUser.getUsername());
         return AjaxResult.success();
@@ -156,7 +156,7 @@ public class LobsterE2eController extends BaseController {
     @PreAuthorize("@ss.hasPermi('workflow:lobster:edit')")
     @PostMapping("/workflow/lobster/dynamic-impl/{id}/reject")
     public AjaxResult dynamicImplReject(@PathVariable Long id, @RequestParam String reason) {
-        if (dynamicNodeImplService == null) return AjaxResult.error("�������");
+        if (dynamicNodeImplService == null) return AjaxResult.error("δ");
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
         dynamicNodeImplService.reject(id, loginUser.getUsername(), reason);
         return AjaxResult.success();

+ 20 - 47
fs-service/src/main/java/com/fs/aiSipCall/service/impl/AiSipCallOutboundCdrServiceImpl.java

@@ -1,7 +1,6 @@
 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;
@@ -23,12 +22,12 @@ import com.fs.company.domain.CompanyVoiceRoboticCallLogCallphone;
 import com.fs.company.domain.CrmCustomerCallLog;
 import com.fs.company.mapper.*;
 import com.fs.company.service.CompanyWorkflowEngine;
-import com.fs.company.vo.CidConfigVO;
+import com.fs.comm.model.CallBalanceDeductionResult;
+import com.fs.comm.service.CallBalanceDeductionService;
 import com.fs.company.vo.easycall.EasyCallOutBoundVO;
 import com.fs.his.utils.PhoneUtil;
 import com.fs.sensitive.DTO.AgentSensitiveWordDetectResultDTO;
 import com.fs.sensitive.component.AgentSensitiveWordDetector;
-import com.fs.system.service.ISysConfigService;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.lang3.StringUtils;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -74,10 +73,7 @@ public class AiSipCallOutboundCdrServiceImpl extends ServiceImpl<AiSipCallOutbou
     @Autowired
     private CompanyWorkflowEngine companyWorkflowEngine;
     @Autowired
-    private ISysConfigService configService;
-
-    private static final BigDecimal DEFAULT_CALL_CHARGE = new BigDecimal("0.12");
-    private static final BigDecimal ONE_MINUTES_SECOND = new BigDecimal("60");
+    private CallBalanceDeductionService callBalanceDeductionService;
 
     @Autowired
     private CrmCustomerCallLogMapper crmCustomerCallLogMapper;
@@ -626,24 +622,15 @@ public class AiSipCallOutboundCdrServiceImpl extends ServiceImpl<AiSipCallOutbou
             companyVoiceRoboticCallLogCallphone.setViolationNum(resultDTO.getViolationCount());
         }
 
-        String json = configService.selectConfigByKey("cId.config");
-        CidConfigVO cidConfigVO = JSONUtil.toBean(json, CidConfigVO.class);
-        BigDecimal callCharge = cidConfigVO.getCallCharge();
-        //
-        if (null == callCharge) {
-            callCharge = DEFAULT_CALL_CHARGE;
-        }
-        //向上取整分钟数
-        Long callTime = companyVoiceRoboticCallLogCallphone.getCallTime();
-        if (callTime != null) {
-            // 毫秒转秒
-//            BigDecimal callTimeSecond = new BigDecimal(companyVoiceRoboticCallLogCallphone.getCallTime()).divide(new BigDecimal(1000), 0, RoundingMode.CEILING);
-            BigDecimal divide = new BigDecimal(callTime).divide(ONE_MINUTES_SECOND, 0, RoundingMode.CEILING);
-            BigDecimal multiply = divide.multiply(callCharge);
-            companyVoiceRoboticCallLogCallphone.setCost(multiply);
+        if (callPhoneRes.getTimeLenValid() != null && callPhoneRes.getTimeLenValid() > 0
+                && req.getTenantId() != null && StringUtils.isNotBlank(req.getUuid())) {
+            CallBalanceDeductionResult deductResult = callBalanceDeductionService.deductOnConnected(
+                    req.getTenantId(), req.getUuid(), callPhoneRes.getTimeLenValid());
+            if (deductResult.isCharged() && deductResult.getAmount() != null) {
+                companyVoiceRoboticCallLogCallphone.setCost(deductResult.getAmount());
+            }
         }
 
-
         int i = companyVoiceRoboticCallLogCallphoneMapper.insertCompanyVoiceRoboticCallLogCallphone(companyVoiceRoboticCallLogCallphone);
 
         Map<String, Object> param = new HashMap<>();
@@ -683,34 +670,20 @@ public class AiSipCallOutboundCdrServiceImpl extends ServiceImpl<AiSipCallOutbou
         }
         callLog.setCallType(Integer.valueOf(callType));
 
-        BigDecimal callCharge = DEFAULT_CALL_CHARGE;
-        String json = configService.selectConfigByKey("cId.config");
-        if (StringUtils.isNotBlank(json)) {
-            try {
-                CidConfigVO cidConfigVO = JSONUtil.toBean(json, CidConfigVO.class);
-
-                if (cidConfigVO != null && cidConfigVO.getCallCharge() != null) {
-                    callCharge = cidConfigVO.getCallCharge();
+        if (callPhoneRes.getTimeLenValid() != null && callPhoneRes.getTimeLenValid() > 0
+                && req.getTenantId() != null && StringUtils.isNotBlank(req.getUuid())) {
+            CallBalanceDeductionResult deductResult = callBalanceDeductionService.deductOnConnected(
+                    req.getTenantId(), req.getUuid(), callPhoneRes.getTimeLenValid());
+            if (deductResult.isCharged()) {
+                if (deductResult.getAmount() != null) {
+                    callLog.setCost(deductResult.getAmount());
+                }
+                if (deductResult.getBillingMinutes() != null) {
+                    callLog.setBillingMinute(deductResult.getBillingMinutes());
                 }
-            } catch (Exception e) {
-                log.error("解析 cId.config 配置失败", e);
             }
         }
 
-        if (callCharge == null) {
-            callCharge = DEFAULT_CALL_CHARGE;
-        }
-
-        Long callTime = callLog.getCallTime();
-        if (callTime != null) {
-            // 毫秒转秒
-            BigDecimal callTimeSecond = new BigDecimal(callTime).divide(new BigDecimal(1000), 0, RoundingMode.CEILING);
-            BigDecimal minuteCount = callTimeSecond.divide(ONE_MINUTES_SECOND, 0, RoundingMode.CEILING);
-            BigDecimal cost = minuteCount.multiply(callCharge);
-            callLog.setCost(cost);
-            callLog.setBillingMinute(minuteCount.intValue());
-        }
-
         return crmCustomerCallLogMapper.insertCrmCustomerCallLog(callLog);
     }
 

+ 1 - 1
fs-service/src/main/java/com/fs/baidu/service/impl/BdApiServiceImpl.java

@@ -29,7 +29,7 @@ public class BdApiServiceImpl extends ServiceImpl<BdApiMapper, BdApi> implements
 
     private final String OAUTH_URL = "https://u.baidu.com/oauth/page/index?platformId=4960345965958561794&appId={appId}&scope={scope}&state={state}&callback={callback}";
 
-    @Value("${baidu.back-domain}")
+    @Value("${baidu.back-domain:null}")
     private String backDomain;
 
     /**

+ 8 - 1
fs-service/src/main/java/com/fs/comm/client/CommGatewayClient.java

@@ -4,6 +4,7 @@ import cn.hutool.http.HttpRequest;
 import cn.hutool.http.HttpResponse;
 import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONObject;
+import com.fs.comm.exception.CommBlacklistRejectException;
 import com.fs.common.exception.ServiceException;
 import com.fs.common.utils.StringUtils;
 import com.fs.wxcid.utils.TenantHelper;
@@ -70,9 +71,15 @@ public class CommGatewayClient {
             }
             Integer code = result.getInteger("code");
             if (code == null || code != 200) {
-                throw new ServiceException(StringUtils.defaultIfBlank(result.getString("msg"), "通讯网关调用失败"));
+                String msg = StringUtils.defaultIfBlank(result.getString("msg"), "通讯网关调用失败");
+                if (Integer.valueOf(CommBlacklistRejectException.BLACKLIST_REJECT_CODE).equals(code)) {
+                    throw CommBlacklistRejectException.fromGatewayResponse(msg, result.getJSONObject("data"));
+                }
+                throw new ServiceException(msg, code);
             }
             return result.getJSONObject("data");
+        } catch (CommBlacklistRejectException ex) {
+            throw ex;
         } catch (ServiceException ex) {
             throw ex;
         } catch (Exception ex) {

+ 74 - 0
fs-service/src/main/java/com/fs/comm/exception/CommBlacklistRejectException.java

@@ -0,0 +1,74 @@
+package com.fs.comm.exception;
+
+import com.alibaba.fastjson.JSONObject;
+import com.fs.company.enums.BusinessTypeEnum;
+import com.fs.company.vo.CompanyVoiceRoboticCallBlacklistCheckVO;
+import lombok.Getter;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 通讯网关黑名单拦截异常(外呼/短信等)
+ */
+@Getter
+public class CommBlacklistRejectException extends RuntimeException {
+
+    public static final int BLACKLIST_REJECT_CODE = 403;
+
+    private final Integer code;
+
+    private final String sceneCode;
+
+    private final CompanyVoiceRoboticCallBlacklistCheckVO checkResult;
+
+    public CommBlacklistRejectException(String sceneCode, CompanyVoiceRoboticCallBlacklistCheckVO checkResult) {
+        super(resolveMessage(checkResult));
+        this.code = BLACKLIST_REJECT_CODE;
+        this.sceneCode = sceneCode;
+        this.checkResult = checkResult;
+    }
+
+    private static String resolveMessage(CompanyVoiceRoboticCallBlacklistCheckVO vo) {
+        if (vo != null && vo.getReason() != null && !vo.getReason().isEmpty()) {
+            return vo.getReason();
+        }
+        return "被叫人命中外呼黑名单";
+    }
+
+    public Map<String, Object> toResponseData() {
+        Map<String, Object> data = new HashMap<>();
+        data.put("blocked", true);
+        data.put("hitBlacklist", true);
+        data.put("sceneCode", sceneCode != null ? sceneCode : BusinessTypeEnum.CALL.getCode());
+        if (checkResult != null) {
+            data.put("blacklistId", checkResult.getBlacklistId());
+            data.put("targetType", checkResult.getTargetType());
+            data.put("interceptReason", checkResult.getReason());
+            data.put("blacklistReason", checkResult.getBlacklistReason());
+            data.put("interceptLogId", checkResult.getInterceptLogId());
+        }
+        return data;
+    }
+
+    public static CommBlacklistRejectException fromGatewayResponse(String msg, JSONObject data) {
+        CompanyVoiceRoboticCallBlacklistCheckVO vo = new CompanyVoiceRoboticCallBlacklistCheckVO();
+        vo.setPass(false);
+        vo.setHitBlacklist(true);
+        vo.setReason(msg);
+        String sceneCode = BusinessTypeEnum.CALL.getCode();
+        if (data != null) {
+            if (data.containsKey("sceneCode")) {
+                sceneCode = data.getString("sceneCode");
+            }
+            vo.setBlacklistId(data.getLong("blacklistId"));
+            vo.setTargetType(data.getInteger("targetType"));
+            if (data.containsKey("interceptReason")) {
+                vo.setReason(data.getString("interceptReason"));
+            }
+            vo.setBlacklistReason(data.getString("blacklistReason"));
+            vo.setInterceptLogId(data.getLong("interceptLogId"));
+        }
+        return new CommBlacklistRejectException(sceneCode, vo);
+    }
+}

+ 28 - 0
fs-service/src/main/java/com/fs/comm/model/CallBalanceDeductionResult.java

@@ -0,0 +1,28 @@
+package com.fs.comm.model;
+
+import com.fs.proxy.enums.ConsumeServiceResult;
+import lombok.Builder;
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * �����ͨ�۷ѽ��
+ */
+@Data
+@Builder
+public class CallBalanceDeductionResult {
+    private ConsumeServiceResult result;
+    private BigDecimal amount;
+    private Integer billingMinutes;
+    private String orderNo;
+    private Long consumeRecordId;
+
+    public boolean isCharged() {
+        return result == ConsumeServiceResult.SUCCESS || result == ConsumeServiceResult.ALREADY_CONSUMED;
+    }
+
+    public boolean isSkipped() {
+        return result == null;
+    }
+}

+ 5 - 0
fs-service/src/main/java/com/fs/comm/model/CommCallSendParam.java

@@ -17,6 +17,9 @@ public class CommCallSendParam {
 
     private Long gatewayId;
 
+    /** 通讯接口 ID(company_sms_api.api_id,可选;为空则按租户绑定自动路由) */
+    private Long apiId;
+
     private String nodeKey;
 
     private String workflowInstanceId;
@@ -29,5 +32,7 @@ public class CommCallSendParam {
 
     private String phone;
 
+    private Long companyUserId;
+
     private Map<String, Object> bizParams;
 }

+ 105 - 0
fs-service/src/main/java/com/fs/comm/service/CallBalanceDeductionService.java

@@ -0,0 +1,105 @@
+package com.fs.comm.service;
+
+import com.fs.comm.model.CallBalanceDeductionResult;
+import com.fs.comm.support.CompanySmsMasterDataSourceHelper;
+import com.fs.common.utils.StringUtils;
+import com.fs.proxy.enums.ConsumeServiceResult;
+import com.fs.proxy.enums.ConsumeTypeEnum;
+import com.fs.proxy.model.ConsumeServiceOutcome;
+import com.fs.proxy.service.BalanceService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+
+/**
+ * 外呼接通后从租户总账户扣费(幂等、高并发安全)
+ */
+@Slf4j
+@Service
+public class CallBalanceDeductionService {
+
+    public static final String CALL_ORDER_PREFIX = "AICALL:";
+
+    private static final BigDecimal ONE_MINUTES_SECOND = new BigDecimal("60");
+
+    @Autowired
+    private BalanceService balanceService;
+
+    @Autowired
+    private CompanySmsMasterDataSourceHelper masterDataSourceHelper;
+
+    /**
+     * 发起外呼前校验:至少能支付 1 分钟(主库查余额后自动切回租户库)
+     */
+    public boolean hasBalanceForOneMinute(Long tenantId) {
+        if (tenantId == null) {
+            return false;
+        }
+        return Boolean.TRUE.equals(masterDataSourceHelper.runOnMasterThenRestoreTenant(tenantId, () ->
+                balanceService.checkPayAsYouGoBalance(tenantId, ConsumeTypeEnum.AI_CALL, 1)));
+    }
+
+    /**
+     * 外呼接通后扣费:按有效通话时长向上取整分钟计费(主库扣费后自动切回租户库)
+     *
+     * @param tenantId       租户ID
+     * @param callUuid       通话唯一标识(幂等键)
+     * @param validTimeLenMs 有效通话时长(毫秒),<=0 表示未接通不扣费
+     */
+    public CallBalanceDeductionResult deductOnConnected(Long tenantId, String callUuid, Integer validTimeLenMs) {
+        if (tenantId == null || StringUtils.isBlank(callUuid)) {
+            return CallBalanceDeductionResult.builder().result(ConsumeServiceResult.INVALID_PARAM).build();
+        }
+        if (validTimeLenMs == null || validTimeLenMs <= 0) {
+            return CallBalanceDeductionResult.builder().build();
+        }
+
+        int billingMinutes = calcBillingMinutes(validTimeLenMs);
+        if (billingMinutes <= 0) {
+            return CallBalanceDeductionResult.builder().build();
+        }
+
+        String orderNo = buildOrderNo(callUuid);
+        String remark = "AI外呼接通扣费,uuid=" + callUuid + ",minutes=" + billingMinutes;
+        ConsumeServiceOutcome outcome = masterDataSourceHelper.runOnMasterThenRestoreTenant(tenantId, () ->
+                balanceService.consumeServiceIdempotent(
+                        tenantId, ConsumeTypeEnum.AI_CALL, billingMinutes, orderNo, remark));
+
+        if (outcome.getResult() == ConsumeServiceResult.INSUFFICIENT_BALANCE) {
+            log.warn("外呼接通扣费失败-余额不足 tenantId={}, uuid={}, minutes={}", tenantId, callUuid, billingMinutes);
+        } else if (outcome.getResult() == ConsumeServiceResult.FAILED
+                || outcome.getResult() == ConsumeServiceResult.LOCK_TIMEOUT) {
+            log.error("外呼接通扣费失败 tenantId={}, uuid={}, result={}", tenantId, callUuid, outcome.getResult());
+        } else if (outcome.getResult() == ConsumeServiceResult.SUCCESS) {
+            log.info("外呼接通扣费成功 tenantId={}, uuid={}, amount={}, minutes={}",
+                    tenantId, callUuid, outcome.getAmount(), billingMinutes);
+        }
+
+        return CallBalanceDeductionResult.builder()
+                .result(outcome.getResult())
+                .amount(outcome.getAmount())
+                .billingMinutes(billingMinutes)
+                .orderNo(orderNo)
+                .consumeRecordId(outcome.getRecordId())
+                .build();
+    }
+
+    public static String buildOrderNo(String callUuid) {
+        return CALL_ORDER_PREFIX + callUuid;
+    }
+
+    /**
+     * 毫秒 → 秒(向上取整)→ 分钟(向上取整)
+     */
+    public static int calcBillingMinutes(int validTimeLenMs) {
+        BigDecimal seconds = new BigDecimal(validTimeLenMs)
+                .divide(new BigDecimal(1000), 0, RoundingMode.CEILING);
+        if (seconds.compareTo(BigDecimal.ZERO) <= 0) {
+            return 0;
+        }
+        return seconds.divide(ONE_MINUTES_SECOND, 0, RoundingMode.CEILING).intValue();
+    }
+}

+ 65 - 15
fs-service/src/main/java/com/fs/comm/service/CommCallSendService.java

@@ -5,6 +5,7 @@ import com.alibaba.fastjson.JSONObject;
 import com.fs.common.core.redis.RedisCache;
 import com.fs.common.exception.ServiceException;
 import com.fs.common.utils.StringUtils;
+import com.fs.comm.exception.CommBlacklistRejectException;
 import com.fs.comm.model.CommCallSendParam;
 import com.fs.comm.model.CommCallSendResult;
 import com.fs.company.domain.*;
@@ -22,10 +23,13 @@ import com.fs.crm.domain.CrmCustomer;
 import com.fs.crm.service.ICrmCustomerService;
 import com.fs.his.config.CidPhoneConfig;
 import com.fs.his.utils.PhoneUtil;
+import com.fs.proxy.domain.CompanySmsApi;
+import com.fs.proxy.service.ICompanySmsPortService;
 import com.fs.system.service.ISysConfigService;
 import com.fs.wxcid.utils.TenantHelper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
 
 import java.util.Collections;
@@ -80,6 +84,12 @@ public class CommCallSendService {
     @Autowired
     private RedisCache redisCache;
 
+    @Autowired
+    private CallBalanceDeductionService callBalanceDeductionService;
+
+    @Value("${easycall.callback-url:null}")
+    private String callbackUrl;
+
     public CommCallSendResult sendWorkflowCall(CommCallSendParam param) {
         CompanyVoiceRobotic robotic = companyVoiceRoboticMapper.selectById(param.getRoboticId());
         if (robotic == null) {
@@ -96,18 +106,16 @@ public class CommCallSendService {
             throw new ServiceException("被叫人不存在或手机号为空");
         }
 
-        CrmCustomer crmCustomer = crmCustomerService.selectCrmCustomerById(callees.getUserId());
-        CompanyVoiceRoboticCallBlacklistCheckParam blacklistParam = new CompanyVoiceRoboticCallBlacklistCheckParam();
-        blacklistParam.setCompanyId(crmCustomer.getCompanyId());
-        blacklistParam.setBusinessType(BusinessTypeEnum.CALL.getCode());
-        blacklistParam.setTargetType(1);
-        blacklistParam.setTargetValue(callees.getPhone());
-        blacklistParam.setCustomerId(crmCustomer.getCustomerId());
-        blacklistParam.setCalleeId(param.getCalleeId());
-        blacklistParam.setRoboticId(robotic.getId());
-        CompanyVoiceRoboticCallBlacklistCheckVO blacklistVo = companyVoiceRoboticCallBlacklistService.checkBlacklist(blacklistParam);
-        if (!blacklistVo.getPass()) {
-            throw new ServiceException("被叫人命中外呼黑名单");
+        CrmCustomer crmCustomer = callees.getUserId() != null
+                ? crmCustomerService.selectCrmCustomerById(callees.getUserId()) : null;
+        assertCallNotBlacklisted(param, robotic, callees, crmCustomer, companyId);
+
+        Long tenantId = param.getTenantId() != null ? param.getTenantId() : TenantHelper.getTenantId();
+        if (tenantId == null) {
+            throw new ServiceException("租户信息缺失,无法发起外呼");
+        }
+        if (!callBalanceDeductionService.hasBalanceForOneMinute(tenantId)) {
+            throw new ServiceException("租户余额不足,无法发起外呼");
         }
 
         String phoneNum = resolveCalleePhone(param, callees);
@@ -115,7 +123,6 @@ public class CommCallSendService {
             throw new ServiceException("被叫人手机号解密失败或号码无效");
         }
 
-        String callBackUrl = resolveCallbackUrl(robotic, param.getCallbackUrl());
         String callBackUuid = UUID.randomUUID().toString();
 
         JSONObject callbackInfo = new JSONObject();
@@ -123,7 +130,7 @@ public class CommCallSendService {
         callbackInfo.put("nodeKey", param.getNodeKey());
         callbackInfo.put("workflowInstanceId", param.getWorkflowInstanceId());
         callbackInfo.put("calleeId", param.getCalleeId());
-        redisCache.setCacheObject(EASYCALL_WORKFLOW_REDIS_KEY + callBackUuid, callbackInfo.toJSONString(), 15, TimeUnit.DAYS);
+        redisCache.setCacheObject(EASYCALL_WORKFLOW_REDIS_KEY + callBackUuid, callbackInfo.toJSONString());
 
         Long batchId = resolveBatchId(param);
         EasyCallCommonAddCallListParam addListParam = new EasyCallCommonAddCallListParam();
@@ -134,7 +141,7 @@ public class CommCallSendService {
         bizJson.put("custName", callees.getUserName());
         bizJson.put("tenantId", param.getTenantId() != null ? param.getTenantId() : TenantHelper.getTenantId());
         bizJson.put("callBackUuid", callBackUuid);
-        bizJson.put("callBackUrl", callBackUrl);
+        bizJson.put("callBackUrl", callbackUrl);
         if (param.getBizParams() != null) {
             bizJson.putAll(param.getBizParams());
         }
@@ -167,6 +174,49 @@ public class CommCallSendService {
                 .build();
     }
 
+    private void assertCallNotBlacklisted(CommCallSendParam param,
+                                          CompanyVoiceRobotic robotic,
+                                          CompanyVoiceRoboticCallees callees,
+                                          CrmCustomer crmCustomer,
+                                          Long companyId) {
+        String targetPhone = StringUtils.isNotBlank(param.getPhone())
+                ? PhoneUtil.resolvePhoneForBlacklist(param.getPhone())
+                : PhoneUtil.resolvePhoneForBlacklist(callees.getPhone());
+        if (StringUtils.isBlank(targetPhone)) {
+            throw new ServiceException("被叫人手机号解密失败或号码无效");
+        }
+
+        CompanyVoiceRoboticCallBlacklistCheckParam blacklistParam = new CompanyVoiceRoboticCallBlacklistCheckParam();
+        blacklistParam.setTenantId(param.getTenantId() != null ? param.getTenantId() : TenantHelper.getTenantId());
+        blacklistParam.setCompanyId(companyId);
+        blacklistParam.setBusinessType(BusinessTypeEnum.CALL.getCode());
+        blacklistParam.setTargetType(1);
+        blacklistParam.setTargetValue(targetPhone);
+        if (crmCustomer != null) {
+            blacklistParam.setCustomerId(crmCustomer.getCustomerId());
+            if (crmCustomer.getCompanyId() != null) {
+                blacklistParam.setCompanyId(crmCustomer.getCompanyId());
+            }
+        }
+        blacklistParam.setCalleeId(param.getCalleeId());
+        blacklistParam.setRoboticId(robotic.getId());
+        blacklistParam.setCompanyUserId(param.getCompanyUserId());
+        if (param.getBusinessId() != null) {
+            blacklistParam.setBizId(String.valueOf(param.getBusinessId()));
+        }
+        blacklistParam.setBizTraceId(param.getWorkflowInstanceId());
+
+        CompanyVoiceRoboticCallBlacklistCheckVO blacklistVo =
+                companyVoiceRoboticCallBlacklistService.checkBlacklistWithDataSource(blacklistParam);
+        if (Boolean.TRUE.equals(blacklistVo.getPass())) {
+            return;
+        }
+        if (Boolean.TRUE.equals(blacklistVo.getHitBlacklist())) {
+            throw new CommBlacklistRejectException(BusinessTypeEnum.CALL.getCode(), blacklistVo);
+        }
+        throw new ServiceException(StringUtils.defaultIfBlank(blacklistVo.getReason(), "黑名单校验未通过"));
+    }
+
     private String resolveCalleePhone(CommCallSendParam param, CompanyVoiceRoboticCallees callees) {
         if (StringUtils.isNotBlank(param.getPhone())) {
             return PhoneUtil.decryptAutoPhone(param.getPhone().trim());

+ 52 - 0
fs-service/src/main/java/com/fs/comm/support/CompanySmsMasterDataSourceHelper.java

@@ -0,0 +1,52 @@
+package com.fs.comm.support;
+
+import com.fs.common.enums.DataSourceType;
+import com.fs.framework.datasource.DynamicDataSourceContextHolder;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import java.util.function.Supplier;
+
+/**
+ * 短信接口主库专用数据源辅助类。
+ * company_sms_api / company_sms_api_tenant / company_sms_api_port 等表位于主库。
+ * 在租户业务模块(fs-company)中查询时需要显式切换主库。
+ */
+@Component
+public class CompanySmsMasterDataSourceHelper {
+
+    @Autowired
+    private CommTenantDataSourceHelper commTenantDataSourceHelper;
+
+    public void runOnMaster(Runnable action) {
+        runOnMaster(() -> {
+            action.run();
+            return null;
+        });
+    }
+
+    public <T> T runOnMaster(Supplier<T> action) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        try {
+            return action.get();
+        } finally {
+            DynamicDataSourceContextHolder.clearDataSourceType();
+        }
+    }
+
+    /** 主库操作完成后恢复租户数据源 */
+    public void runOnMasterThenRestoreTenant(Long tenantId, Runnable action) {
+        runOnMasterThenRestoreTenant(tenantId, () -> {
+            action.run();
+            return null;
+        });
+    }
+
+    public <T> T runOnMasterThenRestoreTenant(Long tenantId, Supplier<T> action) {
+        try {
+            return runOnMaster(action);
+        } finally {
+            commTenantDataSourceHelper.ensureTenant(tenantId);
+        }
+    }
+}

+ 113 - 29
fs-service/src/main/java/com/fs/common/service/impl/SmsServiceImpl.java

@@ -5,12 +5,16 @@ import cn.hutool.json.JSONUtil;
 import com.alibaba.fastjson.JSON;
 import com.fs.common.core.domain.R;
 import com.fs.common.core.redis.RedisCache;
+import com.fs.proxy.domain.CompanySmsApi;
+import com.fs.proxy.domain.CompanySmsApiPort;
 import com.fs.proxy.enums.ConsumeTypeEnum;
 import com.fs.proxy.service.BalanceService;
+import com.fs.comm.support.CompanySmsMasterDataSourceHelper;
 import com.fs.comm.sms.CommSmsChannelRequest;
 import com.fs.comm.sms.CommSmsChannelResult;
 import com.fs.comm.sms.CommSmsProvider;
 import com.fs.common.service.ISmsService;
+import com.fs.common.utils.SecurityUtils;
 import com.fs.common.utils.StringUtils;
 import com.fs.common.vo.SmsNotifyVO;
 import com.fs.common.vo.SmsSendItemVO;
@@ -43,6 +47,8 @@ import com.fs.sms.domain.SendSmsReturn;
 import com.fs.sms.service.impl.SmsTServiceImpl;
 import com.fs.system.domain.SysConfig;
 import com.fs.system.mapper.SysConfigMapper;
+import com.fs.wxcid.utils.TenantAsyncContextHelper;
+import com.fs.wxcid.utils.TenantHelper;
 import com.google.gson.Gson;
 import lombok.Synchronized;
 import lombok.extern.slf4j.Slf4j;
@@ -109,9 +115,6 @@ public class SmsServiceImpl implements ISmsService
     @Autowired
     private com.fs.proxy.mapper.CompanySmsApiMapper smsApiMapper;
 
-    @Autowired
-    private com.fs.proxy.mapper.CompanySmsApiTenantMapper smsApiTenantMapper;
-
     @Autowired
     private com.fs.proxy.mapper.CompanySmsCardMiddlewareMapper smsCardMiddlewareMapper;
 
@@ -121,6 +124,11 @@ public class SmsServiceImpl implements ISmsService
     @Autowired(required = false)
     private List<CommSmsProvider> commSmsProviders = Collections.emptyList();
 
+    @Autowired
+    private CompanySmsMasterDataSourceHelper companySmsMasterDataSourceHelper;
+
+    private static final String SMS_SIGN_PLACEHOLDER = "${sms.sign}";
+
     /**
      * 统一发送方法 - 替代原来6处硬编码的 his.sms 配置读取
      * 
@@ -137,42 +145,90 @@ public class SmsServiceImpl implements ISmsService
         // 将模板类型映射到短信发送类型: tempType 1=行业 → smsType 1; tempType 2=营销 → smsType 2
         Integer smsType = tempType;
 
-        // 1. 通过端口服务解析可用端口(含降级路由逻辑)
-        com.fs.proxy.domain.CompanySmsApiPort port = smsPortService.resolvePort(tenantId, smsType, companyUserId, preferApiId);
+        // 1. 通过端口服务解析可用端口(主库表查询,内部已切回租户库)
+        CompanySmsApiPort port = smsPortService.resolvePort(tenantId, smsType, companyUserId, preferApiId);
         if (port == null) {
             log.warn("resolveAndSend: 无可用端口 tenantId={}, smsType={}, userId={}", tenantId, smsType, companyUserId);
             return "NO_AVAILABLE_PORT";
         }
 
-        // 2. 获取接口信息
-        com.fs.proxy.domain.CompanySmsApi api = smsApiMapper.selectSmsApiById(port.getApiId());
+        CompanySmsApi api = companySmsMasterDataSourceHelper.runOnMasterThenRestoreTenant(tenantId,
+                () -> smsApiMapper.selectSmsApiById(port.getApiId()));
         if (api == null) {
             log.error("resolveAndSend: 接口不存在 apiId={}", port.getApiId());
             return "API_NOT_FOUND";
         }
 
-        // 3. 确定实际使用的账户/密码/签名(端口级优先, 接口级兜底)
-        String useAccount = StringUtils.isNotEmpty(port.getAccount()) ? port.getAccount() : api.getAccount();
-        String usePassword = StringUtils.isNotEmpty(port.getPassword()) ? port.getPassword() : api.getPassword();
-        String useSign = StringUtils.isNotEmpty(port.getSign()) ? port.getSign() : api.getSign();
-        String useUrl = api.getUrl();
-
-        // 4. 根据provider分发
-        String provider = api.getProvider();
-        log.info("resolveAndSend: provider={}, apiId={}, portId={}, phone={}", provider, api.getApiId(), port.getPortId(), phone);
+        String useSign = resolveSmsSign(port, api);
+        if (StringUtils.isBlank(useSign)) {
+            log.warn("resolveAndSend: 短信签名未配置 tenantId={}, apiId={}, portId={}", tenantId, port.getApiId(), port.getPortId());
+            return "SMS_SIGN_MISSING";
+        }
 
+        boolean hasSignPlaceholder = content != null && content.contains(SMS_SIGN_PLACEHOLDER);
+        if (hasSignPlaceholder) {
+            content = content.replace(SMS_SIGN_PLACEHOLDER, useSign);
+        }
+        String channelSign = hasSignPlaceholder ? "" : useSign;
 
-        if ("my".equals(provider)) {
+        final String sendContent = content;
+        return companySmsMasterDataSourceHelper.runOnMasterThenRestoreTenant(tenantId, () -> {
+            String useAccount = StringUtils.isNotEmpty(port.getAccount()) ? port.getAccount() : api.getAccount();
+            String usePassword = StringUtils.isNotEmpty(port.getPassword()) ? port.getPassword() : api.getPassword();
+            String useUrl = api.getUrl();
+            String provider = api.getProvider();
+            log.info("resolveAndSend: provider={}, apiId={}, portId={}, phone={}", provider, api.getApiId(), port.getPortId(), phone);
 
-            // 迈远发送
-            return sendByRf(phone, content, tempType, useAccount, usePassword, useSign, useUrl, port.getPortNo(), tenantId, api.getApiId(), port.getPortId());
-        } else if ("card".equals(provider)) {
-            // 手机卡发送
-            return sendByCard(phone, content, tenantId, api.getApiId(), port.getPortId(), companyUserId);
-        } else {
+            if ("my".equals(provider)) {
+                return sendByRf(phone, sendContent, tempType, useAccount, usePassword, channelSign, useUrl, port.getPortNo(), tenantId, api.getApiId(), port.getPortId());
+            } else if ("card".equals(provider)) {
+                return sendByCard(phone, sendContent, tenantId, api.getApiId(), port.getPortId(), companyUserId);
+            }
             log.error("resolveAndSend: 未知provider={} apiId={}", provider, api.getApiId());
             return "UNKNOWN_PROVIDER";
+        });
+    }
+
+    /** 解析短信签名:端口 > 接口 > 租户 his.sms 配置 */
+    private String resolveSmsSign(CompanySmsApiPort port, CompanySmsApi api) {
+        if (port != null && StringUtils.isNotEmpty(port.getSign())) {
+            return port.getSign();
+        }
+        if (api != null && StringUtils.isNotEmpty(api.getSign())) {
+            return api.getSign();
         }
+        return loadHisSmsSignFromConfig();
+    }
+
+    /** 从租户 sys_config his.sms 读取签名(rfSign) */
+    private String loadHisSmsSignFromConfig() {
+        SysConfig sysConfig = sysConfigMapper.selectConfigByConfigKey("his.sms");
+        if (sysConfig == null || StringUtils.isEmpty(sysConfig.getConfigValue())) {
+            return null;
+        }
+        try {
+            FsSmsConfig sms = JSON.parseObject(sysConfig.getConfigValue(), FsSmsConfig.class);
+            return sms != null ? sms.getRfSign() : null;
+        } catch (Exception e) {
+            log.warn("loadHisSmsSignFromConfig: 解析 his.sms 失败", e);
+            return null;
+        }
+    }
+
+    /**
+     * 解析当前请求的租户ID(非销售公司ID)。
+     * 优先级:TenantHelper → SecurityUtils → 数据源上下文 tenant:{id}
+     */
+    private Long resolveCurrentTenantId() {
+        Long tenantId = TenantHelper.getTenantId();
+        if (tenantId != null) {
+            return tenantId;
+        }
+        tenantId = SecurityUtils.getTenantId();
+        if (tenantId != null) {
+            return tenantId;
+        }
+        return TenantAsyncContextHelper.resolveCurrentTenantId();
     }
 
     /** 迈远发送(优先走 fs-comm-gateway 注册的 CommSmsProvider) */
@@ -383,8 +439,12 @@ public class SmsServiceImpl implements ISmsService
             CompanySms sms=companySmsService.selectCompanySmsByCompanyId(param.getCompanyId());
             if(sms!=null){
                 if(sms.getRemainSmsCount()>0){
+                    Long tenantId = resolveCurrentTenantId();
+                    if (tenantId == null) {
+                        return R.error("租户信息缺失,无法发送短信");
+                    }
                     // 调用余额服务进行扣费(按量扣除模式)
-                    boolean consumeSuccess = balanceService.consumeService(param.getCompanyId(), ConsumeTypeEnum.SMS_SEND, 1, "发送短信");
+                    boolean consumeSuccess = balanceService.consumeService(tenantId, ConsumeTypeEnum.SMS_SEND, 1, "发送短信");
                     if (!consumeSuccess) {
                         return R.error("余额不足,发送失败");
                     }
@@ -549,8 +609,13 @@ public class SmsServiceImpl implements ISmsService
                             content=content.replace("${sms.cardUrl}",param.getCardUrl());
                         }
                         // ===== 动态路由发送 =====
+                        Long tenantId = resolveCurrentTenantId();
+                        if (tenantId == null) {
+                            log.warn("sendOrderMsg: 无法解析租户ID companyId={}", param.getCompanyId());
+                            return R.error("租户信息缺失,无法发送短信");
+                        }
                         String sendResult = resolveAndSend(fsStoreOrder.getUserPhone(), content, temp.getTempType(),
-                                param.getCompanyId(), param.getCompanyUserId(), null);
+                                tenantId, param.getCompanyUserId(), null);
                         if ("OK".equals(sendResult)) {
                             CompanySmsLogs logs=new CompanySmsLogs();
                             logs.setCompanyId(param.getCompanyId());
@@ -612,8 +677,13 @@ public class SmsServiceImpl implements ISmsService
                         content=content.replace("${sms.cardUrl}",param.getCardUrl());
                     }
                     // ===== 动态路由发送 =====
+                    Long tenantId = resolveCurrentTenantId();
+                    if (tenantId == null) {
+                        log.warn("sendPackageOrderMsg: 无法解析租户ID companyId={}", param.getCompanyId());
+                        return R.error("租户信息缺失,无法发送短信");
+                    }
                     String sendResult = resolveAndSend(packageOrder.getPhone(), content, temp.getTempType(),
-                            param.getCompanyId(), param.getCompanyUserId(), null);
+                            tenantId, param.getCompanyUserId(), null);
                     if ("OK".equals(sendResult)) {
                         CompanySmsLogs logs=new CompanySmsLogs();
                         logs.setCompanyId(param.getCompanyId());
@@ -663,8 +733,13 @@ public class SmsServiceImpl implements ISmsService
         SysConfig sysConfig = sysConfigMapper.selectConfigByConfigKey("his.sms");
         FsSmsConfig sms = JSON.parseObject(sysConfig.getConfigValue(), FsSmsConfig.class);
         if (sms.getType().equals("my")){
+            if (content != null && content.contains(SMS_SIGN_PLACEHOLDER)) {
+                if (StringUtils.isBlank(sms.getRfSign())) {
+                    return R.error("短信签名未配置");
+                }
+                content = content.replace(SMS_SIGN_PLACEHOLDER, sms.getRfSign());
+            }
             try {
-                content = content.replace("${sms.sign}",sms.getRfSign());
                 urls = sms.getRfUrl1() + "sms?action=send&account=" + sms.getRfAccount1() + "&password=" + sms.getRfPassword1() + "&mobile=" + phone + "&content=" + URLEncoder.encode(content, "UTF-8") + "&extno=" + sms.getRfCode1() + "&rt=json";
             } catch (UnsupportedEncodingException e) {
                 e.printStackTrace();
@@ -732,6 +807,11 @@ public class SmsServiceImpl implements ISmsService
 
     @Async
     public void batchSmsOp(CompanySmsTemp temp, SmsSendBatchParam param){
+        Long tenantId = resolveCurrentTenantId();
+        if (tenantId == null) {
+            log.error("batchSmsOp: 无法解析租户ID companyId={}", param.getCompanyId());
+            return;
+        }
         CompanyUser companyUser=companyUserService.selectCompanyUserById(param.getCompanyUserId());
         for(Long id:param.getCustomerIds()){
             CrmCustomer crmCustomer=crmCustomerService.selectCrmCustomerById(id);
@@ -750,7 +830,7 @@ public class SmsServiceImpl implements ISmsService
             // ===== 动态路由发送 =====
             Long preferApiId = null; // TODO: 从param中获取销售手动选择的接口ID
             String sendResult = resolveAndSend(crmCustomer.getMobile(), content, temp.getTempType(),
-                    param.getCompanyId(), param.getCompanyUserId(), preferApiId);
+                    tenantId, param.getCompanyUserId(), preferApiId);
 
             if ("OK".equals(sendResult)) {
                 CompanySmsLogs logs=new CompanySmsLogs();
@@ -777,6 +857,10 @@ public class SmsServiceImpl implements ISmsService
 
     @Override
     public void batchSmsOp4AiSend(CompanySmsTemp temp, SmsSendBatchParam param){
+        Long tenantId = resolveCurrentTenantId();
+        if (tenantId == null) {
+            throw new RuntimeException("无法解析租户ID,companyId=" + param.getCompanyId());
+        }
         CompanyUser companyUser=companyUserService.selectCompanyUserById(param.getCompanyUserId());
         if (companyUser != null && StringUtils.isNotEmpty(companyUser.getPhonenumber())) {
             CompanyVoiceRoboticCallBlacklistCheckParam salesBlacklistParam = new CompanyVoiceRoboticCallBlacklistCheckParam();
@@ -828,7 +912,7 @@ public class SmsServiceImpl implements ISmsService
 
             // ===== 动态路由发送 =====
             String sendResult = resolveAndSend(crmCustomer.getMobile(), content, temp.getTempType(),
-                    param.getCompanyId(), param.getCompanyUserId(), null);
+                    tenantId, param.getCompanyUserId(), null);
 
             if ("OK".equals(sendResult)) {
                 CompanySmsLogs logs=new CompanySmsLogs();

+ 17 - 0
fs-service/src/main/java/com/fs/company/domain/CompanyVoiceRoboticCallLogCallphone.java

@@ -181,6 +181,23 @@ public class CompanyVoiceRoboticCallLogCallphone extends BaseEntity{
     @TableField(exist = false)
     private String encryptedPhone;
 
+    /** 详情筛选-运行时间起(yyyy-MM-dd) */
+    @TableField(exist = false)
+    private String beginRunTime;
+
+    /** 详情筛选-运行时间止(yyyy-MM-dd) */
+    @TableField(exist = false)
+    private String endRunTime;
+
+    /** 详情筛选-客户类型为「无」 */
+    @TableField(exist = false)
+    private Boolean intentionEmpty;
+
+    /**
+     * 重试次数
+     */
+    private Integer retryCount;
+
     public static CompanyVoiceRoboticCallLogCallphone initCallLog( String runParam, Long keyId, Long taskId,Long companyId) {
         CompanyVoiceRoboticCallLogCallphone log = new CompanyVoiceRoboticCallLogCallphone();
         log.callerId = keyId;

+ 12 - 0
fs-service/src/main/java/com/fs/company/domain/CrmCustomerCallLog.java

@@ -175,6 +175,18 @@ public class CrmCustomerCallLog extends BaseEntity {
      */
     private Long maxCallTime;
 
+    /**
+     * 查询条件:呼叫开始时间-起(yyyy-MM-dd)
+     * 非持久化字段
+     */
+    private String callBeginTime;
+
+    /**
+     * 查询条件:呼叫开始时间-止(yyyy-MM-dd)
+     * 非持久化字段
+     */
+    private String callEndTime;
+
     /**
      * 计费分钟数
      */

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

@@ -6,6 +6,7 @@ import com.fs.company.domain.CompanyVoiceRoboticCallees;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogCallPhoneDecryptQueryVO;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogCallPhoneVO;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogCount;
+import com.fs.company.vo.CompanyVoiceRoboticCallLogDetailSummary;
 import com.fs.crm.vo.CustomerCallStatVO;
 import org.apache.ibatis.annotations.Param;
 
@@ -108,6 +109,14 @@ public interface CompanyVoiceRoboticCallLogCallphoneMapper extends BaseMapper<Co
      */
     List<CustomerCallStatVO> selectAiCallStatTotal(@Param("customerIds") List<Long> customerIds);
 
+    List<CompanyVoiceRoboticCallLogCallPhoneVO> selectDetailList(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone);
+
+    CompanyVoiceRoboticCallLogDetailSummary selectDetailSummary(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone);
+
     List<CompanyVoiceRoboticCallLogCallPhoneDecryptQueryVO> listDecryptPhoneExport(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone);
 
+    /**
+     * 查询执行中任务下、外呼记录仍为执行中的 callbackUuid 列表
+     */
+    List<String> selectRunningCallbackUuidsByCompanyId(@Param("companyId") Long companyId);
 }

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

@@ -8,6 +8,8 @@ import com.fs.company.vo.easycall.EasyCallOutBoundVO;
 import org.apache.ibatis.annotations.Param;
 import org.springframework.stereotype.Repository;
 
+import java.util.List;
+
 /**
  * @author MixLiu
  * @date 2026/3/7 10:43
@@ -27,4 +29,10 @@ public interface EasyCallMapper {
     @DataSource(DataSourceType.EASYCALL)
     InboundCallInfo selectInboundCallbackInfoByUuid(@Param("uuid") String uuid);
 
+    /**
+     * 根据 callbackUuid 批量查询 EasyCall 已外呼完成的话单 uuid
+     */
+    @DataSource(DataSourceType.EASYCALL)
+    List<String> selectCompletedUuidsByCallbackUuids(@Param("callbackUuids") List<String> callbackUuids);
+
 }

+ 15 - 0
fs-service/src/main/java/com/fs/company/service/ICompanyAiCallDataSyncService.java

@@ -0,0 +1,15 @@
+package com.fs.company.service;
+
+/**
+ * AI外呼数据同步服务
+ */
+public interface ICompanyAiCallDataSyncService {
+
+    /**
+     * 尝试启动异步同步任务(同公司防重复)
+     *
+     * @param companyId 公司ID
+     * @return true-已启动;false-已有任务在运行
+     */
+    boolean tryStartSync(Long companyId);
+}

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

@@ -18,7 +18,7 @@ public interface ICompanyVoiceRoboticCallBlacklistInterceptLogService {
     /**
      * 命中黑名单被拦截时写入明细
      */
-    void saveOnBlacklistReject(CompanyVoiceRoboticCallBlacklistCheckParam param, CompanyVoiceRoboticCallBlacklistCheckVO vo);
+    Long saveOnBlacklistReject(CompanyVoiceRoboticCallBlacklistCheckParam param, CompanyVoiceRoboticCallBlacklistCheckVO vo);
 
     /**
      * 解密对象值(手机号)

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

@@ -5,6 +5,7 @@ import com.fs.company.domain.CompanyVoiceRoboticCallLogCallphone;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogCallPhoneDecryptExportVO;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogCallPhoneVO;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogCount;
+import com.fs.company.vo.CompanyVoiceRoboticCallLogDetailSummary;
 
 import java.util.List;
 
@@ -101,4 +102,11 @@ public interface ICompanyVoiceRoboticCallLogCallphoneService extends IService<Co
      * 根据外呼记录ID获取解密后的手机号
      */
     String getDecryptPhoneByLogId(Long logId);
+
+    void prepareDetailListQuery(CompanyVoiceRoboticCallLogCallphone query, Long companyId);
+
+    List<CompanyVoiceRoboticCallLogCallPhoneVO> selectDetailList(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone);
+
+    CompanyVoiceRoboticCallLogDetailSummary selectDetailSummary(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone);
+
 }

+ 141 - 0
fs-service/src/main/java/com/fs/company/service/easycall/EasyCallCallbackContextHelper.java

@@ -0,0 +1,141 @@
+package com.fs.company.service.easycall;
+
+import com.alibaba.fastjson.JSONObject;
+import com.fs.common.config.RedisTenantContext;
+import com.fs.common.core.domain.model.TenantPrincipal;
+import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.spring.SpringUtils;
+import com.fs.company.vo.CdrDetailVo;
+import com.fs.company.vo.easycall.EasyCallCallPhoneVO;
+import com.fs.core.config.TenantConfigContext;
+import com.fs.config.saas.ProjectConfig;
+import com.fs.wxcid.utils.TenantHelper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+
+import java.lang.reflect.Method;
+import java.util.Collections;
+
+/**
+ * EasyCall 外呼回调租户上下文:从 bizJson / cdrBody 解析 tenantId 并切换数据源、Redis 前缀。
+ */
+@Slf4j
+public final class EasyCallCallbackContextHelper {
+
+    private EasyCallCallbackContextHelper() {
+    }
+
+    public static Long resolveTenantId(CdrDetailVo cdr, EasyCallCallPhoneVO callPhoneRes) {
+        Long tenantId = null;
+        if (callPhoneRes != null) {
+            tenantId = extractTenantId(callPhoneRes.getBizJson());
+        }
+        if (tenantId != null) {
+            return tenantId;
+        }
+        return extractTenantIdFromCdr(cdr);
+    }
+
+    public static Long extractTenantIdFromCdr(CdrDetailVo cdr) {
+        if (cdr == null || StringUtils.isBlank(cdr.getCdrBody())) {
+            return null;
+        }
+        try {
+            JSONObject body = JSONObject.parseObject(cdr.getCdrBody());
+            return extractTenantIdFromCdrBody(body);
+        } catch (Exception e) {
+            log.warn("解析 cdrBody 失败: {}", e.getMessage());
+            return null;
+        }
+    }
+
+    public static Long extractTenantIdFromCdrBody(JSONObject body) {
+        if (body == null) {
+            return null;
+        }
+        JSONObject outbound = body.getJSONObject("outboundPhoneInfo");
+        if (outbound != null) {
+            Long tenantId = extractTenantId(outbound.getString("bizJson"));
+            if (tenantId != null) {
+                return tenantId;
+            }
+        }
+        return extractTenantId(body.getString("bizJson"));
+    }
+
+    public static Long extractTenantId(String bizJsonStr) {
+        if (StringUtils.isBlank(bizJsonStr)) {
+            return null;
+        }
+        try {
+            JSONObject bizJson = JSONObject.parseObject(bizJsonStr);
+            if (bizJson != null && bizJson.containsKey("tenantId")) {
+                return bizJson.getLong("tenantId");
+            }
+        } catch (Exception e) {
+            log.warn("解析 bizJson 失败: {}", e.getMessage());
+        }
+        return null;
+    }
+
+    public static boolean switchTenant(Long tenantId) {
+        if (tenantId == null) {
+            return false;
+        }
+        try {
+            TenantHelper.setTenantId(tenantId);
+            Object manager = SpringUtils.getBean("tenantDataSourceManager");
+            Method method = manager.getClass().getMethod("ensureSwitchByTenantId", Long.class);
+            method.invoke(manager, tenantId);
+            SecurityContextHolder.getContext().setAuthentication(
+                    new UsernamePasswordAuthenticationToken(
+                            new TenantPrincipal(tenantId),
+                            null,
+                            Collections.emptyList()
+                    )
+            );
+            RedisTenantContext.setTenantId(tenantId);
+            return true;
+        } catch (Exception e) {
+            log.error("EasyCall回调切换租户数据源失败: tenantId={}", tenantId, e);
+            return false;
+        }
+    }
+
+    public static void clearTenant(boolean isSwitched) {
+        if (!isSwitched) {
+            return;
+        }
+        try {
+            TenantConfigContext.clear();
+            ProjectConfig.clearTenantConfigs();
+            SecurityContextHolder.clearContext();
+            Object manager = SpringUtils.getBean("tenantDataSourceManager");
+            Method method = manager.getClass().getMethod("clear");
+            method.invoke(manager);
+            TenantHelper.clearTenantId();
+            RedisTenantContext.clear();
+        } catch (Exception e) {
+            log.error("EasyCall回调清理租户上下文失败", e);
+        }
+    }
+
+    public static void runWithTenant(Long tenantId, Runnable action) {
+        if (tenantId == null) {
+            action.run();
+            return;
+        }
+        Long current = TenantHelper.getTenantId();
+        boolean needSwitch = current == null || !tenantId.equals(current);
+        boolean switched = false;
+        if (needSwitch) {
+            switched = switchTenant(tenantId);
+        }
+        try {
+            action.run();
+        } finally {
+            clearTenant(switched);
+        }
+    }
+}

+ 89 - 0
fs-service/src/main/java/com/fs/company/service/impl/CompanyAiCallDataSyncServiceImpl.java

@@ -0,0 +1,89 @@
+package com.fs.company.service.impl;
+
+import com.fs.common.core.redis.RedisCache;
+import com.fs.company.mapper.CompanyVoiceRoboticCallLogCallphoneMapper;
+import com.fs.company.mapper.EasyCallMapper;
+import com.fs.company.service.ICompanyAiCallDataSyncService;
+import com.fs.company.service.ICompanyVoiceRoboticService;
+import com.fs.company.vo.CdrDetailVo;
+import com.fs.wxcid.utils.TenantHelper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * AI外呼数据同步:补偿 EasyCall 已外呼完成但未回调入库的记录
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class CompanyAiCallDataSyncServiceImpl implements ICompanyAiCallDataSyncService {
+
+    private static final String SYNC_LOCK_PREFIX = "ai_call_sync:company:";
+    private static final int BATCH_SIZE = 1000;
+    private static final long LOCK_TIMEOUT_HOURS = 2L;
+
+    private final RedisCache redisCache;
+    private final CompanyVoiceRoboticCallLogCallphoneMapper callLogCallphoneMapper;
+    private final EasyCallMapper easyCallMapper;
+    private final ICompanyVoiceRoboticService companyVoiceRoboticService;
+
+
+    @Override
+    public boolean tryStartSync(Long companyId) {
+        String lockKey = buildLockKey(companyId);
+        if (!redisCache.setIfAbsent(lockKey, "1", LOCK_TIMEOUT_HOURS, TimeUnit.HOURS)) {
+            return false;
+        }
+        doSyncAiCallDataAsync(companyId, lockKey);
+        return true;
+    }
+
+    @Async("callLogExcutor")
+    public void doSyncAiCallDataAsync(Long companyId, String lockKey) {
+        try {
+            List<String> callbackUuids = callLogCallphoneMapper.selectRunningCallbackUuidsByCompanyId(companyId);
+            if (callbackUuids == null || callbackUuids.isEmpty()) {
+                log.info("syncAiCallData: 无待补偿外呼记录, companyId={}", companyId);
+                return;
+            }
+            log.info("syncAiCallData: 开始同步, companyId={}, 待检查callbackUuid数={}", companyId, callbackUuids.size());
+            int triggered = 0;
+            for (int i = 0; i < callbackUuids.size(); i += BATCH_SIZE) {
+                List<String> batch = callbackUuids.subList(i, Math.min(i + BATCH_SIZE, callbackUuids.size()));
+                List<String> completedUuids = easyCallMapper.selectCompletedUuidsByCallbackUuids(batch);
+                if (completedUuids == null || completedUuids.isEmpty()) {
+                    continue;
+                }
+                for (String uuid : completedUuids) {
+                    try {
+                        CdrDetailVo cdrDetailVo = new CdrDetailVo();
+                        cdrDetailVo.setUuid(uuid);
+                        cdrDetailVo.setCdrType("outbound");
+                        companyVoiceRoboticService.callerResult4EasyCall(cdrDetailVo);
+                        triggered++;
+                    } catch (Exception e) {
+                        log.error("syncAiCallData: 触发回调失败, uuid={}, companyId={}", uuid, companyId, e);
+                    }
+                }
+            }
+            log.info("syncAiCallData: 同步完成, companyId={}, 触发回调数={}", companyId, triggered);
+        } catch (Exception e) {
+            log.error("syncAiCallData: 同步异常, companyId={}", companyId, e);
+        } finally {
+            redisCache.deleteObject(lockKey);
+        }
+    }
+
+    private String buildLockKey(Long companyId) {
+        Long tenantId = TenantHelper.getTenantId();
+        if (tenantId != null) {
+            return SYNC_LOCK_PREFIX + tenantId + ":" + companyId;
+        }
+        return SYNC_LOCK_PREFIX + companyId;
+    }
+}

+ 4 - 2
fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticCallBlacklistInterceptLogServiceImpl.java

@@ -42,9 +42,9 @@ public class CompanyVoiceRoboticCallBlacklistInterceptLogServiceImpl implements
     }
 
     @Override
-    public void saveOnBlacklistReject(CompanyVoiceRoboticCallBlacklistCheckParam param, CompanyVoiceRoboticCallBlacklistCheckVO vo) {
+    public Long saveOnBlacklistReject(CompanyVoiceRoboticCallBlacklistCheckParam param, CompanyVoiceRoboticCallBlacklistCheckVO vo) {
         if (param == null || vo == null || Boolean.TRUE.equals(vo.getPass()) || !Boolean.TRUE.equals(vo.getHitBlacklist())) {
-            return;
+            return null;
         }
         try {
             CompanyVoiceRoboticCallBlacklistInterceptLog entity = new CompanyVoiceRoboticCallBlacklistInterceptLog();
@@ -68,8 +68,10 @@ public class CompanyVoiceRoboticCallBlacklistInterceptLogServiceImpl implements
                 }
             }
             interceptLogMapper.insert(entity);
+            return entity.getId();
         } catch (Exception e) {
             log.warn("写入黑名单拦截明细失败: {}", e.getMessage());
+            return null;
         }
     }
 

+ 13 - 3
fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticCallBlacklistServiceImpl.java

@@ -224,7 +224,7 @@ public class CompanyVoiceRoboticCallBlacklistServiceImpl implements ICompanyVoic
                         companyHit.getTargetValue(),
                         buildReason(companyHit, "租户级黑名单")
                 );
-                interceptLogService.saveOnBlacklistReject(param, rejectVo);
+                attachInterceptLog(param, rejectVo, companyHit);
                 return rejectVo;
             }
         }
@@ -243,7 +243,7 @@ public class CompanyVoiceRoboticCallBlacklistServiceImpl implements ICompanyVoic
                     globalHit.getTargetValue(),
                     buildReason(globalHit, "平台级黑名单")
             );
-            interceptLogService.saveOnBlacklistReject(param, rejectVo);
+            attachInterceptLog(param, rejectVo, globalHit);
             return rejectVo;
         }
         return CompanyVoiceRoboticCallBlacklistCheckVO.pass(
@@ -324,10 +324,20 @@ public class CompanyVoiceRoboticCallBlacklistServiceImpl implements ICompanyVoic
                 hit.getTargetValue(),
                 buildReason(hit, scopeDesc)
         );
-        interceptLogService.saveOnBlacklistReject(param, rejectVo);
+        attachInterceptLog(param, rejectVo, hit);
         return rejectVo;
     }
 
+    private void attachInterceptLog(CompanyVoiceRoboticCallBlacklistCheckParam param,
+                                  CompanyVoiceRoboticCallBlacklistCheckVO rejectVo,
+                                  CompanyVoiceRoboticCallBlacklist hit) {
+        if (hit != null) {
+            rejectVo.setBlacklistReason(hit.getReason());
+        }
+        Long interceptLogId = interceptLogService.saveOnBlacklistReject(param, rejectVo);
+        rejectVo.setInterceptLogId(interceptLogId);
+    }
+
     private void restoreDataSource(String previousDs) {
         if (StringUtils.hasText(previousDs)) {
             DynamicDataSourceContextHolder.setDataSourceType(previousDs);

+ 75 - 56
fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticCallLogCallphoneServiceImpl.java

@@ -23,6 +23,9 @@ import com.fs.company.mapper.CompanyWxAccountMapper;
 import com.fs.company.service.CompanyWorkflowEngine;
 import com.fs.company.service.ICompanyVoiceRoboticCallLogCallphoneService;
 import com.fs.company.service.ICompanyVoiceRoboticService;
+import com.fs.company.service.easycall.EasyCallCallbackContextHelper;
+import com.fs.comm.model.CallBalanceDeductionResult;
+import com.fs.comm.service.CallBalanceDeductionService;
 import com.fs.company.vo.*;
 import com.fs.company.vo.easycall.EasyCallCallPhoneVO;
 import com.fs.core.config.TenantConfigContext;
@@ -101,6 +104,9 @@ public class CompanyVoiceRoboticCallLogCallphoneServiceImpl extends ServiceImpl<
     @Autowired
     private AgentSensitiveWordDetector agentSensitiveWordDetector;
 
+    @Autowired
+    private CallBalanceDeductionService callBalanceDeductionService;
+
     /**
      * 查询调用日志_ai打电话
      *
@@ -294,36 +300,21 @@ public class CompanyVoiceRoboticCallLogCallphoneServiceImpl extends ServiceImpl<
     }
     @Async("callLogExcutor")
     public void asyncHandleCalleeCallBackResult4EasyCall(EasyCallCallPhoneVO result, Long calleeId) {
-        boolean isSwitched = false;
+        Long tenantId = EasyCallCallbackContextHelper.extractTenantId(result != null ? result.getBizJson() : null);
+        if (tenantId == null) {
+            log.error("EasyCall回调日志处理无法解析tenantId, calleeId={}, uuid={}",
+                    calleeId, result != null ? result.getUuid() : null);
+            return;
+        }
+        boolean isSwitched = EasyCallCallbackContextHelper.switchTenant(tenantId);
         try {
-            // 1. 切换到租户数据源
-            if (TenantHelper.getTenantId() != null) {
-                try {
-                    Object manager = SpringUtils.getBean("tenantDataSourceManager");
-                    Method method = manager.getClass().getMethod("ensureSwitchByTenantId", Long.class);
-                    method.invoke(manager, TenantHelper.getTenantId());
-                    isSwitched = true;
-                    // 设置租户到 SecurityContext,供 TenantKeyRedisSerializer 自动为 Redis Key 加 tenantid 前缀
-                    SecurityContextHolder.getContext().setAuthentication(
-                            new UsernamePasswordAuthenticationToken(
-                                    new TenantPrincipal(TenantHelper.getTenantId()),
-                                    null,
-                                    Collections.emptyList()
-                            )
-                    );
-                    // 切换 Redis 租户上下文
-                    RedisTenantContext.setTenantId(TenantHelper.getTenantId());
-                } catch (Exception e) {
-                    log.error("pushDialogContent4EasyCall 切换租户数据源失败: tenantId={}", TenantHelper.getTenantId(), e);
-                }
-            }
             CompanyVoiceRoboticCallees callees = companyVoiceRoboticCalleesMapper.selectCompanyVoiceRoboticCalleesById(calleeId);
+            if (callees == null) {
+                log.error("EasyCall回调未找到被叫人记录, calleeId={}, uuid={}, tenantId={}",
+                        calleeId, result != null ? result.getUuid() : null, tenantId);
+                return;
+            }
             try {
-                String json = configService.selectConfigByKey("cId.config");
-                if (StringUtils.isBlank(json)) {
-                    log.error("未配置cid.config");
-                }
-                CidConfigVO cidConfigVO = JSONUtil.toBean(json, CidConfigVO.class);
                 if (null != result) {
 //                getDialogMapDomain getDialogMap = getDialogMapDomain.builder()
 //                        .uuid(uuid)
@@ -431,17 +422,13 @@ public class CompanyVoiceRoboticCallLogCallphoneServiceImpl extends ServiceImpl<
                         BigDecimal divide = new BigDecimal(result.getValidTimeLen()).divide(new BigDecimal(1000), 0, RoundingMode.CEILING);
                         companyVoiceRoboticCallLog.setCallTime(divide.longValue());
                     }
-                    BigDecimal callCharge = cidConfigVO.getCallCharge();
-                    //
-                    if (null == callCharge) {
-                        callCharge = DEFAULT_CALL_CHARGE;
-                    }
-                    //检测空避免报错
-                    if(companyVoiceRoboticCallLog.getCallTime()!=null){
-                        //向上取整分钟数
-                        BigDecimal divide = new BigDecimal(companyVoiceRoboticCallLog.getCallTime()).divide(ONE_MINUTES_SECOND, 0, RoundingMode.CEILING);
-                        BigDecimal multiply = divide.multiply(callCharge);
-                        companyVoiceRoboticCallLog.setCost(multiply);
+                    if (result.getValidTimeLen() != null && result.getValidTimeLen() > 0
+                            && StringUtils.isNotBlank(result.getUuid())) {
+                        CallBalanceDeductionResult deductResult = callBalanceDeductionService.deductOnConnected(
+                                tenantId, result.getUuid(), result.getValidTimeLen());
+                        if (deductResult.isCharged() && deductResult.getAmount() != null) {
+                            companyVoiceRoboticCallLog.setCost(deductResult.getAmount());
+                        }
                     }
 
                     //检测敏感词语
@@ -478,11 +465,15 @@ public class CompanyVoiceRoboticCallLogCallphoneServiceImpl extends ServiceImpl<
                                 Map<String, Object> param = new HashMap<>();
                                 param.put("callBackUuid", userData.getString("callBackUuid"));
                                 param.put("callSource", "callBack");
-                                CompletableFuture.runAsync(() -> {
-                                    companyWorkflowEngine.resumeFromBlockingNode(userData.getString("workflowInstanceId"), userData.getString("nodeKey"), param);
-                                }, cidWorkFlowExecutor).thenRun(() -> {
-                                    redisCache2.deleteObject(EASYCALL_WORKFLOW_REDIS_KEY +  bizJson.getString("callBackUuid"));
-                                });
+                                Long resumeTenantId = tenantId;
+                                CompletableFuture.runAsync(() -> EasyCallCallbackContextHelper.runWithTenant(resumeTenantId, () ->
+                                        companyWorkflowEngine.resumeFromBlockingNode(
+                                                userData.getString("workflowInstanceId"),
+                                                userData.getString("nodeKey"),
+                                                param)
+                                ), cidWorkFlowExecutor).thenRun(() ->
+                                        redisCache2.deleteObject(EASYCALL_WORKFLOW_REDIS_KEY + bizJson.getString("callBackUuid"))
+                                );
                             }
                         }
                         redisCache2.deleteObject(bizJson.getString("callBackUuid"));
@@ -496,19 +487,7 @@ public class CompanyVoiceRoboticCallLogCallphoneServiceImpl extends ServiceImpl<
         } catch (Exception e) {
             log.error("asyncHandleCalleeCallBackResult4EasyCall执行失败", e);
         } finally {
-            // 2. 清理租户上下文
-            if (isSwitched) {
-                try {
-                    TenantConfigContext.clear();
-                    SecurityContextHolder.clearContext();
-                    Object manager = SpringUtils.getBean("tenantDataSourceManager");
-                    Method method = manager.getClass().getMethod("clear");
-                    method.invoke(manager);
-                    RedisTenantContext.clear();
-                } catch (Exception e) {
-                    log.error("SOP异步任务清理租户数据源失败", e);
-                }
-            }
+            EasyCallCallbackContextHelper.clearTenant(isSwitched);
         }
 
     }
@@ -582,6 +561,46 @@ public class CompanyVoiceRoboticCallLogCallphoneServiceImpl extends ServiceImpl<
     public List<CompanyVoiceRoboticCallLogCallPhoneVO> listByRoboticId(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone) {
         return baseMapper.listByRoboticId(companyVoiceRoboticCallLogCallphone);
     }
+
+    @Override
+    public void prepareDetailListQuery(CompanyVoiceRoboticCallLogCallphone query, Long companyId) {
+        query.setCompanyId(companyId);
+        prepareDetailPhoneQuery(query);
+    }
+
+    @Override
+    public List<CompanyVoiceRoboticCallLogCallPhoneVO> selectDetailList(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone) {
+        List<CompanyVoiceRoboticCallLogCallPhoneVO> list = baseMapper.selectDetailList(companyVoiceRoboticCallLogCallphone);
+        if (list == null || list.isEmpty()) {
+            return list;
+        }
+        for (CompanyVoiceRoboticCallLogCallPhoneVO vo : list) {
+            if (StringUtils.isNotEmpty(vo.getCallerNum())) {
+                vo.setCallerNum(PhoneUtil.decryptPhoneMk(vo.getCallerNum()));
+            }
+        }
+        return list;
+    }
+
+    @Override
+    public CompanyVoiceRoboticCallLogDetailSummary selectDetailSummary(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone) {
+        CompanyVoiceRoboticCallLogDetailSummary summary = baseMapper.selectDetailSummary(companyVoiceRoboticCallLogCallphone);
+        if (summary == null) {
+            summary = new CompanyVoiceRoboticCallLogDetailSummary();
+            summary.setTotalCount(0L);
+            summary.setSuccessCount(0L);
+            summary.setFailCount(0L);
+            summary.setConnectedCount(0L);
+            summary.setTotalBillingMinute(0L);
+        }
+        if (summary.getTotalBillingMinute() == null) {
+            summary.setTotalBillingMinute(0L);
+        }
+        long total = summary.getTotalCount() == null ? 0L : summary.getTotalCount();
+        long connected = summary.getConnectedCount() == null ? 0L : summary.getConnectedCount();
+        summary.setConnectRate(total > 0 ? (int) Math.round(connected * 100.0 / total) : 0);
+        return summary;
+    }
     /**
      * 判断整数
      *

+ 73 - 79
fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticServiceImpl.java

@@ -35,6 +35,7 @@ import com.fs.company.mapper.*;
 import com.fs.company.param.ExecutionContext;
 import com.fs.company.param.PauseRoboticActiveParam;
 import com.fs.company.service.*;
+import com.fs.company.service.easycall.EasyCallCallbackContextHelper;
 import com.fs.company.service.impl.call.EasyCallTaskControlService;
 import com.fs.company.vo.*;
 import com.fs.company.vo.easycall.EasyCallCallPhoneVO;
@@ -44,6 +45,7 @@ import com.fs.crm.mapper.CrmCustomerMapper;
 import com.fs.crm.param.SmsSendBatchParam;
 import com.fs.crm.service.ICrmCustomerAnalyzeService;
 import com.fs.crm.service.impl.CrmCustomerServiceImpl;
+import com.fs.company.service.impl.call.node.WorkflowExecErrorMessages;
 import com.fs.enums.ExecutionStatusEnum;
 import com.fs.enums.NodeTypeEnum;
 import com.fs.enums.TaskTypeEnum;
@@ -786,56 +788,39 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
             return;
         }
         log.info("进入easyCall外呼结果查询结果callPhoneRes:{}", JSON.toJSONString(callPhoneRes));
-        //不处理非ai外呼回调请求
-        String bizJsonStr = callPhoneRes.getBizJson();
-        if(StringUtils.isBlank(bizJsonStr)){
-            log.error("easyCall外呼回调信息非调用:{}", JSON.toJSONString(result));
+        if (StringUtils.isBlank(callPhoneRes.getBizJson())) {
+            log.error("easyCall外呼回调信息非AI外呼调用:{}", JSON.toJSONString(result));
             return;
         }
-        JSONObject bizJson = JSONObject.parseObject(bizJsonStr);
-        if(null != bizJson && !bizJson.isEmpty() && bizJson.containsKey("tenantId")){
-            boolean isSwitched = false;
-            try{
-                TenantHelper.setTenantId(bizJson.getLong("tenantId"));
-                try {
-                    Object manager = SpringUtils.getBean("tenantDataSourceManager");
-                    Method method = manager.getClass().getMethod("ensureSwitchByTenantId", Long.class);
-                    method.invoke(manager, TenantHelper.getTenantId());
-                    // 设置租户到 SecurityContext,供 TenantKeyRedisSerializer 自动为 Redis Key 加 tenantid 前缀
-                    SecurityContextHolder.getContext().setAuthentication(
-                            new UsernamePasswordAuthenticationToken(
-                                    new TenantPrincipal(TenantHelper.getTenantId()),
-                                    null,
-                                    Collections.emptyList()
-                            )
-                    );
-                    // 切换 Redis 租户上下文
-                    RedisTenantContext.setTenantId(TenantHelper.getTenantId());
-                } catch (Exception e) {
-                    log.error("callerResult4EasyCall 切换租户数据源失败: tenantId={}", TenantHelper.getTenantId(), e);
+        Long tenantId = EasyCallCallbackContextHelper.resolveTenantId(result, callPhoneRes);
+        if (tenantId == null) {
+            log.error("easyCall回调无法解析tenantId, uuid={}", result.getUuid());
+            return;
+        }
+        boolean isSwitched = EasyCallCallbackContextHelper.switchTenant(tenantId);
+        try {
+            // 【当前启用】cc_call_phone.intent 由 EasyCall 异步评估写入,回调时可能尚未赋值,进入延迟重试队列等待
+            if (StringUtils.isBlank(resolveCcCallPhoneIntent(callPhoneRes))) {
+                String retryKey = EASYCALL_INTENT_RETRY_KEY + result.getUuid();
+                Integer retryCount = redisCache2.getCacheObject(retryKey);
+                if (retryCount == null) {
+                    retryCount = 0;
                 }
-                // 【当前启用】cc_call_phone.intent 由 EasyCall 异步评估写入,回调时可能尚未赋值,进入延迟重试队列等待
-                if (StringUtils.isBlank(resolveCcCallPhoneIntent(callPhoneRes))) {
-                    String retryKey = EASYCALL_INTENT_RETRY_KEY + result.getUuid();
-                    Integer retryCount = redisCache2.getCacheObject(retryKey);
-                    if (retryCount == null) {
-                        retryCount = 0;
-                    }
-                    if (retryCount < EASYCALL_INTENT_MAX_RETRY) {
-                        redisCache2.setCacheObject(retryKey, retryCount + 1, 10, java.util.concurrent.TimeUnit.MINUTES);
-                        log.info("easyCall外呼回调intent意向度暂未评估完成,uuid={},第{}次放入延迟重试队列", result.getUuid(), retryCount + 1);
-                        doRetryCallerResult4EasyCall(result, retryCount + 1);
-                    } else {
-                        log.warn("easyCall外呼回调intent意向度在{}次重试后仍为空,uuid={},以意向未知兜底处理", EASYCALL_INTENT_MAX_RETRY, result.getUuid());
-                        redisCache2.deleteObject(retryKey);
-                        doHandleEasyCallResult(callPhoneRes);
-                    }
-                    return;
+                if (retryCount < EASYCALL_INTENT_MAX_RETRY) {
+                    redisCache2.setCacheObject(retryKey, retryCount + 1, 10, java.util.concurrent.TimeUnit.MINUTES);
+                    log.info("easyCall外呼回调intent意向度暂未评估完成,uuid={},第{}次放入延迟重试队列", result.getUuid(), retryCount + 1);
+                    doRetryCallerResult4EasyCall(result, retryCount + 1);
+                } else {
+                    log.warn("easyCall外呼回调intent意向度在{}次重试后仍为空,uuid={},以意向未知兜底处理", EASYCALL_INTENT_MAX_RETRY, result.getUuid());
+                    redisCache2.deleteObject(retryKey);
+                    doHandleEasyCallResult(callPhoneRes);
                 }
-                redisCache2.deleteObject(EASYCALL_INTENT_RETRY_KEY + result.getUuid());
-                doHandleEasyCallResult(callPhoneRes);
+                return;
+            }
+            redisCache2.deleteObject(EASYCALL_INTENT_RETRY_KEY + result.getUuid());
+            doHandleEasyCallResult(callPhoneRes);
 
-                // ========== 【历史保留-自家AI】根据 dialogue 等待后走 AI 意向度,回滚时注释上方 intent 重试并取消下方注释 ==========
+            // ========== 【历史保留-自家AI】根据 dialogue 等待后走 AI 意向度,回滚时注释上方 intent 重试并取消下方注释 ==========
 //                // 当前:根据对话内容同步调用自家 AI 计算意向度,不依赖第三方 intent
 //                if (isDialogueEmpty(callPhoneRes.getDialogue()) && !"未接通".equals(callPhoneRes.getIntent())) {
 //                    String retryKey = EASYCALL_DIALOGUE_RETRY_KEY + result.getUuid();
@@ -859,24 +844,9 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
             } catch (Exception e) {
                 throw new RuntimeException(e);
             } finally {
-                // 2. 清理租户上下文
-                if (isSwitched) {
-                    try {
-                        TenantConfigContext.clear();
-                        SecurityContextHolder.clearContext();
-                        Object manager = SpringUtils.getBean("tenantDataSourceManager");
-                        Method method = manager.getClass().getMethod("clear");
-                        method.invoke(manager);
-                        TenantHelper.clearTenantId();
-                        RedisTenantContext.clear();
-                    } catch (Exception e) {
-                        log.error("SOP异步任务清理租户数据源失败", e);
-                    }
-                }
+                EasyCallCallbackContextHelper.clearTenant(isSwitched);
             }
 
-        }
-
     }
     private boolean isDialogueEmpty(String dialogue) {
         if (StringUtils.isBlank(dialogue)) {
@@ -905,6 +875,13 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
             log.error("easyCall dialogue重试时仍未查询到外呼结果, uuid={}", result.getUuid());
             return;
         }
+        Long tenantId = EasyCallCallbackContextHelper.resolveTenantId(result, callPhoneRes);
+        if (tenantId == null) {
+            log.error("easyCall dialogue重试无法解析tenantId, uuid={}", result.getUuid());
+            return;
+        }
+        boolean isSwitched = EasyCallCallbackContextHelper.switchTenant(tenantId);
+        try {
         if (isDialogueEmpty(callPhoneRes.getDialogue()) && !"未接通".equals(callPhoneRes.getIntent())) {
             // dialogue 仍为空,继续判断是否还有剩余重试次数
             String retryKey = EASYCALL_DIALOGUE_RETRY_KEY + result.getUuid();
@@ -927,6 +904,9 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
         log.info("easyCall dialogue重试第{}次成功获取到对话内容,uuid={}", currentRetry, result.getUuid());
         redisCache2.deleteObject(EASYCALL_DIALOGUE_RETRY_KEY + result.getUuid());
         doHandleEasyCallResult(callPhoneRes);
+        } finally {
+            EasyCallCallbackContextHelper.clearTenant(isSwitched);
+        }
     }
     /**
      * 延迟重试处理 EasyCall 外呼回调(等待 intent 意向度异步评估完成)
@@ -947,6 +927,13 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
             log.error("easyCall intent重试时仍未查询到外呼结果, uuid={}", result.getUuid());
             return;
         }
+        Long tenantId = EasyCallCallbackContextHelper.resolveTenantId(result, callPhoneRes);
+        if (tenantId == null) {
+            log.error("easyCall intent重试无法解析tenantId, uuid={}", result.getUuid());
+            return;
+        }
+        boolean isSwitched = EasyCallCallbackContextHelper.switchTenant(tenantId);
+        try {
         if (StringUtils.isBlank(callPhoneRes.getIntent())) {
             // intent 仍为空,继续判断是否还有剩余重试次数
             String retryKey = EASYCALL_INTENT_RETRY_KEY + result.getUuid();
@@ -969,6 +956,9 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
         log.info("easyCall intent重试第{}次成功获取到意向度={},uuid={}", currentRetry, callPhoneRes.getIntent(), result.getUuid());
         redisCache2.deleteObject(EASYCALL_INTENT_RETRY_KEY + result.getUuid());
         doHandleEasyCallResult(callPhoneRes);
+        } finally {
+            EasyCallCallbackContextHelper.clearTenant(isSwitched);
+        }
     }
 
     /**
@@ -976,6 +966,11 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
      * 供 {@link #callerResult4EasyCall} 和重试逻辑统一调用
      */
     private void doHandleEasyCallResult(EasyCallCallPhoneVO callPhoneRes) {
+        Long tenantId = EasyCallCallbackContextHelper.extractTenantId(callPhoneRes.getBizJson());
+        EasyCallCallbackContextHelper.runWithTenant(tenantId, () -> doHandleEasyCallResultInternal(callPhoneRes));
+    }
+
+    private void doHandleEasyCallResultInternal(EasyCallCallPhoneVO callPhoneRes) {
         //等待数据信息
         JSONObject bizJson = JSONObject.parseObject(callPhoneRes.getBizJson());
 
@@ -1097,6 +1092,11 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
 //            // 历史第三方值(仅 intent 字段,未走 AI 时的写法)
 //            // String intention = getIntention(callPhoneRes.getIntent());
             CompanyVoiceRoboticCallees callee = companyVoiceRoboticCalleesMapper.selectCompanyVoiceRoboticCalleesById(cacheInfo.getLong("calleeId"));
+            if (callee == null) {
+                log.error("easyCall回调未找到被叫人记录, calleeId={}, uuid={}, tenantId={}",
+                        cacheInfo.getLong("calleeId"), callPhoneRes.getUuid(), TenantHelper.getTenantId());
+                return;
+            }
             callee.setUuid(callPhoneRes.getUuid());
             callee.setIntention(intention);
             callee.setJson(JSON.toJSONString(callPhoneRes.getDialogue()));
@@ -1771,23 +1771,13 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
      * 获取工作流状态名称
      */
     private String getStatusName(Integer status) {
-        switch (status) {
-            case 1:
-                return "执行成功";
-            case 2:
-                return "执行失败";
-            case 3:
-                return "执行中";
-            case 4:
-                return "已暂停";
-            case 5:
-                return "等待中";
-            case 6:
-                return "已取消";
-            case 7:
-                return "执行超时";
-            default:
-                return "未知";
+        if (status == null) {
+            return "未知";
+        }
+        try {
+            return ExecutionStatusEnum.fromValue(status).getDescription();
+        } catch (IllegalArgumentException ex) {
+            return "未知";
         }
     }
 
@@ -1845,7 +1835,11 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
             vo.setStartTime(log.getStartTime());
             vo.setEndTime(log.getEndTime());
             vo.setDuration(log.getDuration());
-            vo.setErrorMessage(log.getErrorMessage());
+            String errorMessage = log.getErrorMessage();
+            if (StringUtils.isBlank(errorMessage) && WorkflowExecErrorMessages.isFailedStatus(log.getStatus())) {
+                errorMessage = WorkflowExecErrorMessages.extractErrorFromOutputData(log.getOutputData());
+            }
+            vo.setErrorMessage(WorkflowExecErrorMessages.sanitizeForDisplay(errorMessage));
             vo.setOutputData(log.getOutputData());
             CallContentVO callContentVO = callContentMap.get(log.getId());
             if (callContentVO != null) {

+ 7 - 1
fs-service/src/main/java/com/fs/company/service/impl/call/node/AbstractWorkflowNode.java

@@ -265,6 +265,11 @@ public abstract class AbstractWorkflowNode implements IWorkflowNode {
         CompanyAiWorkflowExecLog logEntry = createLogEntry(context.getWorkflowInstanceId(), nodeKey, getType(), result, context);
         logEntry.setStatus(logStatus);
         logExecution(logEntry);
+        if (ExecutionStatusEnum.FAILURE.equals(result.getStatus())
+                || ExecutionStatusEnum.TIMEOUT.equals(result.getStatus())) {
+            updateWorkflowStatus(context.getWorkflowInstanceId(), result.getStatus());
+            taskFinish(context);
+        }
     }
 
     /**
@@ -272,8 +277,9 @@ public abstract class AbstractWorkflowNode implements IWorkflowNode {
      */
     protected ExecutionResult handleExecutionError(Exception e, ExecutionContext context) {
         log.error("Error executing node: {} ({})", nodeKey, nodeName, e);
+        String displayMessage = WorkflowExecErrorMessages.resolveExecutionErrorMessage(e);
         return ExecutionResult.failure()
-                .errorMessage(e.getMessage())
+                .errorMessage(displayMessage)
                 .outputData(Collections.singletonMap("error", e.getMessage())).build();
     }
 

+ 7 - 3
fs-service/src/main/java/com/fs/company/service/impl/call/node/AiAddWxTaskNewNode.java

@@ -86,7 +86,9 @@ public class AiAddWxTaskNewNode extends AbstractWorkflowNode {
     @Override
     protected ExecutionResult doExecute(ExecutionContext context) {
         if (!isAsync()) {
-            return ExecutionResult.failure().nextNodeKey(null).build();
+            return ExecutionResult.failure()
+                    .errorMessage("加微节点不支持同步执行")
+                    .nextNodeKey(null).build();
         }
         try {
             // 设置加微话术
@@ -140,8 +142,10 @@ public class AiAddWxTaskNewNode extends AbstractWorkflowNode {
 
         } catch (Exception e) {
             log.error("准备加微任务数据异常 流程:{}:节点:{}执行失败,", context.getWorkflowInstanceId(), nodeKey, e);
-            super.updateWorkflowStatus(context.getWorkflowInstanceId(), ExecutionStatusEnum.INTERRUPT);
-            return ExecutionResult.failure().errorMessage("准备加微任务数据异常: " + e.getMessage()).build();
+            super.updateWorkflowStatus(context.getWorkflowInstanceId(), ExecutionStatusEnum.FAILURE);
+            return ExecutionResult.failure()
+                    .errorMessage(WorkflowExecErrorMessages.resolveExecutionErrorMessage(e))
+                    .build();
         }
     }
 

+ 7 - 3
fs-service/src/main/java/com/fs/company/service/impl/call/node/AiAddWxTaskNode.java

@@ -125,7 +125,9 @@ public class AiAddWxTaskNode extends AbstractWorkflowNode {
     @Override
     protected ExecutionResult doExecute(ExecutionContext context) {
         if (!isAsync()) {
-            return ExecutionResult.failure().nextNodeKey(null).build();
+            return ExecutionResult.failure()
+                    .errorMessage("加微节点不支持同步执行")
+                    .nextNodeKey(null).build();
         }
         try {
             //设置加微话术
@@ -148,8 +150,10 @@ public class AiAddWxTaskNode extends AbstractWorkflowNode {
 
         } catch (Exception e) {
             log.error("准备加微任务数据异常 流程:{}:节点:{}执行失败,", context.getWorkflowInstanceId(), nodeKey, e);
-            super.updateWorkflowStatus(context.getWorkflowInstanceId(), ExecutionStatusEnum.INTERRUPT);
-            return ExecutionResult.failure().errorMessage("准备加微任务数据异常: " + e.getMessage()).build();
+            super.updateWorkflowStatus(context.getWorkflowInstanceId(), ExecutionStatusEnum.FAILURE);
+            return ExecutionResult.failure()
+                    .errorMessage(WorkflowExecErrorMessages.resolveExecutionErrorMessage(e))
+                    .build();
         }
     }
 

+ 11 - 4
fs-service/src/main/java/com/fs/company/service/impl/call/node/AiCallTaskNode.java

@@ -15,6 +15,7 @@ import com.fs.company.service.ICompanyVoiceRoboticCallBlacklistService;
 import com.fs.company.service.ICompanyVoiceRoboticService;
 import com.fs.company.service.easycall.IEasyCallService;
 import com.fs.comm.client.CommGatewayClient;
+import com.fs.comm.exception.CommBlacklistRejectException;
 import com.fs.comm.model.CommCallSendParam;
 import com.fs.comm.model.CommCallSendResult;
 import com.fs.comm.service.CommCallSendService;
@@ -188,12 +189,12 @@ public class AiCallTaskNode extends AbstractWorkflowNode {
                 String nodeConfig = node.getNodeConfig();
                 if (StringUtils.isBlank(nodeConfig)) {
                     log.error("流程:{} 节点:{} 未配置节点配置", context.getWorkflowInstanceId(), nodeKey);
-                    super.updateWorkflowStatus(context.getWorkflowInstanceId(), ExecutionStatusEnum.INTERRUPT);
+                    super.updateWorkflowStatus(context.getWorkflowInstanceId(), ExecutionStatusEnum.FAILURE);
                     return ExecutionResult.failure().errorMessage("未配置节点配置").build();
                 }
                 AiCallConfigVO callConfigVo = JSONObject.parseObject(nodeConfig, AiCallConfigVO.class);
                 if (callConfigVo == null) {
-                    super.updateWorkflowStatus(context.getWorkflowInstanceId(), ExecutionStatusEnum.INTERRUPT);
+                    super.updateWorkflowStatus(context.getWorkflowInstanceId(), ExecutionStatusEnum.FAILURE);
                     return ExecutionResult.failure().errorMessage("节点配置解析失败").build();
                 }
 
@@ -205,7 +206,7 @@ public class AiCallTaskNode extends AbstractWorkflowNode {
 
                 //进入手机号拨打次数校验
                 if(checkPhoneCallLimit(bus.getId())){
-                    super.updateWorkflowStatus(context.getWorkflowInstanceId(), ExecutionStatusEnum.INTERRUPT);
+                    super.updateWorkflowStatus(context.getWorkflowInstanceId(), ExecutionStatusEnum.FAILURE);
                     return ExecutionResult.failure().errorMessage("今日拨打次数已达上限!").build();
                 }
 
@@ -225,12 +226,14 @@ public class AiCallTaskNode extends AbstractWorkflowNode {
                         .nextNodeKey("").build();
             } else {
                 return ExecutionResult.failure()
+                        .errorMessage("外呼节点不支持同步执行")
                         .nextNodeKey(null).build();
             }
         } catch (Exception ex) {
             log.error("流程:{}:节点:{}执行失败,", context.getWorkflowInstanceId(), nodeKey, ex);
-            super.updateWorkflowStatus(context.getWorkflowInstanceId(), ExecutionStatusEnum.INTERRUPT);
+            super.updateWorkflowStatus(context.getWorkflowInstanceId(), ExecutionStatusEnum.FAILURE);
             return ExecutionResult.failure()
+                    .errorMessage(WorkflowExecErrorMessages.resolveExecutionErrorMessage(ex))
                     .nextNodeKey(null).build();
         }
     }
@@ -279,6 +282,8 @@ public class AiCallTaskNode extends AbstractWorkflowNode {
             try {
                 workflowCallPhoneViaGateway(commGatewayClient, roboticId, calleeId, context, callConfigVo);
                 return;
+            } catch (CommBlacklistRejectException ex) {
+                throw ex;
             } catch (Exception ex) {
                 log.error("workflowCallPhoneOne4EasyCall 通讯网关调用失败,roboticId={}, calleeId={}", roboticId, calleeId, ex);
                 if (!commGatewayClient.isFallbackLocal()) {
@@ -324,6 +329,7 @@ public class AiCallTaskNode extends AbstractWorkflowNode {
     private void workflowCallPhoneOne4EasyCallLocal(Long roboticId,Long calleeId, ExecutionContext context, AiCallConfigVO callConfigVo) {
         CommCallSendService commCallSendService = SpringUtils.getBean(CommCallSendService.class);
         CompanyVoiceRoboticBusiness bus = super.getRoboticBusiness(context.getWorkflowInstanceId());
+        CompanyVoiceRobotic robotic = companyVoiceRoboticMapper.selectById(roboticId);
         CommCallSendResult result = commCallSendService.sendWorkflowCall(CommCallSendParam.builder()
                 .roboticId(roboticId)
                 .calleeId(calleeId)
@@ -332,6 +338,7 @@ public class AiCallTaskNode extends AbstractWorkflowNode {
                 .nodeKey(context.getCurrentNodeKey())
                 .workflowInstanceId(context.getWorkflowInstanceId())
                 .tenantId(TenantHelper.getTenantId())
+                .companyUserId(robotic != null ? robotic.getCompanyUserId() : null)
                 .build());
         context.setVariable("callBackUuid", result.getCallBackUuid());
         context.setVariable("easyCallBatchId", result.getBatchId());

+ 7 - 3
fs-service/src/main/java/com/fs/company/service/impl/call/node/AiQwAddWxTaskNode.java

@@ -110,7 +110,9 @@ public class AiQwAddWxTaskNode extends AbstractWorkflowNode {
     @Override
     protected ExecutionResult doExecute(ExecutionContext context) {
         if (!isAsync()) {
-            return ExecutionResult.failure().nextNodeKey(null).build();
+            return ExecutionResult.failure()
+                    .errorMessage("企微加微节点不支持同步执行")
+                    .nextNodeKey(null).build();
         }
         try {
             super.asyncWorkflowForBlockingNode(context.getWorkflowInstanceId(), context.getCurrentNodeKey(), context, ExecutionStatusEnum.WAITING);
@@ -119,8 +121,10 @@ public class AiQwAddWxTaskNode extends AbstractWorkflowNode {
                     .nextNodeKey("").build();
         } catch (Exception e) {
             log.error("准备加微任务数据异常 流程:{}:节点:{}执行失败,", context.getWorkflowInstanceId(), nodeKey, e);
-            super.updateWorkflowStatus(context.getWorkflowInstanceId(), ExecutionStatusEnum.INTERRUPT);
-            return ExecutionResult.failure().errorMessage("准备加微任务数据异常: " + e.getMessage()).build();
+            super.updateWorkflowStatus(context.getWorkflowInstanceId(), ExecutionStatusEnum.FAILURE);
+            return ExecutionResult.failure()
+                    .errorMessage(WorkflowExecErrorMessages.resolveExecutionErrorMessage(e))
+                    .build();
         }
     }
 

+ 1 - 1
fs-service/src/main/java/com/fs/company/service/impl/call/node/AiSendMsgTaskNode.java

@@ -138,7 +138,7 @@ public class AiSendMsgTaskNode extends AbstractWorkflowNode {
         } catch (Exception e) {
             log.error("发送短信异常 - workflowInstanceId: {}", context.getWorkflowInstanceId(), e);
             return ExecutionResult.failure()
-                    .errorMessage("发送短信异常: " + e.getMessage())
+                    .errorMessage(WorkflowExecErrorMessages.resolveExecutionErrorMessage(e))
                     .build();
         }
     }

+ 110 - 0
fs-service/src/main/java/com/fs/company/service/impl/call/node/WorkflowExecErrorMessages.java

@@ -0,0 +1,110 @@
+package com.fs.company.service.impl.call.node;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fs.comm.exception.CommBlacklistRejectException;
+import com.fs.common.exception.CustomException;
+import com.fs.common.exception.ServiceException;
+import com.fs.common.utils.StringUtils;
+
+/**
+ * 工作流节点执行失败原因展示文案处理
+ */
+public final class WorkflowExecErrorMessages {
+
+    public static final String SYSTEM_ERROR_DISPLAY_MSG = "系统异常,请联系管理员";
+
+    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+    private WorkflowExecErrorMessages() {
+    }
+
+    /**
+     * 根据异常类型解析对用户展示的错误信息:业务异常原样,系统异常脱敏
+     */
+    public static String resolveExecutionErrorMessage(Throwable ex) {
+        if (ex == null) {
+            return SYSTEM_ERROR_DISPLAY_MSG;
+        }
+        Throwable root = unwrap(ex);
+        if (root instanceof CommBlacklistRejectException) {
+            CommBlacklistRejectException blacklistEx = (CommBlacklistRejectException) root;
+            if (blacklistEx.getCheckResult() != null
+                    && StringUtils.isNotBlank(blacklistEx.getCheckResult().getReason())) {
+                return blacklistEx.getCheckResult().getReason();
+            }
+            return StringUtils.defaultIfBlank(root.getMessage(), "被叫人命中外呼黑名单");
+        }
+        if (root instanceof ServiceException || root instanceof CustomException) {
+            String message = root.getMessage();
+            return StringUtils.isNotBlank(message) ? message : "业务处理失败";
+        }
+        return SYSTEM_ERROR_DISPLAY_MSG;
+    }
+
+    /**
+     * 存量日志兜底:从 output_data JSON 中提取 error 字段
+     */
+    public static String extractErrorFromOutputData(String outputData) {
+        if (StringUtils.isBlank(outputData)) {
+            return null;
+        }
+        try {
+            JsonNode root = OBJECT_MAPPER.readTree(outputData);
+            if (root == null || !root.has("error")) {
+                return null;
+            }
+            JsonNode errorNode = root.get("error");
+            if (errorNode == null || errorNode.isNull()) {
+                return null;
+            }
+            String error = errorNode.asText();
+            return StringUtils.isNotBlank(error) ? error : null;
+        } catch (Exception ignored) {
+            return null;
+        }
+    }
+
+    /**
+     * 对展示文案做脱敏:技术类错误统一为系统异常提示
+     */
+    public static String sanitizeForDisplay(String message) {
+        if (StringUtils.isBlank(message)) {
+            return message;
+        }
+        if (SYSTEM_ERROR_DISPLAY_MSG.equals(message)) {
+            return message;
+        }
+        if (looksLikeTechnicalError(message)) {
+            return SYSTEM_ERROR_DISPLAY_MSG;
+        }
+        return message;
+    }
+
+    public static boolean isFailedStatus(Integer status) {
+        return status != null && (status == 2 || status == 7 || status == 8);
+    }
+
+    private static boolean looksLikeTechnicalError(String message) {
+        String lower = message.toLowerCase();
+        return lower.contains("exception")
+                || lower.contains("sql")
+                || lower.contains("table '")
+                || lower.contains("doesn't exist")
+                || lower.contains("error updating database")
+                || lower.contains("java.")
+                || lower.contains("org.springframework")
+                || lower.contains("mybatis")
+                || lower.contains("nested exception")
+                || lower.contains("communications link failure")
+                || lower.contains("connection refused");
+    }
+
+    private static Throwable unwrap(Throwable ex) {
+        Throwable current = ex;
+        while (current.getCause() != null && current.getCause() != current) {
+            current = current.getCause();
+        }
+        return current;
+    }
+}

+ 10 - 0
fs-service/src/main/java/com/fs/company/vo/CompanyVoiceRoboticCallBlacklistCheckVO.java

@@ -43,6 +43,16 @@ public class CompanyVoiceRoboticCallBlacklistCheckVO {
      */
     private String reason;
 
+    /**
+     * 黑名单配置中的拉黑原因(快照)
+     */
+    private String blacklistReason;
+
+    /**
+     * 拦截明细记录 ID
+     */
+    private Long interceptLogId;
+
     public static CompanyVoiceRoboticCallBlacklistCheckVO pass(String businessType) {
         CompanyVoiceRoboticCallBlacklistCheckVO vo = new CompanyVoiceRoboticCallBlacklistCheckVO();
         vo.setPass(true);

+ 3 - 0
fs-service/src/main/java/com/fs/company/vo/CompanyVoiceRoboticCallLogCallPhoneVO.java

@@ -87,4 +87,7 @@ public class CompanyVoiceRoboticCallLogCallPhoneVO {
 
     /** 是否警告(0否 1是)用于敏感词 */
     private Integer isWarning;
+
+    /** 计费分钟数:通话时长向上取整,未满1分钟按1分钟 */
+    private Integer billingMinute;
 }

+ 24 - 0
fs-service/src/main/java/com/fs/company/vo/CompanyVoiceRoboticCallLogDetailSummary.java

@@ -0,0 +1,24 @@
+package com.fs.company.vo;
+
+import lombok.Data;
+
+/**
+ * AI外呼记录明细统计
+ */
+@Data
+public class CompanyVoiceRoboticCallLogDetailSummary {
+
+    private Long totalCount;
+
+    private Long successCount;
+
+    private Long failCount;
+
+    private Long connectedCount;
+
+    /** 接通率(百分比,整数) */
+    private Integer connectRate;
+
+    /** 计费分钟合计(未满1分钟按1分钟计) */
+    private Long totalBillingMinute;
+}

+ 13 - 9
fs-service/src/main/java/com/fs/company/vo/ExecutionResult.java

@@ -7,38 +7,42 @@ import lombok.Data;
 import lombok.NoArgsConstructor;
 
 /**
+ * 流程执行结果类
+ *
  * @author MixLiu
  * @date 2026/1/28 10:31
- * @description 流程执行结果类
  */
 @Data
 @Builder
 @NoArgsConstructor
 @AllArgsConstructor
 public class ExecutionResult {
+
     private boolean success;
+
     private String nextNodeKey;
+
     private String errorMessage;
+
     private Object outputData;
+
     private String workflowInstanceId;
+
     private ExecutionStatusEnum status;
 
     public static ExecutionResultBuilder success() {
         return ExecutionResult.builder().success(true).status(ExecutionStatusEnum.SUCCESS);
     }
+
     public static ExecutionResultBuilder failure() {
-        ExecutionResult result = new ExecutionResult();
-        result.success = false;
-        result.status = ExecutionStatusEnum.FAILURE;
         return ExecutionResult.builder().success(false).status(ExecutionStatusEnum.FAILURE);
     }
-    public static ExecutionResultBuilder paused(){
-        ExecutionResult result = new ExecutionResult();
+
+    public static ExecutionResultBuilder paused() {
         return ExecutionResult.builder().success(true).status(ExecutionStatusEnum.PAUSED);
     }
-    public static ExecutionResultBuilder waiting(){
-        ExecutionResult result = new ExecutionResult();
+
+    public static ExecutionResultBuilder waiting() {
         return ExecutionResult.builder().success(true).status(ExecutionStatusEnum.WAITING);
     }
-
 }

+ 4 - 0
fs-service/src/main/java/com/fs/crm/param/CrmCustomeRecoverParam.java

@@ -3,6 +3,8 @@ package com.fs.crm.param;
 
 import lombok.Data;
 
+import java.util.List;
+
 @Data
 public class CrmCustomeRecoverParam extends BaseQueryParam
 {
@@ -10,6 +12,8 @@ public class CrmCustomeRecoverParam extends BaseQueryParam
 
     private Long customerUserId;
 
+    private List<Long> customerUserIds;
+
     private Long companyUserId;
 
     private Long companyId;

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

@@ -101,6 +101,8 @@ public interface ICrmCustomerService
 
     R recover(CrmCustomeRecoverParam param, String operName);
 
+    R batchRecover(CrmCustomeRecoverParam param, String operName);
+
     R assignUser(CrmCustomeAssignUserParam param, String operName);
 
     Integer selectCrmCustomerCountByType(Long companyId, int type);

+ 34 - 0
fs-service/src/main/java/com/fs/crm/service/impl/CrmCustomerServiceImpl.java

@@ -381,6 +381,40 @@ public class CrmCustomerServiceImpl extends ServiceImpl<CrmCustomerMapper, CrmCu
         return R.ok();
     }
 
+    @Override
+    public R batchRecover(CrmCustomeRecoverParam param, String operName) {
+        if (param.getCustomerUserIds() == null || param.getCustomerUserIds().isEmpty()) {
+            return R.error("请选择要回收的客户");
+        }
+        int successCount = 0;
+        int failCount = 0;
+        StringBuilder failMsg = new StringBuilder();
+        for (Long customerUserId : param.getCustomerUserIds()) {
+            CrmCustomeRecoverParam recoverParam = new CrmCustomeRecoverParam();
+            recoverParam.setCustomerUserId(customerUserId);
+            recoverParam.setCompanyId(param.getCompanyId());
+            recoverParam.setCompanyUserId(param.getCompanyUserId());
+            R result = recover(recoverParam, operName);
+            if (Integer.valueOf(200).equals(result.get("code"))) {
+                successCount++;
+            } else {
+                failCount++;
+                if (failMsg.length() > 0) {
+                    failMsg.append(";");
+                }
+                failMsg.append(result.get("msg"));
+            }
+        }
+        if (failCount == 0) {
+            return R.ok("成功回收" + successCount + "个客户");
+        }
+        if (successCount == 0) {
+            return R.error("回收失败:" + failMsg);
+        }
+        return R.ok("成功回收" + successCount + "个客户,失败" + failCount + "个:" + failMsg);
+    }
+
+
     @Override
     @Transactional
     public R recover(CrmCustomeRecoverParam param, String operName) {

+ 3 - 0
fs-service/src/main/java/com/fs/proxy/domain/CompanySmsApi.java

@@ -38,6 +38,9 @@ public class CompanySmsApi extends BaseEntity {
     /** 接口地址(迈远专用) */
     private String url;
 
+    /** 回调地址(通讯网关外呼/下发任务回调) */
+    private String callbackUrl;
+
     /** 扩展码(迈远专用) */
     private String code;
 

+ 19 - 0
fs-service/src/main/java/com/fs/proxy/enums/ConsumeServiceResult.java

@@ -0,0 +1,19 @@
+package com.fs.proxy.enums;
+
+/**
+ * ������ѽ��
+ */
+public enum ConsumeServiceResult {
+    /** �۷ѳɹ� */
+    SUCCESS,
+    /** �ѿ۷ѣ��ݵ����У� */
+    ALREADY_CONSUMED,
+    /** ���� */
+    INSUFFICIENT_BALANCE,
+    /** ������Ч */
+    INVALID_PARAM,
+    /** ��ȡ����ʱ */
+    LOCK_TIMEOUT,
+    /** ����ʧ�� */
+    FAILED
+}

+ 8 - 0
fs-service/src/main/java/com/fs/proxy/mapper/CompanySmsApiMapper.java

@@ -1,9 +1,14 @@
 package com.fs.proxy.mapper;
 
+import com.fs.common.annotation.DataSource;
+import com.fs.common.enums.DataSourceType;
 import com.fs.proxy.domain.CompanySmsApi;
+import org.apache.ibatis.annotations.Param;
 
 import java.util.List;
 
+/** 短信接口(主库 company_sms_api) */
+@DataSource(DataSourceType.MASTER)
 public interface CompanySmsApiMapper {
 
     List<CompanySmsApi> selectSmsApiList(CompanySmsApi query);
@@ -12,6 +17,9 @@ public interface CompanySmsApiMapper {
 
     CompanySmsApi selectSmsApiBySmsType(Integer smsType);
 
+    /** 查询启用的短信接口(无租户绑定时降级使用) */
+    List<CompanySmsApi> selectActiveListBySmsType(@Param("smsType") Integer smsType);
+
     int insertSmsApi(CompanySmsApi smsApi);
 
     int updateSmsApi(CompanySmsApi smsApi);

+ 4 - 0
fs-service/src/main/java/com/fs/proxy/mapper/CompanySmsApiPortMapper.java

@@ -1,10 +1,14 @@
 package com.fs.proxy.mapper;
 
+import com.fs.common.annotation.DataSource;
+import com.fs.common.enums.DataSourceType;
 import com.fs.proxy.domain.CompanySmsApiPort;
 import org.apache.ibatis.annotations.Param;
 
 import java.util.List;
 
+/** 短信端口池(主库 company_sms_api_port) */
+@DataSource(DataSourceType.MASTER)
 public interface CompanySmsApiPortMapper {
 
     List<CompanySmsApiPort> selectPortList(CompanySmsApiPort query);

+ 4 - 0
fs-service/src/main/java/com/fs/proxy/mapper/CompanySmsApiTenantMapper.java

@@ -1,11 +1,15 @@
 package com.fs.proxy.mapper;
 
+import com.fs.common.annotation.DataSource;
+import com.fs.common.enums.DataSourceType;
 import com.fs.proxy.domain.CompanySmsApiTenant;
 import org.apache.ibatis.annotations.Param;
 
 import java.math.BigDecimal;
 import java.util.List;
 
+/** 短信接口-租户绑定(主库 company_sms_api_tenant) */
+@DataSource(DataSourceType.MASTER)
 public interface CompanySmsApiTenantMapper {
 
     List<CompanySmsApiTenant> selectSmsApiTenantList(CompanySmsApiTenant query);

+ 3 - 0
fs-service/src/main/java/com/fs/proxy/mapper/TenantConsumeRecordMapper.java

@@ -47,4 +47,7 @@ public interface TenantConsumeRecordMapper extends BaseMapper<TenantConsumeRecor
 
     /** 按消费类型汇总 */
     List<TenantConsumeRecord> sumByConsumeType(@Param("consumeType") String consumeType, @Param("startTime") String startTime, @Param("endTime") String endTime);
+
+    /** 按租户+流水号查询(幂等扣费) */
+    TenantConsumeRecord selectByTenantAndOrderNo(@Param("tenantId") Long tenantId, @Param("orderNo") String orderNo);
 }

+ 25 - 0
fs-service/src/main/java/com/fs/proxy/model/ConsumeServiceOutcome.java

@@ -0,0 +1,25 @@
+package com.fs.proxy.model;
+
+import com.fs.proxy.enums.ConsumeServiceResult;
+import lombok.Builder;
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * ������ѽ������
+ */
+@Data
+@Builder
+public class ConsumeServiceOutcome {
+    private ConsumeServiceResult result;
+    private BigDecimal amount;
+    private Integer quantity;
+    private BigDecimal unitPrice;
+    private String orderNo;
+    private Long recordId;
+
+    public boolean isSuccess() {
+        return result == ConsumeServiceResult.SUCCESS || result == ConsumeServiceResult.ALREADY_CONSUMED;
+    }
+}

+ 16 - 0
fs-service/src/main/java/com/fs/proxy/model/UnitPricePair.java

@@ -0,0 +1,16 @@
+package com.fs.proxy.model;
+
+import lombok.Getter;
+
+import java.math.BigDecimal;
+
+@Getter
+public class UnitPricePair {
+    private final BigDecimal unitPrice;
+    private final BigDecimal platformCost;
+
+    public UnitPricePair(BigDecimal unitPrice, BigDecimal platformCost) {
+        this.unitPrice = unitPrice;
+        this.platformCost = platformCost;
+    }
+}

+ 18 - 0
fs-service/src/main/java/com/fs/proxy/service/BalanceService.java

@@ -3,7 +3,9 @@ package com.fs.proxy.service;
 import com.fs.proxy.domain.TenantBalance;
 import com.fs.proxy.domain.TenantConsumeRecord;
 import com.fs.proxy.domain.ServiceFeeConfig;
+import com.fs.proxy.enums.ConsumeServiceResult;
 import com.fs.proxy.enums.ConsumeTypeEnum;
+import com.fs.proxy.model.ConsumeServiceOutcome;
 
 import java.math.BigDecimal;
 import java.util.List;
@@ -65,6 +67,22 @@ public interface BalanceService {
      */
     boolean consumeService(Long tenantId, ConsumeTypeEnum consumeType, Integer quantity, String remark);
 
+    /**
+     * 幂等消费(按量扣总账户余额),相同 orderNo 仅扣费一次
+     */
+    ConsumeServiceOutcome consumeServiceIdempotent(Long tenantId, ConsumeTypeEnum consumeType,
+                                                   Integer quantity, String orderNo, String remark);
+
+    /**
+     * 按量服务余额是否充足(校验总账户余额)
+     */
+    boolean checkPayAsYouGoBalance(Long tenantId, ConsumeTypeEnum consumeType, Integer quantity);
+
+    /**
+     * 解析服务单价(含租户定价覆盖)
+     */
+    BigDecimal resolveUnitPrice(Long tenantId, ConsumeTypeEnum consumeType);
+
     /**
      * 服务余额转总余额
      *

+ 4 - 0
fs-service/src/main/java/com/fs/proxy/service/ICompanySmsPortService.java

@@ -1,5 +1,6 @@
 package com.fs.proxy.service;
 
+import com.fs.proxy.domain.CompanySmsApi;
 import com.fs.proxy.domain.CompanySmsApiPort;
 import com.fs.proxy.domain.CompanySmsPortAssign;
 import com.fs.proxy.domain.CompanySmsCard;
@@ -24,6 +25,9 @@ public interface ICompanySmsPortService {
     /** 解析可用端口(降级路由核心方法) */
     CompanySmsApiPort resolvePort(Long tenantId, Integer smsType, Long companyUserId, Long preferApiId);
 
+    /** 解析通讯接口(无端口校验,用于外呼回调地址等) */
+    CompanySmsApi resolveApi(Long tenantId, Integer smsType, Long preferApiId);
+
     // ========== 端口分配 ==========
     List<CompanySmsPortAssign> selectAssignList(CompanySmsPortAssign query);
     int insertAssign(CompanySmsPortAssign assign);

+ 212 - 83
fs-service/src/main/java/com/fs/proxy/service/impl/BalanceServiceImpl.java

@@ -1,19 +1,27 @@
 package com.fs.proxy.service.impl;
 
+import com.fs.common.annotation.DataSource;
+import com.fs.common.enums.DataSourceType;
+import com.fs.common.utils.StringUtils;
+import com.fs.company.domain.CompanyVoiceApiTenant;
+import com.fs.company.mapper.CompanyVoiceApiTenantMapper;
+import com.fs.proxy.domain.CompanySmsApiTenant;
+import com.fs.proxy.domain.ServiceFeeConfig;
 import com.fs.proxy.domain.TenantBalance;
 import com.fs.proxy.domain.TenantConsumeRecord;
-import com.fs.proxy.domain.ServiceFeeConfig;
-import com.fs.proxy.domain.CompanySmsApiTenant;
+import com.fs.proxy.domain.TenantTrafficPricing;
+import com.fs.proxy.enums.ConsumeServiceResult;
 import com.fs.proxy.enums.ConsumeTypeEnum;
+import com.fs.proxy.mapper.CompanySmsApiTenantMapper;
+import com.fs.proxy.mapper.ServiceFeeConfigMapper;
 import com.fs.proxy.mapper.TenantBalanceMapper;
 import com.fs.proxy.mapper.TenantConsumeRecordMapper;
-import com.fs.proxy.mapper.ServiceFeeConfigMapper;
-import com.fs.proxy.domain.TenantTrafficPricing;
 import com.fs.proxy.mapper.TenantTrafficPricingMapper;
-import com.fs.proxy.mapper.CompanySmsApiTenantMapper;
-import com.fs.company.domain.CompanyVoiceApiTenant;
-import com.fs.company.mapper.CompanyVoiceApiTenantMapper;
+import com.fs.proxy.model.ConsumeServiceOutcome;
+import com.fs.proxy.model.UnitPricePair;
 import com.fs.proxy.service.BalanceService;
+import org.redisson.api.RLock;
+import org.redisson.api.RedissonClient;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
@@ -23,6 +31,7 @@ import java.util.Arrays;
 import java.util.Date;
 import java.util.List;
 import java.util.UUID;
+import java.util.concurrent.TimeUnit;
 
 /**
  * 余额消费服务实现类
@@ -51,7 +60,13 @@ public class BalanceServiceImpl implements BalanceService {
     @Autowired
     private CompanyVoiceApiTenantMapper voiceApiTenantMapper;
 
+    @Autowired
+    private RedissonClient redissonClient;
+
+    private static final String CONSUME_LOCK_PREFIX = "tenant:balance:consume:";
+
     @Override
+    @DataSource(DataSourceType.MASTER)
     public TenantBalance getTenantBalance(Long tenantId) {
         return balanceMapper.selectBalanceByTenantId(tenantId);
     }
@@ -157,26 +172,205 @@ public class BalanceServiceImpl implements BalanceService {
 
     @Override
     @Transactional
+    @DataSource(DataSourceType.MASTER)
     public boolean consumeService(Long tenantId, ConsumeTypeEnum consumeType, Integer quantity, String remark) {
+        String orderNo = UUID.randomUUID().toString().replace("-", "").substring(0, 20);
+        ConsumeServiceOutcome outcome = doConsumeService(tenantId, consumeType, quantity, remark, orderNo);
+        return outcome.getResult() == ConsumeServiceResult.SUCCESS;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    @DataSource(DataSourceType.MASTER)
+    public ConsumeServiceOutcome consumeServiceIdempotent(Long tenantId, ConsumeTypeEnum consumeType,
+                                                          Integer quantity, String orderNo, String remark) {
+        if (tenantId == null || consumeType == null || quantity == null || quantity <= 0
+                || StringUtils.isBlank(orderNo)) {
+            return ConsumeServiceOutcome.builder().result(ConsumeServiceResult.INVALID_PARAM).build();
+        }
+        String lockKey = CONSUME_LOCK_PREFIX + tenantId + ":" + orderNo;
+        RLock lock = redissonClient.getLock(lockKey);
+        boolean locked = false;
+        try {
+            locked = lock.tryLock(5, 30, TimeUnit.SECONDS);
+            if (!locked) {
+                return ConsumeServiceOutcome.builder().result(ConsumeServiceResult.LOCK_TIMEOUT).orderNo(orderNo).build();
+            }
+            TenantConsumeRecord existing = recordMapper.selectByTenantAndOrderNo(tenantId, orderNo);
+            if (existing != null && Integer.valueOf(1).equals(existing.getStatus())) {
+                return ConsumeServiceOutcome.builder()
+                        .result(ConsumeServiceResult.ALREADY_CONSUMED)
+                        .amount(existing.getAmount())
+                        .quantity(existing.getQuantity())
+                        .unitPrice(existing.getUnitPrice())
+                        .orderNo(orderNo)
+                        .recordId(existing.getRecordId())
+                        .build();
+            }
+            return doConsumeService(tenantId, consumeType, quantity, remark, orderNo);
+        } catch (InterruptedException ex) {
+            Thread.currentThread().interrupt();
+            return ConsumeServiceOutcome.builder().result(ConsumeServiceResult.FAILED).orderNo(orderNo).build();
+        } finally {
+            if (locked && lock.isHeldByCurrentThread()) {
+                lock.unlock();
+            }
+        }
+    }
+
+    @Override
+    @DataSource(DataSourceType.MASTER)
+    public boolean checkPayAsYouGoBalance(Long tenantId, ConsumeTypeEnum consumeType, Integer quantity) {
+        if (tenantId == null || consumeType == null || quantity == null || quantity <= 0) {
+            return false;
+        }
+        if (consumeType == ConsumeTypeEnum.MANUAL_CALL) {
+            return true;
+        }
+        if (!isPayAsYouGo(consumeType)) {
+            return checkBalance(tenantId, consumeType, quantity);
+        }
+        TenantBalance balance = balanceMapper.selectBalanceByTenantId(tenantId);
+        if (balance == null || balance.getTotalBalance() == null) {
+            return false;
+        }
+        BigDecimal unitPrice = resolveUnitPrice(tenantId, consumeType);
+        if (unitPrice == null || unitPrice.compareTo(BigDecimal.ZERO) <= 0) {
+            return false;
+        }
+        BigDecimal needed = unitPrice.multiply(BigDecimal.valueOf(quantity));
+        return balance.getTotalBalance().compareTo(needed) >= 0;
+    }
+
+    @Override
+    @DataSource(DataSourceType.MASTER)
+    public BigDecimal resolveUnitPrice(Long tenantId, ConsumeTypeEnum consumeType) {
+        UnitPricePair pair = resolveUnitPricePair(tenantId, consumeType);
+        return pair != null ? pair.getUnitPrice() : null;
+    }
+
+    private ConsumeServiceOutcome doConsumeService(Long tenantId, ConsumeTypeEnum consumeType,
+                                                   Integer quantity, String remark, String orderNo) {
         TenantBalance balance = balanceMapper.selectBalanceByTenantIdForUpdate(tenantId);
         if (balance == null) {
-            return false;
+            return ConsumeServiceOutcome.builder().result(ConsumeServiceResult.FAILED).orderNo(orderNo).build();
         }
 
-        // 手拨外呼不再单独计费,由通话接口定价体系(company_voice_api_tenant)覆盖
         if (consumeType == ConsumeTypeEnum.MANUAL_CALL) {
-            return true;
+            return ConsumeServiceOutcome.builder().result(ConsumeServiceResult.SUCCESS).orderNo(orderNo).build();
+        }
+
+        UnitPricePair pricePair = resolveUnitPricePair(tenantId, consumeType);
+        if (pricePair == null) {
+            return ConsumeServiceOutcome.builder().result(ConsumeServiceResult.FAILED).orderNo(orderNo).build();
         }
 
+        BigDecimal unitPrice = pricePair.getUnitPrice();
+        BigDecimal platformCost = pricePair.getPlatformCost();
+        BigDecimal totalCost = unitPrice.multiply(BigDecimal.valueOf(quantity));
+
+        if (isPayAsYouGo(consumeType)) {
+            BigDecimal beforeBalance = balance.getTotalBalance();
+            int rows = balanceMapper.decreaseTotalBalance(tenantId, totalCost);
+            if (rows <= 0) {
+                return ConsumeServiceOutcome.builder()
+                        .result(ConsumeServiceResult.INSUFFICIENT_BALANCE)
+                        .amount(totalCost)
+                        .quantity(quantity)
+                        .unitPrice(unitPrice)
+                        .orderNo(orderNo)
+                        .build();
+            }
+
+            TenantBalance updated = balanceMapper.selectBalanceByTenantId(tenantId);
+            TenantConsumeRecord record = buildConsumeRecord(balance, consumeType, quantity, remark, orderNo,
+                    unitPrice, platformCost, totalCost, beforeBalance, updated.getTotalBalance());
+            int inserted = recordMapper.insertTenantConsumeRecord(record);
+            if (inserted <= 0) {
+                return ConsumeServiceOutcome.builder().result(ConsumeServiceResult.FAILED).orderNo(orderNo).build();
+            }
+            return ConsumeServiceOutcome.builder()
+                    .result(ConsumeServiceResult.SUCCESS)
+                    .amount(totalCost)
+                    .quantity(quantity)
+                    .unitPrice(unitPrice)
+                    .orderNo(orderNo)
+                    .recordId(record.getRecordId())
+                    .build();
+        }
+
+        BigDecimal currentBalance = getServiceBalance(balance, consumeType);
+        if (currentBalance.compareTo(BigDecimal.valueOf(quantity)) < 0) {
+            return ConsumeServiceOutcome.builder()
+                    .result(ConsumeServiceResult.INSUFFICIENT_BALANCE)
+                    .amount(totalCost)
+                    .quantity(quantity)
+                    .unitPrice(unitPrice)
+                    .orderNo(orderNo)
+                    .build();
+        }
+
+        String balanceType = getBalanceType(consumeType);
+        int rows = balanceMapper.decreaseBalanceByType(tenantId, BigDecimal.valueOf(quantity), balanceType);
+        if (rows <= 0) {
+            return ConsumeServiceOutcome.builder()
+                    .result(ConsumeServiceResult.INSUFFICIENT_BALANCE)
+                    .amount(totalCost)
+                    .quantity(quantity)
+                    .unitPrice(unitPrice)
+                    .orderNo(orderNo)
+                    .build();
+        }
+
+        TenantBalance updated = balanceMapper.selectBalanceByTenantId(tenantId);
+        TenantConsumeRecord record = buildConsumeRecord(balance, consumeType, quantity, remark, orderNo,
+                unitPrice, platformCost, totalCost, currentBalance, getServiceBalance(updated, consumeType));
+        int inserted = recordMapper.insertTenantConsumeRecord(record);
+        if (inserted <= 0) {
+            return ConsumeServiceOutcome.builder().result(ConsumeServiceResult.FAILED).orderNo(orderNo).build();
+        }
+        return ConsumeServiceOutcome.builder()
+                .result(ConsumeServiceResult.SUCCESS)
+                .amount(totalCost)
+                .quantity(quantity)
+                .unitPrice(unitPrice)
+                .orderNo(orderNo)
+                .recordId(record.getRecordId())
+                .build();
+    }
+
+    private TenantConsumeRecord buildConsumeRecord(TenantBalance balance, ConsumeTypeEnum consumeType, Integer quantity,
+                                                   String remark, String orderNo, BigDecimal unitPrice,
+                                                   BigDecimal platformCost, BigDecimal totalCost,
+                                                   BigDecimal beforeBalance, BigDecimal afterBalance) {
+        TenantConsumeRecord record = new TenantConsumeRecord();
+        record.setTenantId(balance.getTenantId());
+        record.setTenantName(balance.getTenantName());
+        record.setConsumeType(consumeType.getCode());
+        record.setConsumeTypeName(consumeType.getName());
+        record.setAmount(totalCost);
+        record.setUnitPrice(unitPrice);
+        record.setPlatformCost(platformCost);
+        record.setTenantPrice(unitPrice);
+        record.setQuantity(quantity);
+        record.setBeforeBalance(beforeBalance);
+        record.setAfterBalance(afterBalance);
+        record.setOrderNo(orderNo);
+        record.setStatus(1);
+        record.setConsumeTime(new Date());
+        record.setRemark(remark);
+        return record;
+    }
+
+    private UnitPricePair resolveUnitPricePair(Long tenantId, ConsumeTypeEnum consumeType) {
         ServiceFeeConfig config = getFeeConfig(consumeType.getCode());
         if (config == null) {
-            return false;
+            return null;
         }
 
         BigDecimal unitPrice = config.getFeeStandard();
-        BigDecimal platformCost = config.getPlatformCost();
+        BigDecimal platformCost = config.getPlatformCost() != null ? config.getPlatformCost() : BigDecimal.ZERO;
 
-        // 短信发送:优先使用租户绑定的接口售价(按优先级取第一个)
         if (consumeType == ConsumeTypeEnum.SMS_SEND) {
             List<CompanySmsApiTenant> smsBindings = smsApiTenantMapper.selectActiveByCompanyAndType(tenantId, null);
             if (smsBindings != null && !smsBindings.isEmpty()) {
@@ -190,13 +384,11 @@ public class BalanceServiceImpl implements BalanceService {
             }
         }
 
-        // 通用租户定价覆盖:优先查 tenant_traffic_pricing,未配置则回退全局 service_fee_config
-        // 注意:SMS_SEND 使用专属 company_sms_api_tenant,AI_CALL 使用复合定价
         if (consumeType != ConsumeTypeEnum.SMS_SEND && consumeType != ConsumeTypeEnum.AI_CALL) {
             TenantTrafficPricing trafficPricing = trafficPricingMapper
-                .selectByTenantAndType(tenantId, consumeType.getCode());
+                    .selectByTenantAndType(tenantId, consumeType.getCode());
             if (trafficPricing != null && trafficPricing.getPrice() != null
-                && trafficPricing.getPrice().compareTo(BigDecimal.ZERO) > 0) {
+                    && trafficPricing.getPrice().compareTo(BigDecimal.ZERO) > 0) {
                 unitPrice = trafficPricing.getPrice();
                 if (trafficPricing.getCostPrice() != null) {
                     platformCost = trafficPricing.getCostPrice();
@@ -204,7 +396,6 @@ public class BalanceServiceImpl implements BalanceService {
             }
         }
 
-        // AI外呼:复合定价 = 语音单价(租户绑定) + AI附加费(租户 > 全局 service_fee_config)
         if (consumeType == ConsumeTypeEnum.AI_CALL) {
             List<CompanyVoiceApiTenant> voiceBindings = voiceApiTenantMapper.selectEnabledApisByTenantId(tenantId);
             if (voiceBindings != null && !voiceBindings.isEmpty()) {
@@ -212,13 +403,12 @@ public class BalanceServiceImpl implements BalanceService {
                 if (voiceBinding.getSalePrice() != null && voiceBinding.getSalePrice().compareTo(BigDecimal.ZERO) > 0) {
                     BigDecimal voicePrice = voiceBinding.getSalePrice();
                     BigDecimal voiceCost = voiceBinding.getCostPrice() != null ? voiceBinding.getCostPrice() : BigDecimal.ZERO;
-                    // AI附加费:优先查租户定价,未配置则使用全局 service_fee_config
                     BigDecimal aiSurcharge = config.getFeeStandard();
                     BigDecimal aiCost = config.getPlatformCost() != null ? config.getPlatformCost() : BigDecimal.ZERO;
                     TenantTrafficPricing aiTrafficPricing = trafficPricingMapper
-                        .selectByTenantAndType(tenantId, ConsumeTypeEnum.AI_CALL.getCode());
+                            .selectByTenantAndType(tenantId, ConsumeTypeEnum.AI_CALL.getCode());
                     if (aiTrafficPricing != null && aiTrafficPricing.getPrice() != null
-                        && aiTrafficPricing.getPrice().compareTo(BigDecimal.ZERO) > 0) {
+                            && aiTrafficPricing.getPrice().compareTo(BigDecimal.ZERO) > 0) {
                         aiSurcharge = aiTrafficPricing.getPrice();
                         if (aiTrafficPricing.getCostPrice() != null) {
                             aiCost = aiTrafficPricing.getCostPrice();
@@ -230,68 +420,7 @@ public class BalanceServiceImpl implements BalanceService {
             }
         }
 
-        BigDecimal totalCost = unitPrice.multiply(BigDecimal.valueOf(quantity));
-
-        if (isPayAsYouGo(consumeType)) {
-            BigDecimal beforeBalance = balance.getTotalBalance();
-            
-            int rows = balanceMapper.decreaseTotalBalance(tenantId, totalCost);
-            if (rows <= 0) {
-                return false;
-            }
-
-            TenantBalance updated = balanceMapper.selectBalanceByTenantId(tenantId);
-
-            TenantConsumeRecord record = new TenantConsumeRecord();
-            record.setTenantId(tenantId);
-            record.setTenantName(balance.getTenantName());
-            record.setConsumeType(consumeType.getCode());
-            record.setConsumeTypeName(consumeType.getName());
-            record.setAmount(totalCost);
-            record.setUnitPrice(unitPrice);
-            record.setPlatformCost(platformCost);
-            record.setTenantPrice(unitPrice);
-            record.setQuantity(quantity);
-            record.setBeforeBalance(beforeBalance);
-            record.setAfterBalance(updated.getTotalBalance());
-            record.setOrderNo(UUID.randomUUID().toString().replace("-", "").substring(0, 20));
-            record.setStatus(1);
-            record.setConsumeTime(new Date());
-            record.setRemark(remark);
-
-            return recordMapper.insertTenantConsumeRecord(record) > 0;
-        } else {
-            BigDecimal currentBalance = getServiceBalance(balance, consumeType);
-            if (currentBalance.compareTo(BigDecimal.valueOf(quantity)) < 0) {
-                return false;
-            }
-
-            String balanceType = getBalanceType(consumeType);
-            BigDecimal amount = BigDecimal.valueOf(quantity);
-            int rows = balanceMapper.decreaseBalanceByType(tenantId, amount, balanceType);
-            if (rows <= 0) {
-                return false;
-            }
-
-            TenantBalance updated = balanceMapper.selectBalanceByTenantId(tenantId);
-
-            TenantConsumeRecord record = new TenantConsumeRecord();
-            record.setTenantId(tenantId);
-            record.setTenantName(balance.getTenantName());
-            record.setConsumeType(consumeType.getCode());
-            record.setConsumeTypeName(consumeType.getName());
-            record.setAmount(totalCost);
-            record.setUnitPrice(unitPrice);
-            record.setQuantity(quantity);
-            record.setBeforeBalance(currentBalance);
-            record.setAfterBalance(getServiceBalance(updated, consumeType));
-            record.setOrderNo(UUID.randomUUID().toString().replace("-", "").substring(0, 20));
-            record.setStatus(1);
-            record.setConsumeTime(new Date());
-            record.setRemark(remark);
-
-            return recordMapper.insertTenantConsumeRecord(record) > 0;
-        }
+        return new UnitPricePair(unitPrice, platformCost);
     }
 
     /**

+ 49 - 23
fs-service/src/main/java/com/fs/proxy/service/impl/CompanySmsPortServiceImpl.java

@@ -1,5 +1,6 @@
 package com.fs.proxy.service.impl;
 
+import com.fs.comm.support.CompanySmsMasterDataSourceHelper;
 import com.fs.proxy.domain.*;
 import com.fs.proxy.mapper.*;
 import com.fs.proxy.service.ICompanySmsPortService;
@@ -26,7 +27,11 @@ public class CompanySmsPortServiceImpl implements ICompanySmsPortService {
     @Autowired
     private CompanySmsApiTenantMapper tenantMapper;
     @Autowired
+    private CompanySmsApiMapper smsApiMapper;
+    @Autowired
     private CompanySmsDeviceMapper deviceMapper;
+    @Autowired
+    private CompanySmsMasterDataSourceHelper companySmsMasterDataSourceHelper;
 
     // ========== 端口池 ==========
 
@@ -65,7 +70,7 @@ public class CompanySmsPortServiceImpl implements ICompanySmsPortService {
      * 
      * 逻辑:
      * 1. 如果指定了preferApiId(销售手动选择), 直接查该api下的可用端口
-     * 2. 否则按优先级遍历租户绑定的接口:
+     * 2. 否则按优先级遍历租户绑定的接口(company_sms_api_tenant);无绑定时降级查主库 company_sms_api
      *    a. 查该接口下是否有分配给当前销售的端口
      *    b. 没有则查共享端口(company_user_id IS NULL)
      *    c. 如果是card类型, 还要检查卡是否在线
@@ -74,32 +79,53 @@ public class CompanySmsPortServiceImpl implements ICompanySmsPortService {
      */
     @Override
     public CompanySmsApiPort resolvePort(Long tenantId, Integer smsType, Long companyUserId, Long preferApiId) {
-        // 1. 销售手动选择接口
-        if (preferApiId != null) {
-            CompanySmsApiPort port = findAvailablePort(tenantId, preferApiId, companyUserId);
-            if (port != null) {
-                log.info("resolvePort: 销售手动选择 apiId={}, portId={}", preferApiId, port.getPortId());
-                return port;
+        return companySmsMasterDataSourceHelper.runOnMasterThenRestoreTenant(tenantId, () -> {
+            // 1. 销售手动选择接口
+            if (preferApiId != null) {
+                CompanySmsApiPort port = findAvailablePort(tenantId, preferApiId, companyUserId);
+                if (port != null) {
+                    log.info("resolvePort: 销售手动选择 apiId={}, portId={}", preferApiId, port.getPortId());
+                    return port;
+                }
+                log.warn("resolvePort: 销售手动选择 apiId={} 无可用端口", preferApiId);
+                return null;
             }
-            log.warn("resolvePort: 销售手动选择 apiId={} 无可用端口", preferApiId);
-            return null;
-        }
-
-        // 2. 自动路由: 按优先级遍历租户绑定的接口
-        List<CompanySmsApiTenant> tenants = tenantMapper.selectActiveByCompanyAndType(tenantId, smsType);
 
-        for (CompanySmsApiTenant tenant : tenants) {
-            CompanySmsApiPort port = findAvailablePort(tenantId, tenant.getApiId(), companyUserId);
-            if (port != null) {
-                log.info("resolvePort: 自动路由 tenantId={}, smsType={}, apiId={}, portId={}, priority={}",
-                        tenantId, smsType, tenant.getApiId(), port.getPortId(), tenant.getPriority());
-                return port;
+            // 2. 自动路由: 优先租户绑定,无绑定则降级主库 company_sms_api
+            List<CompanySmsApiTenant> tenants = tenantMapper.selectActiveByCompanyAndType(tenantId, smsType);
+            if (tenants != null && !tenants.isEmpty()) {
+                for (CompanySmsApiTenant tenant : tenants) {
+                    CompanySmsApiPort port = findAvailablePort(tenantId, tenant.getApiId(), companyUserId);
+                    if (port != null) {
+                        log.info("resolvePort: 租户绑定路由 tenantId={}, smsType={}, apiId={}, portId={}, priority={}",
+                                tenantId, smsType, tenant.getApiId(), port.getPortId(), tenant.getPriority());
+                        return port;
+                    }
+                    log.info("resolvePort: 降级 tenantId={}, apiId={} 无可用端口, 尝试下一个", tenantId, tenant.getApiId());
+                }
+            } else {
+                log.info("resolvePort: 租户无绑定接口, 降级查主库 company_sms_api tenantId={}, smsType={}", tenantId, smsType);
+                List<CompanySmsApi> apis = smsApiMapper.selectActiveListBySmsType(smsType);
+                if (apis != null) {
+                    for (CompanySmsApi api : apis) {
+                        CompanySmsApiPort port = findAvailablePort(tenantId, api.getApiId(), companyUserId);
+                        if (port != null) {
+                            log.info("resolvePort: 主库接口路由 tenantId={}, smsType={}, apiId={}, portId={}",
+                                    tenantId, smsType, api.getApiId(), port.getPortId());
+                            return port;
+                        }
+                        log.info("resolvePort: 降级 tenantId={}, apiId={} 无可用端口, 尝试下一个", tenantId, api.getApiId());
+                    }
+                }
             }
-            // 降级: 这个接口没可用端口, 尝试下一个
-            log.info("resolvePort: 降级 tenantId={}, apiId={} 无可用端口, 尝试下一个", tenantId, tenant.getApiId());
-        }
 
-        log.warn("resolvePort: 所有接口均无可用端口 tenantId={}, smsType={}, userId={}", tenantId, smsType, companyUserId);
+            log.warn("resolvePort: 所有接口均无可用端口 tenantId={}, smsType={}, userId={}", tenantId, smsType, companyUserId);
+            return null;
+        });
+    }
+
+    @Override
+    public CompanySmsApi resolveApi(Long tenantId, Integer smsType, Long preferApiId) {
         return null;
     }
 

+ 1 - 0
fs-service/src/main/resources/db/tenant-initTable.sql

@@ -1997,6 +1997,7 @@ CREATE TABLE `company_sms_temp`
     `status`      tinyint(1) NULL DEFAULT 1 COMMENT '状态',
     `cate_id`     tinyint(1) NULL DEFAULT NULL COMMENT '分类ID',
     `is_audit`    tinyint(1) NULL DEFAULT 0 COMMENT '是否审核',
+    `sms_api_ids` varchar(500) NULL DEFAULT NULL COMMENT '绑定的短信接口IDs(逗号分隔,存主库 api_id)',
     PRIMARY KEY (`temp_id`) USING BTREE
 ) ENGINE = InnoDB AUTO_INCREMENT = 1 COMMENT = '短信模板' ROW_FORMAT = DYNAMIC;
 

+ 3 - 0
fs-service/src/main/resources/mapper/comm/CommGatewayApiLogMapper.xml

@@ -52,7 +52,10 @@
             <if test="success != null">and l.success = #{success}</if>
             <if test="limitHit != null">and l.limit_hit = #{limitHit}</if>
             <if test="calleePhone != null and calleePhone != ''">and l.callee_phone like concat('%', #{calleePhone}, '%')</if>
+            <if test="callerPhone != null and callerPhone != ''">and l.caller_phone like concat('%', #{callerPhone}, '%')</if>
             <if test="callerAccount != null and callerAccount != ''">and l.caller_account like concat('%', #{callerAccount}, '%')</if>
+            <if test="resultMsg != null and resultMsg != ''">and l.result_msg like concat('%', #{resultMsg}, '%')</if>
+            <if test="gatewayId != null">and l.gateway_id = #{gatewayId}</if>
             <if test="params.beginTime != null and params.beginTime != ''">
                 and l.create_time &gt;= #{params.beginTime}
             </if>

+ 24 - 5
fs-service/src/main/resources/mapper/company/CompanyVoiceRoboticCallLogCallphoneMapper.xml

@@ -200,6 +200,18 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         select * from company_voice_robotic_call_log_callphone where callback_uuid = #{uuid}
     </select>
 
+    <select id="selectRunningCallbackUuidsByCompanyId" resultType="java.lang.String">
+        SELECT DISTINCT t1.callback_uuid
+        FROM company_voice_robotic_call_log_callphone t1
+                 INNER JOIN company_voice_robotic cvr ON cvr.id = t1.robotic_id
+        WHERE cvr.company_id = #{companyId}
+          AND cvr.task_status = 1
+          AND (cvr.del_flag = 0 OR cvr.del_flag IS NULL)
+          AND t1.status = 1
+          AND t1.callback_uuid IS NOT NULL
+          AND t1.callback_uuid != ''
+    </select>
+
     <select id="countTodayCallsByBusinessId" resultType="int">
         SELECT IFNULL(COUNT(*), 0)
         FROM company_voice_robotic_callees es
@@ -251,13 +263,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
     </select>
 
 
-    <select id="listByRoboticId" resultType="com.fs.company.vo.CompanyVoiceRoboticCallLogCallPhoneVO">
+    <select id="listByRoboticId" resultType="com.fs.company.vo.CompanyVoiceRoboticCallLogCallPhoneVO" parameterType="com.fs.company.domain.CompanyVoiceRoboticCallLogCallphone">
         SELECT
         t1.*,
         t2.company_name,
         t3.nick_name as companyUserName,
-        cvr.name as robotic_name
+        cvr.name as robotic_name,
+        ce.user_id as customer_id
         FROM company_voice_robotic_call_log_callphone t1
+        left join company_voice_robotic_callees ce on ce.id = t1.caller_id
         left join company t2 on t1.company_id = t2.company_id
         left join company_user t3 on t3.user_id = t1.company_user_id
         left join company_voice_robotic cvr on cvr.id = t1.robotic_id
@@ -273,9 +287,14 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <if test="callerNum != null and callerNum != ''">
             and t1.caller_num like concat('%', #{callerNum}, '%')
         </if>
-        <if test="intention != null and intention != ''">
-            and t1.intention = #{intention}
-        </if>
+        <choose>
+            <when test="intentionEmpty != null and intentionEmpty">
+                and (t1.intention is null or t1.intention = '' or t1.intention = '0')
+            </when>
+            <when test="intention != null and intention != ''">
+                and t1.intention = #{intention}
+            </when>
+        </choose>
         <if test="isConnected != null and isConnected == 1">
             and t1.call_time &gt; 0
         </if>

+ 4 - 0
fs-service/src/main/resources/mapper/company/CrmCustomerCallLogMapper.xml

@@ -42,6 +42,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
                 AND date_format(create_time,'%Y-%m-%d') &gt;= #{beginTime}
                 AND date_format(create_time,'%Y-%m-%d') &lt;= #{endTime}
             </if>
+            <if test="callBeginTime != null and callBeginTime != '' and callEndTime != null and callEndTime != ''">
+                AND FROM_UNIXTIME(call_create_time / 1000, '%Y-%m-%d') &gt;= #{callBeginTime}
+                AND FROM_UNIXTIME(call_create_time / 1000, '%Y-%m-%d') &lt;= #{callEndTime}
+            </if>
         </where>
         order by create_time desc
     </select>

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

@@ -24,4 +24,14 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             limit 1
     </select>
 
+
+    <select id="selectCompletedUuidsByCallbackUuids" resultType="java.lang.String">
+        SELECT uuid
+        FROM cc_call_phone
+        WHERE callout_time != 0
+        AND JSON_UNQUOTE(JSON_EXTRACT(biz_json, '$.callBackUuid')) IN
+        <foreach collection="callbackUuids" item="item" open="(" separator="," close=")">
+            #{item}
+        </foreach>
+    </select>
 </mapper>

+ 7 - 0
fs-service/src/main/resources/mapper/proxy/CompanySmsApiMapper.xml

@@ -48,6 +48,13 @@
         LIMIT 1
     </select>
 
+    <select id="selectActiveListBySmsType" resultMap="CompanySmsApiResult">
+        <include refid="selectSmsApiVo"/>
+        WHERE status = 1
+        <if test="smsType != null">AND sms_type = #{smsType}</if>
+        ORDER BY is_default DESC, api_id ASC
+    </select>
+
     <insert id="insertSmsApi" useGeneratedKeys="true" keyProperty="apiId">
         INSERT INTO company_sms_api (
             api_name, provider, sms_type, account, password, url, code, sign,

+ 6 - 0
fs-service/src/main/resources/mapper/proxy/TenantConsumeRecordMapper.xml

@@ -56,6 +56,12 @@
         where record_id = #{recordId}
     </select>
 
+    <select id="selectByTenantAndOrderNo" resultMap="TenantConsumeRecordResult">
+        <include refid="selectVo"/>
+        where tenant_id = #{tenantId} and order_no = #{orderNo}
+        limit 1
+    </select>
+
     <insert id="insertTenantConsumeRecord" useGeneratedKeys="true" keyProperty="recordId">
         insert into tenant_consume_record
         <trim prefix="(" suffix=")" suffixOverrides=",">

+ 1 - 0
fs-wx-task/src/main/java/com/fs/app/service/WxTaskService.java

@@ -99,6 +99,7 @@ public class WxTaskService {
     private final CompanyVoiceRoboticWxServiceImpl companyVoiceRoboticWxServiceImpl;
     private final CompanyWxAccountMapper companyWxAccountMapper;
     private final CompanyVoiceRoboticCalleesServiceImpl companyVoiceRoboticCalleesServiceImpl;
+    @Autowired
     private RedissonClient redissonClient;
     private final CompanyVoiceRoboticServiceImpl companyVoiceRoboticServiceImpl;
     private final CompanyVoiceRoboticCallLogCallphoneServiceImpl companyVoiceRoboticCallLogCallphoneService;

+ 4 - 4
fs-wx-task/src/main/java/com/fs/app/task/WxTask.java

@@ -31,10 +31,10 @@ public class WxTask {
     @Autowired
     private TenantTaskRunner tenantTaskRunner;
 
-//    @Scheduled(cron = "0 0/30 * * * ?")
-//    public void addWx() {
-//        taskService.addWx(null);
-//    }
+    @Scheduled(cron = "0 0/30 * * * ?")
+    public void addWx() {
+        tenantTaskRunner.runForResponsibleTenant("addWx", () ->   taskService.addWx(null));
+    }
 //    @Scheduled(cron = "0 0/1 * * * ?")
 //    public void addWx4Workflow() {
 //        if (saasTaskEnabled) {

+ 2 - 2
scripts/fix_dict.py

@@ -5,8 +5,8 @@ cur = conn.cursor()
 cur.execute("DELETE FROM sys_dict_data WHERE dict_type='sys_job_group'")
 print('deleted', cur.rowcount)
 
-cur.execute("INSERT INTO sys_dict_data (dict_sort,dict_label,dict_value,dict_type,css_class,list_class,is_default,status,create_by,create_time) VALUES (0,%s,%s,'sys_job_group','','','N','0','admin',NOW())", ('ĬϷ���','DEFAULT'))
-cur.execute("INSERT INTO sys_dict_data (dict_sort,dict_label,dict_value,dict_type,css_class,list_class,is_default,status,create_by,create_time) VALUES (1,%s,%s,'sys_job_group','','','N','0','admin',NOW())", ('��΢����','QW_TASK'))
+cur.execute("INSERT INTO sys_dict_data (dict_sort,dict_label,dict_value,dict_type,css_class,list_class,is_default,status,create_by,create_time) VALUES (0,%s,%s,'sys_job_group','','','N','0','admin',NOW())", ('ĬϷ','DEFAULT'))
+cur.execute("INSERT INTO sys_dict_data (dict_sort,dict_label,dict_value,dict_type,css_class,list_class,is_default,status,create_by,create_time) VALUES (1,%s,%s,'sys_job_group','','','N','0','admin',NOW())", ('΢','QW_TASK'))
 conn.commit()
 
 for r in cur.execute('SELECT dict_value, dict_label, hex(dict_label) FROM sys_dict_data WHERE dict_type=%s', ('sys_job_group',)):

+ 1 - 1
set-java17.bat

@@ -1,5 +1,5 @@
 @echo off
-rem 目统一 JDK 17 环境(Temurin 17.0.12+7
+rem 目统一 JDK 17 Temurin 17.0.12+7
 set "JAVA_HOME=D:\AICALL\jdk-17.0.12+7"
 set "PATH=%JAVA_HOME%\bin;%PATH%"
 echo JAVA_HOME=%JAVA_HOME%

+ 37 - 0
sql/add_comm_gateway_log_menu.sql

@@ -0,0 +1,37 @@
+-- ============================================================
+-- adminUI 总后台菜单补充 - 通讯网关外呼/短信日志
+-- 挂载到 通信管理(2400) 分组下
+-- 数据来源:主库 comm_gateway_api_log
+-- ============================================================
+
+-- 外呼日志 (menu_id=2416)
+INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, menu_type, perms, icon, visible, status, is_frame, is_cache, create_by, create_time, remark)
+SELECT 2416, '外呼日志', 2400, 13, 'commGatewayCallLog', 'admin/commGatewayCallLog/index', 'C', 'platform:commGatewayCallLog:list', 'el-icon-phone-outline', '0', '0', 0, 0, 'admin', NOW(), '通讯网关外呼调用日志'
+FROM DUAL
+WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 2416);
+
+INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, menu_type, perms, icon, visible, status, is_frame, is_cache, create_by, create_time)
+SELECT 24161, '外呼日志查询', 2416, 1, '', '', 'F', 'platform:commGatewayCallLog:query', '#', '0', '0', 0, 0, 'admin', NOW()
+FROM DUAL
+WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 24161);
+
+-- 短信日志 (menu_id=2417)
+INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, menu_type, perms, icon, visible, status, is_frame, is_cache, create_by, create_time, remark)
+SELECT 2417, '短信日志', 2400, 14, 'commGatewaySmsLog', 'admin/commGatewaySmsLog/index', 'C', 'platform:commGatewaySmsLog:list', 'el-icon-message', '0', '0', 0, 0, 'admin', NOW(), '通讯网关短信发送日志'
+FROM DUAL
+WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 2417);
+
+INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, menu_type, perms, icon, visible, status, is_frame, is_cache, create_by, create_time)
+SELECT 24171, '短信日志查询', 2417, 1, '', '', 'F', 'platform:commGatewaySmsLog:query', '#', '0', '0', 0, 0, 'admin', NOW()
+FROM DUAL
+WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 24171);
+
+-- 关联 admin 角色(role_id=1)
+INSERT INTO sys_role_menu (role_id, menu_id)
+SELECT 1, 2416 FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_role_menu WHERE role_id = 1 AND menu_id = 2416);
+INSERT INTO sys_role_menu (role_id, menu_id)
+SELECT 1, 24161 FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_role_menu WHERE role_id = 1 AND menu_id = 24161);
+INSERT INTO sys_role_menu (role_id, menu_id)
+SELECT 1, 2417 FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_role_menu WHERE role_id = 1 AND menu_id = 2417);
+INSERT INTO sys_role_menu (role_id, menu_id)
+SELECT 1, 24171 FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_role_menu WHERE role_id = 1 AND menu_id = 24171);

+ 5 - 0
sql/add_tenant_consume_record_order_unique.sql

@@ -0,0 +1,5 @@
+-- ����ݵȿ۷ѣ�tenant_consume_record.order_no ΨһԼ��������ִ�У�
+-- ��ֹͬһͨ�� uuid �ظ��۷�
+
+ALTER TABLE `tenant_consume_record`
+    ADD UNIQUE KEY `uk_tenant_order_no` (`tenant_id`, `order_no`);

+ 5 - 0
sql/company_sms_temp_sms_api_ids_patch.sql

@@ -0,0 +1,5 @@
+-- company_sms_temp 增加 sms_api_ids 列(短信模板绑定通讯接口)
+-- 在租户库执行
+
+ALTER TABLE `company_sms_temp`
+    ADD COLUMN `sms_api_ids` varchar(500) DEFAULT NULL COMMENT '绑定的短信接口IDs(逗号分隔,存主库 api_id)' AFTER `is_audit`;

+ 1 - 1
sql/fix_tenant_sys_menu_other_parent.sql

@@ -25,7 +25,7 @@ UPDATE tenant_sys_menu
 SET parent_id = 0, visible = '1', status = '0', order_num = 99
 WHERE menu_id = 32333;
 
--- Show entire subtree under ÆäËû (maintenance bucket; was hidden before grouping)
+-- Show entire subtree under  (maintenance bucket; was hidden before grouping)
 UPDATE tenant_sys_menu
 SET visible = '0', status = '0'
 WHERE menu_id IN (

+ 2 - 2
sql/fix_tenant_sys_menu_paths.sql

@@ -65,14 +65,14 @@ INSERT INTO tenant_sys_menu
 (menu_id, menu_name, parent_id, order_num, path, component, query,
  is_frame, is_cache, menu_type, visible, status, perms, icon,
  create_by, create_time, remark)
-SELECT 35300, 'ÆäËû', 0, 17, 'other', NULL, NULL,
+SELECT 35300, '', 0, 17, 'other', NULL, NULL,
        1, 0, 'M', '0', '0', NULL, 'more',
        'admin', NOW(), '[organize:other-parent]'
 FROM DUAL
 WHERE NOT EXISTS (SELECT 1 FROM tenant_sys_menu WHERE menu_id = 35300);
 
 UPDATE tenant_sys_menu
-SET menu_name = 'ÆäËû', parent_id = 0, order_num = 17, path = 'other',
+SET menu_name = '', parent_id = 0, order_num = 17, path = 'other',
     menu_type = 'M', visible = '0', status = '0', icon = 'more'
 WHERE menu_id = 35300;
 

+ 2 - 2
sql/organize_tenant_sys_menu_subtree.sql

@@ -203,11 +203,11 @@ WHERE parent_id = 32368 AND component IS NOT NULL AND component <> '';
 -- (35202, '???????', 35101, 2, 'role', 'system/role/index', 'C', '0', '0', 1, 0, 'admin', NOW()),
 -- (35203, '???????', 35101, 3, 'menu', 'system/menu/index', 'C', '0', '0', 1, 0, 'admin', NOW()),
 -- (35204, '???????', 35100, 1, 'dept', 'system/dept/index', 'C', '0', '0', 1, 0, 'admin', NOW()),
--- (35205, '??��????', 35100, 2, 'post', 'system/post/index', 'C', '0', '0', 1, 0, 'admin', NOW()),
+-- (35205, '??????', 35100, 2, 'post', 'system/post/index', 'C', '0', '0', 1, 0, 'admin', NOW()),
 -- (35206, '??????', 35106, 3, 'dict', 'system/dict/index', 'C', '0', '0', 1, 0, 'admin', NOW()),
 -- (35207, '????????', 35106, 4, 'config', 'system/config/index', 'C', '0', '0', 1, 0, 'admin', NOW()),
 -- (35208, '??????', 35106, 5, 'notice', 'system/notice/index', 'C', '0', '0', 1, 0, 'admin', NOW()),
--- (35209, '��?????', 35106, 6, 'keyword', 'system/keyword/index', 'C', '0', '0', 1, 0, 'admin', NOW());
+-- (35209, '?????', 35106, 6, 'keyword', 'system/keyword/index', 'C', '0', '0', 1, 0, 'admin', NOW());
 
 -- ============================================================
 -- Verification