Explorar o código

修复编码问题,加上外呼独立模块的一些功能

吴树波 hai 4 días
pai
achega
eb87be6566

+ 0 - 16
.codegraph/.gitignore

@@ -1,16 +0,0 @@
-# CodeGraph data files
-# These are local to each machine and should not be committed
-
-# Database
-*.db
-*.db-wal
-*.db-shm
-
-# Cache
-cache/
-
-# Logs
-*.log
-
-# Hook markers
-.dirty

+ 51 - 270
fs-comm-gateway/src/main/java/com/fs/comm/service/CommGatewayApiLogRecorder.java

@@ -1,461 +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.beans.factory.annotation.Value;
-
 import org.springframework.stereotype.Service;
 
-
-
 import java.math.BigDecimal;
-
 import java.util.HashMap;
-
 import java.util.Map;
 
-
-
 /**
-
  * 通讯网关 API 调用日志记录(主库)
-
  */
-
 @Slf4j
-
 @Service
-
 public class CommGatewayApiLogRecorder {
 
-
-
-    @Value("${comm.gateway.call-unit-price:0.12}")
-
-    private BigDecimal callUnitPrice;
-
-
-
-    @Value("${comm.gateway.sms-unit-price:0.05}")
-
-    private BigDecimal smsUnitPrice;
-
-
-
     @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 log = new CommGatewayApiLog();
-
-        log.setTenantId(tenantId);
-
-        log.setCompanyId(companyId);
-
+        CommGatewayApiLog logEntity = new CommGatewayApiLog();
+        logEntity.setTenantId(tenantId);
+        logEntity.setCompanyId(companyId);
         if (session != null) {
-
-            log.setCompanyUserId(session.getCompanyUserId());
-
-            log.setCallerAccount(session.getAccount());
-
-            log.setAuthScope(session.getScope());
-
+            logEntity.setCompanyUserId(session.getCompanyUserId());
+            logEntity.setCallerAccount(session.getAccount());
+            logEntity.setAuthScope(session.getScope());
         }
-
-        fillCompanyUserIdFromRequest(log, request);
-
-        log.setApiType(apiType);
-
-        log.setApiPath(apiPath);
-
-        log.setRequestBody(JSON.toJSONString(request));
-
-        log.setDurationMs((int) (System.currentTimeMillis() - startMs));
-
+        fillCompanyUserIdFromRequest(logEntity, request);
+        logEntity.setApiType(apiType);
+        logEntity.setApiPath(apiPath);
+        logEntity.setRequestBody(JSON.toJSONString(request));
+        logEntity.setDurationMs((int) (System.currentTimeMillis() - startMs));
         try {
-
-            log.setClientIp(IpUtils.getIpAddr(ServletUtils.getRequest()));
-
+            logEntity.setClientIp(IpUtils.getIpAddr(ServletUtils.getRequest()));
         } catch (Exception ignored) {
-
         }
 
-
-
         if (result != null) {
-
-            log.setResponseBody(result.getResponseBody());
-
-            log.setResultCode(result.getResultCode());
-
-            log.setResultMsg(result.getResultMsg());
-
-            log.setSuccess(result.isSuccess() ? 1 : 0);
-
-            log.setLimitHit(result.isLimitHit() ? 1 : 0);
-
-            log.setLimitReason(result.getLimitReason());
-
-            log.setCalleePhone(result.getCalleePhone());
-
-            log.setCallerPhone(result.getCallerPhone());
-
-            log.setGatewayId(result.getGatewayId());
-
-            if (result.isSuccess()) {
-
-                log.setBillingAmount(CommGatewayApiLog.API_TYPE_CALL.equals(apiType) ? callUnitPrice : smsUnitPrice);
-
-                log.setBillingUnit(apiType);
-
-            } else {
-
-                log.setBillingAmount(BigDecimal.ZERO);
-
-            }
-
+            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(log, calleePhone, callerPhone, gatewayId, "调用未正常返回结果");
-
+            applyFallbackFields(logEntity, calleePhone, callerPhone, gatewayId, "调用未正常返回结果");
         }
 
-
-
-        if (StringUtils.isBlank(log.getCalleePhone()) && StringUtils.isNotBlank(calleePhone)) {
-
-            log.setCalleePhone(calleePhone);
-
+        if (StringUtils.isBlank(logEntity.getCalleePhone()) && StringUtils.isNotBlank(calleePhone)) {
+            logEntity.setCalleePhone(calleePhone);
         }
-
-        if (StringUtils.isBlank(log.getCallerPhone()) && StringUtils.isNotBlank(callerPhone)) {
-
-            log.setCallerPhone(callerPhone);
-
+        if (StringUtils.isBlank(logEntity.getCallerPhone()) && StringUtils.isNotBlank(callerPhone)) {
+            logEntity.setCallerPhone(callerPhone);
         }
-
-        if (log.getGatewayId() == null && gatewayId != null) {
-
-            log.setGatewayId(gatewayId);
-
+        if (logEntity.getGatewayId() == null && gatewayId != null) {
+            logEntity.setGatewayId(gatewayId);
         }
-
-        return log;
-
+        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; }
-
         }
-
     }
-
 }
-
-

+ 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 查询外呼记录

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

@@ -38,6 +38,12 @@ public class CommGatewayApiLog extends BaseEntity {
     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;

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

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

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

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

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

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

@@ -18,6 +18,9 @@ CREATE TABLE IF NOT EXISTS `comm_gateway_api_log` (
     `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',

+ 9 - 5
fs-service/src/main/resources/mapper/comm/CommGatewayApiLogMapper.xml

@@ -21,6 +21,9 @@
         <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"/>
@@ -33,7 +36,8 @@
         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.billing_unit, l.client_ip, l.auth_scope, l.duration_ms, l.create_time,
+               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
@@ -68,13 +72,13 @@
         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, billing_unit, client_ip,
-         auth_scope, duration_ms, create_time)
+         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}, #{billingUnit}, #{clientIp},
-         #{authScope}, #{durationMs}, #{createTime})
+         #{calleePhone}, #{callerPhone}, #{gatewayId}, #{billingAmount}, #{costPrice}, #{calcPrice}, #{billingQuantity},
+         #{billingUnit}, #{clientIp}, #{authScope}, #{durationMs}, #{createTime})
     </insert>
 
     <select id="countByCalleePhone" resultType="java.lang.Integer">