Explorar o código

加微,外呼

吴树波 hai 3 horas
pai
achega
302aa96e0b

+ 5 - 0
fs-admin/src/main/java/com/fs/admin/controller/CompanyAdminController.java

@@ -13,6 +13,7 @@ import com.fs.framework.datasource.DynamicDataSourceContextHolder;
 import com.fs.framework.datasource.TenantDataSourceContextHelper;
 import com.fs.framework.datasource.TenantDataSourceManager;
 import com.fs.proxy.domain.ProxyTenantRel;
+import com.fs.proxy.service.BalanceService;
 import com.fs.proxy.service.ProxyTenantRelService;
 import com.fs.tenant.domain.TenantInfo;
 import com.fs.tenant.mapper.TenantInfoMapper;
@@ -49,6 +50,9 @@ public class CompanyAdminController extends BaseController {
     @Autowired(required = false)
     private ProxyTenantRelService proxyTenantRelService;
 
+    @Autowired
+    private BalanceService balanceService;
+
     /**
      * 查询所有租户列表
      */
@@ -231,6 +235,7 @@ public class CompanyAdminController extends BaseController {
 
         int rows = tenantInfoService.updateBalance(Long.valueOf(id), delta);
         if (rows > 0) {
+            balanceService.syncBillingBalanceFromTenantInfo(Long.valueOf(id));
             return AjaxResult.success("recharge".equals(operateType) ? "充值成功" : "扣款成功");
         }
         return AjaxResult.error("recharge".equals(operateType) ? "充值失败,租户不存在" : "扣款失败,余额不足或租户不存在");

+ 18 - 0
fs-cid-workflow/src/main/resources/application.yml

@@ -0,0 +1,18 @@
+server:
+  port: 8016
+
+token:
+  header: Authorization
+  secret: YlrzSaas2026CommGatewaySecKey!@#QwErTyUiOp
+  expireTime: 120
+saas:
+  task:
+    enabled: true
+tenant-service-marker: wxTask00
+cid-group-no: 1
+spring:
+  main:
+    allow-bean-definition-overriding: true
+  profiles:
+    active: dev
+    include: common,config-dev

+ 30 - 0
fs-comm-gateway/src/main/resources/application.yml

@@ -0,0 +1,30 @@
+server:
+  port: 8010
+
+token:
+  header: Authorization
+  secret: YlrzSaas2026CommGatewaySecKey!@#QwErTyUiOp
+  expireTime: 120
+
+comm:
+  gateway:
+    internal-secret: CommGatewayInternal2026!@#
+    tenant-qps-limit: 200
+    call-unit-price: 0.12
+    sms-unit-price: 0.05
+    easycall-callback-url: http://127.0.0.1:8010/comm/callback/easycall
+    executor:
+      core-pool-size: 20
+      max-pool-size: 100
+      queue-capacity: 2000
+
+spring:
+  main:
+    allow-bean-definition-overriding: true
+  profiles:
+    active: dev
+    include: common,config-dev
+
+easycall:
+  base-url: http://129.28.164.235:8899
+  callback-url: http://p7229c6f.natappfree.cc/comm/callback/easycall

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

@@ -1,5 +1,6 @@
 package com.fs.comm.service;
 
+import com.fs.common.exception.ServiceException;
 import com.fs.comm.model.CallBalanceDeductionResult;
 import com.fs.comm.support.CompanySmsMasterDataSourceHelper;
 import com.fs.common.utils.StringUtils;
@@ -34,6 +35,23 @@ public class CallBalanceDeductionService {
     /**
      * 发起外呼前校验:至少能支付 1 分钟(主库查余额后自动切回租户库)
      */
+    public void assertBalanceForOneMinute(Long tenantId) {
+        if (tenantId == null) {
+            throw new ServiceException("租户信息缺失,无法发起外呼");
+        }
+        masterDataSourceHelper.runOnMasterThenRestoreTenant(tenantId, () -> {
+            if (Boolean.TRUE.equals(balanceService.checkPayAsYouGoBalance(tenantId, ConsumeTypeEnum.AI_CALL, 1))) {
+                return null;
+            }
+            String reason = balanceService.explainPayAsYouGoBalanceFailure(tenantId, ConsumeTypeEnum.AI_CALL, 1);
+            throw new ServiceException(StringUtils.defaultIfBlank(reason, "租户余额不足,无法发起外呼"));
+        });
+    }
+
+    /**
+     * @deprecated 请使用 {@link #assertBalanceForOneMinute(Long)} 获取明确失败原因
+     */
+    @Deprecated
     public boolean hasBalanceForOneMinute(Long tenantId) {
         if (tenantId == null) {
             return false;

+ 6 - 5
fs-service/src/main/java/com/fs/comm/service/CommCallSendService.java

@@ -114,9 +114,7 @@ public class CommCallSendService {
         if (tenantId == null) {
             throw new ServiceException("租户信息缺失,无法发起外呼");
         }
-        if (!callBalanceDeductionService.hasBalanceForOneMinute(tenantId)) {
-            throw new ServiceException("租户余额不足,无法发起外呼");
-        }
+        callBalanceDeductionService.assertBalanceForOneMinute(tenantId);
 
         String phoneNum = resolveCalleePhone(param, callees);
         if (StringUtils.isBlank(phoneNum)) {
@@ -151,13 +149,16 @@ public class CommCallSendService {
         try {
             boolean added = easyCallService.addCommonCallList(addListParam, companyId, param.getGatewayId());
             if (!added) {
-                throw new ServiceException("外呼名单追加失败或线路限流");
+                throw new ServiceException("外呼名单追加失败:线路限流,已加入重试队列");
             }
+            easyCallService.startTask(batchId, null);
         } catch (ServiceException ex) {
             redisCache.deleteObject(EASYCALL_WORKFLOW_REDIS_KEY + callBackUuid);
             throw ex;
+        } catch (RuntimeException ex) {
+            redisCache.deleteObject(EASYCALL_WORKFLOW_REDIS_KEY + callBackUuid);
+            throw new ServiceException(StringUtils.defaultIfBlank(ex.getMessage(), "外呼任务启动失败"));
         }
-        easyCallService.startTask(batchId, null);
 
         JSONObject runParam = (JSONObject) JSON.toJSON(addListParam);
         runParam.put("companyId", companyId);

+ 3 - 1
fs-service/src/main/java/com/fs/company/service/easycall/EasyCallServiceImpl.java

@@ -316,9 +316,11 @@ public class EasyCallServiceImpl implements IEasyCallService {
             return success;
         } catch (ServiceException e) {
             throw e;
+        } catch (RuntimeException e) {
+            throw new ServiceException(StringUtils.defaultIfBlank(e.getMessage(), "外呼接口调用失败"));
         } catch (Exception e) {
             log.error("addCommonCallList: 外呼接口调用异常 - companyId: {}, gatewayId: {}", companyId, gatewayId, e);
-            return false;
+            throw new ServiceException("外呼接口调用失败: " + e.getMessage());
         }
     }
 

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

@@ -2,6 +2,7 @@ package com.fs.company.service.impl.call.node;
 
 import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONObject;
+import com.fs.common.exception.ServiceException;
 import com.fs.common.utils.StringUtils;
 import com.fs.common.utils.spring.SpringUtils;
 import com.fs.company.domain.*;
@@ -284,12 +285,14 @@ public class AiCallTaskNode extends AbstractWorkflowNode {
                 return;
             } catch (CommBlacklistRejectException ex) {
                 throw ex;
+            } catch (ServiceException ex) {
+                throw ex;
             } catch (Exception ex) {
                 log.error("workflowCallPhoneOne4EasyCall 通讯网关调用失败,roboticId={}, calleeId={}", roboticId, calleeId, ex);
                 if (!commGatewayClient.isFallbackLocal()) {
                     throw ex instanceof RuntimeException ? (RuntimeException) ex : new RuntimeException(ex);
                 }
-                log.warn("workflowCallPhoneOne4EasyCall 降级为本地外呼");
+                log.warn("workflowCallPhoneOne4EasyCall 降级为本地外呼,原因: {}", ex.getMessage());
             }
         }
         workflowCallPhoneOne4EasyCallLocal(roboticId, calleeId, context, callConfigVo);

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

@@ -39,6 +39,12 @@ public final class WorkflowExecErrorMessages {
             String message = root.getMessage();
             return StringUtils.isNotBlank(message) ? message : "业务处理失败";
         }
+        if (root instanceof RuntimeException) {
+            String message = root.getMessage();
+            if (StringUtils.isNotBlank(message) && message.contains("EasyCallCenter365")) {
+                return message;
+            }
+        }
         return SYSTEM_ERROR_DISPLAY_MSG;
     }
 

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

@@ -78,6 +78,16 @@ public interface BalanceService {
      */
     boolean checkPayAsYouGoBalance(Long tenantId, ConsumeTypeEnum consumeType, Integer quantity);
 
+    /**
+     * 按量余额不足时的可读原因(充足时返回 null)
+     */
+    String explainPayAsYouGoBalanceFailure(Long tenantId, ConsumeTypeEnum consumeType, Integer quantity);
+
+    /**
+     * 将 tenant_info.balance 同步到 tenant_balance.total_balance(取较高值,不重复累加)
+     */
+    void syncBillingBalanceFromTenantInfo(Long tenantId);
+
     /**
      * 解析服务单价(含租户定价覆盖)
      */

+ 99 - 0
fs-service/src/main/java/com/fs/proxy/service/impl/BalanceServiceImpl.java

@@ -20,6 +20,9 @@ import com.fs.proxy.mapper.TenantTrafficPricingMapper;
 import com.fs.proxy.model.ConsumeServiceOutcome;
 import com.fs.proxy.model.UnitPricePair;
 import com.fs.proxy.service.BalanceService;
+import com.fs.tenant.domain.TenantInfo;
+import com.fs.tenant.mapper.TenantInfoMapper;
+import lombok.extern.slf4j.Slf4j;
 import org.redisson.api.RLock;
 import org.redisson.api.RedissonClient;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -39,12 +42,16 @@ import java.util.concurrent.TimeUnit;
  * @author fs
  * @date 2024-01-01
  */
+@Slf4j
 @Service
 public class BalanceServiceImpl implements BalanceService {
 
     @Autowired
     private TenantBalanceMapper balanceMapper;
 
+    @Autowired
+    private TenantInfoMapper tenantInfoMapper;
+
     @Autowired
     private TenantConsumeRecordMapper recordMapper;
 
@@ -230,6 +237,7 @@ public class BalanceServiceImpl implements BalanceService {
         if (!isPayAsYouGo(consumeType)) {
             return checkBalance(tenantId, consumeType, quantity);
         }
+        alignTotalBalanceWithTenantInfo(tenantId);
         TenantBalance balance = balanceMapper.selectBalanceByTenantId(tenantId);
         if (balance == null || balance.getTotalBalance() == null) {
             return false;
@@ -242,6 +250,52 @@ public class BalanceServiceImpl implements BalanceService {
         return balance.getTotalBalance().compareTo(needed) >= 0;
     }
 
+    @Override
+    @DataSource(DataSourceType.MASTER)
+    public String explainPayAsYouGoBalanceFailure(Long tenantId, ConsumeTypeEnum consumeType, Integer quantity) {
+        if (tenantId == null) {
+            return "租户信息缺失,无法发起外呼";
+        }
+        if (consumeType == null || quantity == null || quantity <= 0) {
+            return "外呼计费参数无效";
+        }
+        if (consumeType == ConsumeTypeEnum.MANUAL_CALL) {
+            return null;
+        }
+        if (!isPayAsYouGo(consumeType)) {
+            return checkBalance(tenantId, consumeType, quantity) ? null : "服务专项余额不足";
+        }
+        TenantInfo tenantInfo = tenantInfoMapper.selectTenantInfoById(String.valueOf(tenantId));
+        if (tenantInfo == null) {
+            return "租户不存在(tenantId=" + tenantId + ")";
+        }
+        alignTotalBalanceWithTenantInfo(tenantId);
+        TenantBalance balance = balanceMapper.selectBalanceByTenantId(tenantId);
+        BigDecimal infoBalance = tenantInfo.getBalance() != null ? tenantInfo.getBalance() : BigDecimal.ZERO;
+        if (balance == null || balance.getTotalBalance() == null) {
+            if (infoBalance.compareTo(BigDecimal.ZERO) > 0) {
+                return "租户计费账户未初始化,请联系管理员同步余额";
+            }
+            return "租户总账户余额不足(当前余额 0.00 元)";
+        }
+        BigDecimal unitPrice = resolveUnitPrice(tenantId, consumeType);
+        if (unitPrice == null || unitPrice.compareTo(BigDecimal.ZERO) <= 0) {
+            return "未配置" + consumeType.getName() + "计费单价,无法发起外呼";
+        }
+        BigDecimal needed = unitPrice.multiply(BigDecimal.valueOf(quantity));
+        if (balance.getTotalBalance().compareTo(needed) >= 0) {
+            return null;
+        }
+        return String.format("租户余额不足:当前总余额 %.2f 元,至少需要 %.2f 元(%s × %d 分钟)",
+                balance.getTotalBalance(), needed, consumeType.getName(), quantity);
+    }
+
+    @Override
+    @DataSource(DataSourceType.MASTER)
+    public void syncBillingBalanceFromTenantInfo(Long tenantId) {
+        alignTotalBalanceWithTenantInfo(tenantId);
+    }
+
     @Override
     @DataSource(DataSourceType.MASTER)
     public BigDecimal resolveUnitPrice(Long tenantId, ConsumeTypeEnum consumeType) {
@@ -251,6 +305,9 @@ public class BalanceServiceImpl implements BalanceService {
 
     private ConsumeServiceOutcome doConsumeService(Long tenantId, ConsumeTypeEnum consumeType,
                                                    Integer quantity, String remark, String orderNo) {
+        if (isPayAsYouGo(consumeType)) {
+            alignTotalBalanceWithTenantInfo(tenantId);
+        }
         TenantBalance balance = balanceMapper.selectBalanceByTenantIdForUpdate(tenantId);
         if (balance == null) {
             return ConsumeServiceOutcome.builder().result(ConsumeServiceResult.FAILED).orderNo(orderNo).build();
@@ -289,6 +346,7 @@ public class BalanceServiceImpl implements BalanceService {
             if (inserted <= 0) {
                 return ConsumeServiceOutcome.builder().result(ConsumeServiceResult.FAILED).orderNo(orderNo).build();
             }
+            syncTenantInfoBalanceAfterConsume(tenantId, totalCost);
             return ConsumeServiceOutcome.builder()
                     .result(ConsumeServiceResult.SUCCESS)
                     .amount(totalCost)
@@ -440,6 +498,47 @@ public class BalanceServiceImpl implements BalanceService {
         }
     }
 
+    /**
+     * 管理后台充值写入 tenant_info.balance,外呼扣费读取 tenant_balance.total_balance。
+     * 校验/扣费前将 tenant_info 中更高的余额同步到 tenant_balance,避免两表不一致导致误报余额不足。
+     */
+    private void alignTotalBalanceWithTenantInfo(Long tenantId) {
+        if (tenantId == null) {
+            return;
+        }
+        TenantInfo tenantInfo = tenantInfoMapper.selectTenantInfoById(String.valueOf(tenantId));
+        if (tenantInfo == null) {
+            return;
+        }
+        BigDecimal infoBalance = tenantInfo.getBalance() != null ? tenantInfo.getBalance() : BigDecimal.ZERO;
+        TenantBalance balance = balanceMapper.selectBalanceByTenantId(tenantId);
+        if (balance == null) {
+            initTenantBalance(tenantId, StringUtils.defaultIfBlank(tenantInfo.getTenantName(), "租户" + tenantId));
+            if (infoBalance.compareTo(BigDecimal.ZERO) > 0) {
+                balanceMapper.increaseTotalBalance(tenantId, infoBalance);
+            }
+            return;
+        }
+        BigDecimal totalBalance = balance.getTotalBalance() != null ? balance.getTotalBalance() : BigDecimal.ZERO;
+        if (infoBalance.compareTo(totalBalance) > 0) {
+            balanceMapper.increaseTotalBalance(tenantId, infoBalance.subtract(totalBalance));
+        }
+    }
+
+    private void syncTenantInfoBalanceAfterConsume(Long tenantId, BigDecimal amount) {
+        if (tenantId == null || amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
+            return;
+        }
+        try {
+            int rows = tenantInfoMapper.updateBalance(tenantId, amount.negate());
+            if (rows <= 0) {
+                log.warn("扣费后同步 tenant_info 余额失败 tenantId={}, amount={}", tenantId, amount);
+            }
+        } catch (Exception ex) {
+            log.warn("扣费后同步 tenant_info 余额异常 tenantId={}, amount={}", tenantId, amount, ex);
+        }
+    }
+
     @Override
     @Transactional
     public boolean transferToTotal(Long tenantId, ConsumeTypeEnum consumeType, Integer quantity) {

+ 2 - 1
fs-wx-api/src/main/java/com/fs/app/controller/CommonController.java

@@ -76,7 +76,8 @@ public class CommonController {
     public R addWxAction(@RequestBody AddWxActionParam param){
         String wxId = param.getWxId();
         Session session = WebSocketServer.sessionPools.get(wxId);
-        webSocketServer.sendMessage(session, ResultMsgVo.<AddWxVo>builder().cmd(CmdType.ADD_WX).data(new AddWxVo(param.getRemark(), PhoneUtil.decryptPhone(param.getPhone()), param.getApplyMsg(), param.getBizJson())).build());
+        AddWxVo addWxVo = new AddWxVo(param.getRemark(), param.getPhone(), param.getApplyMsg(), param.getBizJson());
+        webSocketServer.sendMessage(session, ResultMsgVo.<AddWxVo>builder().cmd(CmdType.ADD_WX).data(addWxVo).build());
         return R.ok();
     }
 

+ 21 - 0
fs-wx-task/src/main/resources/application.yml

@@ -0,0 +1,21 @@
+server:
+  port: 7007
+
+logging:
+  level:
+    org: INFO
+    com: DEBUG
+
+saas:
+  task:
+    enabled: true
+
+tenant-service-marker: wxTask00
+cid-group-no: 1
+
+spring:
+  main:
+    allow-bean-definition-overriding: true
+  profiles:
+    active: dev
+    include: common,config-dev