Просмотр исходного кода

外呼独立模块-fs-comm-gateway

吴树波 1 неделя назад
Родитель
Сommit
6a1fe52c7b

+ 3 - 0
fs-comm-gateway/src/main/java/com/fs/comm/security/CommTokenAuthFilter.java

@@ -16,6 +16,7 @@ import com.fs.framework.datasource.TenantDataSourceManager;
 import com.fs.config.saas.ProjectConfig;
 import com.fs.system.domain.SysConfig;
 import com.fs.system.mapper.SysConfigMapper;
+import com.fs.wxcid.utils.TenantHelper;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
@@ -59,6 +60,7 @@ public class CommTokenAuthFilter extends OncePerRequestFilter {
             if (session != null) {
                 commTokenService.verifySession(session);
                 if (session.getTenantId() != null) {
+                    TenantHelper.setTenantId(session.getTenantId());
                     tenantDataSourceManager.ensureSwitchByTenantId(session.getTenantId());
                     loadTenantConfig(session.getTenantId());
                     RedisTenantContext.setTenantId(session.getTenantId());
@@ -75,6 +77,7 @@ public class CommTokenAuthFilter extends OncePerRequestFilter {
             }
             chain.doFilter(request, response);
         } finally {
+            TenantHelper.removeTenantId();
             ProjectConfig.clearTenantConfigs();
             TenantConfigContext.clear();
             RedisTenantContext.clear();

+ 5 - 0
fs-comm-gateway/src/main/java/com/fs/comm/service/CommCallService.java

@@ -7,6 +7,7 @@ import com.fs.comm.model.CommCallSendParam;
 import com.fs.comm.model.CommCallSendResult;
 import com.fs.comm.ratelimit.CommRateLimitService;
 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;
@@ -53,6 +54,10 @@ public class CommCallService {
                 .bizParams(request.getBizParams())
                 .build());
 
+        if (StringUtils.isBlank(result.getPhone())) {
+            throw new ServiceException("外呼发起失败:未获取到有效被叫号码");
+        }
+
         Map<String, Object> response = new HashMap<>();
         response.put("callBackUuid", result.getCallBackUuid());
         response.put("batchId", result.getBatchId());

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

@@ -0,0 +1,562 @@
+# fs-comm-gateway 通讯中间件对接文档
+
+## 1. 概述
+
+`fs-comm-gateway` 是 SaaS 平台统一的**外呼 / 短信通讯网关**,对外提供标准化 HTTP API,对内供工作流引擎、业务服务通过 `CommGatewayClient` 调用。
+
+主要能力:
+
+| 能力 | 说明 |
+|------|------|
+| AI 外呼 | 对接 EasyCallCenter365,创建任务、追加名单、启动外呼 |
+| 短信发送 | 基于租户短信模板与余额,批量发送 AI 短信 |
+| 工作流回调 | 接收 EasyCall / 短信平台回调,续跑阻塞节点 |
+| 外呼记录查询 | 按 `callBackUuid` 查询外呼日志 |
+
+默认服务端口:**8010**  
+建议通过 Nginx 反向代理暴露:`/comm/` → `http://127.0.0.1:8010/comm/`
+
+---
+
+## 2. 环境与依赖
+
+网关运行依赖以下基础设施(由运维按环境配置):
+
+| 组件 | 用途 |
+|------|------|
+| MySQL 主库 | 租户信息、公司账号鉴权 |
+| MySQL 租户库 | 业务数据(被叫人、模板、日志等) |
+| Redis | Token 会话、限流、工作流回调上下文 |
+| EasyCallCenter365 | 实际外呼平台,`easycall.base-url` 配置 |
+
+---
+
+## 3. 鉴权方式
+
+网关支持两种调用身份,**二选一**即可访问业务接口(除白名单路径外)。
+
+### 3.1 外部对接:JWT Token(推荐三方系统使用)
+
+#### 3.1.1 获取 Token
+
+```http
+POST /comm/auth/token
+Content-Type: application/json
+```
+
+**请求体:**
+
+```json
+{
+  "tenantCode": "your_tenant_code",
+  "account": "company_user_account",
+  "password": "plain_password"
+}
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| tenantCode | string | 是 | 租户编码 |
+| account | string | 是 | 公司端登录账号 |
+| password | string | 是 | 登录密码(BCrypt 校验) |
+
+**成功响应:**
+
+```json
+{
+  "code": 200,
+  "msg": "success",
+  "data": {
+    "accessToken": "eyJhbGciOiJIUzUxMiJ9...",
+    "expiresIn": 7200,
+    "tokenType": "Bearer",
+    "tenantId": 33,
+    "companyId": 1001,
+    "companyUserId": 2001
+  }
+}
+```
+
+| 字段 | 说明 |
+|------|------|
+| accessToken | JWT,后续请求携带 |
+| expiresIn | 有效秒数(默认 120 分钟,见 `token.expireTime`) |
+| tokenType | 固定 `Bearer` |
+| tenantId / companyId / companyUserId | 当前会话绑定的租户与公司上下文 |
+
+#### 3.1.2 携带 Token 调用业务接口
+
+```http
+Authorization: Bearer {accessToken}
+Content-Type: application/json
+```
+
+#### 3.1.3 刷新 / 注销 Token
+
+```http
+POST /comm/auth/refresh
+Authorization: Bearer {accessToken}
+```
+
+```http
+POST /comm/auth/logout
+Authorization: Bearer {accessToken}
+```
+
+---
+
+### 3.2 内部服务调用:Internal Secret 请求头
+
+平台内部服务(如工作流引擎)通过 `CommGatewayClient` 调用,无需 JWT,使用共享密钥 + 租户/公司头:
+
+| 请求头 | 必填 | 说明 |
+|--------|------|------|
+| X-Comm-Internal-Secret | 是 | 与配置 `comm.gateway.internal-secret` 一致 |
+| X-Comm-Tenant-Id | 是 | 租户 ID |
+| X-Comm-Company-Id | 是 | 公司 ID |
+| X-Comm-Company-User-Id | 否 | 公司用户 ID |
+
+配置项(调用方 `application.yml`):
+
+```yaml
+comm:
+  gateway:
+    base-url: http://127.0.0.1:8010
+    internal-secret: ${COMM_INTERNAL_SECRET}
+    enabled: true
+    fallback-local: false   # 网关失败时是否降级本地直连
+```
+
+---
+
+### 3.3 免鉴权路径
+
+以下路径**不需要** Token 或 Internal Secret:
+
+| 路径 | 说明 |
+|------|------|
+| POST `/comm/auth/token` | 登录换 Token |
+| POST `/comm/callback/easycall` | EasyCall 外呼结果回调(含 IP 白名单校验) |
+| POST `/comm/callback/sms` | 短信回执回调 |
+
+---
+
+## 4. 统一响应格式
+
+业务 Controller 统一返回 `CommApiResult`:
+
+```json
+{
+  "code": 200,
+  "msg": "success",
+  "data": { }
+}
+```
+
+| code | 含义 |
+|------|------|
+| 200 | 成功 |
+| 401 | 未认证(如查询接口未带 Token) |
+| 404 | 资源不存在 |
+| 500 | 业务失败或系统异常 |
+
+**判定规则:**
+
+- HTTP 状态码通常为 **200**,请以响应体 **`code` 字段** 判断业务成败。
+- `code != 200` 时,`msg` 为失败原因,对接方应记录并向上游返回失败。
+
+**常见失败 msg 示例:**
+
+| msg | 场景 |
+|-----|------|
+| 被叫人手机号解密失败或号码无效 | 被叫号码为空或解密失败 |
+| 被叫人命中外呼黑名单 | 黑名单拦截 |
+| 外呼名单追加失败或线路限流 | EasyCall 未追加名单或线路限流 |
+| 成功追加0个名单 | EasyCall 返回 0 条(号码无效等) |
+| 无权使用该外呼线路 | gatewayId 不在公司可用线路内 |
+| 租户请求频率超限,请稍后重试 | 触发 QPS 限流 |
+| 剩余短信数量不足,请充值 | 短信余额不足 |
+| 短信模板不存在或未审核 | 模板无效 |
+
+> 注意:Filter 层未授权时返回 `AjaxResult` 格式(`code: 401`),与 `CommApiResult` 字段结构一致。
+
+---
+
+## 5. 接口明细
+
+### 5.1 发起外呼
+
+```http
+POST /comm/call/send
+Authorization: Bearer {accessToken}
+Content-Type: application/json
+```
+
+**请求体:**
+
+```json
+{
+  "calleeId": 25,
+  "roboticId": 174,
+  "gatewayId": 5,
+  "businessId": null,
+  "nodeKey": "call_node_1",
+  "workflowInstanceId": "wf-instance-uuid",
+  "callbackUrl": "",
+  "phone": null,
+  "llmAccountId": 1,
+  "voiceCode": "xiaoyun",
+  "voiceSource": "ali",
+  "busiGroupId": 10,
+  "maxConcurrency": 1,
+  "bizParams": {}
+}
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| calleeId | long | 是 | 被叫人 ID(`company_voice_robotic_callees.id`) |
+| roboticId | long | 是 | AI 外呼任务 ID |
+| gatewayId | long | 是 | EasyCall 外呼线路 ID |
+| businessId | long | 否 | 商机 ID,传入时校验当日拨打次数上限 |
+| nodeKey | string | 工作流场景必填 | 工作流节点 Key |
+| workflowInstanceId | string | 工作流场景必填 | 工作流实例 ID |
+| callbackUrl | string | 否 | 自定义 EasyCall 回调地址,空则读租户/公司配置 |
+| phone | string | 否 | 指定被叫号码(明文或 AES 密文);为空则从 calleeId 解析 |
+| llmAccountId / voiceCode / voiceSource / busiGroupId / maxConcurrency | - | 否 | AI 外呼扩展参数(工作流节点配置透传) |
+| bizParams | object | 否 | 追加到 EasyCall `bizJson` 的自定义字段 |
+
+**成功响应 data:**
+
+```json
+{
+  "callBackUuid": "4ca54a59-fcdf-4c55-8783-1112dd3405cf",
+  "batchId": 159576,
+  "phone": "13800138000"
+}
+```
+
+| 字段 | 说明 |
+|------|------|
+| callBackUuid | 本次外呼唯一标识,用于查询与回调关联 |
+| batchId | EasyCall 任务批次 ID |
+| phone | 实际外呼号码(明文) |
+
+**处理流程简述:**
+
+1. 校验线路归属、黑名单、号码有效性  
+2. 写入 Redis 工作流回调上下文(`easycall:workflow:callback:{callBackUuid}`)  
+3. 调用 EasyCall `addCallList` + `startTask`  
+4. 异步写入租户库 `company_voice_robotic_call_log_callphone`  
+
+---
+
+### 5.2 发送短信
+
+```http
+POST /comm/sms/send
+Authorization: Bearer {accessToken}
+Content-Type: application/json
+```
+
+**请求体:**
+
+```json
+{
+  "roboticId": 174,
+  "calleeId": 25,
+  "smsTempId": 88,
+  "nodeKey": "sms_node_1",
+  "workflowInstanceId": "wf-instance-uuid",
+  "phone": null,
+  "customerId": null,
+  "companyUserId": null,
+  "senderName": null,
+  "cardUrl": null,
+  "templateParams": {}
+}
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| roboticId | long | 是 | AI 任务 ID |
+| calleeId | long | 是 | 被叫人 ID |
+| smsTempId | long | 是 | 短信模板 ID(须已审核且启用) |
+| nodeKey | string | 工作流场景建议填 | 节点 Key |
+| workflowInstanceId | string | 工作流场景建议填 | 工作流实例 ID |
+| phone | string | 否 | 指定手机号,默认取客户 mobile |
+| customerId | long | 否 | 客户 ID,默认取 callee 关联 userId |
+| companyUserId / senderName | - | 否 | 发送销售信息,空则自动从微信绑定关系解析 |
+| cardUrl | string | 否 | 卡片链接 |
+| templateParams | map | 否 | 模板变量(预留) |
+
+**成功响应 data:**
+
+```json
+{
+  "callbackUuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
+  "customerId": 10001,
+  "phone": "13800138000"
+}
+```
+
+发送结果异步写入租户库 `company_voice_robotic_call_log_sendmsg`(status:1 进行中 / 2 成功 / 3 失败)。
+
+---
+
+### 5.3 查询外呼记录
+
+```http
+GET /comm/query/call/{callBackUuid}
+Authorization: Bearer {accessToken}
+```
+
+**成功响应 data:** 外呼日志对象(`company_voice_robotic_call_log_callphone` 表字段 JSON 化),含 `status`、`result`、`callTime`、`intention` 等。
+
+**失败示例:**
+
+```json
+{
+  "code": 404,
+  "msg": "未找到外呼记录",
+  "data": null
+}
+```
+
+---
+
+### 5.4 平台回调接口(运维 / EasyCall 配置)
+
+#### EasyCall 外呼回调
+
+```http
+POST /comm/callback/easycall
+Content-Type: application/json
+```
+
+- 请求体:EasyCall CDR JSON 字符串(原样 POST)
+- 响应:成功返回 `"success"`,非法 IP 返回 `"illegal IP"`
+- 安全:`@CallbackIpCheck` 校验来源 IP 是否在租户 `cId.config.legalIPs` 白名单内
+- 处理:解析 `bizJson.tenantId` 切换租户库,更新外呼日志并续跑工作流阻塞节点
+
+**EasyCall 侧需配置的回调地址示例:**
+
+```
+https://{your-domain}/comm/callback/easycall
+```
+
+`bizJson` 中需包含(网关外呼时自动写入):
+
+```json
+{
+  "tenantId": 33,
+  "callBackUuid": "4ca54a59-fcdf-4c55-8783-1112dd3405cf",
+  "callBackUrl": "",
+  "custName": "张三"
+}
+```
+
+#### 短信回执回调
+
+```http
+POST /comm/callback/sms
+Content-Type: application/json
+```
+
+- 请求体:短信平台回执 JSON,需包含 `tenantId` 字段以便切库
+- 响应:由 `ISmsService.smsNotify` 返回(通常为 `"success"` 或平台约定字符串)
+
+---
+
+## 6. 对接时序
+
+### 6.1 外呼 + 工作流续跑
+
+```mermaid
+sequenceDiagram
+    participant Client as 调用方
+    participant GW as fs-comm-gateway
+    participant EC as EasyCall
+    participant WF as 工作流引擎
+
+    Client->>GW: POST /comm/call/send
+    GW->>GW: 校验鉴权/线路/号码/黑名单
+    GW->>EC: addCallList + startTask
+    GW-->>Client: callBackUuid, batchId, phone
+
+    EC->>GW: POST /comm/callback/easycall
+    GW->>GW: 切租户库、更新 call_log
+    GW->>WF: resumeFromBlockingNode
+```
+
+### 6.2 三方系统最小对接步骤
+
+1. 调用 `/comm/auth/token` 获取 `accessToken`  
+2. 准备业务数据:`roboticId`、`calleeId`、`gatewayId`(或 `smsTempId`)  
+3. 调用 `/comm/call/send` 或 `/comm/sms/send`  
+4. **必须检查响应 `code === 200`**,并保存 `callBackUuid` / `callbackUuid`  
+5. 轮询 `GET /comm/query/call/{callBackUuid}` 或等待 EasyCall 回调触发后续流程  
+
+---
+
+## 7. 限流与线路鉴权
+
+### 7.1 租户 QPS 限流
+
+- 配置项:`comm.gateway.tenant-qps-limit`(默认 200)
+- 外呼、短信发送前均校验
+- 超限返回:`租户请求频率超限,请稍后重试`
+
+### 7.2 外呼线路鉴权
+
+`gatewayId` 必须属于当前公司可用线路,校验顺序:
+
+1. EasyCall `getGatewayList(companyId)` 返回列表  
+2. 否则读公司 `gateWayList` 配置  
+3. 否则读全局 `cId.config.showGatewayIds`  
+
+---
+
+## 8. 配置参考
+
+`application.yml` 核心项(生产环境请通过环境变量或配置中心注入,**勿提交明文密钥**):
+
+```yaml
+server:
+  port: 8010
+
+token:
+  header: Authorization
+  secret: ${COMM_TOKEN_SECRET}
+  expireTime: 120          # Token 有效分钟数
+
+comm:
+  gateway:
+    internal-secret: ${COMM_INTERNAL_SECRET}
+    tenant-qps-limit: 200
+    executor:
+      core-pool-size: 20
+      max-pool-size: 100
+      queue-capacity: 2000
+
+easycall:
+  base-url: http://{easycall-host}:{port}
+```
+
+---
+
+## 9. 调用示例
+
+### 9.1 cURL:登录 + 外呼
+
+```bash
+# 1. 获取 Token
+TOKEN_RESP=$(curl -s -X POST "http://127.0.0.1:8010/comm/auth/token" \
+  -H "Content-Type: application/json" \
+  -d '{"tenantCode":"demo","account":"admin","password":"your_password"}')
+
+ACCESS_TOKEN=$(echo $TOKEN_RESP | jq -r '.data.accessToken')
+
+# 2. 发起外呼
+curl -s -X POST "http://127.0.0.1:8010/comm/call/send" \
+  -H "Authorization: Bearer $ACCESS_TOKEN" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "calleeId": 25,
+    "roboticId": 174,
+    "gatewayId": 5,
+    "nodeKey": "node_call_1",
+    "workflowInstanceId": "your-workflow-instance-id"
+  }'
+```
+
+### 9.2 cURL:内部服务调用
+
+```bash
+curl -s -X POST "http://127.0.0.1:8010/comm/call/send" \
+  -H "Content-Type: application/json" \
+  -H "X-Comm-Internal-Secret: your_internal_secret" \
+  -H "X-Comm-Tenant-Id: 33" \
+  -H "X-Comm-Company-Id: 1001" \
+  -d '{
+    "calleeId": 25,
+    "roboticId": 174,
+    "gatewayId": 5,
+    "nodeKey": "node_call_1",
+    "workflowInstanceId": "your-workflow-instance-id"
+  }'
+```
+
+### 9.3 Java(内部 CommGatewayClient)
+
+```java
+Map<String, Object> body = new HashMap<>();
+body.put("calleeId", 25L);
+body.put("roboticId", 174L);
+body.put("gatewayId", 5L);
+body.put("nodeKey", "node_call_1");
+body.put("workflowInstanceId", workflowInstanceId);
+
+JSONObject result = commGatewayClient.sendCall(tenantId, companyId, companyUserId, body);
+String callBackUuid = result.getString("callBackUuid");
+String phone = result.getString("phone");
+```
+
+---
+
+## 10. 部署说明
+
+### 10.1 构建
+
+```bash
+cd ylrz_saas_his_scrm
+mvn clean package -pl fs-comm-gateway -am -DskipTests
+```
+
+### 10.2 Docker
+
+```bash
+docker build -t fs-comm-gateway:latest fs-comm-gateway/
+docker run -d -p 8010:8010 \
+  -e SPRING_PROFILES_ACTIVE=prod \
+  fs-comm-gateway:latest
+```
+
+### 10.3 Nginx 反代
+
+参考模块内 `nginx.conf`:
+
+```nginx
+location /comm/ {
+    proxy_pass http://127.0.0.1:8010/comm/;
+    proxy_set_header Host $host;
+    proxy_set_header X-Real-IP $remote_addr;
+    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+    proxy_read_timeout 120s;
+}
+```
+
+---
+
+## 11. 附录:相关数据表
+
+| 表名 | 说明 |
+|------|------|
+| company_voice_robotic_call_log_callphone | AI 外呼执行日志 |
+| company_voice_robotic_call_log_sendmsg | 短信发送日志 |
+| company_voice_robotic_call_log_addwx | 加微执行日志(其他模块写入) |
+
+日志写入均路由至**当前租户库**,非主库。
+
+---
+
+## 12. 版本与联系
+
+| 项 | 值 |
+|----|-----|
+| 模块 | fs-comm-gateway |
+| 默认端口 | 8010 |
+| API 前缀 | `/comm` |
+| 文档更新 | 2026-06-03 |
+
+如有对接问题,请提供:`tenantId`、`callBackUuid`、请求体、完整响应 JSON 及服务端日志时间点,便于排查。

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

@@ -7,6 +7,7 @@ import com.alibaba.fastjson.JSONObject;
 import com.fs.common.exception.ServiceException;
 import com.fs.common.utils.StringUtils;
 import com.fs.wxcid.utils.TenantHelper;
+import lombok.Getter;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Component;
@@ -26,9 +27,11 @@ public class CommGatewayClient {
     @Value("${comm.gateway.internal-secret:CommGatewayInternal2026!@#}")
     private String internalSecret;
 
+    @Getter
     @Value("${comm.gateway.enabled:true}")
     private boolean enabled;
 
+    @Getter
     @Value("${comm.gateway.fallback-local:true}")
     private boolean fallbackLocal;
 
@@ -40,14 +43,6 @@ public class CommGatewayClient {
         return postInternal("/comm/sms/send", tenantId, companyId, companyUserId, body);
     }
 
-    public boolean isEnabled() {
-        return enabled;
-    }
-
-    public boolean isFallbackLocal() {
-        return fallbackLocal;
-    }
-
     private JSONObject postInternal(String path, Long tenantId, Long companyId, Long companyUserId, Map<String, Object> body) {
         if (!enabled) {
             throw new ServiceException("通讯中间件未启用");

+ 21 - 4
fs-service/src/main/java/com/fs/comm/service/CommCallSendService.java

@@ -106,6 +106,11 @@ public class CommCallSendService {
             throw new ServiceException("被叫人命中外呼黑名单");
         }
 
+        String phoneNum = resolveCalleePhone(param, callees);
+        if (StringUtils.isBlank(phoneNum)) {
+            throw new ServiceException("被叫人手机号解密失败或号码无效");
+        }
+
         String callBackUrl = resolveCallbackUrl(robotic, param.getCallbackUrl());
         String callBackUuid = UUID.randomUUID().toString();
 
@@ -120,7 +125,7 @@ public class CommCallSendService {
         EasyCallCommonAddCallListParam addListParam = new EasyCallCommonAddCallListParam();
         addListParam.setBatchId(batchId);
         EasyCallPhoneItemVO phoneItem = new EasyCallPhoneItemVO();
-        phoneItem.setPhoneNum(PhoneUtil.decryptPhone(callees.getPhone()));
+        phoneItem.setPhoneNum(phoneNum);
         JSONObject bizJson = new JSONObject();
         bizJson.put("custName", callees.getUserName());
         bizJson.put("tenantId", param.getTenantId() != null ? param.getTenantId() : TenantHelper.getTenantId());
@@ -132,9 +137,14 @@ public class CommCallSendService {
         phoneItem.setBizJson(bizJson);
         addListParam.setPhoneList(Collections.singletonList(phoneItem));
 
-        boolean added = easyCallService.addCommonCallList(addListParam, companyId, param.getGatewayId());
-        if (!added) {
-            throw new ServiceException("外呼名单追加失败或线路限流");
+        try {
+            boolean added = easyCallService.addCommonCallList(addListParam, companyId, param.getGatewayId());
+            if (!added) {
+                throw new ServiceException("外呼名单追加失败或线路限流");
+            }
+        } catch (ServiceException ex) {
+            redisCache.deleteObject(EASYCALL_WORKFLOW_REDIS_KEY + callBackUuid);
+            throw ex;
         }
         easyCallService.startTask(batchId, null);
 
@@ -153,6 +163,13 @@ public class CommCallSendService {
                 .build();
     }
 
+    private String resolveCalleePhone(CommCallSendParam param, CompanyVoiceRoboticCallees callees) {
+        if (StringUtils.isNotBlank(param.getPhone())) {
+            return PhoneUtil.decryptAutoPhone(param.getPhone().trim());
+        }
+        return PhoneUtil.decryptAutoPhone(callees.getPhone());
+    }
+
     private void checkPhoneCallLimit(Long businessId, Long companyId) {
         String json = configService.selectConfigByKey("cId.config");
         if (StringUtils.isEmpty(json)) {

+ 18 - 0
fs-service/src/main/java/com/fs/company/service/easycall/EasyCallServiceImpl.java

@@ -8,6 +8,7 @@ import com.alibaba.fastjson.JSONArray;
 import com.alibaba.fastjson.JSONObject;
 import com.fs.common.core.redis.RedisCache;
 import com.fs.common.core.redis.RedisCacheT;
+import com.fs.common.exception.ServiceException;
 import com.fs.common.utils.StringUtils;
 import com.fs.company.domain.CompanyConfig;
 import com.fs.company.domain.OutboundLineLimitLog;
@@ -279,6 +280,13 @@ public class EasyCallServiceImpl implements IEasyCallService {
      */
     @Override
     public boolean addCommonCallList(EasyCallCommonAddCallListParam param, Long companyId, Long gatewayId) {
+        if (param.getPhoneList() != null) {
+            for (EasyCallPhoneItemVO item : param.getPhoneList()) {
+                if (item == null || StringUtils.isBlank(item.getPhoneNum())) {
+                    throw new ServiceException("外呼名单追加失败:被叫号码为空");
+                }
+            }
+        }
         // 1. 检查外呼线路限制
         OutboundLimitResultVO limitResult = checkOutboundLineLimit(companyId, gatewayId);
         if (!limitResult.isPassed()) {
@@ -293,11 +301,21 @@ public class EasyCallServiceImpl implements IEasyCallService {
         try {
             JSONObject result = doPost(url, JSON.toJSONString(param));
             boolean success = checkSuccess(result);
+            if (success && param.getPhoneList() != null && !param.getPhoneList().isEmpty()) {
+                JSONArray data = result.getJSONArray("data");
+                String msg = result.getString("msg");
+                if ((data != null && data.isEmpty()) || (msg != null && msg.contains("追加0"))) {
+                    log.warn("addCommonCallList: EasyCall返回成功但未追加名单, msg={}", msg);
+                    throw new ServiceException(StringUtils.defaultIfBlank(msg, "外呼名单追加失败:未追加任何号码"));
+                }
+            }
             if (success) {
                 // 3. 执行成功后记录OutboundLineLimitLog日志
                 saveExecutionLog(companyId, gatewayId, param);
             }
             return success;
+        } catch (ServiceException e) {
+            throw e;
         } catch (Exception e) {
             log.error("addCommonCallList: 外呼接口调用异常 - companyId: {}, gatewayId: {}", companyId, gatewayId, e);
             return false;

+ 10 - 7
fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticCallLogAddwxServiceImpl.java

@@ -10,6 +10,7 @@ import com.fs.company.mapper.CompanyWxClientMapper;
 import com.fs.company.service.ICompanyVoiceRoboticCallLogAddwxService;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogAddwxVO;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogCount;
+import com.fs.wxcid.utils.TenantAsyncContextHelper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.scheduling.annotation.Async;
@@ -106,13 +107,15 @@ public class CompanyVoiceRoboticCallLogAddwxServiceImpl extends ServiceImpl<Comp
 
     @Async("callLogExcutor")
     public void asyncInsertCompanyVoiceRoboticCallLog(CompanyVoiceRoboticCallLogAddwx companyVoiceRoboticCallLog) {
-        try{
-            companyVoiceRoboticCallLog.setCreateTime(DateUtils.getNowDate());
-            baseMapper.insertCompanyVoiceRoboticCallLogAddwx(companyVoiceRoboticCallLog);
-            companyVoiceRoboticBusinessMapper.updateActionCount(1,companyVoiceRoboticCallLog.getRoboticId(),null,companyVoiceRoboticCallLog.getWxClientId());
-        } catch (Exception e) {
-            log.error("记录任务执行日志失败:失败数据:{}",companyVoiceRoboticCallLog, e);
-        }
+        TenantAsyncContextHelper.runWithTenantContext(() -> {
+            try {
+                companyVoiceRoboticCallLog.setCreateTime(DateUtils.getNowDate());
+                baseMapper.insertCompanyVoiceRoboticCallLogAddwx(companyVoiceRoboticCallLog);
+                companyVoiceRoboticBusinessMapper.updateActionCount(1, companyVoiceRoboticCallLog.getRoboticId(), null, companyVoiceRoboticCallLog.getWxClientId());
+            } catch (Exception e) {
+                log.error("记录任务执行日志失败:失败数据:{}", companyVoiceRoboticCallLog, e);
+            }
+        });
     }
 
 

+ 18 - 13
fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticCallLogCallphoneServiceImpl.java

@@ -35,6 +35,7 @@ import com.fs.sensitive.component.AgentSensitiveWordDetector;
 import com.fs.sensitive.manager.SensitiveWordAcManager;
 import com.fs.system.service.ISysConfigService;
 import com.fs.system.service.impl.SysDictTypeServiceImpl;
+import com.fs.wxcid.utils.TenantAsyncContextHelper;
 import com.fs.wxcid.utils.TenantHelper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -174,13 +175,15 @@ public class CompanyVoiceRoboticCallLogCallphoneServiceImpl extends ServiceImpl<
      */
     @Async("callLogExcutor")
     public void asyncInsertCompanyVoiceRoboticCallLog(CompanyVoiceRoboticCallLogCallphone callPhoneLog) {
-        try {
-            callPhoneLog.setCreateTime(DateUtils.getNowDate());
-            baseMapper.insertCompanyVoiceRoboticCallLogCallphone(callPhoneLog);
-            companyVoiceRoboticBusinessMapper.updateActionCount(2, callPhoneLog.getRoboticId(), callPhoneLog.getCallerId(), null);
-        } catch (Exception e) {
-            log.error("记录任务执行日志失败:失败数据:{}", callPhoneLog, e);
-        }
+        TenantAsyncContextHelper.runWithTenantContext(() -> {
+            try {
+                callPhoneLog.setCreateTime(DateUtils.getNowDate());
+                baseMapper.insertCompanyVoiceRoboticCallLogCallphone(callPhoneLog);
+                companyVoiceRoboticBusinessMapper.updateActionCount(2, callPhoneLog.getRoboticId(), callPhoneLog.getCallerId(), null);
+            } catch (Exception e) {
+                log.error("记录任务执行日志失败:失败数据:{}", callPhoneLog, e);
+            }
+        });
     }
 
 
@@ -540,12 +543,14 @@ public class CompanyVoiceRoboticCallLogCallphoneServiceImpl extends ServiceImpl<
 
     @Async("callLogExcutor")
     public void asyncInsertCompanyVoiceRoboticCallLogBatch(List<CompanyVoiceRoboticCallLogCallphone> list) {
-        try {
-            list.stream().forEach(i -> i.setCreateTime(new Date()));
-            this.saveBatch(list);
-        } catch (Exception e) {
-            log.error("批量记录任务执行日志失败:失败数据:{}", list, e);
-        }
+        TenantAsyncContextHelper.runWithTenantContext(() -> {
+            try {
+                list.stream().forEach(i -> i.setCreateTime(new Date()));
+                this.saveBatch(list);
+            } catch (Exception e) {
+                log.error("批量记录任务执行日志失败:失败数据:{}", list, e);
+            }
+        });
     }
 
     public CompanyVoiceRoboticCallLogCallphone selectLogByRoboticIdAndCallerId(Long roboticId, Long callerId) {

+ 10 - 7
fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticCallLogSendmsgServiceImpl.java

@@ -8,6 +8,7 @@ import com.fs.company.mapper.CompanyVoiceRoboticCallLogSendmsgMapper;
 import com.fs.company.service.ICompanyVoiceRoboticCallLogSendmsgService;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogCount;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogSendmsgVO;
+import com.fs.wxcid.utils.TenantAsyncContextHelper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.scheduling.annotation.Async;
@@ -111,13 +112,15 @@ public class CompanyVoiceRoboticCallLogSendmsgServiceImpl extends ServiceImpl<Co
      */
     @Async("callLogExcutor")
     public void asyncInsertCompanyVoiceRoboticCallLog(CompanyVoiceRoboticCallLogSendmsg sendMsgLog) {
-        try{
-            sendMsgLog.setCreateTime(DateUtils.getNowDate());
-            baseMapper.insertCompanyVoiceRoboticCallLogSendmsg(sendMsgLog);
-            companyVoiceRoboticBusinessMapper.updateActionCount(3,sendMsgLog.getRoboticId(),sendMsgLog.getCallerId(),null);
-        } catch (Exception e) {
-            log.error("记录任务执行日志失败:失败数据:{}",sendMsgLog, e);
-        }
+        TenantAsyncContextHelper.runWithTenantContext(() -> {
+            try {
+                sendMsgLog.setCreateTime(DateUtils.getNowDate());
+                baseMapper.insertCompanyVoiceRoboticCallLogSendmsg(sendMsgLog);
+                companyVoiceRoboticBusinessMapper.updateActionCount(3, sendMsgLog.getRoboticId(), sendMsgLog.getCallerId(), null);
+            } catch (Exception e) {
+                log.error("记录任务执行日志失败:失败数据:{}", sendMsgLog, e);
+            }
+        });
     }
 
     @Override

+ 3 - 0
fs-service/src/main/java/com/fs/company/service/impl/call/node/AiCallTaskNode.java

@@ -308,6 +308,9 @@ public class AiCallTaskNode extends AbstractWorkflowNode {
         if (gatewayResult == null || StringUtils.isBlank(gatewayResult.getString("callBackUuid"))) {
             throw new RuntimeException("通讯网关外呼返回异常");
         }
+        if (StringUtils.isBlank(gatewayResult.getString("phone"))) {
+            throw new RuntimeException("通讯网关外呼失败:被叫号码无效");
+        }
         context.setVariable("callBackUuid", gatewayResult.getString("callBackUuid"));
         if (gatewayResult.getLong("batchId") != null) {
             context.setVariable("easyCallBatchId", gatewayResult.getLong("batchId"));

+ 14 - 0
fs-service/src/main/java/com/fs/his/utils/PhoneUtil.java

@@ -56,6 +56,20 @@ public class PhoneUtil {
         return text;
     }
 
+    /**
+     * 自动识别加密/明文手机号并解密(不脱敏)
+     */
+    public static String decryptAutoPhone(String encryptedText) {
+        if (encryptedText == null || encryptedText.isEmpty()) {
+            return null;
+        }
+        String text = encryptedText.trim();
+        if (text.length() > 11) {
+            return decryptPhone(text);
+        }
+        return text;
+    }
+
     public static String decryptAutoPhoneMk(String encryptedText) {
         String text=null;
         if (encryptedText!=null&&encryptedText!="") {

+ 7 - 49
fs-service/src/main/java/com/fs/wxcid/threadExecutor/callLogTaskExecutor.java

@@ -1,22 +1,13 @@
 package com.fs.wxcid.threadExecutor;
 
-import com.fs.common.config.RedisTenantContext;
-import com.fs.common.core.domain.model.TenantPrincipal;
-import com.fs.common.utils.spring.SpringUtils;
-import com.fs.core.config.TenantConfigContext;
-import com.fs.wxcid.utils.TenantHelper;
+import com.fs.wxcid.utils.TenantAsyncContextHelper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.core.task.TaskDecorator;
 import org.springframework.scheduling.annotation.EnableAsync;
 import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
-import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
-import org.springframework.security.core.context.SecurityContextHolder;
 
-import java.lang.reflect.InvocationTargetException;
-import java.lang.reflect.Method;
-import java.util.Collections;
 import java.util.concurrent.Executor;
 import java.util.concurrent.ThreadPoolExecutor;
 
@@ -25,7 +16,7 @@ import java.util.concurrent.ThreadPoolExecutor;
 @Slf4j
 public class callLogTaskExecutor {
 
-    @Bean( "callLogExcutor")
+    @Bean("callLogExcutor")
     public Executor callLogExcutor() {
         ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
         executor.setCorePoolSize(8);
@@ -35,56 +26,23 @@ public class callLogTaskExecutor {
         executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
         executor.setKeepAliveSeconds(60);
         executor.initialize();
-        // 配置任务装饰器,自动传递租户ID到子线程
         executor.setTaskDecorator(new TenantContextTaskDecorator());
         return executor;
     }
+
     /**
-     * 租户上下文任务装饰器
-     * 在任务执行前捕获当前线程的租户ID,在子线程中恢复
+     * 捕获提交线程的租户上下文(TenantHelper + DynamicDataSource),在 CallLog 异步线程中恢复。
      */
     public static class TenantContextTaskDecorator implements TaskDecorator {
         @Override
         public Runnable decorate(Runnable runnable) {
-            // 捕获当前线程的租户ID
-            Long tenantId = TenantHelper.getTenantId();
+            TenantAsyncContextHelper.Snapshot snapshot = TenantAsyncContextHelper.capture();
             return () -> {
                 try {
-                    // 在子线程中设置租户ID
-                    TenantHelper.setTenantId(tenantId);
-                    Object manager = SpringUtils.getBean("tenantDataSourceManager");
-                    Method method = manager.getClass().getMethod("ensureSwitchByTenantId", Long.class);
-                    method.invoke(manager, TenantHelper.getTenantId());
-                    // 设置租户到 SecurityContext,供 TenantKeyRedisSerializer 自动为 Redis Key 加 tenantid 前缀
-                    SecurityContextHolder.getContext().setAuthentication(
-                            new UsernamePasswordAuthenticationToken(
-                                    new TenantPrincipal(TenantHelper.getTenantId()),
-                                    null,
-                                    Collections.emptyList()
-                            )
-                    );
-                    // 切换 Redis 租户上下文
-                    RedisTenantContext.setTenantId(TenantHelper.getTenantId());
+                    TenantAsyncContextHelper.apply(snapshot);
                     runnable.run();
-                } catch (InvocationTargetException e) {
-                    throw new RuntimeException(e);
-                } catch (NoSuchMethodException e) {
-                    throw new RuntimeException(e);
-                } catch (IllegalAccessException e) {
-                    throw new RuntimeException(e);
                 } finally {
-                    try {
-                        // 清理子线程的租户ID
-                        TenantHelper.removeTenantId();
-                        TenantConfigContext.clear();
-                        SecurityContextHolder.clearContext();
-                        Object manager = SpringUtils.getBean("tenantDataSourceManager");
-                        Method method = manager.getClass().getMethod("clear");
-                        method.invoke(manager);
-                        RedisTenantContext.clear();
-                    } catch (Exception e) {
-                        log.error("SOP异步任务清理租户数据源失败", e);
-                    }
+                    TenantAsyncContextHelper.clear();
                 }
             };
         }

+ 123 - 0
fs-service/src/main/java/com/fs/wxcid/utils/TenantAsyncContextHelper.java

@@ -0,0 +1,123 @@
+package com.fs.wxcid.utils;
+
+import com.fs.common.config.RedisTenantContext;
+import com.fs.common.core.domain.model.TenantPrincipal;
+import com.fs.common.utils.spring.SpringUtils;
+import com.fs.core.config.TenantConfigContext;
+import com.fs.framework.datasource.DynamicDataSourceContextHolder;
+import com.fs.framework.datasource.TenantDataSourceManager;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+
+import java.util.Collections;
+
+/**
+ * 异步线程租户上下文传递与切库辅助类
+ */
+@Slf4j
+public final class TenantAsyncContextHelper {
+
+    private TenantAsyncContextHelper() {
+    }
+
+    public static final class Snapshot {
+        private final Long tenantId;
+        private final String dataSourceType;
+
+        private Snapshot(Long tenantId, String dataSourceType) {
+            this.tenantId = tenantId;
+            this.dataSourceType = dataSourceType;
+        }
+
+        public Long getTenantId() {
+            return tenantId;
+        }
+
+        public String getDataSourceType() {
+            return dataSourceType;
+        }
+
+        public boolean hasTenantContext() {
+            return tenantId != null || dataSourceType != null;
+        }
+    }
+
+    public static Snapshot capture() {
+        Long tenantId = TenantHelper.getTenantId();
+        String dataSourceType = DynamicDataSourceContextHolder.getDataSourceType();
+        if (tenantId == null) {
+            tenantId = parseTenantId(dataSourceType);
+        }
+        return new Snapshot(tenantId, dataSourceType);
+    }
+
+    public static Long resolveCurrentTenantId() {
+        Long tenantId = TenantHelper.getTenantId();
+        if (tenantId != null) {
+            return tenantId;
+        }
+        return parseTenantId(DynamicDataSourceContextHolder.getDataSourceType());
+    }
+
+    public static Long parseTenantId(String dataSourceType) {
+        if (dataSourceType != null && dataSourceType.startsWith("tenant:")) {
+            try {
+                return Long.parseLong(dataSourceType.substring("tenant:".length()));
+            } catch (NumberFormatException ex) {
+                log.warn("[TenantAsync] 无法解析租户数据源标识: {}", dataSourceType);
+            }
+        }
+        return null;
+    }
+
+    public static void apply(Snapshot snapshot) {
+        if (snapshot == null || !snapshot.hasTenantContext()) {
+            return;
+        }
+        Long tenantId = snapshot.getTenantId();
+        if (tenantId != null) {
+            TenantHelper.setTenantId(tenantId);
+            TenantDataSourceManager manager = SpringUtils.getBean(TenantDataSourceManager.class);
+            manager.ensureSwitchByTenantId(tenantId);
+            SecurityContextHolder.getContext().setAuthentication(
+                    new UsernamePasswordAuthenticationToken(
+                            new TenantPrincipal(tenantId),
+                            null,
+                            Collections.emptyList()
+                    )
+            );
+            RedisTenantContext.setTenantId(tenantId);
+            return;
+        }
+        DynamicDataSourceContextHolder.setDataSourceType(snapshot.getDataSourceType());
+    }
+
+    public static void clear() {
+        try {
+            TenantHelper.removeTenantId();
+            TenantConfigContext.clear();
+            SecurityContextHolder.clearContext();
+            TenantDataSourceManager manager = SpringUtils.getBean(TenantDataSourceManager.class);
+            manager.clear();
+            RedisTenantContext.clear();
+        } catch (Exception e) {
+            log.error("[TenantAsync] 清理租户上下文失败", e);
+        }
+    }
+
+    public static void runWithTenantContext(Runnable action) {
+        Snapshot snapshot = capture();
+        if (!snapshot.hasTenantContext()) {
+            log.warn("[TenantAsync] 未识别租户上下文,操作将在当前数据源执行");
+            action.run();
+            return;
+        }
+        try {
+            apply(snapshot);
+            action.run();
+        } finally {
+            clear();
+        }
+    }
+}