yzx 2 недель назад
Родитель
Сommit
303c8f1ae6
100 измененных файлов с 6609 добавлено и 431 удалено
  1. 1 1
      README.md
  2. 53 0
      ruoyi-admin/pom.xml
  3. 175 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/aliyun/AliyunFirewallPolicyManager.java
  4. 382 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/claude/AwsClaudeClient.java
  5. 368 20
      ruoyi-admin/src/main/java/com/ruoyi/aicall/controller/ApiController.java
  6. 28 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/controller/CcCallPhoneController.java
  7. 118 5
      ruoyi-admin/src/main/java/com/ruoyi/aicall/controller/CcCallTaskController.java
  8. 107 8
      ruoyi-admin/src/main/java/com/ruoyi/aicall/controller/CcInboundLlmAccountController.java
  9. 51 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/controller/CcLlmAgentAccountController.java
  10. 160 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/controller/CcLlmKbCatController.java
  11. 185 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/controller/CcLlmKbController.java
  12. 86 6
      ruoyi-admin/src/main/java/com/ruoyi/aicall/controller/CcTtsAliyunController.java
  13. 367 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/controller/DoubaoVclController.java
  14. 39 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/domain/CcCallPhone.java
  15. 36 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/domain/CcCallTask.java
  16. 38 2
      ruoyi-admin/src/main/java/com/ruoyi/aicall/domain/CcInboundLlmAccount.java
  17. 7 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/domain/CcLlmAgentAccount.java
  18. 35 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/domain/CcLlmKb.java
  19. 35 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/domain/CcLlmKbCat.java
  20. 13 1
      ruoyi-admin/src/main/java/com/ruoyi/aicall/domain/CcTtsAliyun.java
  21. 6 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/mapper/CcCallPhoneMapper.java
  22. 2 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/mapper/CcCallTaskMapper.java
  23. 61 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/mapper/CcLlmKbCatMapper.java
  24. 67 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/mapper/CcLlmKbMapper.java
  25. 15 3
      ruoyi-admin/src/main/java/com/ruoyi/aicall/model/ApiCallRecordQueryParams.java
  26. 15 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/model/ApiCallRecordQueryResult.java
  27. 24 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/model/ApiGatewaysModel.java
  28. 4 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/model/CallPhoneExportVo.java
  29. 72 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/model/DoubaoVclTtsRequest.java
  30. 13 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/model/KbContentImportDTO.java
  31. 12 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/model/LocalCallModel.java
  32. 119 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/openai/OpenAIChatClient.java
  33. 6 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/service/ICcCallPhoneService.java
  34. 2 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/service/ICcCallTaskService.java
  35. 69 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/service/ICcLlmKbCatService.java
  36. 70 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/service/ICcLlmKbService.java
  37. 15 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/service/impl/CcCallPhoneServiceImpl.java
  38. 5 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/service/impl/CcCallTaskServiceImpl.java
  39. 112 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/service/impl/CcLlmKbCatServiceImpl.java
  40. 156 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/service/impl/CcLlmKbServiceImpl.java
  41. 175 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/tencent/TencentCloudManage.java
  42. 99 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/tts/aliyun/AliAccountTokenCreator.java
  43. 49 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/tts/aliyun/AlibabaTokenEntity.java
  44. 145 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/tts/aliyun/AliyunTTSWebApi.java
  45. 17 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/tts/aliyun/AliyunTtsAccount.java
  46. 96 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/tts/doubao/DoubaoTTSWebApi.java
  47. 20 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/tts/doubao/DoubaoTtsAccount.java
  48. 62 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/tts/doubao/TtsHttpDemo.java
  49. 74 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/tts/doubao/TtsRequest.java
  50. 160 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/tts/doubao/TtsWebsocketDemo.java
  51. 178 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/utils/BillingTaskQueue.java
  52. 80 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/utils/PcmToWavConverter.java
  53. 95 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/utils/ProcessSQLQueue.java
  54. 44 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/utils/WavFileWriter.java
  55. 12 0
      ruoyi-admin/src/main/java/com/ruoyi/aicall/utils/XSSFUtils.java
  56. 9 0
      ruoyi-admin/src/main/java/com/ruoyi/cc/controller/CcExtNumController.java
  57. 102 42
      ruoyi-admin/src/main/java/com/ruoyi/cc/controller/CcGatewaysController.java
  58. 52 92
      ruoyi-admin/src/main/java/com/ruoyi/cc/controller/CcInboundCdrController.java
  59. 358 0
      ruoyi-admin/src/main/java/com/ruoyi/cc/controller/CcIvrController.java
  60. 18 86
      ruoyi-admin/src/main/java/com/ruoyi/cc/controller/CcOutboundCdrController.java
  61. 12 5
      ruoyi-admin/src/main/java/com/ruoyi/cc/controller/CcParamsController.java
  62. 258 42
      ruoyi-admin/src/main/java/com/ruoyi/cc/controller/FsConfController.java
  63. 4 1
      ruoyi-admin/src/main/java/com/ruoyi/cc/domain/CcGateways.java
  64. 35 0
      ruoyi-admin/src/main/java/com/ruoyi/cc/domain/CcInboundCdr.java
  65. 124 0
      ruoyi-admin/src/main/java/com/ruoyi/cc/domain/CcIvr.java
  66. 3 0
      ruoyi-admin/src/main/java/com/ruoyi/cc/domain/CcOutboundCdr.java
  67. 4 0
      ruoyi-admin/src/main/java/com/ruoyi/cc/domain/CcParams.java
  68. 6 0
      ruoyi-admin/src/main/java/com/ruoyi/cc/mapper/CcExtNumMapper.java
  69. 65 0
      ruoyi-admin/src/main/java/com/ruoyi/cc/mapper/CcIvrMapper.java
  70. 6 0
      ruoyi-admin/src/main/java/com/ruoyi/cc/service/ICcExtNumService.java
  71. 6 0
      ruoyi-admin/src/main/java/com/ruoyi/cc/service/ICcGatewaysService.java
  72. 69 0
      ruoyi-admin/src/main/java/com/ruoyi/cc/service/ICcIvrService.java
  73. 25 1
      ruoyi-admin/src/main/java/com/ruoyi/cc/service/IFsConfService.java
  74. 6 0
      ruoyi-admin/src/main/java/com/ruoyi/cc/service/impl/CcExtNumServiceImpl.java
  75. 84 0
      ruoyi-admin/src/main/java/com/ruoyi/cc/service/impl/CcGatewaysServiceImpl.java
  76. 55 4
      ruoyi-admin/src/main/java/com/ruoyi/cc/service/impl/CcInboundCdrServiceImpl.java
  77. 131 0
      ruoyi-admin/src/main/java/com/ruoyi/cc/service/impl/CcIvrServiceImpl.java
  78. 55 4
      ruoyi-admin/src/main/java/com/ruoyi/cc/service/impl/CcOutboundCdrServiceImpl.java
  79. 12 1
      ruoyi-admin/src/main/java/com/ruoyi/cc/service/impl/CcParamsServiceImpl.java
  80. 69 1
      ruoyi-admin/src/main/java/com/ruoyi/cc/service/impl/FsConfServiceImpl.java
  81. 74 47
      ruoyi-admin/src/main/java/com/ruoyi/cc/task/CustIntentionHandleQueue.java
  82. 10 0
      ruoyi-admin/src/main/java/com/ruoyi/cc/utils/CmdExecException.java
  83. 52 0
      ruoyi-admin/src/main/java/com/ruoyi/cc/utils/ShellUtil.java
  84. 22 3
      ruoyi-admin/src/main/java/com/ruoyi/cc/utils/Test.java
  85. 18 4
      ruoyi-admin/src/main/java/com/ruoyi/web/controller/monitor/ServerController.java
  86. 7 8
      ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysIndexController.java
  87. 11 2
      ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysLoginController.java
  88. 97 0
      ruoyi-admin/src/main/java/com/ruoyi/web/core/config/LuaDbCheckRunner.java
  89. 3 0
      ruoyi-admin/src/main/java/com/ruoyi/web/core/config/VersionCheckRunner.java
  90. 4 1
      ruoyi-admin/src/main/resources/application-dev.yml
  91. 3 1
      ruoyi-admin/src/main/resources/application-local.yml
  92. 3 1
      ruoyi-admin/src/main/resources/application-pro.yml
  93. 4 2
      ruoyi-admin/src/main/resources/application-test.yml
  94. 1 1
      ruoyi-admin/src/main/resources/application.yml
  95. 55 6
      ruoyi-admin/src/main/resources/mapper/aicall/CcCallPhoneMapper.xml
  96. 31 2
      ruoyi-admin/src/main/resources/mapper/aicall/CcCallTaskMapper.xml
  97. 50 27
      ruoyi-admin/src/main/resources/mapper/aicall/CcInboundLlmAccountMapper.xml
  98. 9 1
      ruoyi-admin/src/main/resources/mapper/aicall/CcLlmAgentAccountMapper.xml
  99. 62 0
      ruoyi-admin/src/main/resources/mapper/aicall/CcLlmKbCatMapper.xml
  100. 90 0
      ruoyi-admin/src/main/resources/mapper/aicall/CcLlmKbMapper.xml

+ 1 - 1
README.md

@@ -21,5 +21,5 @@
 
 bug反馈或者咨询问题请在gitee/github上,新建 Issue,并贴上日志。
 
-![联系方式](wechat.png)
+![联系方式](wetchat.png)
 

+ 53 - 0
ruoyi-admin/pom.xml

@@ -100,6 +100,59 @@
             <artifactId>java-jwt</artifactId>
             <version>3.10.3</version>
         </dependency>
+
+        <!-- aliyun tts -->
+        <dependency>
+            <groupId>com.aliyun</groupId>
+            <artifactId>aliyun-java-sdk-core</artifactId>
+            <version>4.6.4</version>
+        </dependency>
+
+
+        <dependency>
+            <groupId>org.java-websocket</groupId>
+            <artifactId>Java-WebSocket</artifactId>
+            <version>1.5.1</version>
+        </dependency>
+
+        <dependency>
+            <groupId>org.apache.commons</groupId>
+            <artifactId>commons-exec</artifactId>
+            <version>1.4.0</version>
+        </dependency>
+
+<!--        &lt;!&ndash; AWS SDK for Bedrock &ndash;&gt;-->
+<!--        <dependency>-->
+<!--            <groupId>software.amazon.awssdk</groupId>-->
+<!--            <artifactId>bedrockruntime</artifactId>-->
+<!--            <version>2.25.0</version>-->
+<!--        </dependency>-->
+
+<!--        &lt;!&ndash; OpenAI 官方客户端 &ndash;&gt;-->
+<!--        <dependency>-->
+<!--            <groupId>com.openai</groupId>-->
+<!--            <artifactId>openai-java-core</artifactId>-->
+<!--            <version>2.6.0</version>-->
+<!--        </dependency>-->
+
+        <dependency>
+            <groupId>com.aliyun</groupId>
+            <artifactId>aliyun-java-sdk-core</artifactId>
+            <version>4.6.4</version>
+        </dependency>
+        <dependency>
+            <groupId>com.aliyun</groupId>
+            <artifactId>cloudfw20171207</artifactId>
+            <version>8.0.2</version>
+        </dependency>
+
+        <!-- tencent cloud sdk -->
+        <dependency>
+            <groupId>com.tencentcloudapi</groupId>
+            <artifactId>tencentcloud-sdk-java-lighthouse</artifactId>
+            <version>3.1.1114</version>  <!-- 或尝试 3.1.1000 -->
+        </dependency>
+
     </dependencies>
 
     <build>

+ 175 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/aliyun/AliyunFirewallPolicyManager.java

@@ -0,0 +1,175 @@
+package com.ruoyi.aicall.aliyun;
+
+import com.aliyun.cloudfw20171207.models.*;
+import com.aliyun.teaopenapi.models.*;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * 云防火墙策略管理工具类
+ */
+@Slf4j
+public class AliyunFirewallPolicyManager {
+
+    /**
+     * 使用AK&SK初始化账号Client
+     * @param accessKeyId
+     * @param accessKeySecret
+     * @param regionId
+     * @return Client
+     * @throws Exception
+     */
+    public static com.aliyun.cloudfw20171207.Client createClient(String accessKeyId, String accessKeySecret, String regionId) throws Exception {
+        Config config = new Config();
+        // 您的AccessKey ID
+        config.accessKeyId = accessKeyId;
+        // 您的AccessKey Secret
+        config.accessKeySecret = accessKeySecret;
+        // 您的可用区ID
+        config.regionId = regionId;
+        return new com.aliyun.cloudfw20171207.Client(config);
+    }
+
+    /**
+     * 创建集群
+     * @param client
+     * @return AddControlPolicyResponse
+     * @throws Exception
+     */
+    public static AddControlPolicyResponse addControlPolicy(com.aliyun.cloudfw20171207.Client client, String direction, String sourceType, String source, String destinationType, String destination, String proto, String destPortType, String destPort, String destPortGroup, String applicationName, String aclAction, String description, String ipVersion, String newOrder, String release) throws Exception {
+        AddControlPolicyRequest addControlPolicyRequest = new AddControlPolicyRequest()
+                // 枚举值: in/out, 分别代表"外对内"/"内对外"
+                .setDirection(direction)
+                // 源类型, 枚举值, net/group/location, 分表代表 "IP"/"地址簿"/"区域"
+                .setSourceType(sourceType)
+                // 如果源为 net, source 就是 CIDR, 如 8.8.8.8/32, 如果是 gorup, source 就是 groupId
+                .setSource(source)
+                // 目的类型, 枚举值, net/group, 分表代表 "IP"/"地址簿"
+                .setDestinationType(destinationType)
+                // 如果目的类型为 net, destination 就是 CIDR, 如 8.8.8.8/32, 如果是 gorup, destination 就是 groupId
+                .setDestination(destination)
+                // 协议类型, TCP/UDP/ICMP/ANY
+                .setProto(proto)
+                // 目的端口类型, 枚举值, port/group, 分别代表: "端口"/"端口地址簿"
+                .setDestPortType(destPortType)
+                // destPortType 为 port时, destPort值必填, destPortGroup不填
+                .setDestPort(destPort)
+                // destPortType 为 group, destPortGroup值必填, destPort不填
+                .setDestPortGroup(destPortGroup)
+                // 应用名:
+                .setApplicationName(applicationName)
+                // 拦截策略: log/accept/drop
+                .setAclAction(aclAction)
+                // 描述
+                .setDescription(description)
+                // 4/6
+                .setIpVersion(ipVersion)
+                // 新的排序, -1, 放最后
+                .setNewOrder(newOrder)
+                // 是否生效, true/false
+                .setRelease(release);
+        return client.addControlPolicy(addControlPolicyRequest);
+    }
+
+
+
+    /**
+     * 获取策略
+     * @param client
+     * @return DescribeControlPolicyResponse
+     * @throws Exception
+     */
+    public static DescribeControlPolicyResponse describeControlPolicy(com.aliyun.cloudfw20171207.Client client, String pageNo, String pageSize, String direction, String ipVersion) throws Exception {
+        DescribeControlPolicyRequest describeControlPolicyRequest = new DescribeControlPolicyRequest()
+                // 第几页
+                .setCurrentPage(pageNo)
+                // 每页多少条
+                .setPageSize(pageSize)
+                // 方向, 枚举值, in/out
+                .setDirection(direction)
+                // IP版本, 枚举值, 4/6
+                .setIpVersion(ipVersion);
+        return client.describeControlPolicy(describeControlPolicyRequest);
+    }
+
+
+    /**
+     * 删除策略
+     * @param client
+     * @return DeleteControlPolicyResponse
+     * @throws Exception
+     */
+    public static DeleteControlPolicyResponse deleteControlPolicy(com.aliyun.cloudfw20171207.Client client, String aclUuid, String direction) throws Exception {
+        DeleteControlPolicyRequest deleteControlPolicyRequest = new DeleteControlPolicyRequest()
+                // 唯一 id, 请参见创建的返回值
+                .setAclUuid(aclUuid)
+                // 枚举值: in/out, 分别代表"外对内"/"内对外"
+                .setDirection(direction);
+        return client.deleteControlPolicy(deleteControlPolicyRequest);
+    }
+
+
+    public static void main(String[] args_) throws Exception {
+        String accessKeyId = "";
+        String accessKeySecret = "";
+
+        // 0. 初始化客户端
+        com.aliyun.cloudfw20171207.Client client = AliyunFirewallPolicyManager.createClient(accessKeyId, accessKeySecret, "cn-hangzhou");
+        // 1. 获取访问策略列表
+        String pageNo = "1";
+        String pageSize = "100";
+        String direction = "";
+        String ipVersion = "";
+        DescribeControlPolicyResponse describeControlPolicyResponse = AliyunFirewallPolicyManager.describeControlPolicy(client, pageNo, pageSize, direction, ipVersion);
+        DescribeControlPolicyResponseBody body = describeControlPolicyResponse.body;
+        log.info("Describe Control Policy, TotalCount: " + body.totalCount + ".");
+        for (DescribeControlPolicyResponseBody.DescribeControlPolicyResponseBodyPolicys policy : body.policys) {
+            log.info("aclUuid: " + policy.aclUuid + ", aclAction: " + policy.aclAction + "!");
+        }
+//        // 2. 创建访问策略
+//        direction = "in";
+//        String sourceType = "accept";
+//        String source = "36.7.79.15";
+//        String destinationType = "net";
+//        String destination = "";
+//        String proto = "UDP";
+//        String destPortType = "port";
+//        String destPort = "5060";
+//        String destPortGroup = "";
+//        String applicationName = "ANY";
+//        String aclAction = "";
+//        String description = "";
+//        ipVersion = "";
+//        String newOrder = "";
+//        String release = "";
+//        AddControlPolicyResponse addControlPolicyResponse = CloudFirewallPolicyManager.addControlPolicy(client, direction, sourceType, source, destinationType, destination, proto, destPortType, destPort, destPortGroup, applicationName, aclAction, description, ipVersion, newOrder, release);
+//        String aclUuid = addControlPolicyResponse.body.aclUuid;
+//        log.info("Create Control Policy, AclUuid Id is " + aclUuid + ".");
+//
+//        // 3. 获取访问策略列表
+//        direction = "";
+//        ipVersion = "";
+//        describeControlPolicyResponse = CloudFirewallPolicyManager.describeControlPolicy(client, pageNo, pageSize, direction, ipVersion);
+//        body = describeControlPolicyResponse.body;
+//        log.info("Describe Control Policy, TotalCount: " + body.totalCount + ".");
+//        for (DescribeControlPolicyResponseBody.DescribeControlPolicyResponseBodyPolicys policy : body.policys) {
+//            log.info("aclUuid: " + policy.aclUuid + ", aclAction: " + policy.aclAction + "!");
+//        }
+//
+//        // 4. 删除访问策略
+////        aclUuid = "";
+//        direction = "";
+//        DeleteControlPolicyResponse deleteControlPolicyResponse = CloudFirewallPolicyManager.deleteControlPolicy(client, aclUuid, direction);
+//        String requestId = deleteControlPolicyResponse.body.requestId;
+//        log.info("Delete Control Policy, requestId: " + requestId + ".");
+//
+//
+//        // 5. 获取访问策略列表
+//        describeControlPolicyResponse = CloudFirewallPolicyManager.describeControlPolicy(client, pageNo, pageSize, direction, ipVersion);
+//        body = describeControlPolicyResponse.body;
+//        log.info("Describe Control Policy, TotalCount: " + body.totalCount + ".");
+//        for (DescribeControlPolicyResponseBody.DescribeControlPolicyResponseBodyPolicys policy : body.policys) {
+//            log.info("aclUuid: " + policy.aclUuid + ", aclAction: " + policy.aclAction + "!");
+//        }
+
+    }
+}

+ 382 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/claude/AwsClaudeClient.java

@@ -0,0 +1,382 @@
+//package com.ruoyi.aicall.utils;
+//
+//import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
+//import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
+//import software.amazon.awssdk.core.SdkBytes;
+//import software.amazon.awssdk.regions.Region;
+//import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeClient;
+//import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeAsyncClient;
+//import software.amazon.awssdk.services.bedrockruntime.model.*;
+//import software.amazon.awssdk.core.async.SdkPublisher;
+//
+//import java.util.ArrayList;
+//import java.util.Arrays;
+//import java.util.List;
+//import java.util.concurrent.CompletableFuture;
+//import java.util.concurrent.ExecutionException;
+//
+///**
+// * AWS Bedrock Claude 模型调用工具类
+// * 支持 Claude 3 (Haiku, Sonnet, Opus) 和 Claude 2 系列模型
+// */
+//public class AwsClaudeClient implements AutoCloseable {
+//
+//    private final BedrockRuntimeClient bedrockClient;
+//    private final BedrockRuntimeAsyncClient asyncClient;
+//    private final String modelId;
+//
+//    // 常用模型ID常量
+//    public static final String CLAUDE_3_5_SONNET = "anthropic.claude-3-5-sonnet-20240620-v1:0";
+//    public static final String CLAUDE_3_OPUS = "anthropic.claude-3-opus-20240229-v1:0";
+//    public static final String CLAUDE_3_SONNET = "anthropic.claude-3-sonnet-20240229-v1:0";
+//    public static final String CLAUDE_3_HAIKU = "anthropic.claude-3-haiku-20240307-v1:0";
+//    public static final String CLAUDE_2_1 = "anthropic.claude-v2:1";
+//    public static final String CLAUDE_2 = "anthropic.claude-v2";
+//    public static final String CLAUDE_INSTANT = "anthropic.claude-instant-v1";
+//
+//    /**
+//     * 构造函数
+//     *
+//     * @param accessKey  AWS Access Key
+//     * @param secretKey  AWS Secret Key
+//     * @param region     AWS 区域 (如 Region.US_EAST_1)
+//     * @param modelId    模型ID,可使用本类提供的常量
+//     */
+//    public AwsClaudeClient(String accessKey, String secretKey, Region region, String modelId) {
+//        AwsBasicCredentials credentials = AwsBasicCredentials.create(accessKey, secretKey);
+//        StaticCredentialsProvider credentialsProvider = StaticCredentialsProvider.create(credentials);
+//
+//        this.bedrockClient = BedrockRuntimeClient.builder()
+//                .region(region)
+//                .credentialsProvider(credentialsProvider)
+//                .build();
+//
+//        this.asyncClient = BedrockRuntimeAsyncClient.builder()
+//                .region(region)
+//                .credentialsProvider(credentialsProvider)
+//                .build();
+//
+//        this.modelId = modelId;
+//    }
+//
+//    /**
+//     * 简单文本对话(Claude 3 版本)
+//     *
+//     * @param userMessage 用户输入的消息
+//     * @return 模型的回复文本
+//     */
+//    public String chat(String userMessage) {
+//        return chat(userMessage, 1024, 0.7);
+//    }
+//
+//    /**
+//     * 带参数控制的文本对话(Claude 3 版本)
+//     *
+//     * @param userMessage   用户输入的消息
+//     * @param maxTokens     最大生成token数
+//     * @param temperature   温度参数 (0.0 - 1.0)
+//     * @return 模型的回复文本
+//     */
+//    public String chat(String userMessage, int maxTokens, double temperature) {
+//        // 构建 Claude 3 消息格式
+//        String payload = String.format(
+//                "{" +
+//                        "\"anthropic_version\": \"bedrock-2023-05-31\"," +
+//                        "\"max_tokens\": %d," +
+//                        "\"temperature\": %.2f," +
+//                        "\"messages\": [" +
+//                        "   {\"role\": \"user\", \"content\": \"%s\"}" +
+//                        "]" +
+//                        "}",
+//                maxTokens, temperature, escapeJson(userMessage)
+//        );
+//
+//        return invokeModel(payload);
+//    }
+//
+//    /**
+//     * 多轮对话(Claude 3 版本)
+//     *
+//     * @param messages    消息列表,包含 role 和 content
+//     * @param maxTokens   最大生成token数
+//     * @param temperature 温度参数
+//     * @return 模型的回复文本
+//     */
+//    public String chat(List<Message> messages, int maxTokens, double temperature) {
+//        StringBuilder messagesJson = new StringBuilder();
+//        for (int i = 0; i < messages.size(); i++) {
+//            Message msg = messages.get(i);
+//            messagesJson.append(String.format(
+//                    "{\"role\": \"%s\", \"content\": \"%s\"}",
+//                    msg.getRole(), escapeJson(msg.getContent())
+//            ));
+//            if (i < messages.size() - 1) {
+//                messagesJson.append(",");
+//            }
+//        }
+//
+//        String payload = String.format(
+//                "{" +
+//                        "\"anthropic_version\": \"bedrock-2023-05-31\"," +
+//                        "\"max_tokens\": %d," +
+//                        "\"temperature\": %.2f," +
+//                        "\"messages\": [%s]" +
+//                        "}",
+//                maxTokens, temperature, messagesJson.toString()
+//        );
+//
+//        return invokeModel(payload);
+//    }
+//
+//    /**
+//     * 流式调用(使用异步客户端)
+//     *
+//     * @param userMessage 用户消息
+//     * @param callback    流式响应回调
+//     */
+//    public void chatStream(String userMessage, StreamCallback callback) {
+//        String payload = String.format(
+//                "{" +
+//                        "\"anthropic_version\": \"bedrock-2023-05-31\"," +
+//                        "\"max_tokens\": 4096," +
+//                        "\"messages\": [" +
+//                        "   {\"role\": \"user\", \"content\": \"%s\"}" +
+//                        "]" +
+//                        "}",
+//                escapeJson(userMessage)
+//        );
+//
+//        InvokeModelWithResponseStreamRequest request = InvokeModelWithResponseStreamRequest.builder()
+//                .modelId(modelId)
+//                .body(SdkBytes.fromUtf8String(payload))
+//                .build();
+//
+//        // 使用异步客户端进行流式调用
+//        CompletableFuture<Void> future = asyncClient.invokeModelWithResponseStream(request,
+//                new InvokeModelWithResponseStreamResponseHandler() {
+//
+//                    @Override
+//                    public void responseReceived(InvokeModelWithResponseStreamResponse invokeModelWithResponseStreamResponse) {
+//
+//                    }
+//
+//                    @Override
+//                    public void onEventStream(SdkPublisher<ResponseStream> publisher) {
+//                        publisher.subscribe(event -> {
+//                            if (event instanceof PayloadPart) {
+//                                PayloadPart payloadPart = (PayloadPart) event;
+//                                String chunk = payloadPart.bytes().asUtf8String();
+//                                String content = extractContentFromChunk(chunk);
+//                                if (content != null && !content.isEmpty()) {
+//                                    callback.onChunkReceived(content);
+//                                }
+//                            }
+//                        });
+//                    }
+//
+//                    @Override
+//                    public void exceptionOccurred(Throwable throwable) {
+//                        callback.onError(throwable);
+//                    }
+//
+//                    @Override
+//                    public void complete() {
+//                        callback.onComplete();
+//                    }
+//                });
+//
+//        // 等待流式调用完成
+//        try {
+//            future.get();
+//        } catch (InterruptedException | ExecutionException e) {
+//            throw new RuntimeException("流式调用失败", e);
+//        }
+//    }
+//
+//    /**
+//     * 调用模型并解析响应
+//     */
+//    private String invokeModel(String payload) {
+//        InvokeModelRequest request = InvokeModelRequest.builder()
+//                .modelId(modelId)
+//                .body(SdkBytes.fromUtf8String(payload))
+//                .build();
+//
+//        InvokeModelResponse response = bedrockClient.invokeModel(request);
+//        String responseBody = response.body().asUtf8String();
+//
+//        return extractContent(responseBody);
+//    }
+//
+//    /**
+//     * 从响应 JSON 中提取 content 文本
+//     */
+//    private String extractContent(String jsonResponse) {
+//        // Claude 3 格式处理
+//        int contentStart = jsonResponse.indexOf("\"content\":");
+//        if (contentStart != -1) {
+//            int textStart = jsonResponse.indexOf("\"text\":", contentStart);
+//            if (textStart != -1) {
+//                int quoteStart = jsonResponse.indexOf("\"", textStart + 7) + 1;
+//                int quoteEnd = jsonResponse.indexOf("\"", quoteStart);
+//                if (quoteStart > 0 && quoteEnd > quoteStart) {
+//                    return unescapeJson(jsonResponse.substring(quoteStart, quoteEnd));
+//                }
+//            }
+//        }
+//
+//        // Claude 2 格式处理
+//        int completionStart = jsonResponse.indexOf("\"completion\":");
+//        if (completionStart != -1) {
+//            int quoteStart = jsonResponse.indexOf("\"", completionStart + 13) + 1;
+//            int quoteEnd = jsonResponse.lastIndexOf("\"");
+//            if (quoteStart > 0 && quoteEnd > quoteStart) {
+//                return unescapeJson(jsonResponse.substring(quoteStart, quoteEnd));
+//            }
+//        }
+//
+//        return jsonResponse;
+//    }
+//
+//    /**
+//     * 从流式响应块中提取内容
+//     */
+//    private String extractContentFromChunk(String chunk) {
+//        // 流式响应通常是 JSON 行格式
+//        if (chunk.contains("\"type\":\"content_block_delta\"") || chunk.contains("\"delta\"")) {
+//            int textStart = chunk.indexOf("\"text\":");
+//            if (textStart != -1) {
+//                int quoteStart = chunk.indexOf("\"", textStart + 7) + 1;
+//                int quoteEnd = chunk.indexOf("\"", quoteStart);
+//                if (quoteStart > 0 && quoteEnd > quoteStart) {
+//                    return unescapeJson(chunk.substring(quoteStart, quoteEnd));
+//                }
+//            }
+//        }
+//        return chunk;
+//    }
+//
+//    /**
+//     * JSON 字符串转义
+//     */
+//    private String escapeJson(String input) {
+//        if (input == null) return "";
+//        return input.replace("\\", "\\\\")
+//                .replace("\"", "\\\"")
+//                .replace("\n", "\\n")
+//                .replace("\r", "\\r")
+//                .replace("\t", "\\t");
+//    }
+//
+//    /**
+//     * JSON 字符串反转义
+//     */
+//    private String unescapeJson(String input) {
+//        if (input == null) return "";
+//        return input.replace("\\\"", "\"")
+//                .replace("\\n", "\n")
+//                .replace("\\r", "\r")
+//                .replace("\\t", "\t")
+//                .replace("\\\\", "\\");
+//    }
+//
+//    @Override
+//    public void close() {
+//        bedrockClient.close();
+//        asyncClient.close();
+//    }
+//
+//    // ==================== 内部类和接口 ====================
+//
+//    /**
+//     * 消息对象,用于多轮对话
+//     */
+//    public static class Message {
+//        private final String role;    // "user" 或 "assistant"
+//        private final String content;
+//
+//        public Message(String role, String content) {
+//            this.role = role;
+//            this.content = content;
+//        }
+//
+//        public String getRole() { return role; }
+//        public String getContent() { return content; }
+//
+//        public static Message user(String content) {
+//            return new Message("user", content);
+//        }
+//
+//        public static Message assistant(String content) {
+//            return new Message("assistant", content);
+//        }
+//    }
+//
+//    /**
+//     * 流式响应回调接口
+//     */
+//    public interface StreamCallback {
+//        void onChunkReceived(String chunk);
+//        void onError(Throwable throwable);
+//        void onComplete();
+//    }
+//
+//    // ==================== 使用示例 ====================
+//
+//    public static void main(String[] args) {
+//        // 配置参数(建议从配置文件或环境变量读取)
+//        String accessKey = "AKIAZSHZ4G4NU37EOYFT";
+//        String secretKey = "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";
+//        Region region = Region.US_EAST_1;  // Bedrock 主要在此区域
+//
+//        try (AwsClaudeClient client = new AwsClaudeClient(
+//                accessKey, secretKey, region, CLAUDE_3_5_SONNET)) {
+//
+//            // 1. 简单调用
+//            System.out.println("=== 简单调用 ===");
+//            String response = client.chat("你好,请介绍一下你自己");
+//            System.out.println("响应: " + response);
+//
+//            // 2. 带参数调用
+//            System.out.println("\n=== 带参数调用 ===");
+//            String response2 = client.chat(
+//                    "写一首关于春天的诗",
+//                    2048,    // 最大token
+//                    0.9      // 更高的创造性
+//            );
+//            System.out.println("诗歌: " + response2);
+//
+//            // 3. 多轮对话
+//            System.out.println("\n=== 多轮对话 ===");
+//            List<Message> messages = Arrays.asList(
+//                    Message.user("你好"),
+//                    Message.assistant("你好!有什么我可以帮助你的吗?"),
+//                    Message.user("请用 Java 写一个单例模式")
+//            );
+//            String multiTurnResponse = client.chat(messages, 2048, 0.7);
+//            System.out.println("多轮响应: " + multiTurnResponse);
+//
+//            // 4. 流式调用
+//            System.out.println("\n=== 流式调用 ===");
+//            client.chatStream("请详细解释 Java 的内存模型,包括堆、栈、方法区等", new StreamCallback() {
+//                @Override
+//                public void onChunkReceived(String chunk) {
+//                    System.out.print(chunk);
+//                }
+//
+//                @Override
+//                public void onError(Throwable throwable) {
+//                    System.err.println("流式调用出错: " + throwable.getMessage());
+//                }
+//
+//                @Override
+//                public void onComplete() {
+//                    System.out.println("\n[流式调用完成]");
+//                }
+//            });
+//
+//        } catch (Exception e) {
+//            e.printStackTrace();
+//        }
+//    }
+//}

+ 368 - 20
ruoyi-admin/src/main/java/com/ruoyi/aicall/controller/ApiController.java

@@ -13,20 +13,20 @@ import com.ruoyi.aicall.service.ICcCallTaskService;
 import com.ruoyi.aicall.service.ICcLlmAgentAccountService;
 import com.ruoyi.aicall.service.ICcTtsAliyunService;
 import com.ruoyi.aicall.utils.ClientIpCheck;
-import com.ruoyi.cc.domain.CcBizGroup;
-import com.ruoyi.cc.domain.CcGateways;
-import com.ruoyi.cc.service.ICcBizGroupService;
-import com.ruoyi.cc.service.ICcGatewaysService;
-import com.ruoyi.cc.service.ICcParamsService;
+import com.ruoyi.cc.domain.*;
+import com.ruoyi.cc.service.*;
 import com.ruoyi.cc.utils.DateValidatorUtils;
 import com.ruoyi.common.core.controller.BaseController;
 import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.core.domain.entity.SysUser;
 import com.ruoyi.common.core.page.TableDataInfo;
 import com.ruoyi.common.utils.DateUtils;
+import com.ruoyi.common.utils.ShiroUtils;
 import com.ruoyi.common.utils.StringUtils;
 import com.ruoyi.common.utils.bean.BeanUtils;
 import com.ruoyi.common.utils.uuid.UuidGenerator;
 import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.RandomUtils;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Controller;
 import org.springframework.util.CollectionUtils;
@@ -45,6 +45,10 @@ public class ApiController extends BaseController {
     @Autowired
     private ICcCallPhoneService ccCallPhoneService;
     @Autowired
+    private ICcInboundCdrService inboundCdrService;
+    @Autowired
+    private ICcOutboundCdrService outboundCdrService;
+    @Autowired
     private ICcParamsService paramsService;
     @Autowired
     private ICcGatewaysService ccGatewaysService;
@@ -56,6 +60,10 @@ public class ApiController extends BaseController {
     private ICcCallTaskService ccCallTaskService;
     @Autowired
     private ICcTtsAliyunService ccTtsAliyunService;
+    @Autowired
+    private ICcExtNumService ccExtNumService;
+    @Autowired
+    private ICcParamsService ccParamsService;
 
     /**
      * 获取外呼网关列表接口
@@ -118,7 +126,7 @@ public class ApiController extends BaseController {
             JSONObject obj = new JSONObject();
             obj.put("voiceName", ttsAliyun.getVoiceName());
             obj.put("voiceCode", ttsAliyun.getVoiceCode());
-            obj.put("voiceSource", "aliyun_tts");
+            obj.put("voiceSource", ttsAliyun.getVoiceSource());
             result.add(obj);
         }
         return AjaxResult.success(result);
@@ -133,10 +141,10 @@ public class ApiController extends BaseController {
     @GetMapping("/busigroup/list")
     @ResponseBody
     public AjaxResult getBusigroupList(HttpServletRequest req){
-        // 校验客户端ip是否在白名单内
-        if (!ClientIpCheck.checkIp(req)) {
-            return AjaxResult.error(AjaxResult.Type.NO_AUTH, "未授权,请联系系统管理员添加ip白名单!", "");
-        }
+//        // 校验客户端ip是否在白名单内
+//        if (!ClientIpCheck.checkIp(req)) {
+//            return AjaxResult.error(AjaxResult.Type.NO_AUTH, "未授权,请联系系统管理员添加ip白名单!", "");
+//        }
         // 获取技能组列表
         List<CcBizGroup> list = ccBizGroupService.selectCcBizGroupList(new CcBizGroup());
         return AjaxResult.success(list);
@@ -216,6 +224,42 @@ public class ApiController extends BaseController {
         return tableDataInfo;
     }
 
+    /**
+     * 根据uuid查询
+     * @param req
+     * @param uuid
+     * @param callType
+     * @return
+     */
+    @GetMapping("/record/uuid")
+    @ResponseBody
+    public AjaxResult getRecordByUuid(HttpServletRequest req, @RequestParam String uuid, @RequestParam String callType)
+    {
+        // 校验客户端ip是否在白名单内
+        if (!ClientIpCheck.checkIp(req)) {
+            return AjaxResult.error(AjaxResult.Type.NO_AUTH, "未授权,请联系系统管理员添加ip白名单!", "");
+        }
+        if (StringUtils.isBlank(callType)) {
+            return AjaxResult.error(AjaxResult.Type.INVALID_PARAM, "callType不能为空!", "");
+
+        }
+        // 01:呼入, 02:AI外呼, 03:人工外呼
+        ApiCallRecordQueryParams queryParams = new ApiCallRecordQueryParams().setUuid(uuid).setCallType(callType);
+        TableDataInfo tableDataInfo = new TableDataInfo();
+        if ("01".equals(callType)) {
+            tableDataInfo = getInboundRecords(queryParams);
+        } else if ("02".equals(callType)) {
+            tableDataInfo = getAiCallRecords(queryParams);
+        } else if ("03".equals(callType)) {
+            tableDataInfo = getOutboundRecords(queryParams);
+        }
+        if (null != tableDataInfo.getRows() && tableDataInfo.getRows().size() > 0) {
+            return AjaxResult.success(tableDataInfo.getRows().get(0));
+        } else {
+            return AjaxResult.success();
+        }
+    }
+
     /**
      * 通话记录查询接口(支持按时间、坐席、号码、呼入/呼出类型筛选)
      * @param req
@@ -236,6 +280,11 @@ public class ApiController extends BaseController {
             return tableDataInfo;
         }
         // 分页参数处理
+        if (null == queryParams.getPageNum()
+                && null == queryParams.getPageSize()) {
+            queryParams.setPageNum(1);
+            queryParams.setPageSize(200000);
+        }
         if (null == queryParams.getPageNum()) {
             queryParams.setPageNum(1);
         }
@@ -269,12 +318,13 @@ public class ApiController extends BaseController {
             return tableDataInfo;
         }
 
+        // 01:呼入, 02:AI外呼, 03:人工外呼
         if ("01".equals(callType)) {
-            // TODO
+            return getInboundRecords(queryParams);
         } else if ("02".equals(callType)) {
             return getAiCallRecords(queryParams);
         } else if ("03".equals(callType)) {
-            // TODO
+            return getOutboundRecords(queryParams);
         } else {
             tableDataInfo = new TableDataInfo();
             tableDataInfo.setTotal(0);
@@ -282,7 +332,6 @@ public class ApiController extends BaseController {
             tableDataInfo.setMsg("callType参数不合法,呼入请输入01,AI外呼请输入02,手工外呼请输入03!");
             return tableDataInfo;
         }
-        return null;
     }
 
     /**
@@ -438,7 +487,7 @@ public class ApiController extends BaseController {
                 continue;
             }
             JSONObject bizJson = new JSONObject();
-            CcCallPhone callPhone = buildCcCallPhone(ccCallTask.getBatchId(), phoneNum, bizJson);
+            CcCallPhone callPhone = buildCcCallPhone(ccCallTask, phoneNum, bizJson);
             callPhoneList.add(callPhone);
             successCount ++;
             if (callPhoneList.size() >= 200) {
@@ -496,7 +545,7 @@ public class ApiController extends BaseController {
             if (StringUtils.isBlank(phoneNum)) {
                 continue;
             }
-            CcCallPhone callPhone = buildCcCallPhone(ccCallTask.getBatchId(), phoneNum, commonPhoneModel.getBizJson());
+            CcCallPhone callPhone = buildCcCallPhone(ccCallTask, phoneNum, commonPhoneModel.getBizJson());
             callPhone.setTtsText(commonPhoneModel.getNoticeContent());
             callPhoneList.add(callPhone);
             successCount ++;
@@ -532,7 +581,7 @@ public class ApiController extends BaseController {
 
         // 追加名单
         JSONObject bizJson = new JSONObject();
-        CcCallPhone callPhone = buildCcCallPhone(ccCallTask.getBatchId(), phoneNum, bizJson);
+        CcCallPhone callPhone = buildCcCallPhone(ccCallTask, phoneNum, bizJson);
         callPhone.setTtsText(noticeCallModel.getNoticeContent());
         ccCallPhoneService.insertCcCallPhone(callPhone);
 
@@ -545,11 +594,11 @@ public class ApiController extends BaseController {
         return AjaxResult.success();
     }
 
-    private CcCallPhone buildCcCallPhone(Long batchId, String phoneNum, JSONObject bizJson) {
+    private CcCallPhone buildCcCallPhone(CcCallTask ccCallTask, String phoneNum, JSONObject bizJson) {
         CcCallPhone callPhone = new CcCallPhone();
         callPhone.setId(UuidGenerator.GetOneUuid());
         callPhone.setGroupId("1");
-        callPhone.setBatchId(batchId);
+        callPhone.setBatchId(ccCallTask.getBatchId());
         callPhone.setCreatetime(new Date().getTime());
         callPhone.setCallstatus(0);
         callPhone.setCalloutTime(0L);
@@ -575,10 +624,159 @@ public class ApiController extends BaseController {
             bizJson.put("tailNum", phoneNum);
         }
         callPhone.setCustName(bizJson.getString("custName"));
+        if (null == callPhone.getCustName()) {
+            callPhone.setCustName("");
+        }
+        callPhone.setBizJson(JSONObject.toJSONString(bizJson));
+        if (ccCallTask.getTaskType() == 1) {
+            callPhone.setIntent("");
+        } else {
+            callPhone.setIntent("-");
+        }
         return callPhone;
     }
 
 
+
+    private TableDataInfo getOutboundRecords(ApiCallRecordQueryParams queryParams) {
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("inboundTimeStart", queryParams.getCalloutTimeStart());
+        params.put("inboundTimeEnd", queryParams.getCalloutTimeEnd());
+        if (null != queryParams.getTimeLenStart()) {
+            params.put("timeLenSecondStart", queryParams.getTimeLenStart().toString());
+        }
+        if (null != queryParams.getTimeLenEnd()) {
+            params.put("timeLenSecondEnd", queryParams.getTimeLenEnd().toString());
+        }
+
+        startPage(queryParams.getPageNum(), queryParams.getPageSize());
+        CcOutboundCdr outboundCdr = new CcOutboundCdr();
+        outboundCdr.setUuid(queryParams.getUuid());
+        outboundCdr.setCaller(queryParams.getTelephone());
+        outboundCdr.setOpnum(queryParams.getExtnum());
+        outboundCdr.setParams(params);
+        List<CcOutboundCdr> list = outboundCdrService.selectCcOutboundCdrList(outboundCdr);
+        TableDataInfo tableData = getDataTable(list);
+        List<CcOutboundCdr> records = (List<CcOutboundCdr>) tableData.getRows();
+        List<ApiCallRecordQueryResult> apiRecords = new ArrayList<>();
+        for (CcOutboundCdr data: records) {
+            ApiCallRecordQueryResult apiData = new ApiCallRecordQueryResult();
+            if (data.getRecordFilename().startsWith("/")) {
+                data.setRecordFilename(data.getRecordFilename().substring(1));
+            }
+            data.setWavFileUrl("/recordings/files?filename=" + data.getRecordFilename());
+            apiData.setUuid(data.getUuid());
+            apiData.setTelephone(data.getCallee());
+            apiData.setCalloutTime(DateUtils.format(new Date(data.getStartTime()), "yyyy-MM-dd HH:mm:ss"));
+            if (data.getAnsweredTime() > 0) {
+                apiData.setAnsweredTime(DateUtils.format(new Date(data.getAnsweredTime()), "yyyy-MM-dd HH:mm:ss"));
+            } else {
+                apiData.setAnsweredTime("");
+            }
+            if (data.getEndTime() > 0) {
+                apiData.setCallEndTime(DateUtils.format(new Date(data.getEndTime()), "yyyy-MM-dd HH:mm:ss"));
+            } else {
+                apiData.setCallEndTime("");
+            }
+            apiData.setHangupCause(data.getHangupCause());
+            if (data.getTimeLen() > 0) {
+                apiData.setWavFileUrl(data.getWavFileUrl());
+            } else {
+                apiData.setWavFileUrl("");
+            }
+            apiData.setDialogue(new JSONArray());
+            apiData.setTimeLen(Long.valueOf(data.getTimeLen()/1000).intValue());
+            apiRecords.add(apiData);
+        }
+        tableData.setRows(records);
+        TableDataInfo tableDataInfo = new TableDataInfo();
+        tableDataInfo.setCode(tableData.getCode());
+        tableDataInfo.setRows(apiRecords);
+        tableDataInfo.setTotal(tableData.getTotal());
+        tableDataInfo.setMsg(tableData.getMsg());
+        return tableDataInfo;
+
+    }
+
+    private TableDataInfo getInboundRecords(ApiCallRecordQueryParams queryParams) {
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("inboundTimeStart", queryParams.getCalloutTimeStart());
+        params.put("inboundTimeEnd", queryParams.getCalloutTimeEnd());
+        if (null != queryParams.getTimeLenStart()) {
+            params.put("timeLenSecondStart", queryParams.getTimeLenStart().toString());
+        }
+        if (null != queryParams.getTimeLenEnd()) {
+            params.put("timeLenSecondEnd", queryParams.getTimeLenEnd().toString());
+        }
+
+        startPage(queryParams.getPageNum(), queryParams.getPageSize());
+        CcInboundCdr inboundCdr = new CcInboundCdr();
+        inboundCdr.setUuid(queryParams.getUuid());
+        inboundCdr.setCaller(queryParams.getTelephone());
+        inboundCdr.setOpnum(queryParams.getExtnum());
+        inboundCdr.setParams(params);
+        List<CcInboundCdr> list = inboundCdrService.selectCcInboundCdrList(inboundCdr);
+        TableDataInfo tableData = getDataTable(list);
+        List<CcInboundCdr> records = (List<CcInboundCdr>) tableData.getRows();
+        List<ApiCallRecordQueryResult> apiRecords = new ArrayList<>();
+        for (CcInboundCdr data: records) {
+            ApiCallRecordQueryResult apiData = new ApiCallRecordQueryResult();
+            if (data.getWavFile().startsWith("/")) {
+                data.setWavFile(data.getWavFile().substring(1));
+            }
+            data.setWavFileUrl("/recordings/files?filename=" + data.getWavFile());
+            apiData.setUuid(data.getUuid());
+            apiData.setTelephone(data.getCaller());
+            apiData.setCalloutTime(DateUtils.format(new Date(data.getInboundTime()), "yyyy-MM-dd HH:mm:ss"));
+            if (data.getAnsweredTime() > 0) {
+                apiData.setAnsweredTime(DateUtils.format(new Date(data.getAnsweredTime()), "yyyy-MM-dd HH:mm:ss"));
+            } else {
+                apiData.setAnsweredTime("");
+            }
+            if (data.getHangupTime() > 0) {
+                apiData.setCallEndTime(DateUtils.format(new Date(data.getHangupTime()), "yyyy-MM-dd HH:mm:ss"));
+            } else {
+                apiData.setCallEndTime("");
+            }
+            apiData.setHangupCause("");
+            if (data.getTimeLen() > 0) {
+                apiData.setWavFileUrl(data.getWavFileUrl());
+            } else {
+                apiData.setWavFileUrl("");
+            }
+            if (StringUtils.isNotEmpty(data.getChatContent())) {
+                JSONArray dialogue = new JSONArray();
+                JSONArray.parseArray(data.getChatContent()).forEach(obj -> {
+                    JSONObject json = (JSONObject) obj;
+                    if ("assistant".equals(json.getString("role"))
+                            || "user".equals(json.getString("role"))) {
+                        JSONObject content = new JSONObject();
+                        content.put("role", json.getString("role"));
+                        content.put("content", json.getString("content"));
+                        dialogue.add(content);
+                    }
+                });
+                apiData.setDialogue(dialogue);
+            } else {
+                apiData.setDialogue(new JSONArray());
+            }
+            apiData.setTimeLen(Long.valueOf(data.getTimeLen()/1000).intValue());
+            apiData.setManualAnsweredTime(data.getManualAnsweredTime());
+            apiData.setManualAnsweredTimeLen(data.getManualAnsweredTimeLen());
+            apiRecords.add(apiData);
+        }
+        tableData.setRows(records);
+        TableDataInfo tableDataInfo = new TableDataInfo();
+        tableDataInfo.setCode(tableData.getCode());
+        tableDataInfo.setRows(apiRecords);
+        tableDataInfo.setTotal(tableData.getTotal());
+        tableDataInfo.setMsg(tableData.getMsg());
+        return tableDataInfo;
+
+    }
+
     private TableDataInfo getAiCallRecords(ApiCallRecordQueryParams queryParams) {
 
         Map<String, Object> params = new HashMap<>();
@@ -593,6 +791,14 @@ public class ApiController extends BaseController {
 
         startPage(queryParams.getPageNum(), queryParams.getPageSize());
         CcCallPhone ccCallPhone = new CcCallPhone();
+        if (null != queryParams.getBatchId() && queryParams.getBatchId() > 0) {
+            ccCallPhone.setBatchId(queryParams.getBatchId());
+        }
+        ccCallPhone.setUuid(queryParams.getUuid());
+        ccCallPhone.setTelephone(queryParams.getTelephone());
+        ccCallPhone.setAcdOpnum(queryParams.getExtnum());
+        ccCallPhone.setCallstatus(queryParams.getCallstatus());
+        ccCallPhone.setCallerNumber(queryParams.getCallerNumber());
         ccCallPhone.setParams(params);
         List<CcCallPhone> list = ccCallPhoneService.selectCcCallPhoneList(ccCallPhone);
         TableDataInfo tableData = getDataTable(list);
@@ -640,6 +846,11 @@ public class ApiController extends BaseController {
                 apiData.setDialogue(new JSONArray());
             }
             apiData.setTimeLen(Long.valueOf(data.getTimeLen()/1000).intValue());
+            apiData.setSessionId(data.getId());
+            apiData.setCallstatus(data.getCallstatus());
+            apiData.setCallerNumber(data.getCallerNumber());
+            apiData.setManualAnsweredTime(data.getManualAnsweredTime());
+            apiData.setManualAnsweredTimeLen(data.getManualAnsweredTimeLen());
             apiRecords.add(apiData);
         }
         tableData.setRows(records);
@@ -701,8 +912,10 @@ public class ApiController extends BaseController {
         if (StringUtils.isBlank(voiceCode) || StringUtils.isBlank(voiceSource)) {
             return false;
         }
-        // voiceSource仅支持aliyun_tts
-        if (!"aliyun_tts".equals(voiceSource)) {
+        // voiceSource仅支持aliyun_tts 、 aliyun_tts_flow 和  doubao_vcl_tts
+        if (!"aliyun_tts".equals(voiceSource)
+                && !"aliyun_tts_flow".equals(voiceSource)
+                && !"doubao_vcl_tts".equals(voiceSource)) {
             return false;
         }
         // voiceCode必须是存在的
@@ -712,4 +925,139 @@ public class ApiController extends BaseController {
         }
         return true;
     }
+
+
+
+
+
+    /**
+     * 行稳数智追加名单【不自动启动任务,需要在任务管理页面手动启动任务】
+     * @param req
+     * @param localCallModel
+     * @return
+     */
+    @PostMapping("/local/addCall")
+    @ResponseBody
+    public AjaxResult addLocalCall(HttpServletRequest req, @RequestBody LocalCallModel localCallModel) {
+//        if (!ClientIpCheck.checkIp(req)) {
+//            return AjaxResult.error(AjaxResult.Type.NO_AUTH, "未授权,请联系系统管理员添加ip白名单", "");
+//        }
+        // 获取任务
+        String batchName = localCallModel.getBatchName();
+        if (StringUtils.isEmpty(batchName)) {
+            batchName = paramsService.getParamValueByCode("testNoticeCallTaskName", "test");
+        }
+        CcCallTask ccCallTask = callTaskService.selectCcCallTaskByBatchName(batchName, 1);
+
+        log.info("接收到名单localCallModel:{}", JSONObject.toJSONString(localCallModel));
+        log.info("batchName:{}", batchName);
+        log.info("ccCallTask:{}", JSONObject.toJSONString(ccCallTask));
+
+        // 追加名单
+        String phoneNum = localCallModel.getPhone();
+        JSONObject bizJson = new JSONObject();
+        bizJson.put("caller", localCallModel.getCaller());
+        bizJson.put("welcomeMessage", localCallModel.getWelcomeMessage());
+        bizJson.put("questionChainId", localCallModel.getQuestionChainId());
+        CcCallPhone callPhone = buildCcCallPhone(ccCallTask, phoneNum, bizJson);
+        ccCallPhoneService.insertCcCallPhone(callPhone);
+
+        // 暂时关闭自动启动逻辑(后续改成自动启停逻辑)
+//        // 如果停止超过5分钟,则自动启动
+//        if (ccCallTask.getIfcall() == 0
+//                && (System.currentTimeMillis() - ccCallTask.getStopTime()) >= 5*60*1000L) {
+//            ccCallTask.setIfcall(1);
+//            ccCallTask.setExecuting(0L);
+//            ccCallTask.setStopTime(0L);
+//            callTaskService.updateCcCallTask(ccCallTask);
+//        }
+
+        JSONObject res = new JSONObject();
+        res.put("sessionId", callPhone.getId().toString());
+        log.info("名单处理完成:{}", JSONObject.toJSONString(callPhone));
+
+        return AjaxResult.success(res);
+    }
+
+    /**
+     * 自动绑定并获取分机信息
+     * @param req
+     * @param loginUser
+     * @return
+     */
+    @GetMapping("/phoneBar/extnum/bind")
+    @ResponseBody
+    public AjaxResult getExtNumByUserName(HttpServletRequest req, @RequestParam String loginUser) {
+
+        // 获取分机号
+        CcExtNum ccExtNum = ccExtNumService.selectCcExtNumByUserCode(loginUser);
+        if (null != ccExtNum) {
+            return AjaxResult.success("success", ccExtNum);
+        }
+
+        // 如果没有获取到分机,则自动绑定分机
+        List<CcExtNum> ccExtNumList = ccExtNumService.selectUnBindCcExtNumList();
+        if (CollectionUtils.isEmpty(ccExtNumList)) {
+            log.error("没有可用分机,无法分配分机-:{}", loginUser);
+            return AjaxResult.error("没有可用分机,无法分配分机");
+        }
+        ccExtNum = ccExtNumList.get(RandomUtils.nextInt(0, ccExtNumList.size()));
+        ccExtNum.setUserCode(loginUser);
+        ccExtNumService.updateCcExtNum(ccExtNum);
+        return AjaxResult.success("success", ccExtNum);
+
+    }
+
+    /**
+     * 获取电话工具条的网关列表
+     * @param req
+     * @param extNum
+     * @return
+     */
+    @GetMapping("/phoneBar/params")
+    @ResponseBody
+    public AjaxResult getPhoneBaseParams(HttpServletRequest req, @RequestParam String extNum) {
+
+        // 获取分机号
+        CcExtNum ccExtNum = ccExtNumService.selectCcExtNumByExtNum(Long.valueOf(extNum));
+
+        String extnum = ccExtNum.getExtNum().toString();
+        String opnum = ccExtNum.getUserCode();
+        String groupId = "1";
+        String skillLevel = "9";
+        String projectId = "1";
+        String loginToken = ccExtNumService.createToken(extnum, opnum, groupId, skillLevel, projectId);
+        // 网关用途 0 dropped; 1 phonebar; 2 outbound tasks; 3. Unlimited
+        Map<String, Object> params = new HashMap<>();
+        params.put("purposes", Arrays.asList(1,3));
+        List<CcGateways> gatewaysList = ccGatewaysService.selectCcGatewaysList(new CcGateways().setParams(params));
+        List<JSONObject> gatewayList = new ArrayList<>();
+        for (CcGateways ccGateways: gatewaysList) {
+            JSONObject configGateway = new JSONObject();
+            configGateway.put("uuid", ccGateways.getId().toString());
+            configGateway.put("updateTime", ccGateways.getUpdateTime());
+            configGateway.put("gatewayAddr", ccGateways.getGwAddr());
+            configGateway.put("callerNumber", ccGateways.getCaller());
+            configGateway.put("calleePrefix", ccGateways.getCalleePrefix());
+            configGateway.put("callProfile", ccGateways.getProfileName());
+            configGateway.put("priority", ccGateways.getPriority());
+            configGateway.put("concurrency", ccGateways.getMaxConcurrency());
+            configGateway.put("register", ccGateways.getRegister());
+            configGateway.put("authUsername", ccGateways.getAuthUsername());
+            configGateway.put("audioCodec", ccGateways.getCodec());
+            gatewayList.add(configGateway);
+        }
+        JSONObject callConfig = new JSONObject();
+
+        String scriptServer = ccParamsService.getParamValueByCode("call-center-server-ip-addr", "");
+        String scriptPort = ccParamsService.getParamValueByCode("call-center-websocket-port", "");
+
+        callConfig.put("scriptServer", scriptServer);
+        callConfig.put("scriptPort", scriptPort);
+        callConfig.put("loginToken", loginToken);
+        callConfig.put("gatewayList", gatewayList);
+
+        return AjaxResult.success(callConfig);
+
+    }
 }

+ 28 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/controller/CcCallPhoneController.java

@@ -4,10 +4,12 @@ import java.text.ParseException;
 import java.util.*;
 import java.util.stream.Collectors;
 
+import com.alibaba.fastjson.JSONObject;
 import com.ruoyi.aicall.domain.CcCallTask;
 import com.ruoyi.aicall.model.CallPhoneExportVo;
 import com.ruoyi.aicall.service.ICcCallTaskService;
 import com.ruoyi.common.utils.DateUtils;
+import com.ruoyi.common.utils.ExceptionUtil;
 import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.common.utils.StringUtils;
 import org.apache.shiro.authz.annotation.RequiresPermissions;
@@ -58,11 +60,14 @@ public class CcCallPhoneController extends BaseController
      */
     @RequiresPermissions("aicall:callPhone:list")
     @PostMapping("/list")
+    @Log(title = "查询通话记录", businessType = BusinessType.OTHER)
     @ResponseBody
     public TableDataInfo list(CcCallPhone ccCallPhone)
     {
+        Long t0 = System.currentTimeMillis();
         startPage();
         List<CcCallPhone> list = ccCallPhoneService.selectCcCallPhoneList(ccCallPhone);
+        logger.info("通话记录查询执行sql耗时:{}ms", (System.currentTimeMillis() - t0));
         TableDataInfo tableDataInfo = getDataTable(list);
         List<CcCallPhone> records = (List<CcCallPhone>) tableDataInfo.getRows();
         Map<Long, String> batchNameMap = new HashMap<>();
@@ -82,8 +87,31 @@ public class CcCallPhoneController extends BaseController
                 batchNameMap.put(data.getBatchId(), batchName);
             }
             data.setBatchName(batchName);
+            // 挂机原因处理
+            if (StringUtils.isNotEmpty(data.getHangupCause())) {
+                if (data.getHangupCause().startsWith("{") && data.getHangupCause().endsWith("}")) {
+                    try {
+                        JSONObject hangupCause = JSONObject.parseObject(data.getHangupCause());
+                        String hangupCauseCode = hangupCause.getString("code");
+                        String hangupCauseDetail = hangupCause.getString("details");
+                        String hangupCauseCodeI18n = MessageUtils.message("_hangup_cause_code_" + hangupCauseCode);
+                        if (StringUtils.isNotEmpty(hangupCauseCodeI18n)) {
+                            hangupCauseCode = hangupCauseCodeI18n;
+                        }
+                        if (StringUtils.isNotEmpty(hangupCauseDetail)) {
+                            data.setHangupCause(hangupCauseCode + ":" + hangupCauseDetail);
+                        } else {
+                            data.setHangupCause(hangupCauseCode);
+                        }
+                    } catch (Exception e) {
+                        logger.error(ExceptionUtil.getExceptionMessage(e));
+                    }
+
+                }
+            }
         }
         tableDataInfo.setRows(records);
+        logger.info("通话记录查询后端累计耗时:{}ms", (System.currentTimeMillis() - t0));
         return tableDataInfo;
     }
 

+ 118 - 5
ruoyi-admin/src/main/java/com/ruoyi/aicall/controller/CcCallTaskController.java

@@ -8,8 +8,11 @@ import java.util.*;
 import com.alibaba.fastjson.JSONObject;
 import com.ruoyi.aicall.domain.CcCallPhone;
 import com.ruoyi.aicall.domain.CcLlmAgentProvider;
+import com.ruoyi.aicall.domain.CcTtsAliyun;
 import com.ruoyi.aicall.model.CallTaskStatModel;
 import com.ruoyi.aicall.service.ICcCallPhoneService;
+import com.ruoyi.aicall.service.ICcTtsAliyunService;
+import com.ruoyi.cc.service.ICcParamsService;
 import com.ruoyi.common.utils.DateUtils;
 import com.ruoyi.common.utils.ExceptionUtil;
 import com.ruoyi.common.utils.StringUtils;
@@ -25,6 +28,7 @@ import org.apache.shiro.authz.annotation.RequiresPermissions;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.core.io.ClassPathResource;
 import org.springframework.stereotype.Controller;
+import org.springframework.transaction.annotation.Transactional;
 import org.springframework.ui.ModelMap;
 import org.springframework.web.bind.annotation.*;
 import com.ruoyi.common.annotation.Log;
@@ -62,9 +66,12 @@ public class CcCallTaskController extends BaseController
 
     @Autowired
     private ICcCallTaskService ccCallTaskService;
-
     @Autowired
     private ICcCallPhoneService ccCallPhoneService;
+    @Autowired
+    private ICcTtsAliyunService ttsAliyunService;
+    @Autowired
+    private ICcParamsService paramsService;
 
     @RequiresPermissions("aicall:callTask:view")
     @GetMapping()
@@ -85,6 +92,7 @@ public class CcCallTaskController extends BaseController
         List<CcCallTask> list = ccCallTaskService.selectCcCallTaskList(ccCallTask);
         TableDataInfo tableDataInfo = getDataTable(list);
         List<CcCallTask> records = (List<CcCallTask>) tableDataInfo.getRows();
+        Integer allDelDays = Integer.valueOf(paramsService.getParamValueByCode("callTask_allowDel_days", "30"));
         for (CcCallTask data: records){
             CallTaskStatModel statModel = ccCallPhoneService.statByBatchId(data.getBatchId());
             data.setPhoneCount(statModel.getPhoneCount());
@@ -97,6 +105,19 @@ public class CcCallTaskController extends BaseController
             } else {
                 data.setRealConnectRate(0.0);
             }
+//            if (StringUtils.isNotEmpty(data.getVoiceCode())) {
+//                CcTtsAliyun ccTtsAliyun = ttsAliyunService.selectCcTtsAliyunByVoiceCode(data.getVoiceCode());
+//                if (null != ccTtsAliyun) {
+//                    data.setVoiceSource(ccTtsAliyun.getVoiceSource());
+//                    data.setProvider(ccTtsAliyun.getProvider());
+//                }
+//            }
+            data.setAllowDel(0);
+            if (data.getIfcall() == 0
+                    && data.getStopTime() > 0
+                    && (System.currentTimeMillis() - allDelDays * 24*3600*1000L) > data.getStopTime()) {
+                data.setAllowDel(1);
+            }
         }
         tableDataInfo.setRows(records);
         return tableDataInfo;
@@ -120,8 +141,9 @@ public class CcCallTaskController extends BaseController
      * 新增外呼任务
      */
     @GetMapping("/add")
-    public String add()
+    public String add(ModelMap mmap)
     {
+        mmap.put("ccCallTask", new CcCallTask());
         return prefix + "/add";
     }
 
@@ -139,6 +161,18 @@ public class CcCallTaskController extends BaseController
             ccCallTask.setRate(ccCallTask.getConntectRate()/100.0);
         }
         ccCallTask.setCreatetime(System.currentTimeMillis());
+
+        if ("acd".equals(ccCallTask.getAiTransferType())) {
+            ccCallTask.setAiTransferData(ccCallTask.getAiTransferGroupId());
+        } else if ("extension".equals(ccCallTask.getAiTransferType())) {
+            ccCallTask.setAiTransferData(ccCallTask.getAiTransferExtNumber());
+        } else if ("gateway".equals(ccCallTask.getAiTransferType())) {
+            JSONObject aiTransferData = new JSONObject();
+            aiTransferData.put("gatewayId", ccCallTask.getAiTransferGatewayId());
+            aiTransferData.put("destNumber", ccCallTask.getAiTransferGatewayDestNumber());
+            ccCallTask.setAiTransferData(JSONObject.toJSONString(aiTransferData));
+        }
+
         return toAjax(ccCallTaskService.insertCcCallTask(ccCallTask));
     }
 
@@ -153,6 +187,27 @@ public class CcCallTaskController extends BaseController
         if (null != ccCallTask.getRate() && ccCallTask.getRate() > 0) {
             ccCallTask.setConntectRate((Double.valueOf(ccCallTask.getRate()*100.0).intValue()));
         }
+
+//        if (StringUtils.isNotEmpty(ccCallTask.getVoiceCode())) {
+//            CcTtsAliyun ccTtsAliyun = ttsAliyunService.selectCcTtsAliyunByVoiceCode(ccCallTask.getVoiceCode());
+//            if (null != ccTtsAliyun) {
+//                ccCallTask.setVoiceSource(ccTtsAliyun.getVoiceSource());
+//                ccCallTask.setProvider(ccTtsAliyun.getProvider());
+//            }
+//        }
+
+        if ("acd".equals(ccCallTask.getAiTransferType())) {
+            ccCallTask.setAiTransferGroupId(ccCallTask.getAiTransferData());
+        } else if ("extension".equals(ccCallTask.getAiTransferType())) {
+            ccCallTask.setAiTransferExtNumber(ccCallTask.getAiTransferData());
+        } else if ("gateway".equals(ccCallTask.getAiTransferType())) {
+            if (StringUtils.isNotEmpty(ccCallTask.getAiTransferData())) {
+                JSONObject aiTransferData = JSONObject.parseObject(ccCallTask.getAiTransferData());
+                ccCallTask.setAiTransferGatewayId(aiTransferData.getString("gatewayId"));
+                ccCallTask.setAiTransferGatewayDestNumber(aiTransferData.getString("destNumber"));
+            }
+        }
+
         mmap.put("ccCallTask", ccCallTask);
         return prefix + "/edit";
     }
@@ -166,6 +221,17 @@ public class CcCallTaskController extends BaseController
     @ResponseBody
     public AjaxResult editSave(CcCallTask ccCallTask)
     {
+
+        if ("acd".equals(ccCallTask.getAiTransferType())) {
+            ccCallTask.setAiTransferData(ccCallTask.getAiTransferGroupId());
+        } else if ("extension".equals(ccCallTask.getAiTransferType())) {
+            ccCallTask.setAiTransferData(ccCallTask.getAiTransferExtNumber());
+        } else if ("gateway".equals(ccCallTask.getAiTransferType())) {
+            JSONObject aiTransferData = new JSONObject();
+            aiTransferData.put("gatewayId", ccCallTask.getAiTransferGatewayId());
+            aiTransferData.put("destNumber", ccCallTask.getAiTransferGatewayDestNumber());
+            ccCallTask.setAiTransferData(JSONObject.toJSONString(aiTransferData));
+        }
         return toAjax(ccCallTaskService.updateCcCallTask(ccCallTask));
     }
 
@@ -176,9 +242,18 @@ public class CcCallTaskController extends BaseController
     @Log(title = "外呼任务", businessType = BusinessType.DELETE)
     @PostMapping( "/remove")
     @ResponseBody
+    @Transactional
     public AjaxResult remove(String ids)
     {
-        return toAjax(ccCallTaskService.deleteCcCallTaskByBatchIds(ids));
+        Long batchId = Long.valueOf(ids);
+        // 备份拨打记录数据
+        ccCallPhoneService.bakCallPhoneByBatchId(batchId);
+        // 删除拨打记录数据
+        ccCallPhoneService.delCallPhoneByBatchId(batchId);
+        // 备份任务数据
+        ccCallTaskService.bakCallTaskByBatchId(batchId);
+        // 删除任务数据
+        return toAjax(ccCallTaskService.deleteCcCallTaskByBatchId(batchId));
     }
 
 
@@ -192,6 +267,24 @@ public class CcCallTaskController extends BaseController
     public AjaxResult start(@PathVariable("batchId") Long batchId)
     {
         CcCallTask ccCallTask = ccCallTaskService.selectCcCallTaskByBatchId(batchId);
+        // 如果小于5分钟,则判断是否有未挂机通话
+        if ((System.currentTimeMillis() - ccCallTask.getStopTime()) <= 5*60*1000L) {
+            // 如果有未挂机通话,则不允许启动
+            List<CcCallPhone> noHangupCalls = ccCallPhoneService.selectNoHangupCalls(batchId);
+            if (noHangupCalls.size() > 0) {
+                log.info("还有没挂机的通话,资源还未释放,无法启动");
+                return AjaxResult.error("当前任务资源还未释放完成,无法启动,请2分钟后重试!");
+            }
+
+            // 有挂机不超过30秒的通话,则不允许启动
+            Map<String, Object> params = new HashMap<>();
+            params.put("callEndTimeStart", DateUtils.parseDateToStr("yyyy-MM-dd HH:mm:ss", new Date(System.currentTimeMillis() - 30*1000L)));
+            List<CcCallPhone> ccCallPhoneList = ccCallPhoneService.selectCcCallPhoneList(new CcCallPhone().setBatchId(batchId).setParams(params));
+            if (ccCallPhoneList.size() > 0) {
+                log.info("最后一通电话挂机不超过30秒,资源还未释放,无法启动");
+                return AjaxResult.error("当前任务资源还未释放完成,无法启动,请2分钟后重试!");
+            }
+        }
         ccCallTask.setIfcall(1);
         ccCallTask.setExecuting(0L);
         ccCallTask.setStopTime(0L);
@@ -212,6 +305,7 @@ public class CcCallTaskController extends BaseController
         CcCallTask ccCallTask = ccCallTaskService.selectCcCallTaskByBatchId(batchId);
         ccCallTask.setIfcall(0);
         ccCallTask.setExecuting(0L);
+        ccCallTask.setStopTime(System.currentTimeMillis());
         ccCallTaskService.updateCcCallTask(ccCallTask);
         return toAjax(1);
     }
@@ -258,8 +352,10 @@ public class CcCallTaskController extends BaseController
         if (file == null || file.isEmpty()) {
             return AjaxResult.error("上传文件不能为空!");
         }
+
         Long t0 = System.currentTimeMillis();
         CcCallTask ccCallTask = ccCallTaskService.selectCcCallTaskByBatchId(batchId);
+        JSONObject ttsContentVariables = JSONObject.parseObject(paramsService.getParamValueByCode("tts_content_variables", "{}"));
         try (InputStream inputStream = file.getInputStream()) {
             // 使用 Apache POI 解析 Excel 文件
             Workbook workbook = new XSSFWorkbook(inputStream);
@@ -273,6 +369,8 @@ public class CcCallTaskController extends BaseController
             Integer idxCustName = -1;
             Integer idxPhoneNum = -1;
             Integer idxTtsText = -1;
+            Map<Integer, String> idxMap = new HashMap<>();
+
             for (int i = 0; i < row0.getPhysicalNumberOfCells(); i++) {
                 Cell cell = row0.getCell(i);
                 if (null != cell) {
@@ -284,6 +382,12 @@ public class CcCallTaskController extends BaseController
                             idxPhoneNum = i;
                         } else if ("通知内容".equals(value)) {
                             idxTtsText = i;
+                        } else {
+                            for (String var: ttsContentVariables.keySet()) {
+                                if (ttsContentVariables.getString(var).equals(value)) {
+                                    idxMap.put(i, var);
+                                }
+                            }
                         }
                     }
                 }
@@ -293,6 +397,11 @@ public class CcCallTaskController extends BaseController
             for (int i = 1; i <= rowCount; i++) {
                 Row row = sheet.getRow(i);
                 rowNum ++;
+                JSONObject bizJson = new JSONObject();
+                // 解析扩展字段
+                for (Integer idx: idxMap.keySet()) {
+                    bizJson.put(idxMap.getOrDefault(idx, ""), getCellValue(row.getCell(idx)));
+                }
                 String phoneNumber = getCellValue(row.getCell(idxPhoneNum)); // 手机号码列
                 String custName = "";
                 if (idxCustName >= 0) {
@@ -302,7 +411,7 @@ public class CcCallTaskController extends BaseController
                 if (idxTtsText >= 0) {
                     ttsText = getCellValue(row.getCell(idxTtsText)); // 通知内容列
                 }
-                log.info("解析第{}行获取到的数据为,phoneNumber:{}, custName:{}", rowNum, phoneNumber, custName);
+                log.info("解析第{}行获取到的数据为,phoneNumber:{}, custName:{}, bizJson:{}", rowNum, phoneNumber, custName, bizJson);
                 if (StringUtils.isNotEmpty(phoneNumber)) {
                     phoneNumber = phoneNumber.replace(".00", "").replace(".0", "").replace("-", "").replace(" ", "");
                     if (phoneNumber.matches("\\d+")) {
@@ -311,7 +420,6 @@ public class CcCallTaskController extends BaseController
                             callPhone.setTelephone(phoneNumber);
                             callPhone.setCustName(custName);
                             callPhone.setTtsText(ttsText);
-                            JSONObject bizJson = new JSONObject();
                             bizJson.put("custName", custName);
                             if (phoneNumber.length() > 4) {
                                 bizJson.put("tailNum", phoneNumber.substring(phoneNumber.length()-4));
@@ -396,6 +504,11 @@ public class CcCallTaskController extends BaseController
         callPhone.setAcdOpnum("");
         callPhone.setAcdQueueTime(0L);
         callPhone.setAcdWaitTime(0);
+        if (ccCallTask.getTaskType() == 1) {
+            callPhone.setIntent("");
+        } else {
+            callPhone.setIntent("-");
+        }
         return callPhone;
     }
 

+ 107 - 8
ruoyi-admin/src/main/java/com/ruoyi/aicall/controller/CcInboundLlmAccountController.java

@@ -2,6 +2,7 @@ package com.ruoyi.aicall.controller;
 
 import java.util.List;
 
+import com.alibaba.fastjson.JSONObject;
 import com.ruoyi.aicall.domain.CcLlmAgentAccount;
 import com.ruoyi.aicall.domain.CcTtsAliyun;
 import com.ruoyi.aicall.service.ICcLlmAgentAccountService;
@@ -66,9 +67,27 @@ public class CcInboundLlmAccountController extends BaseController
             } else {
                 data.setLlmAccountName("");
             }
-            CcTtsAliyun ccTtsAliyun = ttsAliyunService.selectCcTtsAliyunByVoiceCode(data.getVoiceCode());
-            if (null != ccTtsAliyun) {
-                data.setVoiceName(ccTtsAliyun.getVoiceName());
+            if (StringUtils.isNotEmpty(data.getVoiceCode())) {
+                CcTtsAliyun ccTtsAliyun = ttsAliyunService.selectCcTtsAliyunByVoiceCode(data.getVoiceCode());
+                if (null != ccTtsAliyun) {
+                    data.setVoiceSource(ccTtsAliyun.getVoiceSource());
+                    data.setVoiceName(ccTtsAliyun.getVoiceName());
+                }
+            }
+            if ("acd".equals(data.getServiceType())) {
+                data.setAiTransferGroupId(data.getAiTransferData());
+            } else if ("ai".equals(data.getServiceType())) {
+                if ("acd".equals(data.getAiTransferType())) {
+                    data.setAiTransferGroupId(data.getAiTransferData());
+                } else if ("extension".equals(data.getAiTransferType())) {
+                    data.setAiTransferExtNumber(data.getAiTransferData());
+                } else if ("gateway".equals(data.getAiTransferType())) {
+                    if (StringUtils.isNotEmpty(data.getAiTransferData())) {
+                        JSONObject aiTransferData = JSONObject.parseObject(data.getAiTransferData());
+                        data.setAiTransferGatewayId(aiTransferData.getString("gatewayId"));
+                        data.setAiTransferGatewayDestNumber(aiTransferData.getString("destNumber"));
+                    }
+                }
             }
         }
         tableDataInfo.setRows(records);
@@ -93,8 +112,9 @@ public class CcInboundLlmAccountController extends BaseController
      * 新增呼入大模型配置
      */
     @GetMapping("/add")
-    public String add()
+    public String add(ModelMap mmap)
     {
+        mmap.put("ccInboundLlmAccount", new CcInboundLlmAccount());
         return prefix + "/add";
     }
 
@@ -107,8 +127,33 @@ public class CcInboundLlmAccountController extends BaseController
     @ResponseBody
     public AjaxResult addSave(CcInboundLlmAccount ccInboundLlmAccount)
     {
-        if (StringUtils.isBlank(ccInboundLlmAccount.getVoiceSource())) {
-            ccInboundLlmAccount.setVoiceSource("aliyun_tts");
+        if ("acd".equals(ccInboundLlmAccount.getServiceType())) {
+            ccInboundLlmAccount.setAiTransferData(ccInboundLlmAccount.getAiTransferGroupId());
+            ccInboundLlmAccount.setAsrProvider("");
+            ccInboundLlmAccount.setVoiceSource("");
+            ccInboundLlmAccount.setVoiceCode("");
+            ccInboundLlmAccount.setAiTransferType("");
+            ccInboundLlmAccount.setIvrId("");
+            ccInboundLlmAccount.setLlmAccountId(-1);
+        } else if ("ai".equals(ccInboundLlmAccount.getServiceType())) {
+            if ("acd".equals(ccInboundLlmAccount.getAiTransferType())) {
+                ccInboundLlmAccount.setAiTransferData(ccInboundLlmAccount.getAiTransferGroupId());
+            } else if ("extension".equals(ccInboundLlmAccount.getAiTransferType())) {
+                ccInboundLlmAccount.setAiTransferData(ccInboundLlmAccount.getAiTransferExtNumber());
+            } else if ("gateway".equals(ccInboundLlmAccount.getAiTransferType())) {
+                JSONObject aiTransferData = new JSONObject();
+                aiTransferData.put("gatewayId", ccInboundLlmAccount.getAiTransferGatewayId());
+                aiTransferData.put("destNumber", ccInboundLlmAccount.getAiTransferGatewayDestNumber());
+                ccInboundLlmAccount.setAiTransferData(JSONObject.toJSONString(aiTransferData));
+            }
+            ccInboundLlmAccount.setIvrId("");
+        } else if ("ivr".equals(ccInboundLlmAccount.getServiceType())) {
+            ccInboundLlmAccount.setAsrProvider("");
+            ccInboundLlmAccount.setVoiceSource("");
+            ccInboundLlmAccount.setVoiceCode("");
+            ccInboundLlmAccount.setAiTransferType("");
+            ccInboundLlmAccount.setAiTransferData("");
+            ccInboundLlmAccount.setLlmAccountId(-1);
         }
         return toAjax(ccInboundLlmAccountService.insertCcInboundLlmAccount(ccInboundLlmAccount));
     }
@@ -121,6 +166,26 @@ public class CcInboundLlmAccountController extends BaseController
     public String edit(@PathVariable("id") Integer id, ModelMap mmap)
     {
         CcInboundLlmAccount ccInboundLlmAccount = ccInboundLlmAccountService.selectCcInboundLlmAccountById(id);
+//        CcTtsAliyun ccTtsAliyun = ttsAliyunService.selectCcTtsAliyunByVoiceCode(ccInboundLlmAccount.getVoiceCode());
+//        ccInboundLlmAccount.setProvider(ccTtsAliyun.getProvider());
+//        ccInboundLlmAccount.setVoiceSource(ccTtsAliyun.getVoiceSource());
+
+        if ("acd".equals(ccInboundLlmAccount.getServiceType())) {
+            ccInboundLlmAccount.setAiTransferGroupId(ccInboundLlmAccount.getAiTransferData());
+        } else if ("ai".equals(ccInboundLlmAccount.getServiceType())) {
+            if ("acd".equals(ccInboundLlmAccount.getAiTransferType())) {
+                ccInboundLlmAccount.setAiTransferGroupId(ccInboundLlmAccount.getAiTransferData());
+            } else if ("extension".equals(ccInboundLlmAccount.getAiTransferType())) {
+                ccInboundLlmAccount.setAiTransferExtNumber(ccInboundLlmAccount.getAiTransferData());
+            } else if ("gateway".equals(ccInboundLlmAccount.getAiTransferType())) {
+                if (StringUtils.isNotEmpty(ccInboundLlmAccount.getAiTransferData())) {
+                    JSONObject aiTransferData = JSONObject.parseObject(ccInboundLlmAccount.getAiTransferData());
+                    ccInboundLlmAccount.setAiTransferGatewayId(aiTransferData.getString("gatewayId"));
+                    ccInboundLlmAccount.setAiTransferGatewayDestNumber(aiTransferData.getString("destNumber"));
+                }
+            }
+        }
+
         mmap.put("ccInboundLlmAccount", ccInboundLlmAccount);
         return prefix + "/edit";
     }
@@ -134,8 +199,33 @@ public class CcInboundLlmAccountController extends BaseController
     @ResponseBody
     public AjaxResult editSave(CcInboundLlmAccount ccInboundLlmAccount)
     {
-        if (StringUtils.isBlank(ccInboundLlmAccount.getVoiceSource())) {
-            ccInboundLlmAccount.setVoiceSource("aliyun_tts");
+        if ("acd".equals(ccInboundLlmAccount.getServiceType())) {
+            ccInboundLlmAccount.setAiTransferData(ccInboundLlmAccount.getAiTransferGroupId());
+            ccInboundLlmAccount.setAsrProvider("");
+            ccInboundLlmAccount.setVoiceSource("");
+            ccInboundLlmAccount.setVoiceCode("");
+            ccInboundLlmAccount.setAiTransferType("");
+            ccInboundLlmAccount.setIvrId("");
+            ccInboundLlmAccount.setLlmAccountId(-1);
+        } else if ("ai".equals(ccInboundLlmAccount.getServiceType())) {
+            if ("acd".equals(ccInboundLlmAccount.getAiTransferType())) {
+                ccInboundLlmAccount.setAiTransferData(ccInboundLlmAccount.getAiTransferGroupId());
+            } else if ("extension".equals(ccInboundLlmAccount.getAiTransferType())) {
+                ccInboundLlmAccount.setAiTransferData(ccInboundLlmAccount.getAiTransferExtNumber());
+            } else if ("gateway".equals(ccInboundLlmAccount.getAiTransferType())) {
+                JSONObject aiTransferData = new JSONObject();
+                aiTransferData.put("gatewayId", ccInboundLlmAccount.getAiTransferGatewayId());
+                aiTransferData.put("destNumber", ccInboundLlmAccount.getAiTransferGatewayDestNumber());
+                ccInboundLlmAccount.setAiTransferData(JSONObject.toJSONString(aiTransferData));
+            }
+            ccInboundLlmAccount.setIvrId("");
+        } else if ("ivr".equals(ccInboundLlmAccount.getServiceType())) {
+            ccInboundLlmAccount.setAsrProvider("");
+            ccInboundLlmAccount.setVoiceSource("");
+            ccInboundLlmAccount.setVoiceCode("");
+            ccInboundLlmAccount.setAiTransferType("");
+            ccInboundLlmAccount.setAiTransferData("");
+            ccInboundLlmAccount.setLlmAccountId(-1);
         }
         return toAjax(ccInboundLlmAccountService.updateCcInboundLlmAccount(ccInboundLlmAccount));
     }
@@ -167,4 +257,13 @@ public class CcInboundLlmAccountController extends BaseController
         }
         return AjaxResult.success(false);
     }
+
+
+    @ResponseBody
+    @GetMapping("/ai/all")
+    public AjaxResult getAllAi()
+    {
+        List<CcInboundLlmAccount> ccInboundLlmAccounts = ccInboundLlmAccountService.selectCcInboundLlmAccountList(new CcInboundLlmAccount().setServiceType("ai"));
+        return AjaxResult.success(ccInboundLlmAccounts);
+    }
 }

+ 51 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/controller/CcLlmAgentAccountController.java

@@ -1,17 +1,21 @@
 package com.ruoyi.aicall.controller;
 
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
 
 import com.alibaba.fastjson.JSONObject;
+import com.ruoyi.aicall.domain.CcCallTask;
 import com.ruoyi.aicall.domain.CcLlmAgentProvider;
 import com.ruoyi.aicall.llm.ILlmCapability;
 import com.ruoyi.aicall.llm.model.AccountBaseEntity;
+import com.ruoyi.aicall.service.ICcCallTaskService;
 import com.ruoyi.cc.service.ICcParamsService;
 import com.ruoyi.cc.utils.LlmAccountParser;
 import com.ruoyi.common.utils.CommonUtils;
 import com.ruoyi.common.utils.ExceptionUtil;
 import com.ruoyi.common.utils.StringUtils;
+import com.sun.org.apache.xpath.internal.operations.Bool;
 import org.apache.shiro.authz.annotation.RequiresPermissions;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Controller;
@@ -46,6 +50,9 @@ public class CcLlmAgentAccountController extends BaseController
     private ICcLlmAgentAccountService ccLlmAgentAccountService;
     @Autowired
     private ICcParamsService ccParamsService;
+    @Autowired
+    private ICcCallTaskService ccCallTaskService;
+
     private static List<String> hideKeys = Arrays.asList("apiKey", "oauthPrivateKey", "oauthPublicKeyId", "patToken");
 
 
@@ -104,6 +111,7 @@ public class CcLlmAgentAccountController extends BaseController
         CcLlmAgentAccount ccLlmAgentAccount = new CcLlmAgentAccount();
         ccLlmAgentAccount.setInterruptIgnoreKeywords(ccParamsService.getParamValueByCode("default_interrupt_ignore_keywords", ""));
         ccLlmAgentAccount.setInterruptFlag(0);
+        ccLlmAgentAccount.setAccountJson("{}");
         mmap.put("ccLlmAgentAccount", ccLlmAgentAccount);
         return prefix + "/add";
     }
@@ -119,12 +127,17 @@ public class CcLlmAgentAccountController extends BaseController
     {
         if ("Coze".equalsIgnoreCase(ccLlmAgentAccount.getProviderClassName())) {
             ccLlmAgentAccount.setAccountEntity("CozeAccount");
+        } else if ("LocalNlpChat".equalsIgnoreCase(ccLlmAgentAccount.getProviderClassName())) {
+            ccLlmAgentAccount.setAccountEntity("CozeAccount");
         } else if ("JiutianWorkflow".equalsIgnoreCase(ccLlmAgentAccount.getProviderClassName())
                 || "JiutianAgent".equalsIgnoreCase(ccLlmAgentAccount.getProviderClassName())) {
             ccLlmAgentAccount.setAccountEntity("JiutianAccount");
         } else {
             ccLlmAgentAccount.setAccountEntity("LlmAccount");
         }
+        if (null == ccLlmAgentAccount.getKbCatId()) {
+            ccLlmAgentAccount.setKbCatId(-1);
+        }
         return toAjax(ccLlmAgentAccountService.insertCcLlmAgentAccount(ccLlmAgentAccount));
     }
 
@@ -147,6 +160,9 @@ public class CcLlmAgentAccountController extends BaseController
         }
         ccLlmAgentAccount.setAccountJson(JSONObject.toJSONString(accountJson));
         mmap.put("ccLlmAgentAccount", ccLlmAgentAccount);
+
+        String errMsg = checkEdit(ccLlmAgentAccount.getId());
+        mmap.put("errorMsg", errMsg);
         return prefix + "/edit";
     }
 
@@ -183,8 +199,18 @@ public class CcLlmAgentAccountController extends BaseController
     @ResponseBody
     public AjaxResult editSave(CcLlmAgentAccount ccLlmAgentAccount)
     {
+
+        if (ccLlmAgentAccount.getId() > 0) {
+            String errMsg = checkEdit(ccLlmAgentAccount.getId());
+            if (StringUtils.isNotEmpty(errMsg)) {
+                return AjaxResult.error(errMsg);
+            }
+        }
+
         if ("Coze".equalsIgnoreCase(ccLlmAgentAccount.getProviderClassName())) {
             ccLlmAgentAccount.setAccountEntity("CozeAccount");
+        } else if ("LocalNlpChat".equalsIgnoreCase(ccLlmAgentAccount.getProviderClassName())) {
+            ccLlmAgentAccount.setAccountEntity("CozeAccount");
         } else if ("JiutianWorkflow".equalsIgnoreCase(ccLlmAgentAccount.getProviderClassName())
                 || "JiutianAgent".equalsIgnoreCase(ccLlmAgentAccount.getProviderClassName())) {
             ccLlmAgentAccount.setAccountEntity("JiutianAccount");
@@ -206,6 +232,9 @@ public class CcLlmAgentAccountController extends BaseController
             }
         }
         ccLlmAgentAccount.setAccountJson(JSONObject.toJSONString(newAccountJson));
+        if (null == ccLlmAgentAccount.getKbCatId()) {
+            ccLlmAgentAccount.setKbCatId(-1);
+        }
 
         if (ccLlmAgentAccount.getId() > 0) {
             return toAjax(ccLlmAgentAccountService.updateCcLlmAgentAccount(ccLlmAgentAccount));
@@ -245,4 +274,26 @@ public class CcLlmAgentAccountController extends BaseController
         return AjaxResult.success(list);
     }
 
+
+
+    private String checkEdit(Integer id)
+    {
+        CcLlmAgentAccount llmAgentAccount = ccLlmAgentAccountService.selectCcLlmAgentAccountById(id);
+        List<CcCallTask> ccCallTaskList = ccCallTaskService.selectCcCallTaskList(new CcCallTask().setLlmAccountId(id));
+        List<String> ids = new ArrayList<>();
+        for (CcCallTask ccCallTask: ccCallTaskList) {
+            // AI外呼,且不自动停止,且状态为正在拨打的任务,需要手动停止任务后再修改大模型配置再手动启动任务
+            if (ccCallTask.getTaskType() == 1
+                    && ccCallTask.getIfcall() == 1
+                    && ccCallTask.getAutoStop() == 0) {
+                ids.add(ccCallTask.getBatchName());
+            }
+        }
+        if (ids.size() > 0) {
+            return String.format("%s%s%s", "请先暂停任务:", StringUtils.join(ids, ","), ",再修改该配置,修改完成后再启动任务");
+        } else {
+            return "";
+        }
+    }
+
 }

+ 160 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/controller/CcLlmKbCatController.java

@@ -0,0 +1,160 @@
+package com.ruoyi.aicall.controller;
+
+import java.util.List;
+
+import com.alibaba.fastjson.JSONObject;
+import com.ruoyi.aicall.domain.CcLlmAgentAccount;
+import com.ruoyi.aicall.service.ICcLlmKbService;
+import com.ruoyi.common.utils.CommonUtils;
+import org.apache.shiro.authz.annotation.RequiresPermissions;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.ModelMap;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.ResponseBody;
+import com.ruoyi.common.annotation.Log;
+import com.ruoyi.common.enums.BusinessType;
+import com.ruoyi.aicall.domain.CcLlmKbCat;
+import com.ruoyi.aicall.service.ICcLlmKbCatService;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.utils.poi.ExcelUtil;
+import com.ruoyi.common.core.page.TableDataInfo;
+
+/**
+ * 知识库Controller
+ * 
+ * @author ruoyi
+ * @date 2026-01-19
+ */
+@Controller
+@RequestMapping("/aicall/kbcat")
+public class CcLlmKbCatController extends BaseController
+{
+    private String prefix = "aicall/kbcat";
+
+    @Autowired
+    private ICcLlmKbCatService ccLlmKbCatService;
+    @Autowired
+    private ICcLlmKbService ccLlmKbService;
+
+    @RequiresPermissions("aicall:kbcat:view")
+    @GetMapping()
+    public String kbcat()
+    {
+        return prefix + "/kbcat";
+    }
+
+    /**
+     * 查询知识库列表
+     */
+    @RequiresPermissions("aicall:kbcat:list")
+    @PostMapping("/list")
+    @ResponseBody
+    public TableDataInfo list(CcLlmKbCat ccLlmKbCat)
+    {
+        startPage();
+        List<CcLlmKbCat> list = ccLlmKbCatService.selectCcLlmKbCatList(ccLlmKbCat);
+        for (CcLlmKbCat data: list) {
+            data.setContentCount(ccLlmKbService.selectCountByCatId(data.getId()));
+        }
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出知识库列表
+     */
+    @RequiresPermissions("aicall:kbcat:export")
+    @Log(title = "知识库", businessType = BusinessType.EXPORT)
+    @PostMapping("/export")
+    @ResponseBody
+    public AjaxResult export(CcLlmKbCat ccLlmKbCat)
+    {
+        List<CcLlmKbCat> list = ccLlmKbCatService.selectCcLlmKbCatList(ccLlmKbCat);
+        ExcelUtil<CcLlmKbCat> util = new ExcelUtil<CcLlmKbCat>(CcLlmKbCat.class);
+        return util.exportExcel(list, "知识库数据");
+    }
+
+    /**
+     * 新增知识库
+     */
+    @GetMapping("/add")
+    public String add(ModelMap mmap)
+    {
+        mmap.put("ccLlmKbCat", new CcLlmKbCat());
+        return prefix + "/add";
+    }
+
+    /**
+     * 新增保存知识库
+     */
+    @RequiresPermissions("aicall:kbcat:add")
+    @Log(title = "知识库", businessType = BusinessType.INSERT)
+    @PostMapping("/add")
+    @ResponseBody
+    public AjaxResult addSave(CcLlmKbCat ccLlmKbCat)
+    {
+        CcLlmKbCat checkCcLlmKbCat = ccLlmKbCatService.selectCcLlmKbCatByCat(null, ccLlmKbCat.getCat());
+        if (null != checkCcLlmKbCat) {
+            return AjaxResult.error("知识库分类不能重复,请修改");
+        }
+        return toAjax(ccLlmKbCatService.insertCcLlmKbCat(ccLlmKbCat));
+    }
+
+    /**
+     * 修改知识库
+     */
+    @RequiresPermissions("aicall:kbcat:edit")
+    @GetMapping("/edit/{id}")
+    public String edit(@PathVariable("id") Long id, ModelMap mmap)
+    {
+        CcLlmKbCat ccLlmKbCat = ccLlmKbCatService.selectCcLlmKbCatById(id);
+        mmap.put("ccLlmKbCat", ccLlmKbCat);
+        return prefix + "/edit";
+    }
+
+    /**
+     * 修改保存知识库
+     */
+    @RequiresPermissions("aicall:kbcat:edit")
+    @Log(title = "知识库", businessType = BusinessType.UPDATE)
+    @PostMapping("/edit")
+    @ResponseBody
+    public AjaxResult editSave(CcLlmKbCat ccLlmKbCat)
+    {
+        CcLlmKbCat checkCcLlmKbCat = ccLlmKbCatService.selectCcLlmKbCatByCat(ccLlmKbCat.getId(), ccLlmKbCat.getCat());
+        if (null != checkCcLlmKbCat) {
+            return AjaxResult.error("知识库分类不能重复,请修改");
+        }
+        return toAjax(ccLlmKbCatService.updateCcLlmKbCat(ccLlmKbCat));
+    }
+
+    /**
+     * 删除知识库
+     */
+    @RequiresPermissions("aicall:kbcat:remove")
+    @Log(title = "知识库", businessType = BusinessType.DELETE)
+    @PostMapping( "/remove")
+    @ResponseBody
+    public AjaxResult remove(String ids)
+    {
+        return toAjax(ccLlmKbCatService.deleteCcLlmKbCatByIds(ids));
+    }
+
+
+
+
+    @GetMapping("/all")
+    @ResponseBody
+    public AjaxResult all()
+    {
+        List<CcLlmKbCat> list = ccLlmKbCatService.selectCcLlmKbCatList(new CcLlmKbCat());
+        return AjaxResult.success(list);
+    }
+
+
+
+}

+ 185 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/controller/CcLlmKbController.java

@@ -0,0 +1,185 @@
+package com.ruoyi.aicall.controller;
+
+import java.io.IOException;
+import java.net.URLEncoder;
+import java.util.List;
+
+import com.ruoyi.aicall.domain.CcLlmKbCat;
+import com.ruoyi.aicall.service.ICcLlmKbCatService;
+import org.apache.poi.ss.usermodel.Row;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.xssf.streaming.SXSSFWorkbook;
+import org.apache.shiro.authz.annotation.RequiresPermissions;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.ModelMap;
+import org.springframework.web.bind.annotation.*;
+import com.ruoyi.common.annotation.Log;
+import com.ruoyi.common.enums.BusinessType;
+import com.ruoyi.aicall.domain.CcLlmKb;
+import com.ruoyi.aicall.service.ICcLlmKbService;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.utils.poi.ExcelUtil;
+import com.ruoyi.common.core.page.TableDataInfo;
+import org.springframework.web.multipart.MultipartFile;
+
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ * 知识库内容Controller
+ * 
+ * @author ruoyi
+ * @date 2026-01-19
+ */
+@Controller
+@RequestMapping("/aicall/kbcontent")
+public class CcLlmKbController extends BaseController
+{
+    private String prefix = "aicall/kbcontent";
+
+    @Autowired
+    private ICcLlmKbService ccLlmKbService;
+    @Autowired
+    private ICcLlmKbCatService ccLlmKbCatService;
+
+    @RequiresPermissions("aicall:kbcontent:view")
+    @GetMapping("/kbcontent")
+    public String kbcontent(@RequestParam(value = "id", required = false) Long catId,
+                            ModelMap mmap) {
+        mmap.put("catId", catId == null ? "" : catId);
+        CcLlmKbCat ccLlmKbCat = ccLlmKbCatService.selectCcLlmKbCatById(catId);
+        mmap.put("ccLlmKbCat", ccLlmKbCat);
+        return prefix + "/kbcontent";
+    }
+
+    /**
+     * 查询知识库内容列表
+     */
+    @RequiresPermissions("aicall:kbcontent:list")
+    @PostMapping("/list")
+    @ResponseBody
+    public TableDataInfo list(CcLlmKb ccLlmKb)
+    {
+        startPage();
+        List<CcLlmKb> list = ccLlmKbService.selectCcLlmKbList(ccLlmKb);
+        return getDataTable(list);
+    }
+
+    /**
+     * 新增知识库内容
+     */
+    @GetMapping("/add")
+    public String add(@RequestParam("catId") Long catId, ModelMap mmap)
+    {
+        CcLlmKb ccLlmKb = new CcLlmKb();
+        ccLlmKb.setCatId(catId);
+        mmap.put("ccLlmKb", ccLlmKb);
+        return prefix + "/add";
+    }
+
+    /**
+     * 新增保存知识库内容
+     */
+    @RequiresPermissions("aicall:kbcontent:add")
+    @Log(title = "知识库内容", businessType = BusinessType.INSERT)
+    @PostMapping("/add")
+    @ResponseBody
+    public AjaxResult addSave(CcLlmKb ccLlmKb)
+    {
+
+        CcLlmKb checkCcLlmKb = ccLlmKbService.selectCcLlmKbContentByTitle(null, ccLlmKb.getTitle(), ccLlmKb.getCatId());
+        if (null != checkCcLlmKb) {
+            return AjaxResult.error("知识库内容不能重复,请修改");
+        }
+        return toAjax(ccLlmKbService.insertCcLlmKb(ccLlmKb));
+    }
+
+    /**
+     * 修改知识库内容
+     */
+    @RequiresPermissions("aicall:kbcontent:edit")
+    @GetMapping("/edit/{id}")
+    public String edit(@PathVariable("id") Long id, ModelMap mmap)
+    {
+        CcLlmKb ccLlmKb = ccLlmKbService.selectCcLlmKbById(id);
+        mmap.put("ccLlmKb", ccLlmKb);
+        return prefix + "/edit";
+    }
+
+    /**
+     * 修改保存知识库内容
+     */
+    @RequiresPermissions("aicall:kbcontent:edit")
+    @Log(title = "知识库内容", businessType = BusinessType.UPDATE)
+    @PostMapping("/edit")
+    @ResponseBody
+    public AjaxResult editSave(CcLlmKb ccLlmKb)
+    {
+
+        CcLlmKb checkCcLlmKb = ccLlmKbService.selectCcLlmKbContentByTitle(ccLlmKb.getId(), ccLlmKb.getTitle(), ccLlmKb.getCatId());
+        if (null != checkCcLlmKb ) {
+            return AjaxResult.error("知识库内容不能重复,请修改");
+        }
+        return toAjax(ccLlmKbService.updateCcLlmKb(ccLlmKb));
+    }
+
+    /**
+     * 删除知识库内容
+     */
+    @RequiresPermissions("aicall:kbcontent:remove")
+    @Log(title = "知识库内容", businessType = BusinessType.DELETE)
+    @PostMapping( "/remove")
+    @ResponseBody
+    public AjaxResult remove(String ids)
+    {
+        return toAjax(ccLlmKbService.deleteCcLlmKbByIds(ids));
+    }
+
+    /* 导出 */
+    @GetMapping("/export/{catId}")
+    public void export(HttpServletResponse resp, @PathVariable Long catId) throws IOException {
+        List<CcLlmKb> list = ccLlmKbService.selectCcLlmKbList(new CcLlmKb().setCatId(catId));
+        CcLlmKbCat ccLlmKbCat = ccLlmKbCatService.selectCcLlmKbCatById(catId);
+        String filename = "知识库-" + ccLlmKbCat.getCat();
+        resp.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+        resp.setHeader("Content-Disposition", "attachment;filename=" + URLEncoder.encode(filename, "UTF-8") + ".xlsx");
+        try (SXSSFWorkbook wb = new SXSSFWorkbook(100)) {
+            Sheet sheet = wb.createSheet(filename);
+            Row head = sheet.createRow(0);
+            head.createCell(0).setCellValue("标题");
+            head.createCell(1).setCellValue("内容");
+            int r = 1;
+            for (CcLlmKb e : list) {
+                Row row = sheet.createRow(r++);
+                row.createCell(0).setCellValue(e.getTitle());
+                row.createCell(1).setCellValue(e.getContent());
+            }
+            wb.write(resp.getOutputStream());
+        }
+    }
+
+    /* 导入 */
+    @PostMapping("/importData/{catId}")
+    @ResponseBody
+    public AjaxResult importData(MultipartFile file,
+                                 @PathVariable Long catId) throws Exception {
+        String msg = ccLlmKbService.importData(file, catId);
+        return success(msg);
+    }
+
+    /* 下载模板(仅两列) */
+    @GetMapping("/importTemplate")
+    public void importTemplate(HttpServletResponse resp) throws IOException {
+        resp.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+        resp.setHeader("Content-Disposition", "attachment;filename=" + URLEncoder.encode("知识库导入模板", "UTF-8") + ".xlsx");
+        try (SXSSFWorkbook wb = new SXSSFWorkbook()) {
+            Sheet sheet = wb.createSheet("模板");
+            Row head = sheet.createRow(0);
+            head.createCell(0).setCellValue("标题");
+            head.createCell(1).setCellValue("内容");
+            wb.write(resp.getOutputStream());
+        }
+    }
+
+}

+ 86 - 6
ruoyi-admin/src/main/java/com/ruoyi/aicall/controller/CcTtsAliyunController.java

@@ -1,15 +1,18 @@
 package com.ruoyi.aicall.controller;
 
+import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
+
+import com.ruoyi.system.domain.SysConfig;
+import com.ruoyi.system.service.ISysConfigService;
 import org.apache.shiro.authz.annotation.RequiresPermissions;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Configurable;
 import org.springframework.stereotype.Controller;
 import org.springframework.ui.ModelMap;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.PathVariable;
-import org.springframework.web.bind.annotation.PostMapping;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.ResponseBody;
+import org.springframework.web.bind.annotation.*;
 import com.ruoyi.common.annotation.Log;
 import com.ruoyi.common.enums.BusinessType;
 import com.ruoyi.aicall.domain.CcTtsAliyun;
@@ -33,6 +36,8 @@ public class CcTtsAliyunController extends BaseController
 
     @Autowired
     private ICcTtsAliyunService ccTtsAliyunService;
+    @Autowired
+    private ISysConfigService sysConfigService;
 
     @RequiresPermissions("aicall:ttsAliyun:view")
     @GetMapping()
@@ -126,7 +131,7 @@ public class CcTtsAliyunController extends BaseController
     }
 
     /**
-     * 查询阿里云音色列列表
+     * 查询全部音色列表
      */
     @GetMapping("/all")
     @ResponseBody
@@ -135,4 +140,79 @@ public class CcTtsAliyunController extends BaseController
         List<CcTtsAliyun> list = ccTtsAliyunService.selectCcTtsAliyunList(new CcTtsAliyun());
         return AjaxResult.success(list);
     }
+
+
+    /**
+     * 查询全部provider列表
+     */
+    @GetMapping("/provider/all")
+    @ResponseBody
+    public AjaxResult allProvider()
+    {
+        List<CcTtsAliyun> list = ccTtsAliyunService.selectCcTtsAliyunList(new CcTtsAliyun());
+        List<String> providerList = new ArrayList<>();
+        for (CcTtsAliyun ccTtsAliyun: list) {
+            if (!providerList.contains(ccTtsAliyun.getProvider().trim())) {
+                providerList.add(ccTtsAliyun.getProvider().trim());
+            }
+        }
+        return AjaxResult.success(providerList);
+    }
+
+//    /**
+//     * 根据provider查询音色列表
+//     */
+//    @GetMapping("/getByProvider")
+//    @ResponseBody
+//    public AjaxResult getByProvider(@RequestParam String provider)
+//    {
+//        List<CcTtsAliyun> list = ccTtsAliyunService.selectCcTtsAliyunList(new CcTtsAliyun().setProvider(provider));
+//        return AjaxResult.success(list);
+//    }
+
+    /**
+     * 根据provider查询音色列表
+     */
+    @GetMapping("/getByVoiceSource")
+    @ResponseBody
+    public AjaxResult getByVoiceSource(@RequestParam String voiceSource)
+    {
+        List<CcTtsAliyun> list = ccTtsAliyunService.selectCcTtsAliyunList(new CcTtsAliyun().setVoiceSource(voiceSource));
+        return AjaxResult.success(list);
+    }
+
+
+    /**
+     * 查询全部ttsprovider列表
+     */
+    @GetMapping("/tts/voiceSource/all")
+    @ResponseBody
+    public AjaxResult allTtsProvider()
+    {
+        SysConfig sysConfig = new SysConfig();
+        sysConfig.setConfigKey("config_tts_provider");
+        List<SysConfig> list = sysConfigService.selectConfigList(sysConfig);
+        Map<String, String> providerMap = new HashMap<>();
+        for (SysConfig data: list) {
+            providerMap.put(data.getConfigValue(), data.getConfigName());
+        }
+        return AjaxResult.success(providerMap);
+    }
+
+    /**
+     * 查询全部asrprovider列表
+     */
+    @GetMapping("/asr/provider/all")
+    @ResponseBody
+    public AjaxResult allAsrProvider()
+    {
+        SysConfig sysConfig = new SysConfig();
+        sysConfig.setConfigKey("config_asr_provider");
+        List<SysConfig> list = sysConfigService.selectConfigList(sysConfig);
+        Map<String, String> providerMap = new HashMap<>();
+        for (SysConfig data: list) {
+            providerMap.put(data.getConfigValue(), data.getConfigName());
+        }
+        return AjaxResult.success(providerMap);
+    }
 }

+ 367 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/controller/DoubaoVclController.java

@@ -0,0 +1,367 @@
+package com.ruoyi.aicall.controller;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.ruoyi.aicall.domain.CcTtsAliyun;
+import com.ruoyi.aicall.model.DoubaoVclTtsRequest;
+import com.ruoyi.aicall.service.ICcCallPhoneService;
+import com.ruoyi.aicall.service.ICcCallTaskService;
+import com.ruoyi.aicall.service.ICcTtsAliyunService;
+import com.ruoyi.cc.service.ICcParamsService;
+import com.ruoyi.common.annotation.Log;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.enums.BusinessType;
+import com.ruoyi.common.utils.CommonUtils;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.common.utils.StringUtils;
+import com.ruoyi.common.utils.uuid.UuidGenerator;
+import lombok.extern.slf4j.Slf4j;
+import okhttp3.MediaType;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.Response;
+import org.apache.shiro.authz.annotation.RequiresPermissions;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+import sun.audio.AudioData;
+
+import javax.servlet.http.HttpServletRequest;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * doubao voice clone
+ * 
+ * @author ruoyi
+ * @date 2025-05-29
+ */
+@Controller
+@RequestMapping("/aicall/doubaovcl")
+@Slf4j
+public class DoubaoVclController extends BaseController {
+    private String prefix = "aicall/doubaovcl";
+
+    @Autowired
+    private ICcTtsAliyunService ttsAliyunService;
+
+    @Autowired
+    private ICcParamsService ccParamsService;
+
+    /**
+     * Add/update tts speakers
+     * @param nameParam
+     * @param speakerId
+     */
+    private synchronized void addSpeakerId(String nameParam, String speakerId) {
+        CcTtsAliyun ttsSpeaker = ttsAliyunService.selectCcTtsAliyunByVoiceCode(speakerId);
+        String name = nameParam.replace("'", "").replace(" ", "");
+        if (name.length() > 20) {
+            name = name.substring(0, 20);
+        }
+
+        boolean update = false;
+        if (null == ttsSpeaker) {
+            ttsSpeaker = new CcTtsAliyun();
+        } else {
+            update = true;
+        }
+
+        ttsSpeaker.setVoiceCode(speakerId);
+        ttsSpeaker.setVoiceEnabled(1);
+        ttsSpeaker.setVoiceName(String.format("[%s] - %s", speakerId, name));
+        ttsSpeaker.setVoiceSource("doubao_vcl_tts");
+        ttsSpeaker.setPriority(0);
+        ttsSpeaker.setProvider("doubao");
+
+        try {
+            if (update) {
+                ttsAliyunService.updateCcTtsAliyun(ttsSpeaker);
+            } else {
+                ttsAliyunService.insertCcTtsAliyun(ttsSpeaker);
+            }
+            logger.info("save doubaovcl speakerId succeed. {} {}", name, speakerId);
+        } catch (Throwable e) {
+            logger.error("save doubaovcl speakerId error: {} {}", e.toString(),
+                    CommonUtils.getStackTraceString(e.getStackTrace()));
+        }
+    }
+
+    @RequiresPermissions("aicall:doubaovcl:view")
+    @GetMapping("voiceclone")
+    public String callTask() {
+        return prefix + "/voiceclone";
+    }
+
+    private static final String HOST = "https://openspeech.bytedance.com";
+
+    private static OkHttpClient _httpClient = null;
+    // 创建带超时配置的OkHttpClient
+    private static synchronized OkHttpClient createHttpClient() {
+        if(_httpClient == null) {
+            _httpClient = new OkHttpClient.Builder()
+                    .connectTimeout(30, TimeUnit.SECONDS)
+                    .readTimeout(60, TimeUnit.SECONDS)
+                    .writeTimeout(60, TimeUnit.SECONDS)
+                    .build();
+        }
+        return _httpClient;
+    }
+
+    public String train(String appid, String token, String audioFormat, byte[] audioBytes, String spkId,
+                        String language,  String modelType) throws IOException {
+        String url = HOST + "/api/v1/mega_tts/audio/upload";
+        OkHttpClient client = createHttpClient();
+        // 编码音频文件
+        AudioData audioData = encodeAudioFile(audioFormat, audioBytes);
+        JSONArray audios = new JSONArray();
+        JSONObject audioObj = new JSONObject();
+        audioObj.put("audio_bytes", audioData.getEncodedData());
+        audioObj.put("audio_format", audioData.getAudioFormat());
+        audios.add(audioObj);
+
+        // 构建请求体
+        JSONObject requestBody = new JSONObject();
+        requestBody.put("appid", appid);
+        requestBody.put("speaker_id", spkId);
+        requestBody.put("audios", audios);
+        requestBody.put("source", 2);
+        requestBody.put("language", Integer.parseInt(language));
+        requestBody.put("model_type", Integer.parseInt(modelType));
+
+        okhttp3.RequestBody body = okhttp3.RequestBody.create(
+                MediaType.parse("application/json"),
+                requestBody.toString()
+        );
+
+        String resourceId = "seed-icl-1.0";
+        if(Integer.parseInt(modelType) == 4){
+            resourceId = "seed-icl-2.0";
+        }
+        Request request = new Request.Builder()
+                .url(url)
+                .post(body)
+                .addHeader("Content-Type", "application/json")
+                .addHeader("Authorization", "Bearer;" + token)
+                .addHeader("Resource-Id", resourceId)
+                .build();
+
+        try (Response response = client.newCall(request).execute()) {
+            logger.info("doubaovcl train http response status code {} ", response.code());
+
+            String responseStr = response.body().string();
+            logger.info("doubaovcl train response: {}", responseStr);
+
+            if(response.code() == 403){
+                return "{\"BaseResp\":{ \"StatusCode\": 403 }}";
+            }
+
+            return responseStr;
+        }
+    }
+
+    public boolean getStatus(String appid, String token, String spkId) throws IOException {
+        String url = HOST + "/api/v1/mega_tts/status";
+        OkHttpClient client = new OkHttpClient();
+        JSONObject requestBody = new JSONObject();
+        requestBody.put("appid", appid);
+        requestBody.put("speaker_id", spkId);
+
+        okhttp3.RequestBody body = okhttp3.RequestBody.create(
+                MediaType.parse("application/json"),
+                requestBody.toString()
+        );
+
+        Request request = new Request.Builder()
+                .url(url)
+                .post(body)
+                .addHeader("Content-Type", "application/json")
+                .addHeader("Authorization", "Bearer;" + token)
+                .addHeader("Resource-Id", "volc.megatts.voiceclone")
+                .build();
+
+        try (Response response = client.newCall(request).execute()) {
+            if(response.isSuccessful()) {
+                String json = response.body().string();
+                logger.info("doubaovcl getStatus response: {}", json);
+                JSONObject jsonObject = JSON.parseObject(json);
+                int status = jsonObject.getInteger("status");
+                return status == 2 || status == 4;
+            }
+        }
+        return false;
+    }
+
+
+    private static AudioData encodeAudioFile(String audioFormat, byte[] audioBytes) throws IOException {
+        String encodedData = Base64.getEncoder().encodeToString(audioBytes);
+        return new AudioData(encodedData, audioFormat);
+    }
+
+    // 辅助类用于存储音频数据
+    private static class AudioData {
+        private final String encodedData;
+        private final String audioFormat;
+
+        public AudioData(String encodedData, String audioFormat) {
+            this.encodedData = encodedData;
+            this.audioFormat = audioFormat;
+        }
+
+        public String getEncodedData() {
+            return encodedData;
+        }
+
+        public String getAudioFormat() {
+            return audioFormat;
+        }
+    }
+
+    public static Map<Integer, String> ERROR_MAP = new HashMap<Integer, String>() {{
+        put(1001, "BadRequestError: 请求参数有误");
+        put(1101, "AudioUploadError: 音频上传失败");
+        put(1102, "ASRError: ASR(语音识别成文字)转写失败");
+        put(1103, "SIDError: SID声纹检测失败");
+        put(1104, "SIDFailError: 声纹检测未通过,声纹跟名人相似度过高");
+        put(1105, "GetAudioDataError: 获取音频数据失败");
+        put(1106, "SpeakerIDDuplicationError: SpeakerID重复");
+        put(1107, "SpeakerIDNotFoundError: SpeakerID未找到");
+        put(1108, "AudioConvertError: 音频转码失败");
+        put(1109, "WERError: wer检测错误,上传音频与请求携带文本对比字错率过高");
+        put(1111, "AEEDerror: aed检测错误,通常由于音频不包含说话声");
+        put(1112, "SNRError: SNR检测错误,通常由于信噪比过高");
+        put(1113, "DenoiseError: 降噪处理失败");
+        put(1114, "AudioQualityError: 音频质量低,降噪失败");
+        put(1122, "ASRNoSpeakerError: 未检测到人声");
+        put(1123, "MaxTrainNumLimitReached: 上传接口已经达到次数限制,目前同一个音色支持10次上传");
+        put(403, "ParameterError: 参数错误:请检查 声音ID、app_id、access_token 是否正确、 检查 声音ID 是否和模型类型匹配? (也可能没有开通相关服务)");
+    }};
+
+    /**
+     * uploadAndTrain
+     */
+    @RequiresPermissions("aicall:doubaovcl:uploadAndTrain")
+    @Log(title = "uploadAndTrain", businessType = BusinessType.IMPORT)
+    @PostMapping("/uploadAndTrain")
+    @ResponseBody
+    public AjaxResult uploadAndTrain(HttpServletRequest request, @RequestParam("file") MultipartFile file) throws Exception {
+
+        if (file == null || file.isEmpty()) {
+            String tips = MessageUtils.message("common.adminPage.upload.requiredTips");
+            return AjaxResult.error(tips);
+        }
+
+        // 上传到远程API接口,成功后写入本地音色数据表中;
+        String voiceName = request.getParameter("voice_name");
+        String spkId = request.getParameter("speaker_id");
+
+        String ttsAccountJson = ccParamsService.getParamValueByCode(
+                "doubao-tts-account-json","{}");
+        JSONObject paramValues = JSONObject.parseObject(ttsAccountJson);
+
+        String appid = paramValues.getString("app_id");
+        String token = paramValues.getString("access_token");
+        String language = request.getParameter("language");
+        String modelType = request.getParameter("model_type");
+
+        int strLen = file.getOriginalFilename().length();
+        String audioFormat = file.getOriginalFilename().substring(strLen - 3, strLen);
+        byte[] audioBytes = file.getBytes();
+
+        try {
+             String response = train(appid, token, audioFormat, audioBytes, spkId, language, modelType);
+            //String response = "{\"BaseResp\":{ \"StatusCode\": 0 }}";;
+            if (!StringUtils.isEmpty(response)) {
+                JSONObject jsonObject = JSON.parseObject(response);
+                int statusCode = jsonObject.getJSONObject("BaseResp").getInteger("StatusCode");
+                if (statusCode == 0) {
+                    addSpeakerId(voiceName, spkId);
+                    boolean  modelReady = getStatus(appid, token, spkId);
+                    String tips = modelReady ? MessageUtils.message("doubaovcl.admin.model-status-ready") :
+                            MessageUtils.message("doubaovcl.admin.model-status-not-ready");
+                    return AjaxResult.success(MessageUtils.message("doubaovcl.admin.train-success") + "\n" + tips);
+                } else {
+                    String errDetails =  jsonObject.getJSONObject("BaseResp").getString("StatusMessage");
+                    String errMsg = ERROR_MAP.get(statusCode);
+                    if (!StringUtils.isEmpty(errMsg)) {
+                        return AjaxResult.error(errMsg + "\n" + errDetails);
+                    } else {
+                        return AjaxResult.error("UnKnown error!\n" + response);
+                    }
+                }
+            } else {
+                return AjaxResult.error("UnKnown error!\n" + response);
+            }
+        } catch (IOException e) {
+            AjaxResult.error(String.format("Server internal error! %s \n %s", e.toString(), CommonUtils.getStackTraceString(e.getStackTrace())));
+        }
+        return null;
+    }
+
+    /**
+     * doubaoTtsTest
+     */
+    @RequiresPermissions("aicall:doubaovcl:doubaoTtsTest")
+    @PostMapping("/doubaoTtsTest")
+    @ResponseBody
+    public AjaxResult doubaoTtsTest(HttpServletRequest request) throws Exception {
+        String ttsAccountJson = ccParamsService.getParamValueByCode(
+                "doubao-tts-account-json","{}");
+        JSONObject paramValues = JSONObject.parseObject(ttsAccountJson);
+        String appid = paramValues.getString("app_id");
+        String token = paramValues.getString("access_token");
+
+        String speakerId = request.getParameter("speakerId");
+        String language = request.getParameter("language");
+        String text = request.getParameter("text");
+
+        DoubaoVclTtsRequest ttsRequestConfig = DoubaoVclTtsRequest.builder()
+                .app(DoubaoVclTtsRequest.App.builder()
+                        .appid(appid)
+                        .cluster("volcano_icl")
+                        .build())
+                .user(DoubaoVclTtsRequest.User.builder()
+                        .uid("uid")
+                        .build())
+                .audio(DoubaoVclTtsRequest.Audio.builder()
+                        .encoding("mp3")
+                        .voiceType(speakerId)
+                        .language(language)
+                        .build())
+                .request(DoubaoVclTtsRequest.Request.builder()
+                        .reqID(UuidGenerator.GetOneUuid())
+                        .operation("query")
+                        .text(text)
+                        .build())
+                .build();
+
+        String reqBody = JSON.toJSONString(ttsRequestConfig);
+        log.info("doubaovcl tts request: {}", reqBody);
+
+        String apiUrl = "https://openspeech.bytedance.com/api/v1/tts";
+        OkHttpClient client = createHttpClient();
+        okhttp3.RequestBody body = okhttp3.RequestBody.create(MediaType.get("application/json; charset=utf-8"), reqBody);
+        okhttp3.Request ttsRequest = new okhttp3.Request.Builder()
+                .url(apiUrl)
+                .header("Authorization", "Bearer;" + token)
+                .post(body)
+                .build();
+
+        Response response = client.newCall(ttsRequest).execute();
+        String respStr = response.body().string();
+        if(!response.isSuccessful()) {
+            log.info("doubaovcl tts response: {}", respStr );
+            return AjaxResult.error("语音合成失败! \n" + respStr);
+        }
+        return  AjaxResult.success("tts request success.", respStr);
+    }
+
+}

+ 39 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/domain/CcCallPhone.java

@@ -2,12 +2,14 @@ package com.ruoyi.aicall.domain;
 
 import com.fasterxml.jackson.annotation.JsonInclude;
 import lombok.Data;
+import lombok.experimental.Accessors;
 import org.apache.commons.lang3.builder.ToStringBuilder;
 import org.apache.commons.lang3.builder.ToStringStyle;
 import com.ruoyi.common.annotation.Excel;
 import com.ruoyi.common.core.domain.BaseEntity;
 
 import java.io.Serializable;
+import java.math.BigDecimal;
 import java.util.HashMap;
 import java.util.Map;
 
@@ -18,6 +20,7 @@ import java.util.Map;
  * @date 2025-05-29
  */
 @Data
+@Accessors(chain = true)
 public class CcCallPhone implements Serializable {
     private static final long serialVersionUID = 1L;
 
@@ -125,6 +128,35 @@ public class CcCallPhone implements Serializable {
     @Excel(name = "客户意向", readConverterExp = "客户意向")
     private String intent;
 
+
+    /** asr时长(秒) */
+    private Integer asrSeconds;
+
+    /** tts调用次数(次) */
+    private Integer ttsTimes;
+
+    /** 大模型tts的字符数(字符) */
+    private Integer ttsFlowTokens;
+
+    /** 总输入token数 */
+    private Integer inputTokens;
+
+    /** 总输出token数 */
+    private Integer outputTokens;
+
+    /** 总调用费用(asr+tts+大模型) */
+    private BigDecimal totalCost;
+
+    /** 计费状态(1:已计费、0:未计费) */
+    private Integer billingStatus;
+
+    /** 主叫号码 */
+    @Excel(name = "主叫号码")
+    private String callerNumber;
+
+    /** 整个ivr通话中的有效按键 */
+    private String ivrDtmfDigits;
+
     /** 请求参数 */
     @JsonInclude(JsonInclude.Include.NON_EMPTY)
     private Map<String, Object> params = new HashMap<>();
@@ -133,4 +165,11 @@ public class CcCallPhone implements Serializable {
     @Excel(name = "批次名称", readConverterExp = "批次名称")
     private String batchName;
 
+    /** manual agent answered time. */
+    private String manualAnsweredTime;
+
+    /** The duration of the manual agent service time. */
+    private String manualAnsweredTimeLen;
+
+
 }

+ 36 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/domain/CcCallTask.java

@@ -2,6 +2,7 @@ package com.ruoyi.aicall.domain;
 
 import com.fasterxml.jackson.annotation.JsonInclude;
 import lombok.Data;
+import lombok.experimental.Accessors;
 import org.apache.commons.lang3.builder.ToStringBuilder;
 import org.apache.commons.lang3.builder.ToStringStyle;
 import com.ruoyi.common.annotation.Excel;
@@ -18,6 +19,7 @@ import java.util.Map;
  * @date 2025-05-29
  */
 @Data
+@Accessors(chain = true)
 public class CcCallTask implements Serializable {
     private static final long serialVersionUID = 1L;
 
@@ -126,4 +128,38 @@ public class CcCallTask implements Serializable {
     @Excel(name = "预估接通率")
     private Integer conntectRate;
 
+//    /** tts provider */
+//    private String provider;
+
+    /** tts provider */
+    private String asrProvider;
+
+    /** aiTransferType */
+    private String aiTransferType;
+
+    /** aiTransferData */
+    private String aiTransferData;
+
+    /** aiTransferGroupId */
+    private String aiTransferGroupId;
+
+    /** aiTransferGatewayId */
+    private String aiTransferGatewayId;
+
+    /** aiTransferGatewayDestNumber */
+    private String aiTransferGatewayDestNumber;
+
+    /** aiTransferExtNumber */
+    private String aiTransferExtNumber;
+
+    /** 播放次数 */
+    @Excel(name = "播放次数")
+    private Integer autoStop;
+
+    /** ivrId */
+    private String ivrId;
+
+    /** 是否允许删除 */
+    private Integer allowDel;
+
 }

+ 38 - 2
ruoyi-admin/src/main/java/com/ruoyi/aicall/domain/CcInboundLlmAccount.java

@@ -1,6 +1,7 @@
 package com.ruoyi.aicall.domain;
 
 import lombok.Data;
+import lombok.experimental.Accessors;
 import org.apache.commons.lang3.builder.ToStringBuilder;
 import org.apache.commons.lang3.builder.ToStringStyle;
 import com.ruoyi.common.annotation.Excel;
@@ -15,12 +16,16 @@ import java.io.Serializable;
  * @date 2025-06-23
  */
 @Data
+@Accessors(chain = true)
 public class CcInboundLlmAccount implements Serializable {
     private static final long serialVersionUID = 1L;
 
     /** 主键id */
     private Integer id;
 
+    /** inboundAlias */
+    private String inboundAlias;
+
     /** 大模型底座id */
     @Excel(name = "大模型底座id")
     private Integer llmAccountId;
@@ -33,10 +38,14 @@ public class CcInboundLlmAccount implements Serializable {
     @Excel(name = "TTS voice code for the robot")
     private String voiceCode;
 
-    /** tts provider */
-    @Excel(name = "tts provider")
+    /** tts voiceSource */
+    @Excel(name = "tts voiceSource")
     private String voiceSource;
 
+//    /** tts provider */
+//    @Excel(name = "tts provider")
+//    private String provider;
+
 
     /************  以下不是表结构字段 ************/
     /** 大模型底座名称 */
@@ -50,4 +59,31 @@ public class CcInboundLlmAccount implements Serializable {
     /** TTS voice for the robot */
     private Integer groupId;
 
+    /** tts provider */
+    private String asrProvider;
+
+    /** aiTransferType */
+    private String aiTransferType;
+
+    /** aiTransferData */
+    private String aiTransferData;
+
+    /** aiTransferGroupId */
+    private String aiTransferGroupId;
+
+    /** aiTransferGatewayId */
+    private String aiTransferGatewayId;
+
+    /** aiTransferGatewayDestNumber */
+    private String aiTransferGatewayDestNumber;
+
+    /** aiTransferExtNumber */
+    private String aiTransferExtNumber;
+
+    /** ivrId */
+    private String ivrId;
+
+    /** satisfSurveyIvrId */
+    private String satisfSurveyIvrId;
+
 }

+ 7 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/domain/CcLlmAgentAccount.java

@@ -57,4 +57,11 @@ public class CcLlmAgentAccount implements Serializable {
     @Excel(name = "模型并发数")
     private Integer concurrentNum;
 
+    /** 转人工的按键支持 */
+    @Excel(name = "转人工的按键支持")
+    private String transferManualDigit;
+
+    /** 知识库id */
+    private Integer kbCatId;
+
 }

+ 35 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/domain/CcLlmKb.java

@@ -0,0 +1,35 @@
+package com.ruoyi.aicall.domain;
+
+import lombok.Data;
+import lombok.experimental.Accessors;
+import org.apache.commons.lang3.builder.ToStringBuilder;
+import org.apache.commons.lang3.builder.ToStringStyle;
+import com.ruoyi.common.annotation.Excel;
+import com.ruoyi.common.core.domain.BaseEntity;
+
+import java.io.Serializable;
+
+/**
+ * 知识库内容对象 cc_llm_kb
+ * 
+ * @author ruoyi
+ * @date 2026-01-19
+ */
+@Data
+@Accessors(chain = true)
+public class CcLlmKb implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    /** 主键id */
+    private Long id;
+
+    /** kb title */
+    private String title;
+
+    /** 答案 */
+    private String content;
+
+    /** kb cat id. */
+    private Long catId;
+
+}

+ 35 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/domain/CcLlmKbCat.java

@@ -0,0 +1,35 @@
+package com.ruoyi.aicall.domain;
+
+import lombok.Data;
+import lombok.experimental.Accessors;
+import org.apache.commons.lang3.builder.ToStringBuilder;
+import org.apache.commons.lang3.builder.ToStringStyle;
+import com.ruoyi.common.annotation.Excel;
+import com.ruoyi.common.core.domain.BaseEntity;
+
+import java.io.Serializable;
+
+/**
+ * 知识库对象 cc_llm_kb_cat
+ * 
+ * @author ruoyi
+ * @date 2026-01-19
+ */
+@Data
+@Accessors(chain = true)
+public class CcLlmKbCat implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    /** 主键id */
+    private Long id;
+
+    /** 分类 */
+    private String cat;
+
+    /** 描述 */
+    private String description;
+
+    /** 内容数量 */
+    private Integer contentCount;
+
+}

+ 13 - 1
ruoyi-admin/src/main/java/com/ruoyi/aicall/domain/CcTtsAliyun.java

@@ -9,9 +9,10 @@ import com.ruoyi.common.core.domain.BaseEntity;
 
 import java.io.Serializable;
 
+
 /**
  * 阿里云音色列对象 cc_tts_aliyun
- * 
+ *
  * @author ruoyi
  * @date 2025-05-29
  */
@@ -35,4 +36,15 @@ public class CcTtsAliyun implements Serializable {
     @Excel(name = "是否启用")
     private Integer voiceEnabled;
 
+    /** 声音源,aliyun_tts、aliyun_tts_flow */
+    @Excel(name = "声音源,aliyun_tts、aliyun_tts_flow")
+    private String voiceSource;
+
+    /** priority */
+    @Excel(name = "priority")
+    private Integer priority;
+
+    /** provider */
+    @Excel(name = "provider")
+    private String provider;
 }

+ 6 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/mapper/CcCallPhoneMapper.java

@@ -83,4 +83,10 @@ public interface CcCallPhoneMapper
     List<CcCallPhone> getCustIntentionList();
 
     int updateIntentionByIds(@Param("intention") String intention, @Param("phoneIds") List<String> phoneIds);
+
+    List<CcCallPhone> selectNoHangupCalls(Long batchId);
+
+    void bakCallPhoneByBatchId(Long batchId);
+
+    void delCallPhoneByBatchId(Long batchId);
 }

+ 2 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/mapper/CcCallTaskMapper.java

@@ -67,4 +67,6 @@ public interface CcCallTaskMapper
      * @return
      */
     CcCallTask selectCcCallTaskByBatchName(@Param("batchName") String batchName, @Param("taskType") Integer taskType);
+
+    void bakCallTaskByBatchId(Long batchId);
 }

+ 61 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/mapper/CcLlmKbCatMapper.java

@@ -0,0 +1,61 @@
+package com.ruoyi.aicall.mapper;
+
+import java.util.List;
+import com.ruoyi.aicall.domain.CcLlmKbCat;
+
+/**
+ * 知识库Mapper接口
+ * 
+ * @author ruoyi
+ * @date 2026-01-19
+ */
+public interface CcLlmKbCatMapper 
+{
+    /**
+     * 查询知识库
+     * 
+     * @param id 知识库主键
+     * @return 知识库
+     */
+    public CcLlmKbCat selectCcLlmKbCatById(Long id);
+
+    /**
+     * 查询知识库列表
+     * 
+     * @param ccLlmKbCat 知识库
+     * @return 知识库集合
+     */
+    public List<CcLlmKbCat> selectCcLlmKbCatList(CcLlmKbCat ccLlmKbCat);
+
+    /**
+     * 新增知识库
+     * 
+     * @param ccLlmKbCat 知识库
+     * @return 结果
+     */
+    public int insertCcLlmKbCat(CcLlmKbCat ccLlmKbCat);
+
+    /**
+     * 修改知识库
+     * 
+     * @param ccLlmKbCat 知识库
+     * @return 结果
+     */
+    public int updateCcLlmKbCat(CcLlmKbCat ccLlmKbCat);
+
+    /**
+     * 删除知识库
+     * 
+     * @param id 知识库主键
+     * @return 结果
+     */
+    public int deleteCcLlmKbCatById(Long id);
+
+    /**
+     * 批量删除知识库
+     * 
+     * @param ids 需要删除的数据主键集合
+     * @return 结果
+     */
+    public int deleteCcLlmKbCatByIds(String[] ids);
+}

+ 67 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/mapper/CcLlmKbMapper.java

@@ -0,0 +1,67 @@
+package com.ruoyi.aicall.mapper;
+
+import java.util.List;
+import com.ruoyi.aicall.domain.CcLlmKb;
+
+/**
+ * 知识库内容Mapper接口
+ * 
+ * @author ruoyi
+ * @date 2026-01-19
+ */
+public interface CcLlmKbMapper 
+{
+    /**
+     * 查询知识库内容
+     * 
+     * @param id 知识库内容主键
+     * @return 知识库内容
+     */
+    public CcLlmKb selectCcLlmKbById(Long id);
+
+    /**
+     * 查询知识库内容列表
+     * 
+     * @param ccLlmKb 知识库内容
+     * @return 知识库内容集合
+     */
+    public List<CcLlmKb> selectCcLlmKbList(CcLlmKb ccLlmKb);
+
+    /**
+     * 新增知识库内容
+     * 
+     * @param ccLlmKb 知识库内容
+     * @return 结果
+     */
+    public int insertCcLlmKb(CcLlmKb ccLlmKb);
+
+    /**
+     * 修改知识库内容
+     * 
+     * @param ccLlmKb 知识库内容
+     * @return 结果
+     */
+    public int updateCcLlmKb(CcLlmKb ccLlmKb);
+
+    /**
+     * 删除知识库内容
+     * 
+     * @param id 知识库内容主键
+     * @return 结果
+     */
+    public int deleteCcLlmKbById(Long id);
+
+    /**
+     * 批量删除知识库内容
+     * 
+     * @param ids 需要删除的数据主键集合
+     * @return 结果
+     */
+    public int deleteCcLlmKbByIds(String[] ids);
+
+    Integer selectCountByCatId(Long catId);
+
+    void deleteCcLlmKbByCatId(Long catId);
+
+    void insertBatch(List<CcLlmKb> ccLlmKbList);
+}

+ 15 - 3
ruoyi-admin/src/main/java/com/ruoyi/aicall/model/ApiCallRecordQueryParams.java

@@ -1,20 +1,22 @@
 package com.ruoyi.aicall.model;
 
-import com.fasterxml.jackson.annotation.JsonInclude;
 import com.ruoyi.common.annotation.Excel;
 import lombok.Data;
+import lombok.experimental.Accessors;
 
 import java.io.Serializable;
-import java.util.HashMap;
-import java.util.Map;
 
 @Data
+@Accessors(chain = true)
 public class ApiCallRecordQueryParams implements Serializable {
     private static final long serialVersionUID = 1L;
 
     private Integer pageNum;
     private Integer pageSize;
 
+    /** uuid */
+    private String uuid;
+
     /** 类型(01:呼入, 02:AI外呼, 03:人工外呼) */
     private String callType;
 
@@ -34,5 +36,15 @@ public class ApiCallRecordQueryParams implements Serializable {
     /** 分机号 */
     private String extnum;
 
+    /** callType=02时可用 */
+    private Long batchId;
+
+    /** callType=02时可用 */
+    /** 0. 未拨打;1. 排队中;2. 正在拨打 ;3. 未接通 ;6. 成功转接;7. 线路故障 */
+    private Integer callstatus;
+
+    /** callType=02时可用 */
+    /** 主叫号码 */
+    private String callerNumber;
 
 }

+ 15 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/model/ApiCallRecordQueryResult.java

@@ -44,4 +44,19 @@ public class ApiCallRecordQueryResult implements Serializable {
     /** 通话时长(秒) */
     private Integer timeLen;
 
+    /** AI外呼的id */
+    private String sessionId;
+
+    /** 0. 未拨打, 1. 排队中, 2. 正在拨打  , 3. 未接通 , 6. 成功转接, 7. 线路故障 */
+    private Integer callstatus;
+
+    /** 主叫号码 */
+    private String callerNumber;
+
+    /** manual agent answered time. */
+    private String manualAnsweredTime;
+
+    /** The duration of the manual agent service time. */
+    private String manualAnsweredTimeLen;
+
 }

+ 24 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/model/ApiGatewaysModel.java

@@ -38,6 +38,30 @@ public class ApiGatewaysModel implements Serializable {
     /** 网关名称描述; */
     private String gwDesc;
 
+    /** 网关最大并发数; 仅用作手工选择参考,不用于程序判断: */
+    private Long maxConcurrency;
+
+    /** 注册模式下;认证用户名 */
+    private String authUsername;
+
+    /** 反向注册模式下;分机号 */
+    private String authUsername2;
+
+    /** 注册模式下;认证密码 */
+    private String authPassword;
+
+    /** 使用优先级; 数字1-9; 数字越小,越优先被使用 */
+    private Long priority;
+
+    /** 更新时间 */
+    private Long updateTime;
+
+    /** 是否需要认证注册; 0 对接模式, 1注册模式, 2反向注册模式 */
+    private Long register;
+
+    /** 自定义属性 */
+    private String configs;
+
     /** 网关用途 0 dropped; 1 phonebar; 2 outbound tasks; 3. Unlimited */
     private Integer purpose;
 

+ 4 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/model/CallPhoneExportVo.java

@@ -44,4 +44,8 @@ public class CallPhoneExportVo implements Serializable {
     @Excel(name = "通话时长")
     private String timeLenSec;
 
+    /** 整个ivr通话中的有效按键 */
+    @Excel(name = "ivr按键")
+    private String ivrDtmfDigits;
+
 }

+ 72 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/model/DoubaoVclTtsRequest.java

@@ -0,0 +1,72 @@
+package com.ruoyi.aicall.model;
+
+import com.alibaba.fastjson.annotation.JSONField;
+import lombok.Builder;
+import lombok.Data;
+
+@Data
+@Builder
+public class DoubaoVclTtsRequest {
+    @JSONField(name = "app")
+    private App app;
+    @JSONField(name = "user")
+    private User user;
+    @JSONField(name = "audio")
+    private Audio audio;
+    @JSONField(name = "request")
+    private Request request;
+
+    @Data
+    @Builder
+    public static class App {
+        @JSONField(name = "appid")
+        private String appid;
+        @JSONField(name = "token")
+        private String token;
+        @JSONField(name = "cluster")
+        private String cluster;
+    }
+
+    @Data
+    @Builder
+    public static class User {
+        @JSONField(name = "uid")
+        private String uid;
+    }
+
+    @Data
+    @Builder
+    public static class Audio {
+        @JSONField(name = "voice_type")
+        private String voiceType;
+        @JSONField(name = "voice")
+        private String voice;
+        @JSONField(name = "encoding")
+        private String encoding;
+        @JSONField(name = "speed_ratio")
+        private Double speedRatio;
+        @JSONField(name = "volume_ratio")
+        private Double volumeRatio;
+        @JSONField(name = "pitch_ratio")
+        private Double pitchRatio;
+        @JSONField(name = "emotion")
+        private String emotion;
+        @JSONField(name = "language")
+        private String language;
+    }
+
+    @Data
+    @Builder
+    public static class Request {
+        @JSONField(name = "reqid")
+        private String reqID;
+        @JSONField(name = "text")
+        private String text;
+        @JSONField(name = "text_type")
+        private String textType;
+        @JSONField(name = "operation")
+        private String operation;
+        @JSONField(name = "silence_duration")
+        private Integer silenceDuration;
+    }
+}

+ 13 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/model/KbContentImportDTO.java

@@ -0,0 +1,13 @@
+package com.ruoyi.aicall.model;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+
+@Data
+public class KbContentImportDTO {
+    @NotBlank(message = "标题不能为空")
+    private String title;
+    private String content;
+
+}

+ 12 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/model/LocalCallModel.java

@@ -0,0 +1,12 @@
+package com.ruoyi.aicall.model;
+
+import lombok.Data;
+
+@Data
+public class LocalCallModel {
+    private String caller; // 主叫号码
+    private String phone; // 被叫号码
+    private String welcomeMessage; // 首句
+    private String questionChainId; // 问题链
+    private String batchName; // 任务名称
+}

+ 119 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/openai/OpenAIChatClient.java

@@ -0,0 +1,119 @@
+//package com.ruoyi.aicall.utils;
+//
+//import com.openai.client.OpenAIClient;
+//import com.openai.models.*;
+//import org.springframework.beans.factory.annotation.Value;
+//import org.springframework.stereotype.Component;
+//
+//import javax.annotation.PostConstruct;
+//import java.util.ArrayList;
+//import java.util.Collections;
+//import java.util.List;
+//import java.util.Map;
+//import java.util.stream.Collectors;
+//
+//@Component
+//public class OpenAIChatClient {
+//
+//    private OpenAIClient client;
+//
+//    @Value("${openai.api-key}")
+//    private String apiKey;
+//
+//    @Value("${openai.model:gpt-4o}")
+//    private String defaultModel;
+//
+//    @PostConstruct
+//    public void init() {
+//        this.client = OpenAIOkHttpClient.builder()
+//                .apiKey(apiKey)
+//                .build();
+//    }
+//
+//    // 简单对话
+//    public String simpleChat(String message) {
+//        List<Map<String, String>> messages = new ArrayList<Map<String, String>>();
+//        Map<String, String> msg = Collections.singletonMap("content", message);
+//        // 注意:singletonMap 不可修改,需要重新构建
+//        messages.add(Collections.<String, String>singletonMap("role", "user"));
+//        // 实际应该使用 HashMap
+//        return chatWithHistory(buildMessages("user", message));
+//    }
+//
+//    // 构建消息列表辅助方法
+//    private List<Map<String, String>> buildMessages(String role, String content) {
+//        Map<String, String> message = new java.util.HashMap<String, String>();
+//        message.put("role", role);
+//        message.put("content", content);
+//
+//        List<Map<String, String>> messages = new ArrayList<Map<String, String>>();
+//        messages.add(message);
+//        return messages;
+//    }
+//
+//    // 带上下文的对话(Java 8 Stream)
+//    public String chatWithHistory(List<Map<String, String>> messages) {
+//        List<ChatCompletionMessageParam> params = messages.stream()
+//                .map(msg -> {
+//                    ChatCompletionUserMessageParam userMsg = ChatCompletionUserMessageParam.builder()
+//                            .content(msg.get("content"))
+//                            .build();
+//                    return ChatCompletionMessageParam.ofChatCompletionUserMessageParam(userMsg);
+//                })
+//                .collect(Collectors.<ChatCompletionMessageParam>toList()); // 显式类型指定
+//
+//        ChatCompletionCreateParams createParams = ChatCompletionCreateParams.builder()
+//                .model(defaultModel)
+//                .messages(params)
+//                .temperature(0.7)
+//                .maxTokens(2000)
+//                .build();
+//
+//        ChatCompletion completion = client.chat().completions().create(createParams);
+//
+//        // Java 8 安全取值
+//        if (completion.choices() != null && !completion.choices().isEmpty()) {
+//            ChatCompletion.Choice choice = completion.choices().get(0);
+//            if (choice.message() != null && choice.message().content().isPresent()) {
+//                return choice.message().content().get();
+//            }
+//        }
+//        throw new RuntimeException("No response from OpenAI");
+//    }
+//
+//    // 带系统提示的对话
+//    public String chatWithSystem(String systemPrompt, String userMessage) {
+//        List<ChatCompletionMessageParam> messages = new ArrayList<ChatCompletionMessageParam>();
+//
+//        // 系统消息
+//        ChatCompletionSystemMessageParam systemMsg = ChatCompletionSystemMessageParam.builder()
+//                .content(systemPrompt)
+//                .build();
+//        messages.add(ChatCompletionMessageParam.ofChatCompletionSystemMessageParam(systemMsg));
+//
+//        // 用户消息
+//        ChatCompletionUserMessageParam userMsg = ChatCompletionUserMessageParam.builder()
+//                .content(userMessage)
+//                .build();
+//        messages.add(ChatCompletionMessageParam.ofChatCompletionUserMessageParam(userMsg));
+//
+//        ChatCompletionCreateParams params = ChatCompletionCreateParams.builder()
+//                .model(defaultModel)
+//                .messages(messages)
+//                .build();
+//
+//        ChatCompletion completion = client.chat().completions().create(params);
+//
+//        return extractContent(completion);
+//    }
+//
+//    private String extractContent(ChatCompletion completion) {
+//        if (completion.choices() != null && !completion.choices().isEmpty()) {
+//            ChatCompletion.Choice choice = completion.choices().get(0);
+//            if (choice.message() != null && choice.message().content().isPresent()) {
+//                return choice.message().content().get();
+//            }
+//        }
+//        return "";
+//    }
+//}

+ 6 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/service/ICcCallPhoneService.java

@@ -81,4 +81,10 @@ public interface ICcCallPhoneService
     void updateIntentionByIds(String intention, List<String> phoneIds);
 
     String getIntenetionByDialogue(CcLlmAgentAccount ccLlmAgentAccount, String dialogue);
+
+    List<CcCallPhone> selectNoHangupCalls(Long batchId);
+
+    void bakCallPhoneByBatchId(Long batchId);
+
+    void delCallPhoneByBatchId(Long batchId);
 }

+ 2 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/service/ICcCallTaskService.java

@@ -66,4 +66,6 @@ public interface ICcCallTaskService
      * @return
      */
     CcCallTask selectCcCallTaskByBatchName(String batchName, Integer taskType);
+
+    void bakCallTaskByBatchId(Long batchId);
 }

+ 69 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/service/ICcLlmKbCatService.java

@@ -0,0 +1,69 @@
+package com.ruoyi.aicall.service;
+
+import java.util.List;
+import com.ruoyi.aicall.domain.CcLlmKbCat;
+
+/**
+ * 知识库Service接口
+ * 
+ * @author ruoyi
+ * @date 2026-01-19
+ */
+public interface ICcLlmKbCatService 
+{
+    /**
+     * 查询知识库
+     * 
+     * @param id 知识库主键
+     * @return 知识库
+     */
+    public CcLlmKbCat selectCcLlmKbCatById(Long id);
+
+    /**
+     * 查询知识库列表
+     * 
+     * @param ccLlmKbCat 知识库
+     * @return 知识库集合
+     */
+    public List<CcLlmKbCat> selectCcLlmKbCatList(CcLlmKbCat ccLlmKbCat);
+
+    /**
+     * 新增知识库
+     * 
+     * @param ccLlmKbCat 知识库
+     * @return 结果
+     */
+    public int insertCcLlmKbCat(CcLlmKbCat ccLlmKbCat);
+
+    /**
+     * 修改知识库
+     * 
+     * @param ccLlmKbCat 知识库
+     * @return 结果
+     */
+    public int updateCcLlmKbCat(CcLlmKbCat ccLlmKbCat);
+
+    /**
+     * 批量删除知识库
+     * 
+     * @param ids 需要删除的知识库主键集合
+     * @return 结果
+     */
+    public int deleteCcLlmKbCatByIds(String ids);
+
+    /**
+     * 删除知识库信息
+     * 
+     * @param id 知识库主键
+     * @return 结果
+     */
+    public int deleteCcLlmKbCatById(Long id);
+
+    /**
+     *
+     * @param id
+     * @param cat
+     * @return
+     */
+    CcLlmKbCat selectCcLlmKbCatByCat(Long id, String cat);
+}

+ 70 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/service/ICcLlmKbService.java

@@ -0,0 +1,70 @@
+package com.ruoyi.aicall.service;
+
+import java.util.List;
+import com.ruoyi.aicall.domain.CcLlmKb;
+import io.swagger.models.auth.In;
+import org.springframework.web.multipart.MultipartFile;
+
+/**
+ * 知识库内容Service接口
+ * 
+ * @author ruoyi
+ * @date 2026-01-19
+ */
+public interface ICcLlmKbService 
+{
+    /**
+     * 查询知识库内容
+     * 
+     * @param id 知识库内容主键
+     * @return 知识库内容
+     */
+    public CcLlmKb selectCcLlmKbById(Long id);
+
+    /**
+     * 查询知识库内容列表
+     * 
+     * @param ccLlmKb 知识库内容
+     * @return 知识库内容集合
+     */
+    public List<CcLlmKb> selectCcLlmKbList(CcLlmKb ccLlmKb);
+
+    /**
+     * 新增知识库内容
+     * 
+     * @param ccLlmKb 知识库内容
+     * @return 结果
+     */
+    public int insertCcLlmKb(CcLlmKb ccLlmKb);
+
+    /**
+     * 修改知识库内容
+     * 
+     * @param ccLlmKb 知识库内容
+     * @return 结果
+     */
+    public int updateCcLlmKb(CcLlmKb ccLlmKb);
+
+    /**
+     * 批量删除知识库内容
+     * 
+     * @param ids 需要删除的知识库内容主键集合
+     * @return 结果
+     */
+    public int deleteCcLlmKbByIds(String ids);
+
+    /**
+     * 删除知识库内容信息
+     * 
+     * @param id 知识库内容主键
+     * @return 结果
+     */
+    public int deleteCcLlmKbById(Long id);
+
+    Integer selectCountByCatId(Long catId);
+
+    String importData(MultipartFile file,  Long fixedCatId) throws Exception;
+
+    CcLlmKb selectCcLlmKbContentByTitle(Long id, String title, Long catId);
+    
+}

+ 15 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/service/impl/CcCallPhoneServiceImpl.java

@@ -209,4 +209,19 @@ public class CcCallPhoneServiceImpl implements ICcCallPhoneService
         }
         return "";
     }
+
+    @Override
+    public List<CcCallPhone> selectNoHangupCalls(Long batchId) {
+        return ccCallPhoneMapper.selectNoHangupCalls(batchId);
+    }
+
+    @Override
+    public void bakCallPhoneByBatchId(Long batchId) {
+        ccCallPhoneMapper.bakCallPhoneByBatchId(batchId);
+    }
+
+    @Override
+    public void delCallPhoneByBatchId(Long batchId) {
+        ccCallPhoneMapper.delCallPhoneByBatchId(batchId);
+    }
 }

+ 5 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/service/impl/CcCallTaskServiceImpl.java

@@ -110,4 +110,9 @@ public class CcCallTaskServiceImpl implements ICcCallTaskService
     public CcCallTask selectCcCallTaskByBatchName(String batchName, Integer taskType) {
         return ccCallTaskMapper.selectCcCallTaskByBatchName(batchName, taskType);
     }
+
+    @Override
+    public void bakCallTaskByBatchId(Long batchId) {
+        ccCallTaskMapper.bakCallTaskByBatchId(batchId);
+    }
 }

+ 112 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/service/impl/CcLlmKbCatServiceImpl.java

@@ -0,0 +1,112 @@
+package com.ruoyi.aicall.service.impl;
+
+import java.util.List;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import com.ruoyi.aicall.mapper.CcLlmKbCatMapper;
+import com.ruoyi.aicall.domain.CcLlmKbCat;
+import com.ruoyi.aicall.service.ICcLlmKbCatService;
+import com.ruoyi.common.core.text.Convert;
+import org.springframework.util.CollectionUtils;
+
+/**
+ * 知识库Service业务层处理
+ * 
+ * @author ruoyi
+ * @date 2026-01-19
+ */
+@Service
+public class CcLlmKbCatServiceImpl implements ICcLlmKbCatService 
+{
+    @Autowired
+    private CcLlmKbCatMapper ccLlmKbCatMapper;
+
+    /**
+     * 查询知识库
+     * 
+     * @param id 知识库主键
+     * @return 知识库
+     */
+    @Override
+    public CcLlmKbCat selectCcLlmKbCatById(Long id)
+    {
+        return ccLlmKbCatMapper.selectCcLlmKbCatById(id);
+    }
+
+    /**
+     * 查询知识库列表
+     * 
+     * @param ccLlmKbCat 知识库
+     * @return 知识库
+     */
+    @Override
+    public List<CcLlmKbCat> selectCcLlmKbCatList(CcLlmKbCat ccLlmKbCat)
+    {
+        return ccLlmKbCatMapper.selectCcLlmKbCatList(ccLlmKbCat);
+    }
+
+    /**
+     * 新增知识库
+     * 
+     * @param ccLlmKbCat 知识库
+     * @return 结果
+     */
+    @Override
+    public int insertCcLlmKbCat(CcLlmKbCat ccLlmKbCat)
+    {
+        return ccLlmKbCatMapper.insertCcLlmKbCat(ccLlmKbCat);
+    }
+
+    /**
+     * 修改知识库
+     * 
+     * @param ccLlmKbCat 知识库
+     * @return 结果
+     */
+    @Override
+    public int updateCcLlmKbCat(CcLlmKbCat ccLlmKbCat)
+    {
+        return ccLlmKbCatMapper.updateCcLlmKbCat(ccLlmKbCat);
+    }
+
+    /**
+     * 批量删除知识库
+     * 
+     * @param ids 需要删除的知识库主键
+     * @return 结果
+     */
+    @Override
+    public int deleteCcLlmKbCatByIds(String ids)
+    {
+        return ccLlmKbCatMapper.deleteCcLlmKbCatByIds(Convert.toStrArray(ids));
+    }
+
+    /**
+     * 删除知识库信息
+     * 
+     * @param id 知识库主键
+     * @return 结果
+     */
+    @Override
+    public int deleteCcLlmKbCatById(Long id)
+    {
+        return ccLlmKbCatMapper.deleteCcLlmKbCatById(id);
+    }
+
+    @Override
+    public CcLlmKbCat selectCcLlmKbCatByCat(Long id, String cat) {
+        List<CcLlmKbCat> list = ccLlmKbCatMapper.selectCcLlmKbCatList(new CcLlmKbCat().setCat(cat));
+        if (!CollectionUtils.isEmpty(list)) {
+            if (null == id) {
+                return list.get(0);
+            } else {
+                for (CcLlmKbCat data: list) {
+                    if (data.getId() != id) {
+                        return data;
+                    }
+                }
+            }
+        }
+        return null;
+    }
+}

+ 156 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/service/impl/CcLlmKbServiceImpl.java

@@ -0,0 +1,156 @@
+package com.ruoyi.aicall.service.impl;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import com.ruoyi.aicall.domain.CcLlmKbCat;
+import com.ruoyi.aicall.utils.XSSFUtils;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.StringUtils;
+import org.apache.poi.ss.usermodel.Row;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.ss.usermodel.Workbook;
+import org.apache.poi.ss.usermodel.WorkbookFactory;
+import org.apache.shiro.SecurityUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import com.ruoyi.aicall.mapper.CcLlmKbMapper;
+import com.ruoyi.aicall.domain.CcLlmKb;
+import com.ruoyi.aicall.service.ICcLlmKbService;
+import com.ruoyi.common.core.text.Convert;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.CollectionUtils;
+import org.springframework.web.multipart.MultipartFile;
+
+/**
+ * 知识库内容Service业务层处理
+ * 
+ * @author ruoyi
+ * @date 2026-01-19
+ */
+@Service
+public class CcLlmKbServiceImpl implements ICcLlmKbService 
+{
+    @Autowired
+    private CcLlmKbMapper ccLlmKbMapper;
+
+    /**
+     * 查询知识库内容
+     * 
+     * @param id 知识库内容主键
+     * @return 知识库内容
+     */
+    @Override
+    public CcLlmKb selectCcLlmKbById(Long id)
+    {
+        return ccLlmKbMapper.selectCcLlmKbById(id);
+    }
+
+    /**
+     * 查询知识库内容列表
+     * 
+     * @param ccLlmKb 知识库内容
+     * @return 知识库内容
+     */
+    @Override
+    public List<CcLlmKb> selectCcLlmKbList(CcLlmKb ccLlmKb)
+    {
+        return ccLlmKbMapper.selectCcLlmKbList(ccLlmKb);
+    }
+
+    /**
+     * 新增知识库内容
+     * 
+     * @param ccLlmKb 知识库内容
+     * @return 结果
+     */
+    @Override
+    public int insertCcLlmKb(CcLlmKb ccLlmKb)
+    {
+        return ccLlmKbMapper.insertCcLlmKb(ccLlmKb);
+    }
+
+    /**
+     * 修改知识库内容
+     * 
+     * @param ccLlmKb 知识库内容
+     * @return 结果
+     */
+    @Override
+    public int updateCcLlmKb(CcLlmKb ccLlmKb)
+    {
+        return ccLlmKbMapper.updateCcLlmKb(ccLlmKb);
+    }
+
+    /**
+     * 批量删除知识库内容
+     * 
+     * @param ids 需要删除的知识库内容主键
+     * @return 结果
+     */
+    @Override
+    public int deleteCcLlmKbByIds(String ids)
+    {
+        return ccLlmKbMapper.deleteCcLlmKbByIds(Convert.toStrArray(ids));
+    }
+
+    /**
+     * 删除知识库内容信息
+     * 
+     * @param id 知识库内容主键
+     * @return 结果
+     */
+    @Override
+    public int deleteCcLlmKbById(Long id)
+    {
+        return ccLlmKbMapper.deleteCcLlmKbById(id);
+    }
+
+    @Override
+    public Integer selectCountByCatId(Long catId) {
+        return ccLlmKbMapper.selectCountByCatId(catId);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public String importData(MultipartFile file, Long fixedCatId) throws Exception {
+        Workbook wb = WorkbookFactory.create(file.getInputStream());
+        Sheet sheet = wb.getSheetAt(0);
+        if (sheet == null) throw new ServiceException("Excel 为空");
+        List<CcLlmKb> ccLlmKbList = new ArrayList<>();
+        // 跳过表头
+        for (int i = 1; i <= sheet.getLastRowNum(); i++) {
+            Row row = sheet.getRow(i);
+            if (row == null) continue;
+            CcLlmKb e = new CcLlmKb();
+            String title = XSSFUtils.getCellString(row.getCell(0));
+            e.setTitle(title);
+            e.setContent(XSSFUtils.getCellString(row.getCell(1)));
+            if (StringUtils.isBlank(e.getTitle())) continue;
+            e.setCatId(fixedCatId);
+            ccLlmKbList.add(e);
+        }
+        wb.close();
+        ccLlmKbMapper.deleteCcLlmKbByCatId(fixedCatId);
+        ccLlmKbMapper.insertBatch(ccLlmKbList);
+        return "导入成功";
+    }
+
+
+    @Override
+    public CcLlmKb selectCcLlmKbContentByTitle(Long id, String title, Long catId) {
+        List<CcLlmKb> list = ccLlmKbMapper.selectCcLlmKbList(new CcLlmKb().setTitle(title).setCatId(catId));
+        if (!CollectionUtils.isEmpty(list)) {
+            if (null == id) {
+                return list.get(0);
+            } else {
+                for (CcLlmKb data: list) {
+                    if (data.getId() != id) {
+                        return data;
+                    }
+                }
+            }
+        }
+        return null;
+    }
+}

+ 175 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/tencent/TencentCloudManage.java

@@ -0,0 +1,175 @@
+package com.ruoyi.aicall.tencent;
+
+import com.tencentcloudapi.common.AbstractModel;
+
+import com.tencentcloudapi.common.Credential;
+import com.tencentcloudapi.common.profile.ClientProfile;
+import com.tencentcloudapi.common.profile.HttpProfile;
+import com.tencentcloudapi.common.exception.TencentCloudSDKException;
+import com.tencentcloudapi.lighthouse.v20200324.LighthouseClient;
+import com.tencentcloudapi.lighthouse.v20200324.models.*;
+import lombok.extern.slf4j.Slf4j;
+
+import java.util.Arrays;
+
+@Slf4j
+public class TencentCloudManage
+{
+    public static void main(String [] args) {
+        String secretId = "";
+        String secretKey = "";
+        String region = "";
+
+    }
+
+
+    public void describeInstances(String secretId, String secretKey, String region) {
+        try{
+            // 密钥信息从环境变量读取,需要提前在环境变量中设置 TENCENTCLOUD_SECRET_ID 和 TENCENTCLOUD_SECRET_KEY
+            // 使用环境变量方式可以避免密钥硬编码在代码中,提高安全性
+            // 生产环境建议使用更安全的密钥管理方案,如密钥管理系统(KMS)、容器密钥注入等
+            // 请参见:https://cloud.tencent.com/document/product/1278/85305
+            // 密钥可前往官网控制台 https://console.cloud.tencent.com/cam/capi 进行获取
+            Credential cred = new Credential(secretId, secretKey);
+            // 使用临时密钥示例
+            // Credential cred = new Credential("SecretId", "SecretKey", "Token");
+            // 实例化一个http选项,可选的,没有特殊需求可以跳过
+            HttpProfile httpProfile = new HttpProfile();
+            httpProfile.setEndpoint("lighthouse.tencentcloudapi.com");
+            // 实例化一个client选项,可选的,没有特殊需求可以跳过
+            ClientProfile clientProfile = new ClientProfile();
+            clientProfile.setHttpProfile(httpProfile);
+            // 实例化要请求产品的client对象,clientProfile是可选的
+            LighthouseClient client = new LighthouseClient(cred, region, clientProfile);
+            // 实例化一个请求对象,每个接口都会对应一个request对象
+            DescribeInstancesRequest req = new DescribeInstancesRequest();
+
+            // 返回的resp是一个DescribeInstancesResponse的实例,与请求对象对应
+            DescribeInstancesResponse resp = client.DescribeInstances(req);
+            // 输出json格式的字符串回包
+            log.info(AbstractModel.toJsonString(resp));
+        } catch (TencentCloudSDKException e) {
+            log.info(e.toString());
+        }
+    }
+
+    public void describeFirewallRules(String secretId, String secretKey, String region, String instanceId){
+        try{
+            // 密钥信息从环境变量读取,需要提前在环境变量中设置 TENCENTCLOUD_SECRET_ID 和 TENCENTCLOUD_SECRET_KEY
+            // 使用环境变量方式可以避免密钥硬编码在代码中,提高安全性
+            // 生产环境建议使用更安全的密钥管理方案,如密钥管理系统(KMS)、容器密钥注入等
+            // 请参见:https://cloud.tencent.com/document/product/1278/85305
+            // 密钥可前往官网控制台 https://console.cloud.tencent.com/cam/capi 进行获取
+            Credential cred = new Credential(secretId, secretKey);
+            // 使用临时密钥示例
+            // Credential cred = new Credential("SecretId", "SecretKey", "Token");
+            // 实例化一个http选项,可选的,没有特殊需求可以跳过
+            HttpProfile httpProfile = new HttpProfile();
+            httpProfile.setEndpoint("lighthouse.tencentcloudapi.com");
+            // 实例化一个client选项,可选的,没有特殊需求可以跳过
+            ClientProfile clientProfile = new ClientProfile();
+            clientProfile.setHttpProfile(httpProfile);
+            // 实例化要请求产品的client对象,clientProfile是可选的
+            LighthouseClient client = new LighthouseClient(cred, region, clientProfile);
+            // 实例化一个请求对象,每个接口都会对应一个request对象
+            DescribeFirewallRulesRequest req = new DescribeFirewallRulesRequest();
+            req.setInstanceId(instanceId);
+
+            // 返回的resp是一个DescribeFirewallRulesResponse的实例,与请求对象对应
+            DescribeFirewallRulesResponse resp = client.DescribeFirewallRules(req);
+            // 输出json格式的字符串回包
+            log.info(AbstractModel.toJsonString(resp));
+        } catch (TencentCloudSDKException e) {
+            log.info(e.toString());
+        }
+    }
+
+    public void createFirewallRules(String secretId, String secretKey, String region, String instanceId, String allowedIp){
+
+        try{
+            // 密钥信息从环境变量读取,需要提前在环境变量中设置 TENCENTCLOUD_SECRET_ID 和 TENCENTCLOUD_SECRET_KEY
+            // 使用环境变量方式可以避免密钥硬编码在代码中,提高安全性
+            // 生产环境建议使用更安全的密钥管理方案,如密钥管理系统(KMS)、容器密钥注入等
+            // 请参见:https://cloud.tencent.com/document/product/1278/85305
+            // 密钥可前往官网控制台 https://console.cloud.tencent.com/cam/capi 进行获取
+            // 接口文档:https://console.cloud.tencent.com/api/explorer?Product=lighthouse&Version=2020-03-24&Action=DescribeFirewallRules
+
+            Credential cred = new Credential(secretId, secretKey);
+            // 使用临时密钥示例
+            // Credential cred = new Credential("SecretId", "SecretKey", "Token");
+            // 实例化一个http选项,可选的,没有特殊需求可以跳过
+            HttpProfile httpProfile = new HttpProfile();
+            httpProfile.setEndpoint("lighthouse.tencentcloudapi.com");
+            // 实例化一个client选项,可选的,没有特殊需求可以跳过
+            ClientProfile clientProfile = new ClientProfile();
+            clientProfile.setHttpProfile(httpProfile);
+            // 实例化要请求产品的client对象,clientProfile是可选的
+            LighthouseClient client = new LighthouseClient(cred, region, clientProfile);
+            // 实例化一个请求对象,每个接口都会对应一个request对象
+            CreateFirewallRulesRequest req = new CreateFirewallRulesRequest();
+            req.setInstanceId(instanceId);
+            FirewallRule[] firewallRules1 = new FirewallRule[1];
+            FirewallRule firewallRule1 = new FirewallRule();
+            firewallRule1.setPort("5080");
+            firewallRule1.setProtocol("UDP");
+
+            // 关键:设置CIDR表示的IP范围,限制只有指定IP可以访问
+            // 支持格式:单个IP (如 "1.1.1.1/32") 或 IP段 (如 "192.168.1.0/24")
+            // 多个IP用逗号分隔,如 "1.1.1.1/32,2.2.2.2/32"
+            firewallRule1.setCidrBlock(allowedIp + "/32");
+            // 可选:设置防火墙规则备注/描述
+            firewallRule1.setFirewallRuleDescription("允许指定IP访问5080 UDP端口");
+            // 设置动作,默认为ACCEPT(接受),可选值:ACCEPT/DROP
+            firewallRule1.setAction("ACCEPT");
+
+            firewallRules1[0] = firewallRule1;
+            req.setFirewallRules(firewallRules1);
+
+            // 返回的resp是一个CreateFirewallRulesResponse的实例,与请求对象对应
+            CreateFirewallRulesResponse resp = client.CreateFirewallRules(req);
+            // 输出json格式的字符串回包
+            log.info(AbstractModel.toJsonString(resp));
+        } catch (TencentCloudSDKException e) {
+            log.info(e.toString());
+        }
+    }
+
+    public void deleteFirewallRules(String secretId, String secretKey, String region, String instanceId, String allowedIp){
+        try{
+            // 密钥信息从环境变量读取,需要提前在环境变量中设置 TENCENTCLOUD_SECRET_ID 和 TENCENTCLOUD_SECRET_KEY
+            // 使用环境变量方式可以避免密钥硬编码在代码中,提高安全性
+            // 生产环境建议使用更安全的密钥管理方案,如密钥管理系统(KMS)、容器密钥注入等
+            // 请参见:https://cloud.tencent.com/document/product/1278/85305
+            // 密钥可前往官网控制台 https://console.cloud.tencent.com/cam/capi 进行获取
+            Credential cred = new Credential(secretId, secretKey);
+            // 使用临时密钥示例
+            // Credential cred = new Credential("SecretId", "SecretKey", "Token");
+            // 实例化一个http选项,可选的,没有特殊需求可以跳过
+            HttpProfile httpProfile = new HttpProfile();
+            httpProfile.setEndpoint("lighthouse.tencentcloudapi.com");
+            // 实例化一个client选项,可选的,没有特殊需求可以跳过
+            ClientProfile clientProfile = new ClientProfile();
+            clientProfile.setHttpProfile(httpProfile);
+            // 实例化要请求产品的client对象,clientProfile是可选的
+            LighthouseClient client = new LighthouseClient(cred, region, clientProfile);
+            // 实例化一个请求对象,每个接口都会对应一个request对象
+            DeleteFirewallRulesRequest req = new DeleteFirewallRulesRequest();
+            req.setInstanceId(instanceId);
+
+            FirewallRule[] firewallRules1 = new FirewallRule[1];
+            FirewallRule firewallRule1 = new FirewallRule();
+            firewallRule1.setProtocol("UDP");
+            firewallRule1.setPort("5080");
+            firewallRule1.setCidrBlock(allowedIp + "/32");
+            firewallRules1[0] = firewallRule1;
+            req.setFirewallRules(firewallRules1);
+
+            // 返回的resp是一个DeleteFirewallRulesResponse的实例,与请求对象对应
+            DeleteFirewallRulesResponse resp = client.DeleteFirewallRules(req);
+            // 输出json格式的字符串回包
+            log.info(AbstractModel.toJsonString(resp));
+        } catch (TencentCloudSDKException e) {
+            log.info(e.toString());
+        }
+    }
+}

+ 99 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/tts/aliyun/AliAccountTokenCreator.java

@@ -0,0 +1,99 @@
+package com.ruoyi.aicall.tts.aliyun;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.aliyuncs.CommonRequest;
+import com.aliyuncs.CommonResponse;
+import com.aliyuncs.DefaultAcsClient;
+import com.aliyuncs.IAcsClient;
+import com.aliyuncs.exceptions.ClientException;
+import com.aliyuncs.http.MethodType;
+import com.aliyuncs.http.ProtocolType;
+import com.aliyuncs.profile.DefaultProfile;
+import link.thingscloud.freeswitch.esl.util.CurrentTimeMillisClock;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class AliAccountTokenCreator {
+
+    private static AlibabaTokenEntity aliToken = new AlibabaTokenEntity();
+    private static final Logger log = LoggerFactory.getLogger(AliAccountTokenCreator.class);
+
+    public static void initToken(){
+        aliToken.setAccessKeyId("");
+        aliToken.setSecret("");
+        aliToken.setAppkey("");
+        aliToken.setToken("");
+        aliToken.setExpreTime(0L);
+    }
+
+    static {
+        initToken();
+    }
+
+    private static JSONObject generateToken(String accessKeyId, String accessKeySecret) throws ClientException {
+        String REGIONID = "cn-shanghai";
+        String DOMAIN = "nls-meta.cn-shanghai.aliyuncs.com";
+        String API_VERSION = "2019-02-28";
+        String REQUEST_ACTION = "CreateToken";
+        // 创建DefaultAcsClient实例并初始化
+        DefaultProfile profile = DefaultProfile.getProfile(REGIONID, accessKeyId, accessKeySecret);
+        IAcsClient client = new DefaultAcsClient(profile);
+        CommonRequest request = new CommonRequest();
+        request.setDomain(DOMAIN);
+        request.setVersion(API_VERSION);
+        request.setAction(REQUEST_ACTION);
+        request.setMethod(MethodType.POST);
+        request.setProtocol(ProtocolType.HTTP);
+        CommonResponse response = client.getCommonResponse(request);
+        log.info(response.getData());
+        if (response.getHttpStatus() == 200) {
+            JSONObject result = JSON.parseObject(response.getData());
+            return result;
+        } else {
+            log.info("aliyun tts generateToke error!");
+            return null;
+        }
+    }
+
+    public static AlibabaTokenEntity getToken(AliyunTtsAccount account){
+        Long expiredMillsLeft = aliToken.getExpreTime() - CurrentTimeMillisClock.now();
+        //过期前1小时
+        if (StringUtils.isBlank(aliToken.getToken()) || expiredMillsLeft < 3600000 || aliToken.getExpreTime() == 0) {
+            synchronized (aliToken.getAccessKeyId().intern()) {
+                if (StringUtils.isBlank(aliToken.getToken()) ||
+                        expiredMillsLeft < 3600000 || aliToken.getExpreTime() == 0) {
+                    try {
+                        log.info("Token for the account is not exists or expired. Initiating token renewal. AccessKeyId={} ", aliToken.getAccessKeyId());
+                        aliToken.setAccessKeyId(account.getAccess_key_id());
+                        aliToken.setSecret(account.getAccess_key_secret());
+                        aliToken.setAppkey(account.getApp_key());
+
+                        JSONObject result = generateToken(account.getAccess_key_id(), account.getAccess_key_secret());
+                        if(null != result) {
+                            String token = result.getJSONObject("Token").getString("Id");
+                            long expireTime = result.getJSONObject("Token").getLongValue("ExpireTime") * 1000;
+                            aliToken.setToken(token);
+                            aliToken.setExpreTime(expireTime);
+                            log.info("alibabaAccount, accessId={}, appKey={}, token={}, expiredTime={}",
+                                    aliToken.getAccessKeyId(),
+                                    aliToken.getAppkey(),
+                                    token,
+                                    expireTime
+                            );
+                        }else{
+                            log.error("aliShortTextTTSWebAPI error: cant not get token." );
+                            return null;
+                        }
+                    } catch (Exception e) {
+                        log.error("aliShortTextTTSWebAPI error: " + e.toString());
+                        return null;
+                    }
+                }
+            }
+        }
+        return  aliToken;
+    }
+
+}

+ 49 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/tts/aliyun/AlibabaTokenEntity.java

@@ -0,0 +1,49 @@
+package com.ruoyi.aicall.tts.aliyun;
+
+public class AlibabaTokenEntity {
+    private    String accessKeyId = "";
+    private    String secret = "";
+    private    String appkey= "";
+    private    String token= "";
+    private   long expreTime = 0L;
+
+    public String getAccessKeyId() {
+        return accessKeyId;
+    }
+
+    public void setAccessKeyId(String accessKeyId) {
+        this.accessKeyId = accessKeyId;
+    }
+
+    public String getSecret() {
+        return secret;
+    }
+
+    public void setSecret(String secret) {
+        this.secret = secret;
+    }
+
+    public String getAppkey() {
+        return appkey;
+    }
+
+    public void setAppkey(String appkey) {
+        this.appkey = appkey;
+    }
+
+    public String getToken() {
+        return token;
+    }
+
+    public void setToken(String token) {
+        this.token = token;
+    }
+
+    public long getExpreTime() {
+        return expreTime;
+    }
+
+    public void setExpreTime(long expreTime) {
+        this.expreTime = expreTime;
+    }
+}

+ 145 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/tts/aliyun/AliyunTTSWebApi.java

@@ -0,0 +1,145 @@
+package com.ruoyi.aicall.tts.aliyun;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import link.thingscloud.freeswitch.esl.EslConnectionUtil;
+import okhttp3.*;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.util.concurrent.TimeUnit;
+
+public class AliyunTTSWebApi {
+    private static final Logger log = LoggerFactory.getLogger(AliyunTTSWebApi.class);
+    private static String ttsAccountJson = null;
+    private static AliyunTtsAccount ttsAccount = null;
+
+    private static OkHttpClient client;
+    static{
+        int ttsThreadNum = 20;
+        client = new OkHttpClient.Builder()
+                .connectTimeout(10, TimeUnit.SECONDS)
+                .readTimeout(10, TimeUnit.SECONDS)
+                .connectionPool(new ConnectionPool(ttsThreadNum, 12L, TimeUnit.HOURS))
+                .build();
+    }
+
+    private static boolean processPOSTRequest(String accessToken, String voiceCode, String text, String audioSaveFile) {
+        /**
+         * 设置HTTPS POST请求
+         * 1.使用HTTPS协议
+         * 2.语音合成服务域名:nls-gateway.cn-shanghai.aliyuncs.com
+         * 3.语音合成接口请求路径:/stream/v1/tts
+         * 4.设置必须请求参数:appkey、token、text、format、sample_rate
+         * 5.设置可选请求参数:voice、volume、speech_rate、pitch_rate
+         */
+
+        String url = ttsAccount.getServer_url_webapi();
+        JSONObject taskObject = new JSONObject();
+        taskObject.put("appkey", ttsAccount.getApp_key());
+        taskObject.put("token", accessToken);
+        taskObject.put("text", text);
+        taskObject.put("format", "wav");
+        taskObject.put("voice", voiceCode);
+        taskObject.put("sample_rate", 8000);
+        // volume 音量,范围是0~100,可选,默认50
+        taskObject.put("volume",   ttsAccount.getVoice_volume() );
+        // speech_rate 语速,范围是-500~500,可选,默认是0
+        taskObject.put("speech_rate",  ttsAccount.getSpeech_rate() );
+        // pitch_rate 语调,范围是-500~500,可选,默认是0
+        // taskObject.put("pitch_rate", 0);
+        String bodyContent = taskObject.toJSONString();
+        RequestBody reqBody = RequestBody.create(MediaType.parse("application/json"), bodyContent);
+        Request request = new Request.Builder()
+                .url(url)
+                .header("Content-Type", "application/json")
+                .post(reqBody)
+                .build();
+        try {
+            Response response = client.newCall(request).execute();
+            String contentType = response.header("Content-Type");
+            if ("audio/mpeg".equals(contentType)) {
+                File f = new File(audioSaveFile);
+                FileOutputStream fout = new FileOutputStream(f);
+                fout.write(response.body().bytes());
+                fout.close();
+                response.close();
+                return true;
+            }else {
+                // ContentType 为 null 或者为 "application/json"
+                String errorMessage = response.body().string();
+                response.close();
+                log.error("Aliyun tts:  the post request failed: " + errorMessage);
+                JSONObject json = JSON.parseObject(errorMessage);
+                Integer status = json.getInteger("status");
+                return false;
+            }
+        } catch (Exception e) {
+            log.error("Aliyun tts合成失败:{}", e.toString());
+            return false;
+        }
+    }
+
+    public static boolean setAliyunTokenToFreeSWITCH(String uuid, String aliyunTtsAccountJson){
+        AlibabaTokenEntity token = getToken(aliyunTtsAccountJson);
+        if (token != null) {
+            log.info("{} set FreeSWITCH channel variables, aliyun_tts_token={}, aliyun_tts_app_key={} ",
+                    uuid, token.getToken().substring(0, 7) + "*******", token.getAppkey()
+            );
+            EslConnectionUtil.sendExecuteCommand("set", "aliyun_tts_token=" + token.getToken(), uuid);
+            EslConnectionUtil.sendExecuteCommand("set", "aliyun_tts_app_key=" + token.getAppkey(), uuid);
+            return true;
+        }
+        return false;
+    }
+
+    public static AlibabaTokenEntity getToken(String aliyunTtsAccountJson){
+
+        boolean accountUpdated = !aliyunTtsAccountJson.equals(ttsAccountJson);
+        if(StringUtils.isBlank(ttsAccountJson) || accountUpdated) {
+            synchronized (AliyunTTSWebApi.class) {
+                accountUpdated = !aliyunTtsAccountJson.equals(ttsAccountJson);
+                if (StringUtils.isBlank(ttsAccountJson) || accountUpdated) {
+                    AliAccountTokenCreator.initToken();
+                    ttsAccountJson = aliyunTtsAccountJson;
+                    if (!StringUtils.isBlank(ttsAccountJson)) {
+                        try {
+                            ttsAccount = JSON.parseObject(ttsAccountJson, AliyunTtsAccount.class);
+                        } catch (Throwable e) {
+                            log.error("parse `aliyun-tts-account-json` error! ");
+                            return null;
+                        }
+                    } else {
+                        log.error("param `aliyun-tts-account-json` cant not be null.");
+                        return null;
+                    }
+                }
+            }
+        }
+        if(ttsAccount == null){
+            return null;
+        }
+        return AliAccountTokenCreator.getToken(ttsAccount);
+    }
+
+    public static boolean shortTextTTSWebAPI(String voiceCode, String text, String ttsPath, String aliyunTtsAccountJson) {
+         if (StringUtils.isBlank(text.trim())) {
+            log.info("tts text cant not be null, ttsPath:" + ttsPath);
+            return true;
+        }
+
+        AlibabaTokenEntity token = getToken(aliyunTtsAccountJson);
+        if(token == null){
+            log.error("ttsAccount is null, cant not process tts request.");
+            return false;
+        }
+        return processPOSTRequest(token.getToken(), voiceCode, text, ttsPath);
+    }
+
+    public static void main(String[] args) {
+        shortTextTTSWebAPI("aixia", "您好,请问您是张三先生吗", "D://file/test.wav", "{\"access_key_id\":\"LTAI5tRcoEpTthDu74zL591e\",\"cluster\":\"volcano_tts\",\"write_pcm_enable\":\"0\",\"voice_name\":\"aixia\",\"language\":\"1\",\"server_url_webapi\":\"https://nls-gateway.aliyuncs.com/stream/v1/tts\",\"app_key\":\"fnFiu4G4nJ3RRKTO\",\"sample_rate\":\"16000\",\"voice_volume\":\"50\",\"ws_conn_timeout_ms\":\"9000\",\"access_key_secret\":\"238XOSSAmRZjgf4WMP0Ygc9SGcuBsT\",\"speech_rate\":\"-100\",\"server_url\":\"wss://nls-gateway.aliyuncs.com/ws/v1\"}");
+    }
+}

+ 17 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/tts/aliyun/AliyunTtsAccount.java

@@ -0,0 +1,17 @@
+package com.ruoyi.aicall.tts.aliyun;
+
+import lombok.Data;
+
+@Data
+public class AliyunTtsAccount {
+
+    private String server_url;
+    private String server_url_webapi;
+    private String access_key_id;
+    private String access_key_secret;
+    private String app_key;
+    private String voice_name;
+    private int speech_rate;
+    private int voice_volume;
+    private int sample_rate;
+}

+ 96 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/tts/doubao/DoubaoTTSWebApi.java

@@ -0,0 +1,96 @@
+package com.ruoyi.aicall.tts.doubao;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.ruoyi.aicall.utils.WavFileWriter;
+import okhttp3.*;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+
+public class DoubaoTTSWebApi {
+    private static final Logger log = LoggerFactory.getLogger(DoubaoTTSWebApi.class);
+    private static String ttsAccountJson = null;
+    private static DoubaoTtsAccount ttsAccount = null;
+
+    private static OkHttpClient client;
+    static{
+        int ttsThreadNum = 20;
+        client = new OkHttpClient.Builder()
+                .connectTimeout(10, TimeUnit.SECONDS)
+                .readTimeout(10, TimeUnit.SECONDS)
+                .connectionPool(new ConnectionPool(ttsThreadNum, 12L, TimeUnit.HOURS))
+                .build();
+    }
+
+    private static boolean processPOSTRequest(String accessToken, String voiceCode, String text, String audioSaveFile) {
+
+        String API_URL = ttsAccount.getServer_url_webapi();
+        String appid = ttsAccount.getApp_id();
+        String voiceType = voiceCode;
+
+        TtsRequest ttsRequest = TtsRequest.builder()
+                .app(TtsRequest.App.builder()
+                        .appid(appid)
+                        .cluster("volcano_tts")
+                        .build())
+                .user(TtsRequest.User.builder()
+                        .uid("uid")
+                        .build())
+                .audio(TtsRequest.Audio.builder()
+                        .encoding("wav")
+                        .voiceType(voiceType)
+                        .build())
+                .request(TtsRequest.Request.builder()
+                        .reqID(UUID.randomUUID().toString())
+                        .operation("query")
+                        .text(text)
+                        .build())
+                .build();
+
+
+        String reqBody = JSON.toJSONString(ttsRequest);
+        log.info("request: {}", reqBody);
+
+        OkHttpClient client = new OkHttpClient();
+        RequestBody body = RequestBody.create(MediaType.get("application/json; charset=utf-8"), reqBody);
+        Request request = new Request.Builder()
+                .url(API_URL)
+                .header("Authorization", "Bearer;" + accessToken)
+                .post(body)
+                .build();
+
+        try {
+            Response response = client.newCall(request).execute();
+            JSONObject result = JSONObject.parseObject(response.body().string());
+            if (StringUtils.isNotBlank(result.getString("data"))) {
+                WavFileWriter.writeWavFromBase64(result.getString("data"), audioSaveFile);
+                response.close();
+                return true;
+            }
+            return false;
+        } catch (Exception e) {
+            log.error("Doubao tts合成失败:{}", e.toString());
+            return false;
+        }
+    }
+
+    public static boolean shortTextTTSWebAPI(String voiceCode, String text, String ttsPath, String doubaoTtsAccountJson) {
+         if (StringUtils.isBlank(text.trim())) {
+            log.info("tts text cant not be null, ttsPath:" + ttsPath);
+            return true;
+        }
+        ttsAccountJson = doubaoTtsAccountJson;
+        ttsAccount = JSON.parseObject(ttsAccountJson, DoubaoTtsAccount.class);
+        return processPOSTRequest(ttsAccount.getAccess_token(), voiceCode, text, ttsPath);
+    }
+
+    public static void main(String[] args) {
+        shortTextTTSWebAPI("zh_female_vv_uranus_bigtts", "您好,请问您是张三先生吗", "D://file/test2.wav", "{\"access_token\":\"esgS1pvKY_jdvpDezc0EFjJbYGPVTY7j\",\"cluster\":\"volcano_tts\",\"emotion\":\"happy\",\"sample_rate\":\"16000\",\"write_pcm_enable\":\"0\",\"ws_conn_timeout_ms\":\"9000\",\"speech_rate\":\"1\",\"voice_name\":\"S_T7d3irgK1\",\"language\":\"0\",\"server_url\":\"wss://openspeech.bytedance.com/api/v1/tts/ws_binary\",\"server_url_webapi\":\"https://openspeech.bytedance.com/api/v1/tts\",\"app_id\":\"8380189306\"}");
+    }
+}

+ 20 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/tts/doubao/DoubaoTtsAccount.java

@@ -0,0 +1,20 @@
+package com.ruoyi.aicall.tts.doubao;
+
+import lombok.Data;
+
+@Data
+public class DoubaoTtsAccount {
+
+    private String server_url;
+    private String server_url_webapi;
+    private String app_id;
+    private String access_token;
+    private String voice_name;
+    private String cluster = "volcano_tts";
+    private String emotion = "happy";
+    private int sample_rate = 16000;
+    private int write_pcm_enable = 0;
+    private int speech_rate = 1;
+    private String language = "0";
+
+}

+ 62 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/tts/doubao/TtsHttpDemo.java

@@ -0,0 +1,62 @@
+//package com.ruoyi.aicall.tts.doubao;
+//
+//import com.alibaba.fastjson.JSON;
+//import com.alibaba.fastjson.JSONObject;
+//import com.ruoyi.aicall.utils.PcmToWavConverter;
+//import com.ruoyi.aicall.utils.WavFileWriter;
+//import okhttp3.*;
+//import org.slf4j.Logger;
+//import org.slf4j.LoggerFactory;
+//
+//import java.io.IOException;
+//import java.util.UUID;
+//
+//public class TtsHttpDemo {
+//    private static final Logger log = LoggerFactory.getLogger(TtsHttpDemo.class);
+//
+//    public static void main(String[] args) throws IOException {
+//        // set your appid and access_token
+//        String API_URL = "https://openspeech.bytedance.com/api/v1/tts";
+//        String appid = "8380189306";
+//        String accessToken = "esgS1pvKY_jdvpDezc0EFjJbYGPVTY7j";
+//        String voiceType = "BV001_streaming";
+//        String text = "您好,请问您是张三先生吗";
+//
+//        TtsRequest ttsRequest = TtsRequest.builder()
+//            .app(TtsRequest.App.builder()
+//                .appid(appid)
+//                .cluster("volcano_tts")
+//                .build())
+//            .user(TtsRequest.User.builder()
+//                .uid("uid")
+//                .build())
+//            .audio(TtsRequest.Audio.builder()
+//                .encoding("wav")
+//                .voiceType(voiceType)
+//                .build())
+//            .request(TtsRequest.Request.builder()
+//                .reqID(UUID.randomUUID().toString())
+//                .operation("query")
+//                .text(text)
+//                .build())
+//            .build();
+//
+//
+//        String reqBody = JSON.toJSONString(ttsRequest);
+//        log.info("request: {}", reqBody);
+//
+//        OkHttpClient client = new OkHttpClient();
+//        RequestBody body = RequestBody.create(MediaType.get("application/json; charset=utf-8"), reqBody);
+//        Request request = new Request.Builder()
+//            .url(API_URL)
+//            .header("Authorization", "Bearer;" + accessToken)
+//            .post(body)
+//            .build();
+//
+//        Response response = client.newCall(request).execute();
+//        JSONObject result = JSONObject.parseObject(response.body().string());
+//        log.info("response: {}", result.getString("data"));
+//        WavFileWriter.writeWavFromBase64(result.getString("data"), "D://file/1.wav");
+//        System.exit(0);
+//    }
+//}

+ 74 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/tts/doubao/TtsRequest.java

@@ -0,0 +1,74 @@
+package com.ruoyi.aicall.tts.doubao;
+
+import com.alibaba.fastjson.annotation.JSONField;
+import lombok.Builder;
+import lombok.Data;
+
+@Data
+@Builder
+public class TtsRequest {
+    @JSONField(name = "app")
+    private App app;
+    @JSONField(name = "user")
+    private User user;
+    @JSONField(name = "audio")
+    private Audio audio;
+    @JSONField(name = "request")
+    private Request request;
+
+    @Data
+    @Builder
+    public static class App {
+        @JSONField(name = "appid")
+        private String appid;
+        @JSONField(name = "token")
+        private String token; // 目前未生效,使用默认值即可
+        @JSONField(name = "cluster")
+        private String cluster;
+    }
+
+    @Data
+    @Builder
+    public static class User {
+        @JSONField(name = "uid")
+        private String uid;
+    }
+
+    @Data
+    @Builder
+    public static class Audio {
+        @JSONField(name = "voice_type")
+        private String voiceType;
+        @JSONField(name = "voice")
+        private String voice;
+        @JSONField(name = "encoding")
+        private String encoding;
+        @JSONField(name = "speed_ratio")
+        private Double speedRatio;
+        @JSONField(name = "volume_ratio")
+        private Double volumeRatio;
+        @JSONField(name = "pitch_ratio")
+        private Double pitchRatio;
+        @JSONField(name = "emotion")
+        private String emotion;
+        @JSONField(name = "language")
+        private String language;
+        @JSONField(name = "language")
+        private Integer rate;
+    }
+
+    @Data
+    @Builder
+    public static class Request {
+        @JSONField(name = "reqid")
+        private String reqID;
+        @JSONField(name = "text")
+        private String text;
+        @JSONField(name = "text_type")
+        private String textType;
+        @JSONField(name = "operation")
+        private String operation;
+        @JSONField(name = "silence_duration")
+        private Integer silenceDuration;
+    }
+}

+ 160 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/tts/doubao/TtsWebsocketDemo.java

@@ -0,0 +1,160 @@
+//package com.ruoyi.aicall.tts.doubao;
+//
+//import com.alibaba.fastjson.JSON;
+//import lombok.Getter;
+//import org.java_websocket.client.WebSocketClient;
+//import org.java_websocket.framing.CloseFrame;
+//import org.java_websocket.handshake.ServerHandshake;
+//import org.slf4j.Logger;
+//import org.slf4j.LoggerFactory;
+//
+//import java.io.*;
+//import java.math.BigInteger;
+//import java.net.URI;
+//import java.nio.ByteBuffer;
+//import java.nio.charset.StandardCharsets;
+//import java.util.Collections;
+//import java.util.UUID;
+//
+//public class TtsWebsocketDemo {
+//    private static final Logger log = LoggerFactory.getLogger(TtsWebsocketDemo.class);
+//    public static final String API_URL = "wss://openspeech.bytedance.com/api/v1/tts/ws_binary";
+//
+//    public static void main(String[] args) throws Exception {
+//        // set your appid and access_token
+//        String appid = "8380189306";
+//        String accessToken = "esgS1pvKY_jdvpDezc0EFjJbYGPVTY7j";
+//
+//        TtsRequest ttsRequest = TtsRequest.builder()
+//            .app(TtsRequest.App.builder()
+//                .appid(appid)
+//                .cluster("volcano_tts")
+//                .build())
+//            .user(TtsRequest.User.builder()
+//                .uid("uid")
+//                .build())
+//            .audio(TtsRequest.Audio.builder()
+//                .encoding("mp3").emotion("entertainment").language("0")
+//                .voiceType("zh_female_jitangnv_saturn_bigtts")
+//                .build())
+//            .request(TtsRequest.Request.builder()
+//                .reqID(UUID.randomUUID().toString())
+//                .operation("query")
+//                .text("火山引擎大模型声音复刻是使用全新自研语音大模型算法打造的高效化的轻量级音色定制方案。用户在开放环境中,只需录制5s数据,即可即时完成对用户音色、说话风格、口音和声学环境音的复刻。")
+//                .build())
+//            .build();
+//        TtsWebsocketClient ttsWebsocketClient = new TtsWebsocketClient(accessToken);
+//        byte[] audio = ttsWebsocketClient.submit(ttsRequest);
+//        FileOutputStream fos = new FileOutputStream("C:/Users/zhaohai/Downloads/test-cn-jiantangnvyou.mp3");
+//        fos.write(audio);
+//        fos.close();
+//        log.info("TTS done.");
+//    }
+//
+//    public static class TtsWebsocketClient extends WebSocketClient {
+//        private final ByteArrayOutputStream buffer = new ByteArrayOutputStream();
+//
+//        public TtsWebsocketClient(String accessToken) {
+//            super(URI.create(API_URL), Collections.singletonMap("Authorization", "Bearer; " + accessToken));
+//        }
+//
+//        public byte[] submit(TtsRequest ttsRequest) throws InterruptedException {
+//            String json = JSON.toJSONString(ttsRequest);
+//            log.info("request: {}", json);
+//            byte[] jsonBytes = json.getBytes(StandardCharsets.UTF_8);
+//            byte[] header = {0x11, 0x10, 0x10, 0x00};
+//            ByteBuffer requestByte = ByteBuffer.allocate(8 + jsonBytes.length);
+//            requestByte.put(header).putInt(jsonBytes.length).put(jsonBytes);
+//
+//            this.connectBlocking();
+//            synchronized (this) {
+//                this.send(requestByte.array());
+//                wait();
+//                return this.buffer.toByteArray();
+//            }
+//        }
+//
+//        @Override
+//        public void onMessage(ByteBuffer bytes) {
+//            int protocolVersion = (bytes.get(0) & 0xff) >> 4;
+//            int headerSize = bytes.get(0) & 0x0f;
+//            int messageType = (bytes.get(1) & 0xff) >> 4;
+//            int messageTypeSpecificFlags = bytes.get(1) & 0x0f;
+//            int serializationMethod = (bytes.get(2) & 0xff) >> 4;
+//            int messageCompression = bytes.get(2) & 0x0f;
+//            int reserved = bytes.get(3) & 0xff;
+//            bytes.position(headerSize * 4);
+//            byte[] fourByte = new byte[4];
+//            if (messageType == 11) {
+//                // Audio-only server response
+//                log.info("received audio-only response.");
+//                if (messageTypeSpecificFlags == 0) {
+//                    // Ack without audio data
+//                } else {
+//                    bytes.get(fourByte, 0, 4);
+//                    int sequenceNumber = new BigInteger(fourByte).intValue();
+//                    bytes.get(fourByte, 0, 4);
+//                    int payloadSize = new BigInteger(fourByte).intValue();
+//                    byte[] payload = new byte[payloadSize];
+//                    bytes.get(payload, 0, payloadSize);
+//                    try {
+//                        this.buffer.write(payload);
+//                    } catch (IOException e) {
+//                        throw new RuntimeException(e);
+//                    }
+//                    if (sequenceNumber < 0) {
+//                        // received the last segment
+//                        this.close(CloseFrame.NORMAL, "received all audio data.");
+//                    }
+//                }
+//            } else if (messageType == 15) {
+//                // Error message from server
+//                bytes.get(fourByte, 0, 4);
+//                int code = new BigInteger(fourByte).intValue();
+//                bytes.get(fourByte, 0, 4);
+//                int messageSize = new BigInteger(fourByte).intValue();
+//                byte[] messageBytes = new byte[messageSize];
+//                bytes.get(messageBytes, 0, messageSize);
+//                String message = new String(messageBytes, StandardCharsets.UTF_8);
+//                throw new TtsException(code, message);
+//            } else {
+//                log.warn("Received unknown response message type: {}", messageType);
+//            }
+//        }
+//
+//        @Override
+//        public void onOpen(ServerHandshake serverHandshake) {
+//            log.info("opened connection");
+//        }
+//
+//        @Override
+//        public void onMessage(String message) {
+//            log.info("received message: " + message);
+//        }
+//
+//        @Override
+//        public void onClose(int code, String reason, boolean remote) {
+//            log.info("Connection closed by {}, Code: {}, Reason: {}", (remote ? "remote" : "us"), code, reason);
+//            synchronized (this) {
+//                notify();
+//            }
+//        }
+//
+//        @Override
+//        public void onError(Exception e) {
+//            close(CloseFrame.NORMAL, e.toString());
+//        }
+//    }
+//
+//    @Getter
+//    public static class TtsException extends RuntimeException {
+//        private final int code;
+//        private final String message;
+//
+//        public TtsException(int code, String message) {
+//            super("code=" + code + ", message=" + message);
+//            this.code = code;
+//            this.message = message;
+//        }
+//    }
+//}

+ 178 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/utils/BillingTaskQueue.java

@@ -0,0 +1,178 @@
+package com.ruoyi.aicall.utils;
+
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.ruoyi.aicall.domain.CcCallPhone;
+import com.ruoyi.aicall.service.ICcCallPhoneService;
+import com.ruoyi.aicall.service.impl.CcCallPhoneServiceImpl;
+import com.ruoyi.cc.domain.CcInboundCdr;
+import com.ruoyi.cc.service.ICcInboundCdrService;
+import com.ruoyi.cc.service.ICcParamsService;
+import com.ruoyi.cc.service.impl.CcInboundCdrServiceImpl;
+import com.ruoyi.cc.service.impl.CcParamsServiceImpl;
+import com.ruoyi.common.utils.ExceptionUtil;
+import com.ruoyi.common.utils.StringUtils;
+import com.ruoyi.common.utils.spring.SpringUtils;
+import com.ruoyi.framework.web.domain.server.Sys;
+import org.apache.poi.hpsf.Decimal;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.PostConstruct;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 计费任务处理队列处理
+ */
+@Component
+public class BillingTaskQueue {
+
+	@Value("${sysconfig.billing.enable:0}")
+	private String billingEnable;
+
+	private static  final Logger log = LoggerFactory.getLogger(BillingTaskQueue.class);
+
+	private static ICcParamsService ccParamsService = SpringUtils.getBean(CcParamsServiceImpl.class);
+	private static ICcCallPhoneService callPhoneService = SpringUtils.getBean(CcCallPhoneServiceImpl.class);
+	private static ICcInboundCdrService inboundCdrService = SpringUtils.getBean(CcInboundCdrServiceImpl.class);
+
+	private static String PARAM_CODE_PRICE_ASR_ALI = "price_aliyun_asr"; // 阿里云asr单价(每小时)
+	private static String PARAM_CODE_PRICE_TTS_ALI = "price_aliyun_tts"; // 阿里云tts单价(每千次)
+	private static String PARAM_CODE_PRICE_FLOW_TTS_ALI = "price_aliyun_tts_flow"; // 阿里云声音克隆单价(每万字符)
+	private static String PARAM_CODE_PRICE_LLM_TOKEN = "price_llm_token"; // 大模型单价(每千token)
+
+	private static final BigDecimal SECONDS_PER_HOUR = BigDecimal.valueOf(3600); // 每小时秒数
+	private static final BigDecimal ONE_THOUSAND = BigDecimal.valueOf(1000); // 1000
+	private static final BigDecimal TEN_THOUSAND = BigDecimal.valueOf(10000); // 10000
+
+
+	// 获取单价信息
+	// 阿里云asr单价(每小时)
+	private static BigDecimal priceAsrAli = new BigDecimal(ccParamsService.getParamValueByCode(PARAM_CODE_PRICE_ASR_ALI, "0.0"));
+	// 阿里云tts单价(每千次)
+	private static BigDecimal priceTtsAli = new BigDecimal(ccParamsService.getParamValueByCode(PARAM_CODE_PRICE_TTS_ALI, "0.0"));
+	// 阿里云声音克隆单价(每万字符)
+	private static BigDecimal priceFlowTtsAli = new BigDecimal(ccParamsService.getParamValueByCode(PARAM_CODE_PRICE_FLOW_TTS_ALI, "0.0"));
+	// 大模型单价(每千token)
+	private static BigDecimal priceLlmAli = new BigDecimal(ccParamsService.getParamValueByCode(PARAM_CODE_PRICE_LLM_TOKEN, "0.0"));
+
+
+	// 把 static 线程启动块删掉,换成下面方法
+	@PostConstruct   // ② 容器初始化完成后执行
+	public void init() {
+		// 只启动一次后台线程
+		if ("1".equals(billingEnable)) {
+			new Thread(this::BillingTaskQueue, "billingTaskQueue").start();
+		}
+	}
+
+	/**
+	 * 获取未计费的外呼通话记录和呼入通话记录,根据设置的单价进行计费
+	 */
+	private void BillingTaskQueue(){
+
+		while(true) {
+			try {
+				// 获取未计费的cc_call_phone数据
+				List<CcCallPhone> callPhoneList = callPhoneService.selectCcCallPhoneList(new CcCallPhone().setBillingStatus(0));
+				// 计算每通电话费用
+				for (CcCallPhone callPhone: callPhoneList) {
+					// 计算token数
+					Integer inputTokens = 0; // 总输入token数
+					Integer outputTokens = 0; // 总输出token数
+					if (StringUtils.isNotEmpty(callPhone.getDialogue())) {
+						JSONArray dialogues = JSONArray.parseArray(callPhone.getDialogue());
+						for (int i = 0; i < dialogues.size(); i++) {
+							JSONObject json = dialogues.getJSONObject(i);
+							if ("assistant".equals(json.getString("role"))) {
+								if (null != json.getInteger("completionTokens")) {
+									outputTokens += json.getInteger("completionTokens");
+								}
+								if (null != json.getInteger("promptTokens")) {
+									inputTokens += json.getInteger("promptTokens");
+								}
+							}
+						}
+					}
+					callPhone.setInputTokens(inputTokens);
+					callPhone.setOutputTokens(outputTokens);
+					// 计算总费用
+					BigDecimal totalCost = calTotalCost(callPhone.getAsrSeconds(), callPhone.getTtsTimes(), callPhone.getTtsFlowTokens(), callPhone.getInputTokens(), callPhone.getOutputTokens());
+					String sql = String.format("update cc_call_phone set total_cost = %s, billing_status = 1 where id = %s;", totalCost.toString(), callPhone.getId());
+					ProcessSQLQueue.addSql(sql);
+				}
+				// 获取未计费的cc_inbound_cdr数据
+				List<CcInboundCdr> inboundCdrList = inboundCdrService.selectCcInboundCdrList(new CcInboundCdr().setBillingStatus(0));
+				// 计算每通电话费用
+				for (CcInboundCdr inboundCdr: inboundCdrList) {
+					// 计算token数
+					Integer inputTokens = 0; // 总输入token数
+					Integer outputTokens = 0; // 总输出token数
+					if (StringUtils.isNotEmpty(inboundCdr.getChatContent())) {
+						JSONArray dialogues = JSONArray.parseArray(inboundCdr.getChatContent());
+						for (int i = 0; i < dialogues.size(); i++) {
+							JSONObject json = dialogues.getJSONObject(i);
+							if ("assistant".equals(json.getString("role"))) {
+								if (null != json.getInteger("completionTokens")) {
+									outputTokens += json.getInteger("completionTokens");
+								}
+								if (null != json.getInteger("promptTokens")) {
+									inputTokens += json.getInteger("promptTokens");
+								}
+							}
+						}
+					}
+					inboundCdr.setInputTokens(inputTokens);
+					inboundCdr.setOutputTokens(outputTokens);
+					BigDecimal totalCost = calTotalCost(inboundCdr.getAsrSeconds(), inboundCdr.getTtsTimes(), inboundCdr.getTtsFlowTokens(), inboundCdr.getInputTokens(), inboundCdr.getOutputTokens());
+					String sql = String.format("update cc_inbound_cdr set total_cost = %s, billing_status = 1 where id = %s;", totalCost.toString(), inboundCdr.getId());
+					ProcessSQLQueue.addSql(sql);
+				}
+			} catch (Exception e) {
+				log.error("计费执行异常:{}", ExceptionUtil.getExceptionMessage(e));
+			}
+			try {
+				Thread.sleep(120*1000L); // 每隔2分钟扫描一次
+			} catch (InterruptedException e) {
+				e.printStackTrace();
+			}
+		}
+	}
+
+	/**
+	 *
+	 * @param asrSeconds  asr时长(秒)
+	 * @param ttsTimes  tts调用次数(次)
+	 * @param ttsFlowTokens  大模型tts的字符数(字符)
+	 * @param inputTokens  总输入token数
+	 * @param outputTokens  总输出token数
+	 * @return
+	 */
+	private static BigDecimal calTotalCost(Integer asrSeconds, Integer ttsTimes, Integer ttsFlowTokens, Integer inputTokens, Integer outputTokens) {
+		BigDecimal totalCost = new BigDecimal("0.0");
+		// asr费用 = asrSeconds/3600 * priceAsrAli
+		if (asrSeconds != null && asrSeconds > 0) {
+			totalCost = totalCost.add((BigDecimal.valueOf(asrSeconds).divide(SECONDS_PER_HOUR, 10, RoundingMode.HALF_UP)).multiply(priceAsrAli));
+		}
+		// tts费用 = ttsTimes / 1000 * priceTtsAli
+		if (ttsTimes != null && ttsTimes > 0) {
+			totalCost = totalCost.add((BigDecimal.valueOf(ttsTimes).divide(ONE_THOUSAND, 10, RoundingMode.HALF_UP)).multiply(priceTtsAli));
+		}
+		// cosyvoice费用 = ttsFlowTokens / 10000 * priceFlowTtsAli
+		if (ttsFlowTokens != null && ttsFlowTokens > 0) {
+			totalCost = totalCost.add((BigDecimal.valueOf(ttsFlowTokens).divide(TEN_THOUSAND, 10, RoundingMode.HALF_UP)).multiply(priceFlowTtsAli));
+		}
+		// 大模型费用 = (inputTokens + outputTokens) / 1000 * priceLlmAli
+		if (inputTokens != null && outputTokens != null && (inputTokens + outputTokens) > 0) {
+			totalCost = totalCost.add((BigDecimal.valueOf((inputTokens + outputTokens)).divide(ONE_THOUSAND, 10, RoundingMode.HALF_UP)).multiply(priceLlmAli));
+		}
+		return totalCost;
+	}
+}

+ 80 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/utils/PcmToWavConverter.java

@@ -0,0 +1,80 @@
+package com.ruoyi.aicall.utils;
+
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.Base64;
+
+public class PcmToWavConverter {
+
+    /**
+     * 将Base64编码的PCM数据转换为标准WAV文件
+     * @param base64String PCM数据的Base64编码
+     * @param outputPath 输出WAV文件路径
+     * @param sampleRate 采样率(如16000)
+     * @param channels 声道数(1=单声道,2=立体声)
+     * @param bitsPerSample 位深(如16)
+     */
+    public static void convertPcmToWav(String base64String, String outputPath,
+                                       int sampleRate, int channels, int bitsPerSample) throws IOException {
+
+        // Step 1: 解码PCM数据
+        byte[] pcmData = Base64.getDecoder().decode(base64String);
+
+        // Step 2: 计算WAV头参数
+        int byteRate = sampleRate * channels * bitsPerSample / 8;
+        int blockAlign = channels * bitsPerSample / 8;
+        int dataSize = pcmData.length;
+        int totalSize = 36 + dataSize;
+
+        // Step 3: 构建WAV头部(44字节)
+        ByteBuffer header = ByteBuffer.allocate(44);
+        header.order(ByteOrder.LITTLE_ENDIAN);
+
+        // RIFF Chunk
+        header.put("RIFF".getBytes());                    // 0-3: RIFF标识
+        header.putInt(totalSize);                          // 4-7: 文件总长度-8
+        header.put("WAVE".getBytes());                     // 8-11: WAVE标识
+
+        // fmt Chunk
+        header.put("fmt ".getBytes());                     // 12-15: fmt标识
+        header.putInt(16);                                 // 16-19: fmt块长度(PCM=16)
+        header.putShort((short) 1);                        // 20-21: 格式类别(PCM=1)
+        header.putShort((short) channels);                 // 22-23: 声道数
+        header.putInt(sampleRate);                         // 24-27: 采样率
+        header.putInt(byteRate);                           // 28-31: 字节速率
+        header.putShort((short) blockAlign);               // 32-33: 块对齐
+        header.putShort((short) bitsPerSample);            // 34-35: 位深度
+
+        // data Chunk
+        header.put("data".getBytes());                     // 36-39: data标识
+        header.putInt(dataSize);                           // 40-43: 数据长度
+
+        // Step 4: 写入文件
+        try (FileOutputStream fos = new FileOutputStream(outputPath)) {
+            fos.write(header.array());  // 写入头部
+            fos.write(pcmData);         // 写入PCM数据
+        }
+
+        System.out.println("WAV文件创建成功: " + outputPath);
+        System.out.println("参数: " + sampleRate + "Hz, " + channels + "声道, " + bitsPerSample + "bit");
+    }
+
+    // 示例
+    public static void main(String[] args) {
+        try {
+            String pcmBase64 = "YOUR_PCM_BASE64_STRING_HERE";
+
+            // 常见参数组合:
+            // 16000Hz, 单声道, 16bit (语音识别常用)
+            convertPcmToWav(pcmBase64, "output.wav", 16000, 1, 16);
+
+            // 或 44100Hz, 立体声, 16bit (音乐CD标准)
+            // convertPcmToWav(pcmBase64, "output.wav", 44100, 2, 16);
+
+        } catch (IOException e) {
+            e.printStackTrace();
+        }
+    }
+}

+ 95 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/utils/ProcessSQLQueue.java

@@ -0,0 +1,95 @@
+package com.ruoyi.aicall.utils;
+
+import com.ruoyi.aicall.domain.CcCallPhone;
+import com.ruoyi.common.utils.ExceptionUtil;
+import com.ruoyi.common.utils.spring.SpringUtils;
+import org.apache.shiro.util.JdbcUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.jdbc.datasource.DataSourceUtils;
+
+import javax.sql.DataSource;
+import java.sql.Connection;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * sql异步执行队列
+ */
+public class ProcessSQLQueue {
+	private static  final Logger log = LoggerFactory.getLogger(ProcessSQLQueue.class);
+
+	private static Object addSQLQueueLocker  = new Object();
+
+	private static List<String> sqlList = new ArrayList<>(300);
+
+	static {
+		new Thread(
+				new Runnable() {
+					@Override
+					public void run() {
+						ProcessSQLQueue();
+					}
+				} , "processSQLQueue").start();
+	}
+
+	/**
+	 *
+	 * @param sql
+	 */
+	public static void addSql(String sql) {
+		synchronized (addSQLQueueLocker) {
+			sqlList.add(sql);
+		}
+	}
+
+	private static void ProcessSQLQueue(){
+
+		while (true) {
+			List<String> toExec = null;
+
+			/* 1. 把当前队列一次性整体“搬走” */
+			synchronized (addSQLQueueLocker) {
+				if (!sqlList.isEmpty()) {
+					toExec = new ArrayList<>(sqlList);
+					sqlList.clear();
+				}
+			}
+
+			/* 2. 真正执行 SQL */
+			if (toExec != null && !toExec.isEmpty()) {
+				Connection conn = null;
+				Statement stmt = null;
+				try {
+					conn = DataSourceUtils.getConnection(SpringUtils.getBean(DataSource.class));
+					conn.setAutoCommit(false);          // 手动事务
+					stmt = conn.createStatement();
+					for (String sql : toExec) {
+						stmt.addBatch(sql);
+					}
+					stmt.executeBatch();
+					conn.commit();                      // 全部成功再提交
+					log.info("成功批量执行 {} 条计费 SQL", toExec.size());
+				} catch (Exception e) {
+					if (conn != null) {
+						try { conn.rollback(); } catch (SQLException r) { /* ignore */ }
+					}
+					log.error("执行 sql 异常:{}", ExceptionUtil.getExceptionMessage(e));
+				} finally {
+					JdbcUtils.closeStatement(stmt);
+					DataSourceUtils.releaseConnection(conn, SpringUtils.getBean(DataSource.class));
+				}
+			}
+
+			/* 3. 歇 10 秒 */
+			try {
+				Thread.sleep(10_000L);
+			} catch (InterruptedException e) {
+				Thread.currentThread().interrupt();
+				break;
+			}
+		}
+	}
+}

+ 44 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/utils/WavFileWriter.java

@@ -0,0 +1,44 @@
+package com.ruoyi.aicall.utils;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.Base64;
+
+public class WavFileWriter {
+
+    /**
+     * 将Base64编码的WAV数据直接写入文件
+     * @param base64String 接口返回的Base64字符串
+     * @param outputPath 输出文件路径,如 "output.wav"
+     */
+    public static void writeWavFromBase64(String base64String, String outputPath) throws IOException {
+        // Step 1: Base64解码
+        byte[] wavData = Base64.getDecoder().decode(base64String);
+
+        // Step 2: 直接写入文件(数据已包含WAV头)
+        Files.write(Paths.get(outputPath), wavData);
+
+        System.out.println("WAV文件已保存至: " + outputPath);
+        System.out.println("文件大小: " + wavData.length + " bytes");
+
+        // Step 3: 验证是否为有效WAV文件
+        if (wavData.length >= 4 &&
+                wavData[0] == 'R' && wavData[1] == 'I' &&
+                wavData[2] == 'F' && wavData[3] == 'F') {
+            System.out.println("验证通过:文件包含有效的WAV头部");
+        } else {
+            System.err.println("警告:未检测到WAV头部,可能是原始PCM数据");
+        }
+    }
+
+    // 示例
+    public static void main(String[] args) {
+        try {
+            String base64Data = "UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgA...";
+            writeWavFromBase64(base64Data, "output.wav");
+        } catch (IOException e) {
+            e.printStackTrace();
+        }
+    }
+}

+ 12 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/utils/XSSFUtils.java

@@ -0,0 +1,12 @@
+package com.ruoyi.aicall.utils;
+
+import org.apache.poi.ss.usermodel.Cell;
+import org.apache.poi.ss.usermodel.CellType;
+
+public class XSSFUtils {
+    public static String getCellString(Cell cell) {
+        if (cell == null) return "";
+        cell.setCellType(CellType.STRING);
+        return cell.getStringCellValue() == null ? "" : cell.getStringCellValue().trim();
+    }
+}

+ 9 - 0
ruoyi-admin/src/main/java/com/ruoyi/cc/controller/CcExtNumController.java

@@ -146,4 +146,13 @@ public class CcExtNumController extends BaseController
     {
         return toAjax(ccExtNumService.deleteCcExtNumByExtIds(ids));
     }
+
+
+    @GetMapping("/all")
+    @ResponseBody
+    public AjaxResult all()
+    {
+        List<CcExtNum> list = ccExtNumService.selectCcExtNumList(new CcExtNum());
+        return AjaxResult.success(list);
+    }
 }

+ 102 - 42
ruoyi-admin/src/main/java/com/ruoyi/cc/controller/CcGatewaysController.java

@@ -101,37 +101,65 @@ public class CcGatewaysController extends BaseController
     public AjaxResult addSave(CcGateways ccGateways)
     {
 
+        if (null != ccGateways.getRegister() && ccGateways.getRegister() == 2) {
+            ccGateways.setAuthUsername(ccGateways.getAuthUsername2());
+            ccGateways.setAuthPassword("");
+            ccGateways.setGwAddr("");
+        }
         CcGateways checkGateways = ccGatewaysService.selectCcGatewaysByGwName(ccGateways.getGwName());
         if (null != checkGateways) {
             return AjaxResult.error("网关名称不能重复,请修改");
         }
         // 新增gateway配置文件
         String configs = ccGateways.getConfigs();
-        JSONObject params = new JSONObject();
-        if (StringUtils.isNotEmpty(configs)) {
-            params = JSONObject.parseObject(configs);
-        }
-        //  0 对接模式; 1注册模式
-        params.put("realm", ccGateways.getGwAddr());
-        if (ccGateways.getRegister() == 0) {
-            params.put("caller-id-in-from", "false");
-            params.put("register", "false");
-            params.put("origination_caller_id_number", ccGateways.getCaller());
-            params.put("origination_caller_id_name", ccGateways.getCaller());
-            params.put("effective_caller_id_number", ccGateways.getCaller());
-            params.put("effective_caller_id_name", ccGateways.getCaller());
-            params.put("username", ccGateways.getCaller());
-            fsConfService.setGwUnRegisterConf("", ccGateways.getProfileName(), ccGateways.getGwName(), params);
-        } else {
-            params.put("caller-id-in-from", "true");
-            params.put("register", "true");
-            params.put("username", ccGateways.getAuthUsername());
-            params.put("password", ccGateways.getAuthPassword());
-            fsConfService.setGwRegisterConf("", ccGateways.getProfileName(), ccGateways.getGwName(), params);
+        if (ccGateways.getPurpose() != 0) {
+            JSONObject params = new JSONObject();
+            if (StringUtils.isNotEmpty(configs)) {
+                params = JSONObject.parseObject(configs);
+            }
+            //  0 对接模式; 1注册模式
+            params.put("realm", ccGateways.getGwAddr());
+            if (ccGateways.getRegister() == 0) {
+                params.put("caller-id-in-from", "false");
+                params.put("register", "false");
+                String _caller0 = "";
+                if (StringUtils.isNotEmpty(ccGateways.getCaller())) {
+                    _caller0 = ccGateways.getCaller().split("\r\n")[0];
+                }
+                params.put("origination_caller_id_number", _caller0);
+                params.put("origination_caller_id_name", _caller0);
+                params.put("effective_caller_id_number", _caller0);
+                params.put("effective_caller_id_name", _caller0);
+                params.put("username", _caller0);
+                fsConfService.setGwUnRegisterConf("", ccGateways.getProfileName(), ccGateways.getGwName(), params);
+            } else if (ccGateways.getRegister() == 1){
+                params.put("caller-id-in-from", "true");
+                params.put("register", "true");
+                params.put("username", ccGateways.getAuthUsername());
+                params.put("password", ccGateways.getAuthPassword());
+                fsConfService.setGwRegisterConf("", ccGateways.getProfileName(), ccGateways.getGwName(), params);
+            } else if (ccGateways.getRegister() == 2) {
+                params.put("caller-id-in-from", "false");
+                params.put("register", "false");
+                String _caller0 = "";
+                if (StringUtils.isNotEmpty(ccGateways.getCaller())) {
+                    _caller0 = ccGateways.getCaller().split("\r\n")[0];
+                }
+                params.put("origination_caller_id_number", _caller0);
+                params.put("origination_caller_id_name", _caller0);
+                params.put("effective_caller_id_number", _caller0);
+                params.put("effective_caller_id_name", _caller0);
+                params.put("username", _caller0);
+                fsConfService.setGwUnRegisterConf("", ccGateways.getProfileName(), ccGateways.getGwName(), params);
+            }
         }
         ccGateways.setConfigs(configs);
         ccGateways.setUpdateTime(new Date().getTime());
+        // 网关信息入库
         Integer result = ccGatewaysService.insertCcGateways(ccGateways);
+        // 删除多余的网关信息
+        Integer deleteCount = ccGatewaysService.refreshGatewaysFiles();
+        // 重新加载网关
         EslMessage eslMessage = EslConnectionUtil.sendSyncApiCommand("sofia", "profile " + ccGateways.getProfileName() + " rescan");
         if (null != eslMessage) {
             logger.info(StringUtils.joinWith("/r/n", eslMessage.getBodyLines().toArray()));
@@ -149,6 +177,9 @@ public class CcGatewaysController extends BaseController
         List<FsConfProfile> profileList = fsConfService.selectProfileList();
         mmap.put("profileList", profileList);
         CcGateways ccGateways = ccGatewaysService.selectCcGatewaysById(id);
+        if (null != ccGateways.getRegister() && ccGateways.getRegister() == 2) {
+            ccGateways.setAuthUsername2(ccGateways.getAuthUsername());
+        }
         mmap.put("ccGateways", ccGateways);
         return prefix + "/edit";
     }
@@ -162,6 +193,11 @@ public class CcGatewaysController extends BaseController
     @ResponseBody
     public AjaxResult editSave(CcGateways ccGateways)
     {
+        if (null != ccGateways.getRegister() && ccGateways.getRegister() == 2) {
+            ccGateways.setAuthUsername(ccGateways.getAuthUsername2());
+            ccGateways.setAuthPassword("");
+            ccGateways.setGwAddr("");
+        }
         CcGateways orignGateways = ccGatewaysService.selectCcGatewaysById(ccGateways.getId());
         CcGateways checkGateways = ccGatewaysService.selectCcGatewaysByGwName(ccGateways.getGwName());
         if (null != checkGateways && !checkGateways.getId().equals(ccGateways.getId())) {
@@ -169,30 +205,54 @@ public class CcGatewaysController extends BaseController
         }
         // 新增gateway配置文件
         String configs = ccGateways.getConfigs();
-        JSONObject params = new JSONObject();
-        if (StringUtils.isNotEmpty(configs)) {
-            params = JSONObject.parseObject(configs);
-        }
-        //  0 对接模式; 1注册模式
-        params.put("realm", ccGateways.getGwAddr());
-        if (ccGateways.getRegister() == 0) {
-            params.put("caller-id-in-from", "false");
-            params.put("register", "false");
-            params.put("origination_caller_id_number", ccGateways.getCaller());
-            params.put("origination_caller_id_name", ccGateways.getCaller());
-            params.put("effective_caller_id_number", ccGateways.getCaller());
-            params.put("effective_caller_id_name", ccGateways.getCaller());
-            params.put("username", ccGateways.getCaller());
-            fsConfService.setGwUnRegisterConf(orignGateways.getProfileName(), ccGateways.getProfileName(), ccGateways.getGwName(), params);
-        } else {
-            params.put("caller-id-in-from", "true");
-            params.put("register", "true");
-            params.put("username", ccGateways.getAuthUsername());
-            params.put("password", ccGateways.getAuthPassword());
-            fsConfService.setGwRegisterConf(orignGateways.getProfileName(), ccGateways.getProfileName(), ccGateways.getGwName(), params);
+        if (ccGateways.getPurpose() != 0) {
+            JSONObject params = new JSONObject();
+            if (StringUtils.isNotEmpty(configs)) {
+                params = JSONObject.parseObject(configs);
+            }
+            //  0 对接模式; 1注册模式
+            params.put("realm", ccGateways.getGwAddr());
+            if (ccGateways.getRegister() == 0) {
+                params.put("caller-id-in-from", "false");
+                params.put("register", "false");
+                String _caller0 = "";
+                if (StringUtils.isNotEmpty(ccGateways.getCaller())) {
+                    _caller0 = ccGateways.getCaller().split("\r\n")[0];
+                }
+                params.put("origination_caller_id_number", _caller0);
+                params.put("origination_caller_id_name", _caller0);
+                params.put("effective_caller_id_number", _caller0);
+                params.put("effective_caller_id_name", _caller0);
+                params.put("username", _caller0);
+                fsConfService.setGwUnRegisterConf(orignGateways.getProfileName(), ccGateways.getProfileName(), ccGateways.getGwName(), params);
+            } else if (ccGateways.getRegister() == 1){
+                params.put("caller-id-in-from", "true");
+                params.put("register", "true");
+                params.put("username", ccGateways.getAuthUsername());
+                params.put("password", ccGateways.getAuthPassword());
+                fsConfService.setGwRegisterConf(orignGateways.getProfileName(), ccGateways.getProfileName(), ccGateways.getGwName(), params);
+            } else if (ccGateways.getRegister() == 2) {
+                params.put("caller-id-in-from", "false");
+                params.put("register", "false");
+                String _caller0 = "";
+                if (StringUtils.isNotEmpty(ccGateways.getCaller())) {
+                    _caller0 = ccGateways.getCaller().split("\r\n")[0];
+                }
+                logger.info("===============");
+                logger.info(_caller0);
+                params.put("origination_caller_id_number", _caller0);
+                params.put("origination_caller_id_name", _caller0);
+                params.put("effective_caller_id_number", _caller0);
+                params.put("effective_caller_id_name", _caller0);
+                params.put("username", _caller0);
+                fsConfService.setGwUnRegisterConf(orignGateways.getProfileName(), ccGateways.getProfileName(), ccGateways.getGwName(), params);
+            }
         }
         ccGateways.setUpdateTime(new Date().getTime());
         Integer result = ccGatewaysService.updateCcGateways(ccGateways);
+        // 删除多余的网关信息
+        Integer deleteCount = ccGatewaysService.refreshGatewaysFiles();
+        // 重新加载网关
         EslMessage eslMessage1 = EslConnectionUtil.sendSyncApiCommand("sofia", "profile " + ccGateways.getProfileName() + " killgw " + ccGateways.getGwName());
         if (null != eslMessage1) {
             logger.info(StringUtils.joinWith("/r/n", eslMessage1.getBodyLines().toArray()));

+ 52 - 92
ruoyi-admin/src/main/java/com/ruoyi/cc/controller/CcInboundCdrController.java

@@ -1,43 +1,39 @@
 package com.ruoyi.cc.controller;
 
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.stream.Collectors;
-
+import com.alibaba.fastjson.JSONObject;
+import com.ruoyi.aicall.domain.CcCallPhone;
 import com.ruoyi.cc.domain.CcBizGroup;
-import com.ruoyi.cc.domain.CcOutboundCdr;
+import com.ruoyi.cc.domain.CcInboundCdr;
 import com.ruoyi.cc.service.ICcBizGroupService;
+import com.ruoyi.cc.service.ICcInboundCdrService;
 import com.ruoyi.cc.service.ICcParamsService;
+import com.ruoyi.common.annotation.Log;
 import com.ruoyi.common.constant.UserConstants;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.AjaxResult;
 import com.ruoyi.common.core.domain.entity.SysDept;
 import com.ruoyi.common.core.domain.entity.SysUser;
-import com.ruoyi.common.utils.DateUtils;
+import com.ruoyi.common.core.page.TableDataInfo;
+import com.ruoyi.common.enums.BusinessType;
+import com.ruoyi.common.utils.ExceptionUtil;
+import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.common.utils.StringUtils;
+import com.ruoyi.common.utils.poi.ExcelUtil;
 import com.ruoyi.system.domain.SysPost;
 import com.ruoyi.system.service.ISysDeptService;
 import com.ruoyi.system.service.ISysPostService;
 import com.ruoyi.system.service.ISysUserService;
 import org.apache.shiro.authz.annotation.RequiresPermissions;
 import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Controller;
 import org.springframework.ui.ModelMap;
-import org.springframework.util.CollectionUtils;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.PathVariable;
-import org.springframework.web.bind.annotation.PostMapping;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.ResponseBody;
-import com.ruoyi.common.annotation.Log;
-import com.ruoyi.common.enums.BusinessType;
-import com.ruoyi.cc.domain.CcInboundCdr;
-import com.ruoyi.cc.service.ICcInboundCdrService;
-import com.ruoyi.common.core.controller.BaseController;
-import com.ruoyi.common.core.domain.AjaxResult;
-import com.ruoyi.common.utils.poi.ExcelUtil;
-import com.ruoyi.common.core.page.TableDataInfo;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
 
 /**
  * 呼入记录Controller
@@ -80,39 +76,9 @@ public class CcInboundCdrController extends BaseController
     @ResponseBody
     public TableDataInfo list(CcInboundCdr ccInboundCdr)
     {
-        startPage();
         Map<String, Object> params = ccInboundCdr.getParams();
-        if (null != params.get("inboundTimeStart")
-                && !"".equals(params.get("inboundTimeStart"))) {
-            params.put("inboundTimeStart", DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", (String)params.get("inboundTimeStart")).getTime());
-        }
-        if (null != params.get("inboundTimeEnd")
-                && !"".equals(params.get("inboundTimeEnd"))) {
-            params.put("inboundTimeEnd", DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", (String)params.get("inboundTimeEnd")).getTime());
-        }
-        if (null != params.get("answeredTimeStart")
-                && !"".equals(params.get("answeredTimeStart"))) {
-            params.put("answeredTimeStart", DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", (String)params.get("answeredTimeStart")).getTime());
-        }
-        if (null != params.get("answeredTimeEnd")
-                && !"".equals(params.get("answeredTimeEnd"))) {
-            params.put("answeredTimeEnd", DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", (String)params.get("answeredTimeEnd")).getTime());
-        }
-        if (null != params.get("hangupTimeStart")
-                && !"".equals(params.get("hangupTimeStart"))) {
-            params.put("hangupTimeStart", DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", (String)params.get("hangupTimeStart")).getTime());
-        }
-        if (null != params.get("hangupTimeEnd")
-                && !"".equals(params.get("hangupTimeEnd"))) {
-            params.put("hangupTimeEnd", DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", (String)params.get("hangupTimeEnd")).getTime());
-        }
-        if (null != params.get("timeLenStart")
-                && !"".equals(params.get("timeLenStart"))) {
-            params.put("timeLenStart", Double.valueOf((String)params.get("timeLenStart")) * 60 * 1000L);
-        }
-        if (null != params.get("timeLenEnd")
-                && !"".equals(params.get("timeLenEnd"))) {
-            params.put("timeLenEnd", Double.valueOf((String)params.get("timeLenEnd")) * 60 * 1000L);
+        if (null == params) {
+            params = new HashMap<>();
         }
         // 如果是班长坐席,根据权限查询有权限的坐席的通话记录,否则查询全部数据
         SysUser currentUser = getSysUser();
@@ -135,8 +101,11 @@ public class CcInboundCdrController extends BaseController
         ccInboundCdr.setParams(params);
 
         Map<String, String> groupNames = new HashMap<>();
+        startPage();
         List<CcInboundCdr> list = ccInboundCdrService.selectCcInboundCdrList(ccInboundCdr);
-        for (CcInboundCdr data: list) {
+        TableDataInfo tableDataInfo = getDataTable(list);
+        List<CcInboundCdr> records = (List<CcInboundCdr>) tableDataInfo.getRows();
+        for (CcInboundCdr data: records) {
             data.setWavFileUrl("/recordings/files?filename=" + data.getWavFile());
             String groupName = groupNames.getOrDefault(data.getGroupId(), "");
             if (StringUtils.isBlank(groupName)) {
@@ -146,11 +115,36 @@ public class CcInboundCdrController extends BaseController
                 } else {
                     groupName = "-";
                 }
-                groupNames.put(data.getGroupId(), groupName);
             }
+            groupNames.put(data.getGroupId(), groupName);
             data.setGroupName(groupName);
+
+            // 挂机原因处理
+            if (StringUtils.isNotEmpty(data.getHangupCause())) {
+                if (data.getHangupCause().startsWith("{") && data.getHangupCause().endsWith("}")) {
+                    try {
+                        JSONObject hangupCause = JSONObject.parseObject(data.getHangupCause());
+                        String hangupCauseCode = hangupCause.getString("code");
+                        String hangupCauseDetail = hangupCause.getString("details");
+                        String hangupCauseCodeI18n = MessageUtils.message("_hangup_cause_code_" + hangupCauseCode);
+                        if (StringUtils.isNotEmpty(hangupCauseCodeI18n)) {
+                            hangupCauseCode = hangupCauseCodeI18n;
+                        }
+                        if (StringUtils.isNotEmpty(hangupCauseDetail)) {
+                            data.setHangupCause(hangupCauseCode + ":" + hangupCauseDetail);
+                        } else {
+                            data.setHangupCause(hangupCauseCode);
+                        }
+                    } catch (Exception e) {
+                        logger.error(ExceptionUtil.getExceptionMessage(e));
+                    }
+
+                }
+            }
+
         }
-        return getDataTable(list);
+        tableDataInfo.setRows(records);
+        return tableDataInfo;
     }
 
     /**
@@ -162,40 +156,6 @@ public class CcInboundCdrController extends BaseController
     @ResponseBody
     public AjaxResult export(CcInboundCdr ccInboundCdr)
     {
-        Map<String, Object> params = ccInboundCdr.getParams();
-        if (null != params.get("inboundTimeStart")
-                && !"".equals(params.get("inboundTimeStart"))) {
-            params.put("inboundTimeStart", DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", (String)params.get("inboundTimeStart")).getTime());
-        }
-        if (null != params.get("inboundTimeEnd")
-                && !"".equals(params.get("inboundTimeEnd"))) {
-            params.put("inboundTimeEnd", DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", (String)params.get("inboundTimeEnd")).getTime());
-        }
-        if (null != params.get("answeredTimeStart")
-                && !"".equals(params.get("answeredTimeStart"))) {
-            params.put("answeredTimeStart", DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", (String)params.get("answeredTimeStart")).getTime());
-        }
-        if (null != params.get("answeredTimeEnd")
-                && !"".equals(params.get("answeredTimeEnd"))) {
-            params.put("answeredTimeEnd", DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", (String)params.get("answeredTimeEnd")).getTime());
-        }
-        if (null != params.get("hangupTimeStart")
-                && !"".equals(params.get("hangupTimeStart"))) {
-            params.put("hangupTimeStart", DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", (String)params.get("hangupTimeStart")).getTime());
-        }
-        if (null != params.get("hangupTimeEnd")
-                && !"".equals(params.get("hangupTimeEnd"))) {
-            params.put("hangupTimeEnd", DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", (String)params.get("hangupTimeEnd")).getTime());
-        }
-        if (null != params.get("timeLenStart")
-                && !"".equals(params.get("timeLenStart"))) {
-            params.put("timeLenStart", Double.valueOf((String)params.get("timeLenStart")) * 60 * 1000L);
-        }
-        if (null != params.get("timeLenEnd")
-                && !"".equals(params.get("timeLenEnd"))) {
-            params.put("timeLenEnd", Double.valueOf((String)params.get("timeLenEnd")) * 60 * 1000L);
-        }
-        ccInboundCdr.setParams(params);
         List<CcInboundCdr> list = ccInboundCdrService.selectCcInboundCdrList(ccInboundCdr);
         ExcelUtil<CcInboundCdr> util = new ExcelUtil<CcInboundCdr>(CcInboundCdr.class);
         return util.exportExcel(list, "呼入记录数据");

+ 358 - 0
ruoyi-admin/src/main/java/com/ruoyi/cc/controller/CcIvrController.java

@@ -0,0 +1,358 @@
+package com.ruoyi.cc.controller;
+
+import java.io.File;
+import java.util.*;
+
+import com.alibaba.fastjson.JSONObject;
+import com.ruoyi.aicall.domain.CcCallTask;
+import com.ruoyi.aicall.tts.aliyun.AliyunTTSWebApi;
+import com.ruoyi.aicall.tts.doubao.DoubaoTTSWebApi;
+import com.ruoyi.cc.service.ICcParamsService;
+import com.ruoyi.common.core.domain.entity.SysMenu;
+import com.ruoyi.common.utils.DateUtils;
+import com.ruoyi.common.utils.ExceptionUtil;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.common.utils.StringUtils;
+import com.ruoyi.common.utils.file.FileUtils;
+import com.ruoyi.common.utils.uuid.UuidGenerator;
+import com.ruoyi.framework.shiro.util.AuthorizationUtils;
+import org.apache.commons.io.FilenameUtils;
+import org.apache.commons.lang3.RandomStringUtils;
+import org.apache.shiro.authz.annotation.RequiresPermissions;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.ModelMap;
+import org.springframework.web.bind.annotation.*;
+import com.ruoyi.common.annotation.Log;
+import com.ruoyi.common.enums.BusinessType;
+import com.ruoyi.cc.domain.CcIvr;
+import com.ruoyi.cc.service.ICcIvrService;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.utils.poi.ExcelUtil;
+import com.ruoyi.common.core.page.TableDataInfo;
+import org.springframework.web.multipart.MultipartFile;
+
+/**
+ * IVR配置Controller
+ * 
+ * @author ruoyi
+ * @date 2025-12-19
+ */
+@Controller
+@RequestMapping("/cc/ivr")
+public class CcIvrController extends BaseController
+{
+    private String prefix = "cc/ivr";
+
+    @Autowired
+    private ICcIvrService ccIvrService;
+    @Autowired
+    private ICcParamsService ccParamsService;
+
+    @RequiresPermissions("cc:ivr:view")
+    @GetMapping()
+    public String ivr()
+    {
+        return prefix + "/ivr";
+    }
+
+    /**
+     * 查询IVR配置列表
+     */
+    @RequiresPermissions("cc:ivr:list")
+    @PostMapping("/list")
+    @ResponseBody
+    public List<CcIvr> list(CcIvr ccIvr)
+    {
+        List<CcIvr> list = ccIvrService.selectCcIvrList(ccIvr);
+        return list;
+    }
+
+    /**
+     * 新增IVR配置
+     */
+    @GetMapping("/add/{parentId}")
+    public String add(@PathVariable("parentId") String parentId, ModelMap mmap)
+    {
+        CcIvr ccIvr = new CcIvr();
+        ccIvr.setParentNodeId(parentId);
+        ccIvr.setId(UuidGenerator.GetOneUuid());
+        if ("0".equals(parentId)) {
+            ccIvr.setRootId(ccIvr.getId());
+        } else {
+            ccIvr.setRootId(ccIvrService.selectCcIvrById(parentId).getRootId());
+        }
+        mmap.put("ccIvr", ccIvr);
+        return prefix + "/add";
+    }
+
+    /**
+     * 新增保存IVR配置
+     */
+    @RequiresPermissions("cc:ivr:add")
+    @Log(title = "IVR配置", businessType = BusinessType.INSERT)
+    @PostMapping("/add")
+    @ResponseBody
+    public AjaxResult addSave(CcIvr ccIvr)
+    {
+        if ("acd".equals(ccIvr.getAction())) {
+            ccIvr.setAiTransferData(ccIvr.getAiTransferGroupId());
+        } else if ("extension".equals(ccIvr.getAction())) {
+            ccIvr.setAiTransferData(ccIvr.getAiTransferExtNumber());
+        } else if ("gateway".equals(ccIvr.getAction())) {
+            JSONObject aiTransferData = new JSONObject();
+            aiTransferData.put("gatewayId", ccIvr.getAiTransferGatewayId());
+            aiTransferData.put("destNumber", ccIvr.getAiTransferGatewayDestNumber());
+            ccIvr.setAiTransferData(JSONObject.toJSONString(aiTransferData));
+        } else if ("ai".equals(ccIvr.getAction())) {
+            ccIvr.setAiTransferData(ccIvr.getAiInboundId());
+        }
+        if ("0".equals(ccIvr.getParentNodeId()) || StringUtils.isBlank(ccIvr.getDigit())) {
+            ccIvr.setDigit("-1");
+        }
+        return toAjax(ccIvrService.insertCcIvr(ccIvr));
+    }
+
+    /**
+     * 修改IVR配置
+     */
+    @RequiresPermissions("cc:ivr:edit")
+    @GetMapping("/edit/{id}")
+    public String edit(@PathVariable("id") String id, ModelMap mmap)
+    {
+        CcIvr ccIvr = ccIvrService.selectCcIvrById(id);
+
+        if ("acd".equals(ccIvr.getAction())) {
+            ccIvr.setAiTransferGroupId(ccIvr.getAiTransferData());
+        } else if ("extension".equals(ccIvr.getAction())) {
+            ccIvr.setAiTransferExtNumber(ccIvr.getAiTransferData());
+        } else if ("gateway".equals(ccIvr.getAction())) {
+            if (StringUtils.isNotEmpty(ccIvr.getAiTransferData())) {
+                JSONObject aiTransferData = JSONObject.parseObject(ccIvr.getAiTransferData());
+                ccIvr.setAiTransferGatewayId(aiTransferData.getString("gatewayId"));
+                ccIvr.setAiTransferGatewayDestNumber(aiTransferData.getString("destNumber"));
+            }
+        } else if ("ai".equals(ccIvr.getAction())) {
+            ccIvr.setAiInboundId(ccIvr.getAiTransferData());
+        }
+        String recordingPath = ccParamsService.getParamValueByCode("recording_path", "/home/Records/");
+        if (StringUtils.isNotEmpty(ccIvr.getTtsTextWav()) && ccIvr.getTtsTextWav().endsWith(".wav")) {
+            ccIvr.setTtsTextfileUrl("recordings/files?filename=" + ccIvr.getTtsTextWav().replace(recordingPath, ""));
+        }
+        if (StringUtils.isNotEmpty(ccIvr.getPressKeyInvalidTipsWav()) && ccIvr.getPressKeyInvalidTipsWav().endsWith(".wav")) {
+            ccIvr.setInvalidTipsfileUrl("recordings/files?filename=" + ccIvr.getPressKeyInvalidTipsWav().replace(recordingPath, ""));
+        }
+        if (StringUtils.isNotEmpty(ccIvr.getHangupTipsWav()) && ccIvr.getHangupTipsWav().endsWith(".wav")) {
+            ccIvr.setHangupTipsfileUrl("recordings/files?filename=" + ccIvr.getHangupTipsWav().replace(recordingPath, ""));
+        }
+        mmap.put("ccIvr", ccIvr);
+        return prefix + "/edit";
+    }
+
+    /**
+     * 修改保存IVR配置
+     */
+    @RequiresPermissions("cc:ivr:edit")
+    @Log(title = "IVR配置", businessType = BusinessType.UPDATE)
+    @PostMapping("/edit")
+    @ResponseBody
+    public AjaxResult editSave(CcIvr ccIvr)
+    {
+
+        if ("acd".equals(ccIvr.getAction())) {
+            ccIvr.setAiTransferData(ccIvr.getAiTransferGroupId());
+        } else if ("extension".equals(ccIvr.getAction())) {
+            ccIvr.setAiTransferData(ccIvr.getAiTransferExtNumber());
+        } else if ("gateway".equals(ccIvr.getAction())) {
+            JSONObject aiTransferData = new JSONObject();
+            aiTransferData.put("gatewayId", ccIvr.getAiTransferGatewayId());
+            aiTransferData.put("destNumber", ccIvr.getAiTransferGatewayDestNumber());
+            ccIvr.setAiTransferData(JSONObject.toJSONString(aiTransferData));
+        } else if ("ai".equals(ccIvr.getAction())) {
+            ccIvr.setAiTransferData(ccIvr.getAiInboundId());
+        }
+        if ("0".equals(ccIvr.getParentNodeId())) {
+            ccIvr.setDigit("-1");
+        }
+        return toAjax(ccIvrService.updateCcIvr(ccIvr));
+    }
+
+
+    /**
+     * 删除
+     */
+    @RequiresPermissions("cc:ivr:remove")
+    @Log(title = "IVR配置", businessType = BusinessType.DELETE)
+    @GetMapping("/remove/{id}")
+    @ResponseBody
+    public AjaxResult remove(@PathVariable("id") String id)
+    {
+        List<CcIvr> list = ccIvrService.selectCcIvrList(new CcIvr().setParentNodeId(id));
+        if (list.size() > 0) {
+            return AjaxResult.error("请先删除下层节点");
+        }
+        return toAjax(ccIvrService.deleteCcIvrById(id));
+    }
+
+    @PostMapping("/uploadVoice")
+    @ResponseBody
+    public AjaxResult uploadVoice(@RequestParam("file") MultipartFile file,
+                                  @RequestParam("rootId") String rootId) {
+        try {
+            if (file.isEmpty()) {
+                return AjaxResult.error("文件不能为空");
+            }
+
+            String suffix = FilenameUtils.getExtension(file.getOriginalFilename()).toLowerCase();
+            if (!"wav".equals(suffix)) {
+                return AjaxResult.error("仅支持WAV格式文件");
+            }
+
+            long maxSize = 50 * 1024 * 1024;
+            if (file.getSize() > maxSize) {
+                return AjaxResult.error("文件大小不能超过50MB");
+            }
+
+            String fileName = DateUtils.format(new Date(), "yyyyMMddHHmmssSSS") + RandomStringUtils.random(3, false, true) + ".wav";
+            String absolutePath = ccParamsService.getParamValueByCode("recording_path", "/home/Records/") + rootId + "/";
+
+            File destPath = new File(absolutePath);
+            destPath.mkdirs();
+            File destFile = new File(absolutePath + fileName);
+            file.transferTo(destFile);
+
+            Map<String, String> result = new HashMap<>();
+            result.put("fileUrl", "recordings/files?filename=" + rootId + "/" + fileName);
+            result.put("filePath", absolutePath  + fileName);
+            result.put("originalName", file.getOriginalFilename());
+            result.put("fileSize", String.valueOf(file.getSize()));
+
+            return AjaxResult.success(result);
+
+        } catch (Exception e) {
+            logger.error("文件上传失败", e);
+            return AjaxResult.error("文件上传失败:" + e.getMessage());
+        }
+    }
+
+    /**
+     * 校验同一层级下digit是否重复
+     * @param digit 按键值
+     * @param parentNodeId 父节点ID
+     * @param id 当前记录ID(编辑时用于排除自身)
+     * @return
+     */
+    @PostMapping("/checkDigit")
+    @ResponseBody
+    public AjaxResult checkDigit(@RequestParam String digit,
+                                 @RequestParam String parentNodeId,
+                                 @RequestParam(required = false) Long id) {
+        // 根节点不校验
+        if ("0".equals(parentNodeId)) {
+            return AjaxResult.success();
+        }
+
+        boolean exists = ccIvrService.checkDigitExists(digit, parentNodeId, id);
+        return exists ? AjaxResult.error("该按键值已存在") : AjaxResult.success();
+    }
+
+
+    @GetMapping("/all")
+    @ResponseBody
+    public AjaxResult all()
+    {
+        List<CcIvr> list = ccIvrService.selectCcIvrList(new CcIvr().setParentNodeId("0"));
+        return AjaxResult.success(list);
+    }
+
+
+    @GetMapping("/reload")
+    @ResponseBody
+    public AjaxResult reload()
+    {
+        try {
+
+            JSONObject reloadRsp = ccIvrService.reloadIvr();;
+            if (null != reloadRsp) {
+                if (reloadRsp.getBoolean("success")) {
+                    return AjaxResult.success(MessageUtils.message("ivr.reload.success"));
+                } else {
+                    return AjaxResult.error(MessageUtils.message("ivr.reload.fail"));
+                }
+            }
+        } catch (Exception e) {
+            logger.error("IVR configuration reloaded failed." + ExceptionUtil.getExceptionMessage(e));
+        }
+        return AjaxResult.error(MessageUtils.message("ivr.reload.fail"));
+    }
+
+
+    @GetMapping("/tts")
+    @ResponseBody
+    public AjaxResult tts(@RequestParam("rootId") String rootId,
+                          @RequestParam(value = "voiceSource", required = false) String voiceSource,
+                          @RequestParam(value = "voiceCode", required = false) String voiceCode,
+                          @RequestParam(value = "ttsText", required = false) String ttsText)
+    {
+        if (StringUtils.isBlank(ttsText)) {
+            return AjaxResult.error("请输入要合成的内容!");
+        }
+        CcIvr ccIvr = ccIvrService.selectCcIvrByRootId(rootId);
+        if (null != ccIvr) {
+            if (StringUtils.isBlank(voiceSource)) {
+                voiceSource = ccIvr.getTtsProvider();
+            }
+            if (StringUtils.isBlank(voiceCode)) {
+                voiceCode = ccIvr.getVoiceCode();
+            }
+        }
+        if (null == ccIvr
+                && StringUtils.isBlank(voiceSource)
+                && StringUtils.isBlank(voiceCode)) {
+            return AjaxResult.error("请选择音色!");
+        }
+        String fileName = DateUtils.format(new Date(), "yyyyMMddHHmmssSSS") + RandomStringUtils.random(3, false, true) + ".wav";
+        String fileDirName = ccParamsService.getParamValueByCode("recording_path", "/home/Records/") + rootId + "/";
+        File fileDir = new File(fileDirName);
+        if (!fileDir.exists()) {
+            fileDir.mkdirs();
+        }
+        String ttsPath = fileDirName + fileName;
+
+        if ("aliyun_tts".equals(voiceSource)) {
+            String aliyunTtsAccountJson = ccParamsService.getParamValueByCode("aliyun-tts-account-json", "{}");
+            String appKey = JSONObject.parseObject(aliyunTtsAccountJson).getString("app_key");
+            if (StringUtils.isBlank(appKey) || appKey.contains("******")) {
+                return AjaxResult.error("请先配置阿里云TTS配置!");
+            }
+            if (AliyunTTSWebApi.shortTextTTSWebAPI(voiceCode, ttsText, ttsPath, aliyunTtsAccountJson)) {
+                Map<String, String> result = new HashMap<>();
+                result.put("fileUrl", "recordings/files?filename=" + rootId + "/" + fileName);
+                result.put("filePath", ttsPath);
+                result.put("originalName", fileName);
+                return AjaxResult.success(result);
+            } else {
+                return AjaxResult.error("合成失败,请检查tts配置!");
+            }
+        } else if ("doubao_vcl_tts".equals(voiceSource)) {
+            String doubaoTtsAccountJson = ccParamsService.getParamValueByCode("doubao-tts-account-json", "{}");
+            String accessToken = JSONObject.parseObject(doubaoTtsAccountJson).getString("access_token");
+            if (StringUtils.isBlank(accessToken) || accessToken.contains("******")) {
+                return AjaxResult.error("请先配置豆包TTS配置!");
+            }
+            if (DoubaoTTSWebApi.shortTextTTSWebAPI(voiceCode, ttsText, ttsPath, doubaoTtsAccountJson)) {
+                Map<String, String> result = new HashMap<>();
+                result.put("fileUrl", "recordings/files?filename=" + rootId + "/" + fileName);
+                result.put("filePath", ttsPath);
+                result.put("originalName", fileName);
+                return AjaxResult.success(result);
+            } else {
+                return AjaxResult.error("合成失败,请检查tts配置!");
+            }
+        } else {
+            return AjaxResult.error("暂不支持该tts厂商的IVR语音在线合成!");
+        }
+    }
+
+}

+ 18 - 86
ruoyi-admin/src/main/java/com/ruoyi/cc/controller/CcOutboundCdrController.java

@@ -1,37 +1,32 @@
 package com.ruoyi.cc.controller;
 
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
-import java.util.stream.Collectors;
-
+import com.ruoyi.cc.domain.CcOutboundCdr;
+import com.ruoyi.cc.service.ICcOutboundCdrService;
 import com.ruoyi.cc.service.ICcParamsService;
+import com.ruoyi.common.annotation.Log;
 import com.ruoyi.common.constant.UserConstants;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.AjaxResult;
 import com.ruoyi.common.core.domain.entity.SysDept;
 import com.ruoyi.common.core.domain.entity.SysUser;
-import com.ruoyi.common.utils.DateUtils;
+import com.ruoyi.common.core.page.TableDataInfo;
+import com.ruoyi.common.enums.BusinessType;
+import com.ruoyi.common.utils.poi.ExcelUtil;
 import com.ruoyi.system.domain.SysPost;
 import com.ruoyi.system.service.ISysDeptService;
 import com.ruoyi.system.service.ISysPostService;
 import com.ruoyi.system.service.ISysUserService;
 import org.apache.shiro.authz.annotation.RequiresPermissions;
 import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Controller;
 import org.springframework.ui.ModelMap;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.PathVariable;
-import org.springframework.web.bind.annotation.PostMapping;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.ResponseBody;
-import com.ruoyi.common.annotation.Log;
-import com.ruoyi.common.enums.BusinessType;
-import com.ruoyi.cc.domain.CcOutboundCdr;
-import com.ruoyi.cc.service.ICcOutboundCdrService;
-import com.ruoyi.common.core.controller.BaseController;
-import com.ruoyi.common.core.domain.AjaxResult;
-import com.ruoyi.common.utils.poi.ExcelUtil;
-import com.ruoyi.common.core.page.TableDataInfo;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
 
 /**
  * 外呼记录Controller
@@ -72,41 +67,10 @@ public class CcOutboundCdrController extends BaseController
     @ResponseBody
     public TableDataInfo list(CcOutboundCdr ccOutboundCdr)
     {
-        startPage();
         Map<String, Object> params = ccOutboundCdr.getParams();
-        if (null != params.get("startTimeStart")
-                && !"".equals(params.get("startTimeStart"))) {
-            params.put("startTimeStart", DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", (String)params.get("startTimeStart")).getTime());
-        }
-        if (null != params.get("startTimeEnd")
-                && !"".equals(params.get("startTimeEnd"))) {
-            params.put("startTimeEnd", DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", (String)params.get("startTimeEnd")).getTime());
-        }
-        if (null != params.get("answeredTimeStart")
-                && !"".equals(params.get("answeredTimeStart"))) {
-            params.put("answeredTimeStart", DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", (String)params.get("answeredTimeStart")).getTime());
-        }
-        if (null != params.get("answeredTimeEnd")
-                && !"".equals(params.get("answeredTimeEnd"))) {
-            params.put("answeredTimeEnd", DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", (String)params.get("answeredTimeEnd")).getTime());
-        }
-        if (null != params.get("endTimeStart")
-                && !"".equals(params.get("endTimeStart"))) {
-            params.put("endTimeStart", DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", (String)params.get("endTimeStart")).getTime());
-        }
-        if (null != params.get("endTimeEnd")
-                && !"".equals(params.get("endTimeEnd"))) {
-            params.put("endTimeEnd", DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", (String)params.get("endTimeEnd")).getTime());
-        }
-        if (null != params.get("timeLenStart")
-                && !"".equals(params.get("timeLenStart"))) {
-            params.put("timeLenStart", Double.valueOf((String)params.get("timeLenStart")) * 60 * 1000L);
-        }
-        if (null != params.get("timeLenEnd")
-                && !"".equals(params.get("timeLenEnd"))) {
-            params.put("timeLenEnd", Double.valueOf((String)params.get("timeLenEnd")) * 60 * 1000L);
+        if (null == params) {
+            params = new HashMap<>();
         }
-
         // 如果是班长坐席,根据权限查询有权限的坐席的通话记录,否则查询全部数据
         SysUser currentUser = getSysUser();
         List<String> manageOpnum = new ArrayList<>(); // 有权限的用户账号
@@ -127,6 +91,7 @@ public class CcOutboundCdrController extends BaseController
         params.put("manageOpnum", manageOpnum);
 
         ccOutboundCdr.setParams(params);
+        startPage();
         List<CcOutboundCdr> list = ccOutboundCdrService.selectCcOutboundCdrList(ccOutboundCdr);
         for (CcOutboundCdr data: list) {
             data.setWavFileUrl("/recordings/files?filename=" + data.getRecordFilename());
@@ -143,39 +108,6 @@ public class CcOutboundCdrController extends BaseController
     @ResponseBody
     public AjaxResult export(CcOutboundCdr ccOutboundCdr)
     {
-        Map<String, Object> params = ccOutboundCdr.getParams();
-        if (null != params.get("startTimeStart")
-                && !"".equals(params.get("startTimeStart"))) {
-            params.put("startTimeStart", DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", (String)params.get("startTimeStart")).getTime());
-        }
-        if (null != params.get("startTimeEnd")
-                && !"".equals(params.get("startTimeEnd"))) {
-            params.put("startTimeEnd", DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", (String)params.get("startTimeEnd")).getTime());
-        }
-        if (null != params.get("answeredTimeStart")
-                && !"".equals(params.get("answeredTimeStart"))) {
-            params.put("answeredTimeStart", DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", (String)params.get("answeredTimeStart")).getTime());
-        }
-        if (null != params.get("answeredTimeEnd")
-                && !"".equals(params.get("answeredTimeEnd"))) {
-            params.put("answeredTimeEnd", DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", (String)params.get("answeredTimeEnd")).getTime());
-        }
-        if (null != params.get("endTimeStart")
-                && !"".equals(params.get("endTimeStart"))) {
-            params.put("endTimeStart", DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", (String)params.get("endTimeStart")).getTime());
-        }
-        if (null != params.get("endTimeEnd")
-                && !"".equals(params.get("endTimeEnd"))) {
-            params.put("endTimeEnd", DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", (String)params.get("endTimeEnd")).getTime());
-        }
-        if (null != params.get("timeLenStart")
-                && !"".equals(params.get("timeLenStart"))) {
-            params.put("timeLenStart", Double.valueOf((String)params.get("timeLenStart")) * 60 * 1000L);
-        }
-        if (null != params.get("timeLenEnd")
-                && !"".equals(params.get("timeLenEnd"))) {
-            params.put("timeLenEnd", Double.valueOf((String)params.get("timeLenEnd")) * 60 * 1000L);
-        }
         List<CcOutboundCdr> list = ccOutboundCdrService.selectCcOutboundCdrList(ccOutboundCdr);
         ExcelUtil<CcOutboundCdr> util = new ExcelUtil<CcOutboundCdr>(CcOutboundCdr.class);
         return util.exportExcel(list, "外呼记录数据");

+ 12 - 5
ruoyi-admin/src/main/java/com/ruoyi/cc/controller/CcParamsController.java

@@ -10,11 +10,7 @@ import org.apache.shiro.authz.annotation.RequiresPermissions;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Controller;
 import org.springframework.ui.ModelMap;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.PathVariable;
-import org.springframework.web.bind.annotation.PostMapping;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.ResponseBody;
+import org.springframework.web.bind.annotation.*;
 import com.ruoyi.common.annotation.Log;
 import com.ruoyi.common.enums.BusinessType;
 import com.ruoyi.common.core.controller.BaseController;
@@ -135,4 +131,15 @@ public class CcParamsController extends BaseController
         return  error("Forbidden.");
         //return toAjax(ccParamsService.deleteCcParamsByIds(ids));
     }
+
+
+    @GetMapping( "/getByParamCode")
+    @ResponseBody
+    public AjaxResult getByParamCode(@RequestParam(value = "paramCode") String paramCode, @RequestParam(value = "defaultValue", required = false) String defaultValue)
+    {
+        if (StringUtils.isBlank(defaultValue)) {
+            defaultValue = "";
+        }
+        return AjaxResult.success("", ccParamsService.getParamValueByCode(paramCode, defaultValue));
+    }
 }

+ 258 - 42
ruoyi-admin/src/main/java/com/ruoyi/cc/controller/FsConfController.java

@@ -7,6 +7,7 @@ import com.ruoyi.cc.model.FsConfProfile;
 import com.ruoyi.cc.model.FsMod;
 import com.ruoyi.cc.model.ProfileRegExtnumModel;
 import com.ruoyi.cc.model.ProfileStatusModel;
+import com.ruoyi.cc.service.ICcGatewaysService;
 import com.ruoyi.cc.service.ICcParamsService;
 import com.ruoyi.cc.service.IFsConfService;
 import com.ruoyi.cc.service.IFsVariablesService;
@@ -15,6 +16,7 @@ import com.ruoyi.common.core.domain.AjaxResult;
 import com.ruoyi.common.core.page.TableDataInfo;
 import com.ruoyi.common.utils.CommonUtils;
 import com.ruoyi.common.utils.StringUtils;
+import com.ruoyi.framework.web.domain.server.Sys;
 import link.thingscloud.freeswitch.esl.EslConnectionUtil;
 import link.thingscloud.freeswitch.esl.transport.message.EslMessage;
 import org.apache.shiro.authz.annotation.RequiresPermissions;
@@ -28,10 +30,17 @@ import org.w3c.dom.Node;
 import org.w3c.dom.NodeList;
 import org.xml.sax.InputSource;
 
+import javax.servlet.http.HttpServletResponse;
 import javax.xml.parsers.DocumentBuilder;
 import javax.xml.parsers.DocumentBuilderFactory;
+import java.io.IOException;
+import java.io.OutputStream;
 import java.io.StringReader;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.text.SimpleDateFormat;
 import java.util.ArrayList;
+import java.util.Date;
 import java.util.List;
 
 
@@ -48,6 +57,8 @@ public class FsConfController extends BaseController {
     private IFsVariablesService fsVariablesService;
     @Autowired
     private ICcParamsService ccParamsService;
+    @Autowired
+    private ICcGatewaysService ccGatewaysService;
 
 
     /**
@@ -216,6 +227,42 @@ public class FsConfController extends BaseController {
         return getConfigFileJsonData(asrFileName, 5);
     }
 
+    /**
+     *  豆包tts 参数配置
+     * @return
+     */
+    @RequiresPermissions("cc:doubaottsconf:view")
+    @GetMapping(value = "/doubaottsconf")
+    public String doubaoTtsConf() {
+        return "cc/doubaottsconf/doubaottsconf";
+    }
+
+    /**
+     * 获取豆包tts配置
+     * @return
+     */
+    @GetMapping(value = "/getDoubaoTtsConf")
+    @ResponseBody
+    public AjaxResult getDoubaoTtsConf() {
+        String fileName = "/autoload_configs/doubao_vcl_tts.conf.xml";
+        return getConfigFileJsonData(fileName, 5);
+    }
+
+    /**
+     * 保存Doubao TTS配置
+     * @param params
+     * @return
+     */
+    @PostMapping(value = "/setDoubaoTtsConf")
+    @ResponseBody
+    public AjaxResult setDoubaoTtsConf(@RequestBody JSONArray params) {
+        String asrFileName = "/autoload_configs/doubao_vcl_tts.conf.xml";
+        String moduleName = "mod_doubao_vcl_tts";
+        AjaxResult result = saveAndReloadTtsModule("doubao-tts-account-json",
+                asrFileName, moduleName, params);
+        return result;
+    }
+
     /**
      * ASR(阿里)参数配置
      * @return
@@ -249,6 +296,32 @@ public class FsConfController extends BaseController {
     }
 
 
+
+
+    @GetMapping(value = "/chinatelecomasrconf")
+    public String chinatelecomAsrConf() {
+        return "cc/chinatelecomasrconf/chinatelecomasrconf";
+    }
+
+    @GetMapping(value = "/chinatelecomttsconf")
+    public String chinatelecomTtsConf() {
+        return "cc/chinatelecomttsconf/chinatelecomttsconf";
+    }
+
+    @GetMapping(value = "/getChinatelecomAsrConf")
+    @ResponseBody
+    public AjaxResult getChinatelecomAsrConf() {
+        String asrFileName = "/autoload_configs/chinatelecom_asr.conf.xml";
+        return getConfigFileJsonData(asrFileName, 5);
+    }
+
+    @GetMapping(value = "/getChinatelecomTtsConf")
+    @ResponseBody
+    public AjaxResult getChinatelecomTtsConf() {
+        String fileName = "/autoload_configs/chinatelecom_tts.conf.xml";
+        return getConfigFileJsonData(fileName, 5);
+    }
+
     /**
      * 获取ASR配置
      * @return
@@ -295,9 +368,18 @@ public class FsConfController extends BaseController {
 
     }
 
-    private AjaxResult saveAndReloadAsrModule(String asrFileName, String moduleName, JSONArray params){
+    private AjaxResult saveAndReloadAsrModule(String asrFileName, String moduleName, JSONArray params, String asrProvider){
         String result = fsConfService.setAsrConf(params, asrFileName);
         if(StringUtils.isEmpty(result)) {
+
+            // 更新cc_params参数表
+            ccParamsService.updateParamsValue("sys-asr-provider", asrProvider);
+            String reloadRsp = ccParamsService.reloadParams();
+            if(!reloadRsp.equalsIgnoreCase("success")){
+                return error("参数修改成功, 但是刷新失败, 请手动重启 call-center!");
+            }
+
+            // reload
             EslMessage resp = EslConnectionUtil.sendSyncApiCommand("reload", moduleName);
             String respText = CommonUtils.ListToString(resp.getBodyLines());
             if(respText.contains("OK module loaded")) {
@@ -310,27 +392,29 @@ public class FsConfController extends BaseController {
         }
     }
 
-    private AjaxResult saveAndReloadTtsModule(String asrFileName, String moduleName, JSONArray params){
+    private AjaxResult saveAndReloadTtsModule(String paramCode, String asrFileName, String moduleName, JSONArray params){
         String result = fsConfService.setAsrConf(params, asrFileName);
         if(StringUtils.isEmpty(result)) {
-            String paramCode = "aliyun-tts-account-json";
-            String ttsAccountJson = ccParamsService.getParamValueByCode(paramCode, "{}");
-            logger.info("修改前的tts账号信息");
-            JSONObject paramValues = JSONObject.parseObject(ttsAccountJson);
-            for (int j = 0; j < params.size(); j++) {
-                JSONObject param = params.getJSONObject(j);
-                String attrName = param.getString("name");
-                String attValue = param.getString("value");
-                // 包含星号的参数值不更新
-                if (!attValue.contains("****")) {
-                    paramValues.put(attrName, attValue);
+            if (StringUtils.isNotEmpty(paramCode)) {
+
+                String ttsAccountJson = ccParamsService.getParamValueByCode(paramCode, "{}");
+                logger.info("修改前的tts账号信息");
+                JSONObject paramValues = JSONObject.parseObject(ttsAccountJson);
+                for (int j = 0; j < params.size(); j++) {
+                    JSONObject param = params.getJSONObject(j);
+                    String attrName = param.getString("name");
+                    String attValue = param.getString("value");
+                    // 包含星号的参数值不更新
+                    if (!attValue.contains("****")) {
+                        paramValues.put(attrName, attValue);
+                    }
                 }
-            }
-            ccParamsService.updateParamsValue(paramCode, JSONObject.toJSONString(paramValues));
+                ccParamsService.updateParamsValue(paramCode, JSONObject.toJSONString(paramValues));
 
-            String reloadRsp = ccParamsService.reloadParams();
-            if(!reloadRsp.equalsIgnoreCase("success")){
-                return error("参数修改成功, 但是刷新失败, 请手动重启 call-center!");
+                String reloadRsp = ccParamsService.reloadParams();
+                if(!reloadRsp.equalsIgnoreCase("success")){
+                    return error("参数修改成功, 但是刷新失败, 请手动重启 call-center!");
+                }
             }
 
             EslMessage resp = EslConnectionUtil.sendSyncApiCommand("reload", moduleName);
@@ -352,14 +436,23 @@ public class FsConfController extends BaseController {
      */
     @PostMapping(value = "/setAliAsrConf")
     @ResponseBody
-    public AjaxResult setAsrConf(@RequestBody JSONArray params) {
-        AjaxResult checkVersion  = checkSoftVersion();
-        if(checkVersion.isError()){
-            return checkVersion;
-        }
+    public AjaxResult setAliAsrConf(@RequestBody JSONArray params) {        
         String asrFileName = "/autoload_configs/aliyun_asr.conf.xml";
         String moduleName = "mod_aliyun_asr";
-        return saveAndReloadAsrModule(asrFileName, moduleName, params);
+        return saveAndReloadAsrModule(asrFileName, moduleName, params, "aliyun");
+    }
+
+    /**
+     * 保存ASR配置
+     * @param params
+     * @return
+     */
+    @PostMapping(value = "/setChinatelecomAsrConf")
+    @ResponseBody
+    public AjaxResult setChinatelecomAsrConf(@RequestBody JSONArray params) {       
+        String asrFileName = "/autoload_configs/chinatelecom_asr.conf.xml";
+        String moduleName = "mod_chinatelecom_asr";
+        return saveAndReloadAsrModule(asrFileName, moduleName, params, "chinatelecom");
     }
 
     /**
@@ -372,18 +465,23 @@ public class FsConfController extends BaseController {
     public AjaxResult setAliTtsConf(@RequestBody JSONArray params) {
         String asrFileName = "/autoload_configs/aliyun_tts.conf.xml";
         String moduleName = "mod_aliyun_tts";
-        AjaxResult result = saveAndReloadTtsModule(asrFileName, moduleName, params);
+        AjaxResult result = saveAndReloadTtsModule("aliyun-tts-account-json",
+                asrFileName, moduleName, params);
         return result;
     }
 
-    private AjaxResult checkSoftVersion(){
-        String version =  ccParamsService.getParamValueByCode(
-                "current-software-version", "community"
-        );
-        if("community".equalsIgnoreCase(version)){
-            return AjaxResult.error("抱歉,社区版无法使用该模块!");
-        }
-        return AjaxResult.success("Ok");
+    /**
+     * 保存TTS配置
+     * @param params
+     * @return
+     */
+    @PostMapping(value = "/setChinatelecomTtsConf")
+    @ResponseBody
+    public AjaxResult setChinatelecomTtsConf(@RequestBody JSONArray params) {
+        String asrFileName = "/autoload_configs/chinatelecom_tts.conf.xml";
+        String moduleName = "mod_chinatelecom_tts";
+        AjaxResult result = saveAndReloadTtsModule("", asrFileName, moduleName, params);
+        return result;
     }
 
     /**
@@ -393,14 +491,10 @@ public class FsConfController extends BaseController {
      */
     @PostMapping(value = "/setXunfeiAsrConf")
     @ResponseBody
-    public AjaxResult setXunfeiAsrConf(@RequestBody JSONArray params) {
-        AjaxResult checkVersion = checkSoftVersion();
-        if(checkVersion.isError()){
-            return checkVersion;
-        }
+    public AjaxResult setXunfeiAsrConf(@RequestBody JSONArray params) {       
         String asrFileName = "/autoload_configs/xunfei_asr.conf.xml";
         String moduleName = "mod_xunfei_asr";
-        return saveAndReloadAsrModule(asrFileName, moduleName, params);
+        return saveAndReloadAsrModule(asrFileName, moduleName, params, "xunfeiasr");
     }
 
     /**
@@ -413,7 +507,7 @@ public class FsConfController extends BaseController {
     public AjaxResult setFunAsrConf(@RequestBody JSONArray params) {
         String asrFileName = "/autoload_configs/funasr.conf.xml";
         String moduleName = "mod_funasr";
-        return saveAndReloadAsrModule(asrFileName, moduleName, params);
+        return saveAndReloadAsrModule(asrFileName, moduleName, params, "funasr");
     }
 
     /**
@@ -496,13 +590,57 @@ public class FsConfController extends BaseController {
         return AjaxResult.success("设置成功!");
     }
 
+
+
+
+    /**
+     * 授权配置
+     * @return
+     */
+    @RequiresPermissions("cc:licenseconf:view")
+    @GetMapping(value = "/licenseconf")
+    public String licenseconf() {
+        return "cc/licenseconf/licenseconf";
+    }
+
+    /**
+     * 获取授权配置
+     * @return
+     */
+    @GetMapping(value = "/getLicenseConf")
+    @ResponseBody
+    public AjaxResult getLicenseConf() {
+        JSONObject result = new JSONObject();
+        result.put("fingerprintValue", fsConfService.getFingerprintValue());
+        result.put("licenseValue", fsConfService.getLicenseValue());
+        result.put("licenseInfo", fsConfService.getLicenseInfo());
+        return AjaxResult.success("success", result);
+    }
+
+    /**
+     * 保存授权配置
+     * @param params
+     * @return
+     */
+    @PostMapping(value = "/setLicenseConf")
+    @ResponseBody
+    public AjaxResult setLicenseConf(@RequestBody JSONObject params) {
+        fsConfService.setLicenseConf(params.getString("licenseValue"));
+        return AjaxResult.success("设置成功!");
+    }
+
+
     /**
      * 日志监控
      * @return
      */
     @RequiresPermissions("cc:catlogs:view")
     @GetMapping(value = "/catlogs")
-    public String catLogs() {
+    public String catLogs(ModelMap mmap) {
+        String ccLogFiles = ccParamsService.getParamValueByCode("cc_log_file_path", "");
+        String errorLogFiles = ccLogFiles.replace("easycallcenter365.log", "easycallcenter365-ERROR.log");
+        String errorLogs = fsConfService.getLogs("", errorLogFiles, "error");
+        mmap.put("errorLogs", errorLogs);
         return "cc/catlogs/catlogs";
     }
 
@@ -528,7 +666,7 @@ public class FsConfController extends BaseController {
         String ccLogs = fsConfService.getLogs(uuid, ccLogFiles, "cc");
         if (StringUtils.isNotEmpty(fsLogs)
                 && StringUtils.isBlank(ccLogs)) {
-            String ccHisLogFiles = ccLogFiles.replaceAll("\\.log$", "*.log");
+            String ccHisLogFiles = ccLogFiles.replace(".log", ".*.log");
             logger.info(ccHisLogFiles);
             ccLogs = fsConfService.getLogs(uuid, ccHisLogFiles, "cc");
         }
@@ -537,10 +675,88 @@ public class FsConfController extends BaseController {
     }
 
 
+    /**
+     * 获取日志
+     * @return
+     */
+    @GetMapping(value = "/downloadLogs")
+    @ResponseBody
+    public void downloadLogs(@RequestParam String uuid, HttpServletResponse response) {
+        if (StringUtils.isBlank(uuid) || uuid.length() <= 10) {
+            try {
+                response.setContentType("application/json;charset=UTF-8");
+                response.getWriter().write("{\"code\":500,\"msg\":\"请输入正确的uuid\"}");
+            } catch (IOException e) {
+                logger.error("写入错误响应失败", e);
+            }
+            return;
+        }
+
+        uuid = uuid.trim();
+
+        // 获取日志内容
+        String fsLogFiles = ccParamsService.getParamValueByCode("fs_log_file_path", "");
+        String fsLogs = fsConfService.getLogs(uuid, fsLogFiles, "cc");
+
+        String ccLogFiles = ccParamsService.getParamValueByCode("cc_log_file_path", "");
+        String ccLogs = fsConfService.getLogs(uuid, ccLogFiles, "cc");
+
+        if (StringUtils.isNotEmpty(fsLogs) && StringUtils.isBlank(ccLogs)) {
+            String ccHisLogFiles = ccLogFiles.replace(".log", ".*.log");
+            logger.info(ccHisLogFiles);
+            ccLogs = fsConfService.getLogs(uuid, ccHisLogFiles, "cc");
+        }
+
+        String errorLogFiles = ccLogFiles.replace("easycallcenter365.log", "easycallcenter365-ERROR.log");
+        String errorLogs = fsConfService.getLogs("", errorLogFiles, "error");
+
+        // 生成TXT文件内容
+        StringBuilder fileContent = new StringBuilder();
+
+        fileContent.append("========================================\n");
+        fileContent.append("UUID: ").append(uuid).append("\n");
+        fileContent.append("下载时间: ").append(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date())).append("\n");
+        fileContent.append("========================================\n\n");
+
+        fileContent.append("【FreeSwitch 日志】\n");
+        fileContent.append("========================================\n");
+        fileContent.append(StringUtils.isNotBlank(fsLogs) ? fsLogs : "无日志内容\n");
+        fileContent.append("\n\n");
+
+        fileContent.append("【CallCenter 日志】\n");
+        fileContent.append("========================================\n");
+        fileContent.append(StringUtils.isNotBlank(ccLogs) ? ccLogs : "无日志内容\n");
+
+        fileContent.append("【Error 日志】\n");
+        fileContent.append("========================================\n");
+        fileContent.append(StringUtils.isNotBlank(errorLogs) ? errorLogs : "无日志内容\n");
+        fileContent.append("\n\n");
+
+        // 设置响应头,实现浏览器下载
+        String fileName = "logs_" + uuid + "_" + System.currentTimeMillis() + ".txt";
+        try {
+            response.setContentType("text/plain;charset=UTF-8");
+            response.setHeader("Content-Disposition", "attachment; filename=\"" + URLEncoder.encode(fileName, "UTF-8") + "\"");
+            response.setHeader("Cache-Control", "no-cache");
+            response.setContentLength(fileContent.toString().getBytes(StandardCharsets.UTF_8).length);
+
+            // 写入文件内容
+            try (OutputStream out = response.getOutputStream()) {
+                out.write(fileContent.toString().getBytes(StandardCharsets.UTF_8));
+                out.flush();
+            }
+        } catch (IOException e) {
+            logger.error("下载日志文件失败", e);
+        }
+    }
+
+
+
     @RequiresPermissions("cc:profileconf:view")
     @GetMapping("/profileconf")
     public String profile()
     {
+        ccGatewaysService.refreshGatewaysFiles();
         return "cc/profileconf/profileconf";
     }
 

+ 4 - 1
ruoyi-admin/src/main/java/com/ruoyi/cc/domain/CcGateways.java

@@ -54,6 +54,9 @@ public class CcGateways implements Serializable {
     /** 注册模式下;认证用户名 */
     private String authUsername;
 
+    /** 反向注册模式下;分机号 */
+    private String authUsername2;
+
     /** 注册模式下;认证密码 */
     private String authPassword;
 
@@ -63,7 +66,7 @@ public class CcGateways implements Serializable {
     /** 更新时间 */
     private Long updateTime;
 
-    /** 是否需要认证注册; 0 对接模式; 1注册模式 */
+    /** 是否需要认证注册; 0 对接模式, 1注册模式, 2反向注册模式 */
     private Long register;
 
     /** 自定义属性 */

+ 35 - 0
ruoyi-admin/src/main/java/com/ruoyi/cc/domain/CcInboundCdr.java

@@ -9,6 +9,7 @@ import com.ruoyi.common.annotation.Excel;
 import com.ruoyi.common.core.domain.BaseEntity;
 
 import java.io.Serializable;
+import java.math.BigDecimal;
 import java.util.Date;
 import java.util.Map;
 
@@ -68,10 +69,44 @@ public class CcInboundCdr implements Serializable {
     /** AI客服对话内容 */
     private String chatContent;
 
+
+    /** asr时长(秒) */
+    private Integer asrSeconds;
+
+    /** tts调用次数(次) */
+    private Integer ttsTimes;
+
+    /** 大模型tts的字符数(字符) */
+    private Integer ttsFlowTokens;
+
+    /** 总输入token数 */
+    private Integer inputTokens;
+
+    /** 总输出token数 */
+    private Integer outputTokens;
+
+    /** 总调用费用(asr+tts+大模型) */
+    private BigDecimal totalCost;
+
+    /** 计费状态(1:已计费、0:未计费) */
+    private Integer billingStatus;
+
+    /** 整个ivr通话中的有效按键 */
+    private String ivrDtmfDigits;
+
     /** 请求参数 */
     @JsonInclude(JsonInclude.Include.NON_EMPTY)
     private Map<String, Object> params;
 
     /** Business Group Name */
     private String groupName;
+
+    /** 挂机原因 */
+    private String hangupCause;
+
+    /** manual agent answered time. */
+    private String manualAnsweredTime;
+
+    /** The duration of the manual agent service time. */
+    private String manualAnsweredTimeLen;
 }

+ 124 - 0
ruoyi-admin/src/main/java/com/ruoyi/cc/domain/CcIvr.java

@@ -0,0 +1,124 @@
+package com.ruoyi.cc.domain;
+
+import lombok.Data;
+import lombok.experimental.Accessors;
+import org.apache.commons.lang3.builder.ToStringBuilder;
+import org.apache.commons.lang3.builder.ToStringStyle;
+import com.ruoyi.common.annotation.Excel;
+import com.ruoyi.common.core.domain.BaseEntity;
+
+import java.io.Serializable;
+
+/**
+ * IVR配置对象 cc_ivr
+ * 
+ * @author ruoyi
+ * @date 2025-12-19
+ */
+@Data
+@Accessors(chain = true)
+public class CcIvr implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    /** 主键id */
+    private String id;
+
+    /** rootId */
+    private String rootId;
+
+    /** DTMF digit key */
+    @Excel(name = "DTMF digit key")
+    private String digit;
+
+    /** node name */
+    @Excel(name = "node name")
+    private String ivrNodeName;
+
+    /** id of parent node. 返回上一级统一使用*号键 */
+    @Excel(name = "id of parent node. 返回上一级统一使用*号键")
+    private String parentNodeId;
+
+    /** The path of the audio file, or the speech-to-text */
+    @Excel(name = "The path of the audio file, or the speech-to-text")
+    private String ttsText;
+
+    /** acd、extension、hangup、gateway、play、function */
+    @Excel(name = "acd、extension、hangup、gateway、play、function")
+    private String action;
+
+    /** Wait for the key press timeout */
+    @Excel(name = "Wait for the key press timeout")
+    private Long waitKeyTimeout;
+
+    /** The maximum number of attempts to press a key */
+    @Excel(name = "The maximum number of attempts to press a key")
+    private Long maxPressKeyFailures;
+
+    /** invalid text tips or wav path. */
+    @Excel(name = "invalid text tips or wav path.")
+    private String pressKeyInvalidTips;
+
+    /**  */
+    @Excel(name = "")
+    private Long enabled;
+
+    /** hangup、upaction */
+    @Excel(name = "hangup、upaction")
+    private String failedAction;
+
+    /** 数字按键的可选范围 */
+    @Excel(name = "数字按键的可选范围")
+    private String digitRange;
+
+    /** tts_provider */
+    @Excel(name = "tts_provider")
+    private String ttsProvider;
+
+    /** name of tts speaker */
+    @Excel(name = "name of tts speaker")
+    private String voiceCode;
+
+    /** aiTransferData */
+    private String aiTransferData;
+
+    /** aiInboundId */
+    private String aiInboundId;
+
+    /** aiTransferGroupId */
+    private String aiTransferGroupId;
+
+    /** aiTransferGatewayId */
+    private String aiTransferGatewayId;
+
+    /** aiTransferGatewayDestNumber */
+    private String aiTransferGatewayDestNumber;
+
+    /** aiTransferExtNumber */
+    private String aiTransferExtNumber;
+
+    /** maxLen */
+    private Integer maxLen;
+
+    /** minLen */
+    private Integer minLen;
+
+    /** userInputVarName */
+    private String userInputVarName;
+
+    /** hangupTips */
+    private String hangupTips;
+
+
+    private String ttsTextfileUrl;
+
+    private String invalidTipsfileUrl;
+
+    private String hangupTipsfileUrl;
+
+
+    private String ttsTextWav;
+
+    private String pressKeyInvalidTipsWav;
+
+    private String hangupTipsWav;
+}

+ 3 - 0
ruoyi-admin/src/main/java/com/ruoyi/cc/domain/CcOutboundCdr.java

@@ -64,6 +64,9 @@ public class CcOutboundCdr implements Serializable {
     /** 录音文件url访问地址 */
     private String wavFileUrl;
 
+    /** 对话内容 */
+    private String chatContent;
+
     /** 请求参数 */
     @JsonInclude(JsonInclude.Include.NON_EMPTY)
     private Map<String, Object> params;

+ 4 - 0
ruoyi-admin/src/main/java/com/ruoyi/cc/domain/CcParams.java

@@ -41,4 +41,8 @@ public class CcParams implements Serializable {
     /** 是否在网页上隐藏参数值 */
     @Excel(name = "隐藏参数值")
     private int hideValue;
+
+    /** 是否允许编辑 */
+    @Excel(name = "是否允许编辑")
+    private int allowEdit;
 }

+ 6 - 0
ruoyi-admin/src/main/java/com/ruoyi/cc/mapper/CcExtNumMapper.java

@@ -58,4 +58,10 @@ public interface CcExtNumMapper
      * @return 结果
      */
     public int deleteCcExtNumByExtIds(String[] extIds);
+
+    /**
+     * 获取未分配的分机
+     * @return
+     */
+    List<CcExtNum> selectUnBindCcExtNumList();
 }

+ 65 - 0
ruoyi-admin/src/main/java/com/ruoyi/cc/mapper/CcIvrMapper.java

@@ -0,0 +1,65 @@
+package com.ruoyi.cc.mapper;
+
+import java.util.List;
+import java.util.Map;
+
+import com.ruoyi.cc.domain.CcIvr;
+
+/**
+ * IVR配置Mapper接口
+ * 
+ * @author ruoyi
+ * @date 2025-12-19
+ */
+public interface CcIvrMapper 
+{
+    /**
+     * 查询IVR配置
+     * 
+     * @param id IVR配置主键
+     * @return IVR配置
+     */
+    public CcIvr selectCcIvrById(String id);
+
+    /**
+     * 查询IVR配置列表
+     * 
+     * @param ccIvr IVR配置
+     * @return IVR配置集合
+     */
+    public List<CcIvr> selectCcIvrList(CcIvr ccIvr);
+
+    /**
+     * 新增IVR配置
+     * 
+     * @param ccIvr IVR配置
+     * @return 结果
+     */
+    public int insertCcIvr(CcIvr ccIvr);
+
+    /**
+     * 修改IVR配置
+     * 
+     * @param ccIvr IVR配置
+     * @return 结果
+     */
+    public int updateCcIvr(CcIvr ccIvr);
+
+    /**
+     * 删除IVR配置
+     * 
+     * @param id IVR配置主键
+     * @return 结果
+     */
+    public int deleteCcIvrById(String id);
+
+    /**
+     * 批量删除IVR配置
+     * 
+     * @param ids 需要删除的数据主键集合
+     * @return 结果
+     */
+    public int deleteCcIvrByIds(String[] ids);
+
+    int countByDigitAndParent(Map<String, Object> params);
+}

+ 6 - 0
ruoyi-admin/src/main/java/com/ruoyi/cc/service/ICcExtNumService.java

@@ -83,4 +83,10 @@ public interface ICcExtNumService
      * @return
      */
     String createToken(String extnum, String opnum, String groupId, String skillLevel, String projectId);
+
+    /**
+     * 获取未分配的分机
+     * @return
+     */
+    List<CcExtNum> selectUnBindCcExtNumList();
 }

+ 6 - 0
ruoyi-admin/src/main/java/com/ruoyi/cc/service/ICcGatewaysService.java

@@ -65,4 +65,10 @@ public interface ICcGatewaysService
      * @return
      */
     CcGateways selectCcGatewaysByGwName(String gwName);
+
+    /**
+     *
+     * @return
+     */
+    Integer refreshGatewaysFiles();
 }

+ 69 - 0
ruoyi-admin/src/main/java/com/ruoyi/cc/service/ICcIvrService.java

@@ -0,0 +1,69 @@
+package com.ruoyi.cc.service;
+
+import java.util.List;
+
+import com.alibaba.fastjson.JSONObject;
+import com.ruoyi.cc.domain.CcIvr;
+
+/**
+ * IVR配置Service接口
+ * 
+ * @author ruoyi
+ * @date 2025-12-19
+ */
+public interface ICcIvrService 
+{
+    /**
+     * 查询IVR配置
+     * 
+     * @param id IVR配置主键
+     * @return IVR配置
+     */
+    public CcIvr selectCcIvrById(String id);
+
+    /**
+     * 查询IVR配置列表
+     * 
+     * @param ccIvr IVR配置
+     * @return IVR配置集合
+     */
+    public List<CcIvr> selectCcIvrList(CcIvr ccIvr);
+
+    /**
+     * 新增IVR配置
+     * 
+     * @param ccIvr IVR配置
+     * @return 结果
+     */
+    public int insertCcIvr(CcIvr ccIvr);
+
+    /**
+     * 修改IVR配置
+     * 
+     * @param ccIvr IVR配置
+     * @return 结果
+     */
+    public int updateCcIvr(CcIvr ccIvr);
+
+    /**
+     * 批量删除IVR配置
+     * 
+     * @param ids 需要删除的IVR配置主键集合
+     * @return 结果
+     */
+    public int deleteCcIvrByIds(String ids);
+
+    /**
+     * 删除IVR配置信息
+     * 
+     * @param id IVR配置主键
+     * @return 结果
+     */
+    public int deleteCcIvrById(String id);
+
+    boolean checkDigitExists(String digit, String parentNodeId, Long id);
+
+    JSONObject reloadIvr();
+
+    CcIvr selectCcIvrByRootId(String rootId);
+}

+ 25 - 1
ruoyi-admin/src/main/java/com/ruoyi/cc/service/IFsConfService.java

@@ -43,7 +43,7 @@ public interface IFsConfService {
 
     /**
      * 保存ASR引擎到autoload_configs/modules.conf.xml
-     * @param params
+     * @param asrengine
      */
     String setAsrengine(String asrengine);
 
@@ -84,6 +84,24 @@ public interface IFsConfService {
      */
     void setCertWssPen(String certValue);
 
+    /**
+     * 获取授权配置
+     * @return
+     */
+    String getLicenseValue();
+
+    /**
+     * 保存授权配置
+     * @param licenseValue
+     */
+    void setLicenseConf(String licenseValue);
+
+    /**
+     * 获取授权到期信息
+     * @return
+     */
+    JSONObject getLicenseInfo();
+
     /**
      * 获取日志内容
      * @param uuid
@@ -128,4 +146,10 @@ public interface IFsConfService {
      * @param params
      */
     void setGwUnRegisterConf(String orginProfileName, String profileName, String gwName, JSONObject params);
+
+    /**
+     * 获取机器指纹信息
+     * @return
+     */
+    String getFingerprintValue();
 }

+ 6 - 0
ruoyi-admin/src/main/java/com/ruoyi/cc/service/impl/CcExtNumServiceImpl.java

@@ -129,6 +129,7 @@ public class CcExtNumServiceImpl implements ICcExtNumService
     @Override
     public String createToken(String extnum, String opnum, String groupId, String skillLevel, String projectId)  {
         try {
+            log.info("extnum:{}, opnum:{}, groupId:{}", extnum, opnum, groupId);
             authTokenSecret = ccParamsService.getParamValueByCode(
                     "ws-server-auth-token-secret", "123456");
             //登录成功后生成JWT
@@ -156,4 +157,9 @@ public class CcExtNumServiceImpl implements ICcExtNumService
         }
         return null;
     }
+
+    @Override
+    public List<CcExtNum> selectUnBindCcExtNumList() {
+        return ccExtNumMapper.selectUnBindCcExtNumList();
+    }
 }

+ 84 - 0
ruoyi-admin/src/main/java/com/ruoyi/cc/service/impl/CcGatewaysServiceImpl.java

@@ -1,7 +1,22 @@
 package com.ruoyi.cc.service.impl;
 
+import java.io.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import com.alibaba.fastjson.JSONObject;
+import com.ruoyi.cc.service.ICcParamsService;
 import com.ruoyi.common.utils.DateUtils;
+import com.ruoyi.common.utils.StringUtils;
+import link.thingscloud.freeswitch.esl.EslConnectionUtil;
+import link.thingscloud.freeswitch.esl.transport.message.EslMessage;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import com.ruoyi.cc.mapper.CcGatewaysMapper;
@@ -16,10 +31,13 @@ import com.ruoyi.common.core.text.Convert;
  * @date 2024-12-22
  */
 @Service
+@Slf4j
 public class CcGatewaysServiceImpl implements ICcGatewaysService 
 {
     @Autowired
     private CcGatewaysMapper ccGatewaysMapper;
+    @Autowired
+    private ICcParamsService ccParamsService;
 
     /**
      * 查询线路配置
@@ -101,4 +119,70 @@ public class CcGatewaysServiceImpl implements ICcGatewaysService
         }
         return null;
     }
+
+    @Override
+    public Integer refreshGatewaysFiles() {
+        List<CcGateways> list = this.selectCcGatewaysList(new CcGateways());
+        Map<String, List<String>> profileNames = new HashMap<>();
+        for (CcGateways ccGateways : list) {
+            List<String> gatewayNames = profileNames.getOrDefault(ccGateways.getProfileName(), new ArrayList<>());
+            // 废弃的配置,也删除
+            if (ccGateways.getPurpose() != 0) {
+                gatewayNames.add(ccGateways.getGwName() + ".xml");
+            }
+            profileNames.put(ccGateways.getProfileName(), gatewayNames);
+        }
+
+        String fsConfDirectory = ccParamsService.getParamValueByCode("fs_conf_directory", "");
+        int deletedCount = 0;
+
+        // 遍历每个profile及其对应的gateway名称集合
+        for (Map.Entry<String, List<String>> entry : profileNames.entrySet()) {
+            String profile = entry.getKey();
+            List<String> gatewayNames = entry.getValue();
+            log.info("==========profile:{}, gatewayNames:{}", profile, JSONObject.toJSONString(gatewayNames));
+
+            // 构建profile目录路径
+            Path profileDir = Paths.get(fsConfDirectory, "/sip_profiles/", profile);
+
+            // 检查目录是否存在且为目录
+            if (!Files.exists(profileDir) || !Files.isDirectory(profileDir)) {
+                continue;
+            }
+
+            try {
+                // 获取目录下的所有文件
+                File[] files = profileDir.toFile().listFiles();
+                if (files == null) {
+                    continue;
+                }
+
+                // 遍历文件,删除不在gatewayNames集合中的文件
+                for (File file : files) {
+                    String gwName = file.getName().replace(".xml", "");
+                    log.info("====================gwName:" + gwName);
+                    log.info("=====================" + (file.isFile() && !gatewayNames.contains(file.getName())));
+                    log.info("=====================" + (gatewayNames.contains(file.getName())));
+                    if (file.isFile() && !gatewayNames.contains(file.getName())) {
+                        boolean deleted = file.delete();
+                        if (deleted) {
+                            // 删除文件后要kill掉网关
+                            EslMessage eslMessage1 = EslConnectionUtil.sendSyncApiCommand("sofia", "profile " + profile + " killgw " + gwName);
+                            if (null != eslMessage1) {
+                                log.info(StringUtils.joinWith("/r/n", eslMessage1.getBodyLines().toArray()));
+                            }
+                            deletedCount++;
+                            log.info("=======Deleted orphaned file: {}", file.getAbsolutePath());
+                        } else {
+                            log.warn("========Failed to delete file: {}", file.getAbsolutePath());
+                        }
+                    }
+                }
+            } catch (Exception e) {
+                log.error("Error processing directory: {}", profileDir, e);
+            }
+        }
+
+        return deletedCount;
+    }
 }

+ 55 - 4
ruoyi-admin/src/main/java/com/ruoyi/cc/service/impl/CcInboundCdrServiceImpl.java

@@ -1,12 +1,16 @@
 package com.ruoyi.cc.service.impl;
 
-import java.util.List;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.stereotype.Service;
-import com.ruoyi.cc.mapper.CcInboundCdrMapper;
 import com.ruoyi.cc.domain.CcInboundCdr;
+import com.ruoyi.cc.mapper.CcInboundCdrMapper;
 import com.ruoyi.cc.service.ICcInboundCdrService;
 import com.ruoyi.common.core.text.Convert;
+import com.ruoyi.common.utils.DateUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
 
 /**
  * 呼入记录Service业务层处理
@@ -41,6 +45,53 @@ public class CcInboundCdrServiceImpl implements ICcInboundCdrService
     @Override
     public List<CcInboundCdr> selectCcInboundCdrList(CcInboundCdr ccInboundCdr)
     {
+        Map<String, Object> params = ccInboundCdr.getParams();
+        if (null == params) {
+            params = new HashMap<>();
+        }
+        if (null != params.get("inboundTimeStart")
+                && !"".equals(params.get("inboundTimeStart"))) {
+            params.put("inboundTimeStart", DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", (String)params.get("inboundTimeStart")).getTime());
+        }
+        if (null != params.get("inboundTimeEnd")
+                && !"".equals(params.get("inboundTimeEnd"))) {
+            params.put("inboundTimeEnd", DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", (String)params.get("inboundTimeEnd")).getTime());
+        }
+        if (null != params.get("answeredTimeStart")
+                && !"".equals(params.get("answeredTimeStart"))) {
+            params.put("answeredTimeStart", DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", (String)params.get("answeredTimeStart")).getTime());
+        }
+        if (null != params.get("answeredTimeEnd")
+                && !"".equals(params.get("answeredTimeEnd"))) {
+            params.put("answeredTimeEnd", DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", (String)params.get("answeredTimeEnd")).getTime());
+        }
+        if (null != params.get("hangupTimeStart")
+                && !"".equals(params.get("hangupTimeStart"))) {
+            params.put("hangupTimeStart", DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", (String)params.get("hangupTimeStart")).getTime());
+        }
+        if (null != params.get("hangupTimeEnd")
+                && !"".equals(params.get("hangupTimeEnd"))) {
+            params.put("hangupTimeEnd", DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", (String)params.get("hangupTimeEnd")).getTime());
+        }
+        // 通话时长(分钟)
+        if (null != params.get("timeLenStart")
+                && !"".equals(params.get("timeLenStart"))) {
+            params.put("timeLenStart", Double.valueOf((String)params.get("timeLenStart")) * 60 * 1000L);
+        }
+        if (null != params.get("timeLenEnd")
+                && !"".equals(params.get("timeLenEnd"))) {
+            params.put("timeLenEnd", Double.valueOf((String)params.get("timeLenEnd")) * 60 * 1000L);
+        }
+        // 通话时长(秒)
+        if (null != params.get("timeLenSecondStart")
+                && !"".equals(params.get("timeLenSecondStart"))) {
+            params.put("timeLenStart", Double.valueOf((String)params.get("timeLenSecondStart")) * 1000L);
+        }
+        if (null != params.get("timeLenSecondEnd")
+                && !"".equals(params.get("timeLenSecondEnd"))) {
+            params.put("timeLenEnd", Double.valueOf((String)params.get("timeLenSecondEnd")) * 1000L);
+        }
+        ccInboundCdr.setParams(params);
         return ccInboundCdrMapper.selectCcInboundCdrList(ccInboundCdr);
     }
 

+ 131 - 0
ruoyi-admin/src/main/java/com/ruoyi/cc/service/impl/CcIvrServiceImpl.java

@@ -0,0 +1,131 @@
+package com.ruoyi.cc.service.impl;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import com.alibaba.fastjson.JSONObject;
+import com.ruoyi.cc.service.ICcParamsService;
+import com.ruoyi.common.utils.StringUtils;
+import com.ruoyi.common.utils.http.HttpUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import com.ruoyi.cc.mapper.CcIvrMapper;
+import com.ruoyi.cc.domain.CcIvr;
+import com.ruoyi.cc.service.ICcIvrService;
+import com.ruoyi.common.core.text.Convert;
+
+/**
+ * IVR配置Service业务层处理
+ * 
+ * @author ruoyi
+ * @date 2025-12-19
+ */
+@Service
+public class CcIvrServiceImpl implements ICcIvrService 
+{
+    @Autowired
+    private CcIvrMapper ccIvrMapper;
+    @Autowired
+    private ICcParamsService paramsService;
+
+    /**
+     * 查询IVR配置
+     * 
+     * @param id IVR配置主键
+     * @return IVR配置
+     */
+    @Override
+    public CcIvr selectCcIvrById(String id)
+    {
+        return ccIvrMapper.selectCcIvrById(id);
+    }
+
+    /**
+     * 查询IVR配置列表
+     * 
+     * @param ccIvr IVR配置
+     * @return IVR配置
+     */
+    @Override
+    public List<CcIvr> selectCcIvrList(CcIvr ccIvr)
+    {
+        return ccIvrMapper.selectCcIvrList(ccIvr);
+    }
+
+    /**
+     * 新增IVR配置
+     * 
+     * @param ccIvr IVR配置
+     * @return 结果
+     */
+    @Override
+    public int insertCcIvr(CcIvr ccIvr)
+    {
+        return ccIvrMapper.insertCcIvr(ccIvr);
+    }
+
+    /**
+     * 修改IVR配置
+     * 
+     * @param ccIvr IVR配置
+     * @return 结果
+     */
+    @Override
+    public int updateCcIvr(CcIvr ccIvr)
+    {
+        return ccIvrMapper.updateCcIvr(ccIvr);
+    }
+
+    /**
+     * 批量删除IVR配置
+     * 
+     * @param ids 需要删除的IVR配置主键
+     * @return 结果
+     */
+    @Override
+    public int deleteCcIvrByIds(String ids)
+    {
+        return ccIvrMapper.deleteCcIvrByIds(Convert.toStrArray(ids));
+    }
+
+    /**
+     * 删除IVR配置信息
+     * 
+     * @param id IVR配置主键
+     * @return 结果
+     */
+    @Override
+    public int deleteCcIvrById(String id)
+    {
+        return ccIvrMapper.deleteCcIvrById(id);
+    }
+
+    @Override
+    public boolean checkDigitExists(String digit, String parentNodeId, Long id) {
+        Map<String, Object> params = new HashMap<>();
+        params.put("digit", digit);
+        params.put("parentNodeId", parentNodeId);
+        params.put("id", id); // 编辑时排除当前记录
+
+        // 查询除当前记录外是否已存在相同digit
+        int count = ccIvrMapper.countByDigitAndParent(params);
+        return count > 0;
+    }
+
+    @Override
+    public JSONObject reloadIvr() {
+        String serverPort = paramsService.getParamValueByCode("call-center-server-port", "");
+        if(!StringUtils.isEmpty(serverPort)){
+            String reloadParamsUrl = String.format("http://127.0.0.1:%s/call-center/api/ivr/reload", serverPort);
+            String response = HttpUtils.sendGet(reloadParamsUrl);
+            return JSONObject.parseObject(response);
+        }
+        return null;
+    }
+
+    @Override
+    public CcIvr selectCcIvrByRootId(String rootId) {
+        return selectCcIvrById(rootId);
+    }
+}

+ 55 - 4
ruoyi-admin/src/main/java/com/ruoyi/cc/service/impl/CcOutboundCdrServiceImpl.java

@@ -1,12 +1,16 @@
 package com.ruoyi.cc.service.impl;
 
-import java.util.List;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.stereotype.Service;
-import com.ruoyi.cc.mapper.CcOutboundCdrMapper;
 import com.ruoyi.cc.domain.CcOutboundCdr;
+import com.ruoyi.cc.mapper.CcOutboundCdrMapper;
 import com.ruoyi.cc.service.ICcOutboundCdrService;
 import com.ruoyi.common.core.text.Convert;
+import com.ruoyi.common.utils.DateUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
 
 /**
  * 外呼记录Service业务层处理
@@ -41,6 +45,53 @@ public class CcOutboundCdrServiceImpl implements ICcOutboundCdrService
     @Override
     public List<CcOutboundCdr> selectCcOutboundCdrList(CcOutboundCdr ccOutboundCdr)
     {
+        Map<String, Object> params = ccOutboundCdr.getParams();
+        if (null == params) {
+            params = new HashMap<>();
+        }
+        if (null != params.get("startTimeStart")
+                && !"".equals(params.get("startTimeStart"))) {
+            params.put("startTimeStart", DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", (String)params.get("startTimeStart")).getTime());
+        }
+        if (null != params.get("startTimeEnd")
+                && !"".equals(params.get("startTimeEnd"))) {
+            params.put("startTimeEnd", DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", (String)params.get("startTimeEnd")).getTime());
+        }
+        if (null != params.get("answeredTimeStart")
+                && !"".equals(params.get("answeredTimeStart"))) {
+            params.put("answeredTimeStart", DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", (String)params.get("answeredTimeStart")).getTime());
+        }
+        if (null != params.get("answeredTimeEnd")
+                && !"".equals(params.get("answeredTimeEnd"))) {
+            params.put("answeredTimeEnd", DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", (String)params.get("answeredTimeEnd")).getTime());
+        }
+        if (null != params.get("endTimeStart")
+                && !"".equals(params.get("endTimeStart"))) {
+            params.put("endTimeStart", DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", (String)params.get("endTimeStart")).getTime());
+        }
+        if (null != params.get("endTimeEnd")
+                && !"".equals(params.get("endTimeEnd"))) {
+            params.put("endTimeEnd", DateUtils.dateTime("yyyy-MM-dd HH:mm:ss", (String)params.get("endTimeEnd")).getTime());
+        }
+        // 通话时长(分钟)
+        if (null != params.get("timeLenStart")
+                && !"".equals(params.get("timeLenStart"))) {
+            params.put("timeLenStart", Double.valueOf((String)params.get("timeLenStart")) * 60 * 1000L);
+        }
+        if (null != params.get("timeLenEnd")
+                && !"".equals(params.get("timeLenEnd"))) {
+            params.put("timeLenEnd", Double.valueOf((String)params.get("timeLenEnd")) * 60 * 1000L);
+        }
+        // 通话时长(秒)
+        if (null != params.get("timeLenSecondStart")
+                && !"".equals(params.get("timeLenSecondStart"))) {
+            params.put("timeLenStart", Double.valueOf((String)params.get("timeLenSecondStart")) * 1000L);
+        }
+        if (null != params.get("timeLenSecondEnd")
+                && !"".equals(params.get("timeLenSecondEnd"))) {
+            params.put("timeLenEnd", Double.valueOf((String)params.get("timeLenSecondEnd")) * 1000L);
+        }
+        ccOutboundCdr.setParams(params);
         return ccOutboundCdrMapper.selectCcOutboundCdrList(ccOutboundCdr);
     }
 

+ 12 - 1
ruoyi-admin/src/main/java/com/ruoyi/cc/service/impl/CcParamsServiceImpl.java

@@ -1,6 +1,7 @@
 package com.ruoyi.cc.service.impl;
 
 import java.util.List;
+import java.util.Locale;
 
 import com.ruoyi.cc.domain.CcParams;
 import com.ruoyi.cc.mapper.CcParamsMapper;
@@ -9,8 +10,10 @@ import com.ruoyi.common.utils.CommonUtils;
 import com.ruoyi.common.utils.StringUtils;
 import com.ruoyi.common.utils.http.HttpUtils;
 import com.sun.org.apache.xpath.internal.operations.Bool;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.i18n.LocaleContextHolder;
 import org.springframework.stereotype.Service;
 import com.ruoyi.common.core.text.Convert;
 
@@ -21,6 +24,7 @@ import com.ruoyi.common.core.text.Convert;
  * @date 2025-04-21
  */
 @Service
+@Slf4j
 public class CcParamsServiceImpl implements ICcParamsService
 {
     @Autowired
@@ -132,9 +136,16 @@ public class CcParamsServiceImpl implements ICcParamsService
 
     @Override
     public String getParamValueByCode(String paramCode, String defaultValue) {
-        List<CcParams> list = ccParamsMapper.selectCcParamsList(new CcParams().setParamCode(paramCode));
+        Locale locale = LocaleContextHolder.getLocale();
+        // 先找对应语言包的配置,没有的话再找通用配置
+        List<CcParams> list = ccParamsMapper.selectCcParamsList(new CcParams().setParamCode(paramCode + "_" + locale.toString()));
         if (list.size() > 0) {
             return list.get(0).getParamValue();
+        } else {
+            list = ccParamsMapper.selectCcParamsList(new CcParams().setParamCode(paramCode));
+            if (list.size() > 0) {
+                return list.get(0).getParamValue();
+            }
         }
         return defaultValue;
     }

+ 69 - 1
ruoyi-admin/src/main/java/com/ruoyi/cc/service/impl/FsConfServiceImpl.java

@@ -5,9 +5,12 @@ import com.alibaba.fastjson.JSONObject;
 import com.ruoyi.cc.model.FsConfProfile;
 import com.ruoyi.cc.service.ICcParamsService;
 import com.ruoyi.cc.service.IFsConfService;
+import com.ruoyi.cc.utils.ShellUtil;
 import com.ruoyi.common.utils.CommonUtils;
 import com.ruoyi.common.utils.ExceptionUtil;
 import com.ruoyi.common.utils.StringUtils;
+import link.thingscloud.freeswitch.esl.EslConnectionUtil;
+import link.thingscloud.freeswitch.esl.transport.message.EslMessage;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Value;
@@ -478,6 +481,50 @@ public class FsConfServiceImpl implements IFsConfService {
         }
     }
 
+
+
+    @Override
+    public String getLicenseValue() {
+        String fsConfDirectory = ccParamsService.getParamValueByCode("fs_conf_directory", "");
+        String fileName = fsConfDirectory + "/autoload_configs/license.conf";
+        StringBuilder content = new StringBuilder();
+        if (!new File(fileName).exists()) {
+            return "";
+        }
+        try (BufferedReader br = new BufferedReader(new FileReader(fileName))) {
+            String line;
+            while ((line = br.readLine()) != null) {
+                content.append(line).append("\n"); // 追加每一行内容,并添加换行符
+            }
+        } catch (IOException e) {
+            e.printStackTrace();
+        }
+        return content.toString();
+    }
+
+    @Override
+    public void setLicenseConf(String licenseValue) {
+        String fsConfDirectory = ccParamsService.getParamValueByCode("fs_conf_directory", "");
+        String fileName = fsConfDirectory + "/autoload_configs/license.conf";
+        if (!new File(fileName).exists()) {
+            try {
+                new File(fileName).createNewFile();
+            } catch (IOException e) {
+                e.printStackTrace();
+            }
+        }
+        try (BufferedWriter bw = new BufferedWriter(new FileWriter(fileName))) {
+            bw.write(licenseValue);
+        } catch (IOException e) {
+            e.printStackTrace();
+        }
+    }
+
+    @Override
+    public JSONObject getLicenseInfo(){
+        return JSONObject.parseObject(ccParamsService.getParamValueByCode("system_license_info", "{}"));
+    }
+
     @Override
     public String getLogs(String uuid, String logFile, String logType) {
         log.info(logFile);
@@ -486,7 +533,11 @@ public class FsConfServiceImpl implements IFsConfService {
         List<String> commands = new ArrayList<>();
         commands.add("sh");
         commands.add("-c");
-        commands.add("cat " + logFile + " | grep '" + uuid + "'");
+        if (StringUtils.isBlank(uuid)) {
+            commands.add("cat " + logFile);
+        } else {
+            commands.add("cat " + logFile + " | grep '" + uuid + "'");
+        }
         log.info(StringUtils.join(commands.toArray(), " "));
         String logs = "";
         try {
@@ -703,6 +754,18 @@ public class FsConfServiceImpl implements IFsConfService {
         setGwConf(fsConfDirectory + "/template/MRWG0.xml", orginProfileName, profileName, gwName, params);
     }
 
+    @Override
+    public String getFingerprintValue() {
+        String cmd = ccParamsService.getParamValueByCode("system_hw_fingerprint_cmd", "");
+        try {
+            String fingerprintValue = ShellUtil.exec(cmd);
+            return fingerprintValue;
+        } catch (Exception e) {
+            log.error(ExceptionUtil.getExceptionMessage(e));
+            return e.getMessage();
+        }
+    }
+
     private void setGwConf(String gwTemplate, String orginProfileName, String profileName, String gwName, JSONObject params) {
         String fsConfDirectory = ccParamsService.getParamValueByCode("fs_conf_directory", "");
         try {
@@ -715,6 +778,11 @@ public class FsConfServiceImpl implements IFsConfService {
                 String orignGatewayXmlPath = fsConfDirectory + "/sip_profiles/" + orginProfileName + "/" + gwName + ".xml";
                 try {
                     Files.delete(Paths.get(orignGatewayXmlPath));
+                    // 删除文件后要kill掉网关
+                    EslMessage eslMessage1 = EslConnectionUtil.sendSyncApiCommand("sofia", "profile " + orginProfileName + " killgw " + gwName);
+                    if (null != eslMessage1) {
+                        log.info(StringUtils.joinWith("/r/n", eslMessage1.getBodyLines().toArray()));
+                    }
                     log.info("文件删除成功!");
                 } catch (Exception e) {
                     log.error("删除文件失败:{}", orignGatewayXmlPath);

+ 74 - 47
ruoyi-admin/src/main/java/com/ruoyi/cc/task/CustIntentionHandleQueue.java

@@ -1,11 +1,13 @@
 package com.ruoyi.cc.task;
 
+import com.alibaba.fastjson.JSONObject;
 import com.ruoyi.aicall.domain.CcCallPhone;
 import com.ruoyi.aicall.domain.CcCallTask;
 import com.ruoyi.aicall.domain.CcLlmAgentAccount;
 import com.ruoyi.aicall.service.ICcCallPhoneService;
 import com.ruoyi.aicall.service.ICcCallTaskService;
 import com.ruoyi.aicall.service.ICcLlmAgentAccountService;
+import com.ruoyi.common.utils.ExceptionUtil;
 import com.ruoyi.common.utils.StringUtils;
 import com.ruoyi.common.utils.spring.SpringUtils;
 import org.slf4j.Logger;
@@ -36,59 +38,84 @@ public class CustIntentionHandleQueue {
 	private static void CustIntentionHandleQueue(){
 		log.info("已经启动客户意向跑批线程!");
 		while(true) {
-			ICcCallPhoneService ccCallPhoneService = SpringUtils.getBean(ICcCallPhoneService.class);
-			ICcLlmAgentAccountService llmAgentAccountService = SpringUtils.getBean(ICcLlmAgentAccountService.class);
-			ICcCallTaskService ccCallTaskService = SpringUtils.getBean(ICcCallTaskService.class);
+			try {
+				ICcCallPhoneService ccCallPhoneService = SpringUtils.getBean(ICcCallPhoneService.class);
+				ICcLlmAgentAccountService llmAgentAccountService = SpringUtils.getBean(ICcLlmAgentAccountService.class);
+				ICcCallTaskService ccCallTaskService = SpringUtils.getBean(ICcCallTaskService.class);
 
-			// 1. 获取所有的已挂机未跑批客户意向的通话记录
-			List<CcCallPhone> ccCallPhoneList = ccCallPhoneService.getCustIntentionList();
-			log.info("run cust intentions phone records size:{}", ccCallPhoneList.size());
-			if (ccCallPhoneList.size() > 0) {
-				Map<Integer, CcLlmAgentAccount> intentionTipsMap = new HashMap<>(); // key:llmAccountId    value:intentionTips
-				Map<Long, Integer> llmAccountIdMap = new HashMap<>(); // key:batchId    value:llmAccountId
-				Map<String, List<String>> intentionMap = new HashMap<>(); // key:intention    value:phoneIdList
-				for (CcCallPhone ccCallPhone: ccCallPhoneList) {
-					// 2. 获取对应任务的对应大模型配置的客户意向提示词
-					Long batchId = ccCallPhone.getBatchId();
-					Integer llmAccountId = llmAccountIdMap.get(batchId);
-					if (null == llmAccountId) {
-						CcCallTask ccCallTask = ccCallTaskService.selectCcCallTaskByBatchId(batchId);
-						if (null != ccCallTask) {
-							llmAccountId = ccCallTask.getLlmAccountId();
-							llmAccountIdMap.put(batchId, llmAccountId);
-						}
-					}
-					CcLlmAgentAccount ccLlmAgentAccount = intentionTipsMap.get(llmAccountId);
-					if (null == ccLlmAgentAccount) {
-						ccLlmAgentAccount = llmAgentAccountService.selectCcLlmAgentAccountById(llmAccountId);
-						if (null == ccLlmAgentAccount) {
-							ccLlmAgentAccount = new CcLlmAgentAccount();
-							ccLlmAgentAccount.setIntentionTips("");
+				// 1. 获取所有的已挂机未跑批客户意向的通话记录
+				List<CcCallPhone> ccCallPhoneList = ccCallPhoneService.getCustIntentionList();
+				log.info("run cust intentions phone records size:{}", ccCallPhoneList.size());
+				if (ccCallPhoneList.size() > 0) {
+					Map<Integer, CcLlmAgentAccount> intentionTipsMap = new HashMap<>(); // key:llmAccountId    value:intentionTips
+					Map<Long, Integer> llmAccountIdMap = new HashMap<>(); // key:batchId    value:llmAccountId
+					Map<String, List<String>> intentionMap = new HashMap<>(); // key:intention    value:phoneIdList
+					for (CcCallPhone ccCallPhone: ccCallPhoneList) {
+						try {
+							// 2. 获取对应任务的对应大模型配置的客户意向提示词
+							Long batchId = ccCallPhone.getBatchId();
+							Integer llmAccountId = llmAccountIdMap.get(batchId);
+							CcCallTask ccCallTask = null;
+							if (null == llmAccountId) {
+								ccCallTask = ccCallTaskService.selectCcCallTaskByBatchId(batchId);
+								if (null != ccCallTask) {
+									log.info("run cust intentions ccCallTask:{}-{}", ccCallPhone.getTelephone(), ccCallTask.getBatchId());
+									llmAccountId = ccCallTask.getLlmAccountId();
+									llmAccountIdMap.put(batchId, llmAccountId);
+								} else {
+									log.info("run cust intentions ccCallTask:{}-null", ccCallPhone.getTelephone());
+								}
+							}
+
+							// 3. 未接通的标记为“未接通”,接通的根据提示词和对话内容调用大模型获取客户意向,如果没有提示词,默认客户意向为-
+							String intention = "-";
+							String intentionTips = "";
+							if (StringUtils.isBlank(ccCallPhone.getDialogue()) || ccCallPhone.getDialogue().equals("[]")) {
+								intention = "未接通";
+							} else if (null != ccCallTask && ccCallTask.getTaskType() == 1) {
+								CcLlmAgentAccount ccLlmAgentAccount = intentionTipsMap.get(llmAccountId);
+								if (null == ccLlmAgentAccount) {
+									ccLlmAgentAccount = llmAgentAccountService.selectCcLlmAgentAccountById(llmAccountId);
+									if (null == ccLlmAgentAccount) {
+										ccLlmAgentAccount = new CcLlmAgentAccount();
+										ccLlmAgentAccount.setIntentionTips("");
+									}
+									log.info("run cust intentions ccLlmAgentAccount:{}-{}", ccCallPhone.getTelephone(), ccLlmAgentAccount.getId());
+									intentionTipsMap.put(llmAccountId, ccLlmAgentAccount);
+								}
+								// 如果是对接本地模型,这里需要修改
+								if ("DeepSeekChat".equals(ccLlmAgentAccount.getProviderClassName())) {
+									intentionTips = ccLlmAgentAccount.getIntentionTips();
+								}
+								if (StringUtils.isBlank(intentionTips)) {
+									intention = "-";
+								} else {
+									log.info("run cust intentions getIntenetionByDialogue:{}-{}", ccCallPhone.getTelephone(), ccLlmAgentAccount.getId());
+									intention = ccCallPhoneService.getIntenetionByDialogue(ccLlmAgentAccount, ccCallPhone.getDialogue());
+								}
+							}
+							List<String> phoneIds = intentionMap.getOrDefault(intention, new ArrayList<>());
+							phoneIds.add(ccCallPhone.getId());
+							intentionMap.put(intention, phoneIds);
+
+						} catch (Exception e) {
+							log.error("跑批客户意向异常:uuid:{}-msg:{}", ccCallPhone.getUuid(), ExceptionUtil.getExceptionMessage(e));
 						}
-						intentionTipsMap.put(llmAccountId, ccLlmAgentAccount);
-					}
-					String intentionTips = ccLlmAgentAccount.getIntentionTips();
 
-					// 3. 未接通的标记为“未接通”,接通的根据提示词和对话内容调用大模型获取客户意向,如果没有提示词,默认客户意向为-
-					String intention = "";
-					if (StringUtils.isBlank(ccCallPhone.getDialogue()) || ccCallPhone.getDialogue().equals("[]")) {
-						intention = "未接通";
-					} else if (StringUtils.isBlank(intentionTips)) {
-						intention = "-";
-					} else {
-						intention = ccCallPhoneService.getIntenetionByDialogue(ccLlmAgentAccount, ccCallPhone.getDialogue());
 					}
-					List<String> phoneIds = intentionMap.getOrDefault(intention, new ArrayList<>());
-					phoneIds.add(ccCallPhone.getId());
-					intentionMap.put(intention, phoneIds);
-				}
-				for (String intention: intentionMap.keySet()) {
-					List<String> phoneIds = intentionMap.getOrDefault(intention, new ArrayList<>());
-					if (phoneIds.size() > 0) {
-						ccCallPhoneService.updateIntentionByIds(intention, phoneIds);
+					for (String intention: intentionMap.keySet()) {
+						try {
+							List<String> phoneIds = intentionMap.getOrDefault(intention, new ArrayList<>());
+							if (phoneIds.size() > 0) {
+								ccCallPhoneService.updateIntentionByIds(intention, phoneIds);
+							}
+						} catch (Exception e) {
+							log.error("客户意向更新入库异常:{}", intention);
+						}
 					}
 				}
-
+			} catch (Exception e) {
+				log.error("客户意向跑批异常:{}", ExceptionUtil.getExceptionMessage(e));
 			}
 			try {
 				Thread.sleep(60*1000L); // 每隔1分钟跑一次

+ 10 - 0
ruoyi-admin/src/main/java/com/ruoyi/cc/utils/CmdExecException.java

@@ -0,0 +1,10 @@
+package com.ruoyi.cc.utils;
+
+public class CmdExecException extends RuntimeException {
+    private final int exitCode;
+    public CmdExecException(int exitCode, String message) {
+        super(message);
+        this.exitCode = exitCode;
+    }
+    public int getExitCode() { return exitCode; }
+}

+ 52 - 0
ruoyi-admin/src/main/java/com/ruoyi/cc/utils/ShellUtil.java

@@ -0,0 +1,52 @@
+package com.ruoyi.cc.utils;
+
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.exec.*;
+
+import java.io.ByteArrayOutputStream;
+import java.util.Arrays;
+
+@Slf4j
+public class ShellUtil {
+    /** 整串指令 -> 输出文本;超 30s 抛异常 */
+    public static String exec(String wholeCmd) throws Exception {
+        // 1. 自动拆成数组,支持引号、转义
+        log.info("wholeCmd:{}", wholeCmd);
+        CommandLine cl = CommandLine.parse(wholeCmd);
+        log.info("CommandLine: " + Arrays.toString(cl.toStrings()));
+
+        // 2. 执行并捕获输出
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        ByteArrayOutputStream err = new ByteArrayOutputStream();
+
+        Executor exec = new DefaultExecutor();
+        exec.setStreamHandler(new PumpStreamHandler(out, err));
+        exec.setWatchdog(new ExecuteWatchdog(30_000)); // 30s 超时
+        try {
+            int exit = exec.execute(cl);
+            // 0 表示成功
+            return out.toString("UTF-8");
+        } catch (Exception e) {
+            /* --- 3. 组装统一错误信息 --- */
+            int exitCode = -1;
+            if (e instanceof ExecuteException) {
+                exitCode = ((ExecuteException) e).getExitValue();
+            }
+            String stdout = out.toString("UTF-8");
+            String stderr = err.toString("UTF-8");
+
+            // 把 docker 原始报错完整带出去
+            String msg = String.format("Command failed (exit=%d)%nstdout:%n%s%nstderr:%n%s",
+                    exitCode, stdout, stderr);
+            throw new CmdExecException(exitCode, msg);
+        }
+    }
+
+    /* ---------- 使用 ---------- */
+    public static void main(String[] args) throws Exception {
+        String cmd = "docker exec -i freeswitch-debian12 " +
+                "/usr/local/freeswitchvideo/bin/hw_fingerprint -v";
+        String result = exec(cmd);
+        System.out.println(result);
+    }
+}

+ 22 - 3
ruoyi-admin/src/main/java/com/ruoyi/cc/utils/Test.java

@@ -1,10 +1,29 @@
 package com.ruoyi.cc.utils;
 
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+
 public class Test {
 
+    /** 每小时秒数 */
+    private static final BigDecimal SECONDS_PER_HOUR = BigDecimal.valueOf(3600);
+    /** ASR 单价:元/秒 */
+    private static final BigDecimal priceAsrAli = new BigDecimal("0.00015");
+
+    private static BigDecimal calTotalCost(Integer asrSeconds, Integer ttsTimes, Integer ttsFlowTokens, Integer inputTokens, Integer outputTokens) {
+        BigDecimal priceAsrAli = new BigDecimal("0.00015");
+        BigDecimal totalCost = new BigDecimal("0.0");
+        // asr费用 = asrSeconds/3600 * priceAsrAli
+        if (asrSeconds != null && asrSeconds > 0) {
+            BigDecimal hours = BigDecimal.valueOf(asrSeconds)
+                    .divide(SECONDS_PER_HOUR, 10, RoundingMode.HALF_UP);
+            totalCost = totalCost.add(hours.multiply(priceAsrAli));
+        }
+        return totalCost;
+    }
+
     public static void main(String[] args) {
-        String ccLogFiles = "/home/ipcc/log/call-center.log";
-        String ccHisLogFiles = ccLogFiles.replaceAll("\\.log$", "*.log");
-        System.out.println(ccHisLogFiles);
+        System.out.println(calTotalCost(350, 12, 888, 12345, 23456));
     }
+
 }

+ 18 - 4
ruoyi-admin/src/main/java/com/ruoyi/web/controller/monitor/ServerController.java

@@ -1,6 +1,9 @@
 package com.ruoyi.web.controller.monitor;
 
+import com.ruoyi.cc.service.ICcParamsService;
+import com.ruoyi.common.utils.StringUtils;
 import org.apache.shiro.authz.annotation.RequiresPermissions;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Controller;
 import org.springframework.ui.ModelMap;
 import org.springframework.web.bind.annotation.GetMapping;
@@ -17,15 +20,26 @@ import com.ruoyi.framework.web.domain.Server;
 @RequestMapping("/monitor/server")
 public class ServerController extends BaseController
 {
+
+    @Autowired
+    private ICcParamsService ccParamsService;
+
     private String prefix = "monitor/server";
 
     @RequiresPermissions("monitor:server:view")
     @GetMapping()
     public String server(ModelMap mmap) throws Exception
     {
-        Server server = new Server();
-        server.copyTo();
-        mmap.put("server", server);
-        return prefix + "/server";
+        // http://192.168.67.238:19999/api/v1/data
+        String netdataApiBase = ccParamsService.getParamValueByCode("netdata_api_base", "");
+        if  (StringUtils.isNotEmpty(netdataApiBase)) {
+            mmap.put("netdataApiBase", netdataApiBase);
+            return prefix + "/system_io";
+        } else {
+            Server server = new Server();
+            server.copyTo();
+            mmap.put("server", server);
+            return prefix + "/server";
+        }
     }
 }

+ 7 - 8
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysIndexController.java

@@ -184,7 +184,10 @@ public class SysIndexController extends BaseController
 
         String extnum = ccExtNum.getExtNum().toString();
         String opnum = ccExtNum.getUserCode();
-        String groupId = "1";
+        String groupId = (String) CacheUtils.get("groupId-" + currentUser.getLoginName());
+        if (StringUtils.isBlank(groupId)) {
+            groupId = "1";
+        }
         String skillLevel = "9";
         String projectId = "1";
         String loginToken = ccExtNumService.createToken(extnum, opnum, groupId, skillLevel, projectId);
@@ -203,14 +206,10 @@ public class SysIndexController extends BaseController
             configGateway.put("callProfile", ccGateways.getProfileName());
             configGateway.put("priority", ccGateways.getPriority());
             configGateway.put("concurrency", ccGateways.getMaxConcurrency());
-            if (ccGateways.getRegister() == 1) {
-                configGateway.put("register", true);
-                configGateway.put("authUsername", ccGateways.getAuthUsername());
-                configGateway.put("authPassword", ccGateways.getAuthPassword());
-            } else {
-                configGateway.put("register", false);
-            }
+            configGateway.put("register", ccGateways.getRegister());
+            configGateway.put("authUsername", ccGateways.getAuthUsername());
             configGateway.put("audioCodec", ccGateways.getCodec());
+            configGateway.put("gwName", ccGateways.getGwName());
             gatewayList.add(configGateway);
         }
         JSONObject callConfig = new JSONObject();

+ 11 - 2
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysLoginController.java

@@ -5,6 +5,7 @@ import javax.servlet.http.HttpServletResponse;
 
 import com.ruoyi.cc.domain.CcExtNum;
 import com.ruoyi.cc.service.ICcExtNumService;
+import com.ruoyi.common.utils.CacheUtils;
 import org.apache.shiro.SecurityUtils;
 import org.apache.shiro.authc.AuthenticationException;
 import org.apache.shiro.authc.UsernamePasswordToken;
@@ -43,6 +44,12 @@ public class SysLoginController extends BaseController
     @Value("${sysconfig.sysVersion}")
     private String sysVersion;
 
+    /**
+     * 是否开启登陆时选择业务组
+     */
+    @Value("${sysconfig.show-dynamic-groupid: false}")
+    private boolean showDynamicGroupid;
+
     @Autowired
     private ConfigService configService;
     @Autowired
@@ -60,15 +67,16 @@ public class SysLoginController extends BaseController
         mmap.put("isRemembered", rememberMe);
         // 是否开启用户注册
         mmap.put("isAllowRegister", Convert.toBool(configService.getKey("sys.account.registerUser"), false));
-
         // 版本号
         mmap.put("sysVersion", sysVersion);
+        // 是否开启登陆时选择业务组
+        mmap.put("showDynamicGroupid", showDynamicGroupid);
         return "login";
     }
 
     @PostMapping("/login")
     @ResponseBody
-    public AjaxResult ajaxLogin(String username, String password, String extNum, Boolean rememberMe)
+    public AjaxResult ajaxLogin(String username, String password, String extNum, String groupId, Boolean rememberMe)
     {
         // 校验分机号
         CcExtNum ccExtNum = ccExtNumService.selectCcExtNumByUserCode(username);
@@ -78,6 +86,7 @@ public class SysLoginController extends BaseController
         if (!ccExtNum.getExtNum().toString().equals(extNum)) {
             return AjaxResult.error("分机号不正确!");
         }
+        CacheUtils.put("groupId-" + username, groupId);
         UsernamePasswordToken token = new UsernamePasswordToken(username, password, rememberMe);
         Subject subject = SecurityUtils.getSubject();
         try

+ 97 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/core/config/LuaDbCheckRunner.java

@@ -0,0 +1,97 @@
+package com.ruoyi.web.core.config;
+
+import com.ruoyi.cc.service.ICcParamsService;
+import com.ruoyi.common.utils.ExceptionUtil;
+import com.ruoyi.common.utils.StringUtils;
+import com.ruoyi.system.service.ISysConfigService;
+import link.thingscloud.freeswitch.esl.EslConnectionUtil;
+import link.thingscloud.freeswitch.esl.transport.message.EslMessage;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.io.FileUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.ApplicationArguments;
+import org.springframework.boot.ApplicationRunner;
+import org.springframework.stereotype.Component;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * 自动更新/home/freeswitch/share/freeswitch/scripts/auth_user.lua 中的数据库连接
+ */
+@Component
+@Slf4j
+public class LuaDbCheckRunner implements ApplicationRunner {
+
+    @Value("${spring.datasource.druid.master.url}")
+    private String dbUrl;
+    @Value("${spring.datasource.druid.master.username}")
+    private String dbUsername;
+    @Value("${spring.datasource.druid.master.password}")
+    private String dbPassword;
+    @Autowired
+    private ICcParamsService ccParamsService;
+
+    @Override
+    public void run(ApplicationArguments args) throws Exception {
+
+        // 获取数据库连接参数
+        String mysqlIp = "";
+        String mysqlPort = "";
+        String databaseName = "";
+
+        // 使用正则表达式解析URL
+        // 匹配:jdbc:mysql://IP:PORT/DBNAME?...
+        String regex = "jdbc:mysql://([^:]+):(\\d+)/([^?]+)";
+        Pattern pattern = Pattern.compile(regex);
+        Matcher matcher = pattern.matcher(dbUrl);
+
+        if (matcher.find()) {
+            mysqlIp = matcher.group(1);
+            mysqlPort = matcher.group(2);
+            databaseName = matcher.group(3);
+        } else {
+            log.warn("无法解析URL: {}", dbUrl);
+        }
+        log.info("mysqlIp:{}, mysqlPort:{},databaseName:{},dbUsername:{},dbPassword:{}", mysqlIp, mysqlPort, databaseName, dbUsername, dbPassword);
+        String dbh = String.format("mariadb://Server=%s;Port=%s;Database=%s;Uid=%s;Pwd=%s;", mysqlIp, mysqlPort, databaseName, dbUsername, dbPassword);
+        // 获取
+        String luaScriptPath = ccParamsService.getParamValueByCode("freeswitch_root", "/home/freeswitch") + "/share/freeswitch/scripts";
+        String templateFile = luaScriptPath + "/auth_user_template.lua";
+        String luaFile = luaScriptPath + "/auth_user.lua";
+        try {
+            // 3. 读取模板文件内容
+            File template = new File(templateFile);
+            if (!template.exists()) {
+                log.error("模板文件不存在: " + templateFile);
+                return;
+            }
+
+            String content = FileUtils.readFileToString(template, StandardCharsets.UTF_8);
+
+            // 4. 替换占位符 ${_DB_URL} 为实际的 _dbUrl
+            String replacedContent = content.replace("${_DB_URL}", dbh);
+
+            // 5. 将替换后的内容写入目标文件(覆盖原有内容)
+            File targetFile = new File(luaFile);
+            FileUtils.writeStringToFile(targetFile, replacedContent, StandardCharsets.UTF_8, false);
+
+            log.info("Lua脚本生成成功: " + luaFile);
+            log.info("Lua脚本数据库URL已替换为: " + dbh);
+
+        } catch (IOException e) {
+            log.error("生成Lua脚本失败: " + e.getMessage());
+            return;
+        }
+
+
+
+    }
+}

+ 3 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/core/config/VersionCheckRunner.java

@@ -9,6 +9,9 @@ import org.springframework.boot.ApplicationRunner;
 import org.springframework.boot.SpringApplication;
 import org.springframework.stereotype.Component;
 
+/**
+ * 版本号校验
+ */
 @Component
 @Slf4j
 public class VersionCheckRunner implements ApplicationRunner {

+ 4 - 1
ruoyi-admin/src/main/resources/application-dev.yml

@@ -203,5 +203,8 @@ sysconfig:
   # 是否开启敏感参数隐藏功能
   hide-secret: true
   # 系统版本号
-  sysVersion: v20250823
+  sysVersion: v20260217
+  # 是否开启登陆时选择业务组
+  show-dynamic-groupid: true
+
 

+ 3 - 1
ruoyi-admin/src/main/resources/application-local.yml

@@ -208,5 +208,7 @@ sysconfig:
   # 是否开启敏感参数隐藏功能
   hide-secret: true
   # 系统版本号
-  sysVersion: v20250823
+  sysVersion: v20260217
+  # 是否开启登陆时选择业务组
+  show-dynamic-groupid: true
 

+ 3 - 1
ruoyi-admin/src/main/resources/application-pro.yml

@@ -204,4 +204,6 @@ sysconfig:
   # 是否开启敏感参数隐藏功能
   hide-secret: true
   # 系统版本号
-  sysVersion: v20250823
+  sysVersion: v20260217
+  # 是否开启登陆时选择业务组
+  show-dynamic-groupid: true

+ 4 - 2
ruoyi-admin/src/main/resources/application-test.yml

@@ -7,7 +7,7 @@ spring:
     druid:
       # 主库数据源
       master:
-        url: jdbc:mysql://192.168.67.228:3306/easycallcenter365?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
+        url: jdbc:mysql://192.168.67.238:3306/easycallcenter365?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
         username: root
         password: tydic202x888
       # 从库数据源
@@ -205,4 +205,6 @@ sysconfig:
   # 是否开启敏感参数隐藏功能
   hide-secret: true
   # 系统版本号
-  sysVersion: v20250823
+  sysVersion: v20260217
+  # 是否开启登陆时选择业务组
+  show-dynamic-groupid: true

+ 1 - 1
ruoyi-admin/src/main/resources/application.yml

@@ -1,3 +1,3 @@
 spring:
   profiles:
-    active: pro
+    active: test

+ 55 - 6
ruoyi-admin/src/main/resources/mapper/aicall/CcCallPhoneMapper.xml

@@ -30,10 +30,26 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="acdWaitTime"    column="dialogue_count"    />
         <result property="ttsText"    column="tts_text"    />
         <result property="intent"    column="intent"    />
+        <result property="asrSeconds"    column="asr_seconds"    />
+        <result property="ttsTimes"    column="tts_times"    />
+        <result property="ttsFlowTokens"    column="tts_flow_tokens"    />
+        <result property="inputTokens"    column="input_tokens"    />
+        <result property="outputTokens"    column="output_tokens"    />
+        <result property="totalCost"    column="total_cost"    />
+        <result property="billingStatus"    column="billing_status"    />
+        <result property="callerNumber"    column="caller_number"    />
+        <result property="ivrDtmfDigits"    column="ivr_dtmf_digits"    />
+        <result property="manualAnsweredTime"    column="manual_answered_time"    />
+        <result property="manualAnsweredTimeLen"    column="manual_answered_time_len"    />
     </resultMap>
 
     <sql id="selectCcCallPhoneVo">
-        select id, batch_id, telephone, cust_name, createtime, callstatus, callout_time, callcount, call_end_time, time_len, valid_time_len, uuid, connected_time, hangup_cause, answered_time, dialogue, wavfile, record_server_url, biz_json, dialogue_count, acd_opnum, acd_queue_time, acd_wait_time, tts_text, intent from cc_call_phone
+        select id, batch_id, telephone, cust_name, createtime, callstatus, callout_time,
+               callcount, call_end_time, time_len, valid_time_len, uuid, connected_time,
+               hangup_cause, answered_time, dialogue, wavfile, record_server_url, biz_json,
+               dialogue_count, acd_opnum, acd_queue_time, acd_wait_time, tts_text, intent,
+               asr_seconds, tts_times, tts_flow_tokens, input_tokens, output_tokens,
+               total_cost, billing_status, caller_number, ivr_dtmf_digits, manual_answered_time, manual_answered_time_len from cc_call_phone
     </sql>
 
     <select id="selectCcCallPhoneList" parameterType="CcCallPhone" resultMap="CcCallPhoneResult">
@@ -80,6 +96,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="intent != null and intent != ''">
                 AND intent = #{intent}
             </if>
+            <if test="billingStatus != null ">
+                and billing_status = #{billingStatus}
+            </if>
+            <if test="callerNumber != null and callerNumber != '' ">
+                and caller_number = #{callerNumber}
+            </if>
             AND callstatus >= 3 AND call_end_time > 0
         </where>
         order by call_end_time desc
@@ -87,7 +109,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
 
     <select id="getCustIntentionList" parameterType="CcCallPhone" resultMap="CcCallPhoneResult">
         <include refid="selectCcCallPhoneVo"/>
-          where intent = '' AND callstatus >= 3 AND call_end_time > 0 limit 1000
+          where intent = '' AND callstatus >= 3 AND call_end_time > 0 limit 100
     </select>
     
     <select id="selectCcCallPhoneById" parameterType="String" resultMap="CcCallPhoneResult">
@@ -122,6 +144,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="acdQueueTime != null">acd_queue_time,</if>
             <if test="acdWaitTime != null">acd_wait_time,</if>
             <if test="ttsText != null">tts_text,</if>
+            <if test="intent != null">intent,</if>
         </trim>
         <trim prefix="values (" suffix=")" suffixOverrides=",">
             <if test="id != null">#{id},</if>
@@ -146,8 +169,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="dialogueCount != null">#{dialogueCount},</if>
             <if test="acdOpnum != null and acdOpnum != ''">#{acdOpnum},</if>
             <if test="acdQueueTime != null">#{acdQueueTime},</if>
-            #{acdWaitTime},
-            #{ttsText},
+            <if test="acdWaitTime != null">#{acdWaitTime},</if>
+            <if test="ttsText != null">#{ttsText},</if>
+            <if test="intent != null">#{intent},</if>
         </trim>
     </insert>
 
@@ -177,6 +201,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="acdQueueTime != null">acd_queue_time = #{acdQueueTime},</if>
             <if test="acdWaitTime != null">acd_wait_time = #{acdWaitTime},</if>
             <if test="ttsText != null">tts_text = #{ttsText},</if>
+            <if test="intent != null">intent = #{intent},</if>
         </trim>
         where id = #{id}
     </update>
@@ -208,7 +233,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         callout_time, callcount, call_end_time, time_len, valid_time_len,
         uuid, connected_time, hangup_cause,
         answered_time, dialogue, wavfile, record_server_url, biz_json,
-        dialogue_count,acd_opnum,acd_queue_time,acd_wait_time, tts_text
+        dialogue_count,acd_opnum,acd_queue_time,acd_wait_time,
+        tts_text, intent
         )
         VALUES
         <foreach collection="list" item="item" separator=",">
@@ -220,7 +246,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             #{item.connectedTime}, #{item.hangupCause},
             #{item.answeredTime}, #{item.dialogue}, #{item.wavfile},
             #{item.recordServerUrl}, #{item.bizJson}, #{item.dialogueCount},
-            #{item.acdOpnum}, #{item.acdQueueTime}, #{item.acdWaitTime}, #{item.ttsText}
+            #{item.acdOpnum}, #{item.acdQueueTime}, #{item.acdWaitTime},
+            #{item.ttsText}, #{item.intent}
             )
         </foreach>
     </insert>
@@ -233,4 +260,26 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             #{id}
         </foreach>
     </update>
+
+
+
+    <select id="selectNoHangupCalls" parameterType="Long" resultMap="CcCallPhoneResult">
+        <include refid="selectCcCallPhoneVo"/>
+        <where>
+            AND batch_id = #{batchId}
+            AND callstatus >= 2
+            AND call_end_time = 0
+        </where>
+        order by call_end_time desc
+    </select>
+
+
+    <insert id="bakCallPhoneByBatchId" parameterType="Long">
+        insert into his_cc_call_phone select * from cc_call_phone where batch_id = #{batchId}
+    </insert>
+
+
+    <delete id="delCallPhoneByBatchId" parameterType="Long">
+        delete from cc_call_phone where batch_id = #{batchId}
+    </delete>
 </mapper>

+ 31 - 2
ruoyi-admin/src/main/resources/mapper/aicall/CcCallTaskMapper.xml

@@ -25,15 +25,21 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="callNodeNo"    column="call_node_no"    />
         <result property="llmAccountId"    column="llm_account_id"    />
         <result property="playTimes"    column="play_times"    />
+        <result property="asrProvider"    column="asr_provider"    />
+        <result property="aiTransferType"    column="ai_transfer_type"    />
+        <result property="aiTransferData"    column="ai_transfer_data"    />
+        <result property="autoStop"    column="auto_stop"    />
+        <result property="ivrId"    column="ivr_id"    />
     </resultMap>
 
     <sql id="selectCcCallTaskVo">
-        select batch_id, group_id, batch_name, ifcall, rate, thread_num, createtime, executing, stop_time, userid, task_type, gateway_id, voice_code, voice_source, avg_ring_time_len, avg_call_talk_time_len, avg_call_end_process_time_len, call_node_no, llm_account_id, play_times from cc_call_task
+        select batch_id, group_id, batch_name, ifcall, rate, thread_num, createtime, executing, stop_time, userid, task_type, gateway_id, voice_code, voice_source, avg_ring_time_len, avg_call_talk_time_len, avg_call_end_process_time_len, call_node_no, llm_account_id, play_times, asr_provider, ai_transfer_type, ai_transfer_data, auto_stop, ivr_id from cc_call_task
     </sql>
 
     <select id="selectCcCallTaskList" parameterType="CcCallTask" resultMap="CcCallTaskResult">
         <include refid="selectCcCallTaskVo"/>
-        <where>  
+        <where>
+            <if test="batchId != null "> and batch_id = #{batchId}</if>
             <if test="groupId != null  and groupId != ''"> and group_id = #{groupId}</if>
             <if test="batchName != null  and batchName != ''"> and batch_name like concat('%', #{batchName}, '%')</if>
             <if test="ifcall != null "> and ifcall = #{ifcall}</if>
@@ -48,6 +54,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="params.createTimeEnd != null and params.createTimeEnd != ''"><!-- 结束时间检索 -->
                 AND createtime &lt;= #{params.createTimeEnd}
             </if>
+            <if test="asrProvider != null and asrProvider != ''" > and asr_provider = #{asrProvider}</if>
+            <if test="aiTransferType != null and aiTransferType != ''" > and ai_transfer_type = #{aiTransferType}</if>
+            <if test="aiTransferData != null and aiTransferData != ''" > and ai_transfer_data = #{aiTransferData}</if>
         </where>
         order by batch_id desc
     </select>
@@ -79,6 +88,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="callNodeNo != null">call_node_no,</if>
             <if test="llmAccountId != null">llm_account_id,</if>
             <if test="playTimes != null">play_times,</if>
+            <if test="asrProvider != null and asrProvider != ''">asr_provider,</if>
+            <if test="aiTransferType != null and aiTransferType != ''">ai_transfer_type,</if>
+            <if test="aiTransferData != null and aiTransferData != ''">ai_transfer_data,</if>
+            <if test="autoStop != null ">auto_stop,</if>
+            <if test="ivrId != null and ivrId != ''">ivr_id,</if>
         </trim>
         <trim prefix="values (" suffix=")" suffixOverrides=",">
             <if test="groupId != null and groupId != ''">#{groupId},</if>
@@ -100,6 +114,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="callNodeNo != null">#{callNodeNo},</if>
             <if test="llmAccountId != null">#{llmAccountId},</if>
             <if test="playTimes != null">#{playTimes},</if>
+            <if test="asrProvider != null and asrProvider != ''">#{asrProvider},</if>
+            <if test="aiTransferType != null and aiTransferType != ''">#{aiTransferType},</if>
+            <if test="aiTransferData != null and aiTransferData != ''">#{aiTransferData},</if>
+            <if test="autoStop != null ">#{autoStop},</if>
+            <if test="ivrId != null and ivrId != ''">#{ivrId},</if>
         </trim>
     </insert>
 
@@ -125,6 +144,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="callNodeNo != null">call_node_no = #{callNodeNo},</if>
             <if test="llmAccountId != null">llm_account_id = #{llmAccountId},</if>
             <if test="playTimes != null">play_times = #{playTimes},</if>
+            <if test="asrProvider != null and asrProvider != ''">asr_provider = #{asrProvider},</if>
+            <if test="aiTransferType != null and aiTransferType != ''">ai_transfer_type = #{aiTransferType},</if>
+            <if test="aiTransferData != null and aiTransferData != ''">ai_transfer_data = #{aiTransferData},</if>
+            <if test="autoStop != null ">auto_stop = #{autoStop},</if>
+            <if test="ivrId != null and ivrId != ''">ivr_id = #{ivrId},</if>
+
         </trim>
         where batch_id = #{batchId}
     </update>
@@ -146,4 +171,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         where batch_name = #{batchName} and task_type = #{taskType}
     </select>
 
+    <insert id="bakCallTaskByBatchId" parameterType="Long">
+        insert into his_cc_call_task select * from cc_call_task where batch_id = #{batchId}
+    </insert>
+
 </mapper>

+ 50 - 27
ruoyi-admin/src/main/resources/mapper/aicall/CcInboundLlmAccountMapper.xml

@@ -7,15 +7,20 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
     <resultMap type="CcInboundLlmAccount" id="CcInboundLlmAccountResult">
         <result property="id"    column="id"    />
         <result property="llmAccountId"    column="llm_account_id"    />
+        <result property="inboundAlias"    column="inbound_alias"    />
         <result property="callee"    column="callee"    />
         <result property="voiceCode"    column="voice_code"    />
         <result property="voiceSource"    column="voice_source"    />
         <result property="serviceType"    column="service_type"    />
-        <result property="groupId"    column="group_id"    />
+        <result property="asrProvider"    column="asr_provider"    />
+        <result property="aiTransferType"    column="ai_transfer_type"    />
+        <result property="aiTransferData"    column="ai_transfer_data"    />
+        <result property="ivrId"    column="ivr_id"    />
+        <result property="satisfSurveyIvrId"    column="satisf_survey_ivr_id"    />
     </resultMap>
 
     <sql id="selectCcInboundLlmAccountVo">
-        select id, llm_account_id, callee, voice_code, voice_source, service_type, group_id from cc_inbound_llm_account
+        select id, llm_account_id, callee, voice_code, voice_source, service_type, asr_provider, ai_transfer_type, ai_transfer_data, ivr_id, satisf_survey_ivr_id, inbound_alias from cc_inbound_llm_account
     </sql>
 
     <select id="selectCcInboundLlmAccountList" parameterType="CcInboundLlmAccount" resultMap="CcInboundLlmAccountResult">
@@ -23,10 +28,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <where>  
             <if test="llmAccountId != null "> and llm_account_id = #{llmAccountId}</if>
             <if test="callee != null  and callee != ''"> and callee = #{callee}</if>
+            <if test="inboundAlias != null  and inboundAlias != ''"> and inbound_alias = #{inboundAlias}</if>
             <if test="voiceCode != null  and voiceCode != ''"> and voice_code = #{voiceCode}</if>
             <if test="voiceSource != null  and voiceSource != ''"> and voice_source = #{voiceSource}</if>
             <if test="serviceType != null  and serviceType != ''"> and service_type = #{serviceType}</if>
-            <if test="groupId != null "> and group_id = #{groupId}</if>
+            <if test="asrProvider != null  and asrProvider != ''"> and asr_provider = #{asrProvider}</if>
+            <if test="aiTransferType != null  and aiTransferType != ''"> and ai_transfer_type = #{aiTransferType}</if>
+            <if test="aiTransferData != null  and aiTransferData != ''"> and ai_transfer_data = #{aiTransferData}</if>
         </where>
         order by id desc
     </select>
@@ -39,35 +47,50 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
     <insert id="insertCcInboundLlmAccount" parameterType="CcInboundLlmAccount">
         insert into cc_inbound_llm_account
         <trim prefix="(" suffix=")" suffixOverrides=",">
-            id,
-            llm_account_id,
-            callee,
-            voice_code,
-            voice_source,
-            service_type,
-            group_id
-         </trim>
+            <if test="id != null">id,</if>
+            <if test="llmAccountId != null">llm_account_id,</if>
+            <if test="callee != null and callee != ''">callee,</if>
+            <if test="inboundAlias != null and inboundAlias != ''">inbound_alias,</if>
+            <if test="voiceCode != null and voiceCode != ''">voice_code,</if>
+            <if test="voiceSource != null and voiceSource != ''">voice_source,</if>
+            <if test="serviceType != null and serviceType != ''">service_type,</if>
+            <if test="asrProvider != null and asrProvider != ''">asr_provider,</if>
+            <if test="aiTransferType != null and aiTransferType != ''">ai_transfer_type,</if>
+            <if test="aiTransferData != null and aiTransferData != ''">ai_transfer_data,</if>
+            <if test="ivrId != null">ivr_id,</if>
+            <if test="satisfSurveyIvrId != null">satisf_survey_ivr_id,</if>
+        </trim>
         <trim prefix="values (" suffix=")" suffixOverrides=",">
-            #{id},
-            #{llmAccountId},
-            #{callee},
-            #{voiceCode},
-            #{voiceSource},
-            #{serviceType},
-            #{groupId},
-         </trim>
+            <if test="id != null">#{id},</if>
+            <if test="llmAccountId != null">#{llmAccountId},</if>
+            <if test="callee != null and callee != ''">#{callee},</if>
+            <if test="inboundAlias != null and inboundAlias != ''">#{inboundAlias},</if>
+            <if test="voiceCode != null and voiceCode != ''">#{voiceCode},</if>
+            <if test="voiceSource != null and voiceSource != ''">#{voiceSource},</if>
+            <if test="serviceType != null and serviceType != ''">#{serviceType},</if>
+            <if test="asrProvider != null and asrProvider != ''">#{asrProvider},</if>
+            <if test="aiTransferType != null and aiTransferType != ''">#{aiTransferType},</if>
+            <if test="aiTransferData != null and aiTransferData != ''">#{aiTransferData},</if>
+            <if test="ivrId != null">#{ivrId},</if>
+            <if test="satisfSurveyIvrId != null">#{satisfSurveyIvrId},</if>
+        </trim>
     </insert>
 
     <update id="updateCcInboundLlmAccount" parameterType="CcInboundLlmAccount">
         update cc_inbound_llm_account
-        <trim prefix="SET" suffixOverrides=",">
-            llm_account_id = #{llmAccountId},
-            callee = #{callee},
-            voice_code = #{voiceCode},
-            voice_source = #{voiceSource},
-            service_type = #{serviceType},
-            group_id = #{groupId},
-        </trim>
+        <set>
+            <if test="llmAccountId != null">llm_account_id = #{llmAccountId},</if>
+            <if test="callee != null and callee != ''">callee = #{callee},</if>
+            <if test="inboundAlias != null and inboundAlias != ''">inbound_alias = #{inboundAlias},</if>
+            <if test="voiceCode != null and voiceCode != ''">voice_code = #{voiceCode},</if>
+            <if test="voiceSource != null and voiceSource != ''">voice_source = #{voiceSource},</if>
+            <if test="serviceType != null and serviceType != ''">service_type = #{serviceType},</if>
+            <if test="asrProvider != null and asrProvider != ''">asr_provider = #{asrProvider},</if>
+            <if test="aiTransferType != null and aiTransferType != ''">ai_transfer_type = #{aiTransferType},</if>
+            <if test="aiTransferData != null and aiTransferData != ''">ai_transfer_data = #{aiTransferData},</if>
+            <if test="ivrId != null">ivr_id = #{ivrId},</if>
+            <if test="satisfSurveyIvrId != null">satisf_survey_ivr_id = #{satisfSurveyIvrId},</if>
+        </set>
         where id = #{id}
     </update>
 

+ 9 - 1
ruoyi-admin/src/main/resources/mapper/aicall/CcLlmAgentAccountMapper.xml

@@ -15,10 +15,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="interruptIgnoreKeywords"    column="interrupt_ignore_keywords"    />
         <result property="intentionTips"    column="intention_tips"    />
         <result property="concurrentNum"    column="concurrent_num"    />
+        <result property="transferManualDigit"    column="transfer_manual_digit"    />
+        <result property="kbCatId"    column="kb_cat_id"    />
     </resultMap>
 
     <sql id="selectCcLlmAgentAccountVo">
-        select id, name, account_json, provider_class_name, account_entity, interrupt_flag, interrupt_keywords, interrupt_ignore_keywords, intention_tips, concurrent_num from cc_llm_agent_account
+        select id, name, account_json, provider_class_name, account_entity, interrupt_flag, interrupt_keywords, interrupt_ignore_keywords, intention_tips, concurrent_num, transfer_manual_digit, kb_cat_id from cc_llm_agent_account
     </sql>
 
     <select id="selectCcLlmAgentAccountList" parameterType="CcLlmAgentAccount" resultMap="CcLlmAgentAccountResult">
@@ -48,6 +50,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             interrupt_ignore_keywords,
             intention_tips,
             concurrent_num,
+            transfer_manual_digit,
+            kb_cat_id,
         </trim>
         <trim prefix="values (" suffix=")" suffixOverrides=",">
                    #{id},
@@ -60,6 +64,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
                    #{interruptIgnoreKeywords},
                    #{intentionTips},
                    #{concurrentNum},
+                   #{transferManualDigit},
+                   #{kbCatId},
         </trim>
     </insert>
 
@@ -75,6 +81,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             interrupt_ignore_keywords = #{interruptIgnoreKeywords},
             intention_tips = #{intentionTips},
             concurrent_num = #{concurrentNum},
+            transfer_manual_digit = #{transferManualDigit},
+            kb_cat_id = #{kbCatId},
         </trim>
         where id = #{id}
     </update>

+ 62 - 0
ruoyi-admin/src/main/resources/mapper/aicall/CcLlmKbCatMapper.xml

@@ -0,0 +1,62 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.ruoyi.aicall.mapper.CcLlmKbCatMapper">
+    
+    <resultMap type="CcLlmKbCat" id="CcLlmKbCatResult">
+        <result property="id"    column="id"    />
+        <result property="cat"    column="cat"    />
+        <result property="description"    column="description"    />
+    </resultMap>
+
+    <sql id="selectCcLlmKbCatVo">
+        select id, cat, description from cc_llm_kb_cat
+    </sql>
+
+    <select id="selectCcLlmKbCatList" parameterType="CcLlmKbCat" resultMap="CcLlmKbCatResult">
+        <include refid="selectCcLlmKbCatVo"/>
+        <where>  
+            <if test="cat != null  and cat != ''"> and cat = #{cat}</if>
+            <if test="description != null  and description != ''"> and description = #{description}</if>
+        </where>
+    </select>
+    
+    <select id="selectCcLlmKbCatById" parameterType="Long" resultMap="CcLlmKbCatResult">
+        <include refid="selectCcLlmKbCatVo"/>
+        where id = #{id}
+    </select>
+
+    <insert id="insertCcLlmKbCat" parameterType="CcLlmKbCat" useGeneratedKeys="true" keyProperty="id">
+        insert into cc_llm_kb_cat
+        <trim prefix="(" suffix=")" suffixOverrides=",">
+            <if test="cat != null">cat,</if>
+            <if test="description != null">description,</if>
+         </trim>
+        <trim prefix="values (" suffix=")" suffixOverrides=",">
+            <if test="cat != null">#{cat},</if>
+            <if test="description != null">#{description},</if>
+         </trim>
+    </insert>
+
+    <update id="updateCcLlmKbCat" parameterType="CcLlmKbCat">
+        update cc_llm_kb_cat
+        <trim prefix="SET" suffixOverrides=",">
+            <if test="cat != null">cat = #{cat},</if>
+            <if test="description != null">description = #{description},</if>
+        </trim>
+        where id = #{id}
+    </update>
+
+    <delete id="deleteCcLlmKbCatById" parameterType="Long">
+        delete from cc_llm_kb_cat where id = #{id}
+    </delete>
+
+    <delete id="deleteCcLlmKbCatByIds" parameterType="String">
+        delete from cc_llm_kb_cat where id in 
+        <foreach item="id" collection="array" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </delete>
+
+</mapper>

+ 90 - 0
ruoyi-admin/src/main/resources/mapper/aicall/CcLlmKbMapper.xml

@@ -0,0 +1,90 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.ruoyi.aicall.mapper.CcLlmKbMapper">
+    
+    <resultMap type="CcLlmKb" id="CcLlmKbResult">
+        <result property="id"    column="id"    />
+        <result property="title"    column="title"    />
+        <result property="content"    column="content"    />
+        <result property="catId"    column="cat_id"    />
+    </resultMap>
+
+    <sql id="selectCcLlmKbVo">
+        select id, title, content, cat_id from cc_llm_kb
+    </sql>
+
+    <select id="selectCcLlmKbList" parameterType="CcLlmKb" resultMap="CcLlmKbResult">
+        <include refid="selectCcLlmKbVo"/>
+        <where>  
+            <if test="title != null  and title != ''"> and title = #{title}</if>
+            <if test="content != null  and content != ''"> and content = #{content}</if>
+            <if test="catId != null "> and cat_id = #{catId}</if>
+        </where>
+    </select>
+    
+    <select id="selectCcLlmKbById" parameterType="Long" resultMap="CcLlmKbResult">
+        <include refid="selectCcLlmKbVo"/>
+        where id = #{id}
+    </select>
+
+    <insert id="insertCcLlmKb" parameterType="CcLlmKb">
+        insert into cc_llm_kb
+        <trim prefix="(" suffix=")" suffixOverrides=",">
+            <if test="id != null">id,</if>
+            <if test="title != null and title != ''">title,</if>
+            <if test="content != null and content != ''">content,</if>
+            <if test="catId != null">cat_id,</if>
+         </trim>
+        <trim prefix="values (" suffix=")" suffixOverrides=",">
+            <if test="id != null">#{id},</if>
+            <if test="title != null and title != ''">#{title},</if>
+            <if test="content != null and content != ''">#{content},</if>
+            <if test="catId != null">#{catId},</if>
+         </trim>
+    </insert>
+
+    <update id="updateCcLlmKb" parameterType="CcLlmKb">
+        update cc_llm_kb
+        <trim prefix="SET" suffixOverrides=",">
+            <if test="title != null and title != ''">title = #{title},</if>
+            <if test="content != null and content != ''">content = #{content},</if>
+            <if test="catId != null">cat_id = #{catId},</if>
+        </trim>
+        where id = #{id}
+    </update>
+
+    <delete id="deleteCcLlmKbById" parameterType="Long">
+        delete from cc_llm_kb where id = #{id}
+    </delete>
+
+    <delete id="deleteCcLlmKbByIds" parameterType="String">
+        delete from cc_llm_kb where id in 
+        <foreach item="id" collection="array" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </delete>
+
+    <select id="selectCountByCatId" parameterType="Long">
+        select count(1) from cc_llm_kb where cat_id = #{catId}
+    </select>
+
+
+    <delete id="deleteCcLlmKbByCatId" parameterType="Long">
+        delete from cc_llm_kb where cat_id = #{catId}
+    </delete>
+
+    <!-- 批量插入 -->
+    <insert id="insertBatch" parameterType="java.util.List">
+        INSERT INTO cc_llm_kb
+        (title, content, cat_id)
+        VALUES
+        <foreach collection="list" item="e" separator=",">
+            (#{e.title},
+            #{e.content},
+            #{e.catId})
+        </foreach>
+    </insert>
+
+</mapper>

Некоторые файлы не были показаны из-за большого количества измененных файлов