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

Merge remote-tracking branch 'origin/saas-api' into saas-api

xgb пре 4 дана
родитељ
комит
aefe807fa7
92 измењених фајлова са 6273 додато и 466 уклоњено
  1. 155 0
      fs-ai-call-task/src/main/resources/application-common.yml
  2. 137 0
      fs-ai-call-task/src/main/resources/application-config-dev.yml
  3. 132 0
      fs-ai-call-task/src/main/resources/application-dev.yml
  4. 155 0
      fs-cid-workflow/src/main/resources/application-common.yml
  5. 137 0
      fs-cid-workflow/src/main/resources/application-config-dev.yml
  6. 132 0
      fs-cid-workflow/src/main/resources/application-dev.yml
  7. 4 0
      fs-comm-gateway/Dockerfile
  8. 14 0
      fs-comm-gateway/nginx.conf
  9. 100 0
      fs-comm-gateway/pom.xml
  10. 33 0
      fs-comm-gateway/src/main/java/com/fs/CommGatewayApplication.java
  11. 70 0
      fs-comm-gateway/src/main/java/com/fs/comm/aspectj/CommCallbackIpCheckAspect.java
  12. 37 0
      fs-comm-gateway/src/main/java/com/fs/comm/auth/CommAuthController.java
  13. 36 0
      fs-comm-gateway/src/main/java/com/fs/comm/auth/CommSession.java
  14. 217 0
      fs-comm-gateway/src/main/java/com/fs/comm/auth/CommTokenService.java
  15. 18 0
      fs-comm-gateway/src/main/java/com/fs/comm/auth/dto/CommTokenRequest.java
  16. 21 0
      fs-comm-gateway/src/main/java/com/fs/comm/auth/dto/CommTokenResponse.java
  17. 33 0
      fs-comm-gateway/src/main/java/com/fs/comm/config/CommDataSourceTaskDecorator.java
  18. 24 0
      fs-comm-gateway/src/main/java/com/fs/comm/config/CommFilterConfig.java
  19. 50 0
      fs-comm-gateway/src/main/java/com/fs/comm/config/CommThreadPoolConfig.java
  20. 41 0
      fs-comm-gateway/src/main/java/com/fs/comm/context/CommAuthContext.java
  21. 29 0
      fs-comm-gateway/src/main/java/com/fs/comm/controller/CommCallController.java
  22. 28 0
      fs-comm-gateway/src/main/java/com/fs/comm/controller/CommCallbackController.java
  23. 32 0
      fs-comm-gateway/src/main/java/com/fs/comm/controller/CommQueryController.java
  24. 29 0
      fs-comm-gateway/src/main/java/com/fs/comm/controller/CommSmsController.java
  25. 27 0
      fs-comm-gateway/src/main/java/com/fs/comm/dto/CommApiResult.java
  26. 37 0
      fs-comm-gateway/src/main/java/com/fs/comm/dto/CommCallSendRequest.java
  27. 31 0
      fs-comm-gateway/src/main/java/com/fs/comm/dto/CommSmsSendRequest.java
  28. 24 0
      fs-comm-gateway/src/main/java/com/fs/comm/exception/CommGlobalExceptionHandler.java
  29. 30 0
      fs-comm-gateway/src/main/java/com/fs/comm/metrics/CommMetricsService.java
  30. 76 0
      fs-comm-gateway/src/main/java/com/fs/comm/ratelimit/CommRateLimitService.java
  31. 28 0
      fs-comm-gateway/src/main/java/com/fs/comm/security/CommAuthenticationEntryPoint.java
  32. 156 0
      fs-comm-gateway/src/main/java/com/fs/comm/security/CommTokenAuthFilter.java
  33. 63 0
      fs-comm-gateway/src/main/java/com/fs/comm/service/CommCallService.java
  34. 86 0
      fs-comm-gateway/src/main/java/com/fs/comm/service/CommCallbackService.java
  35. 73 0
      fs-comm-gateway/src/main/java/com/fs/comm/service/CommGatewayLineAuthService.java
  36. 56 0
      fs-comm-gateway/src/main/java/com/fs/comm/service/CommSmsService.java
  37. 31 0
      fs-comm-gateway/src/main/java/com/fs/framework/config/SecurityConfig.java
  38. 1 0
      fs-comm-gateway/src/main/resources/META-INF/spring-devtools.properties
  39. 155 0
      fs-comm-gateway/src/main/resources/application-common.yml
  40. 137 0
      fs-comm-gateway/src/main/resources/application-config-dev.yml
  41. 132 0
      fs-comm-gateway/src/main/resources/application-dev.yml
  42. 37 0
      fs-comm-gateway/src/main/resources/i18n/messages.properties
  43. 21 0
      fs-comm-gateway/src/main/resources/mybatis/mybatis-config.xml
  44. 635 0
      fs-comm-gateway/src/main/resources/static/chat-aggregate.html
  45. 283 0
      fs-comm-gateway/src/main/resources/static/sales-corpus.html
  46. 324 0
      fs-comm-gateway/src/main/resources/static/workflow-canvas.html
  47. 4 4
      fs-company/src/main/java/com/fs/company/controller/aicall/CcLlmAgentAccountController.java
  48. 121 0
      fs-company/src/main/java/com/fs/company/controller/company/CompanyAiWorkflowServerController.java
  49. 97 0
      fs-company/src/main/java/com/fs/fastGpt/FastGptChatReplaceWordsController.java
  50. 155 0
      fs-company/src/main/resources/application-common.yml
  51. 137 0
      fs-company/src/main/resources/application-config-dev.yml
  52. 73 62
      fs-company/src/main/resources/application-dev.yml
  53. 1 0
      fs-service/src/main/java/com/fs/aicall/domain/CcLlmAgentAccount.java
  54. 14 15
      fs-service/src/main/java/com/fs/aicall/domain/CcLlmAgentProvider.java
  55. 12 11
      fs-service/src/main/java/com/fs/aicall/domain/CcLlmKbCat.java
  56. 50 0
      fs-service/src/main/java/com/fs/aicall/mapper/CcLlmAgentAccountMapper.java
  57. 50 0
      fs-service/src/main/java/com/fs/aicall/mapper/CcLlmAgentProviderMapper.java
  58. 50 0
      fs-service/src/main/java/com/fs/aicall/mapper/CcLlmKbCatMapper.java
  59. 53 0
      fs-service/src/main/java/com/fs/aicall/mapper/CcLlmKbMapper.java
  60. 2 0
      fs-service/src/main/java/com/fs/aicall/mapper/CompanyBindAiModelMapper.java
  61. 1 1
      fs-service/src/main/java/com/fs/aicall/service/impl/CcLlmKbCatServiceImpl.java
  62. 95 0
      fs-service/src/main/java/com/fs/comm/client/CommGatewayClient.java
  63. 33 0
      fs-service/src/main/java/com/fs/comm/model/CommCallSendParam.java
  64. 15 0
      fs-service/src/main/java/com/fs/comm/model/CommCallSendResult.java
  65. 31 0
      fs-service/src/main/java/com/fs/comm/model/CommSmsSendParam.java
  66. 15 0
      fs-service/src/main/java/com/fs/comm/model/CommSmsSendResult.java
  67. 231 0
      fs-service/src/main/java/com/fs/comm/service/CommCallSendService.java
  68. 283 0
      fs-service/src/main/java/com/fs/comm/service/CommSmsSendService.java
  69. 3 0
      fs-service/src/main/java/com/fs/common/service/ISmsService.java
  70. 24 7
      fs-service/src/main/java/com/fs/common/service/impl/SmsServiceImpl.java
  71. 2 0
      fs-service/src/main/java/com/fs/company/mapper/CompanyUserMapper.java
  72. 18 0
      fs-service/src/main/java/com/fs/company/param/BindCidServerParam.java
  73. 78 0
      fs-service/src/main/java/com/fs/company/service/ICompanyAiWorkflowServerService.java
  74. 2 0
      fs-service/src/main/java/com/fs/company/service/ICompanyVoiceRoboticCallLogSendmsgService.java
  75. 165 0
      fs-service/src/main/java/com/fs/company/service/impl/CompanyAiWorkflowServerServiceImpl.java
  76. 71 169
      fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticServiceImpl.java
  77. 51 120
      fs-service/src/main/java/com/fs/company/service/impl/call/node/AiCallTaskNode.java
  78. 4 4
      fs-service/src/main/java/com/fs/fastGpt/mapper/FastGptRoleMapper.java
  79. 3 0
      fs-service/src/main/java/com/fs/system/mapper/SysDictDataMapper.java
  80. 7 0
      fs-service/src/main/resources/application-common.yml
  81. 70 0
      fs-service/src/main/resources/db/20250602-company-voice-robotic-call-log-callphone-manual-answered.sql
  82. 71 0
      fs-service/src/main/resources/db/tenant-initTable-migration.sql
  83. 7 7
      fs-service/src/main/resources/mapper/aicall/CcCallTaskMapper.xml
  84. 6 6
      fs-service/src/main/resources/mapper/aicall/CcLlmAgentAccountMapper.xml
  85. 17 27
      fs-service/src/main/resources/mapper/aicall/CcLlmAgentProviderMapper.xml
  86. 6 6
      fs-service/src/main/resources/mapper/aicall/CcLlmKbCatMapper.xml
  87. 9 9
      fs-service/src/main/resources/mapper/aicall/CcLlmKbMapper.xml
  88. 7 7
      fs-service/src/main/resources/mapper/aicall/CcParamsMapper.xml
  89. 8 8
      fs-service/src/main/resources/mapper/aicall/CompanyBindAiModelMapper.xml
  90. 7 0
      fs-service/src/main/resources/mapper/company/CompanyUserMapper.xml
  91. 6 3
      fs-wx-api/src/main/java/com/fs/app/utils/JwtUtils.java
  92. 1 0
      pom.xml

+ 155 - 0
fs-ai-call-task/src/main/resources/application-common.yml

@@ -0,0 +1,155 @@
+# 项目相关配置
+fs:
+  # 名称
+  name: fs
+  # 版本
+  version: 1.1.0
+  # 版权年份
+  copyrightYear: 2021
+  # 实例演示开关
+  demoEnabled: true
+  # 文件路径 示例( Windows配置D:/fs/uploadPath,Linux配置 /home/fs/uploadPath)
+  profile: c:/fs/uploadPath
+  # 获取ip地址开关
+  addressEnabled: false
+  # 验证码类型 math 数组计算 char 字符验证
+  captchaType: math
+#  jwt:
+#    # 加密秘钥
+#    secret: f4e2e52034348f86b67cde581c0f9eb5
+#    # token有效时长,7天,单位秒
+#    expire: 31536000
+#    header: AppToken
+# 开发环境配置
+server:
+  servlet:
+    # 应用的访问路径
+    context-path: /
+  tomcat:
+    # tomcat的URI编码
+    uri-encoding: UTF-8
+    # tomcat最大线程数,默认为200
+    max-threads: 800
+    # Tomcat启动初始化的线程数,默认值25
+    min-spare-threads: 30
+
+# 日志配置
+logging:
+  level:
+    com.fs: info
+    org.springframework: warn
+
+express:
+  omsCode: "SF.0235402855"
+# Spring配置
+spring:
+  main:
+    allow-circular-references: true
+  cache:
+    type: redis
+  # 资源信息
+  messages:
+    # 国际化资源文件路径
+    basename: i18n/messages
+  mvc:
+    async:
+      request-timeout: 600000
+
+  # 文件上传
+  servlet:
+     multipart:
+       # 单个文件大小
+       max-file-size:  3GB
+       # 设置总上传的文件大小
+       max-request-size:  3GB
+  # 服务模块
+  devtools:
+    restart:
+      # 热部署开关
+      enabled: true
+
+
+# token配置
+token:
+    # 令牌自定义标识
+    header: Authorization
+    # 令牌密钥
+    secret: YlrzSaas2026SecKey!@#QwErTyUiOpAsDfGhJkLzXcVbNm
+    # 令牌有效期(默认30分钟)
+    expireTime: 720
+mybatis-plus:
+  # 搜索指定包别名
+  typeAliasesPackage: com.fs.**.domain,com.fs.**.bo
+  # 配置mapper的扫描,找到所有的mapper.xml映射文件
+  mapperLocations: classpath*:/mapper/**/*.xml
+  configLocation: classpath:mybatis/mybatis-config.xml
+  # 全局配置
+  global-config:
+    db-config:
+      # 主键类型  0:"数据库ID自增", 1:"用户输入ID",2:"全局唯一ID (数字类型唯一ID)", 3:"全局唯一ID UUID";
+      idType: AUTO
+      # 字段策略 0:"忽略判断",1:"非 NULL 判断"),2:"非空判断"
+      fieldStrategy: NOT_EMPTY
+    banner: false
+    # 配置
+  configuration:
+    # 驼峰式命名
+    mapUnderscoreToCamelCase: true
+    # 全局映射器启用缓存
+    cacheEnabled: true
+    # 配置默认的执行器
+    defaultExecutorType: REUSE
+    # 允许 JDBC 支持自动生成主键
+    useGeneratedKeys: true
+
+# MyBatis配置
+mybatis:
+    # 搜索指定包别名
+    typeAliasesPackage: com.fs.**.domain
+    # 配置mapper的扫描,找到所有的mapper.xml映射文件
+    mapperLocations: classpath*:mapper/**/*Mapper.xml
+    # 加载全局的配置文件
+    configLocation: classpath:mybatis/mybatis-config.xml
+
+# PageHelper分页插件
+pagehelper:
+  helperDialect: mysql
+  reasonable: false #超出后不显示
+  supportMethodsArguments: false
+  params: count=countSql
+
+# Swagger配置
+swagger:
+  # 是否开启swagger
+  enabled: false
+  # 请求前缀
+  pathMapping: /dev-api
+
+# 防止XSS攻击
+xss:
+  # 过滤开关
+  enabled: true
+  # 排除链接(多个用逗号分隔)
+  excludes: /system/notice,/system/config/*
+  # 匹配链接
+  urlPatterns: /system/*,/monitor/*,/tool/*
+zhyf:
+  url: https://zhyf-testController.jingpai.com
+
+image:
+  storage:
+    local-path: C:\logoFile\logo.jpg
+    server-path: C:\logoFile\logo.jpg
+# application.properties
+wechat:
+  api:
+    base-url: https://api.weixin.qq.com
+    upload-shipping-info: /wxa/sec/order/upload_shipping_info
+hsy:
+  access_key: AKLTZTc4YTE4ZjI2OWViNDNjZGI2NjhiYTI5Njc5ZjA1Mzk
+  secret_key: WXpjelpUYzFOakF5TUdObE5EZGtNR0ZsWXpKaU1tTmtZakk1WXpObE4yRQ==
+  region: cn-north-1
+  role_access_key: AKLTNmMwNjJkNDFhYTVjNDIzYzhhNzEyZmZmZTlmYzBhNGM
+  role_secret_key: T0RaaFl6UmhZV1V4WXpKbU5EWTBNMkZpT0RNNU9UY3daak0wTjJFd09XUQ==
+  role_trn: trn:iam::2114522511:role/hylj
+

+ 137 - 0
fs-ai-call-task/src/main/resources/application-config-dev.yml

@@ -0,0 +1,137 @@
+baidu:
+  token: 12313231232
+  back-domain: https://www.xxxx.com
+#配置
+logging:
+  level:
+    org.springframework.web: debug
+    com.github.binarywang.demo.wx.cp: DEBUG
+    me.chanjar.weixin: DEBUG
+#wx:
+#  miniapp:
+#    configs:
+#      - appid: wx29d26f63f836be7f
+#        secret: 7542db9774355a89b1adce24defb6013
+#        token: Ncbnd7lJvkripVOpyTFAna6NAWCxCrvC
+#        aesKey: HlEiBB55eaWUaeBVAQO3cWKWPYv1vOVQSq7nFNICw4E
+#        msgDataFormat: JSON
+#  cp:
+#    corpId: wwb2a1055fb6c9a7c2
+#    appConfigs:
+#      - agentId: 1000005
+#        secret: ec7okROXJqkNafq66-L6aKNv0asTzQIG0CYrj3vyBbo
+#        token: PPKOdAlCoMO
+#        aesKey: PKvaxtpSv8NGpfTDm7VUHIK8Wok2ESyYX24qpXJAdMP
+#  pay:
+#    appId: wx73f85f8d62769119 #微信公众号或者小程序等的appid
+#    mchId: 1611402045 #微信支付商户号
+#    mchKey: 8cab128997a3547c1363b0898b877f38 #微信支付商户密钥
+#    subAppId:  #服务商模式下的子商户公众账号ID
+#    subMchId:  #服务商模式下的子商户号
+#    keyPath: c:\\cert\\apiclient_cert.p12 # p12证书的位置,可以指定绝对路径,也可以指定类路径(以classpath:开头)
+#    notifyUrl: https://userapp.his.runtzh.com/app/wxpay/wxPayNotify
+#  mp:
+#    useRedis: false
+#    redisConfig:
+#      host: 127.0.0.1
+#      port: 6379
+#      timeout: 2000
+#    configs:
+#      - appId: wx93ce67750e3cfba3 # 第一个公众号的appid  //公众号名称:云联融智
+#        secret: c172884087264160563bfe5775ca0f6f # 公众号的appsecret
+#        token: PPKOdAlCoMO # 接口配置里的Token值
+#        aesKey: Eswa6VjwtVMCcw03qZy6fWllgrv5aytIA1SZPEU0kU2 # 接口配置里的EncodingAESKey值
+#aifabu:  #爱链接
+#  appKey: 7b471be905ab17e00f3b858c6710dd117601d008
+#watch:
+#  watchUrl: watch.ylrzcloud.com/prod-api
+#  #  account: tcloud
+#  #  password: mdf-m2h_6yw2$hq
+#  account1: ccif #866655060138751
+#  password1: cp-t5or_6xw7$mt
+#  account2: tcloud #rt500台
+#  password2: mdf-m2h_6yw2$hq
+#  account3: whr
+#  password3: v9xsKuqn_$d2y
+#
+#fs :
+#  commonApi: http://172.16.0.16:8010
+#  h5CommonApi: http://119.29.195.254:8010
+#  jwt:
+#    # 加密秘钥
+#    secret: e10adc3949ba59abbe56e057f20f883e
+#    # token有效时长,7天,单位秒
+#    expire: 31536000
+#    header: AppToken
+#nuonuo:
+#  key: 10924508
+#  secret: A2EB20764D304D16
+#
+## 存储捅配置
+#tencent_cloud_config:
+#  secret_id: AKIDiMq9lDf2EOM9lIfqqfKo7FNgM5meD0sT
+#  secret_key: u5SuS80342xzx8FRBukza9lVNHKNMSaB
+#  bucket: myhk-1323137866
+#  app_id: 1323137866
+#  region: ap-chongqing
+#  proxy: myhk
+#cloud_host:
+#  company_name: 金康健
+#  projectCode: DEV
+#  spaceName:
+#  volcengineUrl:
+#headerImg:
+#  imgUrl: https://jz-cos-1356808054.cos.ap-chengdu.myqcloud.com/fs/20250515/0877754b59814ea8a428fa3697b20e68.png
+#ipad:
+#  url:
+#  ipadUrl: http://ipad.cdwjyyh.com
+#  aiApi: http://152.136.202.157:3000/api
+#  voiceApi:
+#  commonApi:
+#wx_miniapp_temp:
+#  pay_order_temp_id:
+#  inquiry_temp_id:
+## 聚水潭API配置
+#jst:
+##  app_key: a4b1fab173c84f67b3873857eea11d90 #聚水潭2025-07-25
+#  app_key: 871348458a964548a72bf8124cf917a4 #聚水潭2025-08-14
+#  app_secret: 5b7d9369dbcd414db45089bc047ebe1a #聚水潭2025-08-14
+##  app_secret: dfce1f8dc8a64ddc91212fc3fcdd9349 #聚水潭2025-07-25
+#  authorization_code: 666666
+#  shop_code: "18461733"
+#
+## RocketMQ配置
+#rocketmq:
+#  name-server: 127.0.0.1:9876
+#  producer:
+#    group: event-feedback-producer
+#    send-message-timeout: 3000
+#    retry-times-when-send-failed: 2
+#    retry-times-when-send-async-failed: 2
+#    max-message-size: 4194304
+#    compress-message-body-threshold: 4096
+#    retry-next-server: true
+#custom:
+#  token: "1o62d3YxvdHd4LEUiltnu7sK"
+#  encoding-aes-key: "UJfTQ5qKTKlegjkXtp1YuzJzxeHlUKvq5GyFbERN1iU"
+#  corp-id: "ww51717e2b71d5e2d3"configValue
+#  secret: "6ODAmw-8W4t6h9mdzHh2Z4Apwj8mnsyRnjEDZOHdA7k"
+#  private-key-path: "privatekey.pem"
+#  webhook-url: "https://your-server.com/wecom/archive"
+## token配置
+#token:
+#  # 令牌自定义标识
+#  header: Authorization
+#  # 令牌密钥
+#  secret: abcdefghijklmnopqrstuvwxyz
+#  # 令牌有效期(默认30分钟)
+#  expireTime: 180
+#openIM:
+#  secret: openIM123
+#  userID: imAdmin
+#  url: https://web.jnmyim.ylrzfs.com/api
+##是否为新商户,新商户不走mpOpenId
+#isNewWxMerchant: true
+##是否使用新im
+#im:
+#  type: OPENIM

+ 132 - 0
fs-ai-call-task/src/main/resources/application-dev.yml

@@ -0,0 +1,132 @@
+# 数据源配置
+spring:
+    # redis 配置
+    redis:
+        # 地址
+        host: localhost
+        # 端口,默认为6379
+        port: 6379
+        # 数据库索引
+        database: 0
+        # 密码
+        password:
+        # 连接超时时间
+        timeout: 20s
+        lettuce:
+            pool:
+                # 连接池中的最小空闲连接
+                min-idle: 0
+                # 连接池中的最大空闲连接
+                max-idle: 8
+                # 连接池的最大数据库连接数
+                max-active: 8
+                # #连接池最大阻塞等待时间(使用负值表示没有限制)
+                max-wait: -1ms
+    datasource:
+        mysql:
+            type: com.alibaba.druid.pool.DruidDataSource
+            driverClassName: com.mysql.cj.jdbc.Driver
+            druid:
+                initialSize: 5
+                minIdle: 10
+                maxActive: 20
+                maxWait: 60000
+                timeBetweenEvictionRunsMillis: 60000
+                minEvictableIdleTimeMillis: 300000
+                maxEvictableIdleTimeMillis: 900000
+                validationQuery: SELECT 1 FROM DUAL
+                testWhileIdle: true
+                testOnBorrow: false
+                testOnReturn: false
+                # 主库数据源
+                master:
+                    url: jdbc:mysql://cq-cdb-8fjmemkb.sql.tencentcdb.com:27220/ylrz_saas?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&useSSL=false
+                    username: root
+                    password: Ylrz_1q2w3e4r5t6y
+                    # 初始连接数
+                    initialSize: 5
+                    # 最小连接池数量
+                    minIdle: 10
+                    # 最大连接池数量
+                    maxActive: 20
+                    # 配置获取连接等待超时的时间
+                    maxWait: 60000
+                    # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
+                    timeBetweenEvictionRunsMillis: 60000
+                    # 配置一个连接在池中最小生存的时间,单位是毫秒
+                    minEvictableIdleTimeMillis: 300000
+                    # 配置一个连接在池中最大生存的时间,单位是毫秒
+                    maxEvictableIdleTimeMillis: 900000
+                    # 配置检测连接是否有效
+                    validationQuery: SELECT 1 FROM DUAL
+                    testWhileIdle: true
+                    testOnBorrow: false
+                    testOnReturn: false
+                    webStatFilter:
+                        enabled: true
+                    statViewServlet:
+                        enabled: true
+                        # 设置白名单,不填则允许所有访问
+                        allow:
+                        url-pattern: /druid/*
+                        # 控制台管理用户名和密码
+                        login-username: fs
+                        login-password: 123456
+                    filter:
+                        stat:
+                            enabled: true
+                            # 慢SQL记录
+                            log-slow-sql: true
+                            slow-sql-millis: 1000
+                            merge-sql: true
+                        wall:
+                            config:
+                                multi-statement-allow: true
+        easycall:
+            type: com.alibaba.druid.pool.DruidDataSource
+            driverClassName: com.mysql.cj.jdbc.Driver
+            druid:
+                # 主库数据源
+                master:
+                    url: jdbc:mysql://129.28.164.235:3306/easycallcenter365?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
+                    username: root
+                    password: easycallcenter365
+                # 初始连接数
+                initialSize: 5
+                # 最小连接池数量
+                minIdle: 10
+                # 最大连接池数量
+                maxActive: 20
+                # 配置获取连接等待超时的时间
+                maxWait: 60000
+                # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
+                timeBetweenEvictionRunsMillis: 60000
+                # 配置一个连接在池中最小生存的时间,单位是毫秒
+                minEvictableIdleTimeMillis: 300000
+                # 配置一个连接在池中最大生存的时间,单位是毫秒
+                maxEvictableIdleTimeMillis: 900000
+                # 配置检测连接是否有效
+                validationQuery: SELECT 1 FROM DUAL
+                testWhileIdle: true
+                testOnBorrow: false
+                testOnReturn: false
+                webStatFilter:
+                    enabled: true
+                statViewServlet:
+                    enabled: true
+                    # 设置白名单,不填则允许所有访问
+                    allow:
+                    url-pattern: /druid/*
+                    # 控制台管理用户名和密码
+                    login-username: fs
+                    login-password: 123456
+                filter:
+                    stat:
+                        enabled: true
+                        # 慢SQL记录
+                        log-slow-sql: true
+                        slow-sql-millis: 1000
+                        merge-sql: true
+                    wall:
+                        config:
+                            multi-statement-allow: true

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

@@ -0,0 +1,155 @@
+# 项目相关配置
+fs:
+  # 名称
+  name: fs
+  # 版本
+  version: 1.1.0
+  # 版权年份
+  copyrightYear: 2021
+  # 实例演示开关
+  demoEnabled: true
+  # 文件路径 示例( Windows配置D:/fs/uploadPath,Linux配置 /home/fs/uploadPath)
+  profile: c:/fs/uploadPath
+  # 获取ip地址开关
+  addressEnabled: false
+  # 验证码类型 math 数组计算 char 字符验证
+  captchaType: math
+#  jwt:
+#    # 加密秘钥
+#    secret: f4e2e52034348f86b67cde581c0f9eb5
+#    # token有效时长,7天,单位秒
+#    expire: 31536000
+#    header: AppToken
+# 开发环境配置
+server:
+  servlet:
+    # 应用的访问路径
+    context-path: /
+  tomcat:
+    # tomcat的URI编码
+    uri-encoding: UTF-8
+    # tomcat最大线程数,默认为200
+    max-threads: 800
+    # Tomcat启动初始化的线程数,默认值25
+    min-spare-threads: 30
+
+# 日志配置
+logging:
+  level:
+    com.fs: info
+    org.springframework: warn
+
+express:
+  omsCode: "SF.0235402855"
+# Spring配置
+spring:
+  main:
+    allow-circular-references: true
+  cache:
+    type: redis
+  # 资源信息
+  messages:
+    # 国际化资源文件路径
+    basename: i18n/messages
+  mvc:
+    async:
+      request-timeout: 600000
+
+  # 文件上传
+  servlet:
+     multipart:
+       # 单个文件大小
+       max-file-size:  3GB
+       # 设置总上传的文件大小
+       max-request-size:  3GB
+  # 服务模块
+  devtools:
+    restart:
+      # 热部署开关
+      enabled: true
+
+
+# token配置
+token:
+    # 令牌自定义标识
+    header: Authorization
+    # 令牌密钥
+    secret: YlrzSaas2026SecKey!@#QwErTyUiOpAsDfGhJkLzXcVbNm
+    # 令牌有效期(默认30分钟)
+    expireTime: 720
+mybatis-plus:
+  # 搜索指定包别名
+  typeAliasesPackage: com.fs.**.domain,com.fs.**.bo
+  # 配置mapper的扫描,找到所有的mapper.xml映射文件
+  mapperLocations: classpath*:/mapper/**/*.xml
+  configLocation: classpath:mybatis/mybatis-config.xml
+  # 全局配置
+  global-config:
+    db-config:
+      # 主键类型  0:"数据库ID自增", 1:"用户输入ID",2:"全局唯一ID (数字类型唯一ID)", 3:"全局唯一ID UUID";
+      idType: AUTO
+      # 字段策略 0:"忽略判断",1:"非 NULL 判断"),2:"非空判断"
+      fieldStrategy: NOT_EMPTY
+    banner: false
+    # 配置
+  configuration:
+    # 驼峰式命名
+    mapUnderscoreToCamelCase: true
+    # 全局映射器启用缓存
+    cacheEnabled: true
+    # 配置默认的执行器
+    defaultExecutorType: REUSE
+    # 允许 JDBC 支持自动生成主键
+    useGeneratedKeys: true
+
+# MyBatis配置
+mybatis:
+    # 搜索指定包别名
+    typeAliasesPackage: com.fs.**.domain
+    # 配置mapper的扫描,找到所有的mapper.xml映射文件
+    mapperLocations: classpath*:mapper/**/*Mapper.xml
+    # 加载全局的配置文件
+    configLocation: classpath:mybatis/mybatis-config.xml
+
+# PageHelper分页插件
+pagehelper:
+  helperDialect: mysql
+  reasonable: false #超出后不显示
+  supportMethodsArguments: false
+  params: count=countSql
+
+# Swagger配置
+swagger:
+  # 是否开启swagger
+  enabled: false
+  # 请求前缀
+  pathMapping: /dev-api
+
+# 防止XSS攻击
+xss:
+  # 过滤开关
+  enabled: true
+  # 排除链接(多个用逗号分隔)
+  excludes: /system/notice,/system/config/*
+  # 匹配链接
+  urlPatterns: /system/*,/monitor/*,/tool/*
+zhyf:
+  url: https://zhyf-testController.jingpai.com
+
+image:
+  storage:
+    local-path: C:\logoFile\logo.jpg
+    server-path: C:\logoFile\logo.jpg
+# application.properties
+wechat:
+  api:
+    base-url: https://api.weixin.qq.com
+    upload-shipping-info: /wxa/sec/order/upload_shipping_info
+hsy:
+  access_key: AKLTZTc4YTE4ZjI2OWViNDNjZGI2NjhiYTI5Njc5ZjA1Mzk
+  secret_key: WXpjelpUYzFOakF5TUdObE5EZGtNR0ZsWXpKaU1tTmtZakk1WXpObE4yRQ==
+  region: cn-north-1
+  role_access_key: AKLTNmMwNjJkNDFhYTVjNDIzYzhhNzEyZmZmZTlmYzBhNGM
+  role_secret_key: T0RaaFl6UmhZV1V4WXpKbU5EWTBNMkZpT0RNNU9UY3daak0wTjJFd09XUQ==
+  role_trn: trn:iam::2114522511:role/hylj
+

+ 137 - 0
fs-cid-workflow/src/main/resources/application-config-dev.yml

@@ -0,0 +1,137 @@
+baidu:
+  token: 12313231232
+  back-domain: https://www.xxxx.com
+#配置
+logging:
+  level:
+    org.springframework.web: debug
+    com.github.binarywang.demo.wx.cp: DEBUG
+    me.chanjar.weixin: DEBUG
+#wx:
+#  miniapp:
+#    configs:
+#      - appid: wx29d26f63f836be7f
+#        secret: 7542db9774355a89b1adce24defb6013
+#        token: Ncbnd7lJvkripVOpyTFAna6NAWCxCrvC
+#        aesKey: HlEiBB55eaWUaeBVAQO3cWKWPYv1vOVQSq7nFNICw4E
+#        msgDataFormat: JSON
+#  cp:
+#    corpId: wwb2a1055fb6c9a7c2
+#    appConfigs:
+#      - agentId: 1000005
+#        secret: ec7okROXJqkNafq66-L6aKNv0asTzQIG0CYrj3vyBbo
+#        token: PPKOdAlCoMO
+#        aesKey: PKvaxtpSv8NGpfTDm7VUHIK8Wok2ESyYX24qpXJAdMP
+#  pay:
+#    appId: wx73f85f8d62769119 #微信公众号或者小程序等的appid
+#    mchId: 1611402045 #微信支付商户号
+#    mchKey: 8cab128997a3547c1363b0898b877f38 #微信支付商户密钥
+#    subAppId:  #服务商模式下的子商户公众账号ID
+#    subMchId:  #服务商模式下的子商户号
+#    keyPath: c:\\cert\\apiclient_cert.p12 # p12证书的位置,可以指定绝对路径,也可以指定类路径(以classpath:开头)
+#    notifyUrl: https://userapp.his.runtzh.com/app/wxpay/wxPayNotify
+#  mp:
+#    useRedis: false
+#    redisConfig:
+#      host: 127.0.0.1
+#      port: 6379
+#      timeout: 2000
+#    configs:
+#      - appId: wx93ce67750e3cfba3 # 第一个公众号的appid  //公众号名称:云联融智
+#        secret: c172884087264160563bfe5775ca0f6f # 公众号的appsecret
+#        token: PPKOdAlCoMO # 接口配置里的Token值
+#        aesKey: Eswa6VjwtVMCcw03qZy6fWllgrv5aytIA1SZPEU0kU2 # 接口配置里的EncodingAESKey值
+#aifabu:  #爱链接
+#  appKey: 7b471be905ab17e00f3b858c6710dd117601d008
+#watch:
+#  watchUrl: watch.ylrzcloud.com/prod-api
+#  #  account: tcloud
+#  #  password: mdf-m2h_6yw2$hq
+#  account1: ccif #866655060138751
+#  password1: cp-t5or_6xw7$mt
+#  account2: tcloud #rt500台
+#  password2: mdf-m2h_6yw2$hq
+#  account3: whr
+#  password3: v9xsKuqn_$d2y
+#
+#fs :
+#  commonApi: http://172.16.0.16:8010
+#  h5CommonApi: http://119.29.195.254:8010
+#  jwt:
+#    # 加密秘钥
+#    secret: e10adc3949ba59abbe56e057f20f883e
+#    # token有效时长,7天,单位秒
+#    expire: 31536000
+#    header: AppToken
+#nuonuo:
+#  key: 10924508
+#  secret: A2EB20764D304D16
+#
+## 存储捅配置
+#tencent_cloud_config:
+#  secret_id: AKIDiMq9lDf2EOM9lIfqqfKo7FNgM5meD0sT
+#  secret_key: u5SuS80342xzx8FRBukza9lVNHKNMSaB
+#  bucket: myhk-1323137866
+#  app_id: 1323137866
+#  region: ap-chongqing
+#  proxy: myhk
+#cloud_host:
+#  company_name: 金康健
+#  projectCode: DEV
+#  spaceName:
+#  volcengineUrl:
+#headerImg:
+#  imgUrl: https://jz-cos-1356808054.cos.ap-chengdu.myqcloud.com/fs/20250515/0877754b59814ea8a428fa3697b20e68.png
+#ipad:
+#  url:
+#  ipadUrl: http://ipad.cdwjyyh.com
+#  aiApi: http://152.136.202.157:3000/api
+#  voiceApi:
+#  commonApi:
+#wx_miniapp_temp:
+#  pay_order_temp_id:
+#  inquiry_temp_id:
+## 聚水潭API配置
+#jst:
+##  app_key: a4b1fab173c84f67b3873857eea11d90 #聚水潭2025-07-25
+#  app_key: 871348458a964548a72bf8124cf917a4 #聚水潭2025-08-14
+#  app_secret: 5b7d9369dbcd414db45089bc047ebe1a #聚水潭2025-08-14
+##  app_secret: dfce1f8dc8a64ddc91212fc3fcdd9349 #聚水潭2025-07-25
+#  authorization_code: 666666
+#  shop_code: "18461733"
+#
+## RocketMQ配置
+#rocketmq:
+#  name-server: 127.0.0.1:9876
+#  producer:
+#    group: event-feedback-producer
+#    send-message-timeout: 3000
+#    retry-times-when-send-failed: 2
+#    retry-times-when-send-async-failed: 2
+#    max-message-size: 4194304
+#    compress-message-body-threshold: 4096
+#    retry-next-server: true
+#custom:
+#  token: "1o62d3YxvdHd4LEUiltnu7sK"
+#  encoding-aes-key: "UJfTQ5qKTKlegjkXtp1YuzJzxeHlUKvq5GyFbERN1iU"
+#  corp-id: "ww51717e2b71d5e2d3"configValue
+#  secret: "6ODAmw-8W4t6h9mdzHh2Z4Apwj8mnsyRnjEDZOHdA7k"
+#  private-key-path: "privatekey.pem"
+#  webhook-url: "https://your-server.com/wecom/archive"
+## token配置
+#token:
+#  # 令牌自定义标识
+#  header: Authorization
+#  # 令牌密钥
+#  secret: abcdefghijklmnopqrstuvwxyz
+#  # 令牌有效期(默认30分钟)
+#  expireTime: 180
+#openIM:
+#  secret: openIM123
+#  userID: imAdmin
+#  url: https://web.jnmyim.ylrzfs.com/api
+##是否为新商户,新商户不走mpOpenId
+#isNewWxMerchant: true
+##是否使用新im
+#im:
+#  type: OPENIM

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

@@ -0,0 +1,132 @@
+# 数据源配置
+spring:
+    # redis 配置
+    redis:
+        # 地址
+        host: localhost
+        # 端口,默认为6379
+        port: 6379
+        # 数据库索引
+        database: 0
+        # 密码
+        password:
+        # 连接超时时间
+        timeout: 20s
+        lettuce:
+            pool:
+                # 连接池中的最小空闲连接
+                min-idle: 0
+                # 连接池中的最大空闲连接
+                max-idle: 8
+                # 连接池的最大数据库连接数
+                max-active: 8
+                # #连接池最大阻塞等待时间(使用负值表示没有限制)
+                max-wait: -1ms
+    datasource:
+        mysql:
+            type: com.alibaba.druid.pool.DruidDataSource
+            driverClassName: com.mysql.cj.jdbc.Driver
+            druid:
+                initialSize: 5
+                minIdle: 10
+                maxActive: 20
+                maxWait: 60000
+                timeBetweenEvictionRunsMillis: 60000
+                minEvictableIdleTimeMillis: 300000
+                maxEvictableIdleTimeMillis: 900000
+                validationQuery: SELECT 1 FROM DUAL
+                testWhileIdle: true
+                testOnBorrow: false
+                testOnReturn: false
+                # 主库数据源
+                master:
+                    url: jdbc:mysql://cq-cdb-8fjmemkb.sql.tencentcdb.com:27220/ylrz_saas?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&useSSL=false
+                    username: root
+                    password: Ylrz_1q2w3e4r5t6y
+                    # 初始连接数
+                    initialSize: 5
+                    # 最小连接池数量
+                    minIdle: 10
+                    # 最大连接池数量
+                    maxActive: 20
+                    # 配置获取连接等待超时的时间
+                    maxWait: 60000
+                    # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
+                    timeBetweenEvictionRunsMillis: 60000
+                    # 配置一个连接在池中最小生存的时间,单位是毫秒
+                    minEvictableIdleTimeMillis: 300000
+                    # 配置一个连接在池中最大生存的时间,单位是毫秒
+                    maxEvictableIdleTimeMillis: 900000
+                    # 配置检测连接是否有效
+                    validationQuery: SELECT 1 FROM DUAL
+                    testWhileIdle: true
+                    testOnBorrow: false
+                    testOnReturn: false
+                    webStatFilter:
+                        enabled: true
+                    statViewServlet:
+                        enabled: true
+                        # 设置白名单,不填则允许所有访问
+                        allow:
+                        url-pattern: /druid/*
+                        # 控制台管理用户名和密码
+                        login-username: fs
+                        login-password: 123456
+                    filter:
+                        stat:
+                            enabled: true
+                            # 慢SQL记录
+                            log-slow-sql: true
+                            slow-sql-millis: 1000
+                            merge-sql: true
+                        wall:
+                            config:
+                                multi-statement-allow: true
+        easycall:
+            type: com.alibaba.druid.pool.DruidDataSource
+            driverClassName: com.mysql.cj.jdbc.Driver
+            druid:
+                # 主库数据源
+                master:
+                    url: jdbc:mysql://129.28.164.235:3306/easycallcenter365?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
+                    username: root
+                    password: easycallcenter365
+                # 初始连接数
+                initialSize: 5
+                # 最小连接池数量
+                minIdle: 10
+                # 最大连接池数量
+                maxActive: 20
+                # 配置获取连接等待超时的时间
+                maxWait: 60000
+                # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
+                timeBetweenEvictionRunsMillis: 60000
+                # 配置一个连接在池中最小生存的时间,单位是毫秒
+                minEvictableIdleTimeMillis: 300000
+                # 配置一个连接在池中最大生存的时间,单位是毫秒
+                maxEvictableIdleTimeMillis: 900000
+                # 配置检测连接是否有效
+                validationQuery: SELECT 1 FROM DUAL
+                testWhileIdle: true
+                testOnBorrow: false
+                testOnReturn: false
+                webStatFilter:
+                    enabled: true
+                statViewServlet:
+                    enabled: true
+                    # 设置白名单,不填则允许所有访问
+                    allow:
+                    url-pattern: /druid/*
+                    # 控制台管理用户名和密码
+                    login-username: fs
+                    login-password: 123456
+                filter:
+                    stat:
+                        enabled: true
+                        # 慢SQL记录
+                        log-slow-sql: true
+                        slow-sql-millis: 1000
+                        merge-sql: true
+                    wall:
+                        config:
+                            multi-statement-allow: true

+ 4 - 0
fs-comm-gateway/Dockerfile

@@ -0,0 +1,4 @@
+FROM openjdk:17-jre
+COPY ./target/fs-comm-gateway.jar fs-comm-gateway.jar
+EXPOSE 8010
+ENTRYPOINT ["java","-jar","fs-comm-gateway.jar"]

+ 14 - 0
fs-comm-gateway/nginx.conf

@@ -0,0 +1,14 @@
+server {
+    listen 80;
+    server_name comm-gateway.local;
+
+    location /comm/ {
+        proxy_pass http://127.0.0.1:8010/comm/;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_connect_timeout 120s;
+        proxy_send_timeout 120s;
+        proxy_read_timeout 120s;
+    }
+}

+ 100 - 0
fs-comm-gateway/pom.xml

@@ -0,0 +1,100 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <parent>
+        <artifactId>fs</artifactId>
+        <groupId>com.fs</groupId>
+        <version>1.1.0</version>
+    </parent>
+    <modelVersion>4.0.0</modelVersion>
+
+    <artifactId>fs-comm-gateway</artifactId>
+    <description>通讯中间件:外呼/短信统一网关</description>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-aop</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-validation</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-security</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-data-redis</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>io.springfox</groupId>
+            <artifactId>springfox-swagger2</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>io.springfox</groupId>
+            <artifactId>springfox-swagger-ui</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>mysql</groupId>
+            <artifactId>mysql-connector-java</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.alibaba</groupId>
+            <artifactId>druid-spring-boot-starter</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.baomidou</groupId>
+            <artifactId>dynamic-datasource-spring-boot-starter</artifactId>
+            <version>3.1.0</version>
+        </dependency>
+        <dependency>
+            <groupId>org.projectlombok</groupId>
+            <artifactId>lombok</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>io.jsonwebtoken</groupId>
+            <artifactId>jjwt</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.fs</groupId>
+            <artifactId>fs-service</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.fs</groupId>
+            <artifactId>fs-framework</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.fs</groupId>
+            <artifactId>fs-common</artifactId>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+                <version>2.7.18</version>
+                <configuration>
+                    <mainClass>com.fs.CommGatewayApplication</mainClass>
+                    <fork>true</fork>
+                </configuration>
+                <executions>
+                    <execution>
+                        <goals>
+                            <goal>repackage</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+        </plugins>
+        <finalName>${project.artifactId}</finalName>
+    </build>
+</project>

+ 33 - 0
fs-comm-gateway/src/main/java/com/fs/CommGatewayApplication.java

@@ -0,0 +1,33 @@
+package com.fs;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.context.annotation.FilterType;
+import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.transaction.annotation.EnableTransactionManagement;
+
+/**
+ * 通讯中间件启动类(外呼/短信统一网关)
+ */
+@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
+@ComponentScan(
+        basePackages = "com.fs",
+        excludeFilters = @ComponentScan.Filter(type = FilterType.REGEX, pattern = {
+                "com\\.fs\\.framework\\.web\\.service\\.PermissionService",
+                "com\\.fs\\.framework\\.web\\.service\\.UserDetailsServiceImpl",
+                "com\\.fs\\.framework\\.web\\.service\\.SysLoginService",
+                "com\\.fs\\.framework\\.web\\.exception\\.GlobalExceptionHandler",
+                "com\\.fs\\.framework\\.security\\.filter\\.JwtAuthenticationTokenFilter"
+        })
+)
+@EnableTransactionManagement
+@EnableAsync
+public class CommGatewayApplication {
+
+    public static void main(String[] args) {
+        SpringApplication.run(CommGatewayApplication.class, args);
+        System.out.println("通讯中间件 fs-comm-gateway 启动成功");
+    }
+}

+ 70 - 0
fs-comm-gateway/src/main/java/com/fs/comm/aspectj/CommCallbackIpCheckAspect.java

@@ -0,0 +1,70 @@
+package com.fs.comm.aspectj;
+
+import com.alibaba.fastjson.JSONObject;
+import com.fs.aicall.utils.StringUtils;
+import com.fs.common.annotation.CallbackIpCheck;
+import com.fs.common.utils.IpUtil;
+import com.fs.company.util.IpCheckUtil;
+import com.fs.company.vo.CidConfigVO;
+import com.fs.system.domain.SysConfig;
+import com.fs.system.mapper.SysConfigMapper;
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.annotation.Around;
+import org.aspectj.lang.annotation.Aspect;
+import org.aspectj.lang.annotation.Pointcut;
+import org.aspectj.lang.reflect.MethodSignature;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+
+import javax.servlet.http.HttpServletRequest;
+import java.lang.reflect.Method;
+
+@Aspect
+@Component
+public class CommCallbackIpCheckAspect {
+
+    private static final Logger log = LoggerFactory.getLogger(CommCallbackIpCheckAspect.class);
+
+    @Autowired
+    private SysConfigMapper sysConfigMapper;
+
+    @Pointcut("@annotation(com.fs.common.annotation.CallbackIpCheck)")
+    public void ipCheckPointCut() {
+    }
+
+    @Around("ipCheckPointCut()")
+    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
+        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
+        Method method = signature.getMethod();
+        CallbackIpCheck annotation = method.getAnnotation(CallbackIpCheck.class);
+
+        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
+        if (attributes == null) {
+            throw new IllegalStateException("CallbackIpCheck: 无法获取当前请求上下文");
+        }
+        HttpServletRequest request = attributes.getRequest();
+        String clientIp = IpUtil.getRequestIp(request);
+
+        String configKey = annotation.configKey();
+        SysConfig sysConfig = sysConfigMapper.selectConfigByConfigKey(configKey);
+        if (sysConfig == null || StringUtils.isBlank(sysConfig.getConfigValue())) {
+            log.error("CallbackIpCheck: 未找到配置, configKey={}, 请求IP: {}", configKey, clientIp);
+            throw new IllegalArgumentException("CallbackIpCheck: 未找到配置");
+        }
+
+        CidConfigVO cidConf = JSONObject.parseObject(sysConfig.getConfigValue(), CidConfigVO.class);
+        String legalIPs = cidConf.getLegalIPs();
+        if (!IpCheckUtil.isIpInList(clientIp, legalIPs)) {
+            log.warn("非法回调来源IP: {}, legalIPs: {}", clientIp, legalIPs);
+            if (method.getReturnType() == String.class) {
+                return "illegal IP";
+            }
+            throw new SecurityException("非法IP来源,请求IP: " + clientIp);
+        }
+        return joinPoint.proceed();
+    }
+}

+ 37 - 0
fs-comm-gateway/src/main/java/com/fs/comm/auth/CommAuthController.java

@@ -0,0 +1,37 @@
+package com.fs.comm.auth;
+
+import com.fs.comm.auth.dto.CommTokenRequest;
+import com.fs.comm.auth.dto.CommTokenResponse;
+import com.fs.comm.dto.CommApiResult;
+import com.fs.common.utils.ServletUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/comm/auth")
+public class CommAuthController {
+
+    @Autowired
+    private CommTokenService commTokenService;
+
+    @PostMapping("/token")
+    public CommApiResult<CommTokenResponse> token(@Validated @RequestBody CommTokenRequest request) {
+        CommTokenResponse response = commTokenService.login(request.getTenantCode(), request.getAccount(), request.getPassword());
+        return CommApiResult.success(response);
+    }
+
+    @PostMapping("/refresh")
+    public CommApiResult<CommTokenResponse> refresh() {
+        return CommApiResult.success(commTokenService.refreshToken(ServletUtils.getRequest()));
+    }
+
+    @PostMapping("/logout")
+    public CommApiResult<Void> logout() {
+        commTokenService.logout(ServletUtils.getRequest());
+        return CommApiResult.success(null);
+    }
+}

+ 36 - 0
fs-comm-gateway/src/main/java/com/fs/comm/auth/CommSession.java

@@ -0,0 +1,36 @@
+package com.fs.comm.auth;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 通讯网关登录会话(存 Redis)
+ */
+@Data
+public class CommSession implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 令牌 uuid */
+    private String token;
+
+    private Long tenantId;
+
+    private String tenantCode;
+
+    private Long companyId;
+
+    private Long companyUserId;
+
+    private String account;
+
+    /** external / internal */
+    private String scope;
+
+    private String ipaddr;
+
+    private Long loginTime;
+
+    private Long expireTime;
+}

+ 217 - 0
fs-comm-gateway/src/main/java/com/fs/comm/auth/CommTokenService.java

@@ -0,0 +1,217 @@
+package com.fs.comm.auth;
+
+import com.fs.comm.auth.dto.CommTokenResponse;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.enums.DataSourceType;
+import com.fs.common.enums.UserStatus;
+import com.fs.common.exception.ServiceException;
+import com.fs.common.utils.SecurityUtils;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.ip.IpUtils;
+import com.fs.common.utils.uuid.IdUtils;
+import com.fs.company.domain.Company;
+import com.fs.company.domain.CompanyUser;
+import com.fs.company.service.ICompanyService;
+import com.fs.company.service.ICompanyUserService;
+import com.fs.framework.datasource.DynamicDataSourceContextHolder;
+import com.fs.framework.datasource.TenantDataSourceManager;
+import com.fs.tenant.domain.TenantInfo;
+import com.fs.tenant.service.TenantInfoService;
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.SignatureAlgorithm;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import javax.servlet.http.HttpServletRequest;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+@Slf4j
+@Service
+public class CommTokenService {
+
+    public static final String COMM_TOKEN_KEY = "comm_token_key";
+    public static final String CLAIM_TENANT_ID = "tenantId";
+    public static final String SCOPE_EXTERNAL = "external";
+    public static final String SCOPE_INTERNAL = "internal";
+
+    @Value("${token.header:Authorization}")
+    private String header;
+
+    @Value("${token.secret}")
+    private String secret;
+
+    @Value("${token.expireTime:120}")
+    private int expireTimeMinutes;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    @Autowired
+    private TenantInfoService tenantInfoService;
+
+    @Autowired
+    private ICompanyUserService companyUserService;
+
+    @Autowired
+    private ICompanyService companyService;
+
+    @Autowired
+    private TenantDataSourceManager tenantDataSourceManager;
+
+    public CommTokenResponse login(String tenantCode, String account, String password) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        TenantInfo tenantInfo = tenantInfoService.selectTenantInfoByCode(tenantCode);
+        if (tenantInfo == null) {
+            throw new ServiceException("租户不存在");
+        }
+        if (tenantInfo.getStatus() == null || tenantInfo.getStatus() != 1) {
+            throw new ServiceException("租户已禁用");
+        }
+        tenantDataSourceManager.switchTenant(tenantInfo);
+        try {
+            CompanyUser user = companyUserService.selectUserByUserName(account);
+            if (user == null) {
+                throw new ServiceException("账号或密码错误");
+            }
+            if (UserStatus.DELETED.getCode().equals(user.getDelFlag())) {
+                throw new ServiceException("账号已被删除");
+            }
+            if (UserStatus.DISABLE.getCode().equals(user.getStatus())) {
+                throw new ServiceException("账号已被停用");
+            }
+            if (!SecurityUtils.matchesPassword(password, user.getPassword())) {
+                throw new ServiceException("账号或密码错误");
+            }
+            Company company = companyService.selectCompanyById(user.getCompanyId());
+            if (company == null || company.getStatus() == 0 || company.getIsDel() == 1) {
+                throw new ServiceException("所属公司已停用");
+            }
+            CommSession session = buildSession(tenantInfo, user, SCOPE_EXTERNAL);
+            refreshSession(session);
+            return buildTokenResponse(session);
+        } finally {
+            tenantDataSourceManager.clear();
+            DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        }
+    }
+
+    public CommSession createInternalSession(Long tenantId, Long companyId, Long companyUserId, String account) {
+        CommSession session = new CommSession();
+        session.setToken(IdUtils.fastUUID());
+        session.setTenantId(tenantId);
+        session.setCompanyId(companyId);
+        session.setCompanyUserId(companyUserId);
+        session.setAccount(StringUtils.isNotBlank(account) ? account : "internal");
+        session.setScope(SCOPE_INTERNAL);
+        session.setIpaddr("internal");
+        refreshSession(session);
+        return session;
+    }
+
+    public CommSession getSession(HttpServletRequest request) {
+        String jwt = resolveToken(request);
+        if (StringUtils.isBlank(jwt)) {
+            return null;
+        }
+        try {
+            Claims claims = parseToken(jwt);
+            String uuid = (String) claims.get(COMM_TOKEN_KEY);
+            Long tenantId = claims.get(CLAIM_TENANT_ID) == null ? null : ((Number) claims.get(CLAIM_TENANT_ID)).longValue();
+            if (StringUtils.isBlank(uuid)) {
+                return null;
+            }
+            return redisCache.getCacheObject(buildRedisKey(tenantId, uuid));
+        } catch (Exception e) {
+            log.debug("解析通讯网关token失败: {}", e.getMessage());
+            return null;
+        }
+    }
+
+    public void verifySession(CommSession session) {
+        if (session == null || session.getExpireTime() == null) {
+            throw new ServiceException("登录状态已失效");
+        }
+        long remain = session.getExpireTime() - System.currentTimeMillis();
+        if (remain <= 0) {
+            throw new ServiceException("登录状态已过期");
+        }
+        if (remain <= 20 * 60 * 1000L) {
+            refreshSession(session);
+        }
+    }
+
+    public CommTokenResponse refreshToken(HttpServletRequest request) {
+        CommSession session = getSession(request);
+        verifySession(session);
+        refreshSession(session);
+        return buildTokenResponse(session);
+    }
+
+    public void logout(HttpServletRequest request) {
+        CommSession session = getSession(request);
+        if (session != null) {
+            redisCache.deleteObject(buildRedisKey(session.getTenantId(), session.getToken()));
+        }
+    }
+
+    private CommSession buildSession(TenantInfo tenantInfo, CompanyUser user, String scope) {
+        CommSession session = new CommSession();
+        session.setToken(IdUtils.fastUUID());
+        session.setTenantId(tenantInfo.getId());
+        session.setTenantCode(tenantInfo.getTenantCode());
+        session.setCompanyId(user.getCompanyId());
+        session.setCompanyUserId(user.getUserId());
+        session.setAccount(user.getUserName());
+        session.setScope(scope);
+        session.setIpaddr(IpUtils.getIpAddr(ServletUtils.getRequest()));
+        return session;
+    }
+
+    private void refreshSession(CommSession session) {
+        session.setLoginTime(System.currentTimeMillis());
+        session.setExpireTime(session.getLoginTime() + expireTimeMinutes * 60L * 1000L);
+        redisCache.setCacheObject(buildRedisKey(session.getTenantId(), session.getToken()), session, expireTimeMinutes, TimeUnit.MINUTES);
+    }
+
+    private CommTokenResponse buildTokenResponse(CommSession session) {
+        Map<String, Object> claims = new HashMap<>();
+        claims.put(COMM_TOKEN_KEY, session.getToken());
+        if (session.getTenantId() != null) {
+            claims.put(CLAIM_TENANT_ID, session.getTenantId());
+        }
+        String jwt = Jwts.builder().setClaims(claims).signWith(SignatureAlgorithm.HS512, secret).compact();
+        return CommTokenResponse.builder()
+                .accessToken(jwt)
+                .expiresIn((long) expireTimeMinutes * 60)
+                .tokenType("Bearer")
+                .tenantId(session.getTenantId())
+                .companyId(session.getCompanyId())
+                .companyUserId(session.getCompanyUserId())
+                .build();
+    }
+
+    public String resolveToken(HttpServletRequest request) {
+        String token = request.getHeader(header);
+        if (StringUtils.isNotEmpty(token) && token.startsWith(com.fs.common.constant.Constants.TOKEN_PREFIX)) {
+            token = token.replace(com.fs.common.constant.Constants.TOKEN_PREFIX, "");
+        }
+        return token;
+    }
+
+    private Claims parseToken(String token) {
+        return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
+    }
+
+    public static String buildRedisKey(Long tenantId, String uuid) {
+        if (tenantId != null) {
+            return "tenantid:" + tenantId + ":comm_token:" + uuid;
+        }
+        return "comm_token:" + uuid;
+    }
+}

+ 18 - 0
fs-comm-gateway/src/main/java/com/fs/comm/auth/dto/CommTokenRequest.java

@@ -0,0 +1,18 @@
+package com.fs.comm.auth.dto;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+
+@Data
+public class CommTokenRequest {
+
+    @NotBlank(message = "tenantCode不能为空")
+    private String tenantCode;
+
+    @NotBlank(message = "account不能为空")
+    private String account;
+
+    @NotBlank(message = "password不能为空")
+    private String password;
+}

+ 21 - 0
fs-comm-gateway/src/main/java/com/fs/comm/auth/dto/CommTokenResponse.java

@@ -0,0 +1,21 @@
+package com.fs.comm.auth.dto;
+
+import lombok.Builder;
+import lombok.Data;
+
+@Data
+@Builder
+public class CommTokenResponse {
+
+    private String accessToken;
+
+    private Long expiresIn;
+
+    private String tokenType;
+
+    private Long tenantId;
+
+    private Long companyId;
+
+    private Long companyUserId;
+}

+ 33 - 0
fs-comm-gateway/src/main/java/com/fs/comm/config/CommDataSourceTaskDecorator.java

@@ -0,0 +1,33 @@
+package com.fs.comm.config;
+
+import com.fs.framework.datasource.DynamicDataSourceContextHolder;
+import org.slf4j.MDC;
+import org.springframework.core.task.TaskDecorator;
+
+import java.util.Map;
+
+/**
+ * 异步线程数据源上下文传递装饰器
+ */
+public class CommDataSourceTaskDecorator implements TaskDecorator {
+
+    @Override
+    public Runnable decorate(Runnable runnable) {
+        String dataSourceType = DynamicDataSourceContextHolder.getDataSourceType();
+        Map<String, String> contextMap = MDC.getCopyOfContextMap();
+        return () -> {
+            try {
+                if (dataSourceType != null) {
+                    DynamicDataSourceContextHolder.setDataSourceType(dataSourceType);
+                }
+                if (contextMap != null) {
+                    MDC.setContextMap(contextMap);
+                }
+                runnable.run();
+            } finally {
+                DynamicDataSourceContextHolder.clearDataSourceType();
+                MDC.clear();
+            }
+        };
+    }
+}

+ 24 - 0
fs-comm-gateway/src/main/java/com/fs/comm/config/CommFilterConfig.java

@@ -0,0 +1,24 @@
+package com.fs.comm.config;
+
+import com.fs.comm.security.CommTokenAuthFilter;
+import org.springframework.boot.web.servlet.FilterRegistrationBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.Ordered;
+
+/**
+ * 通讯网关鉴权过滤器(Servlet Filter 注册,避免与 SecurityConfig 形成 AuthenticationManager 循环依赖)
+ */
+@Configuration
+public class CommFilterConfig {
+
+    @Bean
+    public FilterRegistrationBean<CommTokenAuthFilter> commTokenAuthFilterRegistration(CommTokenAuthFilter filter) {
+        FilterRegistrationBean<CommTokenAuthFilter> registration = new FilterRegistrationBean<>();
+        registration.setFilter(filter);
+        registration.addUrlPatterns("/comm/*");
+        registration.setName("commTokenAuthFilter");
+        registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 20);
+        return registration;
+    }
+}

+ 50 - 0
fs-comm-gateway/src/main/java/com/fs/comm/config/CommThreadPoolConfig.java

@@ -0,0 +1,50 @@
+package com.fs.comm.config;
+
+import com.fs.common.utils.Threads;
+import com.fs.comm.config.CommDataSourceTaskDecorator;
+import org.apache.commons.lang3.concurrent.BasicThreadFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+
+import java.util.concurrent.ThreadPoolExecutor;
+
+@Configuration
+public class CommThreadPoolConfig {
+
+    @Value("${comm.gateway.executor.core-pool-size:20}")
+    private int corePoolSize;
+
+    @Value("${comm.gateway.executor.max-pool-size:100}")
+    private int maxPoolSize;
+
+    @Value("${comm.gateway.executor.queue-capacity:2000}")
+    private int queueCapacity;
+
+    @Bean(name = "commExecutor")
+    public ThreadPoolTaskExecutor commExecutor() {
+        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+        executor.setCorePoolSize(corePoolSize);
+        executor.setMaxPoolSize(maxPoolSize);
+        executor.setQueueCapacity(queueCapacity);
+        executor.setKeepAliveSeconds(300);
+        executor.setThreadNamePrefix("comm-gateway-");
+        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
+        executor.setTaskDecorator(new CommDataSourceTaskDecorator());
+        executor.initialize();
+        return executor;
+    }
+
+    @Bean(name = "commScheduledExecutor")
+    public java.util.concurrent.ScheduledExecutorService commScheduledExecutor() {
+        return new java.util.concurrent.ScheduledThreadPoolExecutor(corePoolSize,
+                new BasicThreadFactory.Builder().namingPattern("comm-schedule-%d").daemon(true).build()) {
+            @Override
+            protected void afterExecute(Runnable r, Throwable t) {
+                super.afterExecute(r, t);
+                Threads.printException(r, t);
+            }
+        };
+    }
+}

+ 41 - 0
fs-comm-gateway/src/main/java/com/fs/comm/context/CommAuthContext.java

@@ -0,0 +1,41 @@
+package com.fs.comm.context;
+
+import com.fs.comm.auth.CommSession;
+
+/**
+ * 当前请求通讯会话上下文
+ */
+public final class CommAuthContext {
+
+    private static final ThreadLocal<CommSession> HOLDER = new ThreadLocal<>();
+
+    private CommAuthContext() {
+    }
+
+    public static void set(CommSession session) {
+        HOLDER.set(session);
+    }
+
+    public static CommSession get() {
+        return HOLDER.get();
+    }
+
+    public static Long getTenantId() {
+        CommSession session = HOLDER.get();
+        return session == null ? null : session.getTenantId();
+    }
+
+    public static Long getCompanyId() {
+        CommSession session = HOLDER.get();
+        return session == null ? null : session.getCompanyId();
+    }
+
+    public static Long getCompanyUserId() {
+        CommSession session = HOLDER.get();
+        return session == null ? null : session.getCompanyUserId();
+    }
+
+    public static void clear() {
+        HOLDER.remove();
+    }
+}

+ 29 - 0
fs-comm-gateway/src/main/java/com/fs/comm/controller/CommCallController.java

@@ -0,0 +1,29 @@
+package com.fs.comm.controller;
+
+import com.fs.comm.context.CommAuthContext;
+import com.fs.comm.dto.CommApiResult;
+import com.fs.comm.dto.CommCallSendRequest;
+import com.fs.comm.service.CommCallService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Map;
+
+@RestController
+@RequestMapping("/comm/call")
+public class CommCallController {
+
+    @Autowired
+    private CommCallService commCallService;
+
+    @PostMapping("/send")
+    public CommApiResult<Map<String, Object>> send(@RequestBody CommCallSendRequest request) {
+        if (CommAuthContext.get() == null) {
+            return CommApiResult.error(401, "未认证");
+        }
+        return CommApiResult.success(commCallService.sendCall(request));
+    }
+}

+ 28 - 0
fs-comm-gateway/src/main/java/com/fs/comm/controller/CommCallbackController.java

@@ -0,0 +1,28 @@
+package com.fs.comm.controller;
+
+import com.fs.comm.service.CommCallbackService;
+import com.fs.common.annotation.CallbackIpCheck;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/comm/callback")
+public class CommCallbackController {
+
+    @Autowired
+    private CommCallbackService commCallbackService;
+
+    @PostMapping("/easycall")
+    @CallbackIpCheck
+    public String easyCall(@RequestBody String cdrStr) {
+        return commCallbackService.handleEasyCallCallback(cdrStr);
+    }
+
+    @PostMapping("/sms")
+    public String sms(@RequestBody String json) {
+        return commCallbackService.handleSmsCallback(json);
+    }
+}

+ 32 - 0
fs-comm-gateway/src/main/java/com/fs/comm/controller/CommQueryController.java

@@ -0,0 +1,32 @@
+package com.fs.comm.controller;
+
+import com.alibaba.fastjson.JSONObject;
+import com.fs.comm.context.CommAuthContext;
+import com.fs.comm.dto.CommApiResult;
+import com.fs.company.domain.CompanyVoiceRoboticCallLogCallphone;
+import com.fs.company.mapper.CompanyVoiceRoboticCallLogCallphoneMapper;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/comm/query")
+public class CommQueryController {
+
+    @Autowired
+    private CompanyVoiceRoboticCallLogCallphoneMapper callLogCallphoneMapper;
+
+    @GetMapping("/call/{callBackUuid}")
+    public CommApiResult<JSONObject> queryCall(@PathVariable String callBackUuid) {
+        if (CommAuthContext.get() == null) {
+            return CommApiResult.error(401, "未认证");
+        }
+        CompanyVoiceRoboticCallLogCallphone log = callLogCallphoneMapper.selectCallLogByCallbackUuid(callBackUuid);
+        if (log == null) {
+            return CommApiResult.error(404, "未找到外呼记录");
+        }
+        return CommApiResult.success((JSONObject) JSONObject.toJSON(log));
+    }
+}

+ 29 - 0
fs-comm-gateway/src/main/java/com/fs/comm/controller/CommSmsController.java

@@ -0,0 +1,29 @@
+package com.fs.comm.controller;
+
+import com.fs.comm.context.CommAuthContext;
+import com.fs.comm.dto.CommApiResult;
+import com.fs.comm.dto.CommSmsSendRequest;
+import com.fs.comm.service.CommSmsService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Map;
+
+@RestController
+@RequestMapping("/comm/sms")
+public class CommSmsController {
+
+    @Autowired
+    private CommSmsService commSmsService;
+
+    @PostMapping("/send")
+    public CommApiResult<Map<String, Object>> send(@RequestBody CommSmsSendRequest request) {
+        if (CommAuthContext.get() == null) {
+            return CommApiResult.error(401, "未认证");
+        }
+        return CommApiResult.success(commSmsService.sendSms(request));
+    }
+}

+ 27 - 0
fs-comm-gateway/src/main/java/com/fs/comm/dto/CommApiResult.java

@@ -0,0 +1,27 @@
+package com.fs.comm.dto;
+
+import lombok.Builder;
+import lombok.Data;
+
+@Data
+@Builder
+public class CommApiResult<T> {
+
+    private int code;
+
+    private String msg;
+
+    private T data;
+
+    public static <T> CommApiResult<T> success(T data) {
+        return CommApiResult.<T>builder().code(200).msg("success").data(data).build();
+    }
+
+    public static <T> CommApiResult<T> error(String msg) {
+        return CommApiResult.<T>builder().code(500).msg(msg).build();
+    }
+
+    public static <T> CommApiResult<T> error(int code, String msg) {
+        return CommApiResult.<T>builder().code(code).msg(msg).build();
+    }
+}

+ 37 - 0
fs-comm-gateway/src/main/java/com/fs/comm/dto/CommCallSendRequest.java

@@ -0,0 +1,37 @@
+package com.fs.comm.dto;
+
+import lombok.Data;
+
+import java.util.Map;
+
+@Data
+public class CommCallSendRequest {
+
+    private String phone;
+
+    private Long calleeId;
+
+    private Long roboticId;
+
+    private Long businessId;
+
+    private Long gatewayId;
+
+    private Long llmAccountId;
+
+    private String voiceCode;
+
+    private String voiceSource;
+
+    private Long busiGroupId;
+
+    private Integer maxConcurrency;
+
+    private String nodeKey;
+
+    private String workflowInstanceId;
+
+    private String callbackUrl;
+
+    private Map<String, Object> bizParams;
+}

+ 31 - 0
fs-comm-gateway/src/main/java/com/fs/comm/dto/CommSmsSendRequest.java

@@ -0,0 +1,31 @@
+package com.fs.comm.dto;
+
+import lombok.Data;
+
+import java.util.Map;
+
+@Data
+public class CommSmsSendRequest {
+
+    private String phone;
+
+    private Long customerId;
+
+    private Long smsTempId;
+
+    private Long roboticId;
+
+    private Long calleeId;
+
+    private String nodeKey;
+
+    private String workflowInstanceId;
+
+    private Long companyUserId;
+
+    private String senderName;
+
+    private String cardUrl;
+
+    private Map<String, String> templateParams;
+}

+ 24 - 0
fs-comm-gateway/src/main/java/com/fs/comm/exception/CommGlobalExceptionHandler.java

@@ -0,0 +1,24 @@
+package com.fs.comm.exception;
+
+import com.fs.comm.dto.CommApiResult;
+import com.fs.common.exception.ServiceException;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+@Slf4j
+@RestControllerAdvice(basePackages = "com.fs.comm")
+public class CommGlobalExceptionHandler {
+
+    @ExceptionHandler(ServiceException.class)
+    public CommApiResult<Void> handleServiceException(ServiceException e) {
+        log.warn("业务异常: {}", e.getMessage());
+        return CommApiResult.error(e.getMessage());
+    }
+
+    @ExceptionHandler(Exception.class)
+    public CommApiResult<Void> handleException(Exception e) {
+        log.error("系统异常", e);
+        return CommApiResult.error("系统异常: " + e.getMessage());
+    }
+}

+ 30 - 0
fs-comm-gateway/src/main/java/com/fs/comm/metrics/CommMetricsService.java

@@ -0,0 +1,30 @@
+package com.fs.comm.metrics;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * 通讯网关关键指标埋点(QPS/成功率/线路用量)
+ */
+@Slf4j
+@Component
+public class CommMetricsService {
+
+    private final ConcurrentHashMap<String, AtomicLong> counters = new ConcurrentHashMap<>();
+
+    public void increment(String metric) {
+        counters.computeIfAbsent(metric, k -> new AtomicLong()).incrementAndGet();
+    }
+
+    public long getCount(String metric) {
+        AtomicLong counter = counters.get(metric);
+        return counter == null ? 0L : counter.get();
+    }
+
+    public void logSnapshot() {
+        counters.forEach((key, value) -> log.info("comm-metric {}={}", key, value.get()));
+    }
+}

+ 76 - 0
fs-comm-gateway/src/main/java/com/fs/comm/ratelimit/CommRateLimitService.java

@@ -0,0 +1,76 @@
+package com.fs.comm.ratelimit;
+
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.exception.ServiceException;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.data.redis.core.script.DefaultRedisScript;
+import org.springframework.stereotype.Service;
+
+import java.util.Collections;
+
+/**
+ * 租户级 QPS 令牌桶限流(Redis Lua 原子操作,支持多实例横向扩容)
+ */
+@Slf4j
+@Service
+public class CommRateLimitService {
+
+    private static final String TOKEN_BUCKET_LUA =
+            "local key = KEYS[1]\n" +
+            "local capacity = tonumber(ARGV[1])\n" +
+            "local rate = tonumber(ARGV[2])\n" +
+            "local now = tonumber(ARGV[3])\n" +
+            "local requested = tonumber(ARGV[4])\n" +
+            "local data = redis.call('HMGET', key, 'tokens', 'timestamp')\n" +
+            "local tokens = tonumber(data[1])\n" +
+            "local timestamp = tonumber(data[2])\n" +
+            "if tokens == nil then tokens = capacity timestamp = now end\n" +
+            "local delta = math.max(0, now - timestamp)\n" +
+            "tokens = math.min(capacity, tokens + delta * rate)\n" +
+            "timestamp = now\n" +
+            "if tokens < requested then\n" +
+            "  redis.call('HMSET', key, 'tokens', tokens, 'timestamp', timestamp)\n" +
+            "  redis.call('EXPIRE', key, 60)\n" +
+            "  return 0\n" +
+            "end\n" +
+            "tokens = tokens - requested\n" +
+            "redis.call('HMSET', key, 'tokens', tokens, 'timestamp', timestamp)\n" +
+            "redis.call('EXPIRE', key, 60)\n" +
+            "return 1";
+
+    @Value("${comm.gateway.tenant-qps-limit:200}")
+    private int tenantQpsLimit;
+
+    @Autowired
+    private StringRedisTemplate stringRedisTemplate;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    public void checkTenantQps(Long tenantId) {
+        if (tenantId == null) {
+            return;
+        }
+        String key = "comm:ratelimit:tenant:" + tenantId;
+        DefaultRedisScript<Long> script = new DefaultRedisScript<>(TOKEN_BUCKET_LUA, Long.class);
+        Long allowed = stringRedisTemplate.execute(script, Collections.singletonList(key),
+                String.valueOf(tenantQpsLimit),
+                String.valueOf(tenantQpsLimit),
+                String.valueOf(System.currentTimeMillis() / 1000.0),
+                "1");
+        if (allowed == null || allowed == 0L) {
+            throw new ServiceException("租户请求频率超限,请稍后重试");
+        }
+    }
+
+    public boolean tryLock(String lockKey, long ttlSeconds) {
+        return redisCache.setIfAbsent(lockKey, "1", ttlSeconds, java.util.concurrent.TimeUnit.SECONDS);
+    }
+
+    public void unlock(String lockKey) {
+        redisCache.deleteObject(lockKey);
+    }
+}

+ 28 - 0
fs-comm-gateway/src/main/java/com/fs/comm/security/CommAuthenticationEntryPoint.java

@@ -0,0 +1,28 @@
+package com.fs.comm.security;
+
+import com.alibaba.fastjson.JSON;
+import com.fs.common.constant.HttpStatus;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.web.AuthenticationEntryPoint;
+import org.springframework.stereotype.Component;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.io.Serializable;
+
+@Component("commAuthenticationEntryPoint")
+public class CommAuthenticationEntryPoint implements AuthenticationEntryPoint, Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @Override
+    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e)
+            throws IOException {
+        String msg = StringUtils.format("请求访问:{},认证失败,无法访问通讯网关", request.getRequestURI());
+        ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.error(HttpStatus.UNAUTHORIZED, msg)));
+    }
+}

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

@@ -0,0 +1,156 @@
+package com.fs.comm.security;
+
+import com.alibaba.fastjson.JSON;
+import com.fs.comm.auth.CommSession;
+import com.fs.comm.auth.CommTokenService;
+import com.fs.comm.context.CommAuthContext;
+import com.fs.common.config.RedisTenantContext;
+import com.fs.common.constant.HttpStatus;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.enums.DataSourceType;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.core.config.TenantConfigContext;
+import com.fs.framework.datasource.DynamicDataSourceContextHolder;
+import com.fs.framework.datasource.TenantDataSourceManager;
+import com.fs.config.saas.ProjectConfig;
+import com.fs.system.domain.SysConfig;
+import com.fs.system.mapper.SysConfigMapper;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.util.Collections;
+
+@Component
+public class CommTokenAuthFilter extends OncePerRequestFilter {
+
+    public static final String HEADER_INTERNAL_SECRET = "X-Comm-Internal-Secret";
+    public static final String HEADER_TENANT_ID = "X-Comm-Tenant-Id";
+    public static final String HEADER_COMPANY_ID = "X-Comm-Company-Id";
+    public static final String HEADER_COMPANY_USER_ID = "X-Comm-Company-User-Id";
+
+    @Value("${comm.gateway.internal-secret:}")
+    private String internalSecret;
+
+    @Autowired
+    private CommTokenService commTokenService;
+
+    @Autowired
+    private TenantDataSourceManager tenantDataSourceManager;
+
+    @Autowired
+    private SysConfigMapper sysConfigMapper;
+
+    @Override
+    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
+            throws ServletException, IOException {
+        String path = request.getRequestURI();
+        try {
+            CommSession session = resolveSession(request);
+            if (session != null) {
+                commTokenService.verifySession(session);
+                if (session.getTenantId() != null) {
+                    tenantDataSourceManager.ensureSwitchByTenantId(session.getTenantId());
+                    loadTenantConfig(session.getTenantId());
+                    RedisTenantContext.setTenantId(session.getTenantId());
+                } else {
+                    DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+                }
+                CommAuthContext.set(session);
+                UsernamePasswordAuthenticationToken authentication =
+                        new UsernamePasswordAuthenticationToken(session, null, Collections.emptyList());
+                SecurityContextHolder.getContext().setAuthentication(authentication);
+            } else if (requiresAuth(path)) {
+                writeUnauthorized(response, "未授权,请先获取通讯网关 Token 或使用内部调用头");
+                return;
+            }
+            chain.doFilter(request, response);
+        } finally {
+            ProjectConfig.clearTenantConfigs();
+            TenantConfigContext.clear();
+            RedisTenantContext.clear();
+            CommAuthContext.clear();
+            tenantDataSourceManager.clear();
+            DynamicDataSourceContextHolder.clearDataSourceType();
+            SecurityContextHolder.clearContext();
+        }
+    }
+
+    private CommSession resolveSession(HttpServletRequest request) {
+        String path = request.getRequestURI();
+        if (path.startsWith("/comm/auth/token") || path.startsWith("/comm/callback/")) {
+            return null;
+        }
+        CommSession session = commTokenService.getSession(request);
+        if (session != null) {
+            return session;
+        }
+        if (isInternalRequest(request)) {
+            Long tenantId = parseLongHeader(request, HEADER_TENANT_ID);
+            Long companyId = parseLongHeader(request, HEADER_COMPANY_ID);
+            Long companyUserId = parseLongHeader(request, HEADER_COMPANY_USER_ID);
+            if (tenantId != null && companyId != null) {
+                return commTokenService.createInternalSession(tenantId, companyId, companyUserId, "internal");
+            }
+        }
+        return null;
+    }
+
+    private boolean requiresAuth(String path) {
+        if (!path.startsWith("/comm/")) {
+            return false;
+        }
+        return !path.startsWith("/comm/auth/token")
+                && !path.startsWith("/comm/callback/");
+    }
+
+    private boolean isInternalRequest(HttpServletRequest request) {
+        if (StringUtils.isBlank(internalSecret)) {
+            return false;
+        }
+        String secret = request.getHeader(HEADER_INTERNAL_SECRET);
+        return internalSecret.equals(secret);
+    }
+
+    private Long parseLongHeader(HttpServletRequest request, String headerName) {
+        String value = request.getHeader(headerName);
+        if (StringUtils.isBlank(value)) {
+            return null;
+        }
+        try {
+            return Long.valueOf(value);
+        } catch (NumberFormatException ex) {
+            return null;
+        }
+    }
+
+    private void loadTenantConfig(Long tenantId) {
+        if (tenantId == null) {
+            return;
+        }
+        SysConfig cfg = sysConfigMapper.selectConfigByConfigKey("projectConfig");
+        if (cfg != null && StringUtils.isNotBlank(cfg.getConfigValue())) {
+            TenantConfigContext.set(com.alibaba.fastjson.JSONObject.parseObject(cfg.getConfigValue()));
+            ProjectConfig.loadTenantConfigsFromContext();
+        }
+    }
+
+    @Override
+    protected boolean shouldNotFilter(HttpServletRequest request) {
+        return false;
+    }
+
+    public static void writeUnauthorized(HttpServletResponse response, String msg) throws IOException {
+        AjaxResult result = AjaxResult.error(HttpStatus.UNAUTHORIZED, msg);
+        ServletUtils.renderString(response, JSON.toJSONString(result));
+    }
+}

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

@@ -0,0 +1,63 @@
+package com.fs.comm.service;
+
+import com.fs.comm.context.CommAuthContext;
+import com.fs.comm.dto.CommCallSendRequest;
+import com.fs.comm.metrics.CommMetricsService;
+import com.fs.comm.model.CommCallSendParam;
+import com.fs.comm.model.CommCallSendResult;
+import com.fs.comm.ratelimit.CommRateLimitService;
+import com.fs.common.exception.ServiceException;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.HashMap;
+import java.util.Map;
+
+@Slf4j
+@Service
+public class CommCallService {
+
+    @Autowired
+    private CommRateLimitService commRateLimitService;
+
+    @Autowired
+    private CommGatewayLineAuthService commGatewayLineAuthService;
+
+    @Autowired
+    private CommCallSendService commCallSendService;
+
+    @Autowired
+    private CommMetricsService commMetricsService;
+
+    public Map<String, Object> sendCall(CommCallSendRequest request) {
+        Long companyId = CommAuthContext.getCompanyId();
+        Long tenantId = CommAuthContext.getTenantId();
+        if (companyId == null) {
+            throw new ServiceException("未获取到公司信息");
+        }
+        commRateLimitService.checkTenantQps(tenantId);
+        commGatewayLineAuthService.validateGateway(companyId, request.getGatewayId());
+
+        CommCallSendResult result = commCallSendService.sendWorkflowCall(CommCallSendParam.builder()
+                .roboticId(request.getRoboticId())
+                .calleeId(request.getCalleeId())
+                .businessId(request.getBusinessId())
+                .gatewayId(request.getGatewayId())
+                .nodeKey(request.getNodeKey())
+                .workflowInstanceId(request.getWorkflowInstanceId())
+                .companyId(companyId)
+                .tenantId(tenantId)
+                .callbackUrl(request.getCallbackUrl())
+                .phone(request.getPhone())
+                .bizParams(request.getBizParams())
+                .build());
+
+        Map<String, Object> response = new HashMap<>();
+        response.put("callBackUuid", result.getCallBackUuid());
+        response.put("batchId", result.getBatchId());
+        response.put("phone", result.getPhone());
+        commMetricsService.increment("call.success");
+        return response;
+    }
+}

+ 86 - 0
fs-comm-gateway/src/main/java/com/fs/comm/service/CommCallbackService.java

@@ -0,0 +1,86 @@
+package com.fs.comm.service;
+
+import com.alibaba.fastjson.JSONObject;
+import com.fs.common.config.RedisTenantContext;
+import com.fs.common.enums.DataSourceType;
+import com.fs.common.utils.StringUtils;
+import com.fs.company.service.ICompanyVoiceRoboticService;
+import com.fs.common.service.ISmsService;
+import com.fs.company.vo.CdrDetailVo;
+import com.fs.common.core.domain.model.TenantPrincipal;
+import com.fs.framework.datasource.DynamicDataSourceContextHolder;
+import com.fs.framework.datasource.TenantDataSourceManager;
+import com.fs.wxcid.utils.TenantHelper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.stereotype.Service;
+
+import java.util.Collections;
+
+@Slf4j
+@Service
+public class CommCallbackService {
+
+    @Autowired
+    private ICompanyVoiceRoboticService companyVoiceRoboticService;
+
+    @Autowired
+    private ISmsService smsService;
+
+    @Autowired
+    private TenantDataSourceManager tenantDataSourceManager;
+
+    public String handleEasyCallCallback(String cdrStr) {
+        CdrDetailVo cdrDetailVo = JSONObject.parseObject(cdrStr, CdrDetailVo.class);
+        companyVoiceRoboticService.callerResult4EasyCall(cdrDetailVo);
+        return "success";
+    }
+
+    public String handleSmsCallback(String json) {
+        Long tenantId = extractTenantId(json);
+        boolean switched = false;
+        try {
+            if (tenantId != null) {
+                switched = switchTenantContext(tenantId);
+            }
+            return smsService.smsNotify(json);
+        } finally {
+            if (switched) {
+                clearTenantContext();
+            }
+        }
+    }
+
+    private Long extractTenantId(String json) {
+        if (StringUtils.isBlank(json)) {
+            return null;
+        }
+        try {
+            JSONObject obj = JSONObject.parseObject(json);
+            if (obj.containsKey("tenantId")) {
+                return obj.getLong("tenantId");
+            }
+        } catch (Exception ignored) {
+        }
+        return null;
+    }
+
+    private boolean switchTenantContext(Long tenantId) {
+        TenantHelper.setTenantId(tenantId);
+        tenantDataSourceManager.ensureSwitchByTenantId(tenantId);
+        SecurityContextHolder.getContext().setAuthentication(
+                new UsernamePasswordAuthenticationToken(new TenantPrincipal(tenantId), null, Collections.emptyList()));
+        RedisTenantContext.setTenantId(tenantId);
+        return true;
+    }
+
+    private void clearTenantContext() {
+        TenantHelper.removeTenantId();
+        RedisTenantContext.clear();
+        tenantDataSourceManager.clear();
+        DynamicDataSourceContextHolder.clearDataSourceType();
+        SecurityContextHolder.clearContext();
+    }
+}

+ 73 - 0
fs-comm-gateway/src/main/java/com/fs/comm/service/CommGatewayLineAuthService.java

@@ -0,0 +1,73 @@
+package com.fs.comm.service;
+
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.fs.common.exception.ServiceException;
+import com.fs.common.utils.StringUtils;
+import com.fs.company.mapper.CompanyMapper;
+import com.fs.company.service.easycall.IEasyCallService;
+import com.fs.company.vo.easycall.EasyCallGatewayVO;
+import com.fs.system.service.ISysConfigService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * 外呼线路(gatewayId)归属鉴权
+ */
+@Slf4j
+@Service
+public class CommGatewayLineAuthService {
+
+    @Autowired
+    private IEasyCallService easyCallService;
+
+    @Autowired
+    private CompanyMapper companyMapper;
+
+    @Autowired
+    private ISysConfigService configService;
+
+    public void validateGateway(Long companyId, Long gatewayId) {
+        if (gatewayId == null) {
+            throw new ServiceException("gatewayId不能为空");
+        }
+        List<EasyCallGatewayVO> allowed = easyCallService.getGatewayList(companyId);
+        if (allowed == null || allowed.isEmpty()) {
+            validateByConfigOnly(companyId, gatewayId);
+            return;
+        }
+        boolean matched = allowed.stream().anyMatch(item -> gatewayId.equals(item.getId()));
+        if (!matched) {
+            throw new ServiceException("无权使用该外呼线路: " + gatewayId);
+        }
+    }
+
+    private void validateByConfigOnly(Long companyId, Long gatewayId) {
+        String gateWayList = companyMapper.getGateWayList(companyId);
+        if (StringUtils.isNotBlank(gateWayList)) {
+            List<Long> ids = Arrays.stream(gateWayList.split(","))
+                    .map(String::trim).filter(StringUtils::isNotBlank).map(Long::valueOf).collect(Collectors.toList());
+            if (!ids.contains(gatewayId)) {
+                throw new ServiceException("无权使用该外呼线路");
+            }
+            return;
+        }
+        String json = configService.selectConfigByKey("cId.config");
+        if (StringUtils.isBlank(json)) {
+            return;
+        }
+        JSONObject obj = JSONObject.parseObject(json);
+        JSONArray showGatewayIds = obj.getJSONArray("showGatewayIds");
+        if (showGatewayIds != null && !showGatewayIds.isEmpty()) {
+            List<Long> ids = showGatewayIds.stream().map(o -> Long.valueOf(o.toString())).collect(Collectors.toList());
+            if (!ids.contains(gatewayId)) {
+                throw new ServiceException("无权使用该外呼线路");
+            }
+        }
+    }
+}

+ 56 - 0
fs-comm-gateway/src/main/java/com/fs/comm/service/CommSmsService.java

@@ -0,0 +1,56 @@
+package com.fs.comm.service;
+
+import com.fs.comm.context.CommAuthContext;
+import com.fs.comm.dto.CommSmsSendRequest;
+import com.fs.comm.metrics.CommMetricsService;
+import com.fs.comm.model.CommSmsSendParam;
+import com.fs.comm.model.CommSmsSendResult;
+import com.fs.comm.ratelimit.CommRateLimitService;
+import com.fs.common.exception.ServiceException;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.HashMap;
+import java.util.Map;
+
+@Slf4j
+@Service
+public class CommSmsService {
+
+    @Autowired
+    private CommRateLimitService commRateLimitService;
+
+    @Autowired
+    private CommSmsSendService commSmsSendService;
+
+    @Autowired
+    private CommMetricsService commMetricsService;
+
+    public Map<String, Object> sendSms(CommSmsSendRequest request) {
+        Long companyId = CommAuthContext.getCompanyId();
+        Long tenantId = CommAuthContext.getTenantId();
+        commRateLimitService.checkTenantQps(tenantId);
+
+        CommSmsSendResult result = commSmsSendService.sendWorkflowSms(CommSmsSendParam.builder()
+                .roboticId(request.getRoboticId())
+                .calleeId(request.getCalleeId())
+                .smsTempId(request.getSmsTempId())
+                .nodeKey(request.getNodeKey())
+                .workflowInstanceId(request.getWorkflowInstanceId())
+                .companyId(companyId)
+                .companyUserId(request.getCompanyUserId() != null ? request.getCompanyUserId() : CommAuthContext.getCompanyUserId())
+                .senderName(request.getSenderName())
+                .phone(request.getPhone())
+                .customerId(request.getCustomerId())
+                .cardUrl(request.getCardUrl())
+                .build());
+
+        Map<String, Object> response = new HashMap<>();
+        response.put("callbackUuid", result.getCallbackUuid());
+        response.put("customerId", result.getCustomerId());
+        response.put("phone", result.getPhone());
+        commMetricsService.increment("sms.success");
+        return response;
+    }
+}

+ 31 - 0
fs-comm-gateway/src/main/java/com/fs/framework/config/SecurityConfig.java

@@ -0,0 +1,31 @@
+package com.fs.framework.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.web.SecurityFilterChain;
+
+/**
+ * 通讯网关 Spring Security(对齐 fs-wx-api 思路:全部放行,鉴权由 CommTokenAuthFilter 负责)
+ * <p>
+ * 不使用 WebSecurityConfigurerAdapter / AuthenticationManager,避免 Spring Security 5.7 循环依赖。
+ */
+@Configuration
+@EnableWebSecurity
+public class SecurityConfig {
+
+    @Bean
+    public SecurityFilterChain commGatewaySecurityFilterChain(HttpSecurity http) throws Exception {
+        http.csrf().disable()
+                .authorizeRequests()
+                .antMatchers("/**").permitAll();
+        return http.build();
+    }
+
+    @Bean
+    public BCryptPasswordEncoder bCryptPasswordEncoder() {
+        return new BCryptPasswordEncoder();
+    }
+}

+ 1 - 0
fs-comm-gateway/src/main/resources/META-INF/spring-devtools.properties

@@ -0,0 +1 @@
+restart.include.json=/com.alibaba.fastjson.*.jar

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

@@ -0,0 +1,155 @@
+# 项目相关配置
+fs:
+  # 名称
+  name: fs
+  # 版本
+  version: 1.1.0
+  # 版权年份
+  copyrightYear: 2021
+  # 实例演示开关
+  demoEnabled: true
+  # 文件路径 示例( Windows配置D:/fs/uploadPath,Linux配置 /home/fs/uploadPath)
+  profile: c:/fs/uploadPath
+  # 获取ip地址开关
+  addressEnabled: false
+  # 验证码类型 math 数组计算 char 字符验证
+  captchaType: math
+#  jwt:
+#    # 加密秘钥
+#    secret: f4e2e52034348f86b67cde581c0f9eb5
+#    # token有效时长,7天,单位秒
+#    expire: 31536000
+#    header: AppToken
+# 开发环境配置
+server:
+  servlet:
+    # 应用的访问路径
+    context-path: /
+  tomcat:
+    # tomcat的URI编码
+    uri-encoding: UTF-8
+    # tomcat最大线程数,默认为200
+    max-threads: 800
+    # Tomcat启动初始化的线程数,默认值25
+    min-spare-threads: 30
+
+# 日志配置
+logging:
+  level:
+    com.fs: info
+    org.springframework: warn
+
+express:
+  omsCode: "SF.0235402855"
+# Spring配置
+spring:
+  main:
+    allow-circular-references: true
+  cache:
+    type: redis
+  # 资源信息
+  messages:
+    # 国际化资源文件路径
+    basename: i18n/messages
+  mvc:
+    async:
+      request-timeout: 600000
+
+  # 文件上传
+  servlet:
+     multipart:
+       # 单个文件大小
+       max-file-size:  3GB
+       # 设置总上传的文件大小
+       max-request-size:  3GB
+  # 服务模块
+  devtools:
+    restart:
+      # 热部署开关
+      enabled: true
+
+
+# token配置
+token:
+    # 令牌自定义标识
+    header: Authorization
+    # 令牌密钥
+    secret: YlrzSaas2026SecKey!@#QwErTyUiOpAsDfGhJkLzXcVbNm
+    # 令牌有效期(默认30分钟)
+    expireTime: 720
+mybatis-plus:
+  # 搜索指定包别名
+  typeAliasesPackage: com.fs.**.domain,com.fs.**.bo
+  # 配置mapper的扫描,找到所有的mapper.xml映射文件
+  mapperLocations: classpath*:/mapper/**/*.xml
+  configLocation: classpath:mybatis/mybatis-config.xml
+  # 全局配置
+  global-config:
+    db-config:
+      # 主键类型  0:"数据库ID自增", 1:"用户输入ID",2:"全局唯一ID (数字类型唯一ID)", 3:"全局唯一ID UUID";
+      idType: AUTO
+      # 字段策略 0:"忽略判断",1:"非 NULL 判断"),2:"非空判断"
+      fieldStrategy: NOT_EMPTY
+    banner: false
+    # 配置
+  configuration:
+    # 驼峰式命名
+    mapUnderscoreToCamelCase: true
+    # 全局映射器启用缓存
+    cacheEnabled: true
+    # 配置默认的执行器
+    defaultExecutorType: REUSE
+    # 允许 JDBC 支持自动生成主键
+    useGeneratedKeys: true
+
+# MyBatis配置
+mybatis:
+    # 搜索指定包别名
+    typeAliasesPackage: com.fs.**.domain
+    # 配置mapper的扫描,找到所有的mapper.xml映射文件
+    mapperLocations: classpath*:mapper/**/*Mapper.xml
+    # 加载全局的配置文件
+    configLocation: classpath:mybatis/mybatis-config.xml
+
+# PageHelper分页插件
+pagehelper:
+  helperDialect: mysql
+  reasonable: false #超出后不显示
+  supportMethodsArguments: false
+  params: count=countSql
+
+# Swagger配置
+swagger:
+  # 是否开启swagger
+  enabled: false
+  # 请求前缀
+  pathMapping: /dev-api
+
+# 防止XSS攻击
+xss:
+  # 过滤开关
+  enabled: true
+  # 排除链接(多个用逗号分隔)
+  excludes: /system/notice,/system/config/*
+  # 匹配链接
+  urlPatterns: /system/*,/monitor/*,/tool/*
+zhyf:
+  url: https://zhyf-testController.jingpai.com
+
+image:
+  storage:
+    local-path: C:\logoFile\logo.jpg
+    server-path: C:\logoFile\logo.jpg
+# application.properties
+wechat:
+  api:
+    base-url: https://api.weixin.qq.com
+    upload-shipping-info: /wxa/sec/order/upload_shipping_info
+hsy:
+  access_key: AKLTZTc4YTE4ZjI2OWViNDNjZGI2NjhiYTI5Njc5ZjA1Mzk
+  secret_key: WXpjelpUYzFOakF5TUdObE5EZGtNR0ZsWXpKaU1tTmtZakk1WXpObE4yRQ==
+  region: cn-north-1
+  role_access_key: AKLTNmMwNjJkNDFhYTVjNDIzYzhhNzEyZmZmZTlmYzBhNGM
+  role_secret_key: T0RaaFl6UmhZV1V4WXpKbU5EWTBNMkZpT0RNNU9UY3daak0wTjJFd09XUQ==
+  role_trn: trn:iam::2114522511:role/hylj
+

+ 137 - 0
fs-comm-gateway/src/main/resources/application-config-dev.yml

@@ -0,0 +1,137 @@
+baidu:
+  token: 12313231232
+  back-domain: https://www.xxxx.com
+#配置
+logging:
+  level:
+    org.springframework.web: debug
+    com.github.binarywang.demo.wx.cp: DEBUG
+    me.chanjar.weixin: DEBUG
+#wx:
+#  miniapp:
+#    configs:
+#      - appid: wx29d26f63f836be7f
+#        secret: 7542db9774355a89b1adce24defb6013
+#        token: Ncbnd7lJvkripVOpyTFAna6NAWCxCrvC
+#        aesKey: HlEiBB55eaWUaeBVAQO3cWKWPYv1vOVQSq7nFNICw4E
+#        msgDataFormat: JSON
+#  cp:
+#    corpId: wwb2a1055fb6c9a7c2
+#    appConfigs:
+#      - agentId: 1000005
+#        secret: ec7okROXJqkNafq66-L6aKNv0asTzQIG0CYrj3vyBbo
+#        token: PPKOdAlCoMO
+#        aesKey: PKvaxtpSv8NGpfTDm7VUHIK8Wok2ESyYX24qpXJAdMP
+#  pay:
+#    appId: wx73f85f8d62769119 #微信公众号或者小程序等的appid
+#    mchId: 1611402045 #微信支付商户号
+#    mchKey: 8cab128997a3547c1363b0898b877f38 #微信支付商户密钥
+#    subAppId:  #服务商模式下的子商户公众账号ID
+#    subMchId:  #服务商模式下的子商户号
+#    keyPath: c:\\cert\\apiclient_cert.p12 # p12证书的位置,可以指定绝对路径,也可以指定类路径(以classpath:开头)
+#    notifyUrl: https://userapp.his.runtzh.com/app/wxpay/wxPayNotify
+#  mp:
+#    useRedis: false
+#    redisConfig:
+#      host: 127.0.0.1
+#      port: 6379
+#      timeout: 2000
+#    configs:
+#      - appId: wx93ce67750e3cfba3 # 第一个公众号的appid  //公众号名称:云联融智
+#        secret: c172884087264160563bfe5775ca0f6f # 公众号的appsecret
+#        token: PPKOdAlCoMO # 接口配置里的Token值
+#        aesKey: Eswa6VjwtVMCcw03qZy6fWllgrv5aytIA1SZPEU0kU2 # 接口配置里的EncodingAESKey值
+#aifabu:  #爱链接
+#  appKey: 7b471be905ab17e00f3b858c6710dd117601d008
+#watch:
+#  watchUrl: watch.ylrzcloud.com/prod-api
+#  #  account: tcloud
+#  #  password: mdf-m2h_6yw2$hq
+#  account1: ccif #866655060138751
+#  password1: cp-t5or_6xw7$mt
+#  account2: tcloud #rt500台
+#  password2: mdf-m2h_6yw2$hq
+#  account3: whr
+#  password3: v9xsKuqn_$d2y
+#
+#fs :
+#  commonApi: http://172.16.0.16:8010
+#  h5CommonApi: http://119.29.195.254:8010
+#  jwt:
+#    # 加密秘钥
+#    secret: e10adc3949ba59abbe56e057f20f883e
+#    # token有效时长,7天,单位秒
+#    expire: 31536000
+#    header: AppToken
+#nuonuo:
+#  key: 10924508
+#  secret: A2EB20764D304D16
+#
+## 存储捅配置
+#tencent_cloud_config:
+#  secret_id: AKIDiMq9lDf2EOM9lIfqqfKo7FNgM5meD0sT
+#  secret_key: u5SuS80342xzx8FRBukza9lVNHKNMSaB
+#  bucket: myhk-1323137866
+#  app_id: 1323137866
+#  region: ap-chongqing
+#  proxy: myhk
+#cloud_host:
+#  company_name: 金康健
+#  projectCode: DEV
+#  spaceName:
+#  volcengineUrl:
+#headerImg:
+#  imgUrl: https://jz-cos-1356808054.cos.ap-chengdu.myqcloud.com/fs/20250515/0877754b59814ea8a428fa3697b20e68.png
+#ipad:
+#  url:
+#  ipadUrl: http://ipad.cdwjyyh.com
+#  aiApi: http://152.136.202.157:3000/api
+#  voiceApi:
+#  commonApi:
+#wx_miniapp_temp:
+#  pay_order_temp_id:
+#  inquiry_temp_id:
+## 聚水潭API配置
+#jst:
+##  app_key: a4b1fab173c84f67b3873857eea11d90 #聚水潭2025-07-25
+#  app_key: 871348458a964548a72bf8124cf917a4 #聚水潭2025-08-14
+#  app_secret: 5b7d9369dbcd414db45089bc047ebe1a #聚水潭2025-08-14
+##  app_secret: dfce1f8dc8a64ddc91212fc3fcdd9349 #聚水潭2025-07-25
+#  authorization_code: 666666
+#  shop_code: "18461733"
+#
+## RocketMQ配置
+#rocketmq:
+#  name-server: 127.0.0.1:9876
+#  producer:
+#    group: event-feedback-producer
+#    send-message-timeout: 3000
+#    retry-times-when-send-failed: 2
+#    retry-times-when-send-async-failed: 2
+#    max-message-size: 4194304
+#    compress-message-body-threshold: 4096
+#    retry-next-server: true
+#custom:
+#  token: "1o62d3YxvdHd4LEUiltnu7sK"
+#  encoding-aes-key: "UJfTQ5qKTKlegjkXtp1YuzJzxeHlUKvq5GyFbERN1iU"
+#  corp-id: "ww51717e2b71d5e2d3"configValue
+#  secret: "6ODAmw-8W4t6h9mdzHh2Z4Apwj8mnsyRnjEDZOHdA7k"
+#  private-key-path: "privatekey.pem"
+#  webhook-url: "https://your-server.com/wecom/archive"
+## token配置
+#token:
+#  # 令牌自定义标识
+#  header: Authorization
+#  # 令牌密钥
+#  secret: abcdefghijklmnopqrstuvwxyz
+#  # 令牌有效期(默认30分钟)
+#  expireTime: 180
+#openIM:
+#  secret: openIM123
+#  userID: imAdmin
+#  url: https://web.jnmyim.ylrzfs.com/api
+##是否为新商户,新商户不走mpOpenId
+#isNewWxMerchant: true
+##是否使用新im
+#im:
+#  type: OPENIM

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

@@ -0,0 +1,132 @@
+# 数据源配置
+spring:
+    # redis 配置
+    redis:
+        # 地址
+        host: localhost
+        # 端口,默认为6379
+        port: 6379
+        # 数据库索引
+        database: 0
+        # 密码
+        password:
+        # 连接超时时间
+        timeout: 20s
+        lettuce:
+            pool:
+                # 连接池中的最小空闲连接
+                min-idle: 0
+                # 连接池中的最大空闲连接
+                max-idle: 8
+                # 连接池的最大数据库连接数
+                max-active: 8
+                # #连接池最大阻塞等待时间(使用负值表示没有限制)
+                max-wait: -1ms
+    datasource:
+        mysql:
+            type: com.alibaba.druid.pool.DruidDataSource
+            driverClassName: com.mysql.cj.jdbc.Driver
+            druid:
+                initialSize: 5
+                minIdle: 10
+                maxActive: 20
+                maxWait: 60000
+                timeBetweenEvictionRunsMillis: 60000
+                minEvictableIdleTimeMillis: 300000
+                maxEvictableIdleTimeMillis: 900000
+                validationQuery: SELECT 1 FROM DUAL
+                testWhileIdle: true
+                testOnBorrow: false
+                testOnReturn: false
+                # 主库数据源
+                master:
+                    url: jdbc:mysql://cq-cdb-8fjmemkb.sql.tencentcdb.com:27220/ylrz_saas?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&useSSL=false
+                    username: root
+                    password: Ylrz_1q2w3e4r5t6y
+                    # 初始连接数
+                    initialSize: 5
+                    # 最小连接池数量
+                    minIdle: 10
+                    # 最大连接池数量
+                    maxActive: 20
+                    # 配置获取连接等待超时的时间
+                    maxWait: 60000
+                    # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
+                    timeBetweenEvictionRunsMillis: 60000
+                    # 配置一个连接在池中最小生存的时间,单位是毫秒
+                    minEvictableIdleTimeMillis: 300000
+                    # 配置一个连接在池中最大生存的时间,单位是毫秒
+                    maxEvictableIdleTimeMillis: 900000
+                    # 配置检测连接是否有效
+                    validationQuery: SELECT 1 FROM DUAL
+                    testWhileIdle: true
+                    testOnBorrow: false
+                    testOnReturn: false
+                    webStatFilter:
+                        enabled: true
+                    statViewServlet:
+                        enabled: true
+                        # 设置白名单,不填则允许所有访问
+                        allow:
+                        url-pattern: /druid/*
+                        # 控制台管理用户名和密码
+                        login-username: fs
+                        login-password: 123456
+                    filter:
+                        stat:
+                            enabled: true
+                            # 慢SQL记录
+                            log-slow-sql: true
+                            slow-sql-millis: 1000
+                            merge-sql: true
+                        wall:
+                            config:
+                                multi-statement-allow: true
+        easycall:
+            type: com.alibaba.druid.pool.DruidDataSource
+            driverClassName: com.mysql.cj.jdbc.Driver
+            druid:
+                # 主库数据源
+                master:
+                    url: jdbc:mysql://129.28.164.235:3306/easycallcenter365?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
+                    username: root
+                    password: easycallcenter365
+                # 初始连接数
+                initialSize: 5
+                # 最小连接池数量
+                minIdle: 10
+                # 最大连接池数量
+                maxActive: 20
+                # 配置获取连接等待超时的时间
+                maxWait: 60000
+                # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
+                timeBetweenEvictionRunsMillis: 60000
+                # 配置一个连接在池中最小生存的时间,单位是毫秒
+                minEvictableIdleTimeMillis: 300000
+                # 配置一个连接在池中最大生存的时间,单位是毫秒
+                maxEvictableIdleTimeMillis: 900000
+                # 配置检测连接是否有效
+                validationQuery: SELECT 1 FROM DUAL
+                testWhileIdle: true
+                testOnBorrow: false
+                testOnReturn: false
+                webStatFilter:
+                    enabled: true
+                statViewServlet:
+                    enabled: true
+                    # 设置白名单,不填则允许所有访问
+                    allow:
+                    url-pattern: /druid/*
+                    # 控制台管理用户名和密码
+                    login-username: fs
+                    login-password: 123456
+                filter:
+                    stat:
+                        enabled: true
+                        # 慢SQL记录
+                        log-slow-sql: true
+                        slow-sql-millis: 1000
+                        merge-sql: true
+                    wall:
+                        config:
+                            multi-statement-allow: true

+ 37 - 0
fs-comm-gateway/src/main/resources/i18n/messages.properties

@@ -0,0 +1,37 @@
+#错误消息
+not.null=* 必须填写
+user.jcaptcha.error=验证码错误
+user.jcaptcha.expire=验证码已失效
+user.not.exists=用户不存在/密码错误
+user.password.not.match=用户不存在/密码错误
+user.password.retry.limit.count=密码输入错误{0}次
+user.password.retry.limit.exceed=密码输入错误{0}次,帐户锁定10分钟
+user.password.delete=对不起,您的账号已被删除
+user.blocked=用户已封禁,请联系管理员
+role.blocked=角色已封禁,请联系管理员
+user.logout.success=退出成功
+
+length.not.valid=长度必须在{min}到{max}个字符之间
+
+user.username.not.valid=* 2到20个汉字、字母、数字或下划线组成,且必须以非数字开头
+user.password.not.valid=* 5-50个字符
+ 
+user.email.not.valid=邮箱格式错误
+user.mobile.phone.number.not.valid=手机号格式错误
+user.login.success=登录成功
+user.register.success=注册成功
+user.notfound=请重新登录
+user.forcelogout=管理员强制退出,请重新登录
+user.unknown.error=未知错误,请重新登录
+
+##文件上传消息
+upload.exceed.maxSize=上传的文件大小超出限制的文件大小!<br/>允许的文件最大大小是:{0}MB!
+upload.filename.exceed.length=上传的文件名最长{0}个字符
+
+##权限
+no.permission=您没有数据的权限,请联系管理员添加权限 [{0}]
+no.create.permission=您没有创建数据的权限,请联系管理员添加权限 [{0}]
+no.update.permission=您没有修改数据的权限,请联系管理员添加权限 [{0}]
+no.delete.permission=您没有删除数据的权限,请联系管理员添加权限 [{0}]
+no.export.permission=您没有导出数据的权限,请联系管理员添加权限 [{0}]
+no.view.permission=您没有查看数据的权限,请联系管理员添加权限 [{0}]

+ 21 - 0
fs-comm-gateway/src/main/resources/mybatis/mybatis-config.xml

@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE configuration
+PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
+"http://mybatis.org/dtd/mybatis-3-config.dtd">
+<configuration>
+
+	<settings>
+		<setting name="cacheEnabled"             value="true" />  <!-- 全局映射器启用缓存 -->
+		<setting name="useGeneratedKeys"         value="true" />  <!-- 允许 JDBC 支持自动生成主键 -->
+		<setting name="defaultExecutorType"      value="REUSE" /> <!-- 配置默认的执行器 -->
+		<setting name="logImpl"                  value="SLF4J" /> <!-- 指定 MyBatis 所用日志的具体实现 -->
+		 <setting name="mapUnderscoreToCamelCase" value="true"/>
+	</settings>
+
+
+	<typeHandlers>
+		<typeHandler handler="com.fs.framework.config.ArrayStringTypeHandler"/>
+	</typeHandlers>
+
+
+</configuration>

+ 635 - 0
fs-comm-gateway/src/main/resources/static/chat-aggregate.html

@@ -0,0 +1,635 @@
+<!DOCTYPE html>
+<html lang="zh">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>龙虾引擎 - 聚合聊天</title>
+<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
+<style>
+*{margin:0;padding:0;box-sizing:border-box}
+body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0a0a1a;color:#e0e0e0;height:100vh;overflow:hidden}
+.main{display:flex;height:100vh}
+
+/* 左侧账户列表 */
+.account-panel{width:80px;background:#0d0d1f;border-right:1px solid #1a1a3e;display:flex;flex-direction:column;padding:12px 0}
+.account-item{width:56px;height:56px;border-radius:50%;margin:0 auto 8px;cursor:pointer;position:relative;border:2px solid transparent;transition:.2s;display:flex;align-items:center;justify-content:center;font-size:24px}
+.account-item:hover{border-color:#e94560;transform:scale(1.05)}
+.account-item.active{border-color:#e94560;background:#e9456022}
+.account-item .badge{position:absolute;top:-2px;right:-2px;background:#e94560;color:#fff;border-radius:10px;padding:1px 5px;font-size:10px}
+.account-item.disabled{opacity:.4;cursor:not-allowed}
+
+/* 会话列表 */
+.session-panel{width:280px;background:#1a1a2e;border-right:1px solid #2a2a4a;display:flex;flex-direction:column}
+.session-header{padding:12px 16px;border-bottom:1px solid #2a2a4a}
+.session-header h3{font-size:14px;color:#e94560}
+.session-header .search{margin-top:8px}
+.session-header input{width:100%;padding:6px 10px;background:#0a0a1a;border:1px solid #2a2a4a;border-radius:4px;color:#e0e0e0;font-size:12px}
+.session-list{flex:1;overflow-y:auto;padding:8px}
+.session-item{display:flex;padding:10px;cursor:pointer;border-radius:6px;margin-bottom:4px;transition:.2s}
+.session-item:hover{background:#2a2a4a}
+.session-item.active{background:#0f3460}
+.session-avatar{width:40px;height:40px;border-radius:50%;background:linear-gradient(135deg,#e94560,#f59e0b);display:flex;align-items:center;justify-content:center;font-size:16px;flex-shrink:0}
+.session-info{flex:1;min-width:0;margin-left:10px}
+.session-info .name{font-size:13px;color:#e0e0e0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.session-info .msg{font-size:12px;color:#888;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-top:2px}
+.session-time{font-size:11px;color:#666;text-align:right}
+.session-item.unread .name{font-weight:600;color:#fff}
+.session-item.unread .badge{background:#e94560;color:#fff;border-radius:10px;padding:1px 5px;font-size:10px}
+
+/* 聊天区域 */
+.chat-panel{flex:1;display:flex;flex-direction:column;background:#0a0a1a}
+.chat-header{padding:12px 20px;background:#1a1a2e;border-bottom:1px solid #2a2a4a;display:flex;align-items:center;justify-content:space-between}
+.chat-title .name{font-size:15px;color:#e0e0e0}
+.chat-title .type{font-size:11px;color:#888;margin-left:8px}
+.chat-actions{display:flex;gap:8px}
+.chat-actions button{padding:6px 12px;background:#0f3460;border:none;border-radius:4px;color:#e0e0e0;font-size:12px;cursor:pointer}
+.chat-actions button:hover{background:#1a4a80}
+
+/* 消息列表 */
+.message-list{flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column}
+.message-item{display:flex;margin-bottom:12px;max-width:80%}
+.message-item.sent{align-self:flex-end}
+.message-item.sent .msg-bubble{background:#e94560;color:#fff;border-radius:12px 12px 0 12px}
+.message-item.received{align-self:flex-start}
+.message-item.received .msg-bubble{background:#1a1a2e;color:#e0e0e0;border-radius:12px 12px 12px 0;border:1px solid #2a2a4a}
+.msg-avatar{width:32px;height:32px;border-radius:50%;background:linear-gradient(135deg,#3b82f6,#22c55e);display:flex;align-items:center;justify-content:center;font-size:12px;flex-shrink:0}
+.message-item.sent .msg-avatar{order:2;margin-left:8px}
+.message-item.received .msg-avatar{order:1;margin-right:8px}
+.msg-content{display:flex;flex-direction:column}
+.message-item.sent .msg-content{order:1}
+.message-item.received .msg-content{order:2}
+.msg-bubble{padding:10px 14px;max-width:max-content}
+.msg-text{font-size:13px;line-height:1.5}
+.msg-time{font-size:10px;color:#666;margin-top:4px;text-align:right}
+.message-item.sent .msg-time{color:#fff8}
+
+/* 输入区域 */
+.chat-input{padding:12px 20px;background:#1a1a2e;border-top:1px solid #2a2a4a}
+.input-row{display:flex;gap:10px}
+.input-row textarea{flex:1;padding:10px 14px;background:#0a0a1a;border:1px solid #2a2a4a;border-radius:8px;color:#e0e0e0;font-size:13px;resize:none;min-height:44px;max-height:120px;font-family:inherit}
+.input-row textarea:focus{outline:none;border-color:#e94560}
+.input-row button{padding:10px 24px;background:#e94560;color:#fff;border:none;border-radius:8px;cursor:pointer;font-size:13px;font-weight:500;flex-shrink:0}
+.input-row button:hover{background:#d63850}
+.input-row button:disabled{opacity:.5;cursor:not-allowed}
+
+/* 客户信息面板 */
+.customer-panel{width:280px;background:#1a1a2e;border-left:1px solid #2a2a4a;display:flex;flex-direction:column}
+.customer-header{padding:16px;border-bottom:1px solid #2a2a4a;text-align:center}
+.customer-avatar{width:64px;height:64px;border-radius:50%;background:linear-gradient(135deg,#e94560,#f59e0b);display:flex;align-items:center;justify-content:center;font-size:24px;margin:0 auto}
+.customer-name{font-size:15px;color:#e0e0e0;margin-top:8px}
+.customer-id{font-size:11px;color:#666}
+.customer-tabs{display:flex;border-bottom:1px solid #2a2a4a}
+.customer-tabs .tab{flex:1;padding:8px;text-align:center;font-size:12px;color:#888;cursor:pointer;border-bottom:2px solid transparent}
+.customer-tabs .tab.active{border-color:#e94560;color:#e94560}
+.customer-detail{flex:1;overflow-y:auto;padding:12px}
+.detail-section{margin-bottom:16px}
+.detail-section h4{font-size:12px;color:#888;margin-bottom:8px}
+.detail-row{display:flex;justify-content:space-between;padding:4px 0;font-size:12px}
+.detail-row .label{color:#888}
+.detail-row .value{color:#e0e0e0}
+.tag-list{display:flex;flex-wrap:gap;gap:4px}
+.tag{display:inline-block;padding:3px 8px;background:#0f3460;color:#ccc;border-radius:4px;font-size:11px}
+.record-item{padding:8px;border-bottom:1px solid #1a1a3e}
+.record-item .time{font-size:11px;color:#666}
+.record-item .desc{font-size:12px;color:#e0e0e0;margin-top:2px}
+
+/* 渠道图标 */
+.channel-qw{background:linear-gradient(135deg,#1890ff,#096dd9)}
+.channel-wx{background:linear-gradient(135deg,#07c160,#10b981)}
+.channel-im{background:linear-gradient(135deg,#6366f1,#8b5cf6)}
+.channel-whatsapp{background:linear-gradient(135deg,#25d366,#10b981)}
+.channel-other{background:linear-gradient(135deg,#6b7280,#9ca3af)}
+
+/* 滚动条 */
+::-webkit-scrollbar{width:6px}
+::-webkit-scrollbar-track{background:#0a0a1a}
+::-webkit-scrollbar-thumb{background:#2a2a4a;border-radius:3px}
+::-webkit-scrollbar-thumb:hover{background:#3a3a5a}
+</style>
+</head>
+<body>
+<div id="app" class="main">
+  <!-- 左侧账户列表 -->
+  <div class="account-panel">
+    <div v-for="acc in accounts" :key="acc.id" 
+         :class="['account-item', acc.active ? 'active' : '', acc.connected ? '' : 'disabled']"
+         @click="selectAccount(acc)" :title="acc.name">
+      <span>{{acc.icon}}</span>
+      <span v-if="acc.unread>0" class="badge">{{acc.unread}}</span>
+    </div>
+  </div>
+
+  <!-- 会话列表 -->
+  <div class="session-panel">
+    <div class="session-header">
+      <h3>🗨️ 聊天列表</h3>
+      <div class="search">
+        <input v-model="searchKey" placeholder="搜索联系人..." @keyup="filterSessions">
+      </div>
+    </div>
+    <div class="session-list">
+      <div v-for="sess in filteredSessions" :key="sess.sessionId" 
+           :class="['session-item', sess.sessionId === currentSession?.sessionId ? 'active' : '', sess.unread > 0 ? 'unread' : '']"
+           @click="selectSession(sess)">
+        <div class="session-avatar">{{sess.avatar||'?'}}</div>
+        <div class="session-info">
+          <div class="name">{{sess.name}}</div>
+          <div class="msg">{{sess.lastMsg||'暂无消息'}}</div>
+        </div>
+        <div style="text-align:right;margin-left:8px">
+          <div class="session-time">{{sess.lastTime||''}}</div>
+          <span v-if="sess.unread>0" class="badge">{{sess.unread}}</span>
+        </div>
+      </div>
+      <div v-if="filteredSessions.length===0" style="text-align:center;padding:40px;color:#666;font-size:13px">
+        暂无会话记录
+      </div>
+    </div>
+  </div>
+
+  <!-- 聊天区域 -->
+  <div class="chat-panel">
+    <div v-if="currentSession" class="chat-header">
+      <div class="chat-title">
+        <span class="name">{{currentSession.name}}</span>
+        <span class="type">{{channelName(currentSession.channelType)}}</span>
+      </div>
+      <div class="chat-actions">
+        <button @click="toggleControlMode">{{currentSession.controlMode === 'ai' ? '🤖 AI接管中' : '👤 人工接管'}}</button>
+        <button @click="showCustomerInfo=true">👤 客户信息</button>
+      </div>
+    </div>
+    <div v-else class="chat-header">
+      <div class="chat-title">
+        <span class="name">请选择一个会话</span>
+      </div>
+    </div>
+
+    <div class="message-list" ref="messageList">
+      <div v-for="(msg, idx) in messages" :key="idx" :class="['message-item', msg.sendType === 1 ? 'received' : 'sent']">
+        <div class="msg-avatar">{{msg.sendType === 1 ? '👤' : '🤖'}}</div>
+        <div class="msg-content">
+          <div class="msg-bubble">
+            <div class="msg-text">{{msg.content}}</div>
+          </div>
+          <div class="msg-time">{{msg.time}}</div>
+        </div>
+      </div>
+      <div v-if="messages.length===0" style="text-align:center;padding:60px;color:#666">
+        <div style="font-size:48px;margin-bottom:12px">💬</div>
+        <p>开始与客户聊天</p>
+      </div>
+    </div>
+
+    <div class="chat-input">
+      <div class="input-row">
+        <textarea v-model="inputMsg" placeholder="输入消息..." @keyup.enter="sendMessage"></textarea>
+        <button @click="sendMessage" :disabled="!inputMsg.trim()">发送</button>
+      </div>
+    </div>
+  </div>
+
+  <!-- 客户信息面板 -->
+  <div class="customer-panel" v-if="showCustomerInfo">
+    <div class="customer-header">
+      <div class="customer-avatar">{{currentSession?.avatar||'?'}}</div>
+      <div class="customer-name">{{currentSession?.name||'-'}}</div>
+      <div class="customer-id">{{currentSession?.channelSourceId||'-'}}</div>
+    </div>
+    <div class="customer-tabs">
+      <div :class="['tab', customerTab==='basic'?'active':'']" @click="customerTab='basic'">基本信息</div>
+      <div :class="['tab', customerTab==='tags'?'active':'']" @click="customerTab='tags'">标签</div>
+      <div :class="['tab', customerTab==='records'?'active':'']" @click="customerTab='records'">访问记录</div>
+    </div>
+    <div class="customer-detail">
+      <div v-if="customerTab==='basic'" class="detail-section">
+        <h4>📋 基本信息</h4>
+        <div class="detail-row"><span class="label">渠道</span><span class="value">{{channelName(currentSession?.channelType)}}</span></div>
+        <div class="detail-row"><span class="label">来源ID</span><span class="value">{{currentSession?.channelSourceId||'-'}}</span></div>
+        <div class="detail-row"><span class="label">联系人ID</span><span class="value">{{currentSession?.contactId||'-'}}</span></div>
+        <div class="detail-row"><span class="label">会话ID</span><span class="value">{{currentSession?.sessionId||'-'}}</span></div>
+        <div class="detail-row"><span class="label">创建时间</span><span class="value">{{currentSession?.createTime||'-'}}</span></div>
+      </div>
+      <div v-if="customerTab==='tags'" class="detail-section">
+        <h4>🏷️ 客户标签</h4>
+        <div class="tag-list">
+          <span v-for="tag in customerTags" :key="tag" class="tag">{{tag}}</span>
+        </div>
+        <div v-if="customerTags.length===0" style="color:#666;font-size:12px">暂无标签</div>
+      </div>
+      <div v-if="customerTab==='records'" class="detail-section">
+        <h4>📊 访问记录</h4>
+        <div v-for="record in visitRecords" :key="record.time" class="record-item">
+          <div class="time">{{record.time}}</div>
+          <div class="desc">{{record.desc}}</div>
+        </div>
+        <div v-if="visitRecords.length===0" style="color:#666;font-size:12px">暂无访问记录</div>
+      </div>
+    </div>
+  </div>
+</div>
+
+<script>
+const {createApp, ref, computed, watch, nextTick, onMounted} = Vue;
+    createApp({
+        setup(){
+            const searchKey = ref('');
+            const inputMsg = ref('');
+            const showCustomerInfo = ref(true);
+            const customerTab = ref('basic');
+            const messageList = ref(null);
+            const loading = ref(false);
+            
+            // ====== API配置 ======
+            // 从URL参数获取配置(iframe嵌入时由父窗口传递)
+            const getUrlParam = (name) => {
+                const params = new URLSearchParams(window.location.search);
+                return params.get(name) || '';
+            };
+            
+            // 获取API基路径:URL参数 > 父窗口webpack环境变量 > 默认值
+            const getBaseApi = () => {
+                const fromUrl = getUrlParam('baseApi');
+                if (fromUrl) return fromUrl;
+                try {
+                    if (window.parent && window.parent.process && window.parent.process.env) {
+                        return window.parent.process.env.VUE_APP_BASE_API || '/dev-api';
+                    }
+                } catch(e) {}
+                return '/dev-api';
+            };
+            const BASE_API = getBaseApi();
+            
+            // 获取前端类型:URL参数 > 默认值
+            const getFrontendType = () => {
+                return getUrlParam('frontendType') || 'company';
+            };
+            const FRONTEND_TYPE = getFrontendType();
+            
+            // 从Cookie获取Token
+            const getToken = () => {
+                const match = document.cookie.match(/(?:^|;\s*)Web-Token=([^;]*)/);
+                return match ? match[1] : null;
+            };
+            
+            // 获取租户编码:URL参数 > localStorage
+            const getTenantCode = () => {
+                const fromUrl = getUrlParam('tenantCode');
+                if (fromUrl) return fromUrl;
+                try {
+                    return localStorage.getItem('tenantCode') || '';
+                } catch(e) { return ''; }
+            };
+            
+            // 认证请求工具
+            const request = async (url, options = {}) => {
+                const token = getToken();
+                const tenantCode = getTenantCode();
+                const headers = {
+                    'Content-Type': 'application/json',
+                    'X-Frontend-Type': FRONTEND_TYPE,
+                };
+                if (token) {
+                    headers['Authorization'] = 'Bearer ' + token;
+                }
+                if (tenantCode) {
+                    headers['tenant-code'] = tenantCode;
+                }
+                // 合并自定义headers
+                if (options.headers) {
+                    Object.assign(headers, options.headers);
+                }
+                
+                const fullUrl = url.startsWith('http') ? url : (BASE_API + url);
+                const response = await fetch(fullUrl, {
+                    ...options,
+                    headers,
+                    credentials: 'include',
+                });
+                if (!response.ok) {
+                    const text = await response.text().catch(() => '');
+                    throw new Error(`HTTP ${response.status}: ${text || response.statusText}`);
+                }
+                const data = await response.json();
+                if (data.code === 401) {
+                    // Token过期,通知父窗口刷新
+                    if (window.parent && window.parent.location) {
+                        window.parent.location.reload();
+                    }
+                    throw new Error('登录已过期');
+                }
+                return data;
+            };
+
+            // 账户列表
+            const accounts = ref([]);
+
+            // 会话列表
+            const sessions = ref([]);
+
+            // 当前会话
+            const currentSession = ref(null);
+
+            // 消息列表
+            const messages = ref([]);
+
+            // 客户标签
+            const customerTags = ref([]);
+
+            // 访问记录
+            const visitRecords = ref([]);
+
+            // 当前登录用户的企微账户
+            const currentAccount = ref(null);
+
+            // 过滤后的会话
+            const filteredSessions = computed(()=>{
+                if(!searchKey.value) return sessions.value;
+                const key = searchKey.value.toLowerCase();
+                return sessions.value.filter(s=>{
+                    const name = s.nickName || s.name || '';
+                    return name.toLowerCase().includes(key);
+                });
+            });
+
+            // 加载账户列表
+            const loadAccounts = async () => {
+                try {
+                    // 先获取当前登录用户绑定的企微账户
+                    const res = await request('/qw/user/getMyQwUserList');
+                    if (res.code === 200 || res.code === 0) {
+                        const qwAccounts = (res.data || []).map((acc, idx) => ({
+                            id: acc.id || `qw_${idx}`,
+                            name: acc.qwUserName || '企微账户',
+                            icon: '💼',
+                            active: idx === 0,
+                            connected: true,
+                            unread: 0,
+                            type: 'QW',
+                            corpId: acc.corpId,
+                            qwUserId: acc.qwUserId
+                        }));
+                        
+                        // 添加个微账户占位(后续扩展)
+                        const wxAccounts = []; // 暂时为空,后续实现个微账户绑定
+                        
+                        accounts.value = [...qwAccounts, ...wxAccounts];
+                        
+                        if (accounts.value.length > 0) {
+                            currentAccount.value = accounts.value[0];
+                        }
+                    }
+                } catch (error) {
+                    console.error('加载账户失败:', error);
+                    // 降级显示模拟账户
+                    accounts.value = [
+                        {id:'qw', name:'企业微信', icon:'💼', active:true, connected:true, unread:0, type:'QW'},
+                        {id:'wx', name:'个人微信', icon:'💬', active:false, connected:false, unread:0, type:'WX'},
+                    ];
+                    currentAccount.value = accounts.value[0];
+                }
+            };
+
+            // 加载会话列表
+            const loadSessions = async () => {
+                try {
+                    // 先尝试加载chat会话
+                    const chatRes = await request('/chat/chatSession/list?pageNum=1&pageSize=100');
+                    if (chatRes.code === 200 || chatRes.rows) {
+                        const chatSessions = (chatRes.rows || []).map(s => ({
+                            sessionId: s.sessionId,
+                            name: s.nickName || s.userName || '客户',
+                            avatar: (s.nickName || s.userName || '客').charAt(0),
+                            channelType: 'CHAT',
+                            channelSourceId: s.userId,
+                            contactId: s.userId,
+                            lastMsg: '',
+                            lastTime: s.createTime || '',
+                            unread: 0,
+                            createTime: s.createTime,
+                            status: s.status
+                        }));
+                        
+                        // 再尝试加载企微外部联系人作为会话
+                        const qwRes = await request('/qw/externalContact/list?pageNum=1&pageSize=100');
+                        if (qwRes.code === 200 || qwRes.rows) {
+                            const qwSessions = (qwRes.rows || []).map(c => ({
+                                sessionId: `qw_${c.id}`,
+                                name: c.name || c.remark || '企微客户',
+                                avatar: (c.name || c.remark || '客').charAt(0),
+                                channelType: 'QW',
+                                channelSourceId: c.externalUserId,
+                                contactId: c.id,
+                                lastMsg: '',
+                                lastTime: c.createTime || '',
+                                unread: 0,
+                                createTime: c.createTime,
+                                tagIds: c.tagIds,
+                                customerId: c.customerId,
+                                remarkMobiles: c.remarkMobiles
+                            }));
+                            sessions.value = [...qwSessions, ...chatSessions];
+                        } else {
+                            sessions.value = chatSessions;
+                        }
+                    }
+                } catch (error) {
+                    console.error('加载会话失败:', error);
+                    sessions.value = [];
+                }
+                
+                if (sessions.value.length > 0 && !currentSession.value) {
+                    selectSession(sessions.value[0]);
+                }
+            };
+
+            // 选择账户
+            const selectAccount = (acc)=>{
+                if(!acc.connected) return;
+                accounts.value.forEach(a=>a.active = false);
+                acc.active = true;
+                currentAccount.value = acc;
+                loadSessions();
+            };
+
+            // 选择会话
+            const selectSession = async (sess)=>{
+                currentSession.value = sess;
+                // 清除未读
+                sess.unread = 0;
+                // 加载消息
+                await loadMessages(sess);
+                // 加载客户标签和信息
+                await loadCustomerInfo(sess);
+            };
+
+            // 加载消息
+            const loadMessages = async (session) => {
+                messages.value = [];
+                try {
+                    if (session.channelType === 'CHAT') {
+                        // 加载chat会话消息
+                        const chatDetailRes = await request(`/chat/chatSession/${session.sessionId}`);
+                        if (chatDetailRes.code === 200 || chatDetailRes.data) {
+                            const msgRes = await request(`/chat/chatMsg/list?sessionId=${session.sessionId}&pageNum=1&pageSize=100`);
+                            if (msgRes.rows) {
+                                messages.value = msgRes.rows.map(m => ({
+                                    content: m.content,
+                                    sendType: m.sendType,
+                                    time: m.createTime || new Date().toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'})
+                                }));
+                            }
+                        }
+                    } else if (session.channelType === 'QW') {
+                        // TODO: 加载企微聊天记录
+                        messages.value = [];
+                    }
+                } catch (error) {
+                    console.error('加载消息失败:', error);
+                    messages.value = [];
+                }
+                
+                nextTick(()=>{
+                    if(messageList.value){
+                        messageList.value.scrollTop = messageList.value.scrollHeight;
+                    }
+                });
+            };
+
+            // 加载客户信息
+            const loadCustomerInfo = async (session) => {
+                customerTags.value = [];
+                visitRecords.value = [];
+                
+                try {
+                    if (session.tagIds && session.tagIds !== '[]') {
+                        // 解析标签ID并加载标签名称
+                        const tagIds = JSON.parse(session.tagIds);
+                        if (tagIds.length > 0) {
+                            const tagRes = await request(`/qw/tag/list?tagIds=${tagIds.join(',')}`);
+                            if (tagRes.rows) {
+                                customerTags.value = tagRes.rows.map(t => t.tagName || t.name);
+                            }
+                        }
+                    }
+                    
+                    // 加载CRM客户信息
+                    if (session.customerId) {
+                        const crmRes = await request(`/crm/customer/${session.customerId}`);
+                        if (crmRes.data) {
+                            // 补充客户基本信息
+                            if (crmRes.data.phone) {
+                                visitRecords.value.push({
+                                    time: new Date().toLocaleDateString('zh-CN'),
+                                    desc: `手机号: ${crmRes.data.phone}`
+                                });
+                            }
+                            if (crmRes.data.email) {
+                                visitRecords.value.push({
+                                    time: new Date().toLocaleDateString('zh-CN'),
+                                    desc: `邮箱: ${crmRes.data.email}`
+                                });
+                            }
+                        }
+                    }
+                    
+                    // 加载fastGpt聊天摘要
+                    try {
+                        const chatRes = await request('/fastGpt/fastGptChatSession/list?pageNum=1&pageSize=10');
+                        if (chatRes.rows) {
+                            const relatedChat = chatRes.rows.find(c => 
+                                c.externalUserId === session.channelSourceId || 
+                                c.userId === session.contactId
+                            );
+                            if (relatedChat) {
+                                visitRecords.value.push({
+                                    time: relatedChat.createTime || new Date().toLocaleDateString('zh-CN'),
+                                    desc: `最近聊天: ${relatedChat.lastMsg || '暂无消息'}`
+                                });
+                            }
+                        }
+                    } catch (err) {
+                        console.error('加载聊天摘要失败:', err);
+                    }
+                } catch (error) {
+                    console.error('加载客户信息失败:', error);
+                }
+            };
+
+            // 发送消息
+            const sendMessage = async ()=>{
+                if(!inputMsg.value.trim() || !currentSession.value) return;
+                
+                const msg = {
+                    content: inputMsg.value, 
+                    sendType: 2, 
+                    time: new Date().toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'})
+                };
+                messages.value.push(msg);
+                const contentToSend = inputMsg.value;
+                inputMsg.value = '';
+                
+                nextTick(()=>{
+                    if(messageList.value){
+                        messageList.value.scrollTop = messageList.value.scrollHeight;
+                    }
+                });
+                
+                // 调用发送消息接口
+                try {
+                    if (currentSession.value.channelType === 'QW') {
+                        // 企微发送消息
+                        await request('/qw/msg/send', {
+                            method: 'POST',
+                            body: JSON.stringify({
+                                externalUserId: currentSession.value.channelSourceId,
+                                content: contentToSend,
+                                qwUserId: currentAccount.value?.qwUserId
+                            })
+                        });
+                    } else if (currentSession.value.channelType === 'CHAT') {
+                        // Chat会话发送消息
+                        await request('/chat/chatMsg', {
+                            method: 'POST',
+                            body: JSON.stringify({
+                                sessionId: currentSession.value.sessionId,
+                                content: contentToSend,
+                                sendType: 2
+                            })
+                        });
+                    }
+                } catch (error) {
+                    console.error('发送消息失败:', error);
+                }
+            };
+
+            // 切换控制模式
+            const toggleControlMode = ()=>{
+                if(currentSession.value){
+                    currentSession.value.controlMode = currentSession.value.controlMode === 'ai' ? 'human' : 'ai';
+                }
+            };
+
+            // 渠道名称
+            const channelName = (type)=>{
+                const names = {QW:'企业微信', WX:'个人微信', IM:'系统IM', WHATSAPP:'WhatsApp', OTHER:'其他渠道', CHAT:'在线咨询'};
+                return names[type] || type;
+            };
+
+            // 初始化
+            onMounted(async () => {
+                await loadAccounts();
+                await loadSessions();
+            });
+
+            return {
+                searchKey, inputMsg, showCustomerInfo, customerTab, messageList, loading,
+                accounts, sessions, currentSession, messages, customerTags, visitRecords,
+                filteredSessions,
+                selectAccount, selectSession, sendMessage, toggleControlMode, channelName
+            };
+        }
+    }).mount('#app');
+</script>
+</body>
+</html>

+ 283 - 0
fs-comm-gateway/src/main/resources/static/sales-corpus.html

@@ -0,0 +1,283 @@
+<!DOCTYPE html>
+<html lang="zh">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>龙虾引擎 - 销冠语料学习</title>
+<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
+<style>
+*{margin:0;padding:0;box-sizing:border-box}
+body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0a0a1a;color:#e0e0e0;min-height:100vh}
+.header{background:#1a1a2e;padding:12px 24px;border-bottom:2px solid #e94560;display:flex;align-items:center;justify-content:space-between}
+.header h1{font-size:18px;color:#e94560}
+.header .nav{display:flex;gap:8px}
+.header .nav a{color:#aaa;text-decoration:none;padding:6px 12px;border-radius:4px;font-size:13px;transition:.2s}
+.header .nav a:hover,.header .nav a.active{background:#e94560;color:#fff}
+.main{padding:20px 24px;max-width:1400px;margin:0 auto}
+.card{background:#1a1a2e;border:1px solid #2a2a4a;border-radius:8px;padding:20px;margin-bottom:16px}
+.card h2{font-size:15px;color:#e94560;margin-bottom:12px;border-bottom:1px solid #2a2a4a;padding-bottom:8px}
+.grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
+.grid3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px}
+.form-group{margin-bottom:12px}
+.form-group label{display:block;font-size:12px;color:#888;margin-bottom:4px}
+.form-group input,.form-group textarea,.form-group select{width:100%;padding:8px 10px;background:#0a0a1a;border:1px solid #2a2a4a;border-radius:4px;color:#e0e0e0;font-size:13px;font-family:inherit}
+.form-group textarea{resize:vertical;min-height:80px}
+.form-group input:focus,.form-group textarea:focus,.form-group select:focus{outline:none;border-color:#e94560}
+.btn{padding:8px 18px;border:none;border-radius:4px;cursor:pointer;font-size:13px;font-weight:500;transition:.2s}
+.btn-primary{background:#e94560;color:#fff}
+.btn-primary:hover{background:#d63850}
+.btn-secondary{background:#0f3460;color:#fff}
+.btn-secondary:hover{background:#1a4a80}
+.btn-success{background:#22c55e;color:#fff}
+.btn-success:hover{background:#1a9e4a}
+.btn-warning{background:#f59e0b;color:#000}
+.toolbar{display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap}
+.stat-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}
+.stat-card{background:linear-gradient(135deg,#16213e,#0f3460);padding:16px;border-radius:8px;text-align:center}
+.stat-card .num{font-size:28px;font-weight:bold;color:#e94560}
+.stat-card .label{font-size:11px;color:#999;margin-top:4px}
+table{width:100%;border-collapse:collapse;font-size:12px}
+table th{background:#0f3460;padding:8px 10px;text-align:left;font-weight:500;color:#ccc}
+table td{padding:8px 10px;border-bottom:1px solid #1a1a3e}
+table tr:hover{background:#16213e}
+.tag{display:inline-block;padding:2px 8px;border-radius:3px;font-size:10px;margin:1px 2px}
+.tag-raw{background:#f59e0b22;color:#f59e0b;border:1px solid #f59e0b44}
+.tag-analyzed{background:#22c55e22;color:#22c55e;border:1px solid #22c55e44}
+.tag-applied{background:#3b82f622;color:#3b82f6;border:1px solid #3b82f644}
+.analysis-panel{background:#0a0a1a;padding:16px;border-radius:6px;margin-top:12px;max-height:400px;overflow-y:auto}
+.analysis-panel h4{color:#e94560;font-size:13px;margin:10px 0 6px}
+.analysis-panel .item{font-size:12px;color:#ccc;padding:4px 0;border-left:2px solid #e94560;margin:4px 0;padding-left:10px}
+.analysis-panel .score{font-size:48px;font-weight:bold;color:#22c55e;text-align:center}
+.tab-bar{display:flex;border-bottom:1px solid #2a2a4a;margin-bottom:16px}
+.tab-bar .tab{padding:8px 16px;cursor:pointer;font-size:13px;border-bottom:2px solid transparent;transition:.2s}
+.tab-bar .tab:hover{color:#e94560}
+.tab-bar .tab.active{border-bottom-color:#e94560;color:#e94560}
+.toast{position:fixed;top:20px;right:20px;z-index:999;padding:10px 18px;border-radius:6px;font-size:13px;animation:slideIn .3s}
+.toast-success{background:#22c55e;color:#000}
+.toast-error{background:#e94560;color:#fff}
+@keyframes slideIn{from{transform:translateX(100px);opacity:0}to{transform:translateX(0);opacity:1}}
+.empty{text-align:center;padding:40px;color:#666;font-size:14px}
+.badge{background:#e94560;color:#fff;border-radius:10px;padding:1px 6px;font-size:10px}
+pre{background:#0a0a1a;padding:10px;border-radius:4px;overflow:auto;font-size:11px;color:#a0a0a0;max-height:300px}
+</style>
+</head>
+<body>
+<div id="app">
+<div class="header">
+  <h1>🦞 龙虾引擎 - 销冠语料学习</h1>
+  <div class="nav">
+    <a href="/workflow-canvas.html" target="_blank">工作流画布</a>
+    <a href="#" class="active">销冠语料</a>
+  </div>
+</div>
+
+<div class="main">
+  <!-- 统计卡片 -->
+  <div class="stat-grid" style="margin-bottom:16px">
+    <div class="stat-card"><div class="num">{{stats.total}}</div><div class="label">总语料</div></div>
+    <div class="stat-card"><div class="num">{{stats.raw}}</div><div class="label">待分析</div></div>
+    <div class="stat-card"><div class="num">{{stats.analyzed}}</div><div class="label">已分析</div></div>
+    <div class="stat-card"><div class="num">{{stats.applied}}</div><div class="label">已应用</div></div>
+  </div>
+
+  <!-- Tab切换 -->
+  <div class="tab-bar">
+    <div class="tab" :class="{active:tab==='list'}" @click="tab='list'">📋 语料列表</div>
+    <div class="tab" :class="{active:tab==='add'}" @click="tab='add'">✍️ 单条录入</div>
+    <div class="tab" :class="{active:tab==='batch'}" @click="tab='batch'">📥 批量导入</div>
+    <div class="tab" :class="{active:tab==='analyze'}" @click="tab='analyze'">🤖 AI分析</div>
+  </div>
+
+  <!-- 列表 -->
+  <div class="card" v-if="tab==='list'">
+    <div class="toolbar">
+      <select v-model="filterScenario" @change="loadList" style="padding:6px 10px;background:#0a0a1a;border:1px solid #2a2a4a;color:#e0e0e0;border-radius:4px">
+        <option value="">全部场景</option>
+        <option v-for="s in scenarios" :value="s.code">{{s.name}}</option>
+      </select>
+      <select v-model="filterStatus" @change="loadList" style="padding:6px 10px;background:#0a0a1a;border:1px solid #2a2a4a;color:#e0e0e0;border-radius:4px">
+        <option value="">全部状态</option>
+        <option value="raw">待分析</option>
+        <option value="analyzed">已分析</option>
+        <option value="applied">已应用</option>
+      </select>
+      <button class="btn btn-secondary" @click="loadList">🔍 查询</button>
+      <button class="btn btn-primary" @click="tab='add'">+ 录入</button>
+    </div>
+    <table v-if="list.length>0">
+      <thead><tr>
+        <th>ID</th><th>销冠</th><th>场景</th><th>客户问题</th><th>销冠回答</th><th>状态</th><th>引用</th><th>时间</th>
+      </tr></thead>
+      <tbody>
+        <tr v-for="item in list" :key="item.id">
+          <td>{{item.id}}</td>
+          <td>{{item.salesperson_name||'-'}}</td>
+          <td>{{scenarioLabel(item.scenario)}}</td>
+          <td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{item.customer_question||''}}</td>
+          <td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{item.sales_answer||''}}</td>
+          <td><span :class="'tag tag-'+item.status">{{item.status==='raw'?'待分析':item.status==='analyzed'?'已分析':'已应用'}}</span></td>
+          <td>{{item.usage_count||0}}</td>
+          <td>{{(item.create_time||'').substring(0,10)}}</td>
+        </tr>
+      </tbody>
+    </table>
+    <div v-else class="empty">暂无语料数据,请先录入</div>
+  </div>
+
+  <!-- 单条录入 -->
+  <div class="card" v-if="tab==='add'">
+    <h2>✍️ 录入销冠对话</h2>
+    <div class="grid2">
+      <div>
+        <div class="form-group"><label>销冠名称</label><input v-model="form.salespersonName" placeholder="例如: 张三"></div>
+        <div class="form-group"><label>场景</label>
+          <select v-model="form.scenario">
+            <option v-for="s in scenarios" :value="s.code">{{s.name}}</option>
+          </select>
+        </div>
+        <div class="form-group"><label>行业类型</label><input v-model="form.industryType" placeholder="education/travel/medical/ecommerce"></div>
+      </div>
+      <div>
+        <div class="form-group"><label>标签 (逗号分隔)</label><input v-model="form.tags" placeholder="高客单价,老客户,逼单"></div>
+      </div>
+    </div>
+    <div class="grid2">
+      <div class="form-group"><label>客户说的话</label><textarea v-model="form.customerQuestion" placeholder="例如: 你们这个课程适合多大孩子?" style="min-height:100px"></textarea></div>
+      <div class="form-group"><label>销冠回答</label><textarea v-model="form.salesAnswer" placeholder="例如: 咱们课程是专门为6-12岁孩子设计的,您家宝贝今年多大啦?" style="min-height:100px"></textarea></div>
+    </div>
+    <div class="toolbar">
+      <button class="btn btn-primary" @click="addDialog" :disabled="submitting">{{submitting?'提交中...':'✅ 录入'}}</button>
+      <button class="btn btn-secondary" @click="clearForm">重置</button>
+    </div>
+  </div>
+
+  <!-- 批量导入 -->
+  <div class="card" v-if="tab==='batch'">
+    <h2>📥 批量导入聊天记录</h2>
+    <p style="font-size:12px;color:#888;margin-bottom:12px">JSON格式,每行一条对话: [{"customer":"客户说","sales":"销冠答","scenario":"consult"}, ...]</p>
+    <div class="grid2">
+      <div>
+        <div class="form-group"><label>销冠名称</label><input v-model="batchForm.salespersonName" placeholder="张三"></div>
+        <div class="form-group"><label>行业类型</label><input v-model="batchForm.industryType" placeholder="education"></div>
+      </div>
+      <div></div>
+    </div>
+    <div class="form-group"><label>聊天JSON</label><textarea v-model="batchForm.chats" placeholder='[{"customer":"您好,我想咨询一下","sales":"您好!请问有什么可以帮您的?","scenario":"greeting"},{"customer":"价格多少?","sales":"这款产品现在有活动,原价..."}]' style="min-height:200px;font-family:monospace;font-size:12px"></textarea></div>
+    <div class="toolbar">
+      <button class="btn btn-primary" @click="batchImport" :disabled="submitting">{{submitting?'导入中...':'📤 批量导入'}}</button>
+      <button class="btn btn-secondary" @click="batchForm={salespersonName:'',industryType:'',chats:''}">清空</button>
+    </div>
+    <div v-if="batchResult" style="margin-top:12px;padding:10px;background:#22c55e22;border-radius:4px;font-size:13px;color:#22c55e">
+      ✅ 批量导入成功,共 {{batchResult.count}} 条语料
+    </div>
+  </div>
+
+  <!-- AI分析 -->
+  <div class="card" v-if="tab==='analyze'">
+    <h2>🤖 AI分析销冠语料</h2>
+    <p style="font-size:12px;color:#888;margin-bottom:12px">AI将自动分析待分析的语料,提取销冠的提问模式、回答策略、信任建立技巧等</p>
+    <div class="toolbar">
+      <button class="btn btn-warning" @click="runAnalyze" :disabled="analyzing">{{analyzing?'🤖 AI分析中,请稍候...':'🚀 开始AI分析'}}</button>
+      <span style="font-size:12px;color:#888;margin-left:8px">当前待分析: {{stats.raw}} 条</span>
+    </div>
+
+    <div v-if="analysisResult" class="analysis-panel">
+      <div class="score">{{analysisResult.overallScore||'-'}}<span style="font-size:16px">分</span></div>
+      <p style="text-align:center;color:#ccc;font-size:13px;margin:8px 0">{{analysisResult.summary}}</p>
+      <h4>❓ 提问模式 ({{(analysisResult.questionPatterns||[]).length}})</h4>
+      <div v-for="q in (analysisResult.questionPatterns||[])" class="item">{{q.pattern}} — "{{q.example}}"<br><small style="color:#888">{{q.why_effective}}</small></div>
+      <h4>💬 回答模式 ({{(analysisResult.answerPatterns||[]).length}})</h4>
+      <div v-for="a in (analysisResult.answerPatterns||[])" class="item">{{a.pattern}} <span class="badge">{{a.structure}}</span> — "{{a.example}}"</div>
+      <h4>🤝 信任策略 ({{(analysisResult.trustStrategies||[]).length}})</h4>
+      <div v-for="t in (analysisResult.trustStrategies||[])" class="item">{{t.strategy}} — {{t.technique}}</div>
+      <h4>🎯 促成技巧 ({{(analysisResult.closingSkills||[]).length}})</h4>
+      <div v-for="c in (analysisResult.closingSkills||[])" class="item">{{c.skill}} ({{c.timing}}) — "{{c.example}}"</div>
+      <h4>🛡️ 异议应对 ({{(analysisResult.objectionHandling||[]).length}})</h4>
+      <div v-for="o in (analysisResult.objectionHandling||[])" class="item">{{o.objection}} → {{o.response_pattern}}</div>
+      <h4>🧠 人格特质</h4>
+      <div style="display:flex;gap:6px;flex-wrap:wrap">
+        <span v-for="p in (analysisResult.personalityTraits||[])" class="tag tag-analyzed">{{p}}</span>
+      </div>
+    </div>
+  </div>
+</div>
+
+<div v-if="toast.visible" :class="'toast toast-'+toast.type">{{toast.msg}}</div>
+</div>
+
+<script>
+const {createApp} = Vue;
+createApp({
+  data(){return{
+    tab:'list',
+    form:{salespersonName:'',scenario:'general',industryType:'',customerQuestion:'',salesAnswer:'',tags:''},
+    batchForm:{salespersonName:'',industryType:'',chats:''},
+    batchResult:null,
+    filterScenario:'',filterStatus:'',
+    list:[],stats:{total:0,raw:0,analyzed:0,applied:0},
+    scenarios:[
+      {code:'greeting',name:'初次问候'},{code:'consult',name:'需求咨询'},{code:'complaint',name:'投诉处理'},
+      {code:'closing',name:'逼单促成'},{code:'followup',name:'跟进关怀'},{code:'objection',name:'异议应对'},
+      {code:'recommend',name:'产品推荐'},{code:'negotiation',name:'价格谈判'},{code:'after_sales',name:'售后服务'},
+      {code:'general',name:'通用场景'}
+    ],
+    submitting:false,analyzing:false,analysisResult:null,
+    toast:{visible:false,msg:'',type:'success'}
+  }},
+  mounted(){this.loadList();this.loadScenarios();},
+  methods:{
+    api(path,body){return fetch('/workflow/lobster/sales-corpus'+path,{method:body?'POST':'GET',headers:body?{'Content-Type':'application/json'}:{},body:body?JSON.stringify(body):null}).then(r=>r.json());},
+    showToast(msg,type='success'){this.toast={visible:true,msg,type};setTimeout(()=>this.toast.visible=false,3000);},
+    scenarioLabel(s){const f=this.scenarios.find(x=>x.code===s);return f?f.name:s||'-';},
+    clearForm(){this.form={salespersonName:'',scenario:'general',industryType:'',customerQuestion:'',salesAnswer:'',tags:''};},
+    async loadScenarios(){
+      try{const r=await this.api('/scenarios');if(r.code===0&&r.data)this.scenarios=r.data;}catch(e){}
+    },
+    async loadList(){
+      try{
+        let url='/list?page=1&size=200';
+        if(this.filterScenario)url+='&scenario='+this.filterScenario;
+        if(this.filterStatus)url+='&status='+this.filterStatus;
+        const r=await fetch(url).then(r=>r.json());
+        if(r.code===0&&r.data){
+          this.list=r.data.list||[];
+          this.stats={total:r.data.total||0,raw:r.data.raw||0,analyzed:r.data.analyzed||0,applied:r.data.applied||0};
+        }
+      }catch(e){this.showToast('加载失败','error');}
+    },
+    async addDialog(){
+      if(!this.form.customerQuestion||!this.form.salesAnswer){this.showToast('客户问题和销冠回答不能为空','error');return;}
+      this.submitting=true;
+      try{
+        const body={salespersonName:this.form.salespersonName||'销冠',scenario:this.form.scenario,industryType:this.form.industryType,customerQuestion:this.form.customerQuestion,salesAnswer:this.form.salesAnswer,tags:this.form.tags?this.form.tags.split(',').reduce((a,k)=>{a[k.trim()]=true;return a;},{}):{}};
+        const r=await this.api('/dialog',body);
+        if(r.code===0){this.showToast('录入成功');this.clearForm();this.loadList();}
+        else this.showToast(r.msg||'录入失败','error');
+      }catch(e){this.showToast('录入失败','error');}
+      this.submitting=false;
+    },
+    async batchImport(){
+      if(!this.batchForm.chats){this.showToast('请填写聊天JSON','error');return;}
+      this.submitting=true;this.batchResult=null;
+      try{
+        const body={salespersonName:this.batchForm.salespersonName||'销冠',industryType:this.batchForm.industryType,chats:this.batchForm.chats};
+        const r=await this.api('/batch-import',body);
+        if(r.code===0){this.batchResult={count:r.data.count};this.showToast('导入成功:'+r.data.count+'条');this.loadList();}
+        else this.showToast(r.msg||'导入失败','error');
+      }catch(e){this.showToast('导入失败','error');}
+      this.submitting=false;
+    },
+    async runAnalyze(){
+      this.analyzing=true;this.analysisResult=null;
+      try{
+        const r=await this.api('/analyze');
+        if(r.code===0){this.analysisResult=r.data;this.showToast('分析完成,得分:'+r.data.overallScore);this.loadList();}
+        else this.showToast(r.msg||'分析失败','error');
+      }catch(e){this.showToast('分析失败','error');}
+      this.analyzing=false;
+    }
+  }
+}).mount('#app');
+</script>
+</body>
+</html>

+ 324 - 0
fs-comm-gateway/src/main/resources/static/workflow-canvas.html

@@ -0,0 +1,324 @@
+<!DOCTYPE html>
+<html lang="zh">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>龙虾引擎 - 工作流可视化编辑器</title>
+<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
+<script src="https://unpkg.com/@vue-flow/core@latest/dist/vue-flow.umd.js"></script>
+<script src="https://unpkg.com/@vue-flow/background@latest/dist/vue-flow-background.umd.js"></script>
+<script src="https://unpkg.com/@vue-flow/minimap@latest/dist/vue-flow-minimap.umd.js"></script>
+<script src="https://unpkg.com/@vue-flow/controls@latest/dist/vue-flow-controls.umd.js"></script>
+<link href="https://unpkg.com/@vue-flow/core@latest/dist/style.css" rel="stylesheet">
+<link href="https://unpkg.com/@vue-flow/minimap@latest/dist/style.css" rel="stylesheet">
+<link href="https://unpkg.com/@vue-flow/controls@latest/dist/style.css" rel="stylesheet">
+
+<style>
+*{margin:0;padding:0;box-sizing:border-box}
+body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;height:100vh;overflow:hidden}
+#app{display:flex;height:100vh}
+.sidebar{width:220px;background:#1a1a2e;color:#fff;padding:12px;overflow-y:auto;flex-shrink:0}
+.sidebar h3{font-size:14px;margin:8px 0;color:#e94560;border-bottom:1px solid #333;padding-bottom:4px}
+.node-item{background:#16213e;margin:4px 0;padding:8px;border-radius:6px;cursor:grab;font-size:12px;border:1px solid #0f3460;transition:all .2s}
+.node-item:hover{background:#0f3460;border-color:#e94560;transform:translateX(2px)}
+.node-item .type-tag{display:inline-block;background:#e94560;color:#fff;padding:1px 6px;border-radius:3px;font-size:10px;margin-right:6px}
+.main{flex:1;position:relative}
+.toolbar{position:absolute;top:10px;left:10px;z-index:10;display:flex;gap:8px}
+.toolbar button{padding:6px 14px;border:none;border-radius:4px;cursor:pointer;font-size:12px}
+.btn-primary{background:#e94560;color:#fff}
+.btn-secondary{background:#0f3460;color:#fff}
+.btn-success{background:#22c55e;color:#fff}
+.prop-panel{width:320px;background:#f8f9fa;border-left:2px solid #dee2e6;padding:12px;overflow-y:auto;flex-shrink:0;font-size:13px}
+.prop-panel h3{color:#1a1a2e;margin-bottom:10px}
+.prop-panel label{display:block;margin:6px 0 2px;color:#555;font-weight:600}
+.prop-panel input,.prop-panel textarea,.prop-panel select{width:100%;padding:6px;border:1px solid #ccc;border-radius:4px;font-size:12px;margin-bottom:6px}
+.prop-panel textarea{min-height:60px;font-family:monospace}
+.vue-flow__node{font-size:12px;min-width:120px;text-align:center}
+.flow-node{padding:8px 12px;border-radius:8px;border:2px solid;background:#fff;box-shadow:0 2px 6px rgba(0,0,0,.1)}
+.flow-node.start{border-color:#22c55e}
+.flow-node.ai{border-color:#8b5cf6}
+.flow-node.msg{border-color:#3b82f6}
+.flow-node.cond{border-color:#f59e0b}
+.flow-node.end{border-color:#ef4444}
+.flow-node.default{border-color:#6b7280}
+.node-name{font-weight:700}
+.node-desc{font-size:10px;color:#888;margin-top:2px}
+.modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:1000}
+.modal{background:#fff;border-radius:12px;padding:24px;width:500px;max-height:80vh;overflow-y:auto}
+.modal h4{margin-bottom:12px}
+.modal textarea{width:100%;min-height:120px;margin:8px 0;font-family:monospace;font-size:12px;padding:8px}
+.modal .actions{display:flex;gap:8px;justify-content:flex-end;margin-top:12px}
+</style>
+</head>
+<body>
+<div id="app">
+  <div class="sidebar">
+    <h3>🦞 龙虾引擎</h3>
+    <div v-for="(group,gname) in nodeGroups" :key="gname">
+      <h3>{{gname}}</h3>
+      <div class="node-item" v-for="nt in group" :key="nt.type"
+           @dragstart="onDragStart($event,nt)" draggable="true">
+        <span class="type-tag">{{nt.type}}</span>{{nt.name}}
+      </div>
+    </div>
+    <div style="margin-top:12px;border-top:1px solid #333;padding-top:8px">
+      <button class="btn-primary" style="width:100%;padding:8px;margin:4px 0" @click="showGenerate=true">🤖 AI生成工作流</button>
+      <button class="btn-secondary" style="width:100%;padding:8px;margin:4px 0" @click="exportJson">📋 导出JSON</button>
+      <button class="btn-success" style="width:100%;padding:8px;margin:4px 0" @click="saveWorkflow">💾 保存到服务器</button>
+      <button class="btn-secondary" style="width:100%;padding:8px;margin:4px 0" @click="showLoad=true">📂 加载工作流</button>
+    </div>
+  </div>
+
+  <div class="main" @drop="onDrop" @dragover.prevent>
+    <div class="toolbar">
+      <button class="btn-primary" @click="fitView()">🎯 适应视图</button>
+      <button class="btn-secondary" @click="undo()">↩ 撤销</button>
+    </div>
+    <vue-flow ref="flowRef" v-model="elements" :node-types="nodeTypes"
+      :default-edge-options="{animated:true,style:{stroke:'#64748b',strokeWidth:2}}"
+      @node-click="onNodeClick" @connect="onConnect" style="height:100%;background:#f1f5f9">
+      <template #node-start="props"><div class="flow-node start"><div class="node-name">🚀 {{props.data.label}}</div></div></template>
+      <template #node-ai="props"><div class="flow-node ai"><div class="node-name">🧠 {{props.data.label}}</div></div></template>
+      <template #node-msg="props"><div class="flow-node msg"><div class="node-name">💬 {{props.data.label}}</div></div></template>
+      <template #node-cond="props"><div class="flow-node cond"><div class="node-name">🔀 {{props.data.label}}</div></div></template>
+      <template #node-end="props"><div class="flow-node end"><div class="node-name">🏁 {{props.data.label}}</div></div></template>
+      <template #node-default="props"><div class="flow-node default"><div class="node-name">📌 {{props.data.label}}</div></div></template>
+      <vue-flow-background />
+      <vue-flow-minimap />
+      <vue-flow-controls />
+    </vue-flow>
+  </div>
+
+  <div class="prop-panel" v-if="selectedNode">
+    <h3>节点属性</h3>
+    <label>节点编码</label><input v-model="selectedNode.data.nodeCode">
+    <label>节点名称</label><input v-model="selectedNode.data.label">
+    <label>节点类型</label>
+    <select v-model.number="selectedNode.data.nodeType" @change="onTypeChange">
+      <option v-for="nt in allNodes" :value="nt.type" :key="nt.type">{{nt.type}}-{{nt.name}}</option>
+    </select>
+    <label>下一节点</label><input v-model="selectedNode.data.nextNodeCode" placeholder="next_node_code">
+    <label>话术模板</label><textarea v-model="selectedNode.data.messageTemplate" placeholder="支持 ${变量}"></textarea>
+    <label>条件表达式</label><textarea v-model="selectedNode.data.conditionExpr" placeholder='{"field":"intent","op":"eq","value":"purchase"}'></textarea>
+    <label>节点配置(JSON)</label><textarea v-model="selectedNode.data.nodeConfig" placeholder='{"collectFields":["1","2"]}'></textarea>
+    <label>最大轮次</label><input v-model.number="selectedNode.data.maxRounds" type="number" min="0">
+    <button class="btn-primary" style="width:100%;margin-top:8px;padding:8px" @click="selectedNode=null">关闭</button>
+  </div>
+</div>
+
+<div class="modal-overlay" v-if="showGenerate">
+  <div class="modal">
+    <h4>🤖 AI生成工作流</h4>
+    <label>需求描述</label>
+    <textarea v-model="genReq" placeholder="例如:我需要一个旅游行业的客户跟进流程,包括意图识别、信息收集、发送方案、条件判断、创建跟进任务..."></textarea>
+    <label>行业类型</label>
+    <select v-model="genIndustry">
+      <option value="travel">旅游</option><option value="medical">医美</option>
+      <option value="education">教育</option><option value="insurance">保险</option>
+      <option value="general">通用</option>
+    </select>
+    <label>已有工作流JSON(可选-迭代优化)</label>
+    <textarea v-model="genExisting" placeholder="粘贴已有工作流JSON进行迭代优化...留空则全新生成"></textarea>
+    <label>迭代指令(可选)</label>
+    <input v-model="genInstruction" placeholder="如:增加关怀节点、优化话术、调整条件">
+    <div class="actions">
+      <button class="btn-secondary" @click="showGenerate=false">取消</button>
+      <button class="btn-primary" @click="aiGenerate()">生成</button>
+    </div>
+    <div v-if="genResult" style="margin-top:12px;background:#f0fdf4;padding:10px;border-radius:6px;font-size:12px">
+      <b>评分:{{genResult.score}}</b> {{genResult.details}}
+    </div>
+  </div>
+</div>
+
+<div class="modal-overlay" v-if="showLoad">
+  <div class="modal">
+    <h4>📂 加载工作流</h4>
+    <textarea v-model="loadJson" placeholder="粘贴工作流JSON..."></textarea>
+    <div class="actions">
+      <button class="btn-secondary" @click="showLoad=false">取消</button>
+      <button class="btn-primary" @click="importJson()">加载</button>
+    </div>
+  </div>
+</div>
+</div>
+
+<script>
+const {createApp,ref,computed,reactive,onMounted} = Vue
+const nodeGroups = {
+  '流程控制':[{type:1,name:'开始',icon:'🚀'},{type:99,name:'结束',icon:'🏁'},{type:4,name:'等待',icon:'⏳'}],
+  'AI智能':[{type:2,name:'AI处理',icon:'🧠'},{type:11,name:'知识检索',icon:'🔍'}],
+  '交互触达':[{type:3,name:'发送消息',icon:'💬'},{type:7,name:'信息收集',icon:'📋'},{type:10,name:'HTTP调用',icon:'🌐'}],
+  '逻辑控制':[{type:5,name:'条件判断',icon:'🔀'},{type:13,name:'循环迭代',icon:'🔄'},{type:16,name:'变量赋值',icon:'📝'}],
+  '业务操作':[{type:6,name:'创建任务',icon:'✅'},{type:8,name:'转人工',icon:'👤'},{type:9,name:'标签操作',icon:'🏷'}],
+  '扩展能力':[{type:12,name:'代码执行',icon:'⚡'},{type:14,name:'数据库查询',icon:'🗄'},{type:15,name:'子流程',icon:'📦'}]
+}
+const allNodes = Object.values(nodeGroups).flat()
+
+const typeToCategory = {}
+allNodes.forEach(n => {
+  if (n.type === 1) typeToCategory[n.type] = 'start'
+  else if (n.type === 99) typeToCategory[n.type] = 'end'
+  else if (n.type === 2 || n.type === 11) typeToCategory[n.type] = 'ai'
+  else if (n.type === 3 || n.type === 7 || n.type === 10) typeToCategory[n.type] = 'msg'
+  else if (n.type === 5 || n.type === 13 || n.type === 16) typeToCategory[n.type] = 'cond'
+  else typeToCategory[n.type] = 'default'
+})
+
+const app = createApp({
+  setup(){
+    const flowRef = ref(null)
+    const selectedNode = ref(null)
+    const elements = ref([
+      {id:'1',type:'start',position:{x:300,y:50},data:{label:'开始',nodeCode:'START',nodeType:1,nextNodeCode:'MSG_1',messageTemplate:'',conditionExpr:'',nodeConfig:'{}',maxRounds:0}},
+      {id:'2',type:'msg',position:{x:300,y:180},data:{label:'欢迎消息',nodeCode:'MSG_1',nodeType:3,nextNodeCode:'END',messageTemplate:'您好${customerName},欢迎咨询!',conditionExpr:'',nodeConfig:'{}',maxRounds:0}},
+      {id:'3',type:'end',position:{x:300,y:310},data:{label:'结束',nodeCode:'END',nodeType:99,nextNodeCode:'',messageTemplate:'',conditionExpr:'',nodeConfig:'{}',maxRounds:0}},
+      {id:'e1-2',source:'1',target:'2',animated:true},
+    ])
+    const showGenerate = ref(false), showLoad = ref(false)
+    const genReq = ref(''), genIndustry = ref('travel'), genExisting = ref(''), genInstruction = ref('')
+    const genResult = ref(null), loadJson = ref('')
+    let nodeIdCounter = elements.value.length
+
+    const nodeTypes = {
+      start:{template:'#node-start'}, ai:{template:'#node-ai'}, msg:{template:'#node-msg'},
+      cond:{template:'#node-cond'}, end:{template:'#node-end'}, default:{template:'#node-default'}
+    }
+
+    function onDragStart(ev,nt){
+      ev.dataTransfer.setData('application/json',JSON.stringify(nt))
+      ev.dataTransfer.effectAllowed = 'move'
+    }
+    function onDrop(ev){
+      const nt = JSON.parse(ev.dataTransfer.getData('application/json'))
+      const pos = flowRef.value?.screenToFlowCoordinate?.({x:ev.clientX,y:ev.clientY}) || {x:ev.offsetX,y:ev.offsetY}
+      nodeIdCounter++
+      const cat = typeToCategory[nt.type] || 'default'
+      elements.value.push({
+        id:String(nodeIdCounter), type:cat, position:pos,
+        data:{label:nt.name,nodeCode:nt.name.toUpperCase()+'_'+nodeIdCounter,nodeType:nt.type,
+              nextNodeCode:'',messageTemplate:'',conditionExpr:'',nodeConfig:'{}',maxRounds:0}
+      })
+    }
+    function onNodeClick({node}){ selectedNode.value = node }
+    function onConnect(conn){
+      elements.value.push({id:'e'+conn.source+'-'+conn.target,source:conn.source,target:conn.target,animated:true})
+    }
+    function onTypeChange(){
+      if(selectedNode.value){
+        selectedNode.value.type = typeToCategory[selectedNode.value.data.nodeType] || 'default'
+      }
+    }
+    function fitView(){ flowRef.value?.fitView() }
+    function undo(){ elements.value.pop() }
+    function exportJson(){
+      const nodes = elements.value.filter(e=>e.type&&e.data).map(e=>({nodeCode:e.data.nodeCode,nodeName:e.data.label,nodeType:e.data.nodeType,sortNo:parseInt(e.id),nextNodeCode:e.data.nextNodeCode,messageTemplate:e.data.messageTemplate||'',conditionExpr:e.data.conditionExpr||'',nodeConfig:e.data.nodeConfig||'{}',maxRounds:e.data.maxRounds||0}))
+      const edges = elements.value.filter(e=>e.source&&e.target).map((e,i)=>({edgeKey:'EDGE_'+i,sourceNodeCode:elements.value.find(n=>n.id===e.source)?.data?.nodeCode||'',targetNodeCode:elements.value.find(n=>n.id===e.target)?.data?.nodeCode||'',edgeLabel:''}))
+      const json = JSON.stringify({templateName:'工作流模板',description:'',nodes,edges},null,2)
+      navigator.clipboard.writeText(json).then(()=>alert('已复制到剪贴板'))
+      console.log(json)
+    }
+
+    async function saveWorkflow(){
+      exportJson()
+      const token = localStorage.getItem('company_token') || ''
+      try{
+        const r = await fetch('http://localhost:8006/workflow/lobster/template/save',{
+          method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+token},
+          body:JSON.stringify(JSON.parse(await navigator.clipboard.readText()))
+        })
+        const d = await r.json()
+        alert(d.code===200?'保存成功!':'保存失败: '+d.msg)
+      }catch(e){alert('请先登录系统获取token,或在控制台复制JSON手动保存')}
+    }
+
+    async function aiGenerate(){
+      genResult.value = null
+      genStatus.value = 'submitting'
+      genProgress.value = '正在提交AI生成任务...'
+      
+      const body = {requirement:genReq.value,industryType:genIndustry.value,modelConfig:{modelA:'doubao-lite',modelB:'doubao-lite',modelC:'doubao-lite'}}
+      if(genExisting.value){
+        body.existingWorkflow = genExisting.value
+        body.modifyInstruction = genInstruction.value||'优化工作流结构'
+      }
+      try{
+        /* 第一步: 提交异步任务, 立即返回recordId */
+        const submitRes = await fetch('/workflow/ai-generator/generate',{
+          method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)
+        })
+        const submitData = await submitRes.json()
+        if(submitData.code!==200||!submitData.data||!submitData.data.recordId){
+          genStatus.value = 'error'
+          genProgress.value = '提交失败'
+          alert('AI生成任务提交失败')
+          return
+        }
+
+        const recordId = submitData.data.recordId
+        genProgress.value = 'AI正在生成工作流(模型A生成中)...'
+        
+        /* 第二步: 轮询任务状态, 每3秒查询一次, 最多轮询60次(3分钟) */
+        for(let i=0;i<60;i++){
+          await new Promise(r=>setTimeout(r,3000))
+          const pollRes = await fetch('/workflow/ai-generator/result/'+recordId+'/detail')
+          const pollData = await pollRes.json()
+          if(pollData.code===200&&pollData.data){
+            const result = pollData.data
+            if(result.status==='completed'||result.status==='done'){
+              genResult.value = {score:result.qualityScore||'85',details:result.suggestions||''}
+              elements.value = parseWorkflowJson(result.workflowJson || result.nodes || result)
+              genStatus.value = 'done'
+              genProgress.value = 'AI生成完成! (得分:'+(result.qualityScore||85)+')'
+              return
+            }
+            if(result.status==='failed'){
+              genStatus.value = 'error'
+              genProgress.value = '生成失败: '+(result.errorMsg||'未知错误')
+              return
+            }
+            /* 进度更新 */
+            if(i<5) genProgress.value = 'AI正在生成工作流(模型A生成中)...'
+            else if(i<10) genProgress.value = '模型B优化中...'
+            else genProgress.value = '模型C质检评分中...'
+          }
+        }
+        genStatus.value = 'error'
+        genProgress.value = '超时(3分钟),请重试'
+      }catch(e){
+        genStatus.value = 'error'
+        genProgress.value = '请求失败: '+e.message
+        alert('AI生成请求失败: '+e.message)
+      }
+    }
+
+    function importJson(){
+      try{
+        elements.value = parseWorkflowJson(JSON.parse(loadJson.value))
+        showLoad.value = false
+      }catch(e){alert('JSON格式错误: '+e.message)}
+    }
+
+    function parseWorkflowJson(wf){
+      if(typeof wf==='string') wf = JSON.parse(wf)
+      const nodes = (wf.nodes||[]).map((n,i)=>({id:String(i+1),type:typeToCategory[n.nodeType]||'default',position:{x:300,y:50+i*130},data:{label:n.nodeName||n.name||'',nodeCode:n.nodeCode||'',nodeType:n.nodeType||3,nextNodeCode:n.nextNodeCode||'',messageTemplate:n.messageTemplate||'',conditionExpr:n.conditionExpr||'',nodeConfig:typeof n.nodeConfig==='string'?n.nodeConfig:JSON.stringify(n.nodeConfig),maxRounds:n.maxRounds||0}}))
+      const edgeMap = {}
+      nodes.forEach(n=>{if(n.data.nextNodeCode) edgeMap[n.data.nodeCode]=n.data.nextNodeCode})
+      const edges = (wf.edges||[]).map((e,i)=>({id:'e'+i,source:nodes.find(n=>n.data.nodeCode===e.sourceNodeCode)?.id||'',target:nodes.find(n=>n.data.nodeCode===e.targetNodeCode)?.id||'',animated:true}))
+      nodeIdCounter = nodes.length
+      return [...nodes,...edges]
+    }
+
+    return {nodeGroups,allNodes,flowRef,elements,selectedNode,nodeTypes,
+      showGenerate,showLoad,genReq,genIndustry,genExisting,genInstruction,genResult,loadJson,
+      onDragStart,onDrop,onNodeClick,onConnect,onTypeChange,fitView,undo,exportJson,saveWorkflow,aiGenerate,importJson}
+  }
+})
+app.component('vue-flow',VueFlow.default)
+app.component('vue-flow-background',VueFlowBackground.default)
+app.component('vue-flow-minimap',VueFlowMinimap.default)
+app.component('vue-flow-controls',VueFlowControls.default)
+app.mount('#app')
+</script>
+</body>
+</html>

+ 4 - 4
fs-company/src/main/java/com/fs/company/controller/aicall/CcLlmAgentAccountController.java

@@ -152,7 +152,7 @@ public class CcLlmAgentAccountController extends BaseController
      * 新增保存机器人参数配置
      */
     @PreAuthorize("@ss.hasPermi('aicall:account:add')")
-    @Log(title = "机器人参数配置", businessType = BusinessType.INSERT)
+    @Log(title = "配置模型新增", businessType = BusinessType.INSERT)
     @PostMapping("/add")
     @ResponseBody
     public AjaxResult addSave(@RequestBody CcLlmAgentAccount ccLlmAgentAccount, HttpServletRequest request)
@@ -181,7 +181,7 @@ public class CcLlmAgentAccountController extends BaseController
                 }
             }
         }
-        
+
         // 新增模型
         int result = ccLlmAgentAccountService.insertCcLlmAgentAccount(ccLlmAgentAccount);
 
@@ -199,7 +199,7 @@ public class CcLlmAgentAccountController extends BaseController
         if (result > 0 && companyId != null) {
             companyBindAiModelService.bindCompanyToModel(ccLlmAgentAccount.getId().longValue(), companyId);
         }
-        
+
         return toAjax(result);
     }
 
@@ -256,7 +256,7 @@ public class CcLlmAgentAccountController extends BaseController
      * 修改保存机器人参数配置
      */
     @PreAuthorize("@ss.hasPermi('aicall:account:edit')")
-    @Log(title = "机器人参数配置", businessType = BusinessType.UPDATE)
+    @Log(title = "配置模型修改", businessType = BusinessType.UPDATE)
     @PostMapping("/edit")
     @ResponseBody
     public AjaxResult editSave(@RequestBody CcLlmAgentAccount ccLlmAgentAccount)

+ 121 - 0
fs-company/src/main/java/com/fs/company/controller/company/CompanyAiWorkflowServerController.java

@@ -0,0 +1,121 @@
+package com.fs.company.controller.company;
+
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.domain.R;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.poi.ExcelUtil;
+import com.fs.company.domain.CompanyAiWorkflowServer;
+import com.fs.company.param.BindCidServerParam;
+import com.fs.company.service.ICompanyAiWorkflowServerService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * cid服务Controller
+ * 
+ * @author fs
+ * @date 2026-02-26
+ */
+@RestController
+@RequestMapping("/company/cid/server")
+public class CompanyAiWorkflowServerController extends BaseController
+{
+    @Autowired
+    private ICompanyAiWorkflowServerService companyAiWorkflowServerService;
+
+    /**
+     * 查询cid服务列表
+     */
+    @PreAuthorize("@ss.hasPermi('company:server:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(CompanyAiWorkflowServer companyAiWorkflowServer)
+    {
+        startPage();
+        List<CompanyAiWorkflowServer> list = companyAiWorkflowServerService.selectCompanyAiWorkflowServerList(companyAiWorkflowServer);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出cid服务列表
+     */
+    @PreAuthorize("@ss.hasPermi('company:server:export')")
+    @Log(title = "cid服务", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(CompanyAiWorkflowServer companyAiWorkflowServer)
+    {
+        List<CompanyAiWorkflowServer> list = companyAiWorkflowServerService.selectCompanyAiWorkflowServerList(companyAiWorkflowServer);
+        ExcelUtil<CompanyAiWorkflowServer> util = new ExcelUtil<CompanyAiWorkflowServer>(CompanyAiWorkflowServer.class);
+        return util.exportExcel(list, "cid服务数据");
+    }
+
+    /**
+     * 获取cid服务详细信息
+     */
+    @PreAuthorize("@ss.hasPermi('company:server:query')")
+    @GetMapping(value = "/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Long id)
+    {
+        return AjaxResult.success(companyAiWorkflowServerService.selectCompanyAiWorkflowServerById(id));
+    }
+
+    /**
+     * 新增cid服务
+     */
+    @PreAuthorize("@ss.hasPermi('company:server:add')")
+    @Log(title = "cid服务", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody CompanyAiWorkflowServer companyAiWorkflowServer)
+    {
+        return toAjax(companyAiWorkflowServerService.insertCompanyAiWorkflowServer(companyAiWorkflowServer));
+    }
+
+    /**
+     * 修改cid服务
+     */
+    @PreAuthorize("@ss.hasPermi('company:server:edit')")
+    @Log(title = "cid服务", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody CompanyAiWorkflowServer companyAiWorkflowServer)
+    {
+        return toAjax(companyAiWorkflowServerService.updateCompanyAiWorkflowServer(companyAiWorkflowServer));
+    }
+
+    /**
+     * 删除cid服务
+     */
+    @PreAuthorize("@ss.hasPermi('company:server:remove')")
+    @Log(title = "cid服务", businessType = BusinessType.DELETE)
+	@DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids)
+    {
+        return toAjax(companyAiWorkflowServerService.deleteCompanyAiWorkflowServerByIds(ids));
+    }
+
+    /**
+     * 绑定cid服务
+     * @param param
+     * @return
+     */
+    @PostMapping("/bindCidServer")
+    public R bindCidServer(@RequestBody BindCidServerParam param){
+        return companyAiWorkflowServerService.bindCidServer(param);
+    }
+
+    /**
+     * 解绑cid服务
+     * @param param
+     * @return
+     */
+    @PostMapping("/unbindCidServer")
+    public R unbindCidServer(@RequestBody BindCidServerParam param){
+        return companyAiWorkflowServerService.unbindCidServer(param);
+    }
+
+
+}

+ 97 - 0
fs-company/src/main/java/com/fs/fastGpt/FastGptChatReplaceWordsController.java

@@ -0,0 +1,97 @@
+package com.fs.fastGpt;
+
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.poi.ExcelUtil;
+import com.fs.fastGpt.domain.FastGptChatReplaceWords;
+import com.fs.fastGpt.service.IFastGptChatReplaceWordsService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 违规词语Controller
+ *
+ * @author fs
+ * @date 2025-01-18
+ */
+@RestController
+@RequestMapping("/fastGpt/fastGptChatReplaceWords")
+public class FastGptChatReplaceWordsController extends BaseController
+{
+    @Autowired
+    private IFastGptChatReplaceWordsService fastGptChatReplaceWordsService;
+
+    /**
+     * 查询违规词语列表
+     */
+    @PreAuthorize("@ss.hasPermi('fastGpt:fastGptChatReplaceWords:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(FastGptChatReplaceWords fastGptChatReplaceWords)
+    {
+        startPage();
+        List<FastGptChatReplaceWords> list = fastGptChatReplaceWordsService.selectFastGptChatReplaceWordsList(fastGptChatReplaceWords);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出违规词语列表
+     */
+    @PreAuthorize("@ss.hasPermi('fastGpt:fastGptChatReplaceWords:export')")
+    @Log(title = "违规词语", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(FastGptChatReplaceWords fastGptChatReplaceWords)
+    {
+        List<FastGptChatReplaceWords> list = fastGptChatReplaceWordsService.selectFastGptChatReplaceWordsList(fastGptChatReplaceWords);
+        ExcelUtil<FastGptChatReplaceWords> util = new ExcelUtil<FastGptChatReplaceWords>(FastGptChatReplaceWords.class);
+        return util.exportExcel(list, "违规词语数据");
+    }
+
+//    /**
+//     * 获取违规词语详细信息
+//     */
+//    @PreAuthorize("@ss.hasPermi('fastGpt:fastGptChatReplaceWords:query')")
+//    @GetMapping(value = "/{id}")
+//    public AjaxResult getInfo(@PathVariable("id") Long id)
+//    {
+//        return AjaxResult.success(fastGptChatReplaceWordsService.selectFastGptChatReplaceWordsById(id));
+//    }
+
+//    /**
+//     * 新增违规词语
+//     */
+//    @PreAuthorize("@ss.hasPermi('fastGpt:fastGptChatReplaceWords:add')")
+//    @Log(title = "违规词语", businessType = BusinessType.INSERT)
+//    @PostMapping
+//    public AjaxResult add(@RequestBody FastGptChatReplaceWords fastGptChatReplaceWords)
+//    {
+//        return toAjax(fastGptChatReplaceWordsService.insertFastGptChatReplaceWords(fastGptChatReplaceWords));
+//    }
+//
+//    /**
+//     * 修改违规词语
+//     */
+//    @PreAuthorize("@ss.hasPermi('fastGpt:fastGptChatReplaceWords:edit')")
+//    @Log(title = "违规词语", businessType = BusinessType.UPDATE)
+//    @PutMapping
+//    public AjaxResult edit(@RequestBody FastGptChatReplaceWords fastGptChatReplaceWords)
+//    {
+//        return toAjax(fastGptChatReplaceWordsService.updateFastGptChatReplaceWords(fastGptChatReplaceWords));
+//    }
+//
+    /**
+     * 删除违规词语
+     */
+    @PreAuthorize("@ss.hasPermi('fastGpt:fastGptChatReplaceWords:remove')")
+    @Log(title = "违规词语", businessType = BusinessType.DELETE)
+	@DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids)
+    {
+        return toAjax(fastGptChatReplaceWordsService.deleteFastGptChatReplaceWordsByIds(ids));
+    }
+}

+ 155 - 0
fs-company/src/main/resources/application-common.yml

@@ -0,0 +1,155 @@
+# 项目相关配置
+fs:
+  # 名称
+  name: fs
+  # 版本
+  version: 1.1.0
+  # 版权年份
+  copyrightYear: 2021
+  # 实例演示开关
+  demoEnabled: true
+  # 文件路径 示例( Windows配置D:/fs/uploadPath,Linux配置 /home/fs/uploadPath)
+  profile: c:/fs/uploadPath
+  # 获取ip地址开关
+  addressEnabled: false
+  # 验证码类型 math 数组计算 char 字符验证
+  captchaType: math
+#  jwt:
+#    # 加密秘钥
+#    secret: f4e2e52034348f86b67cde581c0f9eb5
+#    # token有效时长,7天,单位秒
+#    expire: 31536000
+#    header: AppToken
+# 开发环境配置
+server:
+  servlet:
+    # 应用的访问路径
+    context-path: /
+  tomcat:
+    # tomcat的URI编码
+    uri-encoding: UTF-8
+    # tomcat最大线程数,默认为200
+    max-threads: 800
+    # Tomcat启动初始化的线程数,默认值25
+    min-spare-threads: 30
+
+# 日志配置
+logging:
+  level:
+    com.fs: info
+    org.springframework: warn
+
+express:
+  omsCode: "SF.0235402855"
+# Spring配置
+spring:
+  main:
+    allow-circular-references: true
+  cache:
+    type: redis
+  # 资源信息
+  messages:
+    # 国际化资源文件路径
+    basename: i18n/messages
+  mvc:
+    async:
+      request-timeout: 600000
+
+  # 文件上传
+  servlet:
+     multipart:
+       # 单个文件大小
+       max-file-size:  3GB
+       # 设置总上传的文件大小
+       max-request-size:  3GB
+  # 服务模块
+  devtools:
+    restart:
+      # 热部署开关
+      enabled: true
+
+
+# token配置
+token:
+    # 令牌自定义标识
+    header: Authorization
+    # 令牌密钥
+    secret: YlrzSaas2026SecKey!@#QwErTyUiOpAsDfGhJkLzXcVbNm
+    # 令牌有效期(默认30分钟)
+    expireTime: 720
+mybatis-plus:
+  # 搜索指定包别名
+  typeAliasesPackage: com.fs.**.domain,com.fs.**.bo
+  # 配置mapper的扫描,找到所有的mapper.xml映射文件
+  mapperLocations: classpath*:/mapper/**/*.xml
+  configLocation: classpath:mybatis/mybatis-config.xml
+  # 全局配置
+  global-config:
+    db-config:
+      # 主键类型  0:"数据库ID自增", 1:"用户输入ID",2:"全局唯一ID (数字类型唯一ID)", 3:"全局唯一ID UUID";
+      idType: AUTO
+      # 字段策略 0:"忽略判断",1:"非 NULL 判断"),2:"非空判断"
+      fieldStrategy: NOT_EMPTY
+    banner: false
+    # 配置
+  configuration:
+    # 驼峰式命名
+    mapUnderscoreToCamelCase: true
+    # 全局映射器启用缓存
+    cacheEnabled: true
+    # 配置默认的执行器
+    defaultExecutorType: REUSE
+    # 允许 JDBC 支持自动生成主键
+    useGeneratedKeys: true
+
+# MyBatis配置
+mybatis:
+    # 搜索指定包别名
+    typeAliasesPackage: com.fs.**.domain
+    # 配置mapper的扫描,找到所有的mapper.xml映射文件
+    mapperLocations: classpath*:mapper/**/*Mapper.xml
+    # 加载全局的配置文件
+    configLocation: classpath:mybatis/mybatis-config.xml
+
+# PageHelper分页插件
+pagehelper:
+  helperDialect: mysql
+  reasonable: false #超出后不显示
+  supportMethodsArguments: false
+  params: count=countSql
+
+# Swagger配置
+swagger:
+  # 是否开启swagger
+  enabled: false
+  # 请求前缀
+  pathMapping: /dev-api
+
+# 防止XSS攻击
+xss:
+  # 过滤开关
+  enabled: true
+  # 排除链接(多个用逗号分隔)
+  excludes: /system/notice,/system/config/*
+  # 匹配链接
+  urlPatterns: /system/*,/monitor/*,/tool/*
+zhyf:
+  url: https://zhyf-testController.jingpai.com
+
+image:
+  storage:
+    local-path: C:\logoFile\logo.jpg
+    server-path: C:\logoFile\logo.jpg
+# application.properties
+wechat:
+  api:
+    base-url: https://api.weixin.qq.com
+    upload-shipping-info: /wxa/sec/order/upload_shipping_info
+hsy:
+  access_key: AKLTZTc4YTE4ZjI2OWViNDNjZGI2NjhiYTI5Njc5ZjA1Mzk
+  secret_key: WXpjelpUYzFOakF5TUdObE5EZGtNR0ZsWXpKaU1tTmtZakk1WXpObE4yRQ==
+  region: cn-north-1
+  role_access_key: AKLTNmMwNjJkNDFhYTVjNDIzYzhhNzEyZmZmZTlmYzBhNGM
+  role_secret_key: T0RaaFl6UmhZV1V4WXpKbU5EWTBNMkZpT0RNNU9UY3daak0wTjJFd09XUQ==
+  role_trn: trn:iam::2114522511:role/hylj
+

+ 137 - 0
fs-company/src/main/resources/application-config-dev.yml

@@ -0,0 +1,137 @@
+baidu:
+  token: 12313231232
+  back-domain: https://www.xxxx.com
+#配置
+logging:
+  level:
+    org.springframework.web: debug
+    com.github.binarywang.demo.wx.cp: DEBUG
+    me.chanjar.weixin: DEBUG
+#wx:
+#  miniapp:
+#    configs:
+#      - appid: wx29d26f63f836be7f
+#        secret: 7542db9774355a89b1adce24defb6013
+#        token: Ncbnd7lJvkripVOpyTFAna6NAWCxCrvC
+#        aesKey: HlEiBB55eaWUaeBVAQO3cWKWPYv1vOVQSq7nFNICw4E
+#        msgDataFormat: JSON
+#  cp:
+#    corpId: wwb2a1055fb6c9a7c2
+#    appConfigs:
+#      - agentId: 1000005
+#        secret: ec7okROXJqkNafq66-L6aKNv0asTzQIG0CYrj3vyBbo
+#        token: PPKOdAlCoMO
+#        aesKey: PKvaxtpSv8NGpfTDm7VUHIK8Wok2ESyYX24qpXJAdMP
+#  pay:
+#    appId: wx73f85f8d62769119 #微信公众号或者小程序等的appid
+#    mchId: 1611402045 #微信支付商户号
+#    mchKey: 8cab128997a3547c1363b0898b877f38 #微信支付商户密钥
+#    subAppId:  #服务商模式下的子商户公众账号ID
+#    subMchId:  #服务商模式下的子商户号
+#    keyPath: c:\\cert\\apiclient_cert.p12 # p12证书的位置,可以指定绝对路径,也可以指定类路径(以classpath:开头)
+#    notifyUrl: https://userapp.his.runtzh.com/app/wxpay/wxPayNotify
+#  mp:
+#    useRedis: false
+#    redisConfig:
+#      host: 127.0.0.1
+#      port: 6379
+#      timeout: 2000
+#    configs:
+#      - appId: wx93ce67750e3cfba3 # 第一个公众号的appid  //公众号名称:云联融智
+#        secret: c172884087264160563bfe5775ca0f6f # 公众号的appsecret
+#        token: PPKOdAlCoMO # 接口配置里的Token值
+#        aesKey: Eswa6VjwtVMCcw03qZy6fWllgrv5aytIA1SZPEU0kU2 # 接口配置里的EncodingAESKey值
+#aifabu:  #爱链接
+#  appKey: 7b471be905ab17e00f3b858c6710dd117601d008
+#watch:
+#  watchUrl: watch.ylrzcloud.com/prod-api
+#  #  account: tcloud
+#  #  password: mdf-m2h_6yw2$hq
+#  account1: ccif #866655060138751
+#  password1: cp-t5or_6xw7$mt
+#  account2: tcloud #rt500台
+#  password2: mdf-m2h_6yw2$hq
+#  account3: whr
+#  password3: v9xsKuqn_$d2y
+#
+#fs :
+#  commonApi: http://172.16.0.16:8010
+#  h5CommonApi: http://119.29.195.254:8010
+#  jwt:
+#    # 加密秘钥
+#    secret: e10adc3949ba59abbe56e057f20f883e
+#    # token有效时长,7天,单位秒
+#    expire: 31536000
+#    header: AppToken
+#nuonuo:
+#  key: 10924508
+#  secret: A2EB20764D304D16
+#
+## 存储捅配置
+#tencent_cloud_config:
+#  secret_id: AKIDiMq9lDf2EOM9lIfqqfKo7FNgM5meD0sT
+#  secret_key: u5SuS80342xzx8FRBukza9lVNHKNMSaB
+#  bucket: myhk-1323137866
+#  app_id: 1323137866
+#  region: ap-chongqing
+#  proxy: myhk
+#cloud_host:
+#  company_name: 金康健
+#  projectCode: DEV
+#  spaceName:
+#  volcengineUrl:
+#headerImg:
+#  imgUrl: https://jz-cos-1356808054.cos.ap-chengdu.myqcloud.com/fs/20250515/0877754b59814ea8a428fa3697b20e68.png
+#ipad:
+#  url:
+#  ipadUrl: http://ipad.cdwjyyh.com
+#  aiApi: http://152.136.202.157:3000/api
+#  voiceApi:
+#  commonApi:
+#wx_miniapp_temp:
+#  pay_order_temp_id:
+#  inquiry_temp_id:
+## 聚水潭API配置
+#jst:
+##  app_key: a4b1fab173c84f67b3873857eea11d90 #聚水潭2025-07-25
+#  app_key: 871348458a964548a72bf8124cf917a4 #聚水潭2025-08-14
+#  app_secret: 5b7d9369dbcd414db45089bc047ebe1a #聚水潭2025-08-14
+##  app_secret: dfce1f8dc8a64ddc91212fc3fcdd9349 #聚水潭2025-07-25
+#  authorization_code: 666666
+#  shop_code: "18461733"
+#
+## RocketMQ配置
+#rocketmq:
+#  name-server: 127.0.0.1:9876
+#  producer:
+#    group: event-feedback-producer
+#    send-message-timeout: 3000
+#    retry-times-when-send-failed: 2
+#    retry-times-when-send-async-failed: 2
+#    max-message-size: 4194304
+#    compress-message-body-threshold: 4096
+#    retry-next-server: true
+#custom:
+#  token: "1o62d3YxvdHd4LEUiltnu7sK"
+#  encoding-aes-key: "UJfTQ5qKTKlegjkXtp1YuzJzxeHlUKvq5GyFbERN1iU"
+#  corp-id: "ww51717e2b71d5e2d3"configValue
+#  secret: "6ODAmw-8W4t6h9mdzHh2Z4Apwj8mnsyRnjEDZOHdA7k"
+#  private-key-path: "privatekey.pem"
+#  webhook-url: "https://your-server.com/wecom/archive"
+## token配置
+#token:
+#  # 令牌自定义标识
+#  header: Authorization
+#  # 令牌密钥
+#  secret: abcdefghijklmnopqrstuvwxyz
+#  # 令牌有效期(默认30分钟)
+#  expireTime: 180
+#openIM:
+#  secret: openIM123
+#  userID: imAdmin
+#  url: https://web.jnmyim.ylrzfs.com/api
+##是否为新商户,新商户不走mpOpenId
+#isNewWxMerchant: true
+##是否使用新im
+#im:
+#  type: OPENIM

+ 73 - 62
fs-company/src/main/resources/application-dev.yml

@@ -1,19 +1,26 @@
+# 数据源配置
 spring:
-    devtools:
-        restart:
-            enabled: false
+    # redis 配置
     redis:
-        #host: localhost
-        host: 172.27.0.7
+        # 地址
+        host: localhost
+        # 端口,默认为6379
         port: 6379
-        password:
+        # 数据库索引
         database: 0
+        # 密码
+        password:
+        # 连接超时时间
         timeout: 20s
         lettuce:
             pool:
+                # 连接池中的最小空闲连接
                 min-idle: 0
+                # 连接池中的最大空闲连接
                 max-idle: 8
+                # 连接池的最大数据库连接数
                 max-active: 8
+                # #连接池最大阻塞等待时间(使用负值表示没有限制)
                 max-wait: -1ms
     datasource:
         mysql:
@@ -31,37 +38,26 @@ spring:
                 testWhileIdle: true
                 testOnBorrow: false
                 testOnReturn: false
-                webStatFilter:
-                    enabled: true
-                statViewServlet:
-                    enabled: true
-                    allow:
-                    url-pattern: /druid/*
-                    login-username: fs
-                    login-password: 123456
-                filter:
-                    stat:
-                        enabled: true
-                        log-slow-sql: true
-                        slow-sql-millis: 1000
-                        merge-sql: true
-                    wall:
-                        config:
-                            multi-statement-allow: true
+                # 主库数据源
                 master:
                     url: jdbc:mysql://cq-cdb-8fjmemkb.sql.tencentcdb.com:27220/ylrz_saas?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&useSSL=false
                     username: root
                     password: Ylrz_1q2w3e4r5t6y
-#                    url: jdbc:mysql://139.186.77.83:3306/yLrz_saas_his_scrm?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&allowMultiQueries=true
-#                    username: Rtroot
-#                    password: Rtroot
+                    # 初始连接数
                     initialSize: 5
+                    # 最小连接池数量
                     minIdle: 10
+                    # 最大连接池数量
                     maxActive: 20
+                    # 配置获取连接等待超时的时间
                     maxWait: 60000
+                    # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
                     timeBetweenEvictionRunsMillis: 60000
+                    # 配置一个连接在池中最小生存的时间,单位是毫秒
                     minEvictableIdleTimeMillis: 300000
+                    # 配置一个连接在池中最大生存的时间,单位是毫秒
                     maxEvictableIdleTimeMillis: 900000
+                    # 配置检测连接是否有效
                     validationQuery: SELECT 1 FROM DUAL
                     testWhileIdle: true
                     testOnBorrow: false
@@ -70,52 +66,67 @@ spring:
                         enabled: true
                     statViewServlet:
                         enabled: true
+                        # 设置白名单,不填则允许所有访问
                         allow:
                         url-pattern: /druid/*
+                        # 控制台管理用户名和密码
                         login-username: fs
                         login-password: 123456
                     filter:
                         stat:
                             enabled: true
+                            # 慢SQL记录
                             log-slow-sql: true
                             slow-sql-millis: 1000
                             merge-sql: true
                         wall:
                             config:
                                 multi-statement-allow: true
-
-ai:
-  vector:
-    enabled: true
-    milvus-uri: ./milvus_data/milvus.db
-  embedding:
-    provider: auto
-    endpoint: https://ark.cn-beijing.volces.com/api/v3/embeddings
-    api-key: ""
-    ollama-url: http://localhost:11434
-    ollama-model: bge-m3
-    dimension: 1024
-  multi-model:
-    enabled: true
-    default: deepseek
-  doubao:
-    api-key: "ark-5c19ef54-158a-4e9d-ba90-be9c745a8ff6-5cc00"
-    endpoint: https://ark.cn-beijing.volces.com/api/v3/chat/completions
-    model: doubao-seed-2-0-mini-260428
-  deepseek:
-    api-key: "sk-a44ba22ffa8c4d3d8f593a1503058700"
-    endpoint: https://api.deepseek.com/chat/completions
-    model: deepseek-v4-pro
-  qwen:
-    api-key: ""
-    endpoint: https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions
-    model: qwen-plus
-  yuanbao:
-    api-key: ""
-    endpoint: https://api.hunyuan.cloud.tencent.com/v1/chat/completions
-    model: hunyuan-lite
-# token配置 - 必须与fs-admin一致才能共享JWT token
-token:
-    header: Authorization
-    secret: YlrzSaas2026SecKey!@#QwErTyUiOpAsDfGhJkLzXcVbNm
-    expireTime: 720
+        easycall:
+            type: com.alibaba.druid.pool.DruidDataSource
+            driverClassName: com.mysql.cj.jdbc.Driver
+            druid:
+                # 主库数据源
+                master:
+                    url: jdbc:mysql://129.28.164.235:3306/easycallcenter365?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
+                    username: root
+                    password: easycallcenter365
+                # 初始连接数
+                initialSize: 5
+                # 最小连接池数量
+                minIdle: 10
+                # 最大连接池数量
+                maxActive: 20
+                # 配置获取连接等待超时的时间
+                maxWait: 60000
+                # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
+                timeBetweenEvictionRunsMillis: 60000
+                # 配置一个连接在池中最小生存的时间,单位是毫秒
+                minEvictableIdleTimeMillis: 300000
+                # 配置一个连接在池中最大生存的时间,单位是毫秒
+                maxEvictableIdleTimeMillis: 900000
+                # 配置检测连接是否有效
+                validationQuery: SELECT 1 FROM DUAL
+                testWhileIdle: true
+                testOnBorrow: false
+                testOnReturn: false
+                webStatFilter:
+                    enabled: true
+                statViewServlet:
+                    enabled: true
+                    # 设置白名单,不填则允许所有访问
+                    allow:
+                    url-pattern: /druid/*
+                    # 控制台管理用户名和密码
+                    login-username: fs
+                    login-password: 123456
+                filter:
+                    stat:
+                        enabled: true
+                        # 慢SQL记录
+                        log-slow-sql: true
+                        slow-sql-millis: 1000
+                        merge-sql: true
+                    wall:
+                        config:
+                            multi-statement-allow: true

+ 1 - 0
fs-service/src/main/java/com/fs/aicall/domain/CcLlmAgentAccount.java

@@ -64,6 +64,7 @@ public class CcLlmAgentAccount implements Serializable {
 
     /** 模型ID列表,用于IN查询 */
     private List<Long> modelIds;
+
     /** 绑定公司ID列表(展示字段) */
     private List<Long> companyIds;
 

+ 14 - 15
fs-service/src/main/java/com/fs/aicall/domain/CcLlmAgentProvider.java

@@ -4,27 +4,26 @@ import com.fs.common.annotation.Excel;
 import lombok.Data;
 
 import java.io.Serializable;
-import java.util.Date;
 
+/**
+ * 大模型实现类列表对象 cc_llm_agent_provider
+ * 
+ * @author ruoyi
+ * @date 2025-06-16
+ */
 @Data
 public class CcLlmAgentProvider implements Serializable {
     private static final long serialVersionUID = 1L;
 
-    private Long id;
+    /** 主键id */
+    private Integer id;
 
-    @Excel(name = "供应商名称")
-    private String name;
+    /** 实现类 */
+    @Excel(name = "实现类")
+    private String providerClassName;
 
-    @Excel(name = "实现类名")
-    private String className;
-
-    @Excel(name = "配置JSON")
-    private String configJson;
-
-    private Long companyId;
-
-    private Date createTime;
-
-    private Date updateTime;
+    /** 备注 */
+    @Excel(name = "备注")
+    private String note;
 
 }

+ 12 - 11
fs-service/src/main/java/com/fs/aicall/domain/CcLlmKbCat.java

@@ -4,27 +4,28 @@ import lombok.Data;
 import lombok.experimental.Accessors;
 
 import java.io.Serializable;
-import java.util.Date;
 
+/**
+ * 知识库对象 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 name;
-
-    private Long parentId;
-
-    private Long companyId;
-
+    /** 分类 */
     private String cat;
 
-    private Integer contentCount;
-
-    private Date createTime;
+    /** 描述 */
+    private String description;
 
-    private Date updateTime;
+    /** 内容数量 */
+    private Integer contentCount;
 
 }

+ 50 - 0
fs-service/src/main/java/com/fs/aicall/mapper/CcLlmAgentAccountMapper.java

@@ -1,20 +1,70 @@
 package com.fs.aicall.mapper;
 
 import com.fs.aicall.domain.CcLlmAgentAccount;
+import com.fs.common.annotation.DataSource;
+import com.fs.common.enums.DataSourceType;
 
 import java.util.List;
 
+/**
+ * 机器人参数配置Mapper接口
+ * 
+ * @author ruoyi
+ * @date 2025-06-16
+ */
 public interface CcLlmAgentAccountMapper 
 {
+    /**
+     * 查询机器人参数配置
+     * 
+     * @param id 机器人参数配置主键
+     * @return 机器人参数配置
+     */
+    @DataSource(DataSourceType.EASYCALL)
     public CcLlmAgentAccount selectCcLlmAgentAccountById(Integer id);
 
+    /**
+     * 查询机器人参数配置列表
+     * 
+     * @param ccLlmAgentAccount 机器人参数配置
+     * @return 机器人参数配置集合
+     */
+    @DataSource(DataSourceType.EASYCALL)
     public List<CcLlmAgentAccount> selectCcLlmAgentAccountList(CcLlmAgentAccount ccLlmAgentAccount);
 
+    /**
+     * 新增机器人参数配置
+     * 
+     * @param ccLlmAgentAccount 机器人参数配置
+     * @return 结果
+     */
+    @DataSource(DataSourceType.EASYCALL)
     public int insertCcLlmAgentAccount(CcLlmAgentAccount ccLlmAgentAccount);
 
+    /**
+     * 修改机器人参数配置
+     * 
+     * @param ccLlmAgentAccount 机器人参数配置
+     * @return 结果
+     */
+    @DataSource(DataSourceType.EASYCALL)
     public int updateCcLlmAgentAccount(CcLlmAgentAccount ccLlmAgentAccount);
 
+    /**
+     * 删除机器人参数配置
+     * 
+     * @param id 机器人参数配置主键
+     * @return 结果
+     */
+    @DataSource(DataSourceType.EASYCALL)
     public int deleteCcLlmAgentAccountById(Integer id);
 
+    /**
+     * 批量删除机器人参数配置
+     * 
+     * @param ids 需要删除的数据主键集合
+     * @return 结果
+     */
+    @DataSource(DataSourceType.EASYCALL)
     public int deleteCcLlmAgentAccountByIds(String[] ids);
 }

+ 50 - 0
fs-service/src/main/java/com/fs/aicall/mapper/CcLlmAgentProviderMapper.java

@@ -2,20 +2,70 @@ package com.fs.aicall.mapper;
 
 
 import com.fs.aicall.domain.CcLlmAgentProvider;
+import com.fs.common.annotation.DataSource;
+import com.fs.common.enums.DataSourceType;
 
 import java.util.List;
 
+/**
+ * 大模型实现类列表Mapper接口
+ * 
+ * @author ruoyi
+ * @date 2025-06-16
+ */
 public interface CcLlmAgentProviderMapper 
 {
+    /**
+     * 查询大模型实现类列表
+     * 
+     * @param id 大模型实现类列表主键
+     * @return 大模型实现类列表
+     */
+    @DataSource(DataSourceType.EASYCALL)
     public CcLlmAgentProvider selectCcLlmAgentProviderById(Integer id);
 
+    /**
+     * 查询大模型实现类列表列表
+     * 
+     * @param ccLlmAgentProvider 大模型实现类列表
+     * @return 大模型实现类列表集合
+     */
+    @DataSource(DataSourceType.EASYCALL)
     public List<CcLlmAgentProvider> selectCcLlmAgentProviderList(CcLlmAgentProvider ccLlmAgentProvider);
 
+    /**
+     * 新增大模型实现类列表
+     * 
+     * @param ccLlmAgentProvider 大模型实现类列表
+     * @return 结果
+     */
+    @DataSource(DataSourceType.EASYCALL)
     public int insertCcLlmAgentProvider(CcLlmAgentProvider ccLlmAgentProvider);
 
+    /**
+     * 修改大模型实现类列表
+     * 
+     * @param ccLlmAgentProvider 大模型实现类列表
+     * @return 结果
+     */
+    @DataSource(DataSourceType.EASYCALL)
     public int updateCcLlmAgentProvider(CcLlmAgentProvider ccLlmAgentProvider);
 
+    /**
+     * 删除大模型实现类列表
+     * 
+     * @param id 大模型实现类列表主键
+     * @return 结果
+     */
+    @DataSource(DataSourceType.EASYCALL)
     public int deleteCcLlmAgentProviderById(Integer id);
 
+    /**
+     * 批量删除大模型实现类列表
+     * 
+     * @param ids 需要删除的数据主键集合
+     * @return 结果
+     */
+    @DataSource(DataSourceType.EASYCALL)
     public int deleteCcLlmAgentProviderByIds(String[] ids);
 }

+ 50 - 0
fs-service/src/main/java/com/fs/aicall/mapper/CcLlmKbCatMapper.java

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

+ 53 - 0
fs-service/src/main/java/com/fs/aicall/mapper/CcLlmKbMapper.java

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

+ 2 - 0
fs-service/src/main/java/com/fs/aicall/mapper/CompanyBindAiModelMapper.java

@@ -1,6 +1,7 @@
 package com.fs.aicall.mapper;
 
 import com.fs.aicall.domain.CompanyBindAiModel;
+import org.apache.ibatis.annotations.Mapper;
 import org.apache.ibatis.annotations.Param;
 
 import java.util.List;
@@ -11,6 +12,7 @@ import java.util.List;
  * @author ruoyi
  * @date 2026-03-20
  */
+@Mapper
 public interface CompanyBindAiModelMapper
 {
     /**

+ 1 - 1
fs-service/src/main/java/com/fs/aicall/service/impl/CcLlmKbCatServiceImpl.java

@@ -96,7 +96,7 @@ public class CcLlmKbCatServiceImpl implements ICcLlmKbCatService
 
     @Override
     public CcLlmKbCat selectCcLlmKbCatByCat(Long id, String cat) {
-        List<CcLlmKbCat> list = ccLlmKbCatMapper.selectCcLlmKbCatList(new CcLlmKbCat().setName(cat));
+        List<CcLlmKbCat> list = ccLlmKbCatMapper.selectCcLlmKbCatList(new CcLlmKbCat().setCat(cat));
         if (!CollectionUtils.isEmpty(list)) {
             if (null == id) {
                 return list.get(0);

+ 95 - 0
fs-service/src/main/java/com/fs/comm/client/CommGatewayClient.java

@@ -0,0 +1,95 @@
+package com.fs.comm.client;
+
+import cn.hutool.http.HttpRequest;
+import cn.hutool.http.HttpResponse;
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.fs.common.exception.ServiceException;
+import com.fs.common.utils.StringUtils;
+import com.fs.wxcid.utils.TenantHelper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import java.util.Map;
+
+/**
+ * 通讯中间件 HTTP 客户端(工作流/内部服务调用)
+ */
+@Slf4j
+@Component
+public class CommGatewayClient {
+
+    @Value("${comm.gateway.base-url:http://127.0.0.1:8010}")
+    private String baseUrl;
+
+    @Value("${comm.gateway.internal-secret:CommGatewayInternal2026!@#}")
+    private String internalSecret;
+
+    @Value("${comm.gateway.enabled:true}")
+    private boolean enabled;
+
+    @Value("${comm.gateway.fallback-local:true}")
+    private boolean fallbackLocal;
+
+    public JSONObject sendCall(Long tenantId, Long companyId, Long companyUserId, Map<String, Object> body) {
+        return postInternal("/comm/call/send", tenantId, companyId, companyUserId, body);
+    }
+
+    public JSONObject sendSms(Long tenantId, Long companyId, Long companyUserId, Map<String, Object> body) {
+        return postInternal("/comm/sms/send", tenantId, companyId, companyUserId, body);
+    }
+
+    public boolean isEnabled() {
+        return enabled;
+    }
+
+    public boolean isFallbackLocal() {
+        return fallbackLocal;
+    }
+
+    private JSONObject postInternal(String path, Long tenantId, Long companyId, Long companyUserId, Map<String, Object> body) {
+        if (!enabled) {
+            throw new ServiceException("通讯中间件未启用");
+        }
+        if (tenantId == null) {
+            tenantId = TenantHelper.getTenantId();
+        }
+        String url = trimSlash(baseUrl) + path;
+        try {
+            HttpResponse response = HttpRequest.post(url)
+                    .header("Content-Type", "application/json")
+                    .header("X-Comm-Internal-Secret", internalSecret)
+                    .header("X-Comm-Tenant-Id", tenantId == null ? "" : String.valueOf(tenantId))
+                    .header("X-Comm-Company-Id", companyId == null ? "" : String.valueOf(companyId))
+                    .header("X-Comm-Company-User-Id", companyUserId == null ? "" : String.valueOf(companyUserId))
+                    .body(JSON.toJSONString(body))
+                    .timeout(120000)
+                    .execute();
+            if (response.getStatus() != 200) {
+                throw new ServiceException("通讯网关HTTP异常: " + response.getStatus());
+            }
+            JSONObject result = JSON.parseObject(response.body());
+            if (result == null) {
+                throw new ServiceException("通讯网关返回空响应");
+            }
+            Integer code = result.getInteger("code");
+            if (code == null || code != 200) {
+                throw new ServiceException(StringUtils.defaultIfBlank(result.getString("msg"), "通讯网关调用失败"));
+            }
+            return result.getJSONObject("data");
+        } catch (ServiceException ex) {
+            throw ex;
+        } catch (Exception ex) {
+            log.error("调用通讯网关失败 path={}", path, ex);
+            throw new ServiceException("调用通讯网关失败: " + ex.getMessage());
+        }
+    }
+
+    private String trimSlash(String url) {
+        if (url.endsWith("/")) {
+            return url.substring(0, url.length() - 1);
+        }
+        return url;
+    }
+}

+ 33 - 0
fs-service/src/main/java/com/fs/comm/model/CommCallSendParam.java

@@ -0,0 +1,33 @@
+package com.fs.comm.model;
+
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.Map;
+
+@Data
+@Builder
+public class CommCallSendParam {
+
+    private Long roboticId;
+
+    private Long calleeId;
+
+    private Long businessId;
+
+    private Long gatewayId;
+
+    private String nodeKey;
+
+    private String workflowInstanceId;
+
+    private Long companyId;
+
+    private Long tenantId;
+
+    private String callbackUrl;
+
+    private String phone;
+
+    private Map<String, Object> bizParams;
+}

+ 15 - 0
fs-service/src/main/java/com/fs/comm/model/CommCallSendResult.java

@@ -0,0 +1,15 @@
+package com.fs.comm.model;
+
+import lombok.Builder;
+import lombok.Data;
+
+@Data
+@Builder
+public class CommCallSendResult {
+
+    private String callBackUuid;
+
+    private Long batchId;
+
+    private String phone;
+}

+ 31 - 0
fs-service/src/main/java/com/fs/comm/model/CommSmsSendParam.java

@@ -0,0 +1,31 @@
+package com.fs.comm.model;
+
+import lombok.Builder;
+import lombok.Data;
+
+@Data
+@Builder
+public class CommSmsSendParam {
+
+    private Long roboticId;
+
+    private Long calleeId;
+
+    private Long smsTempId;
+
+    private String nodeKey;
+
+    private String workflowInstanceId;
+
+    private Long companyId;
+
+    private Long companyUserId;
+
+    private String senderName;
+
+    private String cardUrl;
+
+    private String phone;
+
+    private Long customerId;
+}

+ 15 - 0
fs-service/src/main/java/com/fs/comm/model/CommSmsSendResult.java

@@ -0,0 +1,15 @@
+package com.fs.comm.model;
+
+import lombok.Builder;
+import lombok.Data;
+
+@Data
+@Builder
+public class CommSmsSendResult {
+
+    private String callbackUuid;
+
+    private Long customerId;
+
+    private String phone;
+}

+ 231 - 0
fs-service/src/main/java/com/fs/comm/service/CommCallSendService.java

@@ -0,0 +1,231 @@
+package com.fs.comm.service;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.exception.ServiceException;
+import com.fs.common.utils.StringUtils;
+import com.fs.comm.model.CommCallSendParam;
+import com.fs.comm.model.CommCallSendResult;
+import com.fs.company.domain.*;
+import com.fs.company.enums.BusinessTypeEnum;
+import com.fs.company.mapper.*;
+import com.fs.company.param.CompanyVoiceRoboticCallBlacklistCheckParam;
+import com.fs.company.service.CompanyWorkflowEngine;
+import com.fs.company.service.ICompanyVoiceRoboticCallBlacklistService;
+import com.fs.company.service.easycall.IEasyCallService;
+import com.fs.company.service.impl.CompanyVoiceRoboticCallLogCallphoneServiceImpl;
+import com.fs.company.vo.CompanyVoiceRoboticCallBlacklistCheckVO;
+import com.fs.company.vo.easycall.EasyCallCommonAddCallListParam;
+import com.fs.company.vo.easycall.EasyCallPhoneItemVO;
+import com.fs.crm.domain.CrmCustomer;
+import com.fs.crm.service.ICrmCustomerService;
+import com.fs.his.config.CidPhoneConfig;
+import com.fs.his.utils.PhoneUtil;
+import com.fs.system.service.ISysConfigService;
+import com.fs.wxcid.utils.TenantHelper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.Collections;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 通讯网关外呼发送核心实现(工作流/开放 API 共用)
+ */
+@Slf4j
+@Service
+public class CommCallSendService {
+
+    public static final String EASYCALL_WORKFLOW_REDIS_KEY = "easycall:workflow:callback:";
+
+    @Autowired
+    private IEasyCallService easyCallService;
+
+    @Autowired
+    private CompanyConfigMapper companyConfigMapper;
+
+    @Autowired
+    private CompanyVoiceRoboticCalleesMapper companyVoiceRoboticCalleesMapper;
+
+    @Autowired
+    private CompanyVoiceRoboticMapper companyVoiceRoboticMapper;
+
+    @Autowired
+    private CompanySiptaskInfoMapper companySiptaskInfoMapper;
+
+    @Autowired
+    private CompanyAiWorkflowExecMapper companyAiWorkflowExecMapper;
+
+    @Autowired
+    private CompanyVoiceRoboticCallLogCallphoneMapper companyVoiceRoboticCallLogCallphoneMapper;
+
+    @Autowired
+    private CompanyVoiceRoboticCallLogCallphoneServiceImpl companyVoiceRoboticCallLogCallphoneService;
+
+    @Autowired
+    private ICompanyVoiceRoboticCallBlacklistService companyVoiceRoboticCallBlacklistService;
+
+    @Autowired
+    private ICrmCustomerService crmCustomerService;
+
+    @Autowired
+    private ISysConfigService configService;
+
+    @Autowired
+    private CompanyWorkflowEngine companyWorkflowEngine;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    public CommCallSendResult sendWorkflowCall(CommCallSendParam param) {
+        CompanyVoiceRobotic robotic = companyVoiceRoboticMapper.selectById(param.getRoboticId());
+        if (robotic == null) {
+            throw new ServiceException("外呼任务不存在");
+        }
+        Long companyId = param.getCompanyId() != null ? param.getCompanyId() : robotic.getCompanyId();
+
+        if (param.getBusinessId() != null) {
+            checkPhoneCallLimit(param.getBusinessId(), companyId);
+        }
+
+        CompanyVoiceRoboticCallees callees = companyVoiceRoboticCalleesMapper.selectById(param.getCalleeId());
+        if (callees == null || StringUtils.isBlank(callees.getPhone())) {
+            throw new ServiceException("被叫人不存在或手机号为空");
+        }
+
+        CrmCustomer crmCustomer = crmCustomerService.selectCrmCustomerById(callees.getUserId());
+        CompanyVoiceRoboticCallBlacklistCheckParam blacklistParam = new CompanyVoiceRoboticCallBlacklistCheckParam();
+        blacklistParam.setCompanyId(crmCustomer.getCompanyId());
+        blacklistParam.setBusinessType(BusinessTypeEnum.CALL.getCode());
+        blacklistParam.setTargetValue(callees.getPhone());
+        CompanyVoiceRoboticCallBlacklistCheckVO blacklistVo = companyVoiceRoboticCallBlacklistService.checkBlacklist(blacklistParam);
+        if (!blacklistVo.getPass()) {
+            throw new ServiceException("被叫人命中外呼黑名单");
+        }
+
+        String callBackUrl = resolveCallbackUrl(robotic, param.getCallbackUrl());
+        String callBackUuid = UUID.randomUUID().toString();
+
+        JSONObject callbackInfo = new JSONObject();
+        callbackInfo.put("callBackUuid", callBackUuid);
+        callbackInfo.put("nodeKey", param.getNodeKey());
+        callbackInfo.put("workflowInstanceId", param.getWorkflowInstanceId());
+        callbackInfo.put("calleeId", param.getCalleeId());
+        redisCache.setCacheObject(EASYCALL_WORKFLOW_REDIS_KEY + callBackUuid, callbackInfo.toJSONString(), 15, TimeUnit.DAYS);
+
+        Long batchId = resolveBatchId(param);
+        EasyCallCommonAddCallListParam addListParam = new EasyCallCommonAddCallListParam();
+        addListParam.setBatchId(batchId);
+        EasyCallPhoneItemVO phoneItem = new EasyCallPhoneItemVO();
+        phoneItem.setPhoneNum(PhoneUtil.decryptPhone(callees.getPhone()));
+        JSONObject bizJson = new JSONObject();
+        bizJson.put("custName", callees.getUserName());
+        bizJson.put("tenantId", param.getTenantId() != null ? param.getTenantId() : TenantHelper.getTenantId());
+        bizJson.put("callBackUuid", callBackUuid);
+        bizJson.put("callBackUrl", callBackUrl);
+        if (param.getBizParams() != null) {
+            bizJson.putAll(param.getBizParams());
+        }
+        phoneItem.setBizJson(bizJson);
+        addListParam.setPhoneList(Collections.singletonList(phoneItem));
+
+        boolean added = easyCallService.addCommonCallList(addListParam, companyId, param.getGatewayId());
+        if (!added) {
+            throw new ServiceException("外呼名单追加失败或线路限流");
+        }
+        easyCallService.startTask(batchId, null);
+
+        JSONObject runParam = (JSONObject) JSON.toJSON(addListParam);
+        runParam.put("companyId", companyId);
+        CompanyVoiceRoboticCallLogCallphone addLog = CompanyVoiceRoboticCallLogCallphone.initCallLog(
+                runParam.toJSONString(), param.getCalleeId(), param.getRoboticId(), companyId);
+        addLog.setStatus(1);
+        addLog.setCallbackUuid(callBackUuid);
+        companyVoiceRoboticCallLogCallphoneService.asyncInsertCompanyVoiceRoboticCallLog(addLog);
+
+        return CommCallSendResult.builder()
+                .callBackUuid(callBackUuid)
+                .batchId(batchId)
+                .phone(phoneItem.getPhoneNum())
+                .build();
+    }
+
+    private void checkPhoneCallLimit(Long businessId, Long companyId) {
+        String json = configService.selectConfigByKey("cId.config");
+        if (StringUtils.isEmpty(json)) {
+            return;
+        }
+        CidPhoneConfig config = JSONObject.parseObject(json, CidPhoneConfig.class);
+        if (config.getEnablePhoneLimitConfig() == null || !config.getEnablePhoneLimitConfig()) {
+            return;
+        }
+        int num = companyVoiceRoboticCallLogCallphoneMapper.countTodayCallsByBusinessId(businessId, companyId);
+        if (num >= config.getNumberCalls()) {
+            throw new ServiceException("今日拨打次数已达上限");
+        }
+    }
+
+    private String resolveCallbackUrl(CompanyVoiceRobotic robotic, String requestCallbackUrl) {
+        if (StringUtils.isNotBlank(requestCallbackUrl)) {
+            return requestCallbackUrl;
+        }
+        try {
+            CompanyConfig companyConfig = companyConfigMapper.selectCompanyConfigByKey(robotic.getCompanyId(), "cId.config");
+            if (companyConfig != null && StringUtils.isNotBlank(companyConfig.getConfigValue())) {
+                CidPhoneConfig cidConf = JSONObject.parseObject(companyConfig.getConfigValue(), CidPhoneConfig.class);
+                if (cidConf != null && StringUtils.isNotBlank(cidConf.getCallbackUrl())) {
+                    return cidConf.getCallbackUrl();
+                }
+            }
+            String s = configService.selectConfigByKey("cId.config");
+            JSONObject obj = JSONObject.parseObject(s);
+            if (obj != null && StringUtils.isNotBlank(obj.getString("callbackUrl"))) {
+                return obj.getString("callbackUrl");
+            }
+        } catch (Exception ex) {
+            log.warn("获取回调地址失败", ex);
+        }
+        return "";
+    }
+
+    private Long resolveBatchId(CommCallSendParam param) {
+        CompanySiptaskInfo sipTaskInfo = companySiptaskInfoMapper.selectSipTaskInfoByTaskIdAndNodeKey(param.getRoboticId(), param.getNodeKey());
+        if (sipTaskInfo != null && sipTaskInfo.getBatchId() != null) {
+            return sipTaskInfo.getBatchId();
+        }
+        String lockKey = "sipTask:lock:" + param.getRoboticId() + ":" + param.getNodeKey();
+        boolean locked = redisCache.setIfAbsent(lockKey, "1", 30, TimeUnit.SECONDS);
+        if (!locked) {
+            for (int i = 0; i < 20; i++) {
+                try {
+                    Thread.sleep(100);
+                } catch (InterruptedException e) {
+                    Thread.currentThread().interrupt();
+                }
+                sipTaskInfo = companySiptaskInfoMapper.selectSipTaskInfoByTaskIdAndNodeKey(param.getRoboticId(), param.getNodeKey());
+                if (sipTaskInfo != null && sipTaskInfo.getBatchId() != null) {
+                    return sipTaskInfo.getBatchId();
+                }
+            }
+        }
+        try {
+            sipTaskInfo = companySiptaskInfoMapper.selectSipTaskInfoByTaskIdAndNodeKey(param.getRoboticId(), param.getNodeKey());
+            if (sipTaskInfo != null && sipTaskInfo.getBatchId() != null) {
+                return sipTaskInfo.getBatchId();
+            }
+            if (StringUtils.isBlank(param.getWorkflowInstanceId())) {
+                throw new ServiceException("创建外呼任务缺少 workflowInstanceId");
+            }
+            CompanyAiWorkflowExec exec = companyAiWorkflowExecMapper.selectByWorkflowInstanceId(param.getWorkflowInstanceId());
+            if (exec == null) {
+                throw new ServiceException("工作流实例不存在");
+            }
+            return companyWorkflowEngine.createSipTask(param.getRoboticId(), exec.getWorkflowId());
+        } finally {
+            redisCache.deleteObject(lockKey);
+        }
+    }
+}

+ 283 - 0
fs-service/src/main/java/com/fs/comm/service/CommSmsSendService.java

@@ -0,0 +1,283 @@
+package com.fs.comm.service;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.fs.common.constant.Constants;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.exception.ServiceException;
+import com.fs.common.service.ISmsService;
+import com.fs.common.utils.StringUtils;
+import com.fs.comm.model.CommSmsSendParam;
+import com.fs.comm.model.CommSmsSendResult;
+import com.fs.company.domain.*;
+import com.fs.company.enums.BusinessTypeEnum;
+import com.fs.company.mapper.CompanyVoiceRoboticCalleesMapper;
+import com.fs.company.mapper.CompanyVoiceRoboticMapper;
+import com.fs.company.mapper.CompanyWxClientMapper;
+import com.fs.company.param.CompanyVoiceRoboticCallBlacklistCheckParam;
+import com.fs.company.service.*;
+import com.fs.company.vo.CompanyVoiceRoboticCallBlacklistCheckVO;
+import com.fs.crm.domain.CrmCustomer;
+import com.fs.crm.param.SmsSendBatchParam;
+import com.fs.crm.service.ICrmCustomerService;
+import com.fs.his.utils.PhoneUtil;
+import com.fs.qw.domain.QwUser;
+import com.fs.qw.service.impl.QwExternalContactServiceImpl;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 通讯网关短信发送核心实现(工作流/开放 API 共用)
+ */
+@Slf4j
+@Service
+public class CommSmsSendService {
+
+    public static final String WORKFLOW_SMS_ONE_REDIS_KEY = "workflow:sms:one:";
+
+    @Autowired
+    private CompanyVoiceRoboticMapper companyVoiceRoboticMapper;
+
+    @Autowired
+    private CompanyVoiceRoboticCalleesMapper companyVoiceRoboticCalleesMapper;
+
+    @Autowired
+    private CompanyWxClientMapper companyWxClientMapper;
+
+    @Autowired
+    private ICompanyWxAccountService companyWxAccountService;
+
+    @Autowired
+    private QwExternalContactServiceImpl qwExternalContactService;
+
+    @Autowired
+    private ICompanySmsTempService smsTempService;
+
+    @Autowired
+    private ICompanySmsService companySmsService;
+
+    @Autowired
+    private ISmsService smsService;
+
+    @Autowired
+    private ICompanyUserService companyUserService;
+
+    @Autowired
+    private ICrmCustomerService crmCustomerService;
+
+    @Autowired
+    private ICompanyVoiceRoboticCallBlacklistService companyVoiceRoboticCallBlacklistService;
+
+    @Autowired
+    private ICompanyVoiceRoboticCallLogSendmsgService companyVoiceRoboticCallLogSendmsgService;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    public CommSmsSendResult sendWorkflowSms(CommSmsSendParam param) {
+        CompanyVoiceRobotic robotic = companyVoiceRoboticMapper.selectById(param.getRoboticId());
+        if (robotic == null) {
+            throw new ServiceException("外呼任务不存在");
+        }
+        CompanyVoiceRoboticCallees callees = companyVoiceRoboticCalleesMapper.selectById(param.getCalleeId());
+        if (callees == null) {
+            throw new ServiceException("被叫人不存在");
+        }
+
+        Long companyId = param.getCompanyId();
+        Long companyUserId = param.getCompanyUserId();
+        String senderName = param.getSenderName();
+        if (companyUserId == null || StringUtils.isBlank(senderName)) {
+            CompanyWxClient wxClient = companyWxClientMapper.selectOneByRoboticIdAndUserId(param.getRoboticId(), callees.getUserId());
+            if (wxClient != null) {
+                if (wxClient.getIsWeCom() == 2) {
+                    QwUser qwUser = qwExternalContactService.getQwUserByRedisForId(String.valueOf(wxClient.getAccountId()));
+                    companyId = qwUser.getCompanyId();
+                    companyUserId = qwUser.getCompanyUserId();
+                    senderName = qwUser.getQwUserName();
+                } else {
+                    CompanyWxAccount wxAccount = companyWxAccountService.selectCompanyWxAccountById(wxClient.getAccountId());
+                    companyId = wxAccount.getCompanyId();
+                    companyUserId = wxAccount.getCompanyUserId();
+                    senderName = wxAccount.getWxNickName();
+                }
+            }
+        }
+        if (companyId == null) {
+            companyId = robotic.getCompanyId();
+        }
+
+        CompanySmsTemp temp = smsTempService.selectCompanySmsTempById(param.getSmsTempId());
+        if (temp == null || !Integer.valueOf(1).equals(temp.getStatus()) || !Integer.valueOf(1).equals(temp.getIsAudit())) {
+            throw new ServiceException("短信模板不存在或未审核");
+        }
+
+        CompanySms sms = companySmsService.selectCompanySmsByCompanyId(companyId);
+        if (sms == null || sms.getRemainSmsCount() == null || sms.getRemainSmsCount() <= 0) {
+            throw new ServiceException("剩余短信数量不足,请充值");
+        }
+
+        Long customerId = param.getCustomerId() != null ? param.getCustomerId() : callees.getUserId();
+        CrmCustomer customer = crmCustomerService.selectCrmCustomerById(customerId);
+        if (customer == null) {
+            throw new ServiceException("客户不存在");
+        }
+        String targetPhone = StringUtils.isNotBlank(param.getPhone()) ? param.getPhone() : customer.getMobile();
+        checkSmsBlacklist(companyId, targetPhone);
+        if (companyUserId != null) {
+            CompanyUser companyUser = companyUserService.selectCompanyUserById(companyUserId);
+            if (companyUser != null && StringUtils.isNotBlank(companyUser.getPhonenumber())) {
+                checkSmsBlacklist(companyId, companyUser.getPhonenumber());
+            }
+        }
+
+        String callbackUuid = UUID.randomUUID().toString();
+        if (StringUtils.isNotBlank(param.getWorkflowInstanceId())) {
+            JSONObject userDataJson = new JSONObject();
+            userDataJson.put("callbackUuid", callbackUuid);
+            userDataJson.put("nodeKey", param.getNodeKey());
+            userDataJson.put("workflowInstanceId", param.getWorkflowInstanceId());
+            userDataJson.put("callerId", param.getCalleeId());
+            redisCache.setCacheObject(WORKFLOW_SMS_ONE_REDIS_KEY + callbackUuid, userDataJson.toJSONString(), 1, TimeUnit.DAYS);
+        }
+
+        SmsSendBatchParam smsSendBatchParam = new SmsSendBatchParam();
+        smsSendBatchParam.setCompanyId(companyId);
+        smsSendBatchParam.setCompanyUserId(companyUserId);
+        smsSendBatchParam.setSmsType(temp.getTempType());
+        smsSendBatchParam.setTempCode(temp.getTempCode());
+        smsSendBatchParam.setContent(temp.getContent());
+        smsSendBatchParam.setSenderName(senderName);
+        smsSendBatchParam.setCardUrl(param.getCardUrl());
+        smsSendBatchParam.setCustomerIds(new Long[]{customerId});
+
+        JSONObject runParam = (JSONObject) JSON.toJSON(smsSendBatchParam);
+        runParam.put("temp", temp);
+        CompanyVoiceRoboticCallLogSendmsg addLog = CompanyVoiceRoboticCallLogSendmsg.initCallLog(
+                runParam.toJSONString(),
+                param.getCalleeId(),
+                param.getRoboticId(),
+                companyId,
+                companyUserId,
+                temp.getTempId());
+        addLog.setStatus(1);
+        addLog.setCallbackUuid(callbackUuid);
+        addLog.setContentLen(calcSmsContentLen(smsSendBatchParam));
+
+        try {
+            smsService.batchSmsOp4AiSend(temp, smsSendBatchParam);
+            addLog.setStatus(2);
+        } catch (Exception ex) {
+            addLog.setStatus(3);
+            addLog.setResult(ex.getMessage());
+            log.error("CommSmsSendService 发送失败 roboticId={}, calleeId={}", param.getRoboticId(), param.getCalleeId(), ex);
+            throw ex instanceof ServiceException ? (ServiceException) ex : new ServiceException("短信发送失败: " + ex.getMessage());
+        } finally {
+            companyVoiceRoboticCallLogSendmsgService.asyncInsertCompanyVoiceRoboticCallLog(addLog);
+        }
+
+        updateSmsWorkflowProgress(param.getRoboticId(), param.getCalleeId());
+        return CommSmsSendResult.builder()
+                .callbackUuid(callbackUuid)
+                .customerId(customerId)
+                .phone(targetPhone)
+                .build();
+    }
+
+    /**
+     * 已组装好参数的 AI 短信批量发送(WxTask 等场景),统一收口 batchSmsOp4AiSend
+     */
+    public void sendPreparedBatch(CompanySmsTemp temp, SmsSendBatchParam param) {
+        if (temp == null || !Integer.valueOf(1).equals(temp.getStatus()) || !Integer.valueOf(1).equals(temp.getIsAudit())) {
+            throw new ServiceException("短信模板不存在或未审核");
+        }
+        if (param == null || param.getCompanyId() == null) {
+            throw new ServiceException("短信发送参数不完整");
+        }
+        CompanySms sms = companySmsService.selectCompanySmsByCompanyId(param.getCompanyId());
+        if (sms == null || sms.getRemainSmsCount() == null || sms.getRemainSmsCount() <= 0) {
+            throw new ServiceException("剩余短信数量不足,请充值");
+        }
+        if (param.getCustomerIds() != null) {
+            for (Long customerId : param.getCustomerIds()) {
+                CrmCustomer customer = crmCustomerService.selectCrmCustomerById(customerId);
+                if (customer != null && StringUtils.isNotBlank(customer.getMobile())) {
+                    checkSmsBlacklist(param.getCompanyId(), customer.getMobile());
+                }
+            }
+        }
+        if (param.getCompanyUserId() != null) {
+            CompanyUser companyUser = companyUserService.selectCompanyUserById(param.getCompanyUserId());
+            if (companyUser != null && StringUtils.isNotBlank(companyUser.getPhonenumber())) {
+                checkSmsBlacklist(param.getCompanyId(), companyUser.getPhonenumber());
+            }
+        }
+        try {
+            smsService.batchSmsOp4AiSend(temp, param);
+        } catch (Exception ex) {
+            log.error("CommSmsSendService.sendPreparedBatch 发送失败 companyId={}", param.getCompanyId(), ex);
+            throw ex instanceof ServiceException ? (ServiceException) ex : new ServiceException("短信发送失败: " + ex.getMessage());
+        }
+    }
+
+    private int calcSmsContentLen(SmsSendBatchParam param) {
+        if (param.getCustomerIds() == null || param.getCustomerIds().length == 0) {
+            return param.getContent() == null ? 0 : param.getContent().length();
+        }
+        CompanyUser companyUser = companyUserService.selectCompanyUserById(param.getCompanyUserId());
+        CrmCustomer crmCustomer = crmCustomerService.selectCrmCustomerById(param.getCustomerIds()[0]);
+        String content = param.getContent();
+        if (crmCustomer != null && StringUtils.isNotEmpty(crmCustomer.getCustomerName())) {
+            content = content.replace("${sms.csName}", crmCustomer.getCustomerName());
+        }
+        if (companyUser != null && StringUtils.isNotEmpty(companyUser.getPhonenumber())) {
+            content = content.replace("${sms.phoneNumber}", companyUser.getPhonenumber());
+        }
+        if (StringUtils.isNotEmpty(param.getCardUrl())) {
+            content = content.replace("${sms.cardUrl}", param.getCardUrl());
+        }
+        if (StringUtils.isNotEmpty(param.getSenderName())) {
+            content = content.replace("${sms.senderName}", param.getSenderName());
+        }
+        return content.length();
+    }
+
+    private void updateSmsWorkflowProgress(Long roboticId, Long callerId) {
+        CompanyVoiceRobotic robotic = companyVoiceRoboticMapper.selectById(roboticId);
+        CompanyVoiceRoboticCallees callees = companyVoiceRoboticCalleesMapper.selectById(callerId);
+        if (StringUtils.isNotBlank(callees.getRunTaskFlow())) {
+            callees.setRunTaskFlow(callees.getRunTaskFlow() + "," + Constants.SEND_MSG);
+            callees.setIsSendMsg(1);
+        } else {
+            callees.setRunTaskFlow(Constants.SEND_MSG);
+        }
+        companyVoiceRoboticCalleesMapper.updateById(callees);
+        Integer unfulfilledTaskCount = companyVoiceRoboticCalleesMapper.getRoboticIsDoneByRoboticIdAndTaskFlow(roboticId, Constants.SEND_MSG);
+        if (unfulfilledTaskCount.compareTo(0) == 0) {
+            if (StringUtils.isNotBlank(robotic.getRunTaskFlow())) {
+                robotic.setRunTaskFlow(robotic.getRunTaskFlow() + "," + Constants.SEND_MSG);
+            } else {
+                robotic.setRunTaskFlow(Constants.SEND_MSG);
+            }
+            companyVoiceRoboticMapper.updateById(robotic);
+        }
+    }
+
+    private void checkSmsBlacklist(Long companyId, String phone) {
+        if (StringUtils.isBlank(phone)) {
+            return;
+        }
+        CompanyVoiceRoboticCallBlacklistCheckParam checkParam = new CompanyVoiceRoboticCallBlacklistCheckParam();
+        checkParam.setCompanyId(companyId);
+        checkParam.setBusinessType(BusinessTypeEnum.SMS.getCode());
+        checkParam.setTargetValue(PhoneUtil.decryptPhone(phone));
+        CompanyVoiceRoboticCallBlacklistCheckVO vo = companyVoiceRoboticCallBlacklistService.checkBlacklist(checkParam);
+        if (!vo.getPass()) {
+            throw new ServiceException("号码命中短信黑名单: " + phone);
+        }
+    }
+}

+ 3 - 0
fs-service/src/main/java/com/fs/common/service/ISmsService.java

@@ -1,6 +1,7 @@
 package com.fs.common.service;
 
 import com.fs.common.core.domain.R;
+import com.fs.company.domain.CompanySmsTemp;
 import com.fs.crm.param.SmsSendBatchParam;
 import com.fs.crm.param.SmsSendParam;
 import com.fs.crm.param.SmsSendUserParam;
@@ -16,6 +17,8 @@ public interface ISmsService
 
     R sendBatchSms(SmsSendBatchParam param);
 
+    void batchSmsOp4AiSend(CompanySmsTemp temp, SmsSendBatchParam param);
+
     R sendUserSms(String phone,String UserName,String code);
 
     R sendOrderMsg(SmsSendUserParam param);

+ 24 - 7
fs-service/src/main/java/com/fs/common/service/impl/SmsServiceImpl.java

@@ -733,18 +733,34 @@ public class SmsServiceImpl implements ISmsService
         }
     }
 
+    @Override
     public void batchSmsOp4AiSend(CompanySmsTemp temp, SmsSendBatchParam param){
         CompanyUser companyUser=companyUserService.selectCompanyUserById(param.getCompanyUserId());
-        CompanyVoiceRoboticCallBlacklistCheckParam companyVoiceRoboticCallBlacklistCheckParam = new CompanyVoiceRoboticCallBlacklistCheckParam();
-        companyVoiceRoboticCallBlacklistCheckParam.setCompanyId(param.getCompanyId());
-        companyVoiceRoboticCallBlacklistCheckParam.setBusinessType(BusinessTypeEnum.SMS.getCode());
-        companyVoiceRoboticCallBlacklistCheckParam.setTargetValue(companyUser.getPhonenumber());
-        CompanyVoiceRoboticCallBlacklistCheckVO companyVoiceRoboticCallBlacklistCheckVO = companyVoiceRoboticCallBlacklistService.checkBlacklist(companyVoiceRoboticCallBlacklistCheckParam);
-        if (!companyVoiceRoboticCallBlacklistCheckVO.getPass()){
-            throw new RuntimeException("黑名单校验未通过");
+        if (companyUser != null && StringUtils.isNotEmpty(companyUser.getPhonenumber())) {
+            CompanyVoiceRoboticCallBlacklistCheckParam salesBlacklistParam = new CompanyVoiceRoboticCallBlacklistCheckParam();
+            salesBlacklistParam.setCompanyId(param.getCompanyId());
+            salesBlacklistParam.setBusinessType(BusinessTypeEnum.SMS.getCode());
+            salesBlacklistParam.setTargetValue(companyUser.getPhonenumber());
+            CompanyVoiceRoboticCallBlacklistCheckVO salesBlacklistVo = companyVoiceRoboticCallBlacklistService.checkBlacklist(salesBlacklistParam);
+            if (!salesBlacklistVo.getPass()){
+                throw new RuntimeException("销售号码黑名单校验未通过");
+            }
         }
         for(Long id:param.getCustomerIds()){
             CrmCustomer crmCustomer=crmCustomerService.selectCrmCustomerById(id);
+            if (crmCustomer == null) {
+                throw new RuntimeException("客户不存在: " + id);
+            }
+            if (StringUtils.isNotEmpty(crmCustomer.getMobile())) {
+                CompanyVoiceRoboticCallBlacklistCheckParam customerBlacklistParam = new CompanyVoiceRoboticCallBlacklistCheckParam();
+                customerBlacklistParam.setCompanyId(param.getCompanyId());
+                customerBlacklistParam.setBusinessType(BusinessTypeEnum.SMS.getCode());
+                customerBlacklistParam.setTargetValue(crmCustomer.getMobile());
+                CompanyVoiceRoboticCallBlacklistCheckVO customerBlacklistVo = companyVoiceRoboticCallBlacklistService.checkBlacklist(customerBlacklistParam);
+                if (!customerBlacklistVo.getPass()){
+                    throw new RuntimeException("客户号码黑名单校验未通过");
+                }
+            }
             String content="";
             content=param.getContent();
             if(StringUtils.isNotEmpty(crmCustomer.getCustomerName())){
@@ -793,6 +809,7 @@ public class SmsServiceImpl implements ISmsService
                 }
             } else {
                 log.warn("batchSmsOp4AiSend: 发送失败 phone={}, result={}", crmCustomer.getMobile(), sendResult);
+                throw new RuntimeException("短信发送失败: " + sendResult);
             }
         }
     }

+ 2 - 0
fs-service/src/main/java/com/fs/company/mapper/CompanyUserMapper.java

@@ -372,4 +372,6 @@ public interface CompanyUserMapper
     CompanyUserAnalyseVO selectCompanyUserNewUserCount(@Param("companyUserId") Long id);
 
     CompanyUserAnalyseVO selectCompanyUserPhoneLogCount(@Param("companyUserId") Long id);
+
+    int unbindCidServer(@Param("companyUserId") Long companyUserId);
 }

+ 18 - 0
fs-service/src/main/java/com/fs/company/param/BindCidServerParam.java

@@ -0,0 +1,18 @@
+package com.fs.company.param;
+
+import lombok.Data;
+
+/**
+ * @author MixLiu
+ * @date 2026/2/26 18:34
+ * @description
+ */
+
+@Data
+public class BindCidServerParam {
+
+    private Long userId;
+
+    private Long companyId;
+
+}

+ 78 - 0
fs-service/src/main/java/com/fs/company/service/ICompanyAiWorkflowServerService.java

@@ -0,0 +1,78 @@
+package com.fs.company.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.fs.common.core.domain.R;
+import com.fs.company.domain.CompanyAiWorkflowServer;
+import com.fs.company.param.BindCidServerParam;
+
+import java.util.List;
+
+/**
+ * cid服务Service接口
+ * 
+ * @author fs
+ * @date 2026-02-26
+ */
+public interface ICompanyAiWorkflowServerService extends IService<CompanyAiWorkflowServer>{
+    /**
+     * 查询cid服务
+     * 
+     * @param id cid服务主键
+     * @return cid服务
+     */
+    CompanyAiWorkflowServer selectCompanyAiWorkflowServerById(Long id);
+
+    /**
+     * 查询cid服务列表
+     * 
+     * @param companyAiWorkflowServer cid服务
+     * @return cid服务集合
+     */
+    List<CompanyAiWorkflowServer> selectCompanyAiWorkflowServerList(CompanyAiWorkflowServer companyAiWorkflowServer);
+
+    /**
+     * 新增cid服务
+     * 
+     * @param companyAiWorkflowServer cid服务
+     * @return 结果
+     */
+    int insertCompanyAiWorkflowServer(CompanyAiWorkflowServer companyAiWorkflowServer);
+
+    /**
+     * 修改cid服务
+     * 
+     * @param companyAiWorkflowServer cid服务
+     * @return 结果
+     */
+    int updateCompanyAiWorkflowServer(CompanyAiWorkflowServer companyAiWorkflowServer);
+
+    /**
+     * 批量删除cid服务
+     * 
+     * @param ids 需要删除的cid服务主键集合
+     * @return 结果
+     */
+    int deleteCompanyAiWorkflowServerByIds(Long[] ids);
+
+    /**
+     * 删除cid服务信息
+     * 
+     * @param id cid服务主键
+     * @return 结果
+     */
+    int deleteCompanyAiWorkflowServerById(Long id);
+
+    /**
+     * 绑定cid服务
+     * @param param
+     * @return
+     */
+    R bindCidServer( BindCidServerParam param);
+
+    /**
+     * 解绑cid服务
+     * @param param
+     * @return
+     */
+    R unbindCidServer( BindCidServerParam param);
+}

+ 2 - 0
fs-service/src/main/java/com/fs/company/service/ICompanyVoiceRoboticCallLogSendmsgService.java

@@ -67,4 +67,6 @@ public interface ICompanyVoiceRoboticCallLogSendmsgService extends IService<Comp
     List<CompanyVoiceRoboticCallLogSendmsgVO> listByCallerIdAndRoboticId(CompanyVoiceRoboticCallLogSendmsg companyVoiceRoboticCallLogSendmsg);
 
     CompanyVoiceRoboticCallLogCount selectCompanyVoiceRoboticCallLogSendMsgCount();
+
+    void asyncInsertCompanyVoiceRoboticCallLog(CompanyVoiceRoboticCallLogSendmsg sendMsgLog);
 }

+ 165 - 0
fs-service/src/main/java/com/fs/company/service/impl/CompanyAiWorkflowServerServiceImpl.java

@@ -0,0 +1,165 @@
+package com.fs.company.service.impl;
+
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.fs.common.core.domain.R;
+import com.fs.common.utils.DateUtils;
+import com.fs.company.domain.CompanyAiWorkflowServer;
+import com.fs.company.domain.CompanyUser;
+import com.fs.company.mapper.CompanyAiWorkflowServerMapper;
+import com.fs.company.mapper.CompanyUserMapper;
+import com.fs.company.param.BindCidServerParam;
+import com.fs.company.service.ICompanyAiWorkflowServerService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.Random;
+
+/**
+ * cid服务Service业务层处理
+ *
+ * @author fs
+ * @date 2026-02-26
+ */
+@Service
+@Slf4j
+public class CompanyAiWorkflowServerServiceImpl extends ServiceImpl<CompanyAiWorkflowServerMapper, CompanyAiWorkflowServer> implements ICompanyAiWorkflowServerService {
+
+    @Autowired
+    CompanyAiWorkflowServerMapper companyAiWorkflowServerMapper;
+    @Autowired
+    CompanyUserMapper companyUserMapper;
+
+    /**
+     * 查询cid服务
+     *
+     * @param id cid服务主键
+     * @return cid服务
+     */
+    @Override
+    public CompanyAiWorkflowServer selectCompanyAiWorkflowServerById(Long id) {
+        return baseMapper.selectCompanyAiWorkflowServerById(id);
+    }
+
+    /**
+     * 查询cid服务列表
+     *
+     * @param companyAiWorkflowServer cid服务
+     * @return cid服务
+     */
+    @Override
+    public List<CompanyAiWorkflowServer> selectCompanyAiWorkflowServerList(CompanyAiWorkflowServer companyAiWorkflowServer) {
+        return baseMapper.selectCompanyAiWorkflowServerList(companyAiWorkflowServer);
+    }
+
+    /**
+     * 新增cid服务
+     *
+     * @param companyAiWorkflowServer cid服务
+     * @return 结果
+     */
+    @Override
+    public int insertCompanyAiWorkflowServer(CompanyAiWorkflowServer companyAiWorkflowServer) {
+        companyAiWorkflowServer.setCreateTime(DateUtils.getNowDate());
+        return baseMapper.insertCompanyAiWorkflowServer(companyAiWorkflowServer);
+    }
+
+    /**
+     * 修改cid服务
+     *
+     * @param companyAiWorkflowServer cid服务
+     * @return 结果
+     */
+    @Override
+    public int updateCompanyAiWorkflowServer(CompanyAiWorkflowServer companyAiWorkflowServer) {
+        return baseMapper.updateCompanyAiWorkflowServer(companyAiWorkflowServer);
+    }
+
+    /**
+     * 批量删除cid服务
+     *
+     * @param ids 需要删除的cid服务主键
+     * @return 结果
+     */
+    @Override
+    public int deleteCompanyAiWorkflowServerByIds(Long[] ids) {
+        return baseMapper.deleteCompanyAiWorkflowServerByIds(ids);
+    }
+
+    /**
+     * 删除cid服务信息
+     *
+     * @param id cid服务主键
+     * @return 结果
+     */
+    @Override
+    public int deleteCompanyAiWorkflowServerById(Long id) {
+        return baseMapper.deleteCompanyAiWorkflowServerById(id);
+    }
+
+    /**
+     * 绑定cid服务
+     *
+     * @param param
+     * @return
+     */
+    @Override
+    @Transactional
+    public R bindCidServer(BindCidServerParam param) {
+        try {
+            List<CompanyAiWorkflowServer> availableServerList = companyAiWorkflowServerMapper.getAvailableServerList();
+            if (null != availableServerList && availableServerList.size() > 0) {
+                Random random = new Random();
+                int randomIndex = random.nextInt(availableServerList.size());
+                CompanyAiWorkflowServer randomServer = availableServerList.get(randomIndex);
+                CompanyAiWorkflowServer server = companyAiWorkflowServerMapper.selectServerForUpdate(randomServer.getId());
+                if (server != null) {
+                    CompanyUser updateUser = new CompanyUser();
+                    updateUser.setUserId(param.getUserId());
+                    updateUser.setCidServerId(server.getId());
+                    companyUserMapper.updateCompanyUser(updateUser);
+                    server.setCount(server.getCount() - 1);
+                    companyAiWorkflowServerMapper.updateCompanyAiWorkflowServer(server);
+                    return R.ok("绑定成功");
+                } else {
+                    return R.error("绑定失败,请稍后重试");
+                }
+            }
+            return R.error("绑定失败,没有空闲服务");
+        } catch (Exception ex) {
+            log.error("bindCidServer内部错误", ex);
+            throw new RuntimeException("内部错误");
+        }
+    }
+
+    /**
+     * 解绑cid服务
+     * @param param
+     * @return
+     */
+    @Override
+    @Transactional
+    public R unbindCidServer(BindCidServerParam param) {
+        try {
+            CompanyUser companyUser = companyUserMapper.selectCompanyUserByCompanyUserId(param.getUserId());
+            if (null != companyUser && companyUser.getCidServerId() != null) {
+                CompanyAiWorkflowServer server = companyAiWorkflowServerMapper.selectServerUnbindForUpdate(companyUser.getCidServerId());
+                if (null != server) {
+                    companyUserMapper.unbindCidServer(companyUser.getUserId());
+                    server.setCount(server.getCount() + 1);
+                    companyAiWorkflowServerMapper.updateCompanyAiWorkflowServer(server);
+                    return R.ok("解绑成功");
+                } else {
+                    return R.error("解除绑定失败,请稍后重试");
+                }
+            } else {
+                return R.error("解除绑定失败,请稍后重试");
+            }
+        } catch (Exception ex) {
+            log.error("bindCidServer内部错误", ex);
+            throw new RuntimeException("内部错误");
+        }
+    }
+}

+ 71 - 169
fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticServiceImpl.java

@@ -12,6 +12,10 @@ import com.fs.aicall.domain.param.CalleeDomain;
 import com.fs.aicall.domain.param.CalltaskcreateaiCustomizeDomain;
 import com.fs.aicall.domain.result.CalltaskcreateaiCustomizeResult;
 import com.fs.aicall.service.AiCallService;
+import com.fs.comm.client.CommGatewayClient;
+import com.fs.comm.model.CommSmsSendParam;
+import com.fs.comm.model.CommSmsSendResult;
+import com.fs.comm.service.CommSmsSendService;
 import com.fs.common.annotation.DataScope;
 import com.fs.common.config.RedisTenantContext;
 import com.fs.common.constant.Constants;
@@ -22,7 +26,6 @@ import com.fs.common.core.redis.RedisCache;
 import com.fs.common.core.redis.RedisCacheT;
 import com.fs.common.exception.CustomException;
 import com.fs.common.exception.base.BaseException;
-import com.fs.common.service.impl.SmsServiceImpl;
 import com.fs.common.utils.*;
 import com.fs.common.utils.spring.SpringUtils;
 import com.fs.company.domain.*;
@@ -111,7 +114,7 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
 
     private final SysDictDataMapper sysDictDataMapper;
 
-    private final SmsServiceImpl smsService;
+    private final CommSmsSendService commSmsSendService;
     private final CompanySmsTempServiceImpl smsTempService;
     private final ICompanySmsService companySmsService;
     private final CompanyWxClientMapper companyWxClientMapper;
@@ -484,190 +487,88 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
 
     @Override
     public void workflowSendSmsOne(Long roboticId, Long callerId, ExecutionContext context, Long smsTempId) {
-        try {
-            CompanyVoiceRobotic robotic = companyVoiceRoboticMapper.selectById(roboticId);
-            CompanyVoiceRoboticCallees callees = companyVoiceRoboticCalleesMapper.selectById(callerId);
-            //callbackUuid(短信回执预留)
-            String callbackUuid = UUID.randomUUID().toString();
-            JSONObject userDataJson = new JSONObject();
-            userDataJson.put("callbackUuid", callbackUuid);
-            userDataJson.put("nodeKey", context.getCurrentNodeKey());
-            userDataJson.put("workflowInstanceId", context.getWorkflowInstanceId());
-            userDataJson.put("callerId", callerId);
-            context.setVariable("smsCallbackUuid", callbackUuid);
-            redisCache2.setCacheObject(WORKFLOW_SMS_ONE_REDIS_KEY + callbackUuid, userDataJson.toJSONString());
-            //构建短信参数
-            CompanyWxClient wxClient = companyWxClientMapper.selectOneByRoboticIdAndUserId(roboticId, callees.getUserId());
-            CompanyVoiceRoboticWx wx = companyVoiceRoboticWxService.getById(wxClient.getRoboticWxId());
+        CommGatewayClient commGatewayClient = SpringUtils.getBean(CommGatewayClient.class);
+        if (commGatewayClient.isEnabled()) {
+            try {
+                workflowSendSmsOneViaGateway(commGatewayClient, roboticId, callerId, context, smsTempId);
+                return;
+            } catch (Exception ex) {
+                log.error("workflowSendSmsOne 通讯网关调用失败 roboticId={}, callerId={}", roboticId, callerId, ex);
+                if (!commGatewayClient.isFallbackLocal()) {
+                    throw ex instanceof RuntimeException ? (RuntimeException) ex : new RuntimeException(ex);
+                }
+                log.warn("workflowSendSmsOne 降级为本地短信发送");
+            }
+        }
+        workflowSendSmsOneLocal(roboticId, callerId, context, smsTempId);
+    }
 
-            CompanyWxAccount wxAccount = new CompanyWxAccount();
+    private void workflowSendSmsOneViaGateway(CommGatewayClient commGatewayClient, Long roboticId, Long callerId,
+                                              ExecutionContext context, Long smsTempId) {
+        CompanyVoiceRobotic robotic = companyVoiceRoboticMapper.selectById(roboticId);
+        CompanyVoiceRoboticCallees callees = companyVoiceRoboticCalleesMapper.selectById(callerId);
+        CompanyWxClient wxClient = companyWxClientMapper.selectOneByRoboticIdAndUserId(roboticId, callees.getUserId());
+        Long companyUserId = null;
+        String senderName = null;
+        if (wxClient != null) {
             if (wxClient.getIsWeCom() == 2) {
                 QwUser qwUserByRedis = qwExternalContactService.getQwUserByRedisForId(String.valueOf(wxClient.getAccountId()));
-                wxAccount.setCompanyId(qwUserByRedis.getCompanyId());
-                wxAccount.setCompanyUserId(qwUserByRedis.getCompanyUserId());
-                wxAccount.setWxNickName(qwUserByRedis.getQwUserName());
+                companyUserId = qwUserByRedis.getCompanyUserId();
+                senderName = qwUserByRedis.getQwUserName();
             } else {
-                wxAccount = companyWxAccountService.selectCompanyWxAccountById(wxClient.getAccountId());
+                CompanyWxAccount wxAccount = companyWxAccountService.selectCompanyWxAccountById(wxClient.getAccountId());
+                companyUserId = wxAccount.getCompanyUserId();
+                senderName = wxAccount.getWxNickName();
             }
-
-            CompanySmsTemp temp = smsTempService.selectCompanySmsTempById(smsTempId);
-
-            if (temp != null && temp.getStatus().equals(1) && temp.getIsAudit().equals(1)) {
-                CompanySms sms = companySmsService.selectCompanySmsByCompanyId(wxAccount.getCompanyId());
-                if (sms != null) {
-                    if (sms.getRemainSmsCount() > 0) {
-                        SmsSendBatchParam smsSendBatchParam = new SmsSendBatchParam();
-                        smsSendBatchParam.setCompanyId(wxAccount.getCompanyId());
-                        smsSendBatchParam.setCompanyUserId(wxAccount.getCompanyUserId());
-                        smsSendBatchParam.setSmsType(temp.getTempType());
-                        smsSendBatchParam.setTempCode(temp.getTempCode());
-                        smsSendBatchParam.setContent(temp.getContent());
-                        smsSendBatchParam.setSenderName(wxAccount.getWxNickName());
-                        smsSendBatchParam.setCustomerIds(new Long[]{callees.getUserId()});
-                        //记录工作流级短信日志
-                        JSONObject runParam = (JSONObject) JSON.toJSON(smsSendBatchParam);
-                        runParam.put("temp", temp);
-                        CompanyVoiceRoboticCallLogSendmsg addLog = CompanyVoiceRoboticCallLogSendmsg.initCallLog(
-                                runParam.toJSONString(),
-                                callerId,
-                                roboticId,
-                                wxAccount.getCompanyId(),
-                                wxAccount.getCompanyUserId(),
-                                temp.getTempId()
-                        );
-                        addLog.setStatus(1);
-                        try {
-                            int smsContentLen = getSmsContentLen(smsSendBatchParam);
-                            addLog.setContentLen(smsContentLen);
-                            sendMsgBatch(temp, smsSendBatchParam);
-                            addLog.setStatus(2);
-                            addLog.setCallbackUuid(callbackUuid);
-                        } catch (Exception ex) {
-                            addLog.setStatus(3);
-                            addLog.setResult(ex.getMessage());
-                            log.error("sendMsgOne异常:", ex);
-                        } finally {
-                            companyVoiceRoboticCallLogSendmsgService.asyncInsertCompanyVoiceRoboticCallLog(addLog);
-                        }
-                    } else {
-                        log.error("剩余短信数量不足,请充值:task:{},companyId:{}", roboticId, wxAccount.getCompanyId());
-                        throw new RuntimeException("剩余短信数量不足,请充值");
-                    }
-                } else {
-                    log.error("请充值:task:{},companyId:{}", roboticId, robotic.getCompanyId());
-                    throw new RuntimeException("请充值");
-                }
-
-                if (StringUtils.isNotBlank(callees.getRunTaskFlow())) {
-                    callees.setRunTaskFlow(callees.getRunTaskFlow() + "," + Constants.SEND_MSG);
-                    callees.setIsSendMsg(1);
-                } else {
-                    callees.setRunTaskFlow(Constants.SEND_MSG);
-                }
-                companyVoiceRoboticCalleesMapper.updateById(callees);
-                Integer unfulfilledTaskCount = companyVoiceRoboticCalleesMapper.getRoboticIsDoneByRoboticIdAndTaskFlow(roboticId, Constants.SEND_MSG);
-                //全部完成才更新任务状态
-                if (unfulfilledTaskCount.compareTo(0) == 0) {
-                    if (StringUtils.isNotBlank(robotic.getRunTaskFlow())) {
-                        robotic.setRunTaskFlow(robotic.getRunTaskFlow() + "," + Constants.SEND_MSG);
-                    } else {
-                        robotic.setRunTaskFlow(Constants.SEND_MSG);
-                    }
-                    companyVoiceRoboticMapper.updateById(robotic);
-                }
-            } else {
-                log.error("模板未审核:task:{},smsTemp:{}", roboticId, temp);
-                throw new RuntimeException("模板未审核");
-            }
-        } catch (Exception ex) {
-            log.error("workflowSendSmsOne 异常 roboticId:{} callerId:{}", roboticId, callerId, ex);
-            throw new RuntimeException(ex);
         }
+        Map<String, Object> body = new HashMap<>();
+        body.put("roboticId", roboticId);
+        body.put("calleeId", callerId);
+        body.put("smsTempId", smsTempId);
+        body.put("nodeKey", context.getCurrentNodeKey());
+        body.put("workflowInstanceId", context.getWorkflowInstanceId());
+        body.put("companyUserId", companyUserId);
+        body.put("senderName", senderName);
+        JSONObject result = commGatewayClient.sendSms(TenantHelper.getTenantId(), robotic.getCompanyId(), companyUserId, body);
+        if (result != null && StringUtils.isNotBlank(result.getString("callbackUuid"))) {
+            context.setVariable("smsCallbackUuid", result.getString("callbackUuid"));
+        }
+    }
+
+    private void workflowSendSmsOneLocal(Long roboticId, Long callerId, ExecutionContext context, Long smsTempId) {
+        CommSmsSendService commSmsSendService = SpringUtils.getBean(CommSmsSendService.class);
+        CommSmsSendResult result = commSmsSendService.sendWorkflowSms(CommSmsSendParam.builder()
+                .roboticId(roboticId)
+                .calleeId(callerId)
+                .smsTempId(smsTempId)
+                .nodeKey(context.getCurrentNodeKey())
+                .workflowInstanceId(context.getWorkflowInstanceId())
+                .build());
+        context.setVariable("smsCallbackUuid", result.getCallbackUuid());
     }
 
 
     @Synchronized
     public void sendMsgOne(Long roboticId, Long callerId) {
         try {
-            log.info("开始发送短信个人*************");
-            CompanyVoiceRobotic robotic = companyVoiceRoboticMapper.selectById(roboticId);
+            log.info("开始发送短信个人 roboticId={}, callerId={}", roboticId, callerId);
             CompanyVoiceRoboticCallees callees = companyVoiceRoboticCalleesMapper.selectById(callerId);
             CompanyWxClient wxClient = companyWxClientMapper.selectOneByRoboticIdAndUserId(roboticId, callees.getUserId());
+            if (wxClient == null) {
+                throw new RuntimeException("未找到微信客户端配置");
+            }
             CompanyVoiceRoboticWx wx = companyVoiceRoboticWxService.getById(wxClient.getRoboticWxId());
-            CompanyWxAccount wxAccount = companyWxAccountService.selectCompanyWxAccountById(wxClient.getAccountId());
-            CompanySmsTemp temp = smsTempService.selectCompanySmsTempById(Long.valueOf(wx.getSmsTempId()));
-            if (temp != null && temp.getStatus().equals(1) && temp.getIsAudit().equals(1)) {
-                CompanySms sms = companySmsService.selectCompanySmsByCompanyId(wxAccount.getCompanyId());
-                if (sms != null) {
-                    if (sms.getRemainSmsCount() > 0) {
-//                        if(sms.getRemainSmsCount()<(param.getCustomerIds().length*2)){
-//                            return R.error("短信数量不足");
-//                        }
-                        SmsSendBatchParam smsSendBatchParam = new SmsSendBatchParam();
-                        smsSendBatchParam.setSmsType(temp.getTempType());
-                        smsSendBatchParam.setCompanyId(wxAccount.getCompanyId());
-                        smsSendBatchParam.setCompanyUserId(wxAccount.getCompanyUserId());
-                        smsSendBatchParam.setTempCode(temp.getTempCode());
-                        Long[] ids = new Long[1];
-                        ids[0] = callees.getUserId();
-                        smsSendBatchParam.setCustomerIds(ids);
-                        smsSendBatchParam.setContent(temp.getContent());
-                        smsSendBatchParam.setSenderName(wxAccount.getWxNickName());
-                        JSONObject runParam = (JSONObject) JSON.toJSON(smsSendBatchParam);
-                        runParam.put("temp", temp);
-                        CompanyVoiceRoboticCallLogSendmsg addLog = CompanyVoiceRoboticCallLogSendmsg.initCallLog(
-                                runParam.toJSONString(), callerId, roboticId, wxAccount.getCompanyId(), wxAccount.getCompanyUserId(), temp.getTempId());
-                        addLog.setStatus(2);
-                        try {
-                            sendMsgBatch(temp, smsSendBatchParam);
-                        } catch (Exception ex) {
-                            addLog.setStatus(3);
-                            addLog.setResult(ex.getMessage());
-                            log.error("sendMsgOne异常:", ex);
-                        }
-                        int smsContentLen = getSmsContentLen(smsSendBatchParam);
-                        addLog.setContentLen(smsContentLen);
-                        companyVoiceRoboticCallLogSendmsgService.asyncInsertCompanyVoiceRoboticCallLog(addLog);
-                        //如果选择的是名片短链接模版  update by qxj 2023年05月26日10:45:28
-//                        if(StringUtils.isNotEmpty(param.getCardUrl())){
-//                            smsSendBatchParam.setCardUrl(param.getCardUrl());
-//                        }
-//                        batchSmsOp(temp,smsSendBatchParam);
-//                        return R.ok("短信提交成功,正在发送中...");
-                    } else {
-                        log.error("剩余短信数量不足,请充值:task:{},companyId:{}", roboticId, wxAccount.getCompanyId());
-                        throw new RuntimeException("剩余短信数量不足,请充值");
-                    }
-                } else {
-                    log.error("请充值:task:{},companyId:{}", roboticId, robotic.getCompanyId());
-                    throw new RuntimeException("请充值");
-                }
-
-                if (StringUtils.isNotBlank(callees.getRunTaskFlow())) {
-                    callees.setRunTaskFlow(callees.getRunTaskFlow() + "," + Constants.SEND_MSG);
-                    callees.setIsSendMsg(1);
-                } else {
-                    callees.setRunTaskFlow(Constants.SEND_MSG);
-                }
-                companyVoiceRoboticCalleesMapper.updateById(callees);
-                Integer unfulfilledTaskCount = companyVoiceRoboticCalleesMapper.getRoboticIsDoneByRoboticIdAndTaskFlow(roboticId, Constants.SEND_MSG);
-                //全部完成才更新任务状态
-                if (unfulfilledTaskCount.compareTo(0) == 0) {
-                    if (StringUtils.isNotBlank(robotic.getRunTaskFlow())) {
-                        robotic.setRunTaskFlow(robotic.getRunTaskFlow() + "," + Constants.SEND_MSG);
-                    } else {
-                        robotic.setRunTaskFlow(Constants.SEND_MSG);
-                    }
-                    companyVoiceRoboticMapper.updateById(robotic);
-                }
-            } else {
-                log.error("模板未审核:task:{},smsTemp:{}", roboticId, temp);
-                throw new RuntimeException("模板未审核");
+            if (wx == null || wx.getSmsTempId() == null) {
+                throw new RuntimeException("未配置短信模板");
             }
-
+            commSmsSendService.sendWorkflowSms(CommSmsSendParam.builder()
+                    .roboticId(roboticId)
+                    .calleeId(callerId)
+                    .smsTempId(wx.getSmsTempId().longValue())
+                    .build());
         } catch (Exception ex) {
             log.error("sendMsgOne异常,roboticId:{},callerId :{}\n", roboticId, callerId, ex);
-            throw new RuntimeException(ex);
+            throw ex instanceof RuntimeException ? (RuntimeException) ex : new RuntimeException(ex);
         }
     }
 
@@ -766,7 +667,7 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
      * @param param
      */
     public void sendMsgBatch(CompanySmsTemp temp, SmsSendBatchParam param) {
-        smsService.batchSmsOp4AiSend(temp, param);
+        commSmsSendService.sendPreparedBatch(temp, param);
     }
 
     /**
@@ -896,6 +797,7 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
                     );
                     // 切换 Redis 租户上下文
                     RedisTenantContext.setTenantId(TenantHelper.getTenantId());
+                    isSwitched = true;
                 } catch (Exception e) {
                     log.error("callerResult4EasyCall 切换租户数据源失败: tenantId={}", TenantHelper.getTenantId(), e);
                 }

+ 51 - 120
fs-service/src/main/java/com/fs/company/service/impl/call/node/AiCallTaskNode.java

@@ -14,6 +14,10 @@ import com.fs.company.service.CompanyWorkflowEngine;
 import com.fs.company.service.ICompanyVoiceRoboticCallBlacklistService;
 import com.fs.company.service.ICompanyVoiceRoboticService;
 import com.fs.company.service.easycall.IEasyCallService;
+import com.fs.comm.client.CommGatewayClient;
+import com.fs.comm.model.CommCallSendParam;
+import com.fs.comm.model.CommCallSendResult;
+import com.fs.comm.service.CommCallSendService;
 import com.fs.company.service.impl.CompanyVoiceRoboticCallLogCallphoneServiceImpl;
 import com.fs.company.vo.AiCallConfigVO;
 import com.fs.company.vo.AiCallWorkflowConditionVo;
@@ -270,133 +274,60 @@ public class AiCallTaskNode extends AbstractWorkflowNode {
      * @param callConfigVo 节点配置,包含外呼线路 ID、大模型底座 ID、音色、技能组等参数
      */
     private void workflowCallPhoneOne4EasyCall(Long roboticId,Long calleeId, ExecutionContext context, AiCallConfigVO callConfigVo) {
-
-        CompanyVoiceRobotic robotic = companyVoiceRoboticMapper.selectById(roboticId);
-        String callBackUrl = "";
-        try{
-            CompanyConfig companyConfig = companyConfigMapper.selectCompanyConfigByKey(robotic.getCompanyId(), "cId.config");
-            CidPhoneConfig cidConf = JSONObject.parseObject(companyConfig.getConfigValue(), CidPhoneConfig.class);
-            if(null != cidConf && StringUtils.isNotBlank(cidConf.getCallbackUrl())){
-                callBackUrl = cidConf.getCallbackUrl();
-            }
-            //读取总后台配置
-            if(StringUtils.isBlank(callBackUrl)){
-                String s = configService.selectConfigByKey("cId.config");
-                JSONObject obj = JSONObject.parseObject(s);
-                if(null != obj && obj.containsKey("callbackUrl") && StringUtils.isNotBlank(obj.getString("callbackUrl"))){
-                    callBackUrl = obj.getString("callbackUrl");
+        CommGatewayClient commGatewayClient = SpringUtils.getBean(CommGatewayClient.class);
+        if (commGatewayClient.isEnabled()) {
+            try {
+                workflowCallPhoneViaGateway(commGatewayClient, roboticId, calleeId, context, callConfigVo);
+                return;
+            } catch (Exception ex) {
+                log.error("workflowCallPhoneOne4EasyCall 通讯网关调用失败,roboticId={}, calleeId={}", roboticId, calleeId, ex);
+                if (!commGatewayClient.isFallbackLocal()) {
+                    throw ex instanceof RuntimeException ? (RuntimeException) ex : new RuntimeException(ex);
                 }
+                log.warn("workflowCallPhoneOne4EasyCall 降级为本地外呼");
             }
-        } catch (Exception ex){
-            log.error("获取公司Cid配置失败:{}", ex);
-        }
-        // 1. 生成回调唯一标识符,后续回调时通过此 uuid 匹配对应的流程实例
-        String callBackUuid = UUID.randomUUID().toString();
-        // 将回调信息写入 Redis,保存 1 天
-        JSONObject callbackInfo = new JSONObject();
-        callbackInfo.put("callBackUuid", callBackUuid);
-        callbackInfo.put("nodeKey", context.getCurrentNodeKey());
-        callbackInfo.put("workflowInstanceId", context.getWorkflowInstanceId());
-        callbackInfo.put("calleeId", calleeId);
-        super.redisCache.setCacheObject(EASYCALL_WORKFLOW_REDIS_KEY + callBackUuid,
-                callbackInfo.toJSONString(), 15, TimeUnit.DAYS);
-        // 将 callBackUuid 写入 context,供后续回调时从 context 取用
-        context.setVariable("callBackUuid", callBackUuid);
-
-        // 2. 获取被叫人手机号
-        CompanyVoiceRoboticCallees callees = companyVoiceRoboticCalleesMapper.selectById(calleeId);
-        if (callees == null || StringUtils.isBlank(callees.getPhone())) {
-            log.error("workflowCallPhoneOne4EasyCall: 被叫人不存在或手机号为空 - calleeId: {}", calleeId);
-            throw new RuntimeException("被叫人信息异常,calleeId: " + calleeId);
         }
+        workflowCallPhoneOne4EasyCallLocal(roboticId, calleeId, context, callConfigVo);
+    }
 
-        //校验被叫人是否命中黑名单
-        CrmCustomer crmCustomer = crmCustomerService.selectCrmCustomerById(callees.getUserId());
-        CompanyVoiceRoboticCallBlacklistCheckParam companyVoiceRoboticCallBlacklistCheckParam = new CompanyVoiceRoboticCallBlacklistCheckParam();
-        companyVoiceRoboticCallBlacklistCheckParam.setCompanyId(crmCustomer.getCompanyId());
-        companyVoiceRoboticCallBlacklistCheckParam.setBusinessType(BusinessTypeEnum.CALL.getCode());
-        companyVoiceRoboticCallBlacklistCheckParam.setTargetValue(callees.getPhone());
-        CompanyVoiceRoboticCallBlacklistCheckVO companyVoiceRoboticCallBlacklistCheckVO = companyVoiceRoboticCallBlacklistService.checkBlacklist(companyVoiceRoboticCallBlacklistCheckParam);
-        if (!companyVoiceRoboticCallBlacklistCheckVO.getPass()){
-            log.error("workflowCallPhoneOne4EasyCall: 被叫人黑名单校验未通过 - userId: {}", callees.getUserId());
-            throw new RuntimeException("当前用户黑名单校验未通过");
-        }
-
-        // 3. 构建创建任务参数(AI 外呼模式:taskType=1)
-//        EasyCallCreateTaskParam createParam = new EasyCallCreateTaskParam();
-//        // 任务名称:使用工作流实例 ID + 被叫人 ID 组合,保证唯一性
-//        createParam.setBatchName(robotic.getName() + "_" + context.getWorkflowInstanceId() + "_" + calleeId);
-//        if (null != callConfigVo.getMaxConcurrency())
-//            createParam.setThreadNum(Long.valueOf(callConfigVo.getMaxConcurrency()));
-//        else {
-//            createParam.setThreadNum(3L);
-//        }
-//        // AI 外呼模式
-//        createParam.setTaskType(1);
-//        // 外呼线路(网关)
-//        createParam.setGatewayId(callConfigVo.getGatewayId());
-//        // 大模型底座
-//        createParam.setLlmAccountId(callConfigVo.getLlmAccountId());
-//        // 音色编号
-//        createParam.setVoiceCode(callConfigVo.getVoiceCode());
-//        // 音色来源(如未配置默认留空,由 EasyCallCenter365 使用默认值)
-//        createParam.setVoiceSource(callConfigVo.getVoiceSource());
-//        // 技能组(转人工客服分组,可选)
-//        createParam.setGroupId(callConfigVo.getBusiGroupId());
-
-
-        // 4. 调用 EasyCallCenter365 创建任务接口
-        // companyId 传 null 是因为 EasyCallCenter365 是全局地址,不需要按公司隔离
-//        log.info("workflowCallPhoneOne4EasyCall: 创建 EasyCall 任务 - workflowInstanceId: {}, calleeId: {}",
-//                context.getWorkflowInstanceId(), calleeId);
-//        EasyCallTaskVO task = easyCallService.createTask(createParam, null);
-//        if (task == null || task.getBatchId() == null) {
-//            log.error("workflowCallPhoneOne4EasyCall: 创建 EasyCall 任务失败 - workflowInstanceId: {}",
-//                    context.getWorkflowInstanceId());
-//            throw new RuntimeException("EasyCallCenter365 创建任务失败");
-//        }
-        Long batchId = getTaskBatchId(robotic.getId(), context.getCurrentNodeKey(), context.getWorkflowInstanceId());
-        if(null == batchId ){
-            log.error("workflowCallPhoneOne4EasyCall: 获取 EasyCall 任务批次ID失败 - workflowInstanceId: {}",context.getWorkflowInstanceId());
-            throw new RuntimeException("任务批次ID失败");
+    private void workflowCallPhoneViaGateway(CommGatewayClient commGatewayClient, Long roboticId, Long calleeId,
+                                             ExecutionContext context, AiCallConfigVO callConfigVo) {
+        CompanyVoiceRobotic robotic = companyVoiceRoboticMapper.selectById(roboticId);
+        java.util.Map<String, Object> body = new java.util.HashMap<>();
+        body.put("calleeId", calleeId);
+        body.put("roboticId", roboticId);
+        body.put("gatewayId", callConfigVo.getGatewayId());
+        body.put("llmAccountId", callConfigVo.getLlmAccountId());
+        body.put("voiceCode", callConfigVo.getVoiceCode());
+        body.put("voiceSource", callConfigVo.getVoiceSource());
+        body.put("busiGroupId", callConfigVo.getBusiGroupId());
+        body.put("maxConcurrency", callConfigVo.getMaxConcurrency());
+        body.put("nodeKey", context.getCurrentNodeKey());
+        body.put("workflowInstanceId", context.getWorkflowInstanceId());
+        JSONObject gatewayResult = commGatewayClient.sendCall(TenantHelper.getTenantId(), robotic.getCompanyId(), null, body);
+        if (gatewayResult == null || StringUtils.isBlank(gatewayResult.getString("callBackUuid"))) {
+            throw new RuntimeException("通讯网关外呼返回异常");
         }
-
-        log.info("workflowCallPhoneOne4EasyCall: EasyCall 任务创建成功 - batchId: {}", batchId);
-
-        // 5. 将被叫号码加入任务名单(使用通用追加接口,支持传入业务数据)
-        EasyCallCommonAddCallListParam addListParam = new EasyCallCommonAddCallListParam();
-        addListParam.setBatchId(batchId);
-        // 构建号码条目,bizJson 传入默认客户信息占位符
-        EasyCallPhoneItemVO phoneItem = new EasyCallPhoneItemVO();
-        // todo
-        phoneItem.setPhoneNum(PhoneUtil.decryptPhone(callees.getPhone()));
-//        phoneItem.setPhoneNum(callees.getPhone());
-        // bizJson 默认传入客户姓名占位,运行时可根据实际业务填充
-        phoneItem.setBizJson(new JSONObject().fluentPut("custName", callees.getUserName()).fluentPut("tenantId", TenantHelper.getTenantId()).fluentPut("callBackUuid",callBackUuid).fluentPut("callBackUrl",callBackUrl));
-        addListParam.setPhoneList(Collections.singletonList(phoneItem));
-        Long gatewayId = callConfigVo.getGatewayId();
-
-        boolean b = easyCallService.addCommonCallList(addListParam, robotic.getCompanyId(), gatewayId);
-        if(b){
-            log.info("workflowCallPhoneOne4EasyCall: 名单追加成功 - batchId: {}, phone: {}",
-                    batchId, callees.getPhone());
-        }else{
-            log.error("workflowCallPhoneOne4EasyCall: 名单追失败 - batchId: {}, phone: {}",
-                    batchId, callees.getPhone());
+        context.setVariable("callBackUuid", gatewayResult.getString("callBackUuid"));
+        if (gatewayResult.getLong("batchId") != null) {
+            context.setVariable("easyCallBatchId", gatewayResult.getLong("batchId"));
         }
+    }
 
-        // 6. 启动外呼任务
-        easyCallService.startTask(batchId, null);
-        log.info("workflowCallPhoneOne4EasyCall: 任务启动成功 - batchId: {}", batchId);
-        JSONObject runParam = (JSONObject) JSON.toJSON(addListParam);
-        runParam.put("companyId", robotic.getCompanyId());
-        CompanyVoiceRoboticCallLogCallphone addLog = CompanyVoiceRoboticCallLogCallphone.initCallLog(
-                runParam.toJSONString(), calleeId, roboticId, robotic.getCompanyId());
-        addLog.setStatus(1);
-        addLog.setCallbackUuid(callBackUuid);
-        companyVoiceRoboticCallLogCallphoneService.asyncInsertCompanyVoiceRoboticCallLog(addLog);
-        // 7. 将 batchId 写入 context,方便后续节点使用
-        context.setVariable("easyCallBatchId", batchId);
+    private void workflowCallPhoneOne4EasyCallLocal(Long roboticId,Long calleeId, ExecutionContext context, AiCallConfigVO callConfigVo) {
+        CommCallSendService commCallSendService = SpringUtils.getBean(CommCallSendService.class);
+        CompanyVoiceRoboticBusiness bus = super.getRoboticBusiness(context.getWorkflowInstanceId());
+        CommCallSendResult result = commCallSendService.sendWorkflowCall(CommCallSendParam.builder()
+                .roboticId(roboticId)
+                .calleeId(calleeId)
+                .businessId(bus != null ? bus.getId() : null)
+                .gatewayId(callConfigVo.getGatewayId())
+                .nodeKey(context.getCurrentNodeKey())
+                .workflowInstanceId(context.getWorkflowInstanceId())
+                .tenantId(TenantHelper.getTenantId())
+                .build());
+        context.setVariable("callBackUuid", result.getCallBackUuid());
+        context.setVariable("easyCallBatchId", result.getBatchId());
     }
 
     private boolean checkPhoneCallLimit(Long businessId){

+ 4 - 4
fs-service/src/main/java/com/fs/fastGpt/mapper/FastGptRoleMapper.java

@@ -36,10 +36,10 @@ public interface FastGptRoleMapper
     public List<FastGptRole> selectFastGptRoleList(FastGptRole fastGptRole);
 
     @Select("<script> " +
-            "select fr.*,qc.corp_name as corpName  from fastgpt_role fr left join qw_company qc on fr.bind_corp_id=qc.corp_id " +
+            "select fr.*,qc.corp_name as corpName  from fastgpt_role fr left join qw_company qc on fr.bind_corp_id COLLATE utf8mb4_general_ci = qc.corp_id " +
             "         <where>  \n" +
             "            <if test=\"roleName != null  and roleName != ''\"> and role_name like concat('%', #{roleName}, '%')</if>\n" +
-            "            <if test=\"companyId != null \"> and company_id = #{companyId}</if>\n" +
+            "            <if test=\"companyId != null \"> and fr.company_id = #{companyId}</if>\n" +
             "            <if test=\"roleType != null \"> and role_type = #{roleType}</if>\n" +
             "            <if test=\"modeConfigJson != null  and modeConfigJson != ''\"> and mode_config_json = #{modeConfigJson}</if>\n" +
             "            <if test=\"mode != null \"> and mode = #{mode}</if>\n" +
@@ -100,10 +100,10 @@ public interface FastGptRoleMapper
     List<FastgptEventLogTotalVo> selectFastGptRoleAppKeyList();
 
     @Select("<script> " +
-            "select fr.*,qc.corp_name as corpName  from fastgpt_role fr left join qw_company qc on fr.bind_corp_id=qc.corp_id " +
+            "select fr.*,qc.corp_name as corpName  from fastgpt_role fr left join qw_company qc on fr.bind_corp_id COLLATE utf8mb4_general_ci = qc.corp_id " +
             "         <where>  \n" +
             "            <if test=\"roleName != null  and roleName != ''\"> and role_name like concat('%', #{roleName}, '%')</if>\n" +
-            "            <if test=\"companyId != null \"> and company_id = #{companyId}</if>\n" +
+            "            <if test=\"companyId != null \"> and fr.company_id = #{companyId}</if>\n" +
             "            <if test=\"roleType != null \"> and role_type = #{roleType}</if>\n" +
             "            <if test=\"modeConfigJson != null  and modeConfigJson != ''\"> and mode_config_json = #{modeConfigJson}</if>\n" +
             "            <if test=\"mode != null \"> and mode = #{mode}</if>\n" +

+ 3 - 0
fs-service/src/main/java/com/fs/system/mapper/SysDictDataMapper.java

@@ -2,6 +2,8 @@ package com.fs.system.mapper;
 
 import java.util.List;
 
+import com.fs.common.annotation.DataSource;
+import com.fs.common.enums.DataSourceType;
 import com.fs.system.vo.DictVO;
 import org.apache.ibatis.annotations.Param;
 import com.fs.common.core.domain.entity.SysDictData;
@@ -28,6 +30,7 @@ public interface SysDictDataMapper
      * @param dictType 字典类型
      * @return 字典数据集合信息
      */
+    @DataSource(DataSourceType.MASTER)
     public List<SysDictData> selectDictDataByType(String dictType);
 
     /**

+ 7 - 0
fs-service/src/main/resources/application-common.yml

@@ -153,3 +153,10 @@ hsy:
   role_secret_key: T0RaaFl6UmhZV1V4WXpKbU5EWTBNMkZpT0RNNU9UY3daak0wTjJFd09XUQ==
   role_trn: trn:iam::2114522511:role/hylj
 
+comm:
+  gateway:
+    base-url: http://127.0.0.1:8010
+    internal-secret: CommGatewayInternal2026!@#
+    enabled: true
+    fallback-local: true
+

+ 70 - 0
fs-service/src/main/resources/db/20250602-company-voice-robotic-call-log-callphone-manual-answered.sql

@@ -0,0 +1,70 @@
+-- 存量租户库补列:company_voice_robotic_call_log_callphone 人工接听相关字段
+-- 请在对应租户库执行(与 tenant-initTable-migration.sql 中增量逻辑一致)
+
+SET @dbname = DATABASE();
+SET @tablename = 'company_voice_robotic_call_log_callphone';
+
+SET @columnname = 'manual_answered';
+SET @preparedStatement = (SELECT IF(
+  (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
+   WHERE TABLE_SCHEMA = @dbname
+     AND TABLE_NAME = @tablename
+     AND COLUMN_NAME = @columnname) > 0,
+  'SELECT 1',
+  'ALTER TABLE company_voice_robotic_call_log_callphone ADD COLUMN `manual_answered` tinyint NULL DEFAULT NULL COMMENT \"人工接听:1:是,0或无:否\"'
+));
+PREPARE alterIfNotExists FROM @preparedStatement;
+EXECUTE alterIfNotExists;
+DEALLOCATE PREPARE alterIfNotExists;
+
+SET @columnname = 'handle_flag';
+SET @preparedStatement = (SELECT IF(
+  (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
+   WHERE TABLE_SCHEMA = @dbname
+     AND TABLE_NAME = @tablename
+     AND COLUMN_NAME = @columnname) > 0,
+  'SELECT 1',
+  'ALTER TABLE company_voice_robotic_call_log_callphone ADD COLUMN `handle_flag` tinyint NULL DEFAULT 0 COMMENT \"是否被处理:0:未被处理,1:已处理\"'
+));
+PREPARE alterIfNotExists FROM @preparedStatement;
+EXECUTE alterIfNotExists;
+DEALLOCATE PREPARE alterIfNotExists;
+
+SET @columnname = 'answered_ext_num';
+SET @preparedStatement = (SELECT IF(
+  (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
+   WHERE TABLE_SCHEMA = @dbname
+     AND TABLE_NAME = @tablename
+     AND COLUMN_NAME = @columnname) > 0,
+  'SELECT 1',
+  'ALTER TABLE company_voice_robotic_call_log_callphone ADD COLUMN `answered_ext_num` varchar(200) NULL DEFAULT NULL COMMENT \"接听分机号码\"'
+));
+PREPARE alterIfNotExists FROM @preparedStatement;
+EXECUTE alterIfNotExists;
+DEALLOCATE PREPARE alterIfNotExists;
+
+SET @columnname = 'manual_answered_time';
+SET @preparedStatement = (SELECT IF(
+  (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
+   WHERE TABLE_SCHEMA = @dbname
+     AND TABLE_NAME = @tablename
+     AND COLUMN_NAME = @columnname) > 0,
+  'SELECT 1',
+  'ALTER TABLE company_voice_robotic_call_log_callphone ADD COLUMN `manual_answered_time` bigint NULL DEFAULT NULL COMMENT \"人工接听时间\"'
+));
+PREPARE alterIfNotExists FROM @preparedStatement;
+EXECUTE alterIfNotExists;
+DEALLOCATE PREPARE alterIfNotExists;
+
+SET @columnname = 'manual_answered_time_len';
+SET @preparedStatement = (SELECT IF(
+  (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
+   WHERE TABLE_SCHEMA = @dbname
+     AND TABLE_NAME = @tablename
+     AND COLUMN_NAME = @columnname) > 0,
+  'SELECT 1',
+  'ALTER TABLE company_voice_robotic_call_log_callphone ADD COLUMN `manual_answered_time_len` bigint NULL DEFAULT NULL COMMENT \"人工接听时长\"'
+));
+PREPARE alterIfNotExists FROM @preparedStatement;
+EXECUTE alterIfNotExists;
+DEALLOCATE PREPARE alterIfNotExists;

+ 71 - 0
fs-service/src/main/resources/db/tenant-initTable-migration.sql

@@ -1370,4 +1370,75 @@ PREPARE alterIfNotExists FROM @preparedStatement;
 EXECUTE alterIfNotExists;
 DEALLOCATE PREPARE alterIfNotExists;
 
+-- =============================================
+-- 增量补列: company_voice_robotic_call_log_callphone 人工接听相关字段
+-- 日期: 2026-06-02
+-- =============================================
+SET @tablename = 'company_voice_robotic_call_log_callphone';
+
+SET @columnname = 'manual_answered';
+SET @preparedStatement = (SELECT IF(
+  (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
+   WHERE TABLE_SCHEMA = @dbname
+     AND TABLE_NAME = @tablename
+     AND COLUMN_NAME = @columnname) > 0,
+  'SELECT 1',
+  'ALTER TABLE company_voice_robotic_call_log_callphone ADD COLUMN `manual_answered` tinyint NULL DEFAULT NULL COMMENT \"人工接听:1:是,0或无:否\"'
+));
+PREPARE alterIfNotExists FROM @preparedStatement;
+EXECUTE alterIfNotExists;
+DEALLOCATE PREPARE alterIfNotExists;
+
+SET @columnname = 'handle_flag';
+SET @preparedStatement = (SELECT IF(
+  (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
+   WHERE TABLE_SCHEMA = @dbname
+     AND TABLE_NAME = @tablename
+     AND COLUMN_NAME = @columnname) > 0,
+  'SELECT 1',
+  'ALTER TABLE company_voice_robotic_call_log_callphone ADD COLUMN `handle_flag` tinyint NULL DEFAULT 0 COMMENT \"是否被处理:0:未被处理,1:已处理\"'
+));
+PREPARE alterIfNotExists FROM @preparedStatement;
+EXECUTE alterIfNotExists;
+DEALLOCATE PREPARE alterIfNotExists;
+
+SET @columnname = 'answered_ext_num';
+SET @preparedStatement = (SELECT IF(
+  (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
+   WHERE TABLE_SCHEMA = @dbname
+     AND TABLE_NAME = @tablename
+     AND COLUMN_NAME = @columnname) > 0,
+  'SELECT 1',
+  'ALTER TABLE company_voice_robotic_call_log_callphone ADD COLUMN `answered_ext_num` varchar(200) NULL DEFAULT NULL COMMENT \"接听分机号码\"'
+));
+PREPARE alterIfNotExists FROM @preparedStatement;
+EXECUTE alterIfNotExists;
+DEALLOCATE PREPARE alterIfNotExists;
+
+SET @columnname = 'manual_answered_time';
+SET @preparedStatement = (SELECT IF(
+  (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
+   WHERE TABLE_SCHEMA = @dbname
+     AND TABLE_NAME = @tablename
+     AND COLUMN_NAME = @columnname) > 0,
+  'SELECT 1',
+  'ALTER TABLE company_voice_robotic_call_log_callphone ADD COLUMN `manual_answered_time` bigint NULL DEFAULT NULL COMMENT \"人工接听时间\"'
+));
+PREPARE alterIfNotExists FROM @preparedStatement;
+EXECUTE alterIfNotExists;
+DEALLOCATE PREPARE alterIfNotExists;
+
+SET @columnname = 'manual_answered_time_len';
+SET @preparedStatement = (SELECT IF(
+  (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
+   WHERE TABLE_SCHEMA = @dbname
+     AND TABLE_NAME = @tablename
+     AND COLUMN_NAME = @columnname) > 0,
+  'SELECT 1',
+  'ALTER TABLE company_voice_robotic_call_log_callphone ADD COLUMN `manual_answered_time_len` bigint NULL DEFAULT NULL COMMENT \"人工接听时长\"'
+));
+PREPARE alterIfNotExists FROM @preparedStatement;
+EXECUTE alterIfNotExists;
+DEALLOCATE PREPARE alterIfNotExists;
+
 SET FOREIGN_KEY_CHECKS = 1;

+ 7 - 7
fs-service/src/main/resources/mapper/aicall/CcCallTaskMapper.xml

@@ -36,7 +36,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         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" resultMap="CcCallTaskResult">
+    <select id="selectCcCallTaskList" parameterType="CcCallTask" resultMap="CcCallTaskResult">
         <include refid="selectCcCallTaskVo"/>
         <where>
             <if test="batchId != null "> and batch_id = #{batchId}</if>
@@ -61,12 +61,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         order by batch_id desc
     </select>
     
-    <select id="selectCcCallTaskByBatchId" resultMap="CcCallTaskResult">
+    <select id="selectCcCallTaskByBatchId" parameterType="Long" resultMap="CcCallTaskResult">
         <include refid="selectCcCallTaskVo"/>
         where batch_id = #{batchId}
     </select>
 
-    <insert id="insertCcCallTask" useGeneratedKeys="true" keyProperty="batchId">
+    <insert id="insertCcCallTask" parameterType="CcCallTask" useGeneratedKeys="true" keyProperty="batchId">
         insert into cc_call_task
         <trim prefix="(" suffix=")" suffixOverrides=",">
             <if test="groupId != null and groupId != ''">group_id,</if>
@@ -122,7 +122,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         </trim>
     </insert>
 
-    <update id="updateCcCallTask">
+    <update id="updateCcCallTask" parameterType="CcCallTask">
         update cc_call_task
         <trim prefix="SET" suffixOverrides=",">
             <if test="groupId != null and groupId != ''">group_id = #{groupId},</if>
@@ -154,11 +154,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         where batch_id = #{batchId}
     </update>
 
-    <delete id="deleteCcCallTaskByBatchId">
+    <delete id="deleteCcCallTaskByBatchId" parameterType="Long">
         delete from cc_call_task where batch_id = #{batchId}
     </delete>
 
-    <delete id="deleteCcCallTaskByBatchIds">
+    <delete id="deleteCcCallTaskByBatchIds" parameterType="String">
         delete from cc_call_task where batch_id in 
         <foreach item="batchId" collection="array" open="(" separator="," close=")">
             #{batchId}
@@ -171,7 +171,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         where batch_name = #{batchName} and task_type = #{taskType}
     </select>
 
-    <insert id="bakCallTaskByBatchId">
+    <insert id="bakCallTaskByBatchId" parameterType="Long">
         insert into his_cc_call_task select * from cc_call_task where batch_id = #{batchId}
     </insert>
 

+ 6 - 6
fs-service/src/main/resources/mapper/aicall/CcLlmAgentAccountMapper.xml

@@ -23,7 +23,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         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" resultMap="CcLlmAgentAccountResult">
+    <select id="selectCcLlmAgentAccountList" parameterType="CcLlmAgentAccount" resultMap="CcLlmAgentAccountResult">
         <include refid="selectCcLlmAgentAccountVo"/>
         <where>  
             <if test="name != null  and name != ''"> and name like concat('%', #{name}, '%')</if>
@@ -37,12 +37,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         order by id desc
     </select>
     
-    <select id="selectCcLlmAgentAccountById" resultMap="CcLlmAgentAccountResult">
+    <select id="selectCcLlmAgentAccountById" parameterType="Integer" resultMap="CcLlmAgentAccountResult">
         <include refid="selectCcLlmAgentAccountVo"/>
         where id = #{id}
     </select>
 
-    <insert id="insertCcLlmAgentAccount" useGeneratedKeys="true"  keyProperty="id">
+    <insert id="insertCcLlmAgentAccount" parameterType="CcLlmAgentAccount" useGeneratedKeys="true"  keyProperty="id">
         insert into cc_llm_agent_account
         <trim prefix="(" suffix=")" suffixOverrides=",">
             id,
@@ -74,7 +74,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         </trim>
     </insert>
 
-    <update id="updateCcLlmAgentAccount">
+    <update id="updateCcLlmAgentAccount" parameterType="CcLlmAgentAccount">
         update cc_llm_agent_account
         <trim prefix="SET" suffixOverrides=",">
             name = #{name},
@@ -92,11 +92,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         where id = #{id}
     </update>
 
-    <delete id="deleteCcLlmAgentAccountById">
+    <delete id="deleteCcLlmAgentAccountById" parameterType="Integer">
         delete from cc_llm_agent_account where id = #{id}
     </delete>
 
-    <delete id="deleteCcLlmAgentAccountByIds">
+    <delete id="deleteCcLlmAgentAccountByIds" parameterType="String">
         delete from cc_llm_agent_account where id in 
         <foreach item="id" collection="array" open="(" separator="," close=")">
             #{id}

+ 17 - 27
fs-service/src/main/resources/mapper/aicall/CcLlmAgentProviderMapper.xml

@@ -6,63 +6,53 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
     
     <resultMap type="CcLlmAgentProvider" id="CcLlmAgentProviderResult">
         <result property="id"    column="id"    />
-        <result property="name"    column="name"    />
-        <result property="className"    column="class_name"    />
-        <result property="configJson"    column="config_json"    />
-        <result property="companyId"    column="company_id"    />
-        <result property="createTime"    column="create_time"    />
-        <result property="updateTime"    column="update_time"    />
+        <result property="providerClassName"    column="provider_class_name"    />
+        <result property="note"    column="note"    />
     </resultMap>
 
     <sql id="selectCcLlmAgentProviderVo">
-        select id, name, class_name, config_json, company_id, create_time, update_time from cc_llm_agent_provider
+        select id, provider_class_name, note from cc_llm_agent_provider
     </sql>
 
-    <select id="selectCcLlmAgentProviderList" resultMap="CcLlmAgentProviderResult">
+    <select id="selectCcLlmAgentProviderList" parameterType="CcLlmAgentProvider" resultMap="CcLlmAgentProviderResult">
         <include refid="selectCcLlmAgentProviderVo"/>
         <where>  
-            <if test="name != null  and name != ''"> and name like concat('%', #{name}, '%')</if>
-            <if test="className != null  and className != ''"> and class_name like concat('%', #{className}, '%')</if>
+            <if test="providerClassName != null  and providerClassName != ''"> and provider_class_name like concat('%', #{providerClassName}, '%')</if>
+            <if test="note != null  and note != ''"> and note = #{note}</if>
         </where>
     </select>
     
-    <select id="selectCcLlmAgentProviderById" resultMap="CcLlmAgentProviderResult">
+    <select id="selectCcLlmAgentProviderById" parameterType="Integer" resultMap="CcLlmAgentProviderResult">
         <include refid="selectCcLlmAgentProviderVo"/>
         where id = #{id}
     </select>
 
-    <insert id="insertCcLlmAgentProvider" useGeneratedKeys="true" keyProperty="id">
+    <insert id="insertCcLlmAgentProvider" parameterType="CcLlmAgentProvider" useGeneratedKeys="true" keyProperty="id">
         insert into cc_llm_agent_provider
         <trim prefix="(" suffix=")" suffixOverrides=",">
-            name,
-            class_name,
-            config_json,
-            company_id,
+            provider_class_name,
+            note,
         </trim>
         <trim prefix="values (" suffix=")" suffixOverrides=",">
-            #{name},
-            #{className},
-            #{configJson},
-            #{companyId},
+            #{providerClassName},
+            #{note},
         </trim>
     </insert>
 
-    <update id="updateCcLlmAgentProvider">
+    <update id="updateCcLlmAgentProvider" parameterType="CcLlmAgentProvider">
         update cc_llm_agent_provider
         <trim prefix="SET" suffixOverrides=",">
-            name = #{name},
-            class_name = #{className},
-            config_json = #{configJson},
-            company_id = #{companyId},
+            provider_class_name = #{providerClassName},
+            note = #{note},
         </trim>
         where id = #{id}
     </update>
 
-    <delete id="deleteCcLlmAgentProviderById">
+    <delete id="deleteCcLlmAgentProviderById" parameterType="Integer">
         delete from cc_llm_agent_provider where id = #{id}
     </delete>
 
-    <delete id="deleteCcLlmAgentProviderByIds">
+    <delete id="deleteCcLlmAgentProviderByIds" parameterType="String">
         delete from cc_llm_agent_provider where id in 
         <foreach item="id" collection="array" open="(" separator="," close=")">
             #{id}

+ 6 - 6
fs-service/src/main/resources/mapper/aicall/CcLlmKbCatMapper.xml

@@ -14,7 +14,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         select id, cat, description from cc_llm_kb_cat
     </sql>
 
-    <select id="selectCcLlmKbCatList" resultMap="CcLlmKbCatResult">
+    <select id="selectCcLlmKbCatList" parameterType="CcLlmKbCat" resultMap="CcLlmKbCatResult">
         <include refid="selectCcLlmKbCatVo"/>
         <where>  
             <if test="cat != null  and cat != ''"> and cat = #{cat}</if>
@@ -22,12 +22,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         </where>
     </select>
     
-    <select id="selectCcLlmKbCatById" resultMap="CcLlmKbCatResult">
+    <select id="selectCcLlmKbCatById" parameterType="Long" resultMap="CcLlmKbCatResult">
         <include refid="selectCcLlmKbCatVo"/>
         where id = #{id}
     </select>
 
-    <insert id="insertCcLlmKbCat" useGeneratedKeys="true" keyProperty="id">
+    <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>
@@ -39,7 +39,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
          </trim>
     </insert>
 
-    <update id="updateCcLlmKbCat">
+    <update id="updateCcLlmKbCat" parameterType="CcLlmKbCat">
         update cc_llm_kb_cat
         <trim prefix="SET" suffixOverrides=",">
             <if test="cat != null">cat = #{cat},</if>
@@ -48,11 +48,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         where id = #{id}
     </update>
 
-    <delete id="deleteCcLlmKbCatById">
+    <delete id="deleteCcLlmKbCatById" parameterType="Long">
         delete from cc_llm_kb_cat where id = #{id}
     </delete>
 
-    <delete id="deleteCcLlmKbCatByIds">
+    <delete id="deleteCcLlmKbCatByIds" parameterType="String">
         delete from cc_llm_kb_cat where id in 
         <foreach item="id" collection="array" open="(" separator="," close=")">
             #{id}

+ 9 - 9
fs-service/src/main/resources/mapper/aicall/CcLlmKbMapper.xml

@@ -15,7 +15,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         select id, title, content, cat_id from cc_llm_kb
     </sql>
 
-    <select id="selectCcLlmKbList" resultMap="CcLlmKbResult">
+    <select id="selectCcLlmKbList" parameterType="CcLlmKb" resultMap="CcLlmKbResult">
         <include refid="selectCcLlmKbVo"/>
         <where>  
             <if test="title != null  and title != ''"> and title = #{title}</if>
@@ -24,12 +24,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         </where>
     </select>
     
-    <select id="selectCcLlmKbById" resultMap="CcLlmKbResult">
+    <select id="selectCcLlmKbById" parameterType="Long" resultMap="CcLlmKbResult">
         <include refid="selectCcLlmKbVo"/>
         where id = #{id}
     </select>
 
-    <insert id="insertCcLlmKb">
+    <insert id="insertCcLlmKb" parameterType="CcLlmKb">
         insert into cc_llm_kb
         <trim prefix="(" suffix=")" suffixOverrides=",">
             <if test="id != null">id,</if>
@@ -45,7 +45,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
          </trim>
     </insert>
 
-    <update id="updateCcLlmKb">
+    <update id="updateCcLlmKb" parameterType="CcLlmKb">
         update cc_llm_kb
         <trim prefix="SET" suffixOverrides=",">
             <if test="title != null and title != ''">title = #{title},</if>
@@ -55,28 +55,28 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         where id = #{id}
     </update>
 
-    <delete id="deleteCcLlmKbById">
+    <delete id="deleteCcLlmKbById" parameterType="Long">
         delete from cc_llm_kb where id = #{id}
     </delete>
 
-    <delete id="deleteCcLlmKbByIds">
+    <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">
+    <select id="selectCountByCatId" parameterType="Long">
         select count(1) from cc_llm_kb where cat_id = #{catId}
     </select>
 
 
-    <delete id="deleteCcLlmKbByCatId">
+    <delete id="deleteCcLlmKbByCatId" parameterType="Long">
         delete from cc_llm_kb where cat_id = #{catId}
     </delete>
 
     <!-- 批量插入 -->
-    <insert id="insertBatch">
+    <insert id="insertBatch" parameterType="java.util.List">
         INSERT INTO cc_llm_kb
         (title, content, cat_id)
         VALUES

+ 7 - 7
fs-service/src/main/resources/mapper/aicall/CcParamsMapper.xml

@@ -18,7 +18,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         select id, param_name, param_code, param_value, param_type,hide_value, allow_edit from cc_params
     </sql>
 
-    <select id="selectCcParamsList" resultMap="CcParamsResult">
+    <select id="selectCcParamsList" parameterType="CcParams" resultMap="CcParamsResult">
         <include refid="selectCcParamsVo"/>
         <where>  
             <if test="paramName != null  and paramName != ''"> and param_name like concat('%', #{paramName}, '%')</if>
@@ -28,12 +28,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         </where>
     </select>
     
-    <select id="selectCcParamsById" resultMap="CcParamsResult">
+    <select id="selectCcParamsById" parameterType="Long" resultMap="CcParamsResult">
         <include refid="selectCcParamsVo"/>
         where id = #{id}
     </select>
 
-    <insert id="insertCcParams" useGeneratedKeys="true" keyProperty="id">
+    <insert id="insertCcParams" parameterType="CcParams" useGeneratedKeys="true" keyProperty="id">
         insert into cc_params
         <trim prefix="(" suffix=")" suffixOverrides=",">
             <if test="paramName != null and paramName != ''">param_name,</if>
@@ -51,7 +51,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
          </trim>
     </insert>
 
-    <update id="updateCcParams">
+    <update id="updateCcParams" parameterType="CcParams">
         update cc_params
         <trim prefix="SET" suffixOverrides=",">
             <if test="paramName != null and paramName != ''">param_name = #{paramName},</if>
@@ -64,18 +64,18 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         where id = #{id}
     </update>
 
-    <delete id="deleteCcParamsById">
+    <delete id="deleteCcParamsById" parameterType="Long">
         delete from cc_params where id = #{id}
     </delete>
 
-    <delete id="deleteCcParamsByIds">
+    <delete id="deleteCcParamsByIds" parameterType="String">
         delete from cc_params where id in 
         <foreach item="id" collection="array" open="(" separator="," close=")">
             #{id}
         </foreach>
     </delete>
 
-    <update id="updateParamsValue">
+    <update id="updateParamsValue" parameterType="String">
         update cc_params set param_value = #{paramValue} where param_code = #{paramCode}
     </update>
 

+ 8 - 8
fs-service/src/main/resources/mapper/aicall/CompanyBindAiModelMapper.xml

@@ -15,12 +15,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         select id, company_id, model_id, create_time, update_time from company_bind_ai_model
     </sql>
 
-    <select id="selectCompanyBindAiModelById" resultMap="CompanyBindAiModelResult">
+    <select id="selectCompanyBindAiModelById" parameterType="Long" resultMap="CompanyBindAiModelResult">
         <include refid="selectCompanyBindAiModelVo" />
         where id = #{id}
     </select>
 
-    <select id="selectCompanyBindAiModelList" resultMap="CompanyBindAiModelResult">
+    <select id="selectCompanyBindAiModelList" parameterType="com.fs.aicall.domain.CompanyBindAiModel" resultMap="CompanyBindAiModelResult">
         <include refid="selectCompanyBindAiModelVo" />
         <where>
             <if test="companyId != null">
@@ -32,12 +32,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         </where>
     </select>
 
-    <insert id="insertCompanyBindAiModel" useGeneratedKeys="true" keyProperty="id">
+    <insert id="insertCompanyBindAiModel" parameterType="com.fs.aicall.domain.CompanyBindAiModel" useGeneratedKeys="true" keyProperty="id">
         insert into company_bind_ai_model(company_id, model_id, create_time, update_time)
         values (#{companyId}, #{modelId}, #{createTime}, #{updateTime})
     </insert>
 
-    <update id="updateCompanyBindAiModel">
+    <update id="updateCompanyBindAiModel" parameterType="com.fs.aicall.domain.CompanyBindAiModel">
         update company_bind_ai_model
         <set>
             <if test="companyId != null">
@@ -51,22 +51,22 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         where id = #{id}
     </update>
 
-    <delete id="deleteCompanyBindAiModelById">
+    <delete id="deleteCompanyBindAiModelById" parameterType="Long">
         delete from company_bind_ai_model where id = #{id}
     </delete>
 
-    <delete id="deleteCompanyBindAiModelByIds">
+    <delete id="deleteCompanyBindAiModelByIds" parameterType="Long[]">
         delete from company_bind_ai_model where id in
         <foreach item="id" collection="array" open="(" separator="," close=")">
             #{id}
         </foreach>
     </delete>
 
-    <select id="selectModelIdsByCompanyId" resultType="Long">
+    <select id="selectModelIdsByCompanyId" parameterType="Long" resultType="Long">
         select model_id from company_bind_ai_model where company_id = #{companyId}
     </select>
 
-    <delete id="deleteCompanyBindAiModelByModelId">
+    <delete id="deleteCompanyBindAiModelByModelId" parameterType="Long">
         delete from company_bind_ai_model where model_id = #{modelId}
     </delete>
 

+ 7 - 0
fs-service/src/main/resources/mapper/company/CompanyUserMapper.xml

@@ -326,6 +326,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="doctorId != null">`doctor_id` = #{doctorId},</if>
             <if test="unionId != null">`union_id` = #{unionId},</if>
             <if test="analyseData != null">`analyse_data` = #{analyseData},</if>
+            <if test="cidServerId != null">`cid_server_id` = #{cidServerId},</if>
         </trim>
         where user_id = #{userId}
     </update>
@@ -827,4 +828,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         ]]>
     </select>
 
+    <update id="unbindCidServer">
+        update company_user
+        set cid_server_id = null
+        where user_id = #{companyUserId}
+    </update>
+
 </mapper>

+ 6 - 3
fs-wx-api/src/main/java/com/fs/app/utils/JwtUtils.java

@@ -12,12 +12,14 @@ import java.util.Date;
 
 /**
  * jwt工具类
+
  */
 @ConfigurationProperties(prefix = "fs.jwt")
 @Component
 public class JwtUtils {
     private Logger logger = LoggerFactory.getLogger(getClass());
 
+
     private String secret;
     private long expire;
     private String header;
@@ -27,11 +29,12 @@ public class JwtUtils {
      */
     public String generateToken(long userId) {
         Date nowDate = new Date();
+        //过期时间
         Date expireDate = new Date(nowDate.getTime() + expire * 1000);
 
         return Jwts.builder()
                 .setHeaderParam("typ", "JWT")
-                .setSubject(userId + "")
+                .setSubject(userId+"")
                 .setIssuedAt(nowDate)
                 .setExpiration(expireDate)
                 .signWith(SignatureAlgorithm.HS512, secret)
@@ -44,7 +47,7 @@ public class JwtUtils {
                     .setSigningKey(secret)
                     .parseClaimsJws(token)
                     .getBody();
-        } catch (Exception e) {
+        }catch (Exception e){
             logger.debug("validate is token error ", e);
             return null;
         }
@@ -52,7 +55,7 @@ public class JwtUtils {
 
     /**
      * token是否过期
-     * @return true:过期
+     * @return  true:过期
      */
     public boolean isTokenExpired(Date expiration) {
         return expiration.before(new Date());

+ 1 - 0
pom.xml

@@ -316,6 +316,7 @@
         <module>fs-wx-task</module>
         <module>fs-cid-workflow</module>
         <module>fs-ai-call-task</module>
+        <module>fs-comm-gateway</module>
     </modules>
     <packaging>pom</packaging>
     <build>