Browse Source

merge: 以本地为准解决所有冲突

云联一号 4 days ago
parent
commit
f953bd0ae9
36 changed files with 1823 additions and 103 deletions
  1. 49 0
      fs-admin/src/main/java/com/fs/admin/controller/AdminCommGatewayLogController.java
  2. 126 0
      fs-admin/src/main/java/com/fs/admin/controller/AdminVoiceConfigController.java
  3. 2 2
      fs-admin/src/main/resources/application-dev.yml
  4. 3 0
      fs-comm-gateway/src/main/java/com/fs/comm/dto/CommCallSendRequest.java
  5. 2 0
      fs-comm-gateway/src/main/java/com/fs/comm/dto/CommSmsSendRequest.java
  6. 75 29
      fs-comm-gateway/src/main/java/com/fs/comm/service/CommCallService.java
  7. 242 0
      fs-comm-gateway/src/main/java/com/fs/comm/service/CommGatewayApiLogRecorder.java
  8. 12 6
      fs-comm-gateway/src/main/java/com/fs/comm/service/CommGatewayLineAuthService.java
  9. 66 22
      fs-comm-gateway/src/main/java/com/fs/comm/service/CommSmsService.java
  10. 144 0
      fs-comm-gateway/src/main/java/com/fs/comm/sms/MyCommSmsProvider.java
  11. 22 0
      fs-comm-gateway/对接文档.md
  12. 57 0
      fs-service/src/main/java/com/fs/comm/domain/CommGatewayApiLog.java
  13. 33 0
      fs-service/src/main/java/com/fs/comm/mapper/CommGatewayApiLogMapper.java
  14. 26 0
      fs-service/src/main/java/com/fs/comm/model/CommGatewayBillingQuote.java
  15. 18 0
      fs-service/src/main/java/com/fs/comm/model/CommSmsSendContext.java
  16. 51 0
      fs-service/src/main/java/com/fs/comm/service/CommGatewayApiLogServiceImpl.java
  17. 156 0
      fs-service/src/main/java/com/fs/comm/service/CommGatewayBillingService.java
  18. 114 22
      fs-service/src/main/java/com/fs/comm/service/CommSmsSendService.java
  19. 23 0
      fs-service/src/main/java/com/fs/comm/service/CommVoiceConfigMasterService.java
  20. 183 0
      fs-service/src/main/java/com/fs/comm/service/CommVoiceLimitService.java
  21. 18 0
      fs-service/src/main/java/com/fs/comm/service/ICommGatewayApiLogService.java
  22. 36 0
      fs-service/src/main/java/com/fs/comm/sms/CommSmsChannelRequest.java
  23. 40 0
      fs-service/src/main/java/com/fs/comm/sms/CommSmsChannelResult.java
  24. 12 0
      fs-service/src/main/java/com/fs/comm/sms/CommSmsProvider.java
  25. 22 0
      fs-service/src/main/java/com/fs/comm/support/CommTenantDataSourceHelper.java
  26. 44 3
      fs-service/src/main/java/com/fs/common/service/impl/SmsServiceImpl.java
  27. 3 0
      fs-service/src/main/java/com/fs/company/mapper/LobsterAuxiliaryMapper.java
  28. 32 18
      fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticServiceImpl.java
  29. 5 1
      fs-service/src/main/java/com/fs/company/service/impl/call/node/AiCallTaskNode.java
  30. 4 0
      fs-service/src/main/java/com/fs/company/service/workflow/DynamicNodeExecutor.java
  31. 4 0
      fs-service/src/main/java/com/fs/company/service/workflow/channel/ChannelPluginService.java
  32. 4 0
      fs-service/src/main/java/com/fs/company/service/workflow/contact/ContactInfo.java
  33. 5 0
      fs-service/src/main/java/com/fs/company/service/workflow/impl/SensitiveWordServiceImpl.java
  34. 36 0
      fs-service/src/main/resources/db/20250603-comm-gateway-api-log-billing-price.sql
  35. 34 0
      fs-service/src/main/resources/db/20250603-comm-gateway-api-log.sql
  36. 120 0
      fs-service/src/main/resources/mapper/comm/CommGatewayApiLogMapper.xml

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

@@ -0,0 +1,49 @@
+package com.fs.admin.controller;
+
+import com.fs.comm.domain.CommGatewayApiLog;
+import com.fs.comm.service.ICommGatewayApiLogService;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.DataSourceType;
+import com.fs.framework.datasource.DynamicDataSourceContextHolder;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/**
+ * 通讯网关 API 调用日志(主库)
+ */
+@RestController
+@RequestMapping("/admin/comm-gateway-log")
+public class AdminCommGatewayLogController extends BaseController {
+
+    @Autowired(required = false)
+    private ICommGatewayApiLogService commGatewayApiLogService;
+
+    @PreAuthorize("@ss.hasPermi('platform:companyVoiceConfig:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(CommGatewayApiLog query) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        startPage();
+        List<CommGatewayApiLog> list = commGatewayApiLogService != null
+                ? commGatewayApiLogService.selectList(query)
+                : java.util.Collections.emptyList();
+        return getDataTable(list);
+    }
+
+    @PreAuthorize("@ss.hasPermi('platform:companyVoiceConfig:list')")
+    @GetMapping("/{logId}")
+    public AjaxResult getInfo(@PathVariable Long logId) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        if (commGatewayApiLogService == null) {
+            return AjaxResult.error("服务不可用");
+        }
+        return AjaxResult.success(commGatewayApiLogService.selectById(logId));
+    }
+}

+ 126 - 0
fs-admin/src/main/java/com/fs/admin/controller/AdminVoiceConfigController.java

@@ -0,0 +1,126 @@
+package com.fs.admin.controller;
+
+import cn.hutool.json.JSONUtil;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.DataSourceType;
+import com.fs.company.domain.CompanyVoiceConfig;
+import com.fs.company.param.CompanyVoiceConfigParam;
+import com.fs.company.service.ICompanyVoiceConfigService;
+import com.fs.company.vo.CompanyVoiceConfigListVO;
+import com.fs.company.vo.CompanyVoiceConfigVO;
+import com.fs.framework.datasource.DynamicDataSourceContextHolder;
+import com.fs.system.config.SystemVoiceConfig;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 呼叫频率配置(主库 company_voice_config)
+ */
+@RestController
+@RequestMapping("/company/companyVoiceConfig")
+public class AdminVoiceConfigController extends BaseController {
+
+    @Autowired(required = false)
+    private ICompanyVoiceConfigService companyVoiceConfigService;
+
+    @PreAuthorize("@ss.hasPermi('platform:companyVoiceConfig:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(CompanyVoiceConfig query) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        startPage();
+        List<CompanyVoiceConfigListVO> list = companyVoiceConfigService != null
+                ? companyVoiceConfigService.selectCompanyVoiceConfigListVO(query)
+                : java.util.Collections.emptyList();
+        return getDataTable(list);
+    }
+
+    @PreAuthorize("@ss.hasPermi('platform:companyVoiceConfig:query')")
+    @GetMapping("/{configId}")
+    public AjaxResult getInfo(@PathVariable Long configId) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        if (companyVoiceConfigService == null) {
+            return AjaxResult.error("服务不可用");
+        }
+        CompanyVoiceConfig config = companyVoiceConfigService.selectCompanyVoiceConfigById(configId);
+        return AjaxResult.success(toParam(config));
+    }
+
+    @PreAuthorize("@ss.hasPermi('platform:companyVoiceConfig:add')")
+    @PostMapping
+    public AjaxResult add(@RequestBody CompanyVoiceConfigParam param) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        if (companyVoiceConfigService == null) {
+            return AjaxResult.error("服务不可用");
+        }
+        return toAjax(companyVoiceConfigService.insertCompanyVoiceConfig(fromParam(param)));
+    }
+
+    @PreAuthorize("@ss.hasPermi('platform:companyVoiceConfig:edit')")
+    @PutMapping
+    public AjaxResult edit(@RequestBody CompanyVoiceConfigParam param) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        if (companyVoiceConfigService == null) {
+            return AjaxResult.error("服务不可用");
+        }
+        return toAjax(companyVoiceConfigService.updateCompanyVoiceConfig(fromParam(param)));
+    }
+
+    @PreAuthorize("@ss.hasPermi('platform:companyVoiceConfig:remove')")
+    @DeleteMapping("/{configIds}")
+    public AjaxResult remove(@PathVariable Long[] configIds) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        if (companyVoiceConfigService == null) {
+            return AjaxResult.error("服务不可用");
+        }
+        return toAjax(companyVoiceConfigService.deleteCompanyVoiceConfigByIds(configIds));
+    }
+
+    private CompanyVoiceConfig fromParam(CompanyVoiceConfigParam param) {
+        CompanyVoiceConfig config = new CompanyVoiceConfig();
+        config.setConfigId(param.getConfigId());
+        config.setCompanyId(param.getCompanyId());
+        SystemVoiceConfig caller = new SystemVoiceConfig();
+        caller.setCallerMinute(param.getCallerMinute());
+        caller.setCallerHour(param.getCallerHour());
+        caller.setCallerDay(param.getCallerDay());
+        caller.setCallerWeek(param.getCallerWeek());
+        caller.setCallerMonth(param.getCallerMonth());
+        SystemVoiceConfig callee = new SystemVoiceConfig();
+        callee.setCalleeMinute(param.getCalleeMinute());
+        callee.setCalleeHour(param.getCalleeHour());
+        callee.setCalleeDay(param.getCalleeDay());
+        callee.setCalleeWeek(param.getCalleeWeek());
+        callee.setCalleeMonth(param.getCalleeMonth());
+        config.setCallerJson(JSONUtil.toJsonStr(caller));
+        config.setCalleeJson(JSONUtil.toJsonStr(callee));
+        return config;
+    }
+
+    private CompanyVoiceConfigVO toParam(CompanyVoiceConfig config) {
+        CompanyVoiceConfigVO vo = new CompanyVoiceConfigVO();
+        vo.setConfigId(config.getConfigId());
+        vo.setCompanyId(config.getCompanyId());
+        if (config.getCallerJson() != null) {
+            SystemVoiceConfig caller = JSONUtil.toBean(config.getCallerJson(), SystemVoiceConfig.class);
+            vo.setCallerMinute(caller.getCallerMinute());
+            vo.setCallerHour(caller.getCallerHour());
+            vo.setCallerDay(caller.getCallerDay());
+            vo.setCallerWeek(caller.getCallerWeek());
+            vo.setCallerMonth(caller.getCallerMonth());
+        }
+        if (config.getCalleeJson() != null) {
+            SystemVoiceConfig callee = JSONUtil.toBean(config.getCalleeJson(), SystemVoiceConfig.class);
+            vo.setCalleeMinute(callee.getCalleeMinute());
+            vo.setCalleeHour(callee.getCalleeHour());
+            vo.setCalleeDay(callee.getCalleeDay());
+            vo.setCalleeWeek(callee.getCalleeWeek());
+            vo.setCalleeMonth(callee.getCalleeMonth());
+        }
+        return vo;
+    }
+}

+ 2 - 2
fs-admin/src/main/resources/application-dev.yml

@@ -3,8 +3,8 @@ spring:
     # redis 配置
     # redis 配置
     redis:
     redis:
         # 地址
         # 地址
-        #host: localhost
-        host: 172.27.0.7
+        host: localhost
+#        host: 172.27.0.7
         # 端口,默认为6379
         # 端口,默认为6379
         port: 6379
         port: 6379
         # 数据库索引
         # 数据库索引

+ 3 - 0
fs-comm-gateway/src/main/java/com/fs/comm/dto/CommCallSendRequest.java

@@ -9,6 +9,9 @@ public class CommCallSendRequest {
 
 
     private String phone;
     private String phone;
 
 
+    /** 调用人(内部调用时可随请求体传入,写入主库日志) */
+    private Long companyUserId;
+
     private Long calleeId;
     private Long calleeId;
 
 
     private Long roboticId;
     private Long roboticId;

+ 2 - 0
fs-comm-gateway/src/main/java/com/fs/comm/dto/CommSmsSendRequest.java

@@ -17,6 +17,8 @@ public class CommSmsSendRequest {
 
 
     private Long calleeId;
     private Long calleeId;
 
 
+    private Long companyId;
+
     private String nodeKey;
     private String nodeKey;
 
 
     private String workflowInstanceId;
     private String workflowInstanceId;

+ 75 - 29
fs-comm-gateway/src/main/java/com/fs/comm/service/CommCallService.java

@@ -6,6 +6,7 @@ import com.fs.comm.metrics.CommMetricsService;
 import com.fs.comm.model.CommCallSendParam;
 import com.fs.comm.model.CommCallSendParam;
 import com.fs.comm.model.CommCallSendResult;
 import com.fs.comm.model.CommCallSendResult;
 import com.fs.comm.ratelimit.CommRateLimitService;
 import com.fs.comm.ratelimit.CommRateLimitService;
+import com.fs.comm.support.CommTenantDataSourceHelper;
 import com.fs.common.exception.ServiceException;
 import com.fs.common.exception.ServiceException;
 import com.fs.common.utils.StringUtils;
 import com.fs.common.utils.StringUtils;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
@@ -31,38 +32,83 @@ public class CommCallService {
     @Autowired
     @Autowired
     private CommMetricsService commMetricsService;
     private CommMetricsService commMetricsService;
 
 
+    @Autowired
+    private CommVoiceLimitService commVoiceLimitService;
+
+    @Autowired
+    private CommGatewayApiLogRecorder commGatewayApiLogRecorder;
+
+    @Autowired
+    private CommTenantDataSourceHelper commTenantDataSourceHelper;
+
     public Map<String, Object> sendCall(CommCallSendRequest request) {
     public Map<String, Object> sendCall(CommCallSendRequest request) {
+        long startMs = System.currentTimeMillis();
         Long companyId = CommAuthContext.getCompanyId();
         Long companyId = CommAuthContext.getCompanyId();
         Long tenantId = CommAuthContext.getTenantId();
         Long tenantId = CommAuthContext.getTenantId();
-        if (companyId == null) {
-            throw new ServiceException("未获取到公司信息");
-        }
-        commRateLimitService.checkTenantQps(tenantId);
-        commGatewayLineAuthService.validateGateway(companyId, request.getGatewayId());
-
-        CommCallSendResult result = commCallSendService.sendWorkflowCall(CommCallSendParam.builder()
-                .roboticId(request.getRoboticId())
-                .calleeId(request.getCalleeId())
-                .businessId(request.getBusinessId())
-                .gatewayId(request.getGatewayId())
-                .nodeKey(request.getNodeKey())
-                .workflowInstanceId(request.getWorkflowInstanceId())
-                .companyId(companyId)
-                .tenantId(tenantId)
-                .callbackUrl(request.getCallbackUrl())
-                .phone(request.getPhone())
-                .bizParams(request.getBizParams())
-                .build());
-
-        if (StringUtils.isBlank(result.getPhone())) {
-            throw new ServiceException("外呼发起失败:未获取到有效被叫号码");
-        }
+        Long gatewayId = request != null ? request.getGatewayId() : null;
+        String calleePhone = null;
+        String callerKey = null;
+        CommGatewayApiLogRecorder.CommApiRecordResult recordResult = null;
 
 
-        Map<String, Object> response = new HashMap<>();
-        response.put("callBackUuid", result.getCallBackUuid());
-        response.put("batchId", result.getBatchId());
-        response.put("phone", result.getPhone());
-        commMetricsService.increment("call.success");
-        return response;
+        try {
+            if (companyId == null) {
+                throw new ServiceException("未获取到公司信息");
+            }
+            calleePhone = commVoiceLimitService.resolveCallCalleePhone(request.getCalleeId(), request.getPhone());
+            callerKey = commVoiceLimitService.buildCompanyCallerKey(companyId, gatewayId);
+
+            commRateLimitService.checkTenantQps(tenantId);
+            commVoiceLimitService.checkCallLimit(companyId, tenantId, request.getCalleeId(), request.getPhone(), gatewayId);
+            commTenantDataSourceHelper.ensureTenant(tenantId);
+            commGatewayLineAuthService.validateGateway(companyId, tenantId, gatewayId);
+            commTenantDataSourceHelper.ensureTenant(tenantId);
+
+            CommCallSendResult result = commCallSendService.sendWorkflowCall(CommCallSendParam.builder()
+                    .roboticId(request.getRoboticId())
+                    .calleeId(request.getCalleeId())
+                    .businessId(request.getBusinessId())
+                    .gatewayId(gatewayId)
+                    .nodeKey(request.getNodeKey())
+                    .workflowInstanceId(request.getWorkflowInstanceId())
+                    .companyId(companyId)
+                    .tenantId(tenantId)
+                    .callbackUrl(request.getCallbackUrl())
+                    .phone(request.getPhone())
+                    .bizParams(request.getBizParams())
+                    .build());
+
+            if (StringUtils.isBlank(result.getPhone())) {
+                throw new ServiceException("外呼发起失败:未获取到有效被叫号码");
+            }
+
+            if (StringUtils.isBlank(calleePhone)) {
+                calleePhone = commVoiceLimitService.normalizePhone(result.getPhone());
+            }
+
+            Map<String, Object> response = new HashMap<>();
+            response.put("callBackUuid", result.getCallBackUuid());
+            response.put("batchId", result.getBatchId());
+            response.put("phone", result.getPhone());
+
+            recordResult = commGatewayApiLogRecorder.buildSuccess(response, calleePhone, callerKey, gatewayId);
+            commMetricsService.increment("call.success");
+            return response;
+        } catch (ServiceException ex) {
+            boolean limitHit = ex.getMessage() != null && ex.getMessage().contains("限制");
+            recordResult = limitHit
+                    ? commGatewayApiLogRecorder.buildLimitFailure(ex, calleePhone, callerKey, gatewayId)
+                    : commGatewayApiLogRecorder.buildFailure(ex, calleePhone, callerKey, gatewayId);
+            throw ex;
+        } catch (Exception ex) {
+            log.error("外呼调用异常 companyId={}", companyId, ex);
+            ServiceException wrapped = ex instanceof ServiceException
+                    ? (ServiceException) ex
+                    : new ServiceException(StringUtils.defaultIfBlank(ex.getMessage(), "外呼调用异常"));
+            recordResult = commGatewayApiLogRecorder.buildFailure(wrapped, calleePhone, callerKey, gatewayId);
+            throw wrapped;
+        } finally {
+            commGatewayApiLogRecorder.recordCallAttempt(companyId, tenantId, request, recordResult,
+                    calleePhone, callerKey, gatewayId, startMs);
+        }
     }
     }
 }
 }

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

@@ -0,0 +1,242 @@
+package com.fs.comm.service;
+
+import com.alibaba.fastjson.JSON;
+import com.fs.comm.auth.CommSession;
+import com.fs.comm.context.CommAuthContext;
+import com.fs.comm.domain.CommGatewayApiLog;
+import com.fs.comm.dto.CommCallSendRequest;
+import com.fs.comm.dto.CommSmsSendRequest;
+import com.fs.comm.model.CommGatewayBillingQuote;
+import com.fs.common.exception.ServiceException;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.ip.IpUtils;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 通讯网关 API 调用日志记录(主库)
+ */
+@Slf4j
+@Service
+public class CommGatewayApiLogRecorder {
+
+    @Autowired
+    private ICommGatewayApiLogService commGatewayApiLogService;
+
+    @Autowired
+    private CommGatewayBillingService commGatewayBillingService;
+
+    public void recordCallAttempt(Long companyId, Long tenantId, CommCallSendRequest request,
+                                  CommApiRecordResult result, String calleePhone, String callerPhone,
+                                  Long gatewayId, long startMs) {
+        record(buildBaseLog(companyId, tenantId, CommGatewayApiLog.API_TYPE_CALL, "/comm/call/send",
+                request, result, calleePhone, callerPhone, gatewayId, startMs));
+    }
+
+    public void recordSmsAttempt(Long companyId, Long tenantId, CommSmsSendRequest request,
+                                 CommApiRecordResult result, String calleePhone, String callerPhone,
+                                 Long gatewayId, long startMs) {
+        record(buildBaseLog(companyId, tenantId, CommGatewayApiLog.API_TYPE_SMS, "/comm/sms/send",
+                request, result, calleePhone, callerPhone, gatewayId, startMs));
+    }
+
+    private CommGatewayApiLog buildBaseLog(Long companyId, Long tenantId, String apiType, String apiPath,
+                                           Object request, CommApiRecordResult result, String calleePhone,
+                                           String callerPhone, Long gatewayId, long startMs) {
+        CommSession session = CommAuthContext.get();
+        CommGatewayApiLog logEntity = new CommGatewayApiLog();
+        logEntity.setTenantId(tenantId);
+        logEntity.setCompanyId(companyId);
+        if (session != null) {
+            logEntity.setCompanyUserId(session.getCompanyUserId());
+            logEntity.setCallerAccount(session.getAccount());
+            logEntity.setAuthScope(session.getScope());
+        }
+        fillCompanyUserIdFromRequest(logEntity, request);
+        logEntity.setApiType(apiType);
+        logEntity.setApiPath(apiPath);
+        logEntity.setRequestBody(JSON.toJSONString(request));
+        logEntity.setDurationMs((int) (System.currentTimeMillis() - startMs));
+        try {
+            logEntity.setClientIp(IpUtils.getIpAddr(ServletUtils.getRequest()));
+        } catch (Exception ignored) {
+        }
+
+        if (result != null) {
+            logEntity.setResponseBody(result.getResponseBody());
+            logEntity.setResultCode(result.getResultCode());
+            logEntity.setResultMsg(result.getResultMsg());
+            logEntity.setSuccess(result.isSuccess() ? 1 : 0);
+            logEntity.setLimitHit(result.isLimitHit() ? 1 : 0);
+            logEntity.setLimitReason(result.getLimitReason());
+            logEntity.setCalleePhone(result.getCalleePhone());
+            logEntity.setCallerPhone(result.getCallerPhone());
+            logEntity.setGatewayId(result.getGatewayId());
+            applyBilling(logEntity, tenantId, apiType, result.isSuccess());
+        } else {
+            applyFallbackFields(logEntity, calleePhone, callerPhone, gatewayId, "调用未正常返回结果");
+        }
+
+        if (StringUtils.isBlank(logEntity.getCalleePhone()) && StringUtils.isNotBlank(calleePhone)) {
+            logEntity.setCalleePhone(calleePhone);
+        }
+        if (StringUtils.isBlank(logEntity.getCallerPhone()) && StringUtils.isNotBlank(callerPhone)) {
+            logEntity.setCallerPhone(callerPhone);
+        }
+        if (logEntity.getGatewayId() == null && gatewayId != null) {
+            logEntity.setGatewayId(gatewayId);
+        }
+        return logEntity;
+    }
+
+    private void applyBilling(CommGatewayApiLog logEntity, Long tenantId, String apiType, boolean success) {
+        logEntity.setBillingUnit(apiType);
+        if (!success) {
+            logEntity.setBillingAmount(BigDecimal.ZERO);
+            logEntity.setCostPrice(BigDecimal.ZERO);
+            logEntity.setCalcPrice(BigDecimal.ZERO);
+            logEntity.setBillingQuantity(0);
+            return;
+        }
+        CommGatewayBillingQuote quote = commGatewayBillingService.resolveQuote(tenantId, apiType, 1);
+        logEntity.setCostPrice(quote.getCostPrice());
+        logEntity.setCalcPrice(quote.getCalcPrice());
+        logEntity.setBillingQuantity(quote.getBillingQuantity());
+        logEntity.setBillingAmount(quote.getBillingAmount());
+    }
+
+    private void fillCompanyUserIdFromRequest(CommGatewayApiLog logEntity, Object request) {
+        if (logEntity.getCompanyUserId() != null || request == null) {
+            return;
+        }
+        if (request instanceof CommCallSendRequest) {
+            logEntity.setCompanyUserId(((CommCallSendRequest) request).getCompanyUserId());
+        } else if (request instanceof CommSmsSendRequest) {
+            logEntity.setCompanyUserId(((CommSmsSendRequest) request).getCompanyUserId());
+        }
+    }
+
+    private void applyFallbackFields(CommGatewayApiLog logEntity, String calleePhone, String callerPhone,
+                                     Long gatewayId, String message) {
+        logEntity.setCalleePhone(calleePhone);
+        logEntity.setCallerPhone(callerPhone);
+        logEntity.setGatewayId(gatewayId);
+        logEntity.setSuccess(0);
+        logEntity.setLimitHit(0);
+        logEntity.setResultCode(500);
+        logEntity.setResultMsg(message);
+        logEntity.setBillingAmount(BigDecimal.ZERO);
+        logEntity.setCostPrice(BigDecimal.ZERO);
+        logEntity.setCalcPrice(BigDecimal.ZERO);
+        logEntity.setBillingQuantity(0);
+        Map<String, Object> body = new HashMap<>();
+        body.put("code", 500);
+        body.put("msg", message);
+        logEntity.setResponseBody(JSON.toJSONString(body));
+    }
+
+    private void record(CommGatewayApiLog logEntity) {
+        try {
+            commGatewayApiLogService.saveLog(logEntity);
+        } catch (Exception ex) {
+            log.error("写入通讯网关调用日志失败", ex);
+        }
+    }
+
+    public CommApiRecordResult buildLimitFailure(ServiceException ex, String calleePhone, String callerPhone, Long gatewayId) {
+        Map<String, Object> body = new HashMap<>();
+        body.put("code", 500);
+        body.put("msg", ex.getMessage());
+        return CommApiRecordResult.builder()
+                .success(false)
+                .limitHit(true)
+                .limitReason(ex.getMessage())
+                .resultCode(500)
+                .resultMsg(ex.getMessage())
+                .responseBody(JSON.toJSONString(body))
+                .calleePhone(calleePhone)
+                .callerPhone(callerPhone)
+                .gatewayId(gatewayId)
+                .build();
+    }
+
+    public CommApiRecordResult buildSuccess(Object data, String calleePhone, String callerPhone, Long gatewayId) {
+        Map<String, Object> body = new HashMap<>();
+        body.put("code", 200);
+        body.put("msg", "success");
+        body.put("data", data);
+        return CommApiRecordResult.builder()
+                .success(true)
+                .limitHit(false)
+                .resultCode(200)
+                .resultMsg("success")
+                .responseBody(JSON.toJSONString(body))
+                .calleePhone(calleePhone)
+                .callerPhone(callerPhone)
+                .gatewayId(gatewayId)
+                .build();
+    }
+
+    public CommApiRecordResult buildFailure(ServiceException ex, String calleePhone, String callerPhone, Long gatewayId) {
+        Map<String, Object> body = new HashMap<>();
+        body.put("code", 500);
+        body.put("msg", ex.getMessage());
+        return CommApiRecordResult.builder()
+                .success(false)
+                .limitHit(false)
+                .resultCode(500)
+                .resultMsg(ex.getMessage())
+                .responseBody(JSON.toJSONString(body))
+                .calleePhone(calleePhone)
+                .callerPhone(callerPhone)
+                .gatewayId(gatewayId)
+                .build();
+    }
+
+    public static class CommApiRecordResult {
+        private boolean success;
+        private boolean limitHit;
+        private Integer resultCode;
+        private String resultMsg;
+        private String responseBody;
+        private String limitReason;
+        private String calleePhone;
+        private String callerPhone;
+        private Long gatewayId;
+
+        public static CommApiRecordResultBuilder builder() {
+            return new CommApiRecordResultBuilder();
+        }
+
+        public boolean isSuccess() { return success; }
+        public boolean isLimitHit() { return limitHit; }
+        public Integer getResultCode() { return resultCode; }
+        public String getResultMsg() { return resultMsg; }
+        public String getResponseBody() { return responseBody; }
+        public String getLimitReason() { return limitReason; }
+        public String getCalleePhone() { return calleePhone; }
+        public String getCallerPhone() { return callerPhone; }
+        public Long getGatewayId() { return gatewayId; }
+
+        public static class CommApiRecordResultBuilder {
+            private final CommApiRecordResult target = new CommApiRecordResult();
+
+            public CommApiRecordResultBuilder success(boolean success) { target.success = success; return this; }
+            public CommApiRecordResultBuilder limitHit(boolean limitHit) { target.limitHit = limitHit; return this; }
+            public CommApiRecordResultBuilder resultCode(Integer resultCode) { target.resultCode = resultCode; return this; }
+            public CommApiRecordResultBuilder resultMsg(String resultMsg) { target.resultMsg = resultMsg; return this; }
+            public CommApiRecordResultBuilder responseBody(String responseBody) { target.responseBody = responseBody; return this; }
+            public CommApiRecordResultBuilder limitReason(String limitReason) { target.limitReason = limitReason; return this; }
+            public CommApiRecordResultBuilder calleePhone(String calleePhone) { target.calleePhone = calleePhone; return this; }
+            public CommApiRecordResultBuilder callerPhone(String callerPhone) { target.callerPhone = callerPhone; return this; }
+            public CommApiRecordResultBuilder gatewayId(Long gatewayId) { target.gatewayId = gatewayId; return this; }
+            public CommApiRecordResult build() { return target; }
+        }
+    }
+}

+ 12 - 6
fs-comm-gateway/src/main/java/com/fs/comm/service/CommGatewayLineAuthService.java

@@ -2,9 +2,10 @@ package com.fs.comm.service;
 
 
 import com.alibaba.fastjson.JSONArray;
 import com.alibaba.fastjson.JSONArray;
 import com.alibaba.fastjson.JSONObject;
 import com.alibaba.fastjson.JSONObject;
+import com.fs.comm.support.CommTenantDataSourceHelper;
 import com.fs.common.exception.ServiceException;
 import com.fs.common.exception.ServiceException;
 import com.fs.common.utils.StringUtils;
 import com.fs.common.utils.StringUtils;
-import com.fs.company.mapper.CompanyMapper;
+import com.fs.company.mapper.CompanyBindGatewayMapper;
 import com.fs.company.service.easycall.IEasyCallService;
 import com.fs.company.service.easycall.IEasyCallService;
 import com.fs.company.vo.easycall.EasyCallGatewayVO;
 import com.fs.company.vo.easycall.EasyCallGatewayVO;
 import com.fs.system.service.ISysConfigService;
 import com.fs.system.service.ISysConfigService;
@@ -27,18 +28,22 @@ public class CommGatewayLineAuthService {
     private IEasyCallService easyCallService;
     private IEasyCallService easyCallService;
 
 
     @Autowired
     @Autowired
-    private CompanyMapper companyMapper;
+    private CompanyBindGatewayMapper companyBindGatewayMapper;
 
 
     @Autowired
     @Autowired
     private ISysConfigService configService;
     private ISysConfigService configService;
 
 
-    public void validateGateway(Long companyId, Long gatewayId) {
+    @Autowired
+    private CommTenantDataSourceHelper commTenantDataSourceHelper;
+
+    public void validateGateway(Long companyId, Long tenantId, Long gatewayId) {
         if (gatewayId == null) {
         if (gatewayId == null) {
             throw new ServiceException("gatewayId不能为空");
             throw new ServiceException("gatewayId不能为空");
         }
         }
+        commTenantDataSourceHelper.ensureTenant(tenantId);
         List<EasyCallGatewayVO> allowed = easyCallService.getGatewayList(companyId);
         List<EasyCallGatewayVO> allowed = easyCallService.getGatewayList(companyId);
         if (allowed == null || allowed.isEmpty()) {
         if (allowed == null || allowed.isEmpty()) {
-            validateByConfigOnly(companyId, gatewayId);
+            validateByConfigOnly(companyId, tenantId, gatewayId);
             return;
             return;
         }
         }
         boolean matched = allowed.stream().anyMatch(item -> gatewayId.equals(item.getId()));
         boolean matched = allowed.stream().anyMatch(item -> gatewayId.equals(item.getId()));
@@ -47,8 +52,9 @@ public class CommGatewayLineAuthService {
         }
         }
     }
     }
 
 
-    private void validateByConfigOnly(Long companyId, Long gatewayId) {
-        String gateWayList = companyMapper.getGateWayList(companyId);
+    private void validateByConfigOnly(Long companyId, Long tenantId, Long gatewayId) {
+        commTenantDataSourceHelper.ensureTenant(tenantId);
+        String gateWayList = companyBindGatewayMapper.getGateWayIdListByCompanyId(companyId);
         if (StringUtils.isNotBlank(gateWayList)) {
         if (StringUtils.isNotBlank(gateWayList)) {
             List<Long> ids = Arrays.stream(gateWayList.split(","))
             List<Long> ids = Arrays.stream(gateWayList.split(","))
                     .map(String::trim).filter(StringUtils::isNotBlank).map(Long::valueOf).collect(Collectors.toList());
                     .map(String::trim).filter(StringUtils::isNotBlank).map(Long::valueOf).collect(Collectors.toList());

+ 66 - 22
fs-comm-gateway/src/main/java/com/fs/comm/service/CommSmsService.java

@@ -6,7 +6,9 @@ import com.fs.comm.metrics.CommMetricsService;
 import com.fs.comm.model.CommSmsSendParam;
 import com.fs.comm.model.CommSmsSendParam;
 import com.fs.comm.model.CommSmsSendResult;
 import com.fs.comm.model.CommSmsSendResult;
 import com.fs.comm.ratelimit.CommRateLimitService;
 import com.fs.comm.ratelimit.CommRateLimitService;
+import com.fs.comm.support.CommTenantDataSourceHelper;
 import com.fs.common.exception.ServiceException;
 import com.fs.common.exception.ServiceException;
+import com.fs.common.utils.StringUtils;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
@@ -27,30 +29,72 @@ public class CommSmsService {
     @Autowired
     @Autowired
     private CommMetricsService commMetricsService;
     private CommMetricsService commMetricsService;
 
 
+    @Autowired
+    private CommVoiceLimitService commVoiceLimitService;
+
+    @Autowired
+    private CommGatewayApiLogRecorder commGatewayApiLogRecorder;
+
+    @Autowired
+    private CommTenantDataSourceHelper commTenantDataSourceHelper;
+
     public Map<String, Object> sendSms(CommSmsSendRequest request) {
     public Map<String, Object> sendSms(CommSmsSendRequest request) {
+        long startMs = System.currentTimeMillis();
         Long companyId = CommAuthContext.getCompanyId();
         Long companyId = CommAuthContext.getCompanyId();
         Long tenantId = CommAuthContext.getTenantId();
         Long tenantId = CommAuthContext.getTenantId();
-        commRateLimitService.checkTenantQps(tenantId);
-
-        CommSmsSendResult result = commSmsSendService.sendWorkflowSms(CommSmsSendParam.builder()
-                .roboticId(request.getRoboticId())
-                .calleeId(request.getCalleeId())
-                .smsTempId(request.getSmsTempId())
-                .nodeKey(request.getNodeKey())
-                .workflowInstanceId(request.getWorkflowInstanceId())
-                .companyId(companyId)
-                .companyUserId(request.getCompanyUserId() != null ? request.getCompanyUserId() : CommAuthContext.getCompanyUserId())
-                .senderName(request.getSenderName())
-                .phone(request.getPhone())
-                .customerId(request.getCustomerId())
-                .cardUrl(request.getCardUrl())
-                .build());
-
-        Map<String, Object> response = new HashMap<>();
-        response.put("callbackUuid", result.getCallbackUuid());
-        response.put("customerId", result.getCustomerId());
-        response.put("phone", result.getPhone());
-        commMetricsService.increment("sms.success");
-        return response;
+        String callerKey = null;
+        CommGatewayApiLogRecorder.CommApiRecordResult recordResult = null;
+        String calleePhone = null;
+
+        try {
+            if (companyId == null) {
+                throw new ServiceException("未获取到公司信息");
+            }
+            callerKey = commVoiceLimitService.buildCompanyCallerKey(companyId, null);
+
+            commRateLimitService.checkTenantQps(tenantId);
+            commVoiceLimitService.checkSmsLimit(companyId, tenantId, request.getCalleeId(), request.getCustomerId(), request.getPhone());
+            commTenantDataSourceHelper.ensureTenant(tenantId);
+
+            CommSmsSendResult result = commSmsSendService.sendWorkflowSms(CommSmsSendParam.builder()
+                    .roboticId(request.getRoboticId())
+                    .calleeId(request.getCalleeId())
+                    .smsTempId(request.getSmsTempId())
+                    .nodeKey(request.getNodeKey())
+                    .workflowInstanceId(request.getWorkflowInstanceId())
+                    .companyId(request.getCompanyId() != null ? request.getCompanyId() : companyId)
+                    .companyUserId(request.getCompanyUserId() != null ? request.getCompanyUserId() : CommAuthContext.getCompanyUserId())
+                    .senderName(request.getSenderName())
+                    .phone(request.getPhone())
+                    .customerId(request.getCustomerId())
+                    .cardUrl(request.getCardUrl())
+                    .build());
+
+            calleePhone = commVoiceLimitService.normalizePhone(result.getPhone());
+            Map<String, Object> response = new HashMap<>();
+            response.put("callbackUuid", result.getCallbackUuid());
+            response.put("customerId", result.getCustomerId());
+            response.put("phone", result.getPhone());
+
+            recordResult = commGatewayApiLogRecorder.buildSuccess(response, calleePhone, callerKey, null);
+            commMetricsService.increment("sms.success");
+            return response;
+        } catch (ServiceException ex) {
+            boolean limitHit = ex.getMessage() != null && ex.getMessage().contains("限制");
+            recordResult = limitHit
+                    ? commGatewayApiLogRecorder.buildLimitFailure(ex, calleePhone, callerKey, null)
+                    : commGatewayApiLogRecorder.buildFailure(ex, calleePhone, callerKey, null);
+            throw ex;
+        } catch (Exception ex) {
+            log.error("短信调用异常 companyId={}", companyId, ex);
+            ServiceException wrapped = ex instanceof ServiceException
+                    ? (ServiceException) ex
+                    : new ServiceException(StringUtils.defaultIfBlank(ex.getMessage(), "短信调用异常"));
+            recordResult = commGatewayApiLogRecorder.buildFailure(wrapped, calleePhone, callerKey, null);
+            throw wrapped;
+        } finally {
+            commGatewayApiLogRecorder.recordSmsAttempt(companyId, tenantId, request, recordResult,
+                    calleePhone, callerKey, null, startMs);
+        }
     }
     }
 }
 }

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

@@ -0,0 +1,144 @@
+package com.fs.comm.sms;
+
+import cn.hutool.http.HttpRequest;
+import cn.hutool.json.JSONUtil;
+import com.fs.common.utils.StringUtils;
+import com.fs.common.vo.SmsSendItemVO;
+import com.fs.common.vo.SmsSendVO;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * 迈远(my)短信 HTTP 发送实现。
+ * 对接 company_sms_api / company_sms_api_port 配置的 url、account、password、sign、extno。
+ */
+@Slf4j
+@Service
+public class MyCommSmsProvider implements CommSmsProvider {
+
+    private static final String PROVIDER = "my";
+
+    @Override
+    public String provider() {
+        return PROVIDER;
+    }
+
+    @Override
+    public CommSmsChannelResult send(CommSmsChannelRequest request) {
+        String validateError = validate(request);
+        if (validateError != null) {
+            return CommSmsChannelResult.fail(validateError, null);
+        }
+
+        String sendUrl;
+        try {
+            sendUrl = buildSendUrl(request);
+        } catch (UnsupportedEncodingException e) {
+            log.error("MyCommSmsProvider URL编码异常 phone={}", request.getPhone(), e);
+            return CommSmsChannelResult.fail("ENCODE_ERROR", null);
+        }
+
+        String responseBody;
+        try {
+            responseBody = HttpRequest.get(sendUrl).timeout(15000).execute().body();
+        } catch (Exception e) {
+            log.error("MyCommSmsProvider HTTP请求异常 phone={}, url={}", request.getPhone(), maskUrl(sendUrl), e);
+            return CommSmsChannelResult.fail("HTTP_ERROR", e.getMessage());
+        }
+
+        return parseResponse(responseBody, request.getPhone());
+    }
+
+    private String validate(CommSmsChannelRequest request) {
+        if (request == null) {
+            return "INVALID_REQUEST";
+        }
+        if (StringUtils.isBlank(request.getPhone())) {
+            return "INVALID_PHONE";
+        }
+        if (StringUtils.isBlank(request.getUrl())) {
+            return "INVALID_URL";
+        }
+        if (StringUtils.isBlank(request.getAccount()) || StringUtils.isBlank(request.getPassword())) {
+            return "INVALID_CREDENTIAL";
+        }
+        if (request.getTempType() == null) {
+            return "UNSUPPORTED_TEMP_TYPE";
+        }
+        if (!Integer.valueOf(1).equals(request.getTempType()) && !Integer.valueOf(2).equals(request.getTempType())) {
+            return "UNSUPPORTED_TEMP_TYPE";
+        }
+        return null;
+    }
+
+    /**
+     * 迈远发送 URL:{url}sms?action=send&account=...&password=...&mobile=...&content=...&extno=...&rt=json
+     */
+    String buildSendUrl(CommSmsChannelRequest request) throws UnsupportedEncodingException {
+        String sign = StringUtils.defaultString(request.getSign());
+        String body = request.getContent();
+        if (Integer.valueOf(2).equals(request.getTempType())) {
+            body = body + "拒收请回复R";
+        }
+        String encodedContent = URLEncoder.encode(sign + body, StandardCharsets.UTF_8.name());
+        String baseUrl = normalizeBaseUrl(request.getUrl());
+        return baseUrl + "sms?action=send"
+                + "&account=" + request.getAccount()
+                + "&password=" + request.getPassword()
+                + "&mobile=" + request.getPhone()
+                + "&content=" + encodedContent
+                + "&extno=" + StringUtils.defaultString(request.getExtno())
+                + "&rt=json";
+    }
+
+    private CommSmsChannelResult parseResponse(String responseBody, String phone) {
+        if (StringUtils.isBlank(responseBody)) {
+            return CommSmsChannelResult.fail("EMPTY_RESPONSE", null);
+        }
+        try {
+            SmsSendVO vo = JSONUtil.toBean(responseBody, SmsSendVO.class);
+            if (vo == null || vo.getStatus() == null || !Integer.valueOf(0).equals(vo.getStatus())) {
+                log.warn("MyCommSmsProvider 发送失败 phone={}, response={}", phone, abbreviate(responseBody));
+                return CommSmsChannelResult.fail("SEND_FAILED", abbreviate(responseBody));
+            }
+            if (vo.getList() == null) {
+                return CommSmsChannelResult.fail("SEND_FAILED", abbreviate(responseBody));
+            }
+            for (SmsSendItemVO item : vo.getList()) {
+                if (item != null && "0".equals(item.getResult())) {
+                    return CommSmsChannelResult.ok(item.getMid());
+                }
+            }
+            log.warn("MyCommSmsProvider 无成功条目 phone={}, response={}", phone, abbreviate(responseBody));
+            return CommSmsChannelResult.fail("SEND_FAILED", abbreviate(responseBody));
+        } catch (Exception e) {
+            log.error("MyCommSmsProvider 响应解析异常 phone={}, response={}", phone, abbreviate(responseBody), e);
+            return CommSmsChannelResult.fail("PARSE_ERROR", abbreviate(responseBody));
+        }
+    }
+
+    private String normalizeBaseUrl(String url) {
+        if (StringUtils.isBlank(url)) {
+            return "";
+        }
+        return url.endsWith("/") ? url : url + "/";
+    }
+
+    private String maskUrl(String url) {
+        if (StringUtils.isBlank(url)) {
+            return url;
+        }
+        return url.replaceAll("password=[^&]*", "password=***");
+    }
+
+    private String abbreviate(String text) {
+        if (text == null) {
+            return null;
+        }
+        return text.length() > 500 ? text.substring(0, 500) : text;
+    }
+}

+ 22 - 0
fs-comm-gateway/对接文档.md

@@ -302,6 +302,28 @@ Content-Type: application/json
 
 
 发送结果异步写入租户库 `company_voice_robotic_call_log_sendmsg`(status:1 进行中 / 2 成功 / 3 失败)。
 发送结果异步写入租户库 `company_voice_robotic_call_log_sendmsg`(status:1 进行中 / 2 成功 / 3 失败)。
 
 
+**迈远(provider=my)发送说明:**
+
+网关进程内由 `MyCommSmsProvider` 负责实际 HTTP 下发,不再读取旧的 `his.sms` 全局配置,而是按租户在 Admin 配置的接口表路由:
+
+| 配置表 | 字段 | 说明 |
+|--------|------|------|
+| `company_sms_api` | `provider=my` | 固定为迈远 |
+| | `url` | 接口根地址 |
+| | `account` / `password` | 账户密码 |
+| | `sign` | 短信签名 |
+| `company_sms_api_port` | `port_no` | 迈远扩展码 `extno` |
+| | `account` / `password` / `sign` | 可选,覆盖接口级配置 |
+
+请求 URL 格式(与旧版 `sendCaptcha` 一致):
+
+```
+{url}sms?action=send&account={account}&password={password}&mobile={phone}&content={URLEncode(sign+content)}&extno={extno}&rt=json
+```
+
+- 模板类型 `tempType=1`(行业通知):内容为 `sign + content`
+- 模板类型 `tempType=2`(营销):内容为 `sign + content + 拒收请回复R`
+
 ---
 ---
 
 
 ### 5.3 查询外呼记录
 ### 5.3 查询外呼记录

+ 57 - 0
fs-service/src/main/java/com/fs/comm/domain/CommGatewayApiLog.java

@@ -0,0 +1,57 @@
+package com.fs.comm.domain;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+/**
+ * 通讯网关 API 调用日志(主库 comm_gateway_api_log)
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class CommGatewayApiLog extends BaseEntity {
+
+    private static final long serialVersionUID = 1L;
+
+    public static final String API_TYPE_CALL = "call";
+    public static final String API_TYPE_SMS = "sms";
+
+    private Long logId;
+    private Long tenantId;
+    private Long companyId;
+    private Long companyUserId;
+    private String callerAccount;
+    private String apiType;
+    private String apiPath;
+    private String requestBody;
+    private String responseBody;
+    private Integer resultCode;
+    private String resultMsg;
+    private Integer success;
+    private Integer limitHit;
+    private String limitReason;
+    private String calleePhone;
+    private String callerPhone;
+    private Long gatewayId;
+    private BigDecimal billingAmount;
+    /** 成本价(单价) */
+    private BigDecimal costPrice;
+    /** 计算价/售价(单价) */
+    private BigDecimal calcPrice;
+    /** 计费数量 */
+    private Integer billingQuantity;
+    private String billingUnit;
+    private String clientIp;
+    private String authScope;
+    private Integer durationMs;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+
+    /** 查询:公司名称(关联展示) */
+    private String companyName;
+}

+ 33 - 0
fs-service/src/main/java/com/fs/comm/mapper/CommGatewayApiLogMapper.java

@@ -0,0 +1,33 @@
+package com.fs.comm.mapper;
+
+import com.fs.comm.domain.CommGatewayApiLog;
+import com.fs.common.annotation.DataSource;
+import com.fs.common.enums.DataSourceType;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 通讯网关 API 调用日志(主库)
+ */
+@DataSource(DataSourceType.MASTER)
+public interface CommGatewayApiLogMapper {
+
+    int insertCommGatewayApiLog(CommGatewayApiLog log);
+
+    CommGatewayApiLog selectCommGatewayApiLogById(Long logId);
+
+    List<CommGatewayApiLog> selectCommGatewayApiLogList(CommGatewayApiLog query);
+
+    Integer countByCalleePhone(@Param("companyId") Long companyId,
+                               @Param("calleePhone") String calleePhone,
+                               @Param("type") Integer type);
+
+    Integer countByCallerPhone(@Param("companyId") Long companyId,
+                               @Param("callerPhone") String callerPhone,
+                               @Param("type") Integer type);
+
+    Integer countByCompanyCall(@Param("companyId") Long companyId,
+                               @Param("apiType") String apiType,
+                               @Param("type") Integer type);
+}

+ 26 - 0
fs-service/src/main/java/com/fs/comm/model/CommGatewayBillingQuote.java

@@ -0,0 +1,26 @@
+package com.fs.comm.model;
+
+import lombok.Builder;
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * 通讯网关单次调用计费报价
+ */
+@Data
+@Builder
+public class CommGatewayBillingQuote {
+
+    /** 成本价(单价,元) */
+    private BigDecimal costPrice;
+
+    /** 计算价/售价(单价,元) */
+    private BigDecimal calcPrice;
+
+    /** 计费数量 */
+    private Integer billingQuantity;
+
+    /** 计费总额 = calcPrice × billingQuantity */
+    private BigDecimal billingAmount;
+}

+ 18 - 0
fs-service/src/main/java/com/fs/comm/model/CommSmsSendContext.java

@@ -0,0 +1,18 @@
+package com.fs.comm.model;
+
+import lombok.Builder;
+import lombok.Data;
+
+/**
+ * 短信发送上下文(公司、调用人、发送人展示名)
+ */
+@Data
+@Builder
+public class CommSmsSendContext {
+
+    private Long companyId;
+
+    private Long companyUserId;
+
+    private String senderName;
+}

+ 51 - 0
fs-service/src/main/java/com/fs/comm/service/CommGatewayApiLogServiceImpl.java

@@ -0,0 +1,51 @@
+package com.fs.comm.service;
+
+import com.fs.comm.domain.CommGatewayApiLog;
+import com.fs.comm.mapper.CommGatewayApiLogMapper;
+import com.fs.common.annotation.DataSource;
+import com.fs.common.enums.DataSourceType;
+import com.fs.common.utils.DateUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+@Service
+public class CommGatewayApiLogServiceImpl implements ICommGatewayApiLogService {
+
+    @Autowired
+    private CommGatewayApiLogMapper commGatewayApiLogMapper;
+
+    @Override
+    @DataSource(DataSourceType.MASTER)
+    public void saveLog(CommGatewayApiLog log) {
+        if (log.getCreateTime() == null) {
+            log.setCreateTime(DateUtils.getNowDate());
+        }
+        commGatewayApiLogMapper.insertCommGatewayApiLog(log);
+    }
+
+    @Override
+    @DataSource(DataSourceType.MASTER)
+    public CommGatewayApiLog selectById(Long logId) {
+        return commGatewayApiLogMapper.selectCommGatewayApiLogById(logId);
+    }
+
+    @Override
+    @DataSource(DataSourceType.MASTER)
+    public List<CommGatewayApiLog> selectList(CommGatewayApiLog query) {
+        return commGatewayApiLogMapper.selectCommGatewayApiLogList(query);
+    }
+
+    @Override
+    @DataSource(DataSourceType.MASTER)
+    public int countByCalleePhone(Long companyId, String calleePhone, Integer type) {
+        return commGatewayApiLogMapper.countByCalleePhone(companyId, calleePhone, type);
+    }
+
+    @Override
+    @DataSource(DataSourceType.MASTER)
+    public int countByCallerPhone(Long companyId, String callerPhone, Integer type) {
+        return commGatewayApiLogMapper.countByCallerPhone(companyId, callerPhone, type);
+    }
+}

+ 156 - 0
fs-service/src/main/java/com/fs/comm/service/CommGatewayBillingService.java

@@ -0,0 +1,156 @@
+package com.fs.comm.service;
+
+import com.fs.comm.domain.CommGatewayApiLog;
+import com.fs.comm.model.CommGatewayBillingQuote;
+import com.fs.common.annotation.DataSource;
+import com.fs.common.enums.DataSourceType;
+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.TenantTrafficPricing;
+import com.fs.proxy.enums.ConsumeTypeEnum;
+import com.fs.proxy.mapper.CompanySmsApiTenantMapper;
+import com.fs.proxy.mapper.TenantTrafficPricingMapper;
+import com.fs.proxy.service.BalanceService;
+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.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 通讯网关计费报价(对齐 BalanceServiceImpl 定价规则)
+ */
+@Slf4j
+@Service
+public class CommGatewayBillingService {
+
+    @Value("${comm.gateway.call-unit-price:0.12}")
+    private BigDecimal defaultCallCalcPrice;
+
+    @Value("${comm.gateway.sms-unit-price:0.05}")
+    private BigDecimal defaultSmsCalcPrice;
+
+    @Autowired
+    private BalanceService balanceService;
+
+    @Autowired
+    private CompanySmsApiTenantMapper smsApiTenantMapper;
+
+    @Autowired
+    private CompanyVoiceApiTenantMapper voiceApiTenantMapper;
+
+    @Autowired
+    private TenantTrafficPricingMapper trafficPricingMapper;
+
+    @DataSource(DataSourceType.MASTER)
+    public CommGatewayBillingQuote resolveQuote(Long tenantId, String apiType, int quantity) {
+        if (quantity <= 0) {
+            quantity = 1;
+        }
+        if (CommGatewayApiLog.API_TYPE_SMS.equals(apiType)) {
+            return buildQuote(resolveSmsUnitPrice(tenantId), quantity);
+        }
+        if (CommGatewayApiLog.API_TYPE_CALL.equals(apiType)) {
+            return buildQuote(resolveCallUnitPrice(tenantId), quantity);
+        }
+        return zeroQuote();
+    }
+
+    private CommGatewayBillingQuote buildQuote(UnitPricePair pair, int quantity) {
+        BigDecimal calcPrice = pair.calcPrice != null ? pair.calcPrice : BigDecimal.ZERO;
+        BigDecimal costPrice = pair.costPrice != null ? pair.costPrice : BigDecimal.ZERO;
+        return CommGatewayBillingQuote.builder()
+                .costPrice(costPrice)
+                .calcPrice(calcPrice)
+                .billingQuantity(quantity)
+                .billingAmount(calcPrice.multiply(BigDecimal.valueOf(quantity)))
+                .build();
+    }
+
+    private CommGatewayBillingQuote zeroQuote() {
+        return CommGatewayBillingQuote.builder()
+                .costPrice(BigDecimal.ZERO)
+                .calcPrice(BigDecimal.ZERO)
+                .billingQuantity(0)
+                .billingAmount(BigDecimal.ZERO)
+                .build();
+    }
+
+    private UnitPricePair resolveSmsUnitPrice(Long tenantId) {
+        ServiceFeeConfig config = balanceService.getFeeConfig(ConsumeTypeEnum.SMS_SEND.getCode());
+        BigDecimal calcPrice = config != null && config.getFeeStandard() != null
+                ? config.getFeeStandard() : defaultSmsCalcPrice;
+        BigDecimal costPrice = config != null && config.getPlatformCost() != null
+                ? config.getPlatformCost() : BigDecimal.ZERO;
+
+        if (tenantId != null) {
+            List<CompanySmsApiTenant> bindings = smsApiTenantMapper.selectByCompanyId(tenantId);
+            if (bindings != null) {
+                for (CompanySmsApiTenant binding : bindings) {
+                    if (!Integer.valueOf(1).equals(binding.getStatus())) {
+                        continue;
+                    }
+                    if (binding.getPrice() != null && binding.getPrice().compareTo(BigDecimal.ZERO) > 0) {
+                        calcPrice = binding.getPrice();
+                    }
+                    if (binding.getCostPrice() != null) {
+                        costPrice = binding.getCostPrice();
+                    }
+                    break;
+                }
+            }
+        }
+        return new UnitPricePair(costPrice, calcPrice);
+    }
+
+    private UnitPricePair resolveCallUnitPrice(Long tenantId) {
+        ServiceFeeConfig config = balanceService.getFeeConfig(ConsumeTypeEnum.AI_CALL.getCode());
+        BigDecimal calcPrice = config != null && config.getFeeStandard() != null
+                ? config.getFeeStandard() : defaultCallCalcPrice;
+        BigDecimal costPrice = config != null && config.getPlatformCost() != null
+                ? config.getPlatformCost() : BigDecimal.ZERO;
+
+        if (tenantId != null) {
+            List<CompanyVoiceApiTenant> voiceBindings = voiceApiTenantMapper.selectEnabledApisByTenantId(tenantId);
+            if (voiceBindings != null && !voiceBindings.isEmpty()) {
+                CompanyVoiceApiTenant voiceBinding = voiceBindings.get(0);
+                if (voiceBinding.getSalePrice() != null && voiceBinding.getSalePrice().compareTo(BigDecimal.ZERO) > 0) {
+                    BigDecimal voicePrice = voiceBinding.getSalePrice();
+                    BigDecimal voiceCost = voiceBinding.getCostPrice() != null ? voiceBinding.getCostPrice() : BigDecimal.ZERO;
+
+                    BigDecimal aiSurcharge = config != null && config.getFeeStandard() != null
+                            ? config.getFeeStandard() : defaultCallCalcPrice;
+                    BigDecimal aiCost = config != null && config.getPlatformCost() != null
+                            ? config.getPlatformCost() : BigDecimal.ZERO;
+
+                    TenantTrafficPricing aiTrafficPricing = trafficPricingMapper
+                            .selectByTenantAndType(tenantId, ConsumeTypeEnum.AI_CALL.getCode());
+                    if (aiTrafficPricing != null && aiTrafficPricing.getPrice() != null
+                            && aiTrafficPricing.getPrice().compareTo(BigDecimal.ZERO) > 0) {
+                        aiSurcharge = aiTrafficPricing.getPrice();
+                        if (aiTrafficPricing.getCostPrice() != null) {
+                            aiCost = aiTrafficPricing.getCostPrice();
+                        }
+                    }
+                    calcPrice = voicePrice.add(aiSurcharge);
+                    costPrice = voiceCost.add(aiCost);
+                }
+            }
+        }
+        return new UnitPricePair(costPrice, calcPrice);
+    }
+
+    private static class UnitPricePair {
+        private final BigDecimal costPrice;
+        private final BigDecimal calcPrice;
+
+        private UnitPricePair(BigDecimal costPrice, BigDecimal calcPrice) {
+            this.costPrice = costPrice;
+            this.calcPrice = calcPrice;
+        }
+    }
+}

+ 114 - 22
fs-service/src/main/java/com/fs/comm/service/CommSmsSendService.java

@@ -7,6 +7,7 @@ import com.fs.common.core.redis.RedisCache;
 import com.fs.common.exception.ServiceException;
 import com.fs.common.exception.ServiceException;
 import com.fs.common.service.ISmsService;
 import com.fs.common.service.ISmsService;
 import com.fs.common.utils.StringUtils;
 import com.fs.common.utils.StringUtils;
+import com.fs.comm.model.CommSmsSendContext;
 import com.fs.comm.model.CommSmsSendParam;
 import com.fs.comm.model.CommSmsSendParam;
 import com.fs.comm.model.CommSmsSendResult;
 import com.fs.comm.model.CommSmsSendResult;
 import com.fs.company.domain.*;
 import com.fs.company.domain.*;
@@ -88,28 +89,10 @@ public class CommSmsSendService {
             throw new ServiceException("被叫人不存在");
             throw new ServiceException("被叫人不存在");
         }
         }
 
 
-        Long companyId = param.getCompanyId();
-        Long companyUserId = param.getCompanyUserId();
-        String senderName = param.getSenderName();
-        if (companyUserId == null || StringUtils.isBlank(senderName)) {
-            CompanyWxClient wxClient = companyWxClientMapper.selectOneByRoboticIdAndUserId(param.getRoboticId(), callees.getUserId());
-            if (wxClient != null) {
-                if (wxClient.getIsWeCom() == 2) {
-                    QwUser qwUser = qwExternalContactService.getQwUserByRedisForId(String.valueOf(wxClient.getAccountId()));
-                    companyId = qwUser.getCompanyId();
-                    companyUserId = qwUser.getCompanyUserId();
-                    senderName = qwUser.getQwUserName();
-                } else {
-                    CompanyWxAccount wxAccount = companyWxAccountService.selectCompanyWxAccountById(wxClient.getAccountId());
-                    companyId = wxAccount.getCompanyId();
-                    companyUserId = wxAccount.getCompanyUserId();
-                    senderName = wxAccount.getWxNickName();
-                }
-            }
-        }
-        if (companyId == null) {
-            companyId = robotic.getCompanyId();
-        }
+        CommSmsSendContext sendContext = resolveSendContext(param, robotic, callees);
+        Long companyId = sendContext.getCompanyId();
+        Long companyUserId = sendContext.getCompanyUserId();
+        String senderName = sendContext.getSenderName();
 
 
         CompanySmsTemp temp = smsTempService.selectCompanySmsTempById(param.getSmsTempId());
         CompanySmsTemp temp = smsTempService.selectCompanySmsTempById(param.getSmsTempId());
         if (temp == null || !Integer.valueOf(1).equals(temp.getStatus()) || !Integer.valueOf(1).equals(temp.getIsAudit())) {
         if (temp == null || !Integer.valueOf(1).equals(temp.getStatus()) || !Integer.valueOf(1).equals(temp.getIsAudit())) {
@@ -188,6 +171,115 @@ public class CommSmsSendService {
                 .build();
                 .build();
     }
     }
 
 
+    /**
+     * 解析短信发送上下文:请求参数 → 微信绑定 → 外呼任务
+     */
+    public CommSmsSendContext resolveSendContext(CommSmsSendParam param) {
+        CompanyVoiceRobotic robotic = companyVoiceRoboticMapper.selectById(param.getRoboticId());
+        if (robotic == null) {
+            throw new ServiceException("外呼任务不存在");
+        }
+        CompanyVoiceRoboticCallees callees = companyVoiceRoboticCalleesMapper.selectById(param.getCalleeId());
+        if (callees == null) {
+            throw new ServiceException("被叫人不存在");
+        }
+        return resolveSendContext(param, robotic, callees);
+    }
+
+    public CommSmsSendContext resolveSendContext(CommSmsSendParam param, CompanyVoiceRobotic robotic,
+                                                 CompanyVoiceRoboticCallees callees) {
+        CommSmsSendContext context = CommSmsSendContext.builder()
+                .companyId(param.getCompanyId())
+                .companyUserId(param.getCompanyUserId())
+                .senderName(param.getSenderName())
+                .build();
+        fillFromWxClient(param.getRoboticId(), callees.getUserId(), context);
+        fillFromRobotic(robotic, context);
+        fillSenderNameIfBlank(context);
+        if (context.getCompanyId() == null) {
+            throw new ServiceException("未获取到公司信息");
+        }
+        return context;
+    }
+
+    public void validateSmsTempAndBalance(Long smsTempId, Long companyId) {
+        CompanySmsTemp temp = smsTempService.selectCompanySmsTempById(smsTempId);
+        if (temp == null || !Integer.valueOf(1).equals(temp.getStatus()) || !Integer.valueOf(1).equals(temp.getIsAudit())) {
+            throw new ServiceException("模板未审核");
+        }
+        CompanySms sms = companySmsService.selectCompanySmsByCompanyId(companyId);
+        if (sms == null) {
+            throw new ServiceException("请充值");
+        }
+        if (sms.getRemainSmsCount() == null || sms.getRemainSmsCount() <= 0) {
+            throw new ServiceException("剩余短信数量不足,请充值");
+        }
+    }
+
+    private void fillFromWxClient(Long roboticId, Long customerUserId, CommSmsSendContext context) {
+        if (context.getCompanyUserId() != null && StringUtils.isNotBlank(context.getSenderName())) {
+            return;
+        }
+        CompanyWxClient wxClient = companyWxClientMapper.selectOneByRoboticIdAndUserId(roboticId, customerUserId);
+        if (wxClient == null) {
+            return;
+        }
+        try {
+            if (wxClient.getIsWeCom() == 2) {
+                QwUser qwUser = qwExternalContactService.getQwUserByRedisForId(String.valueOf(wxClient.getAccountId()));
+                if (qwUser != null) {
+                    if (context.getCompanyId() == null) {
+                        context.setCompanyId(qwUser.getCompanyId());
+                    }
+                    if (context.getCompanyUserId() == null) {
+                        context.setCompanyUserId(qwUser.getCompanyUserId());
+                    }
+                    if (StringUtils.isBlank(context.getSenderName())) {
+                        context.setSenderName(qwUser.getQwUserName());
+                    }
+                }
+            } else {
+                CompanyWxAccount wxAccount = companyWxAccountService.selectCompanyWxAccountById(wxClient.getAccountId());
+                if (wxAccount != null) {
+                    if (context.getCompanyId() == null) {
+                        context.setCompanyId(wxAccount.getCompanyId());
+                    }
+                    if (context.getCompanyUserId() == null) {
+                        context.setCompanyUserId(wxAccount.getCompanyUserId());
+                    }
+                    if (StringUtils.isBlank(context.getSenderName())) {
+                        context.setSenderName(wxAccount.getWxNickName());
+                    }
+                }
+            }
+        } catch (Exception ex) {
+            log.warn("解析微信发送人信息失败 roboticId={}", roboticId, ex);
+        }
+    }
+
+    private void fillFromRobotic(CompanyVoiceRobotic robotic, CommSmsSendContext context) {
+        if (context.getCompanyId() == null) {
+            context.setCompanyId(robotic.getCompanyId());
+        }
+        if (context.getCompanyUserId() == null) {
+            context.setCompanyUserId(robotic.getCompanyUserId());
+        }
+        if (StringUtils.isBlank(context.getSenderName()) && StringUtils.isNotBlank(robotic.getCreateByName())) {
+            context.setSenderName(robotic.getCreateByName());
+        }
+    }
+
+    private void fillSenderNameIfBlank(CommSmsSendContext context) {
+        if (StringUtils.isNotBlank(context.getSenderName()) || context.getCompanyUserId() == null) {
+            return;
+        }
+        CompanyUser companyUser = companyUserService.selectCompanyUserById(context.getCompanyUserId());
+        if (companyUser == null) {
+            return;
+        }
+        context.setSenderName(StringUtils.defaultIfBlank(companyUser.getNickName(), companyUser.getUserName()));
+    }
+
     /**
     /**
      * 已组装好参数的 AI 短信批量发送(WxTask 等场景),统一收口 batchSmsOp4AiSend
      * 已组装好参数的 AI 短信批量发送(WxTask 等场景),统一收口 batchSmsOp4AiSend
      */
      */

+ 23 - 0
fs-service/src/main/java/com/fs/comm/service/CommVoiceConfigMasterService.java

@@ -0,0 +1,23 @@
+package com.fs.comm.service;
+
+import com.fs.common.annotation.DataSource;
+import com.fs.common.enums.DataSourceType;
+import com.fs.company.domain.CompanyVoiceConfig;
+import com.fs.company.mapper.CompanyVoiceConfigMapper;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+/**
+ * 从主库读取呼叫频率配置(与 admin 平台配置一致)
+ */
+@Service
+public class CommVoiceConfigMasterService {
+
+    @Autowired
+    private CompanyVoiceConfigMapper companyVoiceConfigMapper;
+
+    @DataSource(DataSourceType.MASTER)
+    public CompanyVoiceConfig selectByCompanyId(Long companyId) {
+        return companyVoiceConfigMapper.selectCompanyVoiceConfigByCompanyId(companyId);
+    }
+}

+ 183 - 0
fs-service/src/main/java/com/fs/comm/service/CommVoiceLimitService.java

@@ -0,0 +1,183 @@
+package com.fs.comm.service;
+
+import cn.hutool.json.JSONUtil;
+import com.fs.common.exception.ServiceException;
+import com.fs.common.utils.StringUtils;
+import com.fs.comm.support.CommTenantDataSourceHelper;
+import com.fs.company.domain.CompanyVoiceConfig;
+import com.fs.company.domain.CompanyVoiceRoboticCallees;
+import com.fs.company.mapper.CompanyVoiceRoboticCalleesMapper;
+import com.fs.crm.domain.CrmCustomer;
+import com.fs.crm.service.ICrmCustomerService;
+import com.fs.his.utils.PhoneUtil;
+import com.fs.system.config.SystemVoiceConfig;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+/**
+ * 通讯网关外呼/短信频率限制(对齐 company_voice_config 配置,计数走主库 comm_gateway_api_log)
+ */
+@Slf4j
+@Service
+public class CommVoiceLimitService {
+
+    @Autowired
+    private CommVoiceConfigMasterService commVoiceConfigMasterService;
+
+    @Autowired
+    private ICommGatewayApiLogService commGatewayApiLogService;
+
+    @Autowired
+    private CompanyVoiceRoboticCalleesMapper companyVoiceRoboticCalleesMapper;
+
+    @Autowired
+    private ICrmCustomerService crmCustomerService;
+
+    @Autowired
+    private CommTenantDataSourceHelper commTenantDataSourceHelper;
+
+    public void checkCallLimit(Long companyId, Long tenantId, Long calleeId, String phone, Long gatewayId) {
+        if (companyId == null) {
+            return;
+        }
+        String calleePhone = resolveCallCalleePhone(calleeId, phone);
+        String callerKey = buildCompanyCallerKey(companyId, gatewayId);
+        CompanyVoiceConfig config = commVoiceConfigMasterService.selectByCompanyId(companyId);
+        if (config == null) {
+            commTenantDataSourceHelper.ensureTenant(tenantId);
+            return;
+        }
+        applyLimits(companyId, config, callerKey, calleePhone);
+        commTenantDataSourceHelper.ensureTenant(tenantId);
+    }
+
+    public void checkSmsLimit(Long companyId, Long tenantId, Long calleeId, Long customerId, String phone) {
+        if (companyId == null) {
+            return;
+        }
+        String calleePhone = resolveSmsPhone(calleeId, customerId, phone);
+        String callerKey = buildCompanyCallerKey(companyId, null);
+        CompanyVoiceConfig config = commVoiceConfigMasterService.selectByCompanyId(companyId);
+        if (config == null) {
+            commTenantDataSourceHelper.ensureTenant(tenantId);
+            return;
+        }
+        applyLimits(companyId, config, callerKey, calleePhone);
+        commTenantDataSourceHelper.ensureTenant(tenantId);
+    }
+
+    private void applyLimits(Long companyId, CompanyVoiceConfig config, String callerKey, String calleePhone) {
+        SystemVoiceConfig callerConfig = parseConfig(config.getCallerJson());
+        if (callerConfig != null) {
+            checkCallerLimits(companyId, callerKey, callerConfig);
+        }
+        if (StringUtils.isNotBlank(calleePhone)) {
+            SystemVoiceConfig calleeConfig = parseConfig(config.getCalleeJson());
+            if (calleeConfig != null) {
+                checkCalleeLimits(companyId, calleePhone, calleeConfig);
+            }
+        }
+    }
+
+    private void checkCallerLimits(Long companyId, String callerKey, SystemVoiceConfig callerConfig) {
+        checkLimit(companyId, callerKey, true, 1, callerConfig.getCallerMinute(), "主叫分钟限制");
+        checkLimit(companyId, callerKey, true, 2, callerConfig.getCallerHour(), "主叫小时限制");
+        checkLimit(companyId, callerKey, true, 3, callerConfig.getCallerDay(), "主叫日限制");
+        checkLimit(companyId, callerKey, true, 4, callerConfig.getCallerWeek(), "主叫周限制");
+        checkLimit(companyId, callerKey, true, 5, callerConfig.getCallerMonth(), "主叫月限制");
+    }
+
+    private void checkCalleeLimits(Long companyId, String calleePhone, SystemVoiceConfig calleeConfig) {
+        checkLimit(companyId, calleePhone, false, 1, calleeConfig.getCalleeMinute(), "被叫分钟限制");
+        checkLimit(companyId, calleePhone, false, 2, calleeConfig.getCalleeHour(), "被叫小时限制");
+        checkLimit(companyId, calleePhone, false, 3, calleeConfig.getCalleeDay(), "被叫日限制");
+        checkLimit(companyId, calleePhone, false, 4, calleeConfig.getCalleeWeek(), "被叫周限制");
+        checkLimit(companyId, calleePhone, false, 5, calleeConfig.getCalleeMonth(), "被叫月限制");
+    }
+
+    private void checkLimit(Long companyId, String phoneKey, boolean callerSide, int type, Integer max, String message) {
+        if (max == null || max <= 0 || StringUtils.isBlank(phoneKey)) {
+            return;
+        }
+        int current = callerSide
+                ? commGatewayApiLogService.countByCallerPhone(companyId, phoneKey, type)
+                : commGatewayApiLogService.countByCalleePhone(companyId, phoneKey, type);
+        if (current >= max) {
+            throw new ServiceException(message);
+        }
+    }
+
+    public String resolveCallCalleePhone(Long calleeId, String phone) {
+        if (StringUtils.isNotBlank(phone)) {
+            return normalizePhone(PhoneUtil.decryptAutoPhone(phone.trim()));
+        }
+        if (calleeId == null) {
+            return null;
+        }
+        CompanyVoiceRoboticCallees callees = companyVoiceRoboticCalleesMapper.selectById(calleeId);
+        if (callees == null || StringUtils.isBlank(callees.getPhone())) {
+            return null;
+        }
+        return normalizePhone(PhoneUtil.decryptAutoPhone(callees.getPhone()));
+    }
+
+    private String resolveSmsPhone(Long calleeId, Long customerId, String phone) {
+        if (StringUtils.isNotBlank(phone)) {
+            return normalizePhone(PhoneUtil.decryptAutoPhone(phone.trim()));
+        }
+        if (customerId != null) {
+            CrmCustomer customer = crmCustomerService.selectCrmCustomerById(customerId);
+            if (customer != null && StringUtils.isNotBlank(customer.getMobile())) {
+                return normalizePhone(PhoneUtil.decryptAutoPhone(customer.getMobile()));
+            }
+        }
+        if (calleeId == null) {
+            return null;
+        }
+        CompanyVoiceRoboticCallees callees = companyVoiceRoboticCalleesMapper.selectById(calleeId);
+        if (callees == null) {
+            return null;
+        }
+        if (callees.getUserId() != null) {
+            CrmCustomer customer = crmCustomerService.selectCrmCustomerById(callees.getUserId());
+            if (customer != null && StringUtils.isNotBlank(customer.getMobile())) {
+                return normalizePhone(PhoneUtil.decryptAutoPhone(customer.getMobile()));
+            }
+        }
+        return normalizePhone(PhoneUtil.decryptAutoPhone(callees.getPhone()));
+    }
+
+    public String buildCompanyCallerKey(Long companyId, Long gatewayId) {
+        if (gatewayId != null) {
+            return "gw:" + gatewayId;
+        }
+        return "company:" + companyId;
+    }
+
+    public String normalizePhone(String phone) {
+        if (StringUtils.isBlank(phone)) {
+            return phone;
+        }
+        String text = phone.trim();
+        if (text.startsWith("+86")) {
+            return text;
+        }
+        if (text.matches("\\d+")) {
+            return "+86" + text;
+        }
+        return text;
+    }
+
+    private SystemVoiceConfig parseConfig(String json) {
+        if (StringUtils.isBlank(json)) {
+            return null;
+        }
+        try {
+            return JSONUtil.toBean(json, SystemVoiceConfig.class);
+        } catch (Exception ex) {
+            log.warn("解析呼叫频率配置失败: {}", ex.getMessage());
+            return null;
+        }
+    }
+}

+ 18 - 0
fs-service/src/main/java/com/fs/comm/service/ICommGatewayApiLogService.java

@@ -0,0 +1,18 @@
+package com.fs.comm.service;
+
+import com.fs.comm.domain.CommGatewayApiLog;
+
+import java.util.List;
+
+public interface ICommGatewayApiLogService {
+
+    void saveLog(CommGatewayApiLog log);
+
+    CommGatewayApiLog selectById(Long logId);
+
+    List<CommGatewayApiLog> selectList(CommGatewayApiLog query);
+
+    int countByCalleePhone(Long companyId, String calleePhone, Integer type);
+
+    int countByCallerPhone(Long companyId, String callerPhone, Integer type);
+}

+ 36 - 0
fs-service/src/main/java/com/fs/comm/sms/CommSmsChannelRequest.java

@@ -0,0 +1,36 @@
+package com.fs.comm.sms;
+
+import lombok.Builder;
+import lombok.Data;
+
+/**
+ * 短信通道发送请求(迈远等 HTTP 通道共用)
+ */
+@Data
+@Builder
+public class CommSmsChannelRequest {
+
+    /** 目标手机号 */
+    private String phone;
+
+    /** 短信正文(不含签名) */
+    private String content;
+
+    /** 模板类型: 1=行业通知 2=营销短信 */
+    private Integer tempType;
+
+    /** 账户 */
+    private String account;
+
+    /** 密码 */
+    private String password;
+
+    /** 短信签名 */
+    private String sign;
+
+    /** 接口根地址(迈远,需以 / 结尾或不含路径) */
+    private String url;
+
+    /** 扩展码 extno */
+    private String extno;
+}

+ 40 - 0
fs-service/src/main/java/com/fs/comm/sms/CommSmsChannelResult.java

@@ -0,0 +1,40 @@
+package com.fs.comm.sms;
+
+import lombok.Builder;
+import lombok.Data;
+
+/**
+ * 短信通道发送结果
+ */
+@Data
+@Builder
+public class CommSmsChannelResult {
+
+    /** 是否发送成功 */
+    private boolean success;
+
+    /** 平台消息 ID(迈远 mid) */
+    private String mid;
+
+    /** 失败原因码,成功时为 OK */
+    private String errorCode;
+
+    /** 原始响应摘要(便于排查) */
+    private String rawResponse;
+
+    public static CommSmsChannelResult ok(String mid) {
+        return CommSmsChannelResult.builder()
+                .success(true)
+                .mid(mid)
+                .errorCode("OK")
+                .build();
+    }
+
+    public static CommSmsChannelResult fail(String errorCode, String rawResponse) {
+        return CommSmsChannelResult.builder()
+                .success(false)
+                .errorCode(errorCode)
+                .rawResponse(rawResponse)
+                .build();
+    }
+}

+ 12 - 0
fs-service/src/main/java/com/fs/comm/sms/CommSmsProvider.java

@@ -0,0 +1,12 @@
+package com.fs.comm.sms;
+
+/**
+ * 短信通道发送 SPI(由 fs-comm-gateway 等模块提供具体实现)
+ */
+public interface CommSmsProvider {
+
+    /** 服务商标识,如 my / card */
+    String provider();
+
+    CommSmsChannelResult send(CommSmsChannelRequest request);
+}

+ 22 - 0
fs-service/src/main/java/com/fs/comm/support/CommTenantDataSourceHelper.java

@@ -0,0 +1,22 @@
+package com.fs.comm.support;
+
+import com.fs.framework.datasource.TenantDataSourceManager;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+/**
+ * 通讯网关模块专用:主库 @DataSource 调用后会 clear 数据源,需在租户表操作前显式切回租户库
+ */
+@Component
+public class CommTenantDataSourceHelper {
+
+    @Autowired
+    private TenantDataSourceManager tenantDataSourceManager;
+
+    public void ensureTenant(Long tenantId) {
+        if (tenantId == null) {
+            return;
+        }
+        tenantDataSourceManager.ensureSwitchByTenantId(tenantId);
+    }
+}

+ 44 - 3
fs-service/src/main/java/com/fs/common/service/impl/SmsServiceImpl.java

@@ -7,6 +7,9 @@ import com.fs.common.core.domain.R;
 import com.fs.common.core.redis.RedisCache;
 import com.fs.common.core.redis.RedisCache;
 import com.fs.proxy.enums.ConsumeTypeEnum;
 import com.fs.proxy.enums.ConsumeTypeEnum;
 import com.fs.proxy.service.BalanceService;
 import com.fs.proxy.service.BalanceService;
+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.service.ISmsService;
 import com.fs.common.utils.StringUtils;
 import com.fs.common.utils.StringUtils;
 import com.fs.common.vo.SmsNotifyVO;
 import com.fs.common.vo.SmsNotifyVO;
@@ -52,6 +55,7 @@ import org.springframework.transaction.annotation.Transactional;
 import java.io.UnsupportedEncodingException;
 import java.io.UnsupportedEncodingException;
 import java.net.URLEncoder;
 import java.net.URLEncoder;
 import java.text.SimpleDateFormat;
 import java.text.SimpleDateFormat;
+import java.util.Collections;
 import java.util.Date;
 import java.util.Date;
 import java.util.List;
 import java.util.List;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeUnit;
@@ -113,6 +117,9 @@ public class SmsServiceImpl implements ISmsService
     @Autowired
     @Autowired
     private com.fs.proxy.mapper.CompanySmsCardMapper smsCardMapper;
     private com.fs.proxy.mapper.CompanySmsCardMapper smsCardMapper;
 
 
+    @Autowired(required = false)
+    private List<CommSmsProvider> commSmsProviders = Collections.emptyList();
+
     /**
     /**
      * 统一发送方法 - 替代原来6处硬编码的 his.sms 配置读取
      * 统一发送方法 - 替代原来6处硬编码的 his.sms 配置读取
      * 
      * 
@@ -167,10 +174,45 @@ public class SmsServiceImpl implements ISmsService
         }
         }
     }
     }
 
 
-    /** 迈远发送 */
+    /** 迈远发送(优先走 fs-comm-gateway 注册的 CommSmsProvider) */
     private String sendByRf(String phone, String content, Integer tempType,
     private String sendByRf(String phone, String content, Integer tempType,
                              String account, String password, String sign, String url, String extno,
                              String account, String password, String sign, String url, String extno,
                              Long tenantId, Long apiId, Long portId) {
                              Long tenantId, Long apiId, Long portId) {
+        CommSmsProvider myProvider = findCommSmsProvider("my");
+        if (myProvider != null) {
+            CommSmsChannelResult result = myProvider.send(CommSmsChannelRequest.builder()
+                    .phone(phone)
+                    .content(content)
+                    .tempType(tempType)
+                    .account(account)
+                    .password(password)
+                    .sign(sign)
+                    .url(url)
+                    .extno(extno)
+                    .build());
+            if (result.isSuccess()) {
+                return "OK";
+            }
+            return StringUtils.defaultIfBlank(result.getErrorCode(), "SEND_FAILED");
+        }
+        return sendByRfFallback(phone, content, tempType, account, password, sign, url, extno);
+    }
+
+    private CommSmsProvider findCommSmsProvider(String provider) {
+        if (commSmsProviders == null || commSmsProviders.isEmpty()) {
+            return null;
+        }
+        for (CommSmsProvider commSmsProvider : commSmsProviders) {
+            if (provider.equals(commSmsProvider.provider())) {
+                return commSmsProvider;
+            }
+        }
+        return null;
+    }
+
+    /** 非网关进程兜底:本地直连迈远 HTTP */
+    private String sendByRfFallback(String phone, String content, Integer tempType,
+                                    String account, String password, String sign, String url, String extno) {
         String urls;
         String urls;
         try {
         try {
             if (tempType.equals(1)) {
             if (tempType.equals(1)) {
@@ -185,7 +227,7 @@ public class SmsServiceImpl implements ISmsService
                 return "UNSUPPORTED_TEMP_TYPE";
                 return "UNSUPPORTED_TEMP_TYPE";
             }
             }
         } catch (UnsupportedEncodingException e) {
         } catch (UnsupportedEncodingException e) {
-            log.error("sendByRf: URL编码异常", e);
+            log.error("sendByRfFallback: URL编码异常", e);
             return "ENCODE_ERROR";
             return "ENCODE_ERROR";
         }
         }
 
 
@@ -194,7 +236,6 @@ public class SmsServiceImpl implements ISmsService
         if (vo.getStatus().equals(0)) {
         if (vo.getStatus().equals(0)) {
             for (SmsSendItemVO itemVO : vo.getList()) {
             for (SmsSendItemVO itemVO : vo.getList()) {
                 if (itemVO.getResult().equals("0")) {
                 if (itemVO.getResult().equals("0")) {
-                    // 发送成功, 返回OK (调用方负责写日志)
                     return "OK";
                     return "OK";
                 }
                 }
             }
             }

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

@@ -191,6 +191,9 @@ public interface LobsterAuxiliaryMapper {
                         @Param("configKey") String configKey);
                         @Param("configKey") String configKey);
 
 
     /** 兼容旧 jdbcTemplate.queryForList (单参数 companyId) */
     /** 兼容旧 jdbcTemplate.queryForList (单参数 companyId) */
+    List<Map<String, Object>> queryForList(@Param("sql") String sql,
+                                            @Param("customerId") Long customerId, @Param("companyId") Long companyId);
+    /** 兼容旧 jdbcTemplate.queryForList (单参数 companyId) */
     List<Map<String, Object>> queryForList(@Param("sql") String sql,
     List<Map<String, Object>> queryForList(@Param("sql") String sql,
                                             @Param("companyId") Long companyId);
                                             @Param("companyId") Long companyId);
 
 

+ 32 - 18
fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticServiceImpl.java

@@ -13,6 +13,7 @@ import com.fs.aicall.domain.param.CalltaskcreateaiCustomizeDomain;
 import com.fs.aicall.domain.result.CalltaskcreateaiCustomizeResult;
 import com.fs.aicall.domain.result.CalltaskcreateaiCustomizeResult;
 import com.fs.aicall.service.AiCallService;
 import com.fs.aicall.service.AiCallService;
 import com.fs.comm.client.CommGatewayClient;
 import com.fs.comm.client.CommGatewayClient;
+import com.fs.comm.model.CommSmsSendContext;
 import com.fs.comm.model.CommSmsSendParam;
 import com.fs.comm.model.CommSmsSendParam;
 import com.fs.comm.model.CommSmsSendResult;
 import com.fs.comm.model.CommSmsSendResult;
 import com.fs.comm.service.CommSmsSendService;
 import com.fs.comm.service.CommSmsSendService;
@@ -25,6 +26,7 @@ import com.fs.common.core.domain.model.TenantPrincipal;
 import com.fs.common.core.redis.RedisCache;
 import com.fs.common.core.redis.RedisCache;
 import com.fs.common.core.redis.RedisCacheT;
 import com.fs.common.core.redis.RedisCacheT;
 import com.fs.common.exception.CustomException;
 import com.fs.common.exception.CustomException;
+import com.fs.common.exception.ServiceException;
 import com.fs.common.exception.base.BaseException;
 import com.fs.common.exception.base.BaseException;
 import com.fs.common.utils.*;
 import com.fs.common.utils.*;
 import com.fs.common.utils.spring.SpringUtils;
 import com.fs.common.utils.spring.SpringUtils;
@@ -507,32 +509,44 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
                                               ExecutionContext context, Long smsTempId) {
                                               ExecutionContext context, Long smsTempId) {
         CompanyVoiceRobotic robotic = companyVoiceRoboticMapper.selectById(roboticId);
         CompanyVoiceRobotic robotic = companyVoiceRoboticMapper.selectById(roboticId);
         CompanyVoiceRoboticCallees callees = companyVoiceRoboticCalleesMapper.selectById(callerId);
         CompanyVoiceRoboticCallees callees = companyVoiceRoboticCalleesMapper.selectById(callerId);
-        CompanyWxClient wxClient = companyWxClientMapper.selectOneByRoboticIdAndUserId(roboticId, callees.getUserId());
-        Long companyUserId = null;
-        String senderName = null;
-        if (wxClient != null) {
-            if (wxClient.getIsWeCom() == 2) {
-                QwUser qwUserByRedis = qwExternalContactService.getQwUserByRedisForId(String.valueOf(wxClient.getAccountId()));
-                companyUserId = qwUserByRedis.getCompanyUserId();
-                senderName = qwUserByRedis.getQwUserName();
-            } else {
-                CompanyWxAccount wxAccount = companyWxAccountService.selectCompanyWxAccountById(wxClient.getAccountId());
-                companyUserId = wxAccount.getCompanyUserId();
-                senderName = wxAccount.getWxNickName();
-            }
+        if (robotic == null || callees == null) {
+            throw new RuntimeException("外呼任务或被叫人不存在");
+        }
+
+        CommSmsSendParam sendParam = CommSmsSendParam.builder()
+                .roboticId(roboticId)
+                .calleeId(callerId)
+                .smsTempId(smsTempId)
+                .nodeKey(context.getCurrentNodeKey())
+                .workflowInstanceId(context.getWorkflowInstanceId())
+                .customerId(callees.getUserId())
+                .build();
+        CommSmsSendContext sendContext = commSmsSendService.resolveSendContext(sendParam, robotic, callees);
+        try {
+            commSmsSendService.validateSmsTempAndBalance(smsTempId, sendContext.getCompanyId());
+        } catch (ServiceException ex) {
+            log.error("workflowSendSmsOneViaGateway 校验失败 roboticId={}, callerId={}, msg={}",
+                    roboticId, callerId, ex.getMessage());
+            throw new RuntimeException(ex.getMessage());
         }
         }
+
         Map<String, Object> body = new HashMap<>();
         Map<String, Object> body = new HashMap<>();
         body.put("roboticId", roboticId);
         body.put("roboticId", roboticId);
         body.put("calleeId", callerId);
         body.put("calleeId", callerId);
         body.put("smsTempId", smsTempId);
         body.put("smsTempId", smsTempId);
         body.put("nodeKey", context.getCurrentNodeKey());
         body.put("nodeKey", context.getCurrentNodeKey());
         body.put("workflowInstanceId", context.getWorkflowInstanceId());
         body.put("workflowInstanceId", context.getWorkflowInstanceId());
-        body.put("companyUserId", companyUserId);
-        body.put("senderName", senderName);
-        JSONObject result = commGatewayClient.sendSms(TenantHelper.getTenantId(), robotic.getCompanyId(), companyUserId, body);
-        if (result != null && StringUtils.isNotBlank(result.getString("callbackUuid"))) {
-            context.setVariable("smsCallbackUuid", result.getString("callbackUuid"));
+        body.put("companyId", sendContext.getCompanyId());
+        body.put("companyUserId", sendContext.getCompanyUserId());
+        body.put("senderName", sendContext.getSenderName());
+        body.put("customerId", callees.getUserId());
+
+        JSONObject result = commGatewayClient.sendSms(
+                TenantHelper.getTenantId(), sendContext.getCompanyId(), sendContext.getCompanyUserId(), body);
+        if (result == null || StringUtils.isBlank(result.getString("callbackUuid"))) {
+            throw new RuntimeException("通讯网关短信返回异常");
         }
         }
+        context.setVariable("smsCallbackUuid", result.getString("callbackUuid"));
     }
     }
 
 
     private void workflowSendSmsOneLocal(Long roboticId, Long callerId, ExecutionContext context, Long smsTempId) {
     private void workflowSendSmsOneLocal(Long roboticId, Long callerId, ExecutionContext context, Long smsTempId) {

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

@@ -304,7 +304,11 @@ public class AiCallTaskNode extends AbstractWorkflowNode {
         body.put("maxConcurrency", callConfigVo.getMaxConcurrency());
         body.put("maxConcurrency", callConfigVo.getMaxConcurrency());
         body.put("nodeKey", context.getCurrentNodeKey());
         body.put("nodeKey", context.getCurrentNodeKey());
         body.put("workflowInstanceId", context.getWorkflowInstanceId());
         body.put("workflowInstanceId", context.getWorkflowInstanceId());
-        JSONObject gatewayResult = commGatewayClient.sendCall(TenantHelper.getTenantId(), robotic.getCompanyId(), null, body);
+        Long companyUserId = robotic.getCompanyUserId();
+        if (companyUserId != null) {
+            body.put("companyUserId", companyUserId);
+        }
+        JSONObject gatewayResult = commGatewayClient.sendCall(TenantHelper.getTenantId(), robotic.getCompanyId(), companyUserId, body);
         if (gatewayResult == null || StringUtils.isBlank(gatewayResult.getString("callBackUuid"))) {
         if (gatewayResult == null || StringUtils.isBlank(gatewayResult.getString("callBackUuid"))) {
             throw new RuntimeException("通讯网关外呼返回异常");
             throw new RuntimeException("通讯网关外呼返回异常");
         }
         }

+ 4 - 0
fs-service/src/main/java/com/fs/company/service/workflow/DynamicNodeExecutor.java

@@ -1,5 +1,7 @@
 package com.fs.company.service.workflow;
 package com.fs.company.service.workflow;
 
 
+import lombok.Data;
+
 import java.util.Map;
 import java.util.Map;
 
 
 /**
 /**
@@ -35,9 +37,11 @@ public interface DynamicNodeExecutor {
     /**
     /**
      * 执行上下文
      * 执行上下文
      */
      */
+    @Data
     class ExecutionContext {
     class ExecutionContext {
         private Long companyId;
         private Long companyId;
         private Long customerId;
         private Long customerId;
+        private Long instanceId;
         private Long workflowInstanceId;
         private Long workflowInstanceId;
         private Map<String, Object> variables;
         private Map<String, Object> variables;
         private String lastMessage;
         private String lastMessage;

+ 4 - 0
fs-service/src/main/java/com/fs/company/service/workflow/channel/ChannelPluginService.java

@@ -193,6 +193,10 @@ public class ChannelPluginService {
         }
         }
     }
     }
 
 
+    public boolean isPluginEnabled(Long companyId, String channelType) {
+        return false;
+    }
+
     // ════════════════ 状态对象 ════════════════
     // ════════════════ 状态对象 ════════════════
 
 
     public static class PluginStatus {
     public static class PluginStatus {

+ 4 - 0
fs-service/src/main/java/com/fs/company/service/workflow/contact/ContactInfo.java

@@ -1,5 +1,7 @@
 package com.fs.company.service.workflow.contact;
 package com.fs.company.service.workflow.contact;
 
 
+import lombok.Data;
+
 import java.util.Map;
 import java.util.Map;
 
 
 /**
 /**
@@ -15,6 +17,7 @@ import java.util.Map;
  * - name: 联系人名称
  * - name: 联系人名称
  * - extra: 渠道特有字段(如企微的corpId、个微的roboticId等)
  * - extra: 渠道特有字段(如企微的corpId、个微的roboticId等)
  */
  */
+@Data
 public class ContactInfo {
 public class ContactInfo {
 
 
     /** 租户ID */
     /** 租户ID */
@@ -37,6 +40,7 @@ public class ContactInfo {
 
 
     /** 手机号 */
     /** 手机号 */
     private String phone;
     private String phone;
+    private Long companyId;
 
 
     /** 渠道特有字段(不同渠道有不同的扩展信息) */
     /** 渠道特有字段(不同渠道有不同的扩展信息) */
     private Map<String, Object> extra;
     private Map<String, Object> extra;

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

@@ -131,6 +131,11 @@ public class SensitiveWordServiceImpl implements SensitiveWordService {
         return result;
         return result;
     }
     }
 
 
+    @Override
+    public boolean isHighRiskSensitiveWord(String content, Long companyId) {
+        return false;
+    }
+
     private void loadFromOtherSources(List<Map<String, Object>> result) {
     private void loadFromOtherSources(List<Map<String, Object>> result) {
         if (fastGptChatReplaceWordsMapper != null) {
         if (fastGptChatReplaceWordsMapper != null) {
             try {
             try {

+ 36 - 0
fs-service/src/main/resources/db/20250603-comm-gateway-api-log-billing-price.sql

@@ -0,0 +1,36 @@
+-- 通讯网关 API 调用日志:补充成本价、计算价字段(主库存量库执行,幂等)
+SET @dbname = DATABASE();
+SET @tablename = 'comm_gateway_api_log';
+
+SET @columnname = 'cost_price';
+SET @preparedStatement = (SELECT IF(
+  (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
+   WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = @tablename AND COLUMN_NAME = @columnname) > 0,
+  'SELECT 1',
+  'ALTER TABLE comm_gateway_api_log ADD COLUMN `cost_price` DECIMAL(12, 4) DEFAULT 0.0000 COMMENT \"成本价(单价)\" AFTER `billing_amount`'
+));
+PREPARE alterIfNotExists FROM @preparedStatement;
+EXECUTE alterIfNotExists;
+DEALLOCATE PREPARE alterIfNotExists;
+
+SET @columnname = 'calc_price';
+SET @preparedStatement = (SELECT IF(
+  (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
+   WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = @tablename AND COLUMN_NAME = @columnname) > 0,
+  'SELECT 1',
+  'ALTER TABLE comm_gateway_api_log ADD COLUMN `calc_price` DECIMAL(12, 4) DEFAULT 0.0000 COMMENT \"计算价/售价(单价)\" AFTER `cost_price`'
+));
+PREPARE alterIfNotExists FROM @preparedStatement;
+EXECUTE alterIfNotExists;
+DEALLOCATE PREPARE alterIfNotExists;
+
+SET @columnname = 'billing_quantity';
+SET @preparedStatement = (SELECT IF(
+  (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
+   WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = @tablename AND COLUMN_NAME = @columnname) > 0,
+  'SELECT 1',
+  'ALTER TABLE comm_gateway_api_log ADD COLUMN `billing_quantity` INT DEFAULT 1 COMMENT \"计费数量\" AFTER `calc_price`'
+));
+PREPARE alterIfNotExists FROM @preparedStatement;
+EXECUTE alterIfNotExists;
+DEALLOCATE PREPARE alterIfNotExists;

+ 34 - 0
fs-service/src/main/resources/db/20250603-comm-gateway-api-log.sql

@@ -0,0 +1,34 @@
+-- 通讯网关 API 调用日志(主库)
+CREATE TABLE IF NOT EXISTS `comm_gateway_api_log` (
+    `log_id`           BIGINT         NOT NULL AUTO_INCREMENT COMMENT '主键',
+    `tenant_id`        BIGINT         DEFAULT NULL COMMENT '租户ID',
+    `company_id`       BIGINT         DEFAULT NULL COMMENT '公司ID',
+    `company_user_id`  BIGINT         DEFAULT NULL COMMENT '调用人用户ID',
+    `caller_account`   VARCHAR(100)   DEFAULT NULL COMMENT '调用人账号',
+    `api_type`         VARCHAR(20)    NOT NULL COMMENT '接口类型 call/sms',
+    `api_path`         VARCHAR(200)   DEFAULT NULL COMMENT '请求路径',
+    `request_body`     MEDIUMTEXT     COMMENT '请求参数',
+    `response_body`    MEDIUMTEXT     COMMENT '返回参数',
+    `result_code`      INT            DEFAULT NULL COMMENT '业务code',
+    `result_msg`       VARCHAR(500)   DEFAULT NULL COMMENT '结果说明',
+    `success`          TINYINT(1)     DEFAULT 0 COMMENT '是否成功 0否1是',
+    `limit_hit`        TINYINT(1)     DEFAULT 0 COMMENT '是否触发频率限制 0否1是',
+    `limit_reason`     VARCHAR(200)   DEFAULT NULL COMMENT '限制原因',
+    `callee_phone`     VARCHAR(32)    DEFAULT NULL COMMENT '被叫号码',
+    `caller_phone`     VARCHAR(32)    DEFAULT NULL COMMENT '主叫号码/线路标识',
+    `gateway_id`       BIGINT         DEFAULT NULL COMMENT '外呼线路ID',
+    `billing_amount`   DECIMAL(12, 4) DEFAULT 0.0000 COMMENT '计费金额',
+    `cost_price`       DECIMAL(12, 4) DEFAULT 0.0000 COMMENT '成本价(单价)',
+    `calc_price`       DECIMAL(12, 4) DEFAULT 0.0000 COMMENT '计算价/售价(单价)',
+    `billing_quantity` INT            DEFAULT 1 COMMENT '计费数量',
+    `billing_unit`     VARCHAR(20)    DEFAULT NULL COMMENT '计费单位 call/sms',
+    `client_ip`        VARCHAR(64)    DEFAULT NULL COMMENT '客户端IP',
+    `auth_scope`       VARCHAR(20)    DEFAULT NULL COMMENT '鉴权范围 external/internal',
+    `duration_ms`      INT            DEFAULT NULL COMMENT '耗时毫秒',
+    `create_time`      DATETIME       DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    PRIMARY KEY (`log_id`),
+    KEY `idx_company_callee_time` (`company_id`, `callee_phone`, `create_time`),
+    KEY `idx_company_caller_time` (`company_id`, `caller_phone`, `create_time`),
+    KEY `idx_company_call_time` (`company_id`, `api_type`, `create_time`),
+    KEY `idx_tenant_time` (`tenant_id`, `create_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='通讯网关API调用日志';

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

@@ -0,0 +1,120 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.fs.comm.mapper.CommGatewayApiLogMapper">
+
+    <resultMap id="CommGatewayApiLogResult" type="com.fs.comm.domain.CommGatewayApiLog">
+        <result property="logId" column="log_id"/>
+        <result property="tenantId" column="tenant_id"/>
+        <result property="companyId" column="company_id"/>
+        <result property="companyUserId" column="company_user_id"/>
+        <result property="callerAccount" column="caller_account"/>
+        <result property="apiType" column="api_type"/>
+        <result property="apiPath" column="api_path"/>
+        <result property="requestBody" column="request_body"/>
+        <result property="responseBody" column="response_body"/>
+        <result property="resultCode" column="result_code"/>
+        <result property="resultMsg" column="result_msg"/>
+        <result property="success" column="success"/>
+        <result property="limitHit" column="limit_hit"/>
+        <result property="limitReason" column="limit_reason"/>
+        <result property="calleePhone" column="callee_phone"/>
+        <result property="callerPhone" column="caller_phone"/>
+        <result property="gatewayId" column="gateway_id"/>
+        <result property="billingAmount" column="billing_amount"/>
+        <result property="costPrice" column="cost_price"/>
+        <result property="calcPrice" column="calc_price"/>
+        <result property="billingQuantity" column="billing_quantity"/>
+        <result property="billingUnit" column="billing_unit"/>
+        <result property="clientIp" column="client_ip"/>
+        <result property="authScope" column="auth_scope"/>
+        <result property="durationMs" column="duration_ms"/>
+        <result property="createTime" column="create_time"/>
+        <result property="companyName" column="company_name"/>
+    </resultMap>
+
+    <sql id="selectCommGatewayApiLogVo">
+        select l.log_id, l.tenant_id, l.company_id, l.company_user_id, l.caller_account,
+               l.api_type, l.api_path, l.request_body, l.response_body, l.result_code, l.result_msg,
+               l.success, l.limit_hit, l.limit_reason, l.callee_phone, l.caller_phone, l.gateway_id,
+               l.billing_amount, l.cost_price, l.calc_price, l.billing_quantity, l.billing_unit,
+               l.client_ip, l.auth_scope, l.duration_ms, l.create_time,
+               c.company_name
+        from comm_gateway_api_log l
+        left join company c on c.company_id = l.company_id
+    </sql>
+
+    <select id="selectCommGatewayApiLogList" resultMap="CommGatewayApiLogResult">
+        <include refid="selectCommGatewayApiLogVo"/>
+        <where>
+            <if test="tenantId != null">and l.tenant_id = #{tenantId}</if>
+            <if test="companyId != null">and l.company_id = #{companyId}</if>
+            <if test="apiType != null and apiType != ''">and l.api_type = #{apiType}</if>
+            <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="callerAccount != null and callerAccount != ''">and l.caller_account like concat('%', #{callerAccount}, '%')</if>
+            <if test="params.beginTime != null and params.beginTime != ''">
+                and l.create_time &gt;= #{params.beginTime}
+            </if>
+            <if test="params.endTime != null and params.endTime != ''">
+                and l.create_time &lt;= #{params.endTime}
+            </if>
+        </where>
+        order by l.log_id desc
+    </select>
+
+    <select id="selectCommGatewayApiLogById" resultMap="CommGatewayApiLogResult">
+        <include refid="selectCommGatewayApiLogVo"/>
+        where l.log_id = #{logId}
+    </select>
+
+    <insert id="insertCommGatewayApiLog" useGeneratedKeys="true" keyProperty="logId">
+        insert into comm_gateway_api_log
+        (tenant_id, company_id, company_user_id, caller_account, api_type, api_path,
+         request_body, response_body, result_code, result_msg, success, limit_hit, limit_reason,
+         callee_phone, caller_phone, gateway_id, billing_amount, cost_price, calc_price, billing_quantity,
+         billing_unit, client_ip, auth_scope, duration_ms, create_time)
+        values
+        (#{tenantId}, #{companyId}, #{companyUserId}, #{callerAccount}, #{apiType}, #{apiPath},
+         #{requestBody}, #{responseBody}, #{resultCode}, #{resultMsg}, #{success}, #{limitHit}, #{limitReason},
+         #{calleePhone}, #{callerPhone}, #{gatewayId}, #{billingAmount}, #{costPrice}, #{calcPrice}, #{billingQuantity},
+         #{billingUnit}, #{clientIp}, #{authScope}, #{durationMs}, #{createTime})
+    </insert>
+
+    <select id="countByCalleePhone" resultType="java.lang.Integer">
+        select count(1) from comm_gateway_api_log
+        where company_id = #{companyId}
+          and callee_phone = #{calleePhone}
+          and limit_hit = 0
+        <if test="type != null and type == 1">and timestampdiff(MINUTE, create_time, now()) &lt; 1</if>
+        <if test="type != null and type == 2">and timestampdiff(HOUR, create_time, now()) &lt; 1</if>
+        <if test="type != null and type == 3">and timestampdiff(DAY, create_time, now()) &lt; 1</if>
+        <if test="type != null and type == 4">and timestampdiff(WEEK, create_time, now()) &lt; 1</if>
+        <if test="type != null and type == 5">and timestampdiff(MONTH, create_time, now()) &lt; 1</if>
+    </select>
+
+    <select id="countByCallerPhone" resultType="java.lang.Integer">
+        select count(1) from comm_gateway_api_log
+        where company_id = #{companyId}
+          and caller_phone = #{callerPhone}
+          and limit_hit = 0
+        <if test="type != null and type == 1">and timestampdiff(MINUTE, create_time, now()) &lt; 1</if>
+        <if test="type != null and type == 2">and timestampdiff(HOUR, create_time, now()) &lt; 1</if>
+        <if test="type != null and type == 3">and timestampdiff(DAY, create_time, now()) &lt; 1</if>
+        <if test="type != null and type == 4">and timestampdiff(WEEK, create_time, now()) &lt; 1</if>
+        <if test="type != null and type == 5">and timestampdiff(MONTH, create_time, now()) &lt; 1</if>
+    </select>
+
+    <select id="countByCompanyCall" resultType="java.lang.Integer">
+        select count(1) from comm_gateway_api_log
+        where company_id = #{companyId}
+          and api_type = #{apiType}
+          and limit_hit = 0
+        <if test="type != null and type == 1">and timestampdiff(MINUTE, create_time, now()) &lt; 1</if>
+        <if test="type != null and type == 2">and timestampdiff(HOUR, create_time, now()) &lt; 1</if>
+        <if test="type != null and type == 3">and timestampdiff(DAY, create_time, now()) &lt; 1</if>
+        <if test="type != null and type == 4">and timestampdiff(WEEK, create_time, now()) &lt; 1</if>
+        <if test="type != null and type == 5">and timestampdiff(MONTH, create_time, now()) &lt; 1</if>
+    </select>
+
+</mapper>