Преглед изворни кода

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

云联一号 пре 4 дана
родитељ
комит
f953bd0ae9
36 измењених фајлова са 1823 додато и 103 уклоњено
  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:
         # 地址
-        #host: localhost
-        host: 172.27.0.7
+        host: localhost
+#        host: 172.27.0.7
         # 端口,默认为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 Long companyUserId;
+
     private Long calleeId;
 
     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 companyId;
+
     private String nodeKey;
 
     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.CommCallSendResult;
 import com.fs.comm.ratelimit.CommRateLimitService;
+import com.fs.comm.support.CommTenantDataSourceHelper;
 import com.fs.common.exception.ServiceException;
 import com.fs.common.utils.StringUtils;
 import lombok.extern.slf4j.Slf4j;
@@ -31,38 +32,83 @@ public class CommCallService {
     @Autowired
     private CommMetricsService commMetricsService;
 
+    @Autowired
+    private CommVoiceLimitService commVoiceLimitService;
+
+    @Autowired
+    private CommGatewayApiLogRecorder commGatewayApiLogRecorder;
+
+    @Autowired
+    private CommTenantDataSourceHelper commTenantDataSourceHelper;
+
     public Map<String, Object> sendCall(CommCallSendRequest request) {
+        long startMs = System.currentTimeMillis();
         Long companyId = CommAuthContext.getCompanyId();
         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.JSONObject;
+import com.fs.comm.support.CommTenantDataSourceHelper;
 import com.fs.common.exception.ServiceException;
 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.vo.easycall.EasyCallGatewayVO;
 import com.fs.system.service.ISysConfigService;
@@ -27,18 +28,22 @@ public class CommGatewayLineAuthService {
     private IEasyCallService easyCallService;
 
     @Autowired
-    private CompanyMapper companyMapper;
+    private CompanyBindGatewayMapper companyBindGatewayMapper;
 
     @Autowired
     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) {
             throw new ServiceException("gatewayId不能为空");
         }
+        commTenantDataSourceHelper.ensureTenant(tenantId);
         List<EasyCallGatewayVO> allowed = easyCallService.getGatewayList(companyId);
         if (allowed == null || allowed.isEmpty()) {
-            validateByConfigOnly(companyId, gatewayId);
+            validateByConfigOnly(companyId, tenantId, gatewayId);
             return;
         }
         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)) {
             List<Long> ids = Arrays.stream(gateWayList.split(","))
                     .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.CommSmsSendResult;
 import com.fs.comm.ratelimit.CommRateLimitService;
+import com.fs.comm.support.CommTenantDataSourceHelper;
 import com.fs.common.exception.ServiceException;
+import com.fs.common.utils.StringUtils;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
@@ -27,30 +29,72 @@ public class CommSmsService {
     @Autowired
     private CommMetricsService commMetricsService;
 
+    @Autowired
+    private CommVoiceLimitService commVoiceLimitService;
+
+    @Autowired
+    private CommGatewayApiLogRecorder commGatewayApiLogRecorder;
+
+    @Autowired
+    private CommTenantDataSourceHelper commTenantDataSourceHelper;
+
     public Map<String, Object> sendSms(CommSmsSendRequest request) {
+        long startMs = System.currentTimeMillis();
         Long companyId = CommAuthContext.getCompanyId();
         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 失败)。
 
+**迈远(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 查询外呼记录

+ 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.service.ISmsService;
 import com.fs.common.utils.StringUtils;
+import com.fs.comm.model.CommSmsSendContext;
 import com.fs.comm.model.CommSmsSendParam;
 import com.fs.comm.model.CommSmsSendResult;
 import com.fs.company.domain.*;
@@ -88,28 +89,10 @@ public class CommSmsSendService {
             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());
         if (temp == null || !Integer.valueOf(1).equals(temp.getStatus()) || !Integer.valueOf(1).equals(temp.getIsAudit())) {
@@ -188,6 +171,115 @@ public class CommSmsSendService {
                 .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
      */

+ 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.proxy.enums.ConsumeTypeEnum;
 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.utils.StringUtils;
 import com.fs.common.vo.SmsNotifyVO;
@@ -52,6 +55,7 @@ import org.springframework.transaction.annotation.Transactional;
 import java.io.UnsupportedEncodingException;
 import java.net.URLEncoder;
 import java.text.SimpleDateFormat;
+import java.util.Collections;
 import java.util.Date;
 import java.util.List;
 import java.util.concurrent.TimeUnit;
@@ -113,6 +117,9 @@ public class SmsServiceImpl implements ISmsService
     @Autowired
     private com.fs.proxy.mapper.CompanySmsCardMapper smsCardMapper;
 
+    @Autowired(required = false)
+    private List<CommSmsProvider> commSmsProviders = Collections.emptyList();
+
     /**
      * 统一发送方法 - 替代原来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,
                              String account, String password, String sign, String url, String extno,
                              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;
         try {
             if (tempType.equals(1)) {
@@ -185,7 +227,7 @@ public class SmsServiceImpl implements ISmsService
                 return "UNSUPPORTED_TEMP_TYPE";
             }
         } catch (UnsupportedEncodingException e) {
-            log.error("sendByRf: URL编码异常", e);
+            log.error("sendByRfFallback: URL编码异常", e);
             return "ENCODE_ERROR";
         }
 
@@ -194,7 +236,6 @@ public class SmsServiceImpl implements ISmsService
         if (vo.getStatus().equals(0)) {
             for (SmsSendItemVO itemVO : vo.getList()) {
                 if (itemVO.getResult().equals("0")) {
-                    // 发送成功, 返回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);
 
     /** 兼容旧 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,
                                             @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.service.AiCallService;
 import com.fs.comm.client.CommGatewayClient;
+import com.fs.comm.model.CommSmsSendContext;
 import com.fs.comm.model.CommSmsSendParam;
 import com.fs.comm.model.CommSmsSendResult;
 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.RedisCacheT;
 import com.fs.common.exception.CustomException;
+import com.fs.common.exception.ServiceException;
 import com.fs.common.exception.base.BaseException;
 import com.fs.common.utils.*;
 import com.fs.common.utils.spring.SpringUtils;
@@ -507,32 +509,44 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
                                               ExecutionContext context, Long smsTempId) {
         CompanyVoiceRobotic robotic = companyVoiceRoboticMapper.selectById(roboticId);
         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<>();
         body.put("roboticId", roboticId);
         body.put("calleeId", callerId);
         body.put("smsTempId", smsTempId);
         body.put("nodeKey", context.getCurrentNodeKey());
         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) {

+ 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("nodeKey", context.getCurrentNodeKey());
         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"))) {
             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;
 
+import lombok.Data;
+
 import java.util.Map;
 
 /**
@@ -35,9 +37,11 @@ public interface DynamicNodeExecutor {
     /**
      * 执行上下文
      */
+    @Data
     class ExecutionContext {
         private Long companyId;
         private Long customerId;
+        private Long instanceId;
         private Long workflowInstanceId;
         private Map<String, Object> variables;
         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 {

+ 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;
 
+import lombok.Data;
+
 import java.util.Map;
 
 /**
@@ -15,6 +17,7 @@ import java.util.Map;
  * - name: 联系人名称
  * - extra: 渠道特有字段(如企微的corpId、个微的roboticId等)
  */
+@Data
 public class ContactInfo {
 
     /** 租户ID */
@@ -37,6 +40,7 @@ public class ContactInfo {
 
     /** 手机号 */
     private String phone;
+    private Long companyId;
 
     /** 渠道特有字段(不同渠道有不同的扩展信息) */
     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;
     }
 
+    @Override
+    public boolean isHighRiskSensitiveWord(String content, Long companyId) {
+        return false;
+    }
+
     private void loadFromOtherSources(List<Map<String, Object>> result) {
         if (fastGptChatReplaceWordsMapper != null) {
             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>