Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc8c70829d | ||
|
|
86b488d9bd | ||
|
|
a765526150 | ||
|
|
7d8ad88fac | ||
|
|
57316d927e | ||
|
|
3f81d5d91b | ||
|
|
fa297939e0 | ||
|
|
9eaa5d1f3d | ||
|
|
9f54f95264 | ||
|
|
d99481cdb0 | ||
|
|
0917d40d42 | ||
|
|
f8927afa9c | ||
|
|
2f5c4f7e54 | ||
|
|
94a7480118 | ||
|
|
58477cf7fb | ||
|
|
fe812097dd | ||
|
|
3180fd065e | ||
|
|
9598aec184 | ||
|
|
7416bf8d96 | ||
|
|
f389065798 | ||
|
|
c87cde60d2 | ||
|
|
425728b22f | ||
|
|
65dc04322c | ||
|
|
6008ddf643 | ||
|
|
eae825726e | ||
|
|
98c1c6d31f | ||
|
|
b7cce0befa |
@@ -0,0 +1 @@
|
||||
.git
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
# 阶段1: 构建
|
||||
FROM golang:alpine AS builder
|
||||
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && \
|
||||
apk add --no-cache git ca-certificates tzdata
|
||||
|
||||
ENV TZ=Asia/Shanghai
|
||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||
|
||||
ENV GO111MODULE=on
|
||||
ENV GOPROXY=https://goproxy.cn,direct
|
||||
ENV CGO_ENABLED=0
|
||||
ENV GOTOOLCHAIN=auto
|
||||
WORKDIR /build
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN go mod download && go mod tidy
|
||||
|
||||
RUN go build -ldflags="-s -w" -o main ./main.go
|
||||
|
||||
|
||||
EXPOSE 3012
|
||||
|
||||
CMD ["./main"]
|
||||
+82
-60
@@ -1,28 +1,82 @@
|
||||
server:
|
||||
address: ":3000"
|
||||
address: ":3012"
|
||||
name: "customer-server"
|
||||
|
||||
|
||||
jwt:
|
||||
secret: "abcdefghijklmnopqrstuvwxyz"
|
||||
cache:
|
||||
localTTL: 60
|
||||
redisTTL: 300
|
||||
# Database.
|
||||
database:
|
||||
default:
|
||||
- type: "pgsql"
|
||||
host: "192.168.0.83"
|
||||
port: "15432"
|
||||
user: "postgres"
|
||||
pass: "Bjang09@686^*^"
|
||||
name: "tenant-1"
|
||||
prefix: "customer_server_" # (可选)表名前缀
|
||||
role: "master" # (可选)数据库主从角色(master/slave),默认为master。如果不使用应用主从机制请不配置或留空即可。
|
||||
debug: true # (可选)开启调试模式
|
||||
dryRun: false # (可选)ORM空跑(只读不写)
|
||||
charset: "utf8" # (可选)数据库编码(如: utf8mb4/utf8/gbk/gb2312),一般设置为utf8mb4。默认为utf8。
|
||||
timezone: "Asia/Shanghai" # (可选)时区配置,例如:Local
|
||||
maxIdle: 5 # (可选)连接池最大闲置的连接数(默认10)
|
||||
maxOpen: 20 # (可选)连接池最大打开的连接数(默认无限制)
|
||||
maxLifetime: "30s" # (可选)连接对象可重复使用的时间长度(默认30秒)
|
||||
maxIdleConnTime: "30s" # (可选,v2.10新增)连接池中空闲连接的最大生存时间(默认30秒)。可以通过配置文件或SetConnMaxIdleTime方法设置,避免长时间空闲连接占用资源。
|
||||
createdAt: "created_at" # (可选)自动创建时间字段名称
|
||||
updatedAt: "updated_at" # (可选)自动更新时间字段名称
|
||||
deletedAt: "deleted_at" # (可选)软删除时间字段名称
|
||||
timeMaintainDisabled: false # (可选)是否完全关闭时间更新特性,为true时CreatedAt/UpdatedAt/DeletedAt都将失效
|
||||
- type: "pgsql"
|
||||
host: "192.168.0.83"
|
||||
port: "15432"
|
||||
user: "postgres"
|
||||
pass: "Bjang09@686^*^"
|
||||
name: "tenant-1"
|
||||
prefix: "customer_server_" # (可选)表名前缀
|
||||
role: "slave" # (可选)数据库主从角色(master/slave),默认为master。如果不使用应用主从机制请不配置或留空即可。
|
||||
debug: false # (可选)开启调试模式
|
||||
dryRun: false # (可选)ORM空跑(只读不写)
|
||||
charset: "utf8" # (可选)数据库编码(如: utf8mb4/utf8/gbk/gb2312),一般设置为utf8mb4。默认为utf8。
|
||||
timezone: "Asia/Shanghai" # (可选)时区配置,例如:Local
|
||||
maxIdle: 5 # (可选)连接池最大闲置的连接数(默认10)
|
||||
maxOpen: 20 # (可选)连接池最大打开的连接数(默认无限制)
|
||||
maxLifetime: "30s" # (可选)连接对象可重复使用的时间长度(默认30秒)
|
||||
maxIdleConnTime: "30s" # (可选,v2.10新增)连接池中空闲连接的最大生存时间(默认30秒)。可以通过配置文件或SetConnMaxIdleTime方法设置,避免长时间空闲连接占用资源。
|
||||
createdAt: "created_at" # (可选)自动创建时间字段名称
|
||||
updatedAt: "updated_at" # (可选)自动更新时间字段名称
|
||||
deletedAt: "deleted_at" # (可选)软删除时间字段名称
|
||||
timeMaintainDisabled: false # (可选)是否完全关闭时间更新特性,为true时CreatedAt/UpdatedAt/DeletedAt都将失效
|
||||
tenant-1:
|
||||
- type: "pgsql"
|
||||
host: "192.168.0.83"
|
||||
port: "15432"
|
||||
user: "postgres"
|
||||
pass: "Bjang09@686^*^"
|
||||
name: "tenant-1"
|
||||
prefix: "customer_server_" # (可选)表名前缀
|
||||
role: "master" # (可选)数据库主从角色(master/slave),默认为master。如果不使用应用主从机制请不配置或留空即可。
|
||||
debug: true # (可选)开启调试模式
|
||||
dryRun: false # (可选)ORM空跑(只读不写)
|
||||
charset: "utf8" # (可选)数据库编码(如: utf8mb4/utf8/gbk/gb2312),一般设置为utf8mb4。默认为utf8。
|
||||
timezone: "Asia/Shanghai" # (可选)时区配置,例如:Local
|
||||
maxIdle: 5 # (可选)连接池最大闲置的连接数(默认10)
|
||||
maxOpen: 20 # (可选)连接池最大打开的连接数(默认无限制)
|
||||
maxLifetime: "30s" # (可选)连接对象可重复使用的时间长度(默认30秒)
|
||||
maxIdleConnTime: "30s" # (可选,v2.10新增)连接池中空闲连接的最大生存时间(默认30秒)。可以通过配置文件或SetConnMaxIdleTime方法设置,避免长时间空闲连接占用资源。
|
||||
createdAt: "created_at" # (可选)自动创建时间字段名称
|
||||
updatedAt: "updated_at" # (可选)自动更新时间字段名称
|
||||
deletedAt: "deleted_at" # (可选)软删除时间字段名称
|
||||
timeMaintainDisabled: false # (可选)是否完全关闭时间更新特性,为true时CreatedAt/UpdatedAt/DeletedAt都将失效
|
||||
|
||||
rate:
|
||||
limit: 200
|
||||
burst: 300
|
||||
|
||||
# --- 华为云服务器(116.204.74.41:17027,带认证) ---
|
||||
#mongo:
|
||||
# default:
|
||||
# address: "mongodb://root:M0ng0DB%40Pass2026%21@116.204.74.41:17027?authSource=admin"
|
||||
# database: "customer_service"
|
||||
# logger:
|
||||
# level: "all"
|
||||
# stdout: true
|
||||
|
||||
# --- 内网服务器(192.168.3.200:27017,无认证) ---
|
||||
mongo:
|
||||
default:
|
||||
address: "mongodb://192.168.3.200:27017/?directConnection=true"
|
||||
address: "localhost:27017/?directConnection=true"
|
||||
database: "customer_service"
|
||||
logger:
|
||||
level: "all"
|
||||
@@ -30,7 +84,7 @@ mongo:
|
||||
|
||||
redis:
|
||||
default:
|
||||
address: 116.204.74.41:6379
|
||||
address: 192.168.0.83:6379
|
||||
db: 0
|
||||
idleTimeout: "60s"
|
||||
maxConnLifetime: "90s"
|
||||
@@ -41,28 +95,21 @@ redis:
|
||||
maxActive: 100
|
||||
|
||||
consul:
|
||||
address: 116.204.74.41:8500
|
||||
|
||||
rabbitmq:
|
||||
host: 116.204.74.41
|
||||
port: 5672
|
||||
username: root
|
||||
password: root
|
||||
# 响应队列配置(从message迁移)
|
||||
responseExchange: "ragflow.response"
|
||||
responseQueue: "ragflow.response.queue"
|
||||
responseRoutingKey: "#" # 匹配所有routing key
|
||||
# 不配置instanceName,直接使用os.Hostname()获取容器名/主机名作为实例ID
|
||||
address: 192.168.0.83:8500
|
||||
|
||||
jaeger:
|
||||
addr: 116.204.74.41:4318
|
||||
addr: 192.168.0.83:4318
|
||||
|
||||
# RAGFlow配置(customerservice只需要base_url和api_key用于更新prompt接口)
|
||||
ragflow:
|
||||
base_url: "http://116.204.74.41"
|
||||
api_key: "ragflow-tg4BrmCIDXbZDB80JGyq1cDNFyr2_kfAEd053YuDV58"
|
||||
default_model: "qwen3-235b-a22b-instruct-2507" # 默认LLM模型,可通过Consul动态覆盖
|
||||
embedding_model: "text-embedding-v4@Tongyi-Qianwen" # embedding模型,创建知识库时使用(格式:model@factory)
|
||||
rabbitmq:
|
||||
host: 192.168.0.83
|
||||
port: 5672
|
||||
username: admin
|
||||
password: 123456
|
||||
|
||||
nats:
|
||||
default:
|
||||
addr: 192.168.0.83
|
||||
port: 4222
|
||||
|
||||
followUp:
|
||||
enable: true
|
||||
@@ -75,19 +122,6 @@ followUp:
|
||||
- "如果需要帮助,随时告诉我~"
|
||||
- "我一直在线,有问题随时找我~"
|
||||
|
||||
archive:
|
||||
enable: true
|
||||
queue: "archive.queue"
|
||||
exchange: "archive.delayed"
|
||||
routingKey: "archive"
|
||||
delay: 3600 # 归档延时(秒),默认1小时
|
||||
# 月度归档配置
|
||||
enabled: true # 是否启用月度归档
|
||||
cron: "0 0 0 1 * *" # 每月1号0点执行(归档上个月整月数据)
|
||||
mongoBatchSize: 1000 # 从原表复制到临时表的批量大小
|
||||
esBatchSize: 500 # 从临时表写入 ES 的批量大小
|
||||
testMode: true # 测试模式:true=归档最近7天,false=归档上个月(生产环境设为false)
|
||||
|
||||
# 卡片触发配置
|
||||
card:
|
||||
triggerCount: 5 # 触发发送卡片的对话轮数(对话>=此值时触发)
|
||||
@@ -95,15 +129,3 @@ card:
|
||||
# 历史上下文配置
|
||||
history:
|
||||
contextLimit: 5 # 读取历史对话轮数(用于新Session上下文注入)
|
||||
|
||||
# Elasticsearch 配置
|
||||
elasticsearch:
|
||||
addresses:
|
||||
- "http://116.204.74.41:9200"
|
||||
username: ""
|
||||
password: ""
|
||||
|
||||
database:
|
||||
logger:
|
||||
level: "none" # 关闭数据库日志
|
||||
stdout: false
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
# 各咨询方向的欢迎话术配置
|
||||
# 当用户在状态5选择方向后,会自动发送对应的欢迎语
|
||||
|
||||
welcomes:
|
||||
乳腺贴: |
|
||||
🙋♀️ 欢迎姐妹们来到药济堂!我们是一个拥有10年经验的大健康专业团队🏅,专注于保守调理乳腺问题,已经成功帮助超过1万位姐妹轻松调理乳腺健康❤️。
|
||||
如果您有结节或增生的困扰,欢迎随时咨询!
|
||||
请回复下面的数字,让我帮助您分析结节情况:
|
||||
|
||||
结节
|
||||
增生
|
||||
点击👇获取更快速的服务!
|
||||
|
||||
肝病: |
|
||||
你好,我是黄医生,有什么肝脏方面的问题我可以帮助您吗?无论是肝病、乙肝、丙肝,还是肝硬化腹水、脂肪肝、酒精肝,我都会为您提供专业的建议。😷
|
||||
肝病的类型很多,常见症状有乏力、食欲减退和肝区不适等。为了更好地帮您,我需要了解一些详细的信息,比如:
|
||||
|
||||
您现在有腹胀或腹水的情况吗?
|
||||
是第一次出现还是反复出现呢?
|
||||
目前是早期还是中晚期呢?
|
||||
有没有病毒性肝炎的病史呢?
|
||||
请您留一下联系方式,我可以发送您的报告,并给您详细解读肝病治疗方案和成功案例。📋
|
||||
|
||||
车膜: |
|
||||
🎉亲爱的车主,欢迎来到6膜王!
|
||||
🚗 我们在车膜行业深耕十年,拥有2家千平米的门店和30+专业团队,致力于为您提供最优质的服务!
|
||||
✨ 无论是隐形车衣还是改色膜,我们都能精准适配您的需求。留下您的【车型➕VX】,我们的资深顾问会立即为您匹配专属方案、膜材讲解以及报价!❤️
|
||||
|
||||
毛孔: |
|
||||
啊啊~亲爱的姐妹们,最近有很多小伙伴在问我关于毛孔的问题!我之前也是毛孔大到妆容卡粉,真的是烦恼不断。😩 但在经过一段时间的摸索后,我终于找到了合适的方法,效果真的很好,差不多一个月就改善了许多!(亲测有效!)💖
|
||||
如果需要帮助,可以随时告诉我,我非常乐意分享我的经验给你们哦!
|
||||
|
||||
免税店: |
|
||||
Hi,长春的宝子们~💕欢迎锁定小红提免税集合店!
|
||||
这里有你想要的一切:美妆、香水、包包、首饰和大牌护肤品,正品保真不踩雷,价格特别美丽哦✨
|
||||
全城顺丰包邮,购物更方便!期待你们来逛快来发现更多惊喜吧!💖
|
||||
|
||||
门店地址:长春市绿园区皓月大路吾悦广场1楼 1036号,等你来哦!🌟
|
||||
|
||||
减肥: |
|
||||
你好呀,姐妹~你也有肉肉的困扰吗?我现在已经掉了二十多斤了,至今都没有反弹!
|
||||
想要方法的话可以直接回复"1",我分享给你哦~
|
||||
(💗未成年发育期、哺乳期的姐妹我就不推荐啦)
|
||||
|
||||
气血: |
|
||||
亲爱的,欢迎光临!🌸
|
||||
如果你有月经不调或气血不足的问题,随时可以问我哦!
|
||||
|
||||
停经闭经
|
||||
痛经难忍
|
||||
量少
|
||||
经期不准
|
||||
💗只需回复数字,我们会为你提供专业建议! 🌟如需更多帮助,点击下方咨询专业老师,我们一起寻求解决方案~
|
||||
@@ -0,0 +1,39 @@
|
||||
package account
|
||||
|
||||
import "github.com/gogf/gf/v2/util/gconv"
|
||||
|
||||
var (
|
||||
PlatformXHS = newPlatform(gconv.PtrString("xiaohongshu"), "小红书")
|
||||
PlatformDY = newPlatform(gconv.PtrString("douyin"), "抖音")
|
||||
PlatformKS = newPlatform(gconv.PtrString("kuaishou"), "快手")
|
||||
)
|
||||
|
||||
type Platform *string
|
||||
|
||||
type platform struct {
|
||||
code Platform
|
||||
desc string
|
||||
}
|
||||
|
||||
func (s platform) Code() Platform {
|
||||
return s.code
|
||||
}
|
||||
func (s platform) Desc() string {
|
||||
return s.desc
|
||||
}
|
||||
|
||||
func newPlatform(code Platform, desc string) platform {
|
||||
return platform{code: code, desc: desc}
|
||||
}
|
||||
|
||||
func GetDescByCode(code Platform) string {
|
||||
switch *code {
|
||||
case *PlatformXHS.Code():
|
||||
return PlatformXHS.Desc()
|
||||
case *PlatformDY.Code():
|
||||
return PlatformDY.Desc()
|
||||
case *PlatformKS.Code():
|
||||
return PlatformKS.Desc()
|
||||
}
|
||||
return "未知平台"
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package account
|
||||
|
||||
import "github.com/gogf/gf/v2/util/gconv"
|
||||
|
||||
var (
|
||||
StatusDisable = newStatus(gconv.PtrInt8(0), "disable")
|
||||
StatusEnable = newStatus(gconv.PtrInt8(1), "enable")
|
||||
)
|
||||
|
||||
type Status *int8
|
||||
|
||||
type status struct {
|
||||
code Status
|
||||
desc string
|
||||
}
|
||||
|
||||
func (s status) Code() Status {
|
||||
return s.code
|
||||
}
|
||||
func (s status) Desc() string {
|
||||
return s.desc
|
||||
}
|
||||
|
||||
func newStatus(code Status, desc string) status {
|
||||
return status{code: code, desc: desc}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package public
|
||||
|
||||
const GmqMsgPluginsName = "gmq_msg"
|
||||
|
||||
const (
|
||||
AccountMsgKey = "account:%s:%s:%s"
|
||||
AccountDialogHistoryKey = "account:dialog:history:%s"
|
||||
AccountGreetingOptionsKey = "account:greeting:options:%s"
|
||||
)
|
||||
|
||||
const (
|
||||
AccountFollowupTopic = "account:followup:stream" // 请求 Stream 键名(与发消息的key一致)
|
||||
AccountFollowupConsumer = "account-followup-consumer" // 消费者名称(唯一标识)
|
||||
AccountFollowupCount = 1 // 批处理大小(每次读取1条)
|
||||
AccountFollowupAck = false // ACK是否自动确认(true自动确认,false不确认)
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
package public
|
||||
|
||||
// 欢迎语
|
||||
const (
|
||||
GreetingBegin = "您好,很高兴为您服务!请问有什么可以帮您?"
|
||||
GreetingBetween = "💗回复数字就好~"
|
||||
GreetingEnd = "🌟也可直接点击下方咨询专业老师~"
|
||||
)
|
||||
|
||||
// 追问
|
||||
const (
|
||||
SceneOpeningRemark = "宝子,刚才给您发的信息您有看到吗?有任何问题都能直接问我,加微信也能更方便沟通~"
|
||||
SceneDialog = "看您暂时没回复,是不是还有什么疑问?加微信我详细给您说明~"
|
||||
SceneCardSend = "宝子,加上没~要及时加哦,不然卡片容易失效哒✨"
|
||||
)
|
||||
|
||||
// 对话超时时间
|
||||
const DialogTimeout = 10
|
||||
@@ -0,0 +1,8 @@
|
||||
package public
|
||||
|
||||
// sql 数据库表名
|
||||
const (
|
||||
TableNameAccount = "account"
|
||||
TableNameAccountUserDialog = "account_user_dialog"
|
||||
TableNameScriptedSpeech = "scripted_speech"
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
package scriptedSpeech
|
||||
|
||||
import "github.com/gogf/gf/v2/util/gconv"
|
||||
|
||||
var (
|
||||
SceneTypeOpeningRemark = newSceneType(gconv.PtrInt8(1), "开场白无回应")
|
||||
SceneTypeDialog = newSceneType(gconv.PtrInt8(2), "对话中途无回应")
|
||||
SceneTypeCardSend = newSceneType(gconv.PtrInt8(3), "卡片发送后无回应")
|
||||
)
|
||||
|
||||
type SceneType *int8
|
||||
|
||||
type sceneType struct {
|
||||
code SceneType
|
||||
desc string
|
||||
}
|
||||
|
||||
func (s sceneType) Code() SceneType {
|
||||
return s.code
|
||||
}
|
||||
func (s sceneType) Desc() string {
|
||||
return s.desc
|
||||
}
|
||||
|
||||
func newSceneType(code SceneType, desc string) sceneType {
|
||||
return sceneType{code: code, desc: desc}
|
||||
}
|
||||
|
||||
func GetDescByCode(code SceneType) string {
|
||||
switch *code {
|
||||
case *SceneTypeOpeningRemark.Code():
|
||||
return SceneTypeOpeningRemark.Desc()
|
||||
case *SceneTypeDialog.Code():
|
||||
return SceneTypeDialog.Desc()
|
||||
case *SceneTypeCardSend.Code():
|
||||
return SceneTypeCardSend.Desc()
|
||||
}
|
||||
return "未知场景类型"
|
||||
}
|
||||
@@ -1,591 +0,0 @@
|
||||
package consumer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"gitea.com/red-future/common/db/mongo"
|
||||
"gitea.com/red-future/common/rabbitmq"
|
||||
"gitea.com/red-future/common/ragflow"
|
||||
"gitea.com/red-future/common/redis"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gfile"
|
||||
"github.com/gogf/gf/v2/os/glog"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
// processorConfig 处理器配置(避免重复读取配置)
|
||||
type processorConfig struct {
|
||||
chatId string // RAGFlow Chat ID(默认)
|
||||
responseExchange string // 响应队列 Exchange
|
||||
responseRoutingKey string // 响应队列 RoutingKey
|
||||
followUpExchange string // 追问队列 Exchange
|
||||
followUpRoutingKey string // 追问队列 RoutingKey
|
||||
archiveExchange string // 归档队列 Exchange
|
||||
archiveRoutingKey string // 归档队列 RoutingKey
|
||||
// Mock模式已删除,不再用于压测
|
||||
}
|
||||
|
||||
var cfg *processorConfig
|
||||
|
||||
// responsePublisher 响应消息发布器(单例)
|
||||
var responsePublisher *rabbitmq.Publisher
|
||||
|
||||
// followUpPublisher 追问消息发布器(单例)
|
||||
var followUpPublisher *rabbitmq.Publisher
|
||||
|
||||
// archivePublisher 归档消息发布器(单例)
|
||||
var archivePublisher *rabbitmq.Publisher
|
||||
|
||||
// Processor 消息处理器
|
||||
type Processor struct {
|
||||
processor *ragflow.QueueProcessor
|
||||
}
|
||||
|
||||
// NewProcessor 创建消息处理器
|
||||
func NewProcessor(ctx context.Context) (*Processor, error) {
|
||||
// 从配置读取参数
|
||||
streamKey := g.Cfg().MustGet(ctx, "stream.streamKey").String()
|
||||
groupName := g.Cfg().MustGet(ctx, "stream.groupName").String()
|
||||
consumerName := g.Cfg().MustGet(ctx, "stream.consumerName").String()
|
||||
batchSize := g.Cfg().MustGet(ctx, "stream.batchSize", 200).Int64()
|
||||
blockTimeout := g.Cfg().MustGet(ctx, "stream.blockTimeout", 2000).Int64()
|
||||
|
||||
// 初始化处理器配置(单例)
|
||||
cfg = &processorConfig{
|
||||
chatId: g.Cfg().MustGet(ctx, "ragflow.chat_id").String(),
|
||||
responseExchange: g.Cfg().MustGet(ctx, "rabbitmq.responseExchange", "ragflow.response").String(),
|
||||
responseRoutingKey: g.Cfg().MustGet(ctx, "rabbitmq.responseRoutingKey", "response").String(),
|
||||
followUpExchange: g.Cfg().MustGet(ctx, "followUp.exchange", "followup.delayed").String(),
|
||||
followUpRoutingKey: g.Cfg().MustGet(ctx, "followUp.routingKey", "followup").String(),
|
||||
archiveExchange: g.Cfg().MustGet(ctx, "archive.exchange", "archive.delayed").String(),
|
||||
archiveRoutingKey: g.Cfg().MustGet(ctx, "archive.routingKey", "archive").String(),
|
||||
}
|
||||
|
||||
// 咨询方向配置已从Consul自动加载(common/config包init时自动执行)
|
||||
|
||||
// 初始化响应发布器
|
||||
responsePublisher = rabbitmq.NewPublisher(cfg.responseExchange, cfg.responseRoutingKey)
|
||||
glog.Infof(ctx, "响应发布器已初始化 - Exchange: %s, RoutingKey: %s", cfg.responseExchange, cfg.responseRoutingKey)
|
||||
|
||||
// 初始化追问发布器
|
||||
followUpPublisher = rabbitmq.NewPublisher(cfg.followUpExchange, cfg.followUpRoutingKey)
|
||||
glog.Info(ctx, "追问发布器已初始化")
|
||||
|
||||
// 初始化归档发布器
|
||||
archivePublisher = rabbitmq.NewPublisher(cfg.archiveExchange, cfg.archiveRoutingKey)
|
||||
glog.Info(ctx, "归档发布器已初始化")
|
||||
|
||||
// 创建消息处理器(批量读取 + 并发发送,削峰填谷)
|
||||
return &Processor{
|
||||
processor: ragflow.NewQueueProcessor(
|
||||
streamKey,
|
||||
groupName,
|
||||
consumerName,
|
||||
blockTimeout,
|
||||
batchSize,
|
||||
handleMessage,
|
||||
),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Start 启动消息处理
|
||||
func (p *Processor) Start(ctx context.Context) error {
|
||||
glog.Info(ctx, "开始消费消息...")
|
||||
return p.processor.Start(ctx)
|
||||
}
|
||||
|
||||
// Stop 停止消息处理
|
||||
func (p *Processor) Stop() {
|
||||
p.processor.Stop()
|
||||
}
|
||||
|
||||
// getChatIdByAccountName 根据客服账号名称从ragflow_config表查询chat_id
|
||||
func getChatIdByAccountName(ctx context.Context, tenantId, accountName string) string {
|
||||
if accountName == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
db := mongo.GetDB()
|
||||
if db == nil {
|
||||
glog.Error(ctx, "MongoDB未初始化")
|
||||
return ""
|
||||
}
|
||||
|
||||
collection := db.Collection("ragflow_config")
|
||||
|
||||
// 从MongoDB查询ragflow_config(先尝试字符串tenantId)
|
||||
filter := map[string]interface{}{
|
||||
"accountName": accountName,
|
||||
"isDeleted": false,
|
||||
}
|
||||
|
||||
if tenantId != "" {
|
||||
filter["tenantId"] = tenantId
|
||||
}
|
||||
|
||||
var config struct {
|
||||
ChatId string `json:"chatId" bson:"chatId"`
|
||||
}
|
||||
|
||||
err := collection.FindOne(ctx, filter).Decode(&config)
|
||||
|
||||
// 如果未找到且tenantId可以转为数字,尝试用数字查询(兼容MongoDB中存储为int的情况)
|
||||
if err != nil && tenantId != "" {
|
||||
tenantIdInt := gconv.Int(tenantId)
|
||||
if tenantIdInt > 0 {
|
||||
filter["tenantId"] = tenantIdInt
|
||||
err = collection.FindOne(ctx, filter).Decode(&config)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
glog.Warningf(ctx, "未找到客服账号对应的RAGFlow配置 - 账号: %s, tenantId: %s, err: %v", accountName, tenantId, err)
|
||||
return ""
|
||||
}
|
||||
|
||||
glog.Infof(ctx, "使用客服账号对应的chat_id - 账号: %s, chat_id: %s", accountName, config.ChatId)
|
||||
return config.ChatId
|
||||
}
|
||||
|
||||
// getChatIdByDirection 根据用户选择的咨询方向获取对应的chat_id(从Consul读取)
|
||||
func getChatIdByDirection(ctx context.Context, userId, platform string) string {
|
||||
// 从Redis获取用户状态
|
||||
userState, err := redis.GetUserState(ctx, userId, platform)
|
||||
if err != nil || userState.Direction == "" {
|
||||
// 无方向或获取失败,返回默认chat_id
|
||||
if cfg != nil {
|
||||
return cfg.chatId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// 直接使用accountName查询
|
||||
chatId := ""
|
||||
if chatId != "" {
|
||||
glog.Infof(ctx, "使用咨询方向对应的chat_id - 用户: %s, 方向: %s, chat_id: %s", userId, userState.Direction, chatId)
|
||||
return chatId
|
||||
}
|
||||
|
||||
// 未找到匹配方向,返回默认chat_id
|
||||
glog.Warningf(ctx, "未找到方向对应的chat_id,使用默认 - 用户: %s, 方向: %s", userId, userState.Direction)
|
||||
if cfg != nil {
|
||||
return cfg.chatId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// HandleMessageHTTP 处理HTTP请求的消息(导出供controller调用)
|
||||
func HandleMessageHTTP(ctx context.Context, message map[string]interface{}) error {
|
||||
return handleMessage(ctx, message)
|
||||
}
|
||||
|
||||
// handleMessage 处理单条消息
|
||||
func handleMessage(ctx context.Context, message map[string]interface{}) (err error) {
|
||||
// gconv.Map转换结构体时使用驼峰字段名,而非json标签
|
||||
userId := gconv.String(message["UserId"])
|
||||
content := gconv.String(message["Content"])
|
||||
messageId := gconv.String(message["MessageId"])
|
||||
platform := gconv.String(message["Platform"])
|
||||
tenantId := gconv.String(message["TenantId"])
|
||||
accountId := gconv.String(message["AccountId"]) // 客服账号ID
|
||||
accountName := gconv.String(message["AccountName"]) // 客服账号名称(如cs_xhs_qixue)
|
||||
messageChatId := gconv.String(message["ChatId"]) // 消息中携带的chat_id(从ragflow_config查询)
|
||||
if platform == "" {
|
||||
platform = "xiaohongshu" // 默认平台
|
||||
}
|
||||
|
||||
// 解析历史对话(由 customerservice 从 MongoDB 读取后携带)
|
||||
var history []redis.HistoryMessage
|
||||
if historyData := message["History"]; historyData != nil {
|
||||
_ = gjson.New(historyData).Scan(&history)
|
||||
}
|
||||
|
||||
glog.Infof(ctx, "处理消息 - 用户: %s, 客服账号: %s, ChatId: %s, 内容: %s, 历史轮数: %d",
|
||||
userId, accountName, messageChatId, content, len(history))
|
||||
|
||||
var answer string
|
||||
var sessionId string
|
||||
startTime := gtime.Now()
|
||||
|
||||
// 调用RAGFlow处理消息
|
||||
{
|
||||
// 1. 获取chat_id(优先级:消息携带 > 客服账号查询 > 用户方向查询 > 默认)
|
||||
var chatId string
|
||||
if messageChatId != "" {
|
||||
chatId = messageChatId
|
||||
glog.Infof(ctx, "使用消息携带的chat_id: %s", chatId)
|
||||
} else if accountName != "" {
|
||||
chatId = getChatIdByAccountName(ctx, tenantId, accountName)
|
||||
}
|
||||
if chatId == "" {
|
||||
chatId = getChatIdByDirection(ctx, userId, platform)
|
||||
}
|
||||
|
||||
// 2. 检测chatId是否变更不再需要,因为缓存key已包含chatId
|
||||
// 不同chatId自动使用不同的session缓存 }
|
||||
|
||||
// 3. 获取或创建 Session(使用正确的chat_id,包含租户隔离)
|
||||
var isNewSession bool
|
||||
sessionId, isNewSession, err = getOrCreateSession(ctx, tenantId, userId, chatId)
|
||||
if err != nil {
|
||||
// 检测Chat权限错误(assistant/chat不属于当前API Key)
|
||||
if strings.Contains(err.Error(), "do not own the assistant") || strings.Contains(err.Error(), "don't own the chat") || strings.Contains(err.Error(), "doesn't exist") || strings.Contains(err.Error(), "not found") {
|
||||
glog.Warningf(ctx, "创建Session时检测到Chat权限错误,尝试自动重建 - 用户: %s, accountName: %s, chat_id: %s", userId, accountName, chatId)
|
||||
|
||||
// 调用重建逻辑
|
||||
if accountName != "" {
|
||||
if recreateErr := recreateChatIfNeeded(ctx, tenantId, accountName, platform); recreateErr != nil {
|
||||
glog.Errorf(ctx, "自动重建Chat失败: %v", recreateErr)
|
||||
return
|
||||
}
|
||||
|
||||
// 重新查询chat_id
|
||||
newChatId := getChatIdByAccountName(ctx, tenantId, accountName)
|
||||
if newChatId == "" {
|
||||
glog.Errorf(ctx, "重建Chat后仍无法获取chat_id")
|
||||
return
|
||||
}
|
||||
chatId = newChatId
|
||||
glog.Infof(ctx, "Chat重建成功,新chat_id: %s", chatId)
|
||||
|
||||
// 清理旧session缓存
|
||||
redis.DelSessionCache(ctx, tenantId, userId)
|
||||
|
||||
// 使用新chat_id重新创建session
|
||||
sessionId, isNewSession, err = getOrCreateSession(ctx, tenantId, userId, chatId)
|
||||
if err != nil {
|
||||
glog.Errorf(ctx, "使用新Chat创建Session失败: %v", err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
glog.Errorf(ctx, "Chat权限错误但缺少accountName,无法自动重建")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
glog.Errorf(ctx, "获取 Session 失败: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 调用 RAGFlow API
|
||||
client := ragflow.GetGlobalClient()
|
||||
if client == nil {
|
||||
glog.Error(ctx, "RAGFlow 客户端未初始化")
|
||||
return
|
||||
}
|
||||
|
||||
// 如果是新 Session 且有历史对话,把历史拼接到问题中调用 RAGFlow
|
||||
if isNewSession && len(history) > 0 {
|
||||
var newSessionId string
|
||||
newSessionId, answer, err = callWithHistory(ctx, client, userId, content, history, chatId)
|
||||
if err != nil {
|
||||
glog.Errorf(ctx, "带历史调用 RAGFlow 失败: %v", err)
|
||||
return
|
||||
}
|
||||
// 更新 Session 缓存(包含租户隔离)
|
||||
if newSessionId != "" {
|
||||
redis.SetSessionCache(ctx, tenantId, userId, newSessionId)
|
||||
sessionId = newSessionId
|
||||
}
|
||||
} else {
|
||||
// 已有 Session 或无历史,使用普通 API(RAGFlow 内部维护上下文)
|
||||
var res *ragflow.ChatCompletionRes
|
||||
res, err = client.ChatCompletion(ctx, chatId, &ragflow.ChatCompletionReq{
|
||||
Question: content,
|
||||
SessionId: sessionId,
|
||||
UserId: userId,
|
||||
Stream: false,
|
||||
})
|
||||
if err != nil {
|
||||
// 检测Chat不存在或权限错误(RAGFlow中Chat被删除或不属于当前API Key)
|
||||
if strings.Contains(err.Error(), "doesn't exist") || strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "404") || strings.Contains(err.Error(), "don't own the chat") {
|
||||
glog.Warningf(ctx, "检测到Chat不存在,尝试自动重建 - 用户: %s, accountName: %s, chat_id: %s", userId, accountName, chatId)
|
||||
|
||||
// 调用重建逻辑(需要accountName)
|
||||
if accountName != "" {
|
||||
if recreateErr := recreateChatIfNeeded(ctx, tenantId, accountName, platform); recreateErr != nil {
|
||||
glog.Errorf(ctx, "自动重建Chat失败: %v", recreateErr)
|
||||
return
|
||||
}
|
||||
|
||||
// 重新查询chat_id
|
||||
newChatId := getChatIdByAccountName(ctx, tenantId, accountName)
|
||||
if newChatId == "" {
|
||||
glog.Errorf(ctx, "重建Chat后仍无法获取chat_id")
|
||||
return
|
||||
}
|
||||
chatId = newChatId
|
||||
glog.Infof(ctx, "Chat重建成功,新chat_id: %s", chatId)
|
||||
|
||||
// 清理旧session缓存
|
||||
redis.DelSessionCache(ctx, tenantId, userId)
|
||||
|
||||
// 重新创建session并调用
|
||||
sessionId, _, err = getOrCreateSession(ctx, tenantId, userId, chatId)
|
||||
if err != nil {
|
||||
glog.Errorf(ctx, "创建新Session失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 重试调用
|
||||
res, err = client.ChatCompletion(ctx, chatId, &ragflow.ChatCompletionReq{
|
||||
Question: content,
|
||||
SessionId: sessionId,
|
||||
UserId: userId,
|
||||
Stream: false,
|
||||
})
|
||||
if err != nil {
|
||||
glog.Errorf(ctx, "使用新Chat重试失败: %v", err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
glog.Errorf(ctx, "Chat不存在但缺少accountName,无法自动重建")
|
||||
return
|
||||
}
|
||||
// 检测session ownership错误(session不属于当前chat_id)
|
||||
} else if strings.Contains(err.Error(), "don't own the session") {
|
||||
glog.Warningf(ctx, "Session不属于当前chat_id,清理缓存并重新创建 - 用户: %s, 旧session: %s, chat_id: %s", userId, sessionId, chatId)
|
||||
// 清理缓存(包含租户隔离)
|
||||
redis.DelSessionCache(ctx, tenantId, userId)
|
||||
// 重新创建session并调用
|
||||
sessionId, _, err = getOrCreateSession(ctx, tenantId, userId, chatId)
|
||||
if err != nil {
|
||||
glog.Errorf(ctx, "重新创建Session失败: %v", err)
|
||||
return
|
||||
}
|
||||
// 重试调用
|
||||
res, err = client.ChatCompletion(ctx, chatId, &ragflow.ChatCompletionReq{
|
||||
Question: content,
|
||||
SessionId: sessionId,
|
||||
UserId: userId,
|
||||
Stream: false,
|
||||
})
|
||||
if err != nil {
|
||||
glog.Errorf(ctx, "重试调用RAGFlow失败: %v", err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
glog.Errorf(ctx, "调用 RAGFlow 失败: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
answer = res.Data.Answer
|
||||
|
||||
// 更新 Session 缓存(如果 RAGFlow 返回了新的 session_id,包含租户隔离)
|
||||
if res.Data.SessionId != "" && res.Data.SessionId != sessionId {
|
||||
redis.SetSessionCache(ctx, tenantId, userId, res.Data.SessionId)
|
||||
sessionId = res.Data.SessionId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 计算耗时并写入文件
|
||||
endTime := gtime.Now()
|
||||
elapsed := endTime.Sub(startTime)
|
||||
// 截取回复前20字
|
||||
answerPreview := answer
|
||||
if len([]rune(answerPreview)) > 20 {
|
||||
answerPreview = string([]rune(answerPreview)[:20]) + "..."
|
||||
}
|
||||
glog.Infof(ctx, "回复 - 用户: %s, 耗时: %s, 回复: %s", userId, elapsed, answerPreview)
|
||||
// 日志格式:发送时间 | 响应时间 | 耗时 | 用户 | 问题 | 回复前20字
|
||||
// 写入时间日志,失败时记录警告但不影响主流程
|
||||
logContent := startTime.Format("Y-m-d H:i:s") + "\t" + endTime.Format("Y-m-d H:i:s") + "\t" + elapsed.String() + "\t" + userId + "\t" + content + "\t" + answerPreview + "\n"
|
||||
if err := gfile.PutContentsAppend("timelog/ragflow_time.log", logContent); err != nil {
|
||||
glog.Warningf(ctx, "写入时间日志失败: %v", err)
|
||||
}
|
||||
|
||||
// 3. 更新用户最后活跃时间
|
||||
redis.SetSessionLastActive(ctx, userId)
|
||||
|
||||
// 6. 原样写入 RabbitMQ 结果队列(透传 TenantId、AccountId、AccountName)
|
||||
responseMsg := &redis.ResponseStreamMessage{
|
||||
UserId: userId,
|
||||
Platform: platform,
|
||||
TenantId: tenantId,
|
||||
AccountId: accountId,
|
||||
AccountName: accountName,
|
||||
Question: content,
|
||||
Content: answer,
|
||||
SessionId: sessionId,
|
||||
Timestamp: gtime.Now().Timestamp(),
|
||||
MessageId: messageId,
|
||||
}
|
||||
|
||||
// 读取请求中的ReplyQueue字段,支持多实例独立队列
|
||||
replyQueue := gconv.String(message["reply_queue"])
|
||||
if replyQueue != "" && replyQueue != cfg.responseRoutingKey {
|
||||
// 使用自定义响应队列(多实例场景)
|
||||
// routing key使用队列名,实现精确路由(避免广播到所有实例)
|
||||
glog.Infof(ctx, "使用自定义响应队列: %s - 用户: %s", replyQueue, userId)
|
||||
customPublisher := rabbitmq.NewPublisher(cfg.responseExchange, replyQueue)
|
||||
if err = customPublisher.PublishWithRoutingKey(ctx, replyQueue, responseMsg); err != nil {
|
||||
glog.Errorf(ctx, "写入自定义响应队列失败: %v", err)
|
||||
return
|
||||
}
|
||||
glog.Infof(ctx, "响应已写入 RabbitMQ - 用户: %s, routingKey: %s (队列名)", userId, replyQueue)
|
||||
} else {
|
||||
// 使用默认响应队列(单实例或旧版本兼容)
|
||||
// routing key使用:tenantId.userId
|
||||
routingKey := tenantId + "." + userId
|
||||
if err = responsePublisher.PublishWithRoutingKey(ctx, routingKey, responseMsg); err != nil {
|
||||
glog.Errorf(ctx, "写入 RabbitMQ 结果队列失败: %v", err)
|
||||
return
|
||||
}
|
||||
glog.Infof(ctx, "响应已写入 RabbitMQ - 用户: %s, routingKey: %s (租户.用户)", userId, routingKey)
|
||||
}
|
||||
|
||||
// 7. 发送追问消息到延时队列
|
||||
sendFollowUpMessages(ctx, userId, platform)
|
||||
|
||||
// 8. 发送归档消息到延时队列(60分钟后)
|
||||
sendArchiveMessage(ctx, userId, platform, sessionId, tenantId)
|
||||
|
||||
glog.Infof(ctx, "消息处理完成 - 用户: %s", userId)
|
||||
return
|
||||
}
|
||||
|
||||
// sendArchiveMessage 发送归档消息到延时队列
|
||||
func sendArchiveMessage(ctx context.Context, userId, platform, sessionId, tenantId string) {
|
||||
msg := &redis.ArchiveMessage{
|
||||
UserId: userId,
|
||||
Platform: platform,
|
||||
SessionId: sessionId,
|
||||
TenantId: tenantId,
|
||||
Timestamp: gtime.Now().Timestamp(),
|
||||
}
|
||||
|
||||
if err := archivePublisher.PublishDelayed(ctx, msg, redis.GetArchiveDelay()); err != nil {
|
||||
glog.Errorf(ctx, "发送归档消息失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// callWithHistory 带历史上下文调用 RAGFlow(用于新 Session)
|
||||
// history 由 customerservice 从 MongoDB 读取后通过消息携带
|
||||
// 使用 RAGFlow 原生 API,把历史对话拼接到问题中,保留提示词和知识库功能
|
||||
// chatId 为用户选择方向对应的chat_id,而非默认chat_id
|
||||
func callWithHistory(ctx context.Context, client *ragflow.Client, userId, content string, history []redis.HistoryMessage, chatId string) (sessionId, answer string, err error) {
|
||||
// 构建带历史上下文的问题
|
||||
var question string
|
||||
if len(history) > 0 {
|
||||
// 限制历史对话长度:最多3轮或总字符数不超过8000(避免超过RAGFlow输入限制)
|
||||
const maxHistoryRounds = 3
|
||||
const maxHistoryChars = 8000
|
||||
|
||||
var builder strings.Builder
|
||||
builder.WriteString("[以下是之前的对话历史,请参考]\n")
|
||||
|
||||
// 限制历史轮数
|
||||
historyToUse := history
|
||||
if len(history) > maxHistoryRounds {
|
||||
historyToUse = history[len(history)-maxHistoryRounds:] // 只取最近3轮
|
||||
glog.Infof(ctx, "历史对话超过%d轮,截取最近%d轮 - 用户: %s", len(history), maxHistoryRounds, userId)
|
||||
}
|
||||
|
||||
// 拼接历史对话,同时检查字符数限制
|
||||
for _, h := range historyToUse {
|
||||
builder.WriteString("用户: ")
|
||||
builder.WriteString(h.Question)
|
||||
builder.WriteString("\nAI: ")
|
||||
builder.WriteString(h.Answer)
|
||||
builder.WriteString("\n")
|
||||
|
||||
// 检查是否超过字符数限制
|
||||
if builder.Len() > maxHistoryChars {
|
||||
glog.Warningf(ctx, "历史对话超过%d字符,停止追加 - 用户: %s", maxHistoryChars, userId)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
builder.WriteString("\n[当前问题]\n")
|
||||
builder.WriteString(content)
|
||||
question = builder.String()
|
||||
} else {
|
||||
question = content
|
||||
}
|
||||
|
||||
glog.Infof(ctx, "注入 %d 轮历史对话上下文 - chat_id: %s", len(history), chatId)
|
||||
|
||||
// 先创建新 session(使用方向对应的chat_id)
|
||||
session, err := client.CreateSession(ctx, chatId, &ragflow.CreateSessionReq{
|
||||
Name: "session_" + userId,
|
||||
UserId: userId,
|
||||
})
|
||||
if err != nil {
|
||||
glog.Errorf(ctx, "创建 Session 失败: %v", err)
|
||||
return
|
||||
}
|
||||
sessionId = session.Id
|
||||
|
||||
// 使用 RAGFlow 原生 API(保留提示词和知识库,使用方向对应的chat_id)
|
||||
res, err := client.ChatCompletion(ctx, chatId, &ragflow.ChatCompletionReq{
|
||||
Question: question,
|
||||
SessionId: sessionId,
|
||||
UserId: userId,
|
||||
Stream: false,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
answer = res.Data.Answer
|
||||
return
|
||||
}
|
||||
|
||||
// sendFollowUpMessages 发送追问消息到延时队列
|
||||
func sendFollowUpMessages(ctx context.Context, userId, platform string) {
|
||||
now := gtime.Now().Timestamp()
|
||||
|
||||
for followUpType := redis.FollowUpType1; followUpType <= redis.FollowUpType3; followUpType++ {
|
||||
msg := &redis.FollowUpMessage{
|
||||
UserId: userId,
|
||||
Platform: platform,
|
||||
Content: redis.GetFollowUpContent(followUpType),
|
||||
FollowUpType: followUpType,
|
||||
Timestamp: now,
|
||||
}
|
||||
|
||||
if err := followUpPublisher.PublishDelayed(ctx, msg, redis.GetFollowUpDelay(followUpType)); err != nil {
|
||||
glog.Errorf(ctx, "发送追问消息失败 - 类型: %d, 错误: %v", followUpType, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getOrCreateSession 获取或创建 RAGFlow Session(支持租户隔离)
|
||||
// 返回 isNew=true 表示是新创建的 session,需要注入历史上下文
|
||||
func getOrCreateSession(ctx context.Context, tenantId, userId, chatId string) (sessionId string, isNew bool, err error) {
|
||||
// 先从缓存获取(包含租户隔离)
|
||||
if sessionId, err = redis.GetSessionCache(ctx, tenantId, userId); err != nil {
|
||||
return
|
||||
}
|
||||
if sessionId != "" {
|
||||
glog.Infof(ctx, "使用缓存的session - 租户: %s, 用户: %s, chat_id: %s, session: %s", tenantId, userId, chatId, sessionId)
|
||||
return // 已有 session,不是新的
|
||||
}
|
||||
|
||||
// 缓存不存在,创建新 Session(使用传入的chat_id)
|
||||
client := ragflow.GetGlobalClient()
|
||||
if client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
glog.Infof(ctx, "创建新session - 租户: %s, 用户: %s, chat_id: %s", tenantId, userId, chatId)
|
||||
session, err := client.CreateSession(ctx, chatId, &ragflow.CreateSessionReq{
|
||||
Name: "session_" + tenantId + "_" + userId,
|
||||
UserId: userId,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
sessionId = session.Id
|
||||
isNew = true // 标记为新创建
|
||||
|
||||
// 缓存 Session ID(包含租户隔离)
|
||||
redis.SetSessionCache(ctx, tenantId, userId, sessionId)
|
||||
glog.Infof(ctx, "新session已创建并缓存 - 租户: %s, 用户: %s, session: %s", tenantId, userId, sessionId)
|
||||
return
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package consumer
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/glog"
|
||||
)
|
||||
|
||||
// recreateChatIfNeeded 检测并重建Chat配置
|
||||
// 调用customerservice的recreateRAGFlow接口,由customerservice负责MongoDB操作
|
||||
func recreateChatIfNeeded(ctx context.Context, tenantId, accountName, platform string) error {
|
||||
glog.Infof(ctx, "开始自动重建Chat - accountName: %s, platform: %s, tenantId: %s", accountName, platform, tenantId)
|
||||
|
||||
// 1. 从配置获取customerservice地址(避免consul.GetInstanceAddr在Stream context中panic)
|
||||
customerserviceAddr := g.Cfg().MustGet(ctx, "customerservice.address", "customerservice:3000").String()
|
||||
|
||||
// 2. 调用customerservice的recreateRAGFlow接口
|
||||
url := "http://" + customerserviceAddr + "/customer/service/account/recreateRAGFlow"
|
||||
reqBody := g.Map{
|
||||
"accountName": accountName,
|
||||
"platform": platform,
|
||||
}
|
||||
|
||||
glog.Infof(ctx, "调用customerservice recreateRAGFlow接口 - URL: %s, 请求: %+v", url, reqBody)
|
||||
|
||||
client := g.Client()
|
||||
resp, err := client.Post(ctx, url, reqBody)
|
||||
if err != nil {
|
||||
return gerror.Wrapf(err, "调用recreateRAGFlow接口失败")
|
||||
}
|
||||
defer resp.Close()
|
||||
|
||||
// 3. 解析响应
|
||||
respBody := resp.ReadAllString()
|
||||
result := gjson.New(respBody)
|
||||
|
||||
if result.Get("code").Int() != 0 {
|
||||
errMsg := result.Get("message").String()
|
||||
return gerror.Newf("recreateRAGFlow失败: %s", errMsg)
|
||||
}
|
||||
|
||||
glog.Infof(ctx, "Chat自动重建完成 - accountName: %s", accountName)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Package controller - 客服账号控制器
|
||||
// 功能:客服账号的增删改查、状态切换
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/model/dto"
|
||||
"customer-server/service"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/beans"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
var Account = new(account)
|
||||
|
||||
type account struct{}
|
||||
|
||||
// Add 添加客服账号
|
||||
// 参数: req - 添加客服账号请求,包含客服账号名称、平台等信息
|
||||
// 返回: res - 添加成功后的客服账号ID等信息
|
||||
// 功能: 创建新的客服账号记录
|
||||
func (c *account) Add(ctx context.Context, req *dto.AddAccountReq) (res *dto.AddAccountRes, err error) {
|
||||
res, err = service.AccountService.Add(ctx, req)
|
||||
return
|
||||
}
|
||||
|
||||
// Update 更新客服账号
|
||||
// 参数: req - 更新客服账号请求,包含客服账号ID和待更新字段
|
||||
// 返回: res - 空响应(成功则err为nil)
|
||||
// 功能: 更新客服账号信息
|
||||
func (c *account) Update(ctx context.Context, req *dto.UpdateAccountReq) (res *beans.ResponseEmpty, err error) {
|
||||
err = service.AccountService.Update(ctx, req)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete 删除客服账号
|
||||
// 参数: req - 删除客服账号请求,包含客服账号ID
|
||||
// 返回: res - 空响应(成功则err为nil)
|
||||
// 功能: 逻辑删除客服账号记录
|
||||
func (c *account) Delete(ctx context.Context, req *dto.DeleteAccountReq) (res *beans.ResponseEmpty, err error) {
|
||||
err = service.AccountService.Delete(ctx, req)
|
||||
return
|
||||
}
|
||||
|
||||
// Get 获取单个客服账号
|
||||
// 参数: req - 获取客服账号请求,包含客服账号ID
|
||||
// 返回: res - 客服账号信息
|
||||
// 功能: 根据ID获取单个客服账号详情
|
||||
func (c *account) Get(ctx context.Context, req *dto.GetAccountReq) (res *dto.AccountVO, err error) {
|
||||
res, err = service.AccountService.Get(ctx, req)
|
||||
return
|
||||
}
|
||||
|
||||
// List 获取客服账号列表
|
||||
// 参数: req - 列表查询请求,支持分页、账号名称、状态、平台筛选
|
||||
// 返回: res - 客服账号列表及分页信息
|
||||
// 功能: 分页查询客服账号记录
|
||||
func (c *account) List(ctx context.Context, req *dto.ListAccountReq) (res *dto.ListAccountRes, err error) {
|
||||
if !g.IsEmpty(req.Page) {
|
||||
req.Page = &beans.Page{PageNum: 1, PageSize: 20}
|
||||
}
|
||||
res, err = service.AccountService.List(ctx, req)
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/model/dto"
|
||||
"customer-server/service"
|
||||
)
|
||||
|
||||
var AccountHttp = new(accountHttp)
|
||||
|
||||
type accountHttp struct{}
|
||||
|
||||
func (c *accountHttp) Connect(ctx context.Context, req *dto.AccountHttpConnectReq) (res *dto.AccountHttpConnectRes, err error) {
|
||||
res, err = service.AccountHttpService.Connect(ctx, req)
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Package controller - WebSocket控制器
|
||||
// 功能:WebSocket连接管理、实时消息推送
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/model/dto"
|
||||
"customer-server/service"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/beans"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
var AccountWebsocket = new(accountWebSocket)
|
||||
|
||||
type accountWebSocket struct{}
|
||||
|
||||
// Connect WebSocket连接
|
||||
// 参数: req - WebSocket连接请求,包含用户ID和平台信息
|
||||
// 返回: res - 连接结果(实际通过WebSocket协议通信)
|
||||
// 功能: 升级HTTP连接为WebSocket,建立实时通信通道
|
||||
func (c *accountWebSocket) Connect(ctx context.Context, req *dto.AccountWebSocketConnectReq) (res *beans.ResponseEmpty, err error) {
|
||||
r := g.RequestFromCtx(ctx)
|
||||
err = service.AccountWebSocket.Connect(ctx, r, req)
|
||||
return
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
// Package controller - 归档控制器
|
||||
// 功能:手动触发对话记录从MongoDB归档到Elasticsearch
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/model/dto"
|
||||
"customer-server/service"
|
||||
)
|
||||
|
||||
var Archive = new(archive)
|
||||
|
||||
type archive struct{}
|
||||
|
||||
// RunNow 手动触发归档
|
||||
// 参数: req - 归档请求(可选参数)
|
||||
// 返回: res - 归档执行结果信息
|
||||
// 功能: 手动触发月度归档任务,将MongoDB对话记录迁移到Elasticsearch
|
||||
// 路由: POST /archive/run
|
||||
func (c *archive) RunNow(ctx context.Context, req *dto.ArchiveRunReq) (res *dto.ArchiveRunRes, err error) {
|
||||
err = service.ArchiveService.RunNow(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = &dto.ArchiveRunRes{
|
||||
Message: "归档任务执行完成",
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
// Package controller - 客服账号控制器
|
||||
// 功能:客服账号的增删改查
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/model/dto"
|
||||
"customer-server/service"
|
||||
|
||||
"gitea.com/red-future/common/beans"
|
||||
)
|
||||
|
||||
var CustomerServiceAccount = new(customerServiceAccount)
|
||||
|
||||
type customerServiceAccount struct{}
|
||||
|
||||
// Add 添加客服账号
|
||||
// 参数: req - 添加客服账号请求,包含账号名、平台、开场白等信息
|
||||
// 返回: res - 添加成功后的账号ID等信息
|
||||
// 功能: 创建客服账号并自动创建对应的RAGFlow配置(Dataset、Chat)
|
||||
func (c *customerServiceAccount) Add(ctx context.Context, req *dto.AddCustomerServiceAccountReq) (res *dto.AddCustomerServiceAccountRes, err error) {
|
||||
res, err = service.CustomerServiceAccount.Add(ctx, req)
|
||||
return
|
||||
}
|
||||
|
||||
// Update 更新客服账号
|
||||
// 参数: req - 更新客服账号请求,包含账号ID和待更新字段
|
||||
// 返回: res - 空响应(成功则err为nil)
|
||||
// 功能: 更新客服账号基本信息(不包含开场白和RAGFlow配置)
|
||||
func (c *customerServiceAccount) Update(ctx context.Context, req *dto.UpdateCustomerServiceAccountReq) (res *beans.ResponseEmpty, err error) {
|
||||
err = service.CustomerServiceAccount.Update(ctx, req)
|
||||
return
|
||||
}
|
||||
|
||||
// ToggleStatus 切换客服账号状态(启用/禁用)
|
||||
// 参数: req - 状态切换请求,包含账号ID
|
||||
// 返回: res - 空响应(成功则err为nil)
|
||||
// 功能: 在启用和禁用状态之间切换,禁用后不再接收用户消息
|
||||
func (c *customerServiceAccount) ToggleStatus(ctx context.Context, req *dto.ToggleCustomerServiceAccountStatusReq) (res *beans.ResponseEmpty, err error) {
|
||||
err = service.CustomerServiceAccount.ToggleStatus(ctx, req)
|
||||
return
|
||||
}
|
||||
|
||||
// List 获取客服账号列表
|
||||
// 参数: req - 列表查询请求,支持分页、平台筛选、状态筛选
|
||||
// 返回: res - 客服账号列表及分页信息
|
||||
// 功能: 分页查询客服账号,支持按平台、状态筛选
|
||||
func (c *customerServiceAccount) List(ctx context.Context, req *dto.ListCustomerServiceAccountReq) (res *dto.ListCustomerServiceAccountRes, err error) {
|
||||
res, err = service.CustomerServiceAccount.List(ctx, req)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete 删除客服账号
|
||||
// 参数: req - 删除客服账号请求,包含账号ID
|
||||
// 返回: res - 删除结果信息
|
||||
// 功能: 逻辑删除客服账号,同时删除关联的RAGFlow配置
|
||||
func (c *customerServiceAccount) Delete(ctx context.Context, req *dto.DeleteCustomerServiceAccountReq) (res *dto.DeleteCustomerServiceAccountRes, err error) {
|
||||
res, err = service.CustomerServiceAccount.Delete(ctx, req)
|
||||
return
|
||||
}
|
||||
|
||||
// GetAccessibleSpeechcrafts 获取客服账号可访问的话术列表
|
||||
// 参数: req - 查询请求,包含账号名
|
||||
// 返回: res - 该账号可访问的话术列表
|
||||
// 功能: 查询绑定到该客服账号的所有话术
|
||||
func (c *customerServiceAccount) GetAccessibleSpeechcrafts(ctx context.Context, req *dto.GetAccessibleSpeechcraftsReq) (res *dto.GetAccessibleSpeechcraftsRes, err error) {
|
||||
res, err = service.CustomerServiceAccount.GetAccessibleSpeechcrafts(ctx, req)
|
||||
return
|
||||
}
|
||||
|
||||
// RecreateRAGFlowConfig 重新创建RAGFlow配置
|
||||
// 参数: req - 重建请求,包含账号名
|
||||
// 返回: res - 空响应(成功则err为nil)
|
||||
// 功能: 删除旧配置并重新创建Dataset和Chat,用于修复配置异常
|
||||
func (c *customerServiceAccount) RecreateRAGFlowConfig(ctx context.Context, req *dto.RecreateRAGFlowConfigReq) (res *beans.ResponseEmpty, err error) {
|
||||
err = service.CustomerServiceAccount.RecreateRAGFlowConfig(ctx, req)
|
||||
return
|
||||
}
|
||||
|
||||
// UpdateGreeting 更新开场白
|
||||
// 参数: req - 更新开场白请求,包含账号名和新开场白内容
|
||||
// 返回: res - 空响应(成功则err为nil)
|
||||
// 功能: 更新客服账号的开场白,用户连接WebSocket时推送
|
||||
func (c *customerServiceAccount) UpdateGreeting(ctx context.Context, req *dto.UpdateGreetingReq) (res *beans.ResponseEmpty, err error) {
|
||||
err = service.CustomerServiceAccount.UpdateGreeting(ctx, req)
|
||||
return
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
// Package controller - 对话数据控制器
|
||||
// 功能:对话记录的查询、导出
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/model/dto"
|
||||
"customer-server/service"
|
||||
|
||||
"gitea.com/red-future/common/beans"
|
||||
)
|
||||
|
||||
var Data = new(data)
|
||||
|
||||
type data struct{}
|
||||
|
||||
// Add 添加数据
|
||||
// 参数: req - 添加数据请求,包含日期和数据内容
|
||||
// 返回: res - 添加成功后的数据ID等信息
|
||||
// 功能: 创建新的数据记录
|
||||
func (c *data) Add(ctx context.Context, req *dto.AddDataReq) (res *dto.AddDataRes, err error) {
|
||||
res, err = service.Data.Add(ctx, req)
|
||||
return
|
||||
}
|
||||
|
||||
// Update 更新数据
|
||||
// 参数: req - 更新数据请求,包含数据ID和待更新字段
|
||||
// 返回: res - 空响应(成功则err为nil)
|
||||
// 功能: 更新数据记录内容
|
||||
func (c *data) Update(ctx context.Context, req *dto.UpdateDataReq) (res *beans.ResponseEmpty, err error) {
|
||||
err = service.Data.Update(ctx, req)
|
||||
return
|
||||
}
|
||||
|
||||
// // Delete 删除数据
|
||||
// func (c *data) Delete(ctx context.Context, req *dto.DeleteDataReq) (res *beans.ResponseEmpty, err error) {
|
||||
// err = service.Data.Delete(ctx, req)
|
||||
// if err != nil {
|
||||
// return
|
||||
// }
|
||||
// res = &beans.ResponseEmpty{}
|
||||
// return
|
||||
// }
|
||||
|
||||
// List 获取数据列表
|
||||
// 参数: req - 列表查询请求,支持分页、日期范围筛选
|
||||
// 返回: res - 数据列表及分页信息
|
||||
// 功能: 分页查询数据记录,支持按日期范围筛选
|
||||
func (c *data) List(ctx context.Context, req *dto.ListDataReq) (res *dto.ListDataRes, err error) {
|
||||
res, err = service.Data.List(ctx, req)
|
||||
return
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// Package controller - 数据统计控制器
|
||||
// 功能:对话数据的统计分析、报表导出
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/model/dto"
|
||||
"customer-server/service"
|
||||
|
||||
"gitea.com/red-future/common/beans"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
var DataStatistics = new(dataStatistics)
|
||||
|
||||
type dataStatistics struct{}
|
||||
|
||||
// Add 添加数据统计
|
||||
// 参数: req - 添加数据统计请求,包含日期和统计数据
|
||||
// 返回: res - 添加成功后的统计ID等信息
|
||||
// 功能: 创建新的数据统计记录
|
||||
func (c *dataStatistics) Add(ctx context.Context, req *dto.AddDataStatisticsReq) (res *dto.AddDataStatisticsRes, err error) {
|
||||
res, err = service.DataStatistics.Add(ctx, req)
|
||||
return
|
||||
}
|
||||
|
||||
// Update 更新数据统计
|
||||
// 参数: req - 更新数据统计请求,包含统计ID和待更新字段
|
||||
// 返回: res - 空响应(成功则err为nil)
|
||||
// 功能: 更新数据统计记录内容
|
||||
func (c *dataStatistics) Update(ctx context.Context, req *dto.UpdateDataStatisticsReq) (res *beans.ResponseEmpty, err error) {
|
||||
err = service.DataStatistics.Update(ctx, req)
|
||||
return
|
||||
}
|
||||
|
||||
// List 获取数据统计列表
|
||||
// 参数: req - 列表查询请求,支持分页、日期范围筛选
|
||||
// 返回: res - 数据统计列表及分页信息
|
||||
// 功能: 分页查询数据统计记录,支持按日期范围筛选
|
||||
func (c *dataStatistics) List(ctx context.Context, req *dto.ListDataStatisticsReq) (res *dto.ListDataStatisticsRes, err error) {
|
||||
res, err = service.DataStatistics.List(ctx, req)
|
||||
return
|
||||
}
|
||||
|
||||
// Export 导出数据统计
|
||||
// 参数: req - 导出请求,包含筛选条件
|
||||
// 返回: res - 空响应(直接写入Response流)
|
||||
// 功能: 将数据统计导出为ZIP文件,包含Excel文件
|
||||
func (c *dataStatistics) Export(ctx context.Context, req *dto.ExportDataStatisticsReq) (res *beans.ResponseEmpty, err error) {
|
||||
zipData, filename, err := service.DataStatistics.Export(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
r := g.RequestFromCtx(ctx)
|
||||
r.Response.Header().Set("Content-Type", "application/zip")
|
||||
r.Response.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
|
||||
r.Response.Header().Set("Content-Length", gconv.String(len(zipData)))
|
||||
r.Response.Write(zipData)
|
||||
return
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
// Package controller - 健康检查控制器
|
||||
// 功能:服务健康状态检查接口
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
var Health = new(health)
|
||||
|
||||
type health struct{}
|
||||
|
||||
// Check 健康检查
|
||||
func (c *health) Check(ctx context.Context, req *HealthCheckReq) (res *HealthCheckRes, err error) {
|
||||
res = &HealthCheckRes{
|
||||
Status: "ok",
|
||||
Service: "customerservice",
|
||||
Version: "v1.0.0",
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// HealthCheckReq 健康检查请求
|
||||
type HealthCheckReq struct {
|
||||
g.Meta `path:"/" method:"get" tags:"Health" summary:"健康检查" dc:"检查服务是否正常运行"`
|
||||
}
|
||||
|
||||
// HealthCheckRes 健康检查响应
|
||||
type HealthCheckRes struct {
|
||||
Status string `json:"status" dc:"状态"`
|
||||
Service string `json:"service" dc:"服务名称"`
|
||||
Version string `json:"version" dc:"版本"`
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
// Package controller - 产品控制器
|
||||
// 功能:产品的增删改查、导入/导出ZIP、绑定/解绑客服账号
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/model/dto"
|
||||
"customer-server/service"
|
||||
|
||||
"gitea.com/red-future/common/beans"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
var Product = new(product)
|
||||
|
||||
type product struct{}
|
||||
|
||||
// Add 添加产品
|
||||
// 参数: req - 添加产品请求,包含产品名称、描述、价格等信息
|
||||
// 返回: res - 添加成功后的产品ID等信息
|
||||
// 功能: 创建新产品记录并同步到RAGFlow知识库
|
||||
func (c *product) Add(ctx context.Context, req *dto.AddProductReq) (res *dto.AddProductRes, err error) {
|
||||
res, err = service.Product.Add(ctx, req)
|
||||
return
|
||||
}
|
||||
|
||||
// Update 更新产品
|
||||
// 参数: req - 更新产品请求,包含产品ID和待更新字段
|
||||
// 返回: res - 空响应(成功则err为nil)
|
||||
// 功能: 更新产品信息并同步到RAGFlow
|
||||
func (c *product) Update(ctx context.Context, req *dto.UpdateProductReq) (res *beans.ResponseEmpty, err error) {
|
||||
err = service.Product.Update(ctx, req)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete 删除产品
|
||||
// 参数: req - 删除产品请求,包含产品ID
|
||||
// 返回: res - 空响应(成功则err为nil)
|
||||
// 功能: 逻辑删除产品记录并从RAGFlow移除
|
||||
func (c *product) Delete(ctx context.Context, req *dto.DeleteProductReq) (res *beans.ResponseEmpty, err error) {
|
||||
err = service.Product.Delete(ctx, req)
|
||||
return
|
||||
}
|
||||
|
||||
// List 获取产品列表
|
||||
// 参数: req - 列表查询请求,支持分页、关键词搜索
|
||||
// 返回: res - 产品列表及分页信息
|
||||
// 功能: 分页查询产品记录,支持按名称、描述搜索
|
||||
func (c *product) List(ctx context.Context, req *dto.ListProductReq) (res *dto.ListProductRes, err error) {
|
||||
res, err = service.Product.List(ctx, req)
|
||||
return
|
||||
}
|
||||
|
||||
// Export 导出产品为ZIP文件
|
||||
// 参数: req - 导出请求,包含筛选条件
|
||||
// 返回: res - 空响应(直接写入Response流)
|
||||
// 功能: 将产品数据导出为ZIP文件,包含JSON数据
|
||||
func (c *product) Export(ctx context.Context, req *dto.ExportProductReq) (res *dto.ExportProductRes, err error) {
|
||||
// 调用Service层导出
|
||||
zipData, filename, err := service.Product.Export(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 获取原始 Response 对象以设置响应头和写入文件
|
||||
r := g.RequestFromCtx(ctx)
|
||||
|
||||
// 设置响应头为文件下载
|
||||
r.Response.Header().Set("Content-Type", "application/zip")
|
||||
r.Response.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
|
||||
r.Response.Header().Set("Content-Length", gconv.String(len(zipData)))
|
||||
|
||||
// 写入 ZIP 数据
|
||||
r.Response.Write(zipData)
|
||||
return
|
||||
}
|
||||
|
||||
// Import 导入产品(从ZIP文件)
|
||||
// 参数: req - 导入请求(实际从multipart/form-data获取文件)
|
||||
// 返回: res - 导入结果,包含成功和失败数量
|
||||
// 功能: 从ZIP文件批量导入产品数据并同步到RAGFlow
|
||||
func (c *product) Import(ctx context.Context, req *dto.ImportProductReq) (res *dto.ImportProductRes, err error) {
|
||||
r := g.RequestFromCtx(ctx)
|
||||
|
||||
// 获取上传的文件(虽然 req 中定义了 File,但实际需要从 Request 中获取)
|
||||
file := r.GetUploadFile("file")
|
||||
if file == nil {
|
||||
return nil, gerror.New("请上传ZIP文件")
|
||||
}
|
||||
|
||||
// 调用 Service 层处理导入(传递 FileHeader)
|
||||
res, err = service.Product.Import(ctx, file.FileHeader)
|
||||
return
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
// Package controller - RAGFlow配置控制器
|
||||
// 功能:管理ragflow_config表,更新对话配置提示词
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/model/dto"
|
||||
"customer-server/service"
|
||||
)
|
||||
|
||||
var RAGFlowConfig = new(ragflowConfig)
|
||||
|
||||
type ragflowConfig struct{}
|
||||
|
||||
// Get 获取RAGFlow配置
|
||||
// 参数: req - 查询请求,包含客服账号名
|
||||
// 返回: res - RAGFlow配置信息,包含提示词、参数等
|
||||
// 功能: 查询指定客服账号的RAGFlow对话配置
|
||||
func (c *ragflowConfig) Get(ctx context.Context, req *dto.GetRAGFlowConfigReq) (res *dto.GetRAGFlowConfigRes, err error) {
|
||||
res, err = service.RAGFlowConfig.Get(ctx, req)
|
||||
return
|
||||
}
|
||||
|
||||
// UpdatePrompt 更新提示词
|
||||
// 参数: req - 更新提示词请求,包含客服账号名和新提示词内容
|
||||
// 返回: res - 更新结果信息
|
||||
// 功能: 更新RAGFlow对话的系统提示词,影响AI回复风格
|
||||
func (c *ragflowConfig) UpdatePrompt(ctx context.Context, req *dto.UpdatePromptReq) (res *dto.UpdatePromptRes, err error) {
|
||||
res, err = service.RAGFlowConfig.UpdatePrompt(ctx, req)
|
||||
return
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
// Package controller - RAGFlow控制器
|
||||
// 功能:接收来自外部的RAGFlow请求(测试/调试用)
|
||||
package controller
|
||||
|
||||
import (
|
||||
"customer-server/consumer"
|
||||
|
||||
"gitea.com/red-future/common/redis"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
)
|
||||
|
||||
type ragflow struct{}
|
||||
|
||||
var RAGFlow = new(ragflow)
|
||||
|
||||
// Process 处理RAGFlow请求
|
||||
// 参数: r - HTTP请求对象
|
||||
// 功能: 接收外部RAGFlow消息请求,直接调用consumer处理逻辑(用于测试)
|
||||
// 注意: 正常流程不经过此接口,而是直接消费Redis Stream
|
||||
func (c *ragflow) Process(r *ghttp.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
// 调试:打印原始请求体
|
||||
bodyBytes := r.GetBody()
|
||||
g.Log().Infof(ctx, "收到原始请求体: %s", string(bodyBytes))
|
||||
|
||||
var req redis.SendStreamMessage
|
||||
if err := r.Parse(&req); err != nil {
|
||||
r.Response.WriteJsonExit(g.Map{
|
||||
"code": 400,
|
||||
"msg": "参数错误: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "收到RAGFlow请求 - 用户: %s, 内容: %s", req.UserId, req.Content)
|
||||
|
||||
// 直接调用consumer的处理逻辑
|
||||
message := map[string]interface{}{
|
||||
"UserId": req.UserId,
|
||||
"Content": req.Content,
|
||||
"MessageId": req.MessageId,
|
||||
"Platform": req.Platform,
|
||||
"TenantId": req.TenantId,
|
||||
"AccountName": req.AccountName,
|
||||
"ChatId": req.ChatId,
|
||||
"ReplyQueue": req.ReplyQueue,
|
||||
"History": req.History,
|
||||
}
|
||||
|
||||
if err := consumer.HandleMessageHTTP(ctx, message); err != nil {
|
||||
r.Response.WriteJsonExit(g.Map{
|
||||
"code": 500,
|
||||
"msg": "处理失败: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
r.Response.WriteJsonExit(g.Map{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Package controller - 预制话术控制器
|
||||
// 功能:预制话术的增删改查
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/model/dto"
|
||||
"customer-server/service"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/beans"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
var ScriptedSpeech = new(scriptedSpeech)
|
||||
|
||||
type scriptedSpeech struct{}
|
||||
|
||||
// Add 添加预制话术
|
||||
// 参数: req - 添加预制话术请求,包含账号ID、数据集ID、问题、回答等信息
|
||||
// 返回: res - 添加成功后的预制话术ID等信息
|
||||
// 功能: 创建新的预制话术记录
|
||||
func (c *scriptedSpeech) Add(ctx context.Context, req *dto.AddScriptedSpeechReq) (res *dto.AddScriptedSpeechRes, err error) {
|
||||
res, err = service.ScriptedSpeechService.Add(ctx, req)
|
||||
return
|
||||
}
|
||||
|
||||
// Update 更新预制话术
|
||||
// 参数: req - 更新预制话术请求,包含预制话术ID和待更新字段
|
||||
// 返回: res - 空响应(成功则err为nil)
|
||||
// 功能: 更新预制话术信息
|
||||
func (c *scriptedSpeech) Update(ctx context.Context, req *dto.UpdateScriptedSpeechReq) (res *beans.ResponseEmpty, err error) {
|
||||
err = service.ScriptedSpeechService.Update(ctx, req)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete 删除预制话术
|
||||
// 参数: req - 删除预制话术请求,包含预制话术ID
|
||||
// 返回: res - 空响应(成功则err为nil)
|
||||
// 功能: 逻辑删除预制话术记录
|
||||
func (c *scriptedSpeech) Delete(ctx context.Context, req *dto.DeleteScriptedSpeechReq) (res *beans.ResponseEmpty, err error) {
|
||||
err = service.ScriptedSpeechService.Delete(ctx, req)
|
||||
return
|
||||
}
|
||||
|
||||
// Get 获取单个预制话术
|
||||
// 参数: req - 获取预制话术请求,包含预制话术ID
|
||||
// 返回: res - 预制话术信息
|
||||
// 功能: 根据ID获取单个预制话术详情
|
||||
func (c *scriptedSpeech) Get(ctx context.Context, req *dto.GetScriptedSpeechReq) (res *dto.ScriptedSpeechVO, err error) {
|
||||
res, err = service.ScriptedSpeechService.Get(ctx, req)
|
||||
return
|
||||
}
|
||||
|
||||
// List 获取预制话术列表
|
||||
// 参数: req - 列表查询请求,支持分页、账号ID、数据集ID筛选
|
||||
// 返回: res - 预制话术列表及分页信息
|
||||
// 功能: 分页查询预制话术记录
|
||||
func (c *scriptedSpeech) List(ctx context.Context, req *dto.ListScriptedSpeechReq) (res *dto.ListScriptedSpeechRes, err error) {
|
||||
if g.IsEmpty(req.Page) {
|
||||
req.Page = &beans.Page{PageNum: 1, PageSize: 20}
|
||||
}
|
||||
res, err = service.ScriptedSpeechService.List(ctx, req)
|
||||
return
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
// Package controller - 话术控制器
|
||||
// 功能:话术的增删改查、绑定/解绑客服账号
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/model/dto"
|
||||
"customer-server/service"
|
||||
|
||||
"gitea.com/red-future/common/beans"
|
||||
)
|
||||
|
||||
var Speechcraft = new(speechcraft)
|
||||
|
||||
type speechcraft struct{}
|
||||
|
||||
// Add 添加话术
|
||||
// 参数: req - 添加话术请求,包含话术标题、内容等信息
|
||||
// 返回: res - 添加成功后的话术ID等信息
|
||||
// 功能: 创建新的话术记录并同步到RAGFlow知识库
|
||||
func (c *speechcraft) Add(ctx context.Context, req *dto.AddSpeechcraftReq) (res *dto.AddSpeechcraftRes, err error) {
|
||||
res, err = service.Speechcraft.Add(ctx, req)
|
||||
return
|
||||
}
|
||||
|
||||
// Update 更新话术
|
||||
// 参数: req - 更新话术请求,包含话术ID和待更新字段
|
||||
// 返回: res - 空响应(成功则err为nil)
|
||||
// 功能: 更新话术内容并同步到RAGFlow
|
||||
func (c *speechcraft) Update(ctx context.Context, req *dto.UpdateSpeechcraftReq) (res *beans.ResponseEmpty, err error) {
|
||||
err = service.Speechcraft.Update(ctx, req)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete 删除话术
|
||||
// 参数: req - 删除话术请求,包含话术ID
|
||||
// 返回: res - 空响应(成功则err为nil)
|
||||
// 功能: 逻辑删除话术记录并从RAGFlow移除
|
||||
func (c *speechcraft) Delete(ctx context.Context, req *dto.DeleteSpeechcraftReq) (res *beans.ResponseEmpty, err error) {
|
||||
err = service.Speechcraft.Delete(ctx, req)
|
||||
return
|
||||
}
|
||||
|
||||
// List 获取话术列表
|
||||
// 参数: req - 列表查询请求,支持分页、关键词搜索
|
||||
// 返回: res - 话术列表及分页信息
|
||||
// 功能: 分页查询话术记录,支持按标题、内容搜索
|
||||
func (c *speechcraft) List(ctx context.Context, req *dto.ListSpeechcraftReq) (res *dto.ListSpeechcraftRes, err error) {
|
||||
res, err = service.Speechcraft.List(ctx, req)
|
||||
return
|
||||
}
|
||||
|
||||
// Bind 绑定话术到客服账号
|
||||
// 参数: req - 绑定请求,包含话术ID和客服账号列表
|
||||
// 返回: res - 绑定结果,包含成功和失败的账号信息
|
||||
// 功能: 将话术绑定到指定客服账号,更新账号的RAGFlow配置
|
||||
func (c *speechcraft) Bind(ctx context.Context, req *dto.BindSpeechcraftReq) (res *dto.BindSpeechcraftRes, err error) {
|
||||
res, err = service.Speechcraft.BindToCustomerServices(ctx, req)
|
||||
return
|
||||
}
|
||||
|
||||
// Unbind 解绑话术
|
||||
// 参数: req - 解绑请求,包含话术ID和客服账号
|
||||
// 返回: res - 解绑结果信息
|
||||
// 功能: 从客服账号解绑指定话术,更新RAGFlow配置
|
||||
func (c *speechcraft) Unbind(ctx context.Context, req *dto.UnbindSpeechcraftReq) (res *dto.UnbindSpeechcraftRes, err error) {
|
||||
res, err = service.Speechcraft.UnbindFromCustomerService(ctx, req)
|
||||
return
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
// Package controller - Webhook控制器
|
||||
// 功能:接收平台(小红书、抖音)的webhook消息
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/model/dto"
|
||||
"customer-server/service"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
var Webhook = new(webhook)
|
||||
|
||||
type webhook struct{}
|
||||
|
||||
// Receive 接收Webhook消息
|
||||
// 参数: req - Webhook消息请求,包含平台、用户ID、内容等
|
||||
// 返回: res - 处理结果
|
||||
// 功能: 接收来自各平台的Webhook消息推送,统一处理后推送到Redis Stream
|
||||
func (c *webhook) Receive(ctx context.Context, req *dto.WebhookReceiveReq) (res *dto.WebhookReceiveRes, err error) {
|
||||
res, err = service.Webhook.Receive(ctx, req)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify 平台回调验证
|
||||
// 参数: req - 回调请求,包含echostr等
|
||||
// 返回: res - 处理结果
|
||||
// 功能: 验证平台回调请求,返回echostr
|
||||
func (c *webhook) Verify(ctx context.Context, req *dto.WebhookCallbackReq) (res *dto.WebhookCallbackRes, err error) {
|
||||
r := g.RequestFromCtx(ctx)
|
||||
// 直接返回 echostr(微信/抖音等平台验证方式)
|
||||
r.Response.Write(req.Echostr)
|
||||
return
|
||||
}
|
||||
|
||||
// History 查询对话记录
|
||||
func (c *webhook) History(ctx context.Context, req *dto.ConversationListReq) (res *dto.ConversationListRes, err error) {
|
||||
res, err = service.Webhook.GetHistory(ctx, req)
|
||||
return
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
// Package controller - WebSocket控制器
|
||||
// 功能:WebSocket连接管理、实时消息推送
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/model/dto"
|
||||
"customer-server/service"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
var Websocket = new(websocket)
|
||||
|
||||
type websocket struct{}
|
||||
|
||||
// Connect WebSocket连接
|
||||
// 参数: req - WebSocket连接请求,包含用户ID和平台信息
|
||||
// 返回: res - 连接结果(实际通过WebSocket协议通信)
|
||||
// 功能: 升级HTTP连接为WebSocket,建立实时通信通道
|
||||
func (c *websocket) Connect(ctx context.Context, req *dto.WebSocketConnectReq) (res *dto.WebSocketConnectRes, err error) {
|
||||
r := g.RequestFromCtx(ctx)
|
||||
err = service.WebSocket.Connect(ctx, r, req.UserId, req.Platform)
|
||||
return
|
||||
}
|
||||
|
||||
// Send 发送消息到Redis Stream
|
||||
// 参数: req - 发送消息请求,包含用户ID、内容等
|
||||
// 返回: res - 发送结果,包含消息ID
|
||||
// 功能: 将用户消息推送到Redis Stream,由RAGFlow处理器消费
|
||||
func (c *websocket) Send(ctx context.Context, req *dto.WebSocketSendReq) (res *dto.WebSocketSendRes, err error) {
|
||||
res, err = service.WebSocket.Send(ctx, req)
|
||||
return
|
||||
}
|
||||
|
||||
// Broadcast 广播消息给所有连接
|
||||
// 参数: req - 广播请求,包含广播内容
|
||||
// 返回: res - 广播结果(空响应)
|
||||
// 功能: 向所有在线WebSocket连接广播消息
|
||||
func (c *websocket) Broadcast(ctx context.Context, req *dto.WebSocketBroadcastReq) (res *dto.WebSocketBroadcastRes, err error) {
|
||||
service.WebSocket.Broadcast(ctx, req.Content)
|
||||
return
|
||||
}
|
||||
|
||||
// Online 获取在线用户
|
||||
// 参数: req - 查询请求(无参数)
|
||||
// 返回: res - 在线用户列表
|
||||
// 功能: 查询当前所有WebSocket在线用户信息
|
||||
func (c *websocket) Online(ctx context.Context, req *dto.WebSocketOnlineReq) (res *dto.WebSocketOnlineRes, err error) {
|
||||
res = service.WebSocket.GetOnlineUsers()
|
||||
return
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/model/dto"
|
||||
"customer-server/service"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
var XiaohongshuController = new(xiaohongshuController)
|
||||
|
||||
type xiaohongshuController struct{}
|
||||
|
||||
// BindAccount 绑定小红书账户
|
||||
// 参数: req - 绑定账户请求,包含账户信息和授权码
|
||||
// 返回: res - 处理结果
|
||||
// 功能: 处理小红书账户绑定,保存授权码到客服账号
|
||||
func (c *xiaohongshuController) BindAccount(ctx context.Context, req *XhsBindAccountReq) (res *dto.XhsCommonRes, err error) {
|
||||
if err = service.Xiaohongshu.HandleBindAccount(ctx, &req.XhsBindAccountReq); err != nil {
|
||||
return
|
||||
}
|
||||
res = &dto.XhsCommonRes{Code: 0, Msg: "成功", Success: true}
|
||||
return
|
||||
}
|
||||
|
||||
// UnbindAccount 解绑小红书账户
|
||||
// 参数: req - 解绑账户请求,包含账户信息和授权码
|
||||
// 返回: res - 处理结果
|
||||
// 功能: 处理小红书账户解绑,删除授权码从客服账号
|
||||
func (c *xiaohongshuController) UnbindAccount(ctx context.Context, req *XhsUnbindAccountReq) (res *dto.XhsCommonRes, err error) {
|
||||
if err = service.Xiaohongshu.HandleUnbindAccount(ctx, &req.XhsUnbindAccountReq); err != nil {
|
||||
return
|
||||
}
|
||||
res = &dto.XhsCommonRes{Code: 0, Msg: "成功", Success: true}
|
||||
return
|
||||
}
|
||||
|
||||
// BindKosUser 绑定小红书KOS用户
|
||||
// 参数: req - KOS绑定请求,包含绑定状态和token
|
||||
// 返回: res - 处理结果
|
||||
// 功能: 处理小红书KOS授权回调,保存授权token到客服账号
|
||||
func (c *xiaohongshuController) BindKosUser(ctx context.Context, req *XhsBindKosUserReq) (res *dto.XhsCommonRes, err error) {
|
||||
g.Log().Infof(ctx, "[小红书] 收到KOS绑定通知: %s", req.Content)
|
||||
res = &dto.XhsCommonRes{Code: 0, Msg: "成功", Success: true}
|
||||
return
|
||||
}
|
||||
|
||||
// ReceiveMessage 接收小红书消息
|
||||
// 参数: req - 消息请求,包含消息内容和用户信息
|
||||
// 返回: res - 处理结果
|
||||
// 功能: 接收小红书消息,自动回复或转发到RAGFlow处理
|
||||
func (c *xiaohongshuController) ReceiveMessage(ctx context.Context, req *XhsReceiveMessageReq) (res *dto.XhsCommonRes, err error) {
|
||||
if err = service.Xiaohongshu.HandleReceiveMessage(ctx, &req.XhsReceiveMessageReq); err != nil {
|
||||
return
|
||||
}
|
||||
res = &dto.XhsCommonRes{Code: 0, Msg: "成功", Success: true}
|
||||
return
|
||||
}
|
||||
|
||||
// IntentCommentPush 接收意向评论推送
|
||||
// 参数: req - 评论推送请求,包含评论ID、内容、用户信息
|
||||
// 返回: res - 处理结果
|
||||
// 功能: 接收小红书意向评论,自动回复或转发到RAGFlow处理
|
||||
func (c *xiaohongshuController) IntentCommentPush(ctx context.Context, req *XhsIntentCommentPushReq) (res *dto.XhsCommonRes, err error) {
|
||||
g.Log().Infof(ctx, "[小红书] 收到意向评论推送: commentId=%s, content=%s", req.CommentId, req.CommentContent)
|
||||
res = &dto.XhsCommonRes{Code: 0, Msg: "成功", Success: true}
|
||||
return
|
||||
}
|
||||
|
||||
// PushLead 接收留资推送
|
||||
// 参数: req - 留资推送请求,包含用户留资信息
|
||||
// 返回: res - 处理结果
|
||||
// 功能: 接收小红书用户留资信息,记录到系统并触发后续流程
|
||||
func (c *xiaohongshuController) PushLead(ctx context.Context, req *XhsPushLeadReq) (res *dto.XhsCommonRes, err error) {
|
||||
g.Log().Infof(ctx, "[小红书] 收到留资推送: userId=%s, pushType=%d", req.UserId, req.PushType)
|
||||
res = &dto.XhsCommonRes{Code: 0, Msg: "成功", Success: true}
|
||||
return
|
||||
}
|
||||
|
||||
// ==================== 请求结构(路由元数据) ====================
|
||||
|
||||
type XhsBindAccountReq struct {
|
||||
g.Meta `path:"/api/open/im/third/bind_account" method:"post" tags:"Xiaohongshu" summary:"小红书绑定账户通知"`
|
||||
dto.XhsBindAccountReq
|
||||
}
|
||||
|
||||
type XhsUnbindAccountReq struct {
|
||||
g.Meta `path:"/api/open/im/third/unbind_account" method:"post" tags:"Xiaohongshu" summary:"小红书解绑账户通知"`
|
||||
dto.XhsUnbindAccountReq
|
||||
}
|
||||
|
||||
type XhsBindKosUserReq struct {
|
||||
g.Meta `path:"/api/open/im/auth/bind_user/event" method:"post" tags:"Xiaohongshu" summary:"小红书KOS绑定事件"`
|
||||
dto.XhsBindKosUserReq
|
||||
}
|
||||
|
||||
type XhsReceiveMessageReq struct {
|
||||
g.Meta `path:"/api/open/im/send" method:"post" tags:"Xiaohongshu" summary:"小红书接收消息"`
|
||||
dto.XhsReceiveMessageReq
|
||||
}
|
||||
|
||||
type XhsIntentCommentPushReq struct {
|
||||
g.Meta `path:"/api/open/intent/comment" method:"post" tags:"Xiaohongshu" summary:"小红书意向评论推送"`
|
||||
dto.XhsIntentCommentPushReq
|
||||
}
|
||||
|
||||
type XhsPushLeadReq struct {
|
||||
g.Meta `path:"/api/open/im/push_lead" method:"post" tags:"Xiaohongshu" summary:"小红书留资推送"`
|
||||
dto.XhsPushLeadReq
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/consts/public"
|
||||
"customer-server/model/dto"
|
||||
"customer-server/model/entity"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/db/gfdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
var Account = new(account)
|
||||
|
||||
type account struct{}
|
||||
|
||||
func (d *account) Insert(ctx context.Context, req *dto.AddAccountReq) (id int64, err error) {
|
||||
var e *entity.Account
|
||||
if err = gconv.Struct(req, &e); err != nil {
|
||||
return
|
||||
}
|
||||
result, err := gfdb.DB(ctx).Model(ctx, public.TableNameAccount).Insert(e)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return result.LastInsertId()
|
||||
}
|
||||
|
||||
func (d *account) Update(ctx context.Context, req *dto.UpdateAccountReq) (rows int64, err error) {
|
||||
r, err := gfdb.DB(ctx).Model(ctx, public.TableNameAccount).Data(&req).Where(entity.AccountCol.Id, req.Id).OmitEmpty().Update()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return r.RowsAffected()
|
||||
}
|
||||
|
||||
func (d *account) Delete(ctx context.Context, req *dto.DeleteAccountReq) (rows int64, err error) {
|
||||
r, err := gfdb.DB(ctx).Model(ctx, public.TableNameAccount).Where(entity.AccountCol.Id, req.Id).Delete()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return r.RowsAffected()
|
||||
}
|
||||
|
||||
func (d *account) Count(ctx context.Context, req *dto.ListAccountReq) (count int, err error) {
|
||||
count, err = gfdb.DB(ctx).Model(ctx, public.TableNameAccount).OmitEmpty().Where(entity.AccountCol.AccountCode, req.AccountCode).Count()
|
||||
return
|
||||
}
|
||||
|
||||
// GetById 根据ID查询客服账号
|
||||
func (d *account) GetById(ctx context.Context, req *dto.GetAccountReq, fields ...string) (res *entity.Account, err error) {
|
||||
r, err := gfdb.DB(ctx).Model(ctx, public.TableNameAccount).Where(entity.AccountCol.Id, req.Id).Fields(fields).One()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = r.Struct(&res)
|
||||
return
|
||||
}
|
||||
|
||||
// List 获取客服账号列表
|
||||
func (d *account) List(ctx context.Context, req *dto.ListAccountReq, fields ...string) (res []*entity.Account, total int, err error) {
|
||||
model := gfdb.DB(ctx).Model(ctx, public.TableNameAccount).Fields(fields).OmitEmpty()
|
||||
if !g.IsEmpty(req.Keyword) {
|
||||
model.WhereLike(entity.AccountCol.AccountName, "%"+req.Keyword+"%")
|
||||
}
|
||||
model.Where(entity.AccountCol.AccountCode, req.AccountCode)
|
||||
model.Where(entity.AccountCol.Status, req.Status)
|
||||
model.Where(entity.AccountCol.Platform, req.Platform)
|
||||
model.OrderDesc(entity.AccountCol.CreatedAt)
|
||||
if req.Page != nil {
|
||||
model.Page(int(req.Page.PageNum), int(req.Page.PageSize))
|
||||
}
|
||||
r, total, err := model.AllAndCount(false)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = r.Structs(&res)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// GetByAccountCode 根据客服账号编码查询(不带租户id)
|
||||
func (d *account) GetByAccountCode(ctx context.Context, req *dto.GetByAccountCodeReq, fields ...string) (res *entity.Account, err error) {
|
||||
r, err := gfdb.DB(ctx).Model(ctx, public.TableNameAccount).NoTenantId(ctx).Where(entity.AccountCol.AccountCode, req.AccountCode).Fields(fields).One()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = r.Struct(&res)
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/consts/public"
|
||||
"customer-server/model/dto"
|
||||
"customer-server/model/entity"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/db/gfdb"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
var AccountUserDialog = new(accountUserDialog)
|
||||
|
||||
type accountUserDialog struct{}
|
||||
|
||||
func (d *accountUserDialog) Insert(ctx context.Context, req *dto.AddAccountUserDialogReq) (id int64, err error) {
|
||||
var e *entity.AccountUserDialog
|
||||
if err = gconv.Struct(req, &e); err != nil {
|
||||
return
|
||||
}
|
||||
result, err := gfdb.DB(ctx).Model(ctx, public.TableNameAccountUserDialog).Insert(e)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return result.LastInsertId()
|
||||
}
|
||||
|
||||
func (d *accountUserDialog) Update(ctx context.Context, req *dto.UpdateAccountUserDialogReq) (rows int64, err error) {
|
||||
r, err := gfdb.DB(ctx).Model(ctx, public.TableNameAccountUserDialog).Data(&req).Where(entity.AccountUserDialogCol.Id, req.Id).OmitEmpty().
|
||||
Data(entity.AccountUserDialogCol.DialogCount, &gdb.Counter{
|
||||
Field: entity.AccountUserDialogCol.DialogCount,
|
||||
Value: gconv.Float64(req.DialogCount),
|
||||
}).Update()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return r.RowsAffected()
|
||||
}
|
||||
|
||||
func (d *accountUserDialog) Delete(ctx context.Context, req *dto.DeleteAccountUserDialogReq) (rows int64, err error) {
|
||||
r, err := gfdb.DB(ctx).Model(ctx, public.TableNameAccountUserDialog).Where(entity.AccountUserDialogCol.Id, req.Id).Delete()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return r.RowsAffected()
|
||||
}
|
||||
|
||||
func (d *accountUserDialog) Get(ctx context.Context, req *dto.GetAccountUserDialogReq) (res *entity.AccountUserDialog, err error) {
|
||||
r, err := gfdb.DB(ctx).Model(ctx, public.TableNameAccountUserDialog).OmitEmpty().
|
||||
Where(entity.AccountUserDialogCol.UserId, req.UserId).
|
||||
Where(entity.AccountUserDialogCol.AccountId, req.AccountId).
|
||||
One()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = gconv.Struct(r, &res)
|
||||
return
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/model/entity"
|
||||
"time"
|
||||
|
||||
"gitea.com/red-future/common/db/mongo"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// archive 归档 DAO
|
||||
type archive struct{}
|
||||
|
||||
// Archive 归档 DAO 单例
|
||||
var Archive = new(archive)
|
||||
|
||||
// CopyToTempByRange 将指定时间范围的数据复制到临时表
|
||||
// startTime: 开始时间(包含),endTime: 结束时间(不包含)
|
||||
func (d *archive) CopyToTempByRange(ctx context.Context, startTime, endTime time.Time) (count int64, err error) {
|
||||
db := mongo.GetDB()
|
||||
|
||||
// 查询指定时间范围的数据
|
||||
filter := bson.M{
|
||||
"createdAt": bson.M{
|
||||
"$gte": startTime,
|
||||
"$lt": endTime,
|
||||
},
|
||||
"isDeleted": false,
|
||||
}
|
||||
|
||||
cursor, err := db.Collection(entity.ConversationCollection).Find(ctx, filter)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
// 批量插入临时表
|
||||
batchSize := g.Cfg().MustGet(ctx, "archive.mongoBatchSize", 1000).Int()
|
||||
var docs []interface{}
|
||||
for cursor.Next(ctx) {
|
||||
var conv entity.Conversation
|
||||
if err = cursor.Decode(&conv); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为临时表结构
|
||||
temp := entity.ConversationArchiveTemp{
|
||||
MongoBaseDO: conv.MongoBaseDO,
|
||||
UserId: conv.UserId,
|
||||
Platform: conv.Platform,
|
||||
SessionId: conv.SessionId,
|
||||
Question: conv.Question,
|
||||
Answer: conv.Answer,
|
||||
MessageId: conv.MessageId,
|
||||
MsgTime: conv.MsgTime,
|
||||
OriginalId: conv.Id.Hex(), // 保存原始 ID
|
||||
}
|
||||
// 清空 ID,让 MongoDB 自动生成新 ID
|
||||
temp.Id = nil
|
||||
docs = append(docs, temp)
|
||||
|
||||
// 批量插入
|
||||
if len(docs) >= batchSize {
|
||||
if _, err = db.Collection(entity.ConversationArchiveTempCollection).InsertMany(ctx, docs); err != nil {
|
||||
return
|
||||
}
|
||||
count += int64(len(docs))
|
||||
docs = docs[:0]
|
||||
}
|
||||
}
|
||||
|
||||
// 插入剩余数据
|
||||
if len(docs) > 0 {
|
||||
if _, err = db.Collection(entity.ConversationArchiveTempCollection).InsertMany(ctx, docs); err != nil {
|
||||
return
|
||||
}
|
||||
count += int64(len(docs))
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// DeleteByTempIds 根据临时表中的 originalId 删除原表数据
|
||||
func (d *archive) DeleteByTempIds(ctx context.Context) (count int64, err error) {
|
||||
db := mongo.GetDB()
|
||||
|
||||
// 从临时表获取所有 originalId
|
||||
cursor, err := db.Collection(entity.ConversationArchiveTempCollection).Find(ctx, bson.M{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
var ids []bson.ObjectID
|
||||
for cursor.Next(ctx) {
|
||||
var temp entity.ConversationArchiveTemp
|
||||
if err = cursor.Decode(&temp); err != nil {
|
||||
return
|
||||
}
|
||||
if oid, parseErr := bson.ObjectIDFromHex(temp.OriginalId); parseErr == nil {
|
||||
ids = append(ids, oid)
|
||||
}
|
||||
|
||||
// 每 1000 条批量删除一次
|
||||
if len(ids) >= 1000 {
|
||||
result, delErr := db.Collection(entity.ConversationCollection).DeleteMany(ctx, bson.M{
|
||||
"_id": bson.M{"$in": ids},
|
||||
})
|
||||
if delErr != nil {
|
||||
err = delErr
|
||||
return
|
||||
}
|
||||
count += result.DeletedCount
|
||||
ids = ids[:0]
|
||||
}
|
||||
}
|
||||
|
||||
// 删除剩余数据
|
||||
if len(ids) > 0 {
|
||||
result, delErr := db.Collection(entity.ConversationCollection).DeleteMany(ctx, bson.M{
|
||||
"_id": bson.M{"$in": ids},
|
||||
})
|
||||
if delErr != nil {
|
||||
err = delErr
|
||||
return
|
||||
}
|
||||
count += result.DeletedCount
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// GetTempData 获取临时表数据(用于写入 ES)
|
||||
func (d *archive) GetTempData(ctx context.Context) (data []*entity.ConversationArchiveTemp, err error) {
|
||||
db := mongo.GetDB()
|
||||
|
||||
cursor, err := db.Collection(entity.ConversationArchiveTempCollection).Find(ctx, bson.M{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
err = cursor.All(ctx, &data)
|
||||
return
|
||||
}
|
||||
|
||||
// DropTempCollection 删除临时表
|
||||
func (d *archive) DropTempCollection(ctx context.Context) (err error) {
|
||||
return mongo.GetDB().Collection(entity.ConversationArchiveTempCollection).Drop(ctx)
|
||||
}
|
||||
|
||||
// CountTemp 统计临时表记录数
|
||||
func (d *archive) CountTemp(ctx context.Context) (count int64, err error) {
|
||||
return mongo.GetDB().Collection(entity.ConversationArchiveTempCollection).CountDocuments(ctx, bson.M{})
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/model/entity"
|
||||
|
||||
"gitea.com/red-future/common/db/mongo"
|
||||
|
||||
"gitea.com/red-future/common/redis"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
var Conversation = new(conversation)
|
||||
|
||||
type conversation struct{}
|
||||
|
||||
// BatchInsert 批量插入对话记录
|
||||
func (d *conversation) BatchInsert(ctx context.Context, list []*entity.Conversation) (err error) {
|
||||
if len(list) == 0 {
|
||||
return
|
||||
}
|
||||
now := gtime.Now().Time
|
||||
docs := make([]interface{}, 0, len(list))
|
||||
for _, data := range list {
|
||||
docs = append(docs, bson.M{
|
||||
"userId": data.UserId,
|
||||
"platform": data.Platform,
|
||||
"sessionId": data.SessionId,
|
||||
"question": data.Question,
|
||||
"answer": data.Answer,
|
||||
"messageId": data.MessageId,
|
||||
"msgTime": data.MsgTime,
|
||||
"tenantId": data.TenantId,
|
||||
"creator": "system",
|
||||
"createdAt": now,
|
||||
"updater": "system",
|
||||
"updatedAt": now,
|
||||
"isDeleted": false,
|
||||
})
|
||||
}
|
||||
_, err = mongo.GetDB().Collection(entity.ConversationCollection).InsertMany(ctx, docs)
|
||||
return
|
||||
}
|
||||
|
||||
// UpsertByMessageId 幂等插入对话记录(使用 message_id 做唯一键,防止重复消费)
|
||||
func (d *conversation) UpsertByMessageId(ctx context.Context, data *entity.Conversation) (inserted bool, err error) {
|
||||
filter := bson.M{"messageId": data.MessageId}
|
||||
now := gtime.Now().Time
|
||||
|
||||
update := bson.M{
|
||||
"$setOnInsert": bson.M{
|
||||
"userId": data.UserId,
|
||||
"platform": data.Platform,
|
||||
"sessionId": data.SessionId,
|
||||
"question": data.Question,
|
||||
"answer": data.Answer,
|
||||
"messageId": data.MessageId,
|
||||
"msgTime": data.MsgTime,
|
||||
"creator": "system",
|
||||
"createdAt": now,
|
||||
"tenantId": data.TenantId, // 使用传入的租户ID
|
||||
"isDeleted": false,
|
||||
},
|
||||
"$set": bson.M{
|
||||
"updater": "system",
|
||||
"updatedAt": now,
|
||||
},
|
||||
}
|
||||
|
||||
opts := options.UpdateOne().SetUpsert(true)
|
||||
result, err := mongo.GetDB().Collection(entity.ConversationCollection).UpdateOne(ctx, filter, update, opts)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// UpsertedCount > 0 表示是新插入,否则是已存在(幂等跳过)
|
||||
inserted = result.UpsertedCount > 0
|
||||
return
|
||||
}
|
||||
|
||||
// FindByUserId 根据用户ID查询对话记录
|
||||
func (d *conversation) FindByUserId(ctx context.Context, userId string, limit int64) (list []*entity.Conversation, err error) {
|
||||
filter := bson.M{"userId": userId, "isDeleted": false}
|
||||
opts := options.Find().SetSort(bson.D{{Key: "msgTime", Value: -1}}).SetLimit(limit)
|
||||
|
||||
cursor, err := mongo.GetDB().Collection(entity.ConversationCollection).Find(ctx, filter, opts)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
err = cursor.All(ctx, &list)
|
||||
return
|
||||
}
|
||||
|
||||
// FindBySessionId 根据 Session ID 查询对话记录
|
||||
func (d *conversation) FindBySessionId(ctx context.Context, sessionId string) (list []*entity.Conversation, err error) {
|
||||
filter := bson.M{"sessionId": sessionId, "isDeleted": false}
|
||||
opts := options.Find().SetSort(bson.D{{Key: "msgTime", Value: 1}})
|
||||
|
||||
cursor, err := mongo.GetDB().Collection(entity.ConversationCollection).Find(ctx, filter, opts)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
err = cursor.All(ctx, &list)
|
||||
return
|
||||
}
|
||||
|
||||
// GetRecentHistory 获取用户最近 N 轮历史对话(用于上下文注入)
|
||||
// 返回 redis.HistoryMessage 切片,按时间正序排列
|
||||
func (d *conversation) GetRecentHistory(ctx context.Context, userId string, limit int64) (history []redis.HistoryMessage, err error) {
|
||||
filter := bson.M{"userId": userId, "isDeleted": false}
|
||||
// 先按时间倒序取最近 N 条,再反转为正序
|
||||
opts := options.Find().SetSort(bson.D{{Key: "msgTime", Value: -1}}).SetLimit(limit)
|
||||
|
||||
cursor, err := mongo.GetDB().Collection(entity.ConversationCollection).Find(ctx, filter, opts)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
var list []*entity.Conversation
|
||||
if err = cursor.All(ctx, &list); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 反转为时间正序
|
||||
history = make([]redis.HistoryMessage, len(list))
|
||||
for i, conv := range list {
|
||||
history[len(list)-1-i] = redis.HistoryMessage{
|
||||
Question: conv.Question,
|
||||
Answer: conv.Answer,
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/model/dto"
|
||||
"customer-server/model/entity"
|
||||
"fmt"
|
||||
|
||||
"gitea.com/red-future/common/beans"
|
||||
"gitea.com/red-future/common/db/mongo"
|
||||
"gitea.com/red-future/common/redis"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
var CustomerServiceAccount = new(customerServiceAccount)
|
||||
|
||||
type customerServiceAccount struct{}
|
||||
|
||||
// FindByAccountName 根据accountName查询
|
||||
// 使用 MongoDAO(不需要token验证)
|
||||
func (d *customerServiceAccount) FindByAccountName(ctx context.Context, accountName string) (account *entity.CustomerServiceAccount, err error) {
|
||||
filter := bson.M{"accountName": accountName, "isDeleted": false}
|
||||
|
||||
var result entity.CustomerServiceAccount
|
||||
err = MongoDAO.FindOne(ctx, filter, &result, entity.CustomerServiceAccountCollection)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 如果未找到记录,result 是零值
|
||||
if result.Id.IsZero() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
account = &result
|
||||
return
|
||||
}
|
||||
|
||||
// Insert 插入客服账号
|
||||
func (d *customerServiceAccount) Insert(ctx context.Context, data *entity.CustomerServiceAccount) (err error) {
|
||||
// 统一使用commonmongo.DB().Insert,自动清除缓存
|
||||
// service层已经设置了TenantId,commonmongo.DB().Insert不会覆盖已有值
|
||||
ids, err := mongo.DB().Insert(ctx, []interface{}{data}, entity.CustomerServiceAccountCollection)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if len(ids) > 0 {
|
||||
if oid, ok := ids[0].(bson.ObjectID); ok {
|
||||
data.Id = &oid // 取地址赋值给指针类型
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Update 更新客服账号
|
||||
func (d *customerServiceAccount) Update(ctx context.Context, req *dto.UpdateCustomerServiceAccountReq) (err error) {
|
||||
objectId, err := bson.ObjectIDFromHex(req.Id)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
filter := bson.M{"_id": objectId}
|
||||
|
||||
// 如果accountName变更,需要清理旧的缓存
|
||||
if !g.IsEmpty(req.AccountName) {
|
||||
// 先查出旧的accountName
|
||||
var oldAccount entity.CustomerServiceAccount
|
||||
if findErr := MongoDAO.FindOne(ctx, filter, &oldAccount, entity.CustomerServiceAccountCollection); findErr == nil {
|
||||
// 清理旧accountName的缓存
|
||||
oldCacheKey := fmt.Sprintf("tenant:account:%s", oldAccount.AccountName)
|
||||
redis.RedisClient().Del(ctx, oldCacheKey)
|
||||
}
|
||||
}
|
||||
|
||||
updateFields := bson.M{}
|
||||
if !g.IsEmpty(req.AccountName) {
|
||||
updateFields["accountName"] = req.AccountName
|
||||
}
|
||||
if !g.IsEmpty(req.Platform) {
|
||||
updateFields["platform"] = req.Platform
|
||||
}
|
||||
if req.SelfIdentity != nil {
|
||||
updateFields["selfIdentity"] = *req.SelfIdentity
|
||||
}
|
||||
|
||||
// 如果有字段需要更新,则执行 MongoDB 更新操作
|
||||
if len(updateFields) > 0 {
|
||||
_, err = mongo.DB().Update(ctx, filter, bson.M{"$set": updateFields}, entity.CustomerServiceAccountCollection)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Delete 软删除客服账号
|
||||
func (d *customerServiceAccount) Delete(ctx context.Context, id string) (err error) {
|
||||
objectId, err := bson.ObjectIDFromHex(id)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
filter := bson.M{"_id": objectId, "isDeleted": false}
|
||||
|
||||
// 删除前先查出accountName,清理缓存
|
||||
var account entity.CustomerServiceAccount
|
||||
if findErr := MongoDAO.FindOne(ctx, filter, &account, entity.CustomerServiceAccountCollection); findErr == nil {
|
||||
cacheKey := fmt.Sprintf("tenant:account:%s", account.AccountName)
|
||||
redis.RedisClient().Del(ctx, cacheKey)
|
||||
}
|
||||
|
||||
update := bson.M{"$set": bson.M{"isDeleted": true, "updatedAt": gtime.Now().Time}}
|
||||
_, err = mongo.DB().Update(ctx, filter, update, entity.CustomerServiceAccountCollection)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "删除客服账号失败")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// ToggleStatus 切换客服账号状态(1 启用,0 禁用)
|
||||
func (d *customerServiceAccount) ToggleStatus(ctx context.Context, req *dto.ToggleCustomerServiceAccountStatusReq) (err error) {
|
||||
objectId, err := bson.ObjectIDFromHex(req.Id)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
filter := bson.M{"_id": objectId}
|
||||
|
||||
// 先查出当前状态
|
||||
var account entity.CustomerServiceAccount
|
||||
if err = MongoDAO.FindOne(ctx, filter, &account, entity.CustomerServiceAccountCollection); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 计算新的状态:true->false,false->true(切换禁用状态)
|
||||
newIsDisabled := true
|
||||
if !account.Id.IsZero() && account.IsDisabled {
|
||||
newIsDisabled = false
|
||||
}
|
||||
|
||||
_, err = mongo.DB().Update(ctx, filter, bson.M{"$set": bson.M{"isDisabled": newIsDisabled}}, entity.CustomerServiceAccountCollection)
|
||||
return
|
||||
}
|
||||
|
||||
// buildListFilter 构建列表查询的过滤条件
|
||||
func (d *customerServiceAccount) buildListFilter(req *dto.ListCustomerServiceAccountReq) bson.M {
|
||||
filter := bson.M{}
|
||||
if !g.IsEmpty(req.AccountName) {
|
||||
filter["accountName"] = bson.M{"$regex": req.AccountName, "$options": "i"} // $regex模糊查询,忽略大小写
|
||||
}
|
||||
if req.IsDisabled != nil {
|
||||
filter["isDisabled"] = *req.IsDisabled
|
||||
}
|
||||
if !g.IsEmpty(req.Platform) {
|
||||
filter["platform"] = req.Platform
|
||||
}
|
||||
return filter
|
||||
}
|
||||
|
||||
// checkTotalCount 检查总数
|
||||
func (d *customerServiceAccount) checkTotalCount(ctx context.Context, filter bson.M) (total int64, err error) {
|
||||
total, err = mongo.DB().Count(ctx, filter, entity.CustomerServiceAccountCollection)
|
||||
return
|
||||
}
|
||||
|
||||
// List 获取客服账号列表(包含所有账号,含已禁用账号)
|
||||
func (d *customerServiceAccount) List(ctx context.Context, req *dto.ListCustomerServiceAccountReq) (list []*entity.CustomerServiceAccount, total int64, err error) {
|
||||
// 构建查询过滤条件
|
||||
filter := d.buildListFilter(req)
|
||||
|
||||
// 检查总数
|
||||
total, err = d.checkTotalCount(ctx, filter)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 分页参数处理
|
||||
pageNum := req.PageNum
|
||||
if pageNum <= 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
pageSize := req.PageSize
|
||||
if pageSize <= 0 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
// 使用统一的mongo.DB().Find方法(支持分页和排序)
|
||||
page := &beans.Page{
|
||||
PageNum: int64(pageNum),
|
||||
PageSize: int64(pageSize),
|
||||
}
|
||||
orderBy := []beans.OrderBy{
|
||||
{Field: "createdAt", Order: beans.Desc}, // 按创建时间倒序
|
||||
}
|
||||
|
||||
_, err = mongo.DB().Find(ctx, filter, &list, entity.CustomerServiceAccountCollection, page, orderBy)
|
||||
return
|
||||
}
|
||||
-167
@@ -1,167 +0,0 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/model/dto"
|
||||
"customer-server/model/entity"
|
||||
|
||||
"gitea.com/red-future/common/beans"
|
||||
"gitea.com/red-future/common/db/mongo"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
var Data = new(data)
|
||||
|
||||
type data struct{}
|
||||
|
||||
// Insert 插入数据
|
||||
func (d *data) Insert(ctx context.Context, data *entity.Data) (err error) {
|
||||
_, err = mongo.DB().Insert(ctx, []interface{}{data}, entity.DataCollection)
|
||||
return
|
||||
}
|
||||
|
||||
// Update 更新数据
|
||||
func (d *data) Update(ctx context.Context, req *dto.UpdateDataReq) (err error) {
|
||||
objectId, err := bson.ObjectIDFromHex(req.Id)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
filter := bson.M{"_id": objectId}
|
||||
|
||||
// 构建动态更新字段
|
||||
updateFields := bson.M{}
|
||||
if !g.IsEmpty(req.CustomerId) {
|
||||
updateFields["customerId"] = req.CustomerId
|
||||
}
|
||||
if !g.IsEmpty(req.AccountName) {
|
||||
updateFields["accountName"] = req.AccountName
|
||||
}
|
||||
if req.IsInbound != nil {
|
||||
updateFields["isInbound"] = *req.IsInbound
|
||||
}
|
||||
if req.IsActive != nil {
|
||||
updateFields["isActive"] = *req.IsActive
|
||||
}
|
||||
if req.IsServed != nil {
|
||||
updateFields["isServed"] = *req.IsServed
|
||||
}
|
||||
if req.HasSentContactCard != nil {
|
||||
updateFields["hasSentContactCard"] = *req.HasSentContactCard
|
||||
}
|
||||
if req.HasSentNameCard != nil {
|
||||
updateFields["hasSentNameCard"] = *req.HasSentNameCard
|
||||
}
|
||||
if req.HasLeftContactInfo != nil {
|
||||
updateFields["hasLeftContactInfo"] = *req.HasLeftContactInfo
|
||||
}
|
||||
|
||||
if len(updateFields) > 0 {
|
||||
_, err = mongo.DB().Update(ctx, filter, bson.M{"$set": updateFields}, entity.DataCollection)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// // Delete 删除数据
|
||||
// func (d *data) Delete(ctx context.Context, req *dto.DeleteDataReq) (err error) {
|
||||
// objectId, err := bson.ObjectIDFromHex(req.Id)
|
||||
// if err != nil {
|
||||
// return
|
||||
// }
|
||||
// filter := bson.M{"_id": objectId}
|
||||
// _, err = mongo.DB().Delete(ctx, filter, d.collection)
|
||||
// return
|
||||
// }
|
||||
|
||||
// buildListFilter 构建列表查询的过滤条件
|
||||
func (d *data) buildListFilter(req *dto.ListDataReq) bson.M {
|
||||
filter := bson.M{}
|
||||
if !g.IsEmpty(req.CustomerId) {
|
||||
filter["customerId"] = req.CustomerId
|
||||
}
|
||||
if !g.IsEmpty(req.AccountName) {
|
||||
filter["accountName"] = *req.AccountName
|
||||
}
|
||||
|
||||
// 处理时间范围筛选
|
||||
if !g.IsEmpty(req.StartDate) || !g.IsEmpty(req.EndDate) {
|
||||
timeFilter := bson.M{}
|
||||
|
||||
// 开始日期:大于等于当天 00:00:00(时间戳秒)
|
||||
if !g.IsEmpty(req.StartDate) {
|
||||
// 将日期字符串转换为时间戳(秒)
|
||||
startTime, err := parseDate(req.StartDate)
|
||||
if err == nil {
|
||||
timeFilter["$gte"] = startTime
|
||||
}
|
||||
}
|
||||
|
||||
// 结束日期:小于等于当天 23:59:59(时间戳秒)
|
||||
if !g.IsEmpty(req.EndDate) {
|
||||
// 将日期字符串转换为时间戳(秒)+ 86399(一天的最后一秒)
|
||||
endTime, err := parseDate(req.EndDate)
|
||||
if err == nil {
|
||||
timeFilter["$lte"] = endTime + 86399 // 加上一天的秒数 - 1
|
||||
}
|
||||
}
|
||||
|
||||
if len(timeFilter) > 0 {
|
||||
filter["sessionStartTime"] = timeFilter
|
||||
}
|
||||
}
|
||||
|
||||
return filter
|
||||
}
|
||||
|
||||
// checkTotalCount 检查总数
|
||||
func (d *data) checkTotalCount(ctx context.Context, filter bson.M) (total int64, err error) {
|
||||
total, err = mongo.DB().Count(ctx, filter, entity.DataCollection)
|
||||
return
|
||||
}
|
||||
|
||||
// List 获取数据列表
|
||||
func (d *data) List(ctx context.Context, req *dto.ListDataReq) (list []*entity.Data, total int64, err error) {
|
||||
// 构建查询过滤条件
|
||||
filter := d.buildListFilter(req)
|
||||
|
||||
// 检查总数
|
||||
total, err = d.checkTotalCount(ctx, filter)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 分页参数处理
|
||||
pageNum := req.PageNum
|
||||
if pageNum <= 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
pageSize := req.PageSize
|
||||
if pageSize <= 0 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
// 使用统一的mongo.DB().Find方法(支持分页和排序)
|
||||
page := &beans.Page{
|
||||
PageNum: int64(pageNum),
|
||||
PageSize: int64(pageSize),
|
||||
}
|
||||
orderBy := []beans.OrderBy{
|
||||
{Field: "sessionStartTime", Order: beans.Desc}, // 按会话开始时间倒序
|
||||
}
|
||||
|
||||
_, err = mongo.DB().Find(ctx, filter, &list, entity.DataCollection, page, orderBy)
|
||||
return
|
||||
}
|
||||
|
||||
// parseDate 将日期字符串(YYYY-MM-DD)转换为时间戳(秒)
|
||||
func parseDate(dateStr string) (int64, error) {
|
||||
// 使用 gtime 解析日期字符串
|
||||
t := gtime.NewFromStr(dateStr)
|
||||
if t == nil {
|
||||
return 0, gerror.New("日期格式错误")
|
||||
}
|
||||
// 返回时间戳(秒)
|
||||
return t.Timestamp(), nil
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/model/dto"
|
||||
"customer-server/model/entity"
|
||||
|
||||
"gitea.com/red-future/common/beans"
|
||||
"gitea.com/red-future/common/db/mongo"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// dataStatistics DAO 单例
|
||||
var DataStatistics = new(dataStatistics)
|
||||
|
||||
type dataStatistics struct{}
|
||||
|
||||
// Insert 插入数据统计
|
||||
func (d *dataStatistics) Insert(ctx context.Context, data *entity.DataStatistics) (err error) {
|
||||
// 如果 ID 为空,生成一个新的 ObjectID
|
||||
if data.Id == nil || data.Id.IsZero() {
|
||||
newId := bson.NewObjectID()
|
||||
data.Id = &newId // 取地址赋值给指针类型
|
||||
}
|
||||
|
||||
// 使用 common/db/mongo.DB().Insert,自动添加 tenantId、creator、updater 等字段
|
||||
// 确保查询时能通过 tenantId 正确过滤数据
|
||||
_, err = mongo.DB().Insert(ctx, []interface{}{data}, entity.DataStatisticsCollection)
|
||||
return
|
||||
}
|
||||
|
||||
// Update 更新数据统计
|
||||
func (d *dataStatistics) Update(ctx context.Context, req *dto.UpdateDataStatisticsReq) (err error) {
|
||||
objectId, err := bson.ObjectIDFromHex(req.Id)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
filter := bson.M{"_id": objectId}
|
||||
|
||||
updateFields := bson.M{}
|
||||
// 使用 gconv 和 gtime 转换日期
|
||||
if !g.IsEmpty(req.Date) {
|
||||
if dateTime := gtime.NewFromStr(req.Date); dateTime != nil {
|
||||
updateFields["date"] = dateTime.Time
|
||||
}
|
||||
}
|
||||
if !g.IsEmpty(req.AccountName) {
|
||||
updateFields["accountName"] = req.AccountName
|
||||
}
|
||||
if !g.IsEmpty(req.CustomerServiceName) {
|
||||
updateFields["customerServiceName"] = req.CustomerServiceName
|
||||
}
|
||||
if !g.IsEmpty(req.CustomerServicePlatform) {
|
||||
updateFields["customerServicePlatform"] = req.CustomerServicePlatform
|
||||
}
|
||||
if req.InboundCount != nil {
|
||||
updateFields["inboundCount"] = *req.InboundCount
|
||||
}
|
||||
if req.ActiveCount != nil {
|
||||
updateFields["activeCount"] = *req.ActiveCount
|
||||
}
|
||||
if req.ServedCount != nil {
|
||||
updateFields["servedCount"] = *req.ServedCount
|
||||
}
|
||||
if req.ContactCardSentCount != nil {
|
||||
updateFields["contactCardSentCount"] = *req.ContactCardSentCount
|
||||
}
|
||||
if req.NameCardSentCount != nil {
|
||||
updateFields["nameCardSentCount"] = *req.NameCardSentCount
|
||||
}
|
||||
if req.LeftContactInfoCount != nil {
|
||||
updateFields["leftContactInfoCount"] = *req.LeftContactInfoCount
|
||||
}
|
||||
if req.ResponseRate30s != nil {
|
||||
updateFields["responseRate30s"] = *req.ResponseRate30s
|
||||
}
|
||||
if req.ResponseRate60s != nil {
|
||||
updateFields["responseRate60s"] = *req.ResponseRate60s
|
||||
}
|
||||
if req.ResponseRate360s != nil {
|
||||
updateFields["responseRate360s"] = *req.ResponseRate360s
|
||||
}
|
||||
|
||||
if len(updateFields) > 0 {
|
||||
_, err = mongo.DB().Update(ctx, filter, bson.M{"$set": updateFields}, entity.DataStatisticsCollection)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// buildListFilter 构建列表查询的过滤条件
|
||||
func (d *dataStatistics) buildListFilter(req *dto.ListDataStatisticsReq) bson.M {
|
||||
filter := bson.M{}
|
||||
|
||||
// 客服平台筛选
|
||||
if !g.IsEmpty(req.CustomerServicePlatform) {
|
||||
filter["customerServicePlatform"] = req.CustomerServicePlatform
|
||||
}
|
||||
|
||||
// 日期范围筛选:支持单独传 StartDate 或 EndDate,或同时传两者
|
||||
// 前端传入字符串格式(YYYY-MM-DD),需要转换为 time.Time 进行比较
|
||||
if !g.IsEmpty(req.StartDate) || !g.IsEmpty(req.EndDate) {
|
||||
dateFilter := bson.M{}
|
||||
if !g.IsEmpty(req.StartDate) {
|
||||
// 使用 gtime 转换,设置为当天 00:00:00
|
||||
if startTime := gtime.NewFromStr(req.StartDate); startTime != nil {
|
||||
dateFilter["$gte"] = startTime.Time
|
||||
}
|
||||
}
|
||||
if !g.IsEmpty(req.EndDate) {
|
||||
// 使用 gtime 转换,设置为当天 23:59:59
|
||||
if endTime := gtime.NewFromStr(req.EndDate + " 23:59:59"); endTime != nil {
|
||||
dateFilter["$lte"] = endTime.Time
|
||||
}
|
||||
}
|
||||
filter["date"] = dateFilter
|
||||
}
|
||||
|
||||
return filter
|
||||
}
|
||||
|
||||
// checkTotalCount 检查总数
|
||||
func (d *dataStatistics) checkTotalCount(ctx context.Context, filter bson.M) (total int64, err error) {
|
||||
total, err = mongo.DB().Count(ctx, filter, entity.DataStatisticsCollection)
|
||||
return
|
||||
}
|
||||
|
||||
// List 获取数据统计列表
|
||||
func (d *dataStatistics) List(ctx context.Context, req *dto.ListDataStatisticsReq) (list []*entity.DataStatistics, total int64, err error) {
|
||||
// 构建查询过滤条件
|
||||
filter := d.buildListFilter(req)
|
||||
|
||||
// 检查总数
|
||||
total, err = d.checkTotalCount(ctx, filter)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
pageNum := req.PageNum
|
||||
if pageNum <= 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
pageSize := req.PageSize
|
||||
if pageSize <= 0 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
// 使用统一的mongo.DB().Find方法(支持分页和排序)
|
||||
page := &beans.Page{
|
||||
PageNum: int64(pageNum),
|
||||
PageSize: int64(pageSize),
|
||||
}
|
||||
orderBy := []beans.OrderBy{
|
||||
{Field: "date", Order: beans.Desc}, // 按日期倒序
|
||||
}
|
||||
|
||||
_, err = mongo.DB().Find(ctx, filter, &list, entity.DataStatisticsCollection, page, orderBy)
|
||||
return
|
||||
}
|
||||
|
||||
// FindAllForExport 查询所有符合条件的数据统计(用于导出,不分页,需要租户过滤)
|
||||
func (d *dataStatistics) FindAllForExport(ctx context.Context, req *dto.ExportDataStatisticsReq) (list []*entity.DataStatistics, err error) {
|
||||
// 构建查询过滤条件(复用 buildListFilter 逻辑)
|
||||
filter := bson.M{}
|
||||
|
||||
// 客服平台筛选
|
||||
if !g.IsEmpty(req.CustomerServicePlatform) {
|
||||
filter["customerServicePlatform"] = req.CustomerServicePlatform
|
||||
}
|
||||
|
||||
// 日期范围筛选
|
||||
if !g.IsEmpty(req.StartDate) || !g.IsEmpty(req.EndDate) {
|
||||
dateFilter := bson.M{}
|
||||
if !g.IsEmpty(req.StartDate) {
|
||||
if startTime := gtime.NewFromStr(req.StartDate); startTime != nil {
|
||||
dateFilter["$gte"] = startTime.Time
|
||||
}
|
||||
}
|
||||
if !g.IsEmpty(req.EndDate) {
|
||||
if endTime := gtime.NewFromStr(req.EndDate + " 23:59:59"); endTime != nil {
|
||||
dateFilter["$lte"] = endTime.Time
|
||||
}
|
||||
}
|
||||
filter["date"] = dateFilter
|
||||
}
|
||||
|
||||
// 使用 mongo.DB().Find 查询,自动添加 tenantId 过滤,确保租户数据隔离
|
||||
// 不分页,设置一个足够大的PageSize
|
||||
page := &beans.Page{
|
||||
PageNum: 1,
|
||||
PageSize: 100000, // 导出场景:设置足够大的PageSize
|
||||
}
|
||||
orderBy := []beans.OrderBy{
|
||||
{Field: "date", Order: beans.Desc}, // 按日期倒序
|
||||
}
|
||||
_, err = mongo.DB().Find(ctx, filter, &list, entity.DataStatisticsCollection, page, orderBy)
|
||||
return list, err
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
commonMongo "gitea.com/red-future/common/db/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// MongoDAO MongoDB原生查询(不需要token验证)
|
||||
var MongoDAO = new(mongoDAO)
|
||||
|
||||
type mongoDAO struct{}
|
||||
|
||||
// FindOne 原生查询单条记录(不需要token验证)
|
||||
// 未找到记录时返回 nil error,调用方需检查 result 是否为零值
|
||||
func (m *mongoDAO) FindOne(ctx context.Context, filter bson.M, result interface{}, collectionName string) error {
|
||||
db := commonMongo.GetDB()
|
||||
collection := db.Collection(collectionName)
|
||||
|
||||
err := collection.FindOne(ctx, filter).Decode(result)
|
||||
if err != nil {
|
||||
if err.Error() == "mongo: no documents in result" {
|
||||
return nil // 未找到记录,返回nil而不是错误
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InsertOne 原生插入单条记录(不需要token验证)
|
||||
func (m *mongoDAO) InsertOne(ctx context.Context, document interface{}, collectionName string) (interface{}, error) {
|
||||
db := commonMongo.GetDB()
|
||||
collection := db.Collection(collectionName)
|
||||
|
||||
result, err := collection.InsertOne(ctx, document)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.InsertedID, nil
|
||||
}
|
||||
|
||||
// UpdateOne 原生更新单条记录(不需要token验证)
|
||||
// 返回 matchedCount(匹配到的记录数)和 modifiedCount(实际修改的记录数)
|
||||
func (m *mongoDAO) UpdateOne(ctx context.Context, filter bson.M, update bson.M, collectionName string) (matchedCount int64, modifiedCount int64, err error) {
|
||||
db := commonMongo.GetDB()
|
||||
collection := db.Collection(collectionName)
|
||||
|
||||
result, err := collection.UpdateOne(ctx, filter, update)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return result.MatchedCount, result.ModifiedCount, nil
|
||||
}
|
||||
|
||||
// UpdateMany 原生批量更新记录(不需要token验证)
|
||||
func (m *mongoDAO) UpdateMany(ctx context.Context, filter bson.M, update bson.M, collectionName string) (matchedCount int64, modifiedCount int64, err error) {
|
||||
db := commonMongo.GetDB()
|
||||
collection := db.Collection(collectionName)
|
||||
|
||||
result, err := collection.UpdateMany(ctx, filter, update)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return result.MatchedCount, result.ModifiedCount, nil
|
||||
}
|
||||
|
||||
// Delete 原生删除记录(不需要token验证,用于回滚操作)
|
||||
func (m *mongoDAO) Delete(ctx context.Context, filter bson.M, collectionName string) error {
|
||||
db := commonMongo.GetDB()
|
||||
collection := db.Collection(collectionName)
|
||||
|
||||
_, err := collection.DeleteOne(ctx, filter)
|
||||
return err
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/model/dto"
|
||||
"customer-server/model/entity"
|
||||
|
||||
"gitea.com/red-future/common/beans"
|
||||
"gitea.com/red-future/common/db/mongo"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
var Product = new(product)
|
||||
|
||||
type product struct{}
|
||||
|
||||
// Insert 插入产品
|
||||
// 注意:mongo.DB().Insert不会自动将生成的ID回写到原始对象
|
||||
// 必须手动从返回的InsertedIDs中提取并赋值给data.Id,否则后续访问data.Id会触发空指针异常
|
||||
func (d *product) Insert(ctx context.Context, data *entity.Product) (id bson.ObjectID, err error) {
|
||||
ids, err := mongo.DB().Insert(ctx, []interface{}{data}, entity.ProductCollection)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// 从返回的ID列表中提取ObjectID并回写到data.Id
|
||||
if len(ids) > 0 {
|
||||
if oid, ok := ids[0].(bson.ObjectID); ok {
|
||||
id = oid
|
||||
data.Id = &oid // 回写ID到原始对象,防止后续访问时空指针异常
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Update 更新产品
|
||||
func (d *product) Update(ctx context.Context, req *dto.UpdateProductReq) (err error) {
|
||||
objectId, err := bson.ObjectIDFromHex(req.Id)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
filter := bson.M{"_id": objectId, "isDeleted": false}
|
||||
|
||||
updateFields := bson.M{}
|
||||
if !g.IsEmpty(req.Name) {
|
||||
updateFields["name"] = req.Name
|
||||
}
|
||||
if !g.IsEmpty(req.Description) {
|
||||
updateFields["description"] = req.Description
|
||||
}
|
||||
// 自动更新时间为当前时间
|
||||
updateFields["updatedAt"] = gtime.Now().Time
|
||||
|
||||
if len(updateFields) > 0 {
|
||||
_, err = mongo.DB().Update(ctx, filter, bson.M{"$set": updateFields}, entity.ProductCollection)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Delete 软删除产品(设置 IsDeleted=true)
|
||||
func (d *product) Delete(ctx context.Context, req *dto.DeleteProductReq) (err error) {
|
||||
objectId, err := bson.ObjectIDFromHex(req.Id)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
filter := bson.M{"_id": objectId, "isDeleted": false}
|
||||
update := bson.M{"$set": bson.M{"isDeleted": true, "updatedAt": gtime.Now().Time}}
|
||||
_, err = mongo.DB().Update(ctx, filter, update, entity.ProductCollection)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "删除产品失败")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// buildListFilter 构建列表查询的过滤条件
|
||||
func (d *product) buildListFilter(req *dto.ListProductReq) bson.M {
|
||||
filter := bson.M{"isDeleted": false}
|
||||
if !g.IsEmpty(req.Name) {
|
||||
filter["name"] = bson.M{"$regex": req.Name}
|
||||
}
|
||||
return filter
|
||||
}
|
||||
|
||||
// checkTotalCount 检查总数
|
||||
func (d *product) checkTotalCount(ctx context.Context, filter bson.M) (total int64, err error) {
|
||||
total, err = mongo.DB().Count(ctx, filter, entity.ProductCollection)
|
||||
return
|
||||
}
|
||||
|
||||
// FindByName 根据名称查询产品(用于去重检查)
|
||||
func (d *product) FindByName(ctx context.Context, name string) (product *entity.Product, err error) {
|
||||
filter := bson.M{
|
||||
"name": name,
|
||||
"isDeleted": false,
|
||||
}
|
||||
err = mongo.DB().FindOne(ctx, filter, &product, entity.ProductCollection)
|
||||
if err != nil {
|
||||
if err.Error() == "mongo: no documents in result" {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// List 获取产品列表(排除已删除)
|
||||
func (d *product) List(ctx context.Context, req *dto.ListProductReq) (list []*entity.Product, total int64, err error) {
|
||||
// 构建查询过滤条件
|
||||
filter := d.buildListFilter(req)
|
||||
|
||||
// 检查总数
|
||||
total, err = d.checkTotalCount(ctx, filter)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 分页参数处理
|
||||
pageNum := req.PageNum
|
||||
if pageNum <= 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
pageSize := req.PageSize
|
||||
if pageSize <= 0 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
// 使用统一的mongo.DB().Find方法(支持分页和排序)
|
||||
page := &beans.Page{
|
||||
PageNum: int64(pageNum),
|
||||
PageSize: int64(pageSize),
|
||||
}
|
||||
orderBy := []beans.OrderBy{
|
||||
{Field: "createdAt", Order: beans.Desc}, // 按创建时间倒序
|
||||
}
|
||||
|
||||
_, err = mongo.DB().Find(ctx, filter, &list, entity.ProductCollection, page, orderBy)
|
||||
return
|
||||
}
|
||||
|
||||
// GetById 根据ID获取产品
|
||||
func (d *product) GetById(ctx context.Context, id string) (product *entity.Product, err error) {
|
||||
objectId, err := bson.ObjectIDFromHex(id)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
filter := bson.M{"_id": objectId, "isDeleted": false}
|
||||
err = mongo.DB().FindOne(ctx, filter, &product, entity.ProductCollection)
|
||||
return
|
||||
}
|
||||
|
||||
// FindAllForExport 查询所有产品用于导出(不分页)
|
||||
func (d *product) FindAllForExport(ctx context.Context, name string) (list []*entity.Product, err error) {
|
||||
filter := bson.M{}
|
||||
if !g.IsEmpty(name) {
|
||||
filter["name"] = bson.M{"$regex": name, "$options": "i"} // 模糊查询,忽略大小写
|
||||
}
|
||||
|
||||
// 使用 mongo.DB().Find(会自动过滤租户和已删除数据)
|
||||
// 导出场景:不分页,设置足够大的PageSize
|
||||
page := &beans.Page{
|
||||
PageNum: 1,
|
||||
PageSize: 100000, // 导出场景:设置足够大的PageSize
|
||||
}
|
||||
orderBy := []beans.OrderBy{
|
||||
{Field: "createdAt", Order: beans.Desc}, // 按创建时间倒序
|
||||
}
|
||||
_, err = mongo.DB().Find(ctx, filter, &list, entity.ProductCollection, page, orderBy)
|
||||
return
|
||||
}
|
||||
|
||||
// UpdateEntity 更新产品实体(用于绑定/解绑/同步等场景)
|
||||
func (d *product) UpdateEntity(ctx context.Context, product *entity.Product) (err error) {
|
||||
filter := bson.M{"_id": product.Id, "isDeleted": false}
|
||||
|
||||
// 将实体转换为bson.M
|
||||
updateDoc := bson.M{}
|
||||
data, _ := bson.Marshal(product)
|
||||
bson.Unmarshal(data, &updateDoc)
|
||||
|
||||
_, err = mongo.DB().Update(ctx, filter, bson.M{"$set": updateDoc}, entity.ProductCollection)
|
||||
return
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/model/entity"
|
||||
|
||||
"gitea.com/red-future/common/db/mongo"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
var RAGFlowConfig = new(ragflowConfig)
|
||||
|
||||
type ragflowConfig struct{}
|
||||
|
||||
// FindByAccountName 根据客服账号名称查询配置(带租户隔离,兼容tenantId类型不一致)
|
||||
func (d *ragflowConfig) FindByAccountName(ctx context.Context, accountName string) (*entity.RAGFlowConfig, error) {
|
||||
// 先查询客服账号获取tenantId
|
||||
account, err := CustomerServiceAccount.FindByAccountName(ctx, accountName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if account == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// 使用accountName + tenantId查询RAGFlow配置(租户隔离)
|
||||
// 先尝试原始类型查询
|
||||
filter := bson.M{"accountName": accountName, "tenantId": account.TenantId, "isDeleted": false}
|
||||
var config entity.RAGFlowConfig
|
||||
err = MongoDAO.FindOne(ctx, filter, &config, entity.RAGFlowConfigCollection)
|
||||
|
||||
// 如果未找到且tenantId可以转为string,尝试用string查询(兼容性处理)
|
||||
if (err != nil || config.Id == nil || config.Id.IsZero()) && account.TenantId != nil {
|
||||
tenantIdStr := gconv.String(account.TenantId)
|
||||
if tenantIdStr != "" {
|
||||
filter = bson.M{"accountName": accountName, "tenantId": tenantIdStr, "isDeleted": false}
|
||||
err = MongoDAO.FindOne(ctx, filter, &config, entity.RAGFlowConfigCollection)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if config.Id.IsZero() {
|
||||
return nil, nil
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// FindDatasetIdByTenant 根据租户ID查询知识库ID(从任意一条RAGFlowConfig记录中获取)
|
||||
func (d *ragflowConfig) FindDatasetIdByTenant(ctx context.Context, tenantId string) (datasetId string, err error) {
|
||||
// 先尝试字符串查询
|
||||
filter := bson.M{"tenantId": tenantId, "isDeleted": false}
|
||||
var config entity.RAGFlowConfig
|
||||
err = MongoDAO.FindOne(ctx, filter, &config, entity.RAGFlowConfigCollection)
|
||||
|
||||
// 如果未找到且tenantId可以转为数字,尝试用数字查询(兼容MongoDB中可能存储为int的情况)
|
||||
if err != nil || config.Id.IsZero() {
|
||||
tenantIdInt := gconv.Int(tenantId)
|
||||
if tenantIdInt > 0 {
|
||||
filter = bson.M{"tenantId": tenantIdInt, "isDeleted": false}
|
||||
err = MongoDAO.FindOne(ctx, filter, &config, entity.RAGFlowConfigCollection)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if config.Id.IsZero() {
|
||||
return "", nil // 未找到记录
|
||||
}
|
||||
return config.DatasetId, nil
|
||||
}
|
||||
|
||||
// Insert 插入配置
|
||||
func (d *ragflowConfig) Insert(ctx context.Context, config *entity.RAGFlowConfig) error {
|
||||
_, err := mongo.DB().Insert(ctx, []interface{}{config}, entity.RAGFlowConfigCollection)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateEntity 更新配置(避免双重token验证冲突)
|
||||
func (d *ragflowConfig) UpdateEntity(ctx context.Context, config *entity.RAGFlowConfig) error {
|
||||
filter := bson.M{"_id": config.Id, "isDeleted": false}
|
||||
|
||||
// 将实体转换为bson.M
|
||||
updateDoc := bson.M{}
|
||||
data, _ := bson.Marshal(config)
|
||||
bson.Unmarshal(data, &updateDoc)
|
||||
|
||||
update := bson.M{"$set": updateDoc}
|
||||
// 使用MongoDAO.UpdateOne(不需要token验证)
|
||||
_, _, err := MongoDAO.UpdateOne(ctx, filter, update, entity.RAGFlowConfigCollection)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "更新RAGFlow配置失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateDocumentIds 更新文档ID列表(避免双重token验证冲突)
|
||||
func (d *ragflowConfig) UpdateDocumentIds(ctx context.Context, accountName string, documentIds []string) error {
|
||||
filter := bson.M{"accountName": accountName, "isDeleted": false}
|
||||
update := bson.M{"$set": bson.M{"documentIds": documentIds}}
|
||||
// 使用MongoDAO.UpdateOne(不需要token验证)
|
||||
_, _, err := MongoDAO.UpdateOne(ctx, filter, update, entity.RAGFlowConfigCollection)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateDatasetIdByTenant 批量更新租户的所有datasetId记录(兼容tenantId类型不一致)
|
||||
func (d *ragflowConfig) UpdateDatasetIdByTenant(ctx context.Context, tenantId, newDatasetId string) error {
|
||||
// 先尝试字符串查询
|
||||
filter := bson.M{"tenantId": tenantId, "isDeleted": false}
|
||||
update := bson.M{"$set": bson.M{"datasetId": newDatasetId}}
|
||||
matchedCount, _, err := MongoDAO.UpdateMany(ctx, filter, update, entity.RAGFlowConfigCollection)
|
||||
|
||||
// 如果未匹配到且tenantId可以转为数字,尝试用数字查询(兼容MongoDB中可能存储为int的情况)
|
||||
if (err != nil || matchedCount == 0) && gconv.Int(tenantId) > 0 {
|
||||
filter = bson.M{"tenantId": gconv.Int(tenantId), "isDeleted": false}
|
||||
matchedCount, _, err = MongoDAO.UpdateMany(ctx, filter, update, entity.RAGFlowConfigCollection)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "批量更新datasetId失败(数字类型尝试)")
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "批量更新datasetId失败")
|
||||
}
|
||||
|
||||
if matchedCount == 0 {
|
||||
return gerror.Newf("未找到租户%s的记录", tenantId)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/consts/public"
|
||||
"customer-server/model/dto"
|
||||
"customer-server/model/entity"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/db/gfdb"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
var ScriptedSpeech = new(scriptedSpeech)
|
||||
|
||||
type scriptedSpeech struct{}
|
||||
|
||||
func (d *scriptedSpeech) Insert(ctx context.Context, req *dto.AddScriptedSpeechReq) (id int64, err error) {
|
||||
var e *entity.ScriptedSpeech
|
||||
if err = gconv.Struct(req, &e); err != nil {
|
||||
return
|
||||
}
|
||||
result, err := gfdb.DB(ctx).Model(ctx, public.TableNameScriptedSpeech).Insert(e)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return result.LastInsertId()
|
||||
}
|
||||
|
||||
func (d *scriptedSpeech) Update(ctx context.Context, req *dto.UpdateScriptedSpeechReq) (rows int64, err error) {
|
||||
r, err := gfdb.DB(ctx).Model(ctx, public.TableNameScriptedSpeech).Data(&req).Where(entity.ScriptedSpeechCol.Id, req.Id).OmitEmpty().Update()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return r.RowsAffected()
|
||||
}
|
||||
|
||||
func (d *scriptedSpeech) Delete(ctx context.Context, req *dto.DeleteScriptedSpeechReq) (rows int64, err error) {
|
||||
r, err := gfdb.DB(ctx).Model(ctx, public.TableNameScriptedSpeech).Where(entity.ScriptedSpeechCol.Id, req.Id).Delete()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return r.RowsAffected()
|
||||
}
|
||||
|
||||
func (d *scriptedSpeech) Count(ctx context.Context, req *dto.ListScriptedSpeechReq) (count int, err error) {
|
||||
count, err = gfdb.DB(ctx).Model(ctx, public.TableNameScriptedSpeech).OmitEmpty().
|
||||
Where(entity.ScriptedSpeechCol.DatasetId, req.DatasetId).
|
||||
Where(entity.ScriptedSpeechCol.SceneType, req.SceneType).
|
||||
Count()
|
||||
return
|
||||
}
|
||||
|
||||
// GetById 根据ID查询预制话术
|
||||
func (d *scriptedSpeech) GetById(ctx context.Context, req *dto.GetScriptedSpeechReq, fields ...string) (res *entity.ScriptedSpeech, err error) {
|
||||
r, err := gfdb.DB(ctx).Model(ctx, public.TableNameScriptedSpeech).Where(entity.ScriptedSpeechCol.Id, req.Id).Fields(fields).One()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = r.Struct(&res)
|
||||
return
|
||||
}
|
||||
|
||||
// GetByDatasetIdAndSceneType 根据数据集ID和场景类型查询预制话术
|
||||
func (d *scriptedSpeech) GetByDatasetIdAndSceneType(ctx context.Context, req *dto.ListScriptedSpeechReq, fields ...string) (res *entity.ScriptedSpeech, err error) {
|
||||
r, err := gfdb.DB(ctx).Model(ctx, public.TableNameScriptedSpeech).Fields(fields).
|
||||
Where(entity.ScriptedSpeechCol.DatasetId, req.DatasetId).
|
||||
Where(entity.ScriptedSpeechCol.SceneType, req.SceneType).
|
||||
One()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = r.Struct(&res)
|
||||
return
|
||||
}
|
||||
|
||||
// List 获取预制话术列表
|
||||
func (d *scriptedSpeech) List(ctx context.Context, req *dto.ListScriptedSpeechReq, fields ...string) (res []*entity.ScriptedSpeech, total int, err error) {
|
||||
model := gfdb.DB(ctx).Model(ctx, public.TableNameScriptedSpeech).Fields(fields).OmitEmpty()
|
||||
model.Where(entity.ScriptedSpeechCol.DatasetId, req.DatasetId)
|
||||
model.Where(entity.ScriptedSpeechCol.SceneType, req.SceneType)
|
||||
model.OrderDesc(entity.ScriptedSpeechCol.CreatedAt)
|
||||
if req.Page != nil {
|
||||
model.Page(int(req.Page.PageNum), int(req.Page.PageSize))
|
||||
}
|
||||
r, total, err := model.AllAndCount(false)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = r.Structs(&res)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/model/entity"
|
||||
|
||||
"gitea.com/red-future/common/db/mongo"
|
||||
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
var Session = new(session)
|
||||
|
||||
type session struct{}
|
||||
|
||||
// Upsert 更新或插入会话(根据 userId + sessionId)
|
||||
// 注意:消费者调用,无 HTTP 上下文,直接使用原生 MongoDB 操作
|
||||
func (d *session) Upsert(ctx context.Context, data *entity.Session) (err error) {
|
||||
filter := bson.M{
|
||||
"userId": data.UserId,
|
||||
"sessionId": data.SessionId,
|
||||
"isDeleted": false,
|
||||
}
|
||||
|
||||
now := gtime.Now().Time
|
||||
update := bson.M{
|
||||
"$set": bson.M{
|
||||
"platform": data.Platform,
|
||||
"status": data.Status,
|
||||
"lastActiveAt": data.LastActiveAt,
|
||||
"updater": "system",
|
||||
"updatedAt": now,
|
||||
},
|
||||
"$inc": bson.M{
|
||||
"messageCount": 1,
|
||||
},
|
||||
"$setOnInsert": bson.M{
|
||||
"creator": "system",
|
||||
"createdAt": now,
|
||||
"isDeleted": false,
|
||||
},
|
||||
}
|
||||
|
||||
opts := options.UpdateOne().SetUpsert(true)
|
||||
_, err = mongo.GetDB().Collection(entity.SessionCollection).UpdateOne(ctx, filter, update, opts)
|
||||
return
|
||||
}
|
||||
|
||||
// Archive 归档会话
|
||||
// 注意:消费者调用,无 HTTP 上下文,直接使用原生 MongoDB 操作
|
||||
func (d *session) Archive(ctx context.Context, userId, sessionId string) (err error) {
|
||||
filter := bson.M{
|
||||
"userId": userId,
|
||||
"sessionId": sessionId,
|
||||
"isDeleted": false,
|
||||
}
|
||||
|
||||
now := gtime.Now().Time
|
||||
update := bson.M{
|
||||
"$set": bson.M{
|
||||
"status": entity.SessionStatusArchived,
|
||||
"archivedAt": now,
|
||||
"updater": "system",
|
||||
"updatedAt": now,
|
||||
},
|
||||
}
|
||||
|
||||
_, err = mongo.GetDB().Collection(entity.SessionCollection).UpdateOne(ctx, filter, update)
|
||||
return
|
||||
}
|
||||
|
||||
// FindByUserId 根据用户ID查询会话列表
|
||||
func (d *session) FindByUserId(ctx context.Context, userId string, limit int64) (list []*entity.Session, err error) {
|
||||
filter := bson.M{"userId": userId, "isDeleted": false}
|
||||
opts := options.Find().SetSort(bson.D{{Key: "lastActiveAt", Value: -1}}).SetLimit(limit)
|
||||
|
||||
cursor, err := mongo.GetDB().Collection(entity.SessionCollection).Find(ctx, filter, opts)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
err = cursor.All(ctx, &list)
|
||||
return
|
||||
}
|
||||
|
||||
// FindActiveByUserId 查询用户活跃会话
|
||||
func (d *session) FindActiveByUserId(ctx context.Context, userId string) (data *entity.Session, err error) {
|
||||
filter := bson.M{
|
||||
"userId": userId,
|
||||
"status": entity.SessionStatusActive,
|
||||
"isDeleted": false,
|
||||
}
|
||||
|
||||
err = mongo.GetDB().Collection(entity.SessionCollection).FindOne(ctx, filter).Decode(&data)
|
||||
return
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/model/dto"
|
||||
"customer-server/model/entity"
|
||||
"strings"
|
||||
|
||||
"gitea.com/red-future/common/beans"
|
||||
"gitea.com/red-future/common/db/mongo"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/grand"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
var Speechcraft = new(speechcraft)
|
||||
|
||||
type speechcraft struct{}
|
||||
|
||||
// Insert 插入话术
|
||||
func (d *speechcraft) Insert(ctx context.Context, data *entity.Speechcraft) (id bson.ObjectID, err error) {
|
||||
// 统一使用mongo.DB().Insert,自动清除缓存
|
||||
// service层已经设置了TenantId,mongo.DB().Insert不会覆盖已有值
|
||||
ids, err := mongo.DB().Insert(ctx, []interface{}{data}, entity.SpeechcraftCollection)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if len(ids) > 0 {
|
||||
if oid, ok := ids[0].(bson.ObjectID); ok {
|
||||
id = oid
|
||||
data.Id = &oid // 取地址赋值给指针类型
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Update 更新话术
|
||||
func (d *speechcraft) Update(ctx context.Context, req *dto.UpdateSpeechcraftReq) (err error) {
|
||||
objectId, err := bson.ObjectIDFromHex(req.Id)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
updateFields := bson.M{}
|
||||
if !g.IsEmpty(req.Tag) {
|
||||
updateFields["tag"] = req.Tag
|
||||
}
|
||||
if !g.IsEmpty(req.Content) {
|
||||
updateFields["content"] = req.Content
|
||||
}
|
||||
// 状态机字段
|
||||
if req.Stage != nil {
|
||||
updateFields["stage"] = *req.Stage
|
||||
}
|
||||
if req.Status != nil {
|
||||
updateFields["status"] = *req.Status
|
||||
}
|
||||
if req.Keywords != nil {
|
||||
updateFields["keywords"] = req.Keywords
|
||||
}
|
||||
if req.NextStage != nil {
|
||||
updateFields["nextStage"] = *req.NextStage
|
||||
}
|
||||
if req.Platform != nil {
|
||||
updateFields["platform"] = *req.Platform
|
||||
}
|
||||
|
||||
if len(updateFields) > 0 {
|
||||
_, err = mongo.DB().Update(ctx, bson.M{"_id": objectId, "isDeleted": false}, bson.M{"$set": updateFields}, entity.SpeechcraftCollection)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Delete 软删除话术
|
||||
func (d *speechcraft) Delete(ctx context.Context, req *dto.DeleteSpeechcraftReq) (err error) {
|
||||
objectId, err := bson.ObjectIDFromHex(req.Id)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
filter := bson.M{"_id": objectId, "isDeleted": false}
|
||||
update := bson.M{"$set": bson.M{"isDeleted": true, "updatedAt": gtime.Now().Time}}
|
||||
_, err = mongo.DB().Update(ctx, filter, update, entity.SpeechcraftCollection)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "删除话术失败")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// buildListFilter 构建列表查询的过滤条件
|
||||
func (d *speechcraft) buildListFilter(req *dto.ListSpeechcraftReq) bson.M {
|
||||
filter := bson.M{"isDeleted": false}
|
||||
if !g.IsEmpty(req.Tag) {
|
||||
filter["tag"] = bson.M{"$regex": req.Tag}
|
||||
}
|
||||
if !g.IsEmpty(req.Content) {
|
||||
filter["content"] = bson.M{"$regex": req.Content}
|
||||
}
|
||||
if req.Stage != nil {
|
||||
filter["stage"] = *req.Stage
|
||||
}
|
||||
if !g.IsEmpty(req.Platform) {
|
||||
filter["platform"] = req.Platform
|
||||
}
|
||||
return filter
|
||||
}
|
||||
|
||||
// checkTotalCount 检查总数
|
||||
func (d *speechcraft) checkTotalCount(ctx context.Context, filter bson.M) (total int64, err error) {
|
||||
total, err = mongo.DB().Count(ctx, filter, entity.SpeechcraftCollection)
|
||||
return
|
||||
}
|
||||
|
||||
// List 获取话术列表(排除已删除)
|
||||
func (d *speechcraft) List(ctx context.Context, req *dto.ListSpeechcraftReq) (list []*entity.Speechcraft, total int64, err error) {
|
||||
// 构建查询过滤条件
|
||||
filter := d.buildListFilter(req)
|
||||
|
||||
// 检查总数
|
||||
total, err = d.checkTotalCount(ctx, filter)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 分页参数处理
|
||||
pageNum := req.PageNum
|
||||
if pageNum <= 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
pageSize := req.PageSize
|
||||
if pageSize <= 0 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
// 使用统一的mongo.DB().Find方法(支持分页和排序)
|
||||
page := &beans.Page{
|
||||
PageNum: int64(pageNum),
|
||||
PageSize: int64(pageSize),
|
||||
}
|
||||
orderBy := []beans.OrderBy{
|
||||
{Field: "createdAt", Order: beans.Desc}, // 按创建时间倒序
|
||||
}
|
||||
|
||||
_, err = mongo.DB().Find(ctx, filter, &list, entity.SpeechcraftCollection, page, orderBy)
|
||||
return
|
||||
}
|
||||
|
||||
// MatchByStage 根据阶段和用户输入匹配话术
|
||||
// 匹配逻辑:阶段匹配 + 行为匹配(可选)+ 关键字匹配(可选)
|
||||
// 从匹配结果中随机选择一条(话术池随机)
|
||||
func (d *speechcraft) MatchByStage(ctx context.Context, stage int, status, content, platform string) (script *entity.Speechcraft, err error) {
|
||||
// 查询该阶段的所有话术
|
||||
filter := bson.M{
|
||||
"stage": stage,
|
||||
"isDeleted": false,
|
||||
}
|
||||
if !g.IsEmpty(platform) {
|
||||
filter["platform"] = platform
|
||||
}
|
||||
|
||||
var list []*entity.Speechcraft
|
||||
// 使用mongo.DB().Find会自动从token或accountName获取tenantId并过滤
|
||||
// 查询所有匹配的话术(不分页)
|
||||
page := &beans.Page{
|
||||
PageNum: 1,
|
||||
PageSize: 10000, // 话术匹配场景:设置足够大的PageSize
|
||||
}
|
||||
orderBy := []beans.OrderBy{} // 无需排序,后续会随机选择
|
||||
if _, err = mongo.DB().Find(ctx, filter, &list, entity.SpeechcraftCollection, page, orderBy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 收集所有匹配的话术
|
||||
matched := make([]*entity.Speechcraft, 0, len(list))
|
||||
for _, item := range list {
|
||||
// 行为匹配(空=任意行为都匹配)
|
||||
if !g.IsEmpty(item.Status) && item.Status != status {
|
||||
continue
|
||||
}
|
||||
|
||||
// 关键字匹配(空=任意内容都匹配)
|
||||
if len(item.Keywords) > 0 && !d.matchKeywords(content, item.Keywords) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 匹配成功,加入候选池
|
||||
matched = append(matched, item)
|
||||
}
|
||||
|
||||
// 从候选池随机选择一条
|
||||
if len(matched) > 0 {
|
||||
script = matched[grand.Intn(len(matched))]
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// matchKeywords 检查内容是否包含任一关键字
|
||||
func (d *speechcraft) matchKeywords(content string, keywords []string) bool {
|
||||
for _, kw := range keywords {
|
||||
if g.IsEmpty(kw) {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(content, kw) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// FindByTag 根据tag查询话术(用于去重检查,同一租户下tag唯一)
|
||||
func (d *speechcraft) FindByTag(ctx context.Context, tag string) (speechcraft *entity.Speechcraft, err error) {
|
||||
filter := bson.M{
|
||||
"tag": tag,
|
||||
"isDeleted": false,
|
||||
}
|
||||
err = mongo.DB().FindOne(ctx, filter, &speechcraft, entity.SpeechcraftCollection)
|
||||
if err != nil {
|
||||
if err.Error() == "mongo: no documents in result" {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// FindByTagAndPlatform 根据tag和platform查询话术(用于去重检查)
|
||||
func (d *speechcraft) FindByTagAndPlatform(ctx context.Context, tag, platform string) (speechcraft *entity.Speechcraft, err error) {
|
||||
filter := bson.M{
|
||||
"tag": tag,
|
||||
"platform": platform,
|
||||
"isDeleted": false,
|
||||
}
|
||||
err = mongo.DB().FindOne(ctx, filter, &speechcraft, entity.SpeechcraftCollection)
|
||||
if err != nil {
|
||||
if err.Error() == "mongo: no documents in result" {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// GetById 根据ID查询话术
|
||||
// 使用 MongoDAO(不需要token验证)
|
||||
func (d *speechcraft) GetById(ctx context.Context, id string) (speechcraft *entity.Speechcraft, err error) {
|
||||
objectId, err := bson.ObjectIDFromHex(id)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
filter := bson.M{"_id": objectId, "isDeleted": false}
|
||||
|
||||
var result entity.Speechcraft
|
||||
err = MongoDAO.FindOne(ctx, filter, &result, entity.SpeechcraftCollection)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 如果未找到记录,result 是零值
|
||||
if result.Id.IsZero() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
speechcraft = &result
|
||||
return
|
||||
}
|
||||
|
||||
// UpdateEntity 更新话术实体(用于绑定/解绑/同步等场景)
|
||||
func (d *speechcraft) UpdateEntity(ctx context.Context, speechcraft *entity.Speechcraft) (err error) {
|
||||
filter := bson.M{"_id": speechcraft.Id, "isDeleted": false}
|
||||
|
||||
// 将实体转换为bson.M
|
||||
updateDoc := bson.M{}
|
||||
data, _ := bson.Marshal(speechcraft)
|
||||
bson.Unmarshal(data, &updateDoc)
|
||||
|
||||
_, err = mongo.DB().Update(ctx, filter, bson.M{"$set": updateDoc}, entity.SpeechcraftCollection)
|
||||
return
|
||||
}
|
||||
|
||||
// FindByStage 查询指定阶段的所有话术
|
||||
func (d *speechcraft) FindByStage(ctx context.Context, stage int, platform string) (list []*entity.Speechcraft, err error) {
|
||||
filter := bson.M{
|
||||
"stage": stage,
|
||||
"isDeleted": false,
|
||||
}
|
||||
if !g.IsEmpty(platform) {
|
||||
filter["platform"] = platform
|
||||
}
|
||||
// 使用统一的mongo.DB().Find方法(支持分页和排序)
|
||||
page := &beans.Page{
|
||||
PageNum: 1,
|
||||
PageSize: 10000, // 查询所有话术(不分页)
|
||||
}
|
||||
orderBy := []beans.OrderBy{
|
||||
{Field: "priority", Order: beans.Desc}, // 按优先级倒序
|
||||
}
|
||||
_, err = mongo.DB().Find(ctx, filter, &list, entity.SpeechcraftCollection, page, orderBy)
|
||||
return
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/model/entity"
|
||||
|
||||
"gitea.com/red-future/common/db/mongo"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
var UserStage = new(userStage)
|
||||
|
||||
type userStage struct{}
|
||||
|
||||
// GetOrCreate 获取用户阶段,不存在则创建
|
||||
func (d *userStage) GetOrCreate(ctx context.Context, userId, platform string) (state *entity.UserStage, err error) {
|
||||
filter := bson.M{
|
||||
"userId": userId,
|
||||
"platform": platform,
|
||||
"isDeleted": false,
|
||||
}
|
||||
|
||||
state = &entity.UserStage{}
|
||||
if err = mongo.DB().FindOne(ctx, filter, state, entity.UserStageCollection); err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 不存在则创建
|
||||
now := gtime.Now().Time
|
||||
state = &entity.UserStage{
|
||||
UserId: userId,
|
||||
Platform: platform,
|
||||
Stage: entity.StageInit,
|
||||
Status: entity.StatusIdle,
|
||||
LastMsgAt: &now, // 取地址赋值给指针类型
|
||||
}
|
||||
state.CreatedAt = &now // 取地址赋值给指针类型
|
||||
state.UpdatedAt = &now // 取地址赋值给指针类型
|
||||
state.IsDeleted = false
|
||||
|
||||
_, err = mongo.DB().Insert(ctx, []interface{}{state}, entity.UserStageCollection)
|
||||
return
|
||||
}
|
||||
|
||||
// UpdateStage 更新用户阶段
|
||||
func (d *userStage) UpdateStage(ctx context.Context, userId, platform string, stage int) (err error) {
|
||||
filter := bson.M{
|
||||
"userId": userId,
|
||||
"platform": platform,
|
||||
"isDeleted": false,
|
||||
}
|
||||
update := bson.M{
|
||||
"$set": bson.M{
|
||||
"stage": stage,
|
||||
"lastMsgAt": gtime.Now().Time,
|
||||
"updatedAt": gtime.Now().Time,
|
||||
},
|
||||
}
|
||||
_, err = mongo.DB().Update(ctx, filter, update, entity.UserStageCollection)
|
||||
return
|
||||
}
|
||||
|
||||
// UpdateStatus 更新用户行为
|
||||
func (d *userStage) UpdateStatus(ctx context.Context, userId, platform, status string) (err error) {
|
||||
filter := bson.M{
|
||||
"userId": userId,
|
||||
"platform": platform,
|
||||
"isDeleted": false,
|
||||
}
|
||||
update := bson.M{
|
||||
"$set": bson.M{
|
||||
"status": status,
|
||||
"lastMsgAt": gtime.Now().Time,
|
||||
"updatedAt": gtime.Now().Time,
|
||||
},
|
||||
}
|
||||
_, err = mongo.DB().Update(ctx, filter, update, entity.UserStageCollection)
|
||||
return
|
||||
}
|
||||
|
||||
// UpdateStageAndStatus 同时更新阶段和行为
|
||||
func (d *userStage) UpdateStageAndStatus(ctx context.Context, userId, platform string, stage int, status string) (err error) {
|
||||
filter := bson.M{
|
||||
"userId": userId,
|
||||
"platform": platform,
|
||||
"isDeleted": false,
|
||||
}
|
||||
update := bson.M{
|
||||
"$set": bson.M{
|
||||
"stage": stage,
|
||||
"status": status,
|
||||
"lastMsgAt": gtime.Now().Time,
|
||||
"updatedAt": gtime.Now().Time,
|
||||
},
|
||||
}
|
||||
_, err = mongo.DB().Update(ctx, filter, update, entity.UserStageCollection)
|
||||
return
|
||||
}
|
||||
|
||||
// Reset 重置用户阶段到初始状态
|
||||
func (d *userStage) Reset(ctx context.Context, userId, platform string) (err error) {
|
||||
return d.UpdateStageAndStatus(ctx, userId, platform, entity.StageInit, entity.StatusIdle)
|
||||
}
|
||||
|
||||
// FindByUser 查询用户阶段
|
||||
func (d *userStage) FindByUser(ctx context.Context, userId, platform string) (state *entity.UserStage, err error) {
|
||||
filter := bson.M{
|
||||
"userId": userId,
|
||||
"platform": platform,
|
||||
"isDeleted": false,
|
||||
}
|
||||
state = &entity.UserStage{}
|
||||
err = mongo.DB().FindOne(ctx, filter, state, entity.UserStageCollection)
|
||||
return
|
||||
}
|
||||
|
||||
// Upsert 更新或插入用户阶段
|
||||
func (d *userStage) Upsert(ctx context.Context, userId, platform string, stage int, status string) (err error) {
|
||||
filter := bson.M{
|
||||
"userId": userId,
|
||||
"platform": platform,
|
||||
}
|
||||
now := gtime.Now().Time
|
||||
update := bson.M{
|
||||
"$set": bson.M{
|
||||
"stage": stage,
|
||||
"status": status,
|
||||
"lastMsgAt": now,
|
||||
"updatedAt": now,
|
||||
"isDeleted": false,
|
||||
},
|
||||
"$setOnInsert": bson.M{
|
||||
"userId": userId,
|
||||
"platform": platform,
|
||||
"createdAt": now,
|
||||
},
|
||||
}
|
||||
opts := options.UpdateOne().SetUpsert(true)
|
||||
_, err = mongo.GetDB().Collection(entity.UserStageCollection).UpdateOne(ctx, filter, update, opts)
|
||||
return
|
||||
}
|
||||
@@ -1,34 +1,42 @@
|
||||
module customer-server
|
||||
|
||||
go 1.25.7
|
||||
go 1.26.0
|
||||
|
||||
//replace gitea.com/red-future/common => ../common
|
||||
//replace github.com/bjang03/gmq => ../gmq
|
||||
|
||||
require (
|
||||
gitea.com/red-future/common v0.0.4
|
||||
github.com/gogf/gf/contrib/nosql/redis/v2 v2.10.0
|
||||
github.com/gogf/gf/v2 v2.10.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/hashicorp/consul/api v1.33.4
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0
|
||||
gitea.redpowerfuture.com/red-future/common v0.0.29
|
||||
github.com/bjang03/gmq v0.0.1
|
||||
github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.2
|
||||
github.com/gogf/gf/contrib/nosql/redis/v2 v2.10.2
|
||||
github.com/gogf/gf/v2 v2.10.2
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.5.0 // indirect
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/armon/go-metrics v0.4.1 // indirect
|
||||
github.com/bwmarrin/snowflake v0.3.0 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/clbanning/mxj/v2 v2.7.0 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/dgraph-io/badger/v4 v4.2.0 // indirect
|
||||
github.com/dgraph-io/ristretto v0.1.1 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/emirpasic/gods/v2 v2.0.0-alpha // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/fatih/color v1.19.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/go-ego/gse v1.0.2 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
github.com/gogf/gf/contrib/registry/consul/v2 v2.9.5 // indirect
|
||||
github.com/gogf/gf/contrib/trace/otlphttp/v2 v2.9.5 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
@@ -37,57 +45,67 @@ require (
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/golang/snappy v1.0.0 // indirect
|
||||
github.com/google/flatbuffers v1.12.1 // indirect
|
||||
github.com/google/btree v1.1.3 // indirect
|
||||
github.com/google/flatbuffers v25.12.19+incompatible // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/grokify/html-strip-tags-go v0.1.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
||||
github.com/hashicorp/consul/api v1.33.5 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-hclog v1.5.0 // indirect
|
||||
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/hashicorp/go-rootcerts v1.0.2 // indirect
|
||||
github.com/hashicorp/go-version v1.9.0 // indirect
|
||||
github.com/hashicorp/golang-lru v1.0.2 // indirect
|
||||
github.com/hashicorp/serf v0.10.1 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/compress v1.18.6 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lib/pq v1.12.3 // indirect
|
||||
github.com/magiconair/properties v1.8.10 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.24 // indirect
|
||||
github.com/miekg/dns v1.1.72 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/olekukonko/errors v1.1.0 // indirect
|
||||
github.com/olekukonko/ll v0.0.9 // indirect
|
||||
github.com/olekukonko/tablewriter v1.1.0 // indirect
|
||||
github.com/olivere/elastic/v7 v7.0.32 // indirect
|
||||
github.com/nats-io/nats.go v1.49.0 // indirect
|
||||
github.com/nats-io/nkeys v0.4.15 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect
|
||||
github.com/olekukonko/errors v1.3.0 // indirect
|
||||
github.com/olekukonko/ll v0.1.8 // indirect
|
||||
github.com/olekukonko/tablewriter v1.1.4 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/r3labs/diff/v2 v2.15.1 // indirect
|
||||
github.com/rabbitmq/amqp091-go v1.10.0 // indirect
|
||||
github.com/redis/go-redis/v9 v9.12.1 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/redis/go-redis/v9 v9.21.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/tiger1103/gfast-token v1.0.10 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
github.com/xdg-go/scram v1.2.0 // indirect
|
||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
go.opencensus.io v0.23.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/otel v1.38.0 // indirect
|
||||
github.com/vcaesar/cedar v0.30.0 // indirect
|
||||
github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
|
||||
go.opencensus.io v0.24.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.7.1 // indirect
|
||||
golang.org/x/crypto v0.41.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250808145144-a408d31f581a // indirect
|
||||
golang.org/x/net v0.43.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect
|
||||
google.golang.org/grpc v1.75.0 // indirect
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/crypto v0.53.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/text v0.38.0 // indirect
|
||||
google.golang.org/appengine v1.6.7 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect
|
||||
google.golang.org/grpc v1.79.3 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
gitea.com/red-future/common v0.0.3/go.mod h1:mq4smQZFI5nYul6gvLH7ScnC/26bAOcTvR3hP625NYY=
|
||||
gitea.com/red-future/common v0.0.4/go.mod h1:UI9N5UUjilbMPF7+/lypZSnqDVHigt14300oSRrAyZg=
|
||||
gitea.redpowerfuture.com/red-future/common v0.0.29 h1:5McaN5pSewvrLUHQzWMX6EaUvD+B5I5bMYoU+clHJk4=
|
||||
gitea.redpowerfuture.com/red-future/common v0.0.29/go.mod h1:50U1Xi+Ie56z09S5LQbZvaken0Mxv3OeS9LgR7U/ZRY=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
|
||||
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
@@ -19,10 +19,14 @@ github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24
|
||||
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
|
||||
github.com/bjang03/gmq v0.0.1 h1:jdnr4uy+iuCtXaxozRBWRnMV+vbVArlPR+bdHAQSPZA=
|
||||
github.com/bjang03/gmq v0.0.1/go.mod h1:uxVFyZhbAQQQbb2Dc320D5ODzlG0j6FcpAMuN4Y5+cg=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0=
|
||||
github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
@@ -34,6 +38,10 @@ github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp
|
||||
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
|
||||
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -45,8 +53,6 @@ github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWa
|
||||
github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA=
|
||||
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA=
|
||||
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
@@ -59,12 +65,16 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7
|
||||
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
|
||||
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
|
||||
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
|
||||
github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
||||
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/go-ego/gse v1.0.2 h1:+27lYFPhQEhA9igtdOsJPRKYL/k3TwYsxBF5jr6KFv4=
|
||||
github.com/go-ego/gse v1.0.2/go.mod h1:Fy35G+q7VV7Et1zIKO8o/sW1kkugV3znXap/lF/11zc=
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
|
||||
@@ -74,17 +84,29 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/gogf/gf/contrib/nosql/redis/v2 v2.10.0 h1:N/F9CuDdUZLoM1nVRqrDE/33pDZuhVxpNY4wYdeIaBs=
|
||||
github.com/gogf/gf/contrib/nosql/redis/v2 v2.10.0/go.mod h1:x6uoJGfZOtirIRQls8xUlYzC6f7T/eULPUa9er368X0=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.2 h1:u8EpP24GkprogROnJ7htMov9Fc66pTP1eVYrWxiCYOs=
|
||||
github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.2/go.mod h1:GmvM3r8GVByVMi4RD2+MCs5+CfxVXPMeT8mVDkAaAXE=
|
||||
github.com/gogf/gf/contrib/nosql/redis/v2 v2.10.2 h1:iTQegT+lEg/wDKvj2mi3W1wrdrwFarjokf88EXVVgu4=
|
||||
github.com/gogf/gf/contrib/nosql/redis/v2 v2.10.2/go.mod h1:ZRw3GNz5cq4uYrW4TPSVyrYWaoqzujKdWro/AOcGBaE=
|
||||
github.com/gogf/gf/contrib/registry/consul/v2 v2.9.5 h1:eUqwJ/qNH8lJ6yssiqskazgp1ACQuNU6zXlLOZVuXTQ=
|
||||
github.com/gogf/gf/contrib/registry/consul/v2 v2.9.5/go.mod h1:sjQyMry9+0POYZCA6lHXBxO77WoNKkruJpRB4xKqk5k=
|
||||
github.com/gogf/gf/contrib/trace/otlphttp/v2 v2.9.5 h1:tHUEZYB5GTqEYYVDYnlGobf1xISARKDE4KHVlgjwTec=
|
||||
github.com/gogf/gf/contrib/trace/otlphttp/v2 v2.9.5/go.mod h1:cfzTn2HS9RDX8f5pUVkbGxUWcSosouqfNQ1G6cY0V88=
|
||||
github.com/gogf/gf/v2 v2.10.0 h1:rzDROlyqGMe/eM6dCalSR8dZOuMIdLhmxKSH1DGhbFs=
|
||||
github.com/gogf/gf/v2 v2.10.0/go.mod h1:Svl1N+E8G/QshU2DUbh/3J/AJauqCgUnxHurXWR4Qx0=
|
||||
github.com/gogf/gf/v2 v2.10.2 h1:46IO0Uc8e85/FqdftJFskfDejJLBL0JBnGS5qOftUu8=
|
||||
github.com/gogf/gf/v2 v2.10.2/go.mod h1:Svl1N+E8G/QshU2DUbh/3J/AJauqCgUnxHurXWR4Qx0=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
@@ -112,10 +134,10 @@ github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6
|
||||
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4=
|
||||
github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA=
|
||||
github.com/google/flatbuffers v1.12.1 h1:MVlul7pQNoDzWRLTw5imwYsl+usrS1TXG2H4jg6ImGw=
|
||||
github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
|
||||
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
|
||||
github.com/google/flatbuffers v25.12.19+incompatible h1:haMV2JRRJCe1998HeW/p0X9UaMTK6SDo0ffLn2+DbLs=
|
||||
github.com/google/flatbuffers v25.12.19+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
@@ -128,16 +150,16 @@ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
||||
github.com/grokify/html-strip-tags-go v0.1.0 h1:03UrQLjAny8xci+R+qjCce/MYnpNXCtgzltlQbOBae4=
|
||||
github.com/grokify/html-strip-tags-go v0.1.0/go.mod h1:ZdzgfHEzAfz9X6Xe5eBLVblWIxXfYSQ40S/VKrAOGpc=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs=
|
||||
github.com/hashicorp/consul/api v1.33.4 h1:AJkZp6qzgAYcMIU0+CjJ0Rb7+byfh0dazFK/gzlOcJk=
|
||||
github.com/hashicorp/consul/api v1.33.4/go.mod h1:BkH3WEUzsnWvJJaHoDqKqoe2Q2EIixx7Gjj6MTwYnOA=
|
||||
github.com/hashicorp/consul/sdk v0.17.2 h1:sC0jgNhJkZX3wo1DCrkG12r+1JlZQpWvk3AoL3yZE4Q=
|
||||
github.com/hashicorp/consul/sdk v0.17.2/go.mod h1:VjccKcw6YhMhjH84/ZhTXZ0OG4SUq+K25P6DiCV/Hvg=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
||||
github.com/hashicorp/consul/api v1.33.5 h1:Nn6q87zudRU1rLBTJEgaWxz9STCNadilLCD7B8OA5aI=
|
||||
github.com/hashicorp/consul/api v1.33.5/go.mod h1:pa6fJOSHKLOzNHpUVeqLDtxA5+J1D7NNzLasuk8eRXA=
|
||||
github.com/hashicorp/consul/sdk v0.17.3 h1:oZMMxzQGSsiT+ToOH50y3Qcs0nc9Ud+7L5lRx+EmMU0=
|
||||
github.com/hashicorp/consul/sdk v0.17.3/go.mod h1:jnOmYjiNfVRpBaujQ1DFFVs0N6g3S1y6wygSjLTzYfc=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
@@ -167,8 +189,8 @@ github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/b
|
||||
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-version v1.2.1 h1:zEfKbn2+PDgroKdiOzqiE8rsmLqU2uwi5PB5pBJ3TkI=
|
||||
github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA=
|
||||
github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
|
||||
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||
@@ -178,15 +200,15 @@ github.com/hashicorp/memberlist v0.5.0 h1:EtYPN8DpAURiapus508I4n9CzHs2W+8NZGbmmR
|
||||
github.com/hashicorp/memberlist v0.5.0/go.mod h1:yvyXLpo0QaGE59Y7hDTsTzDD25JYBZ4mHgHUZ8lrOI0=
|
||||
github.com/hashicorp/serf v0.10.1 h1:Z1H2J60yRKvfDYAOZLd2MU0ND4AH/WDz7xYHDWQsIPY=
|
||||
github.com/hashicorp/serf v0.10.1/go.mod h1:yL2t6BqATOLGc5HF7qbFkTfXoPIY0WZdWHfEvMqbG+4=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
@@ -196,31 +218,33 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
||||
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
||||
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
|
||||
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||
github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=
|
||||
github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI=
|
||||
github.com/miekg/dns v1.1.63 h1:8M5aAw6OMZfFXTT7K5V0Eu5YiiL8l7nUAkyN6C9YwaY=
|
||||
github.com/miekg/dns v1.1.63/go.mod h1:6NGHfjhpmr5lt3XPLuyfDJi5AXbNIPM9PY6H6sF1Nfs=
|
||||
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
@@ -230,14 +254,20 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM=
|
||||
github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
|
||||
github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI=
|
||||
github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g=
|
||||
github.com/olekukonko/tablewriter v1.1.0 h1:N0LHrshF4T39KvI96fn6GT8HEjXRXYNDrDjKFDB7RIY=
|
||||
github.com/olekukonko/tablewriter v1.1.0/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo=
|
||||
github.com/olivere/elastic/v7 v7.0.32 h1:R7CXvbu8Eq+WlsLgxmKVKPox0oOwAE/2T9Si5BnvK6E=
|
||||
github.com/olivere/elastic/v7 v7.0.32/go.mod h1:c7PVmLe3Fxq77PIfY/bZmxY/TAamBhCzZ8xDOE09a9k=
|
||||
github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE=
|
||||
github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw=
|
||||
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc=
|
||||
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0=
|
||||
github.com/olekukonko/errors v1.3.0 h1:teJvgLGUEqMzBUms+Dj3/3szNqCG/Jdw9iDbum8fR6U=
|
||||
github.com/olekukonko/errors v1.3.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
|
||||
github.com/olekukonko/ll v0.1.8 h1:ysHCJRGHYKzmBSdz9w5AySztx7lG8SQY+naTGYUbsz8=
|
||||
github.com/olekukonko/ll v0.1.8/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw=
|
||||
github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I=
|
||||
github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY=
|
||||
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
|
||||
github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY=
|
||||
github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
|
||||
@@ -262,67 +292,74 @@ github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8b
|
||||
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
|
||||
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
|
||||
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
|
||||
github.com/r3labs/diff/v2 v2.15.1 h1:EOrVqPUzi+njlumoqJwiS/TgGgmZo83619FNDB9xQUg=
|
||||
github.com/r3labs/diff/v2 v2.15.1/go.mod h1:I8noH9Fc2fjSaMxqF3G2lhDdC0b+JXCfyx85tWFM9kc=
|
||||
github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
|
||||
github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
|
||||
github.com/redis/go-redis/v9 v9.12.1 h1:k5iquqv27aBtnTm2tIkROUDp8JBXhXZIVu1InSgvovg=
|
||||
github.com/redis/go-redis/v9 v9.12.1/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E=
|
||||
github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
|
||||
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I=
|
||||
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tiger1103/gfast-token v1.0.10 h1:fNiBE/Dq5iTHvTGlCx3DmXa2o4hr0NtumFpffZ39k6s=
|
||||
github.com/tiger1103/gfast-token v1.0.10/go.mod h1:a/21mxmj7zFeNvjhZSC0XpEAFHfb1aT2k6DXnufFU1s=
|
||||
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
|
||||
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
|
||||
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||
github.com/vcaesar/cedar v0.30.0 h1:9fSDpM7FTjjUdPiBUUa0MWYMRGSEcqgFXvppZcZ4d7Y=
|
||||
github.com/vcaesar/cedar v0.30.0/go.mod h1:lyuGvALuZZDPNXwpzv/9LyxW+8Y6faN7zauFezNsnik=
|
||||
github.com/vcaesar/tt v0.20.1 h1:D/jUeeVCNbq3ad8M7hhtB3J9x5RZ6I1n1eZ0BJp7M+4=
|
||||
github.com/vcaesar/tt v0.20.1/go.mod h1:cH2+AwGAJm19Wa6xvEa+0r+sXDJBT0QgNQey6mwqLeU=
|
||||
github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI=
|
||||
github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.opencensus.io v0.23.0 h1:gqCw0LfLxScz8irSi8exQc7fyQ0fKQU/qnC/X8+V/1M=
|
||||
go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.mongodb.org/mongo-driver/v2 v2.6.0 h1:b9sJOYrkmt4l8bY43ZenFBcPlhYIjaOfYHLtbB/5qi8=
|
||||
go.mongodb.org/mongo-driver/v2 v2.6.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4=
|
||||
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
|
||||
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4=
|
||||
go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
@@ -330,26 +367,25 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
|
||||
golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20250808145144-a408d31f581a h1:Y+7uR/b1Mw2iSXZ3G//1haIiSElDQZ8KWh0h+sZPG90=
|
||||
golang.org/x/exp v0.0.0-20250808145144-a408d31f581a/go.mod h1:rT6SFzZ7oxADUDx58pcaKFTcZ+inxAa9fTrYx/uVYwg=
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
@@ -358,9 +394,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
|
||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -369,9 +404,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -390,27 +424,20 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
@@ -420,9 +447,8 @@ golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtn
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||
golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk=
|
||||
golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@@ -431,20 +457,23 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
|
||||
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||
google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
|
||||
google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
|
||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
@@ -454,8 +483,8 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -2,16 +2,20 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/consts/public"
|
||||
"customer-server/controller"
|
||||
"customer-server/service"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"gitea.com/red-future/common/elasticsearch"
|
||||
"gitea.com/red-future/common/http"
|
||||
"gitea.com/red-future/common/jaeger"
|
||||
"gitea.com/red-future/common/rabbitmq"
|
||||
"gitea.redpowerfuture.com/red-future/common/http"
|
||||
"gitea.redpowerfuture.com/red-future/common/jaeger"
|
||||
"gitea.redpowerfuture.com/red-future/common/utils"
|
||||
gmq "github.com/bjang03/gmq/core/gmq"
|
||||
"github.com/bjang03/gmq/mq"
|
||||
"github.com/bjang03/gmq/types"
|
||||
_ "github.com/gogf/gf/contrib/drivers/pgsql/v2"
|
||||
_ "github.com/gogf/gf/contrib/nosql/redis/v2"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/glog"
|
||||
@@ -21,64 +25,35 @@ func main() {
|
||||
ctx := context.Background()
|
||||
defer jaeger.ShutDown(ctx)
|
||||
|
||||
// 提前初始化实例队列名,确保全局唯一
|
||||
baseQueue := g.Cfg().MustGet(ctx, "rabbitmq.responseQueue", "ragflow.response.queue").String()
|
||||
instanceQueue := rabbitmq.GetInstanceQueueName(baseQueue)
|
||||
glog.Infof(ctx, "🔧 实例队列名已初始化: %s", instanceQueue)
|
||||
|
||||
// 初始化Consul配置监听(咨询方向配置)
|
||||
service.InitConsulWatcher(ctx)
|
||||
|
||||
// 路由注册(common/http init() 已异步启动服务器,这里注册路由不影响)
|
||||
http.RouteRegister([]interface{}{
|
||||
controller.Health,
|
||||
controller.Archive,
|
||||
controller.CustomerServiceAccount,
|
||||
controller.Data,
|
||||
controller.DataStatistics,
|
||||
controller.Product,
|
||||
controller.Speechcraft,
|
||||
controller.Websocket,
|
||||
controller.Webhook,
|
||||
controller.RAGFlowConfig,
|
||||
controller.RAGFlow, // RAGFlow HTTP处理接口(从message迁移)
|
||||
controller.XiaohongshuController, // 小红书Webhook接口
|
||||
controller.Account,
|
||||
controller.ScriptedSpeech,
|
||||
controller.AccountWebsocket,
|
||||
controller.AccountHttp,
|
||||
})
|
||||
|
||||
// 初始化消费者管理器并注册所有消费者
|
||||
mgr := rabbitmq.NewConsumerManager(ctx)
|
||||
|
||||
// 注册响应消费者
|
||||
responseConsumer := service.NewResponseConsumer(ctx)
|
||||
mgr.Register("响应消费者", responseConsumer.Start, responseConsumer.Stop)
|
||||
|
||||
// 注册追问消费者
|
||||
followUpConsumer := service.NewFollowUpConsumer(ctx)
|
||||
mgr.Register("追问消费者", followUpConsumer.Start, followUpConsumer.Stop)
|
||||
|
||||
// 注册会话归档消费者
|
||||
sessionArchiveConsumer := service.NewSessionArchiveConsumer(ctx)
|
||||
mgr.Register("会话归档消费者", sessionArchiveConsumer.Start, sessionArchiveConsumer.Stop)
|
||||
|
||||
// 注册延时落库消费者
|
||||
delayedFlushConsumer := service.NewDelayedFlushConsumer(ctx)
|
||||
mgr.Register("延时落库消费者", delayedFlushConsumer.Start, delayedFlushConsumer.Stop)
|
||||
|
||||
// 初始化并启动所有消费者
|
||||
if err := mgr.Init(); err != nil {
|
||||
glog.Fatalf(ctx, "消费者管理器初始化失败: %v", err)
|
||||
if err := utils.InitGseTool(ctx); err != nil {
|
||||
g.Log().Error(ctx, "gse 分词工具初始化失败:", err)
|
||||
}
|
||||
|
||||
// 初始化 ES 客户端
|
||||
if !g.Cfg().MustGet(ctx, "elasticsearch").IsEmpty() {
|
||||
if err := elasticsearch.Init(ctx); err != nil {
|
||||
glog.Warningf(ctx, "ES 初始化失败(月度归档功能不可用): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 启动月度归档定时任务
|
||||
if !g.Cfg().MustGet(ctx, "mongo").IsEmpty() && !g.Cfg().MustGet(ctx, "elasticsearch").IsEmpty() {
|
||||
service.ArchiveService.StartCron(ctx)
|
||||
gmq.GmqRegister(public.GmqMsgPluginsName, &mq.NatsConn{
|
||||
NatsConfig: mq.NatsConfig{
|
||||
Addr: g.Cfg().MustGet(ctx, "nats.default.addr").String(),
|
||||
Port: g.Cfg().MustGet(ctx, "nats.default.port").String(),
|
||||
},
|
||||
})
|
||||
if err := gmq.GetGmq(public.GmqMsgPluginsName).GmqSubscribe(ctx, &mq.NatsSubMessage{
|
||||
SubMessage: types.SubMessage{
|
||||
Topic: public.AccountFollowupTopic,
|
||||
ConsumerName: public.AccountFollowupConsumer,
|
||||
AutoAck: public.AccountFollowupAck,
|
||||
FetchCount: public.AccountFollowupCount,
|
||||
HandleFunc: service.AccountWebSocket.AccountMsg,
|
||||
},
|
||||
IsDelayMsg: true,
|
||||
}); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 监听系统信号,支持 Ctrl+C 优雅退出
|
||||
@@ -86,10 +61,6 @@ func main() {
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
glog.Info(ctx, "收到停止信号,正在关闭服务...")
|
||||
glog.Info(ctx, "正在关闭服务...")
|
||||
|
||||
// 停止所有消费者并关闭RabbitMQ连接
|
||||
mgr.Stop()
|
||||
|
||||
glog.Info(ctx, "customerservice 服务已停止")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// Package dto - 客服账号DTO
|
||||
// 功能:客服账号的增删改查请求响应结构体
|
||||
package dto
|
||||
|
||||
import (
|
||||
"customer-server/consts/account"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/beans"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// AddAccountReq 添加客服账号
|
||||
type AddAccountReq struct {
|
||||
g.Meta `path:"/add" method:"post" tags:"客服账号管理" summary:"添加客服账号" dc:"创建新的客服账号"`
|
||||
|
||||
DatasetIds []int64 `json:"datasetIds" v:"required#数据集ID不能为空" dc:"数据集ID列表"`
|
||||
DocumentIds []int64 `json:"documentIds" v:"required#文档ID不能为空" dc:"文档ID列表"`
|
||||
AccountCode string `json:"accountCode" v:"required#账号编码不能为空" dc:"账号编码"`
|
||||
AccountName string `json:"accountName" dc:"客服账号名称"`
|
||||
Status account.Status `json:"status" dc:"客服账号状态"`
|
||||
Greeting string `json:"greeting" dc:"开场白"`
|
||||
KeywordOption []string `json:"keywordOption" dc:"关键词选项"`
|
||||
SelfIdentity string `json:"selfIdentity" dc:"AI身份描述"`
|
||||
Platform account.Platform `json:"platform" v:"required#客服平台不能为空" dc:"客服平台"`
|
||||
}
|
||||
|
||||
type AddAccountRes struct {
|
||||
Id int64 `json:"id"`
|
||||
}
|
||||
|
||||
// UpdateAccountReq 更新客服账号
|
||||
type UpdateAccountReq struct {
|
||||
g.Meta `path:"/update" method:"post" tags:"客服账号管理" summary:"更新客服账号" dc:"更新客服账号信息"`
|
||||
|
||||
Id int64 `json:"id" v:"required#客服账号ID不能为空" dc:"客服账号ID"`
|
||||
DatasetIds []int64 `json:"datasetIds" dc:"数据集ID列表"`
|
||||
DocumentIds []int64 `json:"documentIds" dc:"文档ID列表"`
|
||||
AccountCode string `json:"accountCode" dc:"账号编码"`
|
||||
AccountName string `json:"accountName" dc:"客服账号名称"`
|
||||
Status account.Status `json:"status" dc:"客服账号状态"`
|
||||
Greeting string `json:"greeting" dc:"开场白"`
|
||||
KeywordOption []string `json:"keywordOption" dc:"关键词选项"`
|
||||
SelfIdentity string `json:"selfIdentity" dc:"AI身份描述"`
|
||||
Platform account.Platform `json:"platform" dc:"客服平台"`
|
||||
}
|
||||
|
||||
// DeleteAccountReq 删除客服账号
|
||||
type DeleteAccountReq struct {
|
||||
g.Meta `path:"/delete" method:"post" tags:"客服账号管理" summary:"删除客服账号" dc:"删除指定客服账号"`
|
||||
Id int64 `json:"id" v:"required#客服账号ID不能为空" dc:"客服账号ID"`
|
||||
}
|
||||
|
||||
// GetAccountReq 获取单个客服账号
|
||||
type GetAccountReq struct {
|
||||
g.Meta `path:"/getOne" method:"get" tags:"客服账号管理" summary:"获取客服账号详情" dc:"根据ID获取单个客服账号信息"`
|
||||
Id int64 `json:"id" v:"required#客服账号ID不能为空" dc:"客服账号ID"`
|
||||
}
|
||||
|
||||
// ListAccountReq 获取客服账号列表
|
||||
type ListAccountReq struct {
|
||||
g.Meta `path:"/list" method:"get" tags:"客服账号管理" summary:"获取客服账号列表" dc:"分页查询客服账号,支持按账号名称、状态、平台筛选"`
|
||||
Page *beans.Page `json:"page"`
|
||||
Keyword string `json:"keyword" dc:"关键字"`
|
||||
AccountCode string `json:"accountCode" dc:"账号编码"`
|
||||
AccountName string `json:"accountName" dc:"客服账号名称"`
|
||||
Status account.Status `json:"status" dc:"客服账号状态"`
|
||||
Platform account.Platform `json:"platform" dc:"客服平台"`
|
||||
}
|
||||
|
||||
type ListAccountRes struct {
|
||||
List []*AccountVO `json:"list"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// AccountVO 客服账号视图对象
|
||||
type AccountVO struct {
|
||||
Id int64 `json:"id,string"`
|
||||
DatasetIds []int64 `json:"datasetIds,string"`
|
||||
DocumentIds []int64 `json:"documentIds,string"`
|
||||
AccountCode string `json:"accountCode"`
|
||||
AccountName string `json:"accountName"`
|
||||
Status account.Status `json:"status"`
|
||||
Greeting string `json:"greeting"`
|
||||
KeywordOption []string `json:"keywordOption"`
|
||||
SelfIdentity string `json:"selfIdentity"`
|
||||
Platform account.Platform `json:"platform"`
|
||||
TenantId uint64 `json:"tenantId"`
|
||||
Creator string `json:"creator"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// GetByAccountCodeReq 根据账号名称获取客服账号
|
||||
type GetByAccountCodeReq struct {
|
||||
AccountCode string `json:"accountCode" dc:"客服账号"`
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"customer-server/consts/account"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type AccountHttpConnectReq struct {
|
||||
g.Meta `path:"/accountConnect" method:"get" tags:"AccountHttp" summary:"Http连接" dc:"发送Http请求,用于消息推送"`
|
||||
UserId string `json:"userId" v:"required#用户ID不能为空" dc:"用户ID"`
|
||||
AccountCode string `json:"accountCode" v:"required#客服账号不能为空" dc:"客服账号"`
|
||||
Platform account.Platform `json:"platform" v:"required#平台不能为空" dc:"平台"`
|
||||
QuestionContent string `json:"questionContent" dc:"问题内容"`
|
||||
}
|
||||
|
||||
type AccountHttpConnectRes struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// AccountWebSocketConnectReq WebSocket 连接请求
|
||||
type AccountWebSocketConnectReq struct {
|
||||
g.Meta `path:"/accountConnect" method:"get" tags:"AccountWebSocket" summary:"WebSocket连接" dc:"建立WebSocket连接,用于实时消息推送"`
|
||||
UserId string `json:"userId" v:"required#用户ID不能为空" dc:"用户ID"`
|
||||
AccountCode string `json:"accountCode" v:"required#客服账号不能为空" dc:"客服账号"`
|
||||
Platform account.Platform `json:"platform" v:"required#平台不能为空" dc:"平台"`
|
||||
QuestionContent string `json:"questionContent" dc:"问题内容"`
|
||||
}
|
||||
|
||||
type AccountWebSocketConnectRes struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package dto
|
||||
|
||||
type AddAccountUserDialogReq struct {
|
||||
AccountId int64 `json:"accountId" v:"required#accountId不能为空" dc:"客服id"`
|
||||
UserId string `json:"userId" v:"required#userId不能为空" dc:"用户id"`
|
||||
DialogCount int64 `json:"dialogCount" v:"required#dialogCount不能为空" dc:"对话次数"`
|
||||
}
|
||||
|
||||
type UpdateAccountUserDialogReq struct {
|
||||
Id int64 `json:"id" v:"required#id不能为空"`
|
||||
DialogCount int64 `json:"dialogCount" v:"required#dialogCount不能为空" dc:"对话次数"`
|
||||
}
|
||||
|
||||
type DeleteAccountUserDialogReq struct {
|
||||
Id int64 `json:"id" v:"required#id不能为空"`
|
||||
}
|
||||
|
||||
type GetAccountUserDialogReq struct {
|
||||
AccountId int64 `json:"accountId" v:"required#accountId不能为空" dc:"客服id"`
|
||||
UserId string `json:"userId" v:"required#userId不能为空" dc:"用户id"`
|
||||
}
|
||||
|
||||
type AccountUserDialogVO struct {
|
||||
Id int64 `json:"id,string" dc:"id"`
|
||||
AccountId int64 `json:"accountId,string" dc:"客服id"`
|
||||
UserId string `json:"userId" dc:"用户id"`
|
||||
DialogCount int64 `json:"dialogCount" dc:"对话次数"`
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package dto
|
||||
|
||||
import "github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
// ArchiveRunReq 手动触发归档请求
|
||||
type ArchiveRunReq struct {
|
||||
g.Meta `path:"/run" method:"post" tags:"归档管理" summary:"手动触发归档" dc:"立即执行月度归档任务"`
|
||||
}
|
||||
|
||||
// ArchiveRunRes 手动触发归档响应
|
||||
type ArchiveRunRes struct {
|
||||
Message string `json:"message"` // 执行结果消息
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"customer-server/model/entity"
|
||||
|
||||
"gitea.com/red-future/common/beans"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// AddCustomerServiceAccountReq 添加客服账号
|
||||
type AddCustomerServiceAccountReq struct {
|
||||
g.Meta `path:"/add" method:"post" tags:"客服账号管理" summary:"添加客服账号" dc:"创建新的客服账号"` // 路由: POST /customerserviceaccount/add
|
||||
AccountName string `json:"accountName" v:"required" dc:"客服账号名称" example:"cs_xhs_qixue"`
|
||||
Platform string `json:"platform" v:"required" dc:"客服平台" example:"小红书"`
|
||||
Prompt *string `json:"prompt" dc:"可选:自定义提示词"`
|
||||
Greeting *string `json:"greeting" dc:"可选:开场白"`
|
||||
SelfIdentity *string `json:"selfIdentity" dc:"可选:AI身份描述"`
|
||||
SpeechcraftIds []string `json:"speechcraftIds" dc:"绑定的话术ID列表(为空则绑定该租户下所有话术)"`
|
||||
TenantId interface{} `json:"tenantId" dc:"租户ID(可选,不传则使用session中的租户ID)"`
|
||||
}
|
||||
|
||||
type AddCustomerServiceAccountRes struct {
|
||||
Id string `json:"id" dc:"新创建的客服账号ID"`
|
||||
}
|
||||
|
||||
// UpdateCustomerServiceAccountReq 更新客服账号
|
||||
type UpdateCustomerServiceAccountReq struct {
|
||||
g.Meta `path:"/update" method:"post" tags:"客服账号管理" summary:"更新客服账号" dc:"更新客服账号信息"` // 路由: POST /customerserviceaccount/update
|
||||
Id string `json:"id" v:"required" dc:"客服账号ID"`
|
||||
AccountName string `json:"accountName" dc:"客服账号名称"`
|
||||
Platform string `json:"platform" dc:"客服平台"`
|
||||
Prompt *string `json:"prompt" dc:"自定义提示词(可选)"`
|
||||
Greeting *string `json:"greeting" dc:"开场白(可选)"`
|
||||
SelfIdentity *string `json:"selfIdentity" dc:"AI身份描述(可选)"`
|
||||
}
|
||||
|
||||
// ToggleCustomerServiceAccountStatusReq 切换客服账号状态(启用/禁用)
|
||||
type ToggleCustomerServiceAccountStatusReq struct {
|
||||
g.Meta `path:"/toggleStatus" method:"post" tags:"客服账号管理" summary:"切换客服账号状态" dc:"根据当前状态在启用/禁用之间切换客服账号"` // 路由: POST /customerserviceaccount/toggleStatus
|
||||
Id string `json:"id" v:"required" dc:"客服账号ID"`
|
||||
}
|
||||
|
||||
// GetCustomerServiceAccountReq 获取客服账号详情
|
||||
type GetCustomerServiceAccountReq struct {
|
||||
g.Meta `path:"/one" method:"get" tags:"客服账号管理" summary:"获取客服账号详情" dc:"根据ID获取单个客服账号信息"` // 路由: GET /customerserviceaccount/one
|
||||
Id string `json:"id" v:"required" dc:"客服账号ID"`
|
||||
}
|
||||
|
||||
// ListCustomerServiceAccountReq 获取客服账号列表
|
||||
type ListCustomerServiceAccountReq struct {
|
||||
g.Meta `path:"/list" method:"get" tags:"客服账号管理" summary:"获取客服账号列表" dc:"分页查询客服账号列表,支持多条件筛选"` // 路由: GET /customerserviceaccount/list
|
||||
beans.Page
|
||||
AccountName string `json:"accountName" dc:"客服账号名称"`
|
||||
IsDisabled *bool `json:"isDisabled" dc:"筛选:是否禁用(true-禁用,false-启用)"`
|
||||
Platform string `json:"platform" dc:"筛选:平台"`
|
||||
}
|
||||
|
||||
// RecreateRAGFlowConfigReq 重新创建RAGFlow配置
|
||||
type RecreateRAGFlowConfigReq struct {
|
||||
g.Meta `path:"/recreateRAGFlow" method:"post" tags:"客服账号管理" summary:"重新创建RAGFlow配置" dc:"为已存在的客服账号重新创建RAGFlow知识库和Chat配置"` // 路由: POST /customerserviceaccount/recreateRAGFlow
|
||||
AccountName string `json:"accountName" v:"required" dc:"客服账号名称" example:"cs_xhs_qixue"`
|
||||
Platform string `json:"platform" v:"required" dc:"客服平台" example:"xiaohongshu"`
|
||||
}
|
||||
|
||||
// UpdateGreetingReq 更新开场白
|
||||
type UpdateGreetingReq struct {
|
||||
g.Meta `path:"/updateGreeting" method:"post" tags:"客服账号管理" summary:"更新开场白" dc:"更新客服账号的WebSocket连接开场白"` // 路由: POST /customerserviceaccount/updateGreeting
|
||||
AccountName string `json:"accountName" v:"required" dc:"客服账号名称" example:"cs_xhs_qixue"`
|
||||
Greeting string `json:"greeting" v:"required" dc:"开场白内容" example:"亲爱的,欢迎光临!🌸\n如果你有月经不调或气血不足的问题,随时可以问我哦!"`
|
||||
}
|
||||
|
||||
type ListCustomerServiceAccountRes struct {
|
||||
List []*entity.CustomerServiceAccount `json:"list" dc:"客服账号列表"`
|
||||
Total int `json:"total" dc:"总记录数"`
|
||||
}
|
||||
|
||||
// DeleteCustomerServiceAccountReq 删除客服账号
|
||||
type DeleteCustomerServiceAccountReq struct {
|
||||
g.Meta `path:"/delete" method:"post" tags:"客服账号管理" summary:"删除客服账号" dc:"软删除客服账号,同时删除RAGFlow Chat实例"`
|
||||
Id string `json:"id" v:"required" dc:"客服账号ID"`
|
||||
}
|
||||
|
||||
type DeleteCustomerServiceAccountRes struct {
|
||||
Success bool `json:"success" dc:"是否成功"`
|
||||
Message string `json:"message" dc:"提示信息"`
|
||||
}
|
||||
|
||||
// GetAccessibleSpeechcraftsReq 获取客服账号可访问的话术列表
|
||||
type GetAccessibleSpeechcraftsReq struct {
|
||||
g.Meta `path:"/speechcrafts/:accountName" method:"get" tags:"客服账号管理" summary:"获取可访问话术" dc:"获取客服账号可访问的话术列表(动态权限)"`
|
||||
AccountName string `json:"accountName" v:"required" dc:"客服账号名称"`
|
||||
}
|
||||
|
||||
type GetAccessibleSpeechcraftsRes struct {
|
||||
Speechcrafts []SpeechcraftItem `json:"speechcrafts" dc:"话术列表"`
|
||||
TotalCount int `json:"totalCount" dc:"总数"`
|
||||
Permission string `json:"permission" dc:"权限类型:dynamic=所有话术,specified=指定话术"`
|
||||
}
|
||||
|
||||
type SpeechcraftItem struct {
|
||||
Id string `json:"id"`
|
||||
Tag string `json:"tag"`
|
||||
Content string `json:"content"`
|
||||
Direction string `json:"direction"`
|
||||
Platform string `json:"platform"`
|
||||
Keywords []string `json:"keywords"`
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"customer-server/model/entity"
|
||||
|
||||
"gitea.com/red-future/common/beans"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// AddDataReq 添加数据
|
||||
type AddDataReq struct {
|
||||
g.Meta `path:"/add" method:"post" tags:"数据管理" summary:"添加数据" dc:"记录客服与客户的交互数据"` // 路由: POST /data/add
|
||||
CustomerId string `json:"customerId" v:"required"` // 客户ID
|
||||
AccountName string `json:"accountName" v:"required"` // 客服账号名称
|
||||
CustomerServicePlatform string `json:"customerServicePlatform" v:"required"` // 客服平台
|
||||
CustomerServiceName string `json:"customerServiceName" v:"required"` // 客服名称
|
||||
IsInbound bool `json:"isInbound"` // 是否进线
|
||||
IsActive bool `json:"isActive"` // 是否活跃
|
||||
IsServed bool `json:"isServed"` // 是否接待
|
||||
HasSentContactCard bool `json:"hasSentContactCard"` // 是否发联系卡
|
||||
HasSentNameCard bool `json:"hasSentNameCard"` // 是否发名片
|
||||
HasLeftContactInfo bool `json:"hasLeftContactInfo"` // 是否留资
|
||||
SessionStartTime int64 `json:"sessionStartTime"` // 会话开始时间
|
||||
MessageTime int64 `json:"messageTime"` // 消息时间
|
||||
}
|
||||
|
||||
type AddDataRes struct {
|
||||
Id string `json:"id"`
|
||||
}
|
||||
|
||||
// UpdateDataReq 更新数据
|
||||
type UpdateDataReq struct {
|
||||
g.Meta `path:"/update" method:"post" tags:"数据管理" summary:"更新数据" dc:"更新客服交互数据"` // 路由: POST /data/update
|
||||
Id string `json:"id" v:"required"` // ID
|
||||
CustomerId string `json:"customerId"`
|
||||
AccountName string `json:"accountName"`
|
||||
CustomerServicePlatform string `json:"customerServicePlatform"`
|
||||
CustomerServiceName string `json:"customerServiceName"`
|
||||
IsInbound *bool `json:"isInbound"` // 使用指针以区分 false 和未传值
|
||||
IsActive *bool `json:"isActive"`
|
||||
IsServed *bool `json:"isServed"`
|
||||
HasSentContactCard *bool `json:"hasSentContactCard"`
|
||||
HasSentNameCard *bool `json:"hasSentNameCard"`
|
||||
HasLeftContactInfo *bool `json:"hasLeftContactInfo"`
|
||||
SessionStartTime int64 `json:"sessionStartTime"`
|
||||
MessageTime int64 `json:"messageTime"`
|
||||
}
|
||||
|
||||
// // DeleteDataReq 删除数据
|
||||
// type DeleteDataReq struct {
|
||||
// g.Meta `path:"/delete" method:"post" tags:"数据管理" summary:"删除数据" dc:"删除指定数据记录"` // 路由: POST /data/delete
|
||||
// Id string `json:"id" v:"required"` // ID
|
||||
// }
|
||||
|
||||
// GetDataReq 获取单个数据
|
||||
type GetDataReq struct {
|
||||
g.Meta `path:"/one" method:"get" tags:"数据管理" summary:"获取数据详情" dc:"根据ID获取单条数据记录"` // 路由: GET /data/one
|
||||
Id string `json:"id" v:"required"` // ID
|
||||
}
|
||||
|
||||
// ListDataReq 获取数据列表
|
||||
type ListDataReq struct {
|
||||
g.Meta `path:"/list" method:"get" tags:"数据管理" summary:"获取数据列表" dc:"分页查询交互数据,支持按客户、客服、时间筛选"` // 路由: GET /data/list
|
||||
beans.Page
|
||||
CustomerId string `json:"customerId" p:"customerId" dc:"筛选:客户ID"` // 筛选:客户ID
|
||||
AccountName *string `json:"accountName" dc:"客服账号名称"` // 筛选:客服ID
|
||||
StartDate string `json:"startDate" p:"startDate" dc:"筛选:开始日期(YYYY-MM-DD)"` // 筛选:开始日期
|
||||
EndDate string `json:"endDate" p:"endDate" dc:"筛选:结束日期(YYYY-MM-DD)"` // 筛选:结束日期
|
||||
}
|
||||
|
||||
type ListDataRes struct {
|
||||
List []*entity.Data `json:"list"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"customer-server/model/entity"
|
||||
|
||||
"gitea.com/red-future/common/beans"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// AddDataStatisticsReq 添加数据统计
|
||||
type AddDataStatisticsReq struct {
|
||||
g.Meta `path:"/add" method:"post" tags:"数据统计" summary:"添加统计数据" dc:"记录每日客服的汇总统计数据"` // 路由: POST /data/statistics/add
|
||||
Date string `json:"date" v:"required|date-format:2006-01-02"` // 日期,格式:YYYY-MM-DD
|
||||
AccountName string `json:"accountName" v:"required"` // 客服账号名称
|
||||
CustomerServiceName string `json:"customerServiceName" v:"required"` // 客服名称
|
||||
CustomerServicePlatform string `json:"customerServicePlatform" v:"required"` // 客服平台
|
||||
InboundCount int `json:"inboundCount"` // 进线数
|
||||
ActiveCount int `json:"activeCount"` // 开口数
|
||||
ServedCount int `json:"servedCount"` // 接待数
|
||||
ContactCardSentCount int `json:"contactCardSentCount"` // 发名片数
|
||||
NameCardSentCount int `json:"nameCardSentCount"` // 发留资卡数
|
||||
LeftContactInfoCount int `json:"leftContactInfoCount"` // 留资数
|
||||
ResponseRate30s float64 `json:"responseRate30s"` // 30s响应率
|
||||
ResponseRate60s float64 `json:"responseRate60s"` // 60s响应率
|
||||
ResponseRate360s float64 `json:"responseRate360s"` // 3分钟响应率
|
||||
}
|
||||
|
||||
type AddDataStatisticsRes struct {
|
||||
Id string `json:"id"`
|
||||
}
|
||||
|
||||
// UpdateDataStatisticsReq 更新数据统计
|
||||
type UpdateDataStatisticsReq struct {
|
||||
g.Meta `path:"/update" method:"post" tags:"数据统计" summary:"更新统计数据" dc:"更新每日统计数据"` // 路由: POST /data/statistics/update
|
||||
Id string `json:"id" v:"required"`
|
||||
Date string `json:"date" v:"date-format:2006-01-02"`
|
||||
AccountName string `json:"accountName"`
|
||||
CustomerServiceName string `json:"customerServiceName"`
|
||||
CustomerServicePlatform string `json:"customerServicePlatform"`
|
||||
InboundCount *int `json:"inboundCount"`
|
||||
ActiveCount *int `json:"activeCount"`
|
||||
ServedCount *int `json:"servedCount"`
|
||||
ContactCardSentCount *int `json:"contactCardSentCount"`
|
||||
NameCardSentCount *int `json:"nameCardSentCount"`
|
||||
LeftContactInfoCount *int `json:"leftContactInfoCount"`
|
||||
ResponseRate30s *float64 `json:"responseRate30s"`
|
||||
ResponseRate60s *float64 `json:"responseRate60s"`
|
||||
ResponseRate360s *float64 `json:"responseRate360s"`
|
||||
}
|
||||
|
||||
// ListDataStatisticsReq 获取数据统计列表
|
||||
type ListDataStatisticsReq struct {
|
||||
g.Meta `path:"/list" method:"get" tags:"数据统计" summary:"获取统计列表" dc:"分页查询统计数据,支持按平台、日期范围筛选"` // 路由: GET /data/statistics/list
|
||||
beans.Page
|
||||
// 同时写json和p ,同时支持get的 query解析和post的json解析
|
||||
CustomerServicePlatform string `json:"customerServicePlatform" p:"customerServicePlatform" dc:"筛选:客服平台"` // 筛选:客服平台
|
||||
StartDate string `json:"startDate" p:"startDate" dc:"筛选:开始日期(YYYY-MM-DD)"` // 筛选:开始日期
|
||||
EndDate string `json:"endDate" p:"endDate" dc:"筛选:结束日期(YYYY-MM-DD)"` // 筛选:结束日期
|
||||
}
|
||||
|
||||
type ListDataStatisticsRes struct {
|
||||
List []*entity.DataStatistics `json:"list"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// ExportDataStatisticsReq 导出请求复用列表请求(忽略分页参数)
|
||||
type ExportDataStatisticsReq struct {
|
||||
g.Meta `path:"/export" method:"get" tags:"数据统计" summary:"导出统计数据" dc:"导出数据统计为ZIP文件(包含TXT)"` // 路由: GET /data/statistics/export
|
||||
// 同时写json和p,同时支持get的query解析和post的json解析
|
||||
CustomerServicePlatform string `json:"customerServicePlatform" p:"customerServicePlatform" dc:"筛选:客服平台"` // 筛选:客服平台
|
||||
StartDate string `json:"startDate" p:"startDate" dc:"筛选:开始日期(YYYY-MM-DD)"` // 筛选:开始日期
|
||||
EndDate string `json:"endDate" p:"endDate" dc:"筛选:结束日期(YYYY-MM-DD)"` // 筛选:结束日期
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
// Package dto - 产品DTO
|
||||
// 功能:产品的增删改查、导入/导出、绑定/解绑客服账号的请求响应结构体
|
||||
package dto
|
||||
|
||||
import (
|
||||
"customer-server/model/entity"
|
||||
|
||||
"gitea.com/red-future/common/beans"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// AddProductReq 添加产品
|
||||
type AddProductReq struct {
|
||||
g.Meta `path:"/add" method:"post" tags:"产品管理" summary:"添加产品" dc:"创建新的产品信息"` // 路由: POST /product/add
|
||||
Name string `json:"name" v:"required"` // 产品名称
|
||||
Description string `json:"description"` // 产品详情
|
||||
}
|
||||
|
||||
type AddProductRes struct {
|
||||
Id string `json:"id"`
|
||||
}
|
||||
|
||||
// UpdateProductReq 更新产品
|
||||
type UpdateProductReq struct {
|
||||
g.Meta `path:"/update" method:"post" tags:"产品管理" summary:"更新产品" dc:"更新产品信息"` // 路由: POST /product/update
|
||||
Id string `json:"id" v:"required"` // ID
|
||||
Name string `json:"name"` // 产品名称
|
||||
Description string `json:"description"` // 产品详情
|
||||
}
|
||||
|
||||
// DeleteProductReq 删除产品
|
||||
type DeleteProductReq struct {
|
||||
g.Meta `path:"/delete" method:"post" tags:"产品管理" summary:"删除产品" dc:"删除指定产品"` // 路由: POST /product/delete
|
||||
Id string `json:"id" v:"required"` // ID
|
||||
}
|
||||
|
||||
// GetProductReq 获取单个产品
|
||||
type GetProductReq struct {
|
||||
g.Meta `path:"/one" method:"get" tags:"产品管理" summary:"获取产品详情" dc:"根据ID获取单个产品信息"` // 路由: GET /product/one
|
||||
Id string `p:"id" v:"required"` // ID
|
||||
}
|
||||
|
||||
// ListProductReq 获取产品列表
|
||||
type ListProductReq struct {
|
||||
g.Meta `path:"/list" method:"get" tags:"产品管理" summary:"获取产品列表" dc:"分页查询产品列表,支持按名称筛选"` // 路由: GET /product/list
|
||||
beans.Page
|
||||
Name string `p:"name"` // 筛选:产品名称
|
||||
}
|
||||
|
||||
type ListProductRes struct {
|
||||
List []*entity.Product `json:"list"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// ExportProductReq 导出产品
|
||||
type ExportProductReq struct {
|
||||
g.Meta `path:"/export" method:"get" tags:"产品管理" summary:"导出产品" dc:"导出产品为TXT格式的ZIP压缩包,支持按名称筛选" produces:"application/zip"` // 路由: GET /product/export
|
||||
Name string `p:"name" dc:"筛选:产品名称(支持模糊查询,为空则导出所有)"` // 筛选:产品名称
|
||||
}
|
||||
|
||||
type ExportProductRes struct {
|
||||
// 实际返回的是文件流,这个结构体只是为了 Swagger 文档
|
||||
}
|
||||
|
||||
// ImportProductReq 导入产品
|
||||
type ImportProductReq struct {
|
||||
g.Meta `path:"/import" method:"post" tags:"产品管理" summary:"导入产品" dc:"上传ZIP文件批量导入产品(TXT格式)" consumes:"multipart/form-data"` // 路由: POST /product/import
|
||||
File string `json:"file" type:"file" dc:"上传的ZIP文件" v:"required"` // ZIP文件
|
||||
}
|
||||
|
||||
type ImportProductRes struct {
|
||||
SuccessCount int `json:"successCount"` // 成功导入数量
|
||||
FailCount int `json:"failCount"` // 失败数量
|
||||
FailReasons []string `json:"failReasons"` // 失败原因列表
|
||||
}
|
||||
|
||||
// BindProductReq 绑定产品到客服账号请求
|
||||
type BindProductReq struct {
|
||||
g.Meta `path:"/bind" method:"post" tags:"产品管理" summary:"绑定产品" dc:"将产品绑定到客服账号"`
|
||||
ProductId string `json:"productId" v:"required#产品ID不能为空"`
|
||||
AccountNames []string `json:"accountNames" v:"required" dc:"客服账号名称列表"`
|
||||
}
|
||||
|
||||
// BindProductRes 绑定产品响应
|
||||
type BindProductRes struct {
|
||||
SuccessCount int `json:"successCount"` // 成功绑定数量
|
||||
FailedIds []string `json:"failedIds"` // 失败的客服账号ID列表
|
||||
Message string `json:"message"` // 提示信息
|
||||
}
|
||||
|
||||
// UnbindProductReq 解绑产品请求
|
||||
type UnbindProductReq struct {
|
||||
g.Meta `path:"/unbind" method:"post" tags:"产品管理" summary:"解绑产品" dc:"将产品从客服账号解绑"`
|
||||
ProductId string `json:"productId" v:"required#产品ID不能为空"`
|
||||
AccountName string `json:"accountName" v:"required" dc:"客服账号名称"`
|
||||
}
|
||||
|
||||
// UnbindProductRes 解绑产品响应
|
||||
type UnbindProductRes struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
// Package dto - RAGFlow对话配置DTO
|
||||
// 功能:定义更新对话配置提示词的请求响应结构体
|
||||
package dto
|
||||
|
||||
// UpdateChatPromptReq 更新对话配置提示词请求
|
||||
type UpdateChatPromptReq struct {
|
||||
ChatId string `json:"chatId" v:"required#对话配置ID不能为空"`
|
||||
Prompt string `json:"prompt" v:"required#提示词不能为空"`
|
||||
SimilarityThreshold float64 `json:"similarityThreshold"` // 相似度阈值(0.0-1.0,默认0.2)
|
||||
KeywordsSimilarityWeight float64 `json:"keywordsSimilarityWeight"` // 关键词权重(0.0-1.0,默认0.7)
|
||||
TopN int `json:"topN"` // 返回chunk数量(默认8)
|
||||
EmptyResponse string `json:"emptyResponse"` // 无匹配时回复
|
||||
}
|
||||
|
||||
// UpdateChatPromptRes 更新对话配置提示词响应
|
||||
type UpdateChatPromptRes struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package dto
|
||||
|
||||
import "github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
// GetRAGFlowConfigReq 获取RAGFlow配置请求
|
||||
type GetRAGFlowConfigReq struct {
|
||||
g.Meta `path:"/get" method:"get" tags:"RAGFlow配置" summary:"获取RAGFlow配置"`
|
||||
AccountName string `json:"accountName" v:"required" dc:"客服账号名称"`
|
||||
}
|
||||
|
||||
// GetRAGFlowConfigRes 获取RAGFlow配置响应
|
||||
type GetRAGFlowConfigRes struct {
|
||||
AccountName string `json:"accountName"`
|
||||
Platform string `json:"platform"`
|
||||
DatasetId string `json:"datasetId"`
|
||||
DatasetName string `json:"datasetName"`
|
||||
ChatId string `json:"chatId"`
|
||||
Prompt string `json:"prompt"`
|
||||
DocumentIds []string `json:"documentIds"`
|
||||
SimilarityThreshold float64 `json:"similarityThreshold"`
|
||||
KeywordsSimilarityWeight float64 `json:"keywordsSimilarityWeight"`
|
||||
TopN int `json:"topN"`
|
||||
EmptyResponse string `json:"emptyResponse"`
|
||||
}
|
||||
|
||||
// UpdatePromptReq 更新提示词请求
|
||||
type UpdatePromptReq struct {
|
||||
g.Meta `path:"/updatePrompt" method:"post" tags:"RAGFlow配置" summary:"更新提示词"`
|
||||
AccountName string `json:"accountName" v:"required" dc:"客服账号名称"`
|
||||
Prompt string `json:"prompt" v:"required" dc:"提示词内容"`
|
||||
SimilarityThreshold *float64 `json:"similarityThreshold" dc:"相似度阈值"`
|
||||
KeywordsSimilarityWeight *float64 `json:"keywordsSimilarityWeight" dc:"关键词权重"`
|
||||
TopN *int `json:"topN" dc:"返回chunk数量"`
|
||||
EmptyResponse *string `json:"emptyResponse" dc:"无匹配时回复"`
|
||||
}
|
||||
|
||||
// UpdatePromptRes 更新提示词响应
|
||||
type UpdatePromptRes struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
// Package dto - RAGFlow同步相关DTO
|
||||
// 功能:定义RAGFlow同步、重试消息的请求响应结构体
|
||||
package dto
|
||||
|
||||
// SyncToRAGFlowReq 同步到RAGFlow请求
|
||||
type SyncToRAGFlowReq struct {
|
||||
SpeechcraftIds []string `json:"speechcraftIds" v:"required#话术ID列表不能为空"` // 话术ID列表
|
||||
ProductIds []string `json:"productIds"` // 产品ID列表(可选)
|
||||
ForceSync bool `json:"forceSync"` // 是否强制重新同步(已同步的也重新上传)
|
||||
}
|
||||
|
||||
// SyncToRAGFlowRes 同步到RAGFlow响应
|
||||
type SyncToRAGFlowRes struct {
|
||||
TotalCount int `json:"totalCount"` // 总数
|
||||
SuccessCount int `json:"successCount"` // 成功数量
|
||||
FailedCount int `json:"failedCount"` // 失败数量
|
||||
FailedIds []string `json:"failedIds"` // 失败的ID列表
|
||||
Message string `json:"message"` // 提示信息
|
||||
}
|
||||
|
||||
// RAGFlowSyncRetryMsg RAGFlow同步重试消息(RabbitMQ)
|
||||
// 说明:租户级知识库,dataset_id从租户配置中获取
|
||||
type RAGFlowSyncRetryMsg struct {
|
||||
Type string `json:"type"` // speechcraft or product
|
||||
Id string `json:"id"` // 话术/产品ID
|
||||
AccountName string `json:"accountName"` // 客服账号名称
|
||||
TenantId string `json:"tenantId"` // 租户ID(用于查询知识库)
|
||||
RetryCount int `json:"retryCount"` // 当前重试次数
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Package dto - 预制话术DTO
|
||||
// 功能:预制话术的增删改查请求响应结构体
|
||||
package dto
|
||||
|
||||
import (
|
||||
"customer-server/consts/scriptedSpeech"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/beans"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// AddScriptedSpeechReq 添加预制话术
|
||||
type AddScriptedSpeechReq struct {
|
||||
g.Meta `path:"/add" method:"post" tags:"预制话术管理" summary:"添加预制话术" dc:"创建新的预制话术"`
|
||||
|
||||
DatasetId int64 `json:"datasetId" v:"required#数据集ID不能为空" dc:"数据集ID"`
|
||||
SceneType scriptedSpeech.SceneType `json:"sceneType" v:"required#场景类型不能为空" dc:"场景类型"`
|
||||
QuestionContent string `json:"questionContent" v:"required#问题内容不能为空" dc:"问题内容"`
|
||||
}
|
||||
|
||||
type AddScriptedSpeechRes struct {
|
||||
Id int64 `json:"id"`
|
||||
}
|
||||
|
||||
// UpdateScriptedSpeechReq 更新预制话术
|
||||
type UpdateScriptedSpeechReq struct {
|
||||
g.Meta `path:"/update" method:"post" tags:"预制话术管理" summary:"更新预制话术" dc:"更新预制话术内容"`
|
||||
|
||||
Id int64 `json:"id" v:"required#预制话术ID不能为空" dc:"预制话术ID"`
|
||||
QuestionContent string `json:"questionContent" dc:"问题内容"`
|
||||
}
|
||||
|
||||
// DeleteScriptedSpeechReq 删除预制话术
|
||||
type DeleteScriptedSpeechReq struct {
|
||||
g.Meta `path:"/delete" method:"post" tags:"预制话术管理" summary:"删除预制话术" dc:"删除指定预制话术"`
|
||||
|
||||
Id int64 `json:"id" v:"required#预制话术ID不能为空" dc:"预制话术ID"`
|
||||
}
|
||||
|
||||
// GetScriptedSpeechReq 获取单个预制话术
|
||||
type GetScriptedSpeechReq struct {
|
||||
g.Meta `path:"/getOne" method:"get" tags:"预制话术管理" summary:"获取预制话术详情" dc:"根据ID获取单个预制话术"`
|
||||
|
||||
Id int64 `json:"id" v:"required#预制话术ID不能为空" dc:"预制话术ID"`
|
||||
}
|
||||
|
||||
// ListScriptedSpeechReq 获取预制话术列表
|
||||
type ListScriptedSpeechReq struct {
|
||||
g.Meta `path:"/list" method:"get" tags:"预制话术管理" summary:"获取预制话术列表" dc:"分页查询预制话术,支持按账号ID、数据集ID筛选"`
|
||||
|
||||
Page *beans.Page `json:"page"`
|
||||
DatasetId int64 `json:"datasetId" dc:"数据集ID"`
|
||||
DatasetIds []int64 `json:"datasetIds" dc:"数据集ID列表"`
|
||||
SceneType scriptedSpeech.SceneType `json:"sceneType" dc:"场景类型"`
|
||||
}
|
||||
|
||||
type ListScriptedSpeechRes struct {
|
||||
List []*ScriptedSpeechVO `json:"list"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// ScriptedSpeechVO 预制话术视图对象
|
||||
type ScriptedSpeechVO struct {
|
||||
Id int64 `json:"id,string"`
|
||||
DatasetId int64 `json:"datasetId,string"`
|
||||
SceneType scriptedSpeech.SceneType `json:"sceneType"`
|
||||
QuestionContent string `json:"questionContent"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
// RagListDatasetReq 数据集列表请求
|
||||
type RagListDatasetReq struct {
|
||||
Ids []int64 `json:"ids" dc:"数据集ID列表"`
|
||||
}
|
||||
|
||||
// RagListDatasetRes 数据集列表响应
|
||||
type RagListDatasetRes struct {
|
||||
List []*RagDatasetVO `json:"list"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
type RagDatasetVO struct {
|
||||
Id int64 `json:"id,string" dc:"id"`
|
||||
Name string `json:"name" dc:"数据集名称"`
|
||||
Description string `json:"description" dc:"数据集描述"`
|
||||
}
|
||||
|
||||
// RagQueryReq RAG查询请求
|
||||
type RagQueryReq struct {
|
||||
g.Meta `path:"/ragQuery" method:"post" tags:"RAG查询" summary:"执行RAG查询" dc:"执行RAG查询"`
|
||||
|
||||
Content string `json:"content" v:"required#查询内容不能为空" dc:"用户问题"`
|
||||
DatasetIds []int64 `json:"datasetIds" dc:"数据集ID"`
|
||||
History []*Message `json:"history" dc:"历史对话"`
|
||||
TopK int `json:"topK" d:"5" dc:"检索topK,默认5"`
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// RagQueryRes RAG查询响应
|
||||
type RagQueryRes struct {
|
||||
Answer string `json:"answer" dc:"生成的答案"`
|
||||
}
|
||||
|
||||
type RAGListKeywordReq struct {
|
||||
Words []string `json:"words"`
|
||||
}
|
||||
|
||||
// RAGListKeywordRes 关键词列表响应
|
||||
type RAGListKeywordRes struct {
|
||||
List []*RAGKeywordVO `json:"list"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
type RAGKeywordVO struct {
|
||||
Id int64 `json:"id,string" dc:"id"`
|
||||
Word string `json:"word" dc:"关键词名称"`
|
||||
Weight int16 `json:"weight" dc:"权重"`
|
||||
DatasetId int64 `json:"datasetId,string" dc:"数据集ID"`
|
||||
DocumentId int64 `json:"documentId,string" dc:"文档ID"`
|
||||
CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"`
|
||||
UpdatedAt *gtime.Time `json:"updatedAt" dc:"更新时间"`
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
// Package dto - 话术DTO
|
||||
// 功能:话术的增删改查、绑定/解绑客服账号的请求响应结构体
|
||||
package dto
|
||||
|
||||
import (
|
||||
"customer-server/model/entity"
|
||||
|
||||
"gitea.com/red-future/common/beans"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// AddSpeechcraftReq 添加话术
|
||||
type AddSpeechcraftReq struct {
|
||||
g.Meta `path:"/add" method:"post" tags:"话术管理" summary:"添加话术" dc:"创建新的客服话术模板"` // 路由: POST /speechcraft/add
|
||||
Tag string `json:"tag" v:"required"` // 标签
|
||||
Content string `json:"content" v:"required"` // 内容
|
||||
Direction string `json:"direction"` // 咨询方向(气血、减肥、护肤等)
|
||||
AccountName string `json:"accountName,omitempty"` // 客服账号名称(可选,用于绕过gateway时查询tenantId)
|
||||
TenantId interface{} `json:"tenantId,omitempty"` // 租户ID(可选,不传则使用默认值1)
|
||||
|
||||
// 状态机字段
|
||||
Stage int `json:"stage"` // 触发阶段(0=初始)
|
||||
Status string `json:"status"` // 触发行为(click/keyword/空=任意)
|
||||
Keywords []string `json:"keywords"` // 触发关键字(空=任意)
|
||||
NextStage int `json:"nextStage"` // 下一阶段(-1=结束)
|
||||
Platform string `json:"platform"` // 平台(xiaohongshu)
|
||||
}
|
||||
|
||||
type AddSpeechcraftRes struct {
|
||||
Id string `json:"id"`
|
||||
}
|
||||
|
||||
// UpdateSpeechcraftReq 更新话术
|
||||
type UpdateSpeechcraftReq struct {
|
||||
g.Meta `path:"/update" method:"post" tags:"话术管理" summary:"更新话术" dc:"更新话术模板内容"` // 路由: POST /speechcraft/update
|
||||
Id string `json:"id" v:"required"` // ID
|
||||
Tag string `json:"tag"` // 标签
|
||||
Content string `json:"content"` // 内容
|
||||
|
||||
// 状态机字段
|
||||
Stage *int `json:"stage"` // 触发阶段
|
||||
Status *string `json:"status"` // 触发行为
|
||||
Keywords []string `json:"keywords"` // 触发关键字
|
||||
NextStage *int `json:"nextStage"` // 下一阶段
|
||||
Platform *string `json:"platform"` // 平台
|
||||
}
|
||||
|
||||
// DeleteSpeechcraftReq 删除话术
|
||||
type DeleteSpeechcraftReq struct {
|
||||
g.Meta `path:"/delete" method:"post" tags:"话术管理" summary:"删除话术" dc:"删除指定话术模板"` // 路由: POST /speechcraft/delete
|
||||
Id string `json:"id" v:"required"` // ID
|
||||
}
|
||||
|
||||
// GetSpeechcraftReq 获取单个话术
|
||||
type GetSpeechcraftReq struct {
|
||||
g.Meta `path:"/one" method:"get" tags:"话术管理" summary:"获取话术详情" dc:"根据ID获取单个话术模板"` // 路由: GET /speechcraft/one
|
||||
Id string `json:"id" v:"required"` // ID
|
||||
}
|
||||
|
||||
// ListSpeechcraftReq 获取话术列表
|
||||
type ListSpeechcraftReq struct {
|
||||
g.Meta `path:"/list" method:"get" tags:"话术管理" summary:"获取话术列表" dc:"分页查询话术模板,支持按标签、内容筛选"` // 路由: GET /speechcraft/list
|
||||
beans.Page
|
||||
Tag string `json:"tag"` // 筛选:标签
|
||||
Content string `json:"content"` // 筛选:内容
|
||||
Stage *int `json:"stage"` // 筛选:阶段
|
||||
Platform string `json:"platform"` // 筛选:平台
|
||||
}
|
||||
|
||||
type ListSpeechcraftRes struct {
|
||||
List []*entity.Speechcraft `json:"list"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// BindSpeechcraftReq 绑定话术到客服账号请求
|
||||
type BindSpeechcraftReq struct {
|
||||
g.Meta `path:"/bind" method:"post" tags:"话术管理" summary:"绑定话术" dc:"将话术绑定到客服账号"`
|
||||
SpeechcraftId string `json:"speechcraftId" v:"required#话术ID不能为空"`
|
||||
AccountNames []string `json:"accountNames" v:"required#客服账号名称列表不能为空"`
|
||||
}
|
||||
|
||||
// BindSpeechcraftRes 绑定话术响应
|
||||
type BindSpeechcraftRes struct {
|
||||
SuccessCount int `json:"successCount"` // 成功绑定数量
|
||||
AlreadyBound []string `json:"alreadyBound"` // 已绑定的客服账号ID
|
||||
NotFound []string `json:"notFound"` // 不存在的客服账号ID
|
||||
Message string `json:"message"` // 提示信息
|
||||
}
|
||||
|
||||
// UnbindSpeechcraftReq 解绑话术请求
|
||||
type UnbindSpeechcraftReq struct {
|
||||
g.Meta `path:"/unbind" method:"post" tags:"话术管理" summary:"解绑话术" dc:"将话术从客服账号解绑"`
|
||||
SpeechcraftId string `json:"speechcraftId" v:"required#话术ID不能为空"`
|
||||
AccountName string `json:"accountName" v:"required#客服账号名称不能为空"`
|
||||
}
|
||||
|
||||
// UnbindSpeechcraftRes 解绑话术响应
|
||||
type UnbindSpeechcraftRes struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package dto
|
||||
|
||||
import "github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
// WebhookReceiveReq 接收平台消息请求
|
||||
type WebhookReceiveReq struct {
|
||||
g.Meta `path:"/:platform" method:"post" tags:"Webhook" summary:"接收平台消息" dc:"接收小红书/抖音等平台推送的用户消息"`
|
||||
Platform string `p:"platform" v:"required#平台标识不能为空" dc:"平台标识(xiaohongshu/douyin/kuaishou)"`
|
||||
UserId string `json:"userId" v:"required#用户ID不能为空" dc:"平台用户ID"`
|
||||
AccountId string `json:"accountId" dc:"客服账号ID"`
|
||||
Content string `json:"content" v:"required#消息内容不能为空" dc:"消息内容"`
|
||||
MsgId string `json:"msgId" dc:"平台消息ID(用于去重)"`
|
||||
Timestamp int64 `json:"timestamp" dc:"消息时间戳"`
|
||||
}
|
||||
|
||||
type WebhookReceiveRes struct {
|
||||
Success bool `json:"success" dc:"是否成功"`
|
||||
MsgId string `json:"msg_id,omitempty" dc:"消息ID"`
|
||||
}
|
||||
|
||||
// WebhookCallbackReq 平台回调验证请求(部分平台需要)
|
||||
type WebhookCallbackReq struct {
|
||||
g.Meta `path:"/:platform/verify" method:"get" tags:"Webhook" summary:"回调验证" dc:"平台回调验证(部分平台接入时需要)"`
|
||||
Platform string `p:"platform" dc:"平台标识"`
|
||||
Signature string `p:"signature" dc:"签名"`
|
||||
Timestamp string `p:"timestamp" dc:"时间戳"`
|
||||
Nonce string `p:"nonce" dc:"随机数"`
|
||||
Echostr string `p:"echostr" dc:"回显字符串"`
|
||||
}
|
||||
|
||||
type WebhookCallbackRes struct {
|
||||
g.Meta `mime:"text/plain"`
|
||||
}
|
||||
|
||||
// ConversationListReq 对话记录查询请求
|
||||
type ConversationListReq struct {
|
||||
g.Meta `path:"/history" method:"get" tags:"Webhook" summary:"查询对话记录" dc:"查询用户历史对话记录"`
|
||||
UserId string `p:"user_id" v:"required#用户ID不能为空" dc:"用户ID"`
|
||||
Limit int64 `p:"limit" d:"20" dc:"返回数量(默认20)"`
|
||||
}
|
||||
|
||||
type ConversationListRes struct {
|
||||
List []*ConversationItem `json:"list" dc:"对话记录列表"`
|
||||
}
|
||||
|
||||
type ConversationItem struct {
|
||||
Question string `json:"question" dc:"用户问题"`
|
||||
Answer string `json:"answer" dc:"AI回复"`
|
||||
MsgTime string `json:"msgTime" dc:"消息时间"`
|
||||
SessionId string `json:"sessionId" dc:"会话ID"`
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"gitea.com/red-future/common/beans"
|
||||
"gitea.redpowerfuture.com/red-future/common/beans"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
|
||||
@@ -1,427 +0,0 @@
|
||||
package dto
|
||||
|
||||
// ==================== 通用响应结构 ====================
|
||||
|
||||
type XhsCommonRes struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
// ==================== 绑定/解绑账户通知(接收) ====================
|
||||
|
||||
type XhsBindAccountReq struct {
|
||||
Content string `json:"content" v:"required"` // 加密后内容
|
||||
}
|
||||
|
||||
type XhsBindAccountDecrypted struct {
|
||||
UserId string `json:"user_id"`
|
||||
NickName string `json:"nick_name"`
|
||||
AppId int64 `json:"app_id"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type XhsUnbindAccountReq struct {
|
||||
Content string `json:"content" v:"required"` // 加密后内容
|
||||
}
|
||||
|
||||
type XhsUnbindAccountDecrypted struct {
|
||||
UserId string `json:"user_id"`
|
||||
AppId int64 `json:"app_id"`
|
||||
AccountCode string `json:"account_code"`
|
||||
}
|
||||
|
||||
type XhsBindKosUserReq struct {
|
||||
Content string `json:"content" v:"required"` // 加密后内容
|
||||
}
|
||||
|
||||
// KOS账户绑定事件
|
||||
type XhsBindKosUserDecrypted struct {
|
||||
UserId string `json:"user_id"`
|
||||
AuthStatus int `json:"auth_status"` // 2-已生效,4-已取消
|
||||
KosNickName string `json:"kos_nick_name"`
|
||||
KosUserId string `json:"kos_user_id"`
|
||||
KosAvatarImg string `json:"kos_avatar_img"`
|
||||
}
|
||||
|
||||
// ==================== 查询KOS授权绑定列表(发送) ====================
|
||||
|
||||
type XhsQueryBindUsersReq struct {
|
||||
UserId string `json:"user_id" v:"required"`
|
||||
PageNum int `json:"page_num" v:"required"`
|
||||
PageSize int `json:"page_size" v:"required"`
|
||||
}
|
||||
|
||||
type XhsQueryBindUsersRes struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Total int64 `json:"total"`
|
||||
KosUserList []struct {
|
||||
UserId string `json:"user_id"`
|
||||
NickName string `json:"nick_name"`
|
||||
AvatarImg string `json:"avatar_img"`
|
||||
} `json:"kos_user_list"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// ==================== 发送消息接口(发送) ====================
|
||||
|
||||
type XhsSendMessageReq struct {
|
||||
UserId string `json:"user_id" v:"required"`
|
||||
RequestId string `json:"request_id" v:"required"`
|
||||
MessageType string `json:"message_type" v:"required"` // TEXT/IMAGE/VIDEO/CARD/REVOKE
|
||||
FromUserId string `json:"from_user_id" v:"required"`
|
||||
ToUserId string `json:"to_user_id" v:"required"`
|
||||
ThirdAccountId string `json:"third_account_id" v:"required"`
|
||||
Timestamp int64 `json:"timestamp" v:"required"`
|
||||
Content string `json:"content" v:"required"` // 加密后内容
|
||||
}
|
||||
|
||||
type XhsSendMessageRes struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
RequestId string `json:"request_id"`
|
||||
MessageId string `json:"message_id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// 消息内容解密后结构
|
||||
type XhsTextContent struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type XhsImageContent struct {
|
||||
Link string `json:"link"`
|
||||
Size struct {
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
} `json:"size"`
|
||||
}
|
||||
|
||||
type XhsVideoContent struct {
|
||||
Duration int64 `json:"duration"`
|
||||
VideoSize int64 `json:"video_size"`
|
||||
Cover string `json:"cover"`
|
||||
Link string `json:"link"`
|
||||
Size struct {
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
} `json:"size"`
|
||||
}
|
||||
|
||||
// 通用卡片内容(用于switch分支解析)
|
||||
type XhsCardContent struct {
|
||||
Id string `json:"id"`
|
||||
ContentType string `json:"content_type"`
|
||||
}
|
||||
|
||||
type XhsCardNoteContent struct {
|
||||
NoteId string `json:"note_id"`
|
||||
ContentType string `json:"content_type"` // note
|
||||
}
|
||||
|
||||
type XhsCardPageContent struct {
|
||||
PageId string `json:"page_id"`
|
||||
ContentType string `json:"content_type"` // common
|
||||
}
|
||||
|
||||
type XhsCardCommentContent struct {
|
||||
CommentId string `json:"comment_id"`
|
||||
Content string `json:"content"`
|
||||
ContentType string `json:"content_type"` // purchaseComments
|
||||
}
|
||||
|
||||
type XhsCardSocialContent struct {
|
||||
Id string `json:"id"`
|
||||
ContentType string `json:"content_type"` // social_card
|
||||
}
|
||||
|
||||
type XhsCardLeadContent struct {
|
||||
Id string `json:"id"`
|
||||
ContentType string `json:"content_type"` // lead_card
|
||||
}
|
||||
|
||||
type XhsCardTradeContent struct {
|
||||
Id string `json:"id"`
|
||||
ContentType string `json:"content_type"` // tradeBusinessCard
|
||||
}
|
||||
|
||||
type XhsRevokeContent struct {
|
||||
MessageId string `json:"message_id"`
|
||||
}
|
||||
|
||||
// ==================== 接收消息接口(接收Webhook) ====================
|
||||
|
||||
type XhsReceiveMessageReq struct {
|
||||
MessageId string `json:"message_id" v:"required"`
|
||||
MessageType string `json:"message_type" v:"required"` // TEXT/IMAGE/VIDEO/CARD/HINT/REVOKE/SMILES
|
||||
MessageSource int `json:"message_source" v:"required"` // 1-C2B用户,2-C2B系统,3-B2C系统
|
||||
FromUserId string `json:"from_user_id" v:"required"`
|
||||
ToUserId string `json:"to_user_id" v:"required"`
|
||||
Timestamp int64 `json:"timestamp" v:"required"`
|
||||
Content string `json:"content" v:"required"` // 加密后内容
|
||||
UserInfo []XhsReceiveMessageUser `json:"user_info"`
|
||||
}
|
||||
|
||||
type XhsReceiveMessageUser struct {
|
||||
UserId string `json:"user_id"`
|
||||
Nickname string `json:"nickname"`
|
||||
HeaderImage string `json:"header_image"`
|
||||
}
|
||||
|
||||
// 接收消息-卡片内容(笔记)
|
||||
type XhsReceiveCardNoteContent struct {
|
||||
Title string `json:"title"`
|
||||
Cover string `json:"cover"`
|
||||
Link string `json:"link"`
|
||||
ContentType string `json:"content_type"` // note
|
||||
UserInfo struct {
|
||||
Nickname string `json:"nickname"`
|
||||
HeaderImage string `json:"header_image"`
|
||||
} `json:"user_info"`
|
||||
}
|
||||
|
||||
// 接收消息-卡片内容(落地页)
|
||||
type XhsReceiveCardPageContent struct {
|
||||
Title string `json:"title"`
|
||||
Cover string `json:"cover"`
|
||||
Link string `json:"link"`
|
||||
Desc string `json:"desc"`
|
||||
ContentType string `json:"content_type"` // common
|
||||
}
|
||||
|
||||
// 接收消息-卡片内容(名片)
|
||||
type XhsReceiveCardSocialContent struct {
|
||||
Id string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Name string `json:"name"`
|
||||
Image string `json:"image"`
|
||||
ContentType string `json:"content_type"` // social_card
|
||||
}
|
||||
|
||||
// 接收消息-卡片内容(留资卡)
|
||||
type XhsReceiveCardLeadContent struct {
|
||||
Id string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Name string `json:"name"`
|
||||
Image string `json:"image"`
|
||||
ContentType string `json:"content_type"` // lead_card
|
||||
}
|
||||
|
||||
// 接收消息-卡片内容(交易卡)
|
||||
type XhsReceiveCardTradeContent struct {
|
||||
Id string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
SubTitle string `json:"sub_title"`
|
||||
Image string `json:"image"`
|
||||
LinkPlatform string `json:"link_platform"` // wx_mini/ctrip/meituan
|
||||
ContentType string `json:"content_type"` // tradeBusinessCard
|
||||
}
|
||||
|
||||
// 接收消息-HINT内容
|
||||
type XhsReceiveHintContent struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// ==================== 查询物料接口(发送) ====================
|
||||
|
||||
// 查询落地页列表
|
||||
type XhsQueryPageListReq struct {
|
||||
UserId string `json:"user_id" v:"required"`
|
||||
PageNum int `json:"page_num" v:"required"`
|
||||
PageSize int `json:"page_size" v:"required"`
|
||||
}
|
||||
|
||||
type XhsQueryPageListRes struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Total int64 `json:"total"`
|
||||
List []struct {
|
||||
Title string `json:"title"`
|
||||
CreateTime int64 `json:"create_time"`
|
||||
PageId string `json:"page_id"`
|
||||
PageDesc string `json:"page_desc"`
|
||||
PageUrl string `json:"page_url"`
|
||||
Cover string `json:"cover"`
|
||||
} `json:"list"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// 查询名片/留资卡/交易卡列表
|
||||
type XhsQueryMaterialListReq struct {
|
||||
UserId string `json:"user_id" v:"required"`
|
||||
PageNum int `json:"page_num" v:"required"`
|
||||
PageSize int `json:"page_size" v:"required"`
|
||||
Type int `json:"type" v:"required"` // 4-名片 5-留资卡 7-交易卡
|
||||
}
|
||||
|
||||
type XhsQueryMaterialListRes struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Total int64 `json:"total"`
|
||||
List []struct {
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Title string `json:"title"`
|
||||
Image string `json:"image"`
|
||||
Ext struct {
|
||||
SocialCardExt *struct {
|
||||
CardType int `json:"card_type"` // 1-微信、2-钉钉、3-电话、4-企微 5-留资卡
|
||||
} `json:"social_card_ext,omitempty"`
|
||||
TradeCardExt *struct {
|
||||
SubTitle string `json:"sub_title"`
|
||||
LinkPlatform string `json:"link_platform"` // ctrip/meituan/wx_mini
|
||||
} `json:"trade_card_ext,omitempty"`
|
||||
} `json:"ext"`
|
||||
} `json:"list"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// 查询笔记列表
|
||||
type XhsQueryNoteListReq struct {
|
||||
UserId string `json:"user_id" v:"required"`
|
||||
NoteId string `json:"note_id,omitempty"`
|
||||
PageNum int `json:"page_num" v:"required"`
|
||||
PageSize int `json:"page_size" v:"required"`
|
||||
}
|
||||
|
||||
type XhsQueryNoteListRes struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Total int64 `json:"total"`
|
||||
List []struct {
|
||||
NoteId string `json:"note_id"`
|
||||
PublishTime int64 `json:"publish_time"`
|
||||
Link string `json:"link"`
|
||||
Title string `json:"title"`
|
||||
Cover string `json:"cover"`
|
||||
UserInfo struct {
|
||||
Nickname string `json:"nickname"`
|
||||
HeaderImage string `json:"header_image"`
|
||||
} `json:"user_info"`
|
||||
} `json:"list"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// 查询意向评论列表
|
||||
type XhsQueryCommentListReq struct {
|
||||
UserId string `json:"user_id" v:"required"`
|
||||
PageNum int `json:"page_num" v:"required"`
|
||||
PageSize int `json:"page_size" v:"required"`
|
||||
BeginTime string `json:"begin_time,omitempty"`
|
||||
EndTime string `json:"end_time,omitempty"`
|
||||
}
|
||||
|
||||
type XhsQueryCommentListRes struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Total int64 `json:"total"`
|
||||
List []struct {
|
||||
NoteId string `json:"note_id"`
|
||||
Cover string `json:"cover"`
|
||||
NoteTitle string `json:"note_title"`
|
||||
NoteAuthorUserId string `json:"note_author_user_id"`
|
||||
CommentContent string `json:"comment_content"`
|
||||
CommentTime int64 `json:"comment_time"`
|
||||
CommentUserName string `json:"comment_user_name"`
|
||||
CommentUserId string `json:"comment_user_id"`
|
||||
CommentId string `json:"comment_id"`
|
||||
UniqId string `json:"uniq_id"`
|
||||
ReplyState int `json:"reply_state"`
|
||||
ReplyThirdAccountId string `json:"reply_third_account_id"`
|
||||
} `json:"list"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// ==================== 意向评论推送(接收Webhook) ====================
|
||||
|
||||
type XhsIntentCommentPushReq struct {
|
||||
NoteId string `json:"note_id"`
|
||||
Cover string `json:"cover"`
|
||||
NoteTitle string `json:"note_title"`
|
||||
NoteAuthorUserId string `json:"note_author_user_id"`
|
||||
CommentContent string `json:"comment_content"`
|
||||
CommentTime int64 `json:"comment_time"`
|
||||
CommentUserName string `json:"comment_user_name"`
|
||||
CommentUserId string `json:"comment_user_id"`
|
||||
CommentId string `json:"comment_id"`
|
||||
UniqId string `json:"uniq_id"`
|
||||
ReplyState int `json:"reply_state"`
|
||||
ReplyThirdAccountId string `json:"reply_third_account_id"`
|
||||
}
|
||||
|
||||
// ==================== 留资数据回传(发送) ====================
|
||||
|
||||
type XhsBackLeadResultReq struct {
|
||||
UserId string `json:"user_id" v:"required"`
|
||||
RequestId string `json:"request_id" v:"required"`
|
||||
BrandUserId string `json:"brand_user_id" v:"required"`
|
||||
CUserId string `json:"c_user_id" v:"required"`
|
||||
ThirdBackSource string `json:"third_back_source" v:"required"`
|
||||
Timestamp int64 `json:"timestamp" v:"required"`
|
||||
Content string `json:"content" v:"required"` // 加密后内容
|
||||
}
|
||||
|
||||
type XhsBackLeadResultDecrypted struct {
|
||||
PhoneNum string `json:"phone_num"`
|
||||
Wechat string `json:"wechat"`
|
||||
Remark string `json:"remark"`
|
||||
Area string `json:"area"`
|
||||
City string `json:"city"`
|
||||
CustomerServiceName string `json:"customer_service_name"`
|
||||
Time int64 `json:"time"`
|
||||
ExtList []struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
} `json:"extList"`
|
||||
}
|
||||
|
||||
type XhsBackLeadResultRes struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
RequestId string `json:"request_id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// ==================== 留资和广告归因数据推送(接收Webhook) ====================
|
||||
|
||||
type XhsPushLeadReq struct {
|
||||
UserId string `json:"user_id"`
|
||||
BrandUserId string `json:"brand_user_id"`
|
||||
KosUserId string `json:"kos_user_id"`
|
||||
ConvTime string `json:"conv_time"`
|
||||
AdvertiserName string `json:"advertiser_name"`
|
||||
AdvertiserId string `json:"advertiser_id"`
|
||||
CampaignName string `json:"campaign_name"`
|
||||
CampaignId string `json:"campaign_id"`
|
||||
CreativityName string `json:"creativity_name"`
|
||||
CreativityId string `json:"creativity_id"`
|
||||
LeadsTag string `json:"leads_tag"`
|
||||
Area string `json:"area"`
|
||||
PhoneNum string `json:"phone_num"`
|
||||
Wechat string `json:"wechat"`
|
||||
Remark string `json:"remark"`
|
||||
PushType int `json:"push_type"` // 1-进线 2-开口 3-留资 4-留资归因
|
||||
WechatCopy int `json:"wechat_copy"`
|
||||
LinkId string `json:"link_id"`
|
||||
LinkName string `json:"link_name"`
|
||||
CustomerChannel string `json:"customer_channel"`
|
||||
MsgAppOpen int `json:"msg_app_open"`
|
||||
WechatType int `json:"wechat_type"` // 1-文本 2-图片链接
|
||||
DecryptUserId string `json:"decrypt_user_id"`
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"customer-server/consts/account"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/beans"
|
||||
)
|
||||
|
||||
type accountCol struct {
|
||||
beans.SQLBaseCol
|
||||
DatasetIds string
|
||||
DocumentIds string
|
||||
SpeechcraftIds string
|
||||
AccountCode string
|
||||
AccountName string
|
||||
Status string
|
||||
Greeting string
|
||||
KeywordOption string
|
||||
SelfIdentity string
|
||||
Platform string
|
||||
AccessToken string
|
||||
AppId string
|
||||
SecretKey string
|
||||
XhsUserId string
|
||||
ContactCardMessage string
|
||||
NameCardMessage string
|
||||
CardTriggerCount string
|
||||
}
|
||||
|
||||
var AccountCol = accountCol{
|
||||
SQLBaseCol: beans.DefSQLBaseCol,
|
||||
DatasetIds: "dataset_ids",
|
||||
DocumentIds: "document_ids",
|
||||
SpeechcraftIds: "speechcraft_ids",
|
||||
AccountCode: "account_code",
|
||||
AccountName: "account_name",
|
||||
Status: "status",
|
||||
Greeting: "greeting",
|
||||
KeywordOption: "keyword_option",
|
||||
SelfIdentity: "self_identity",
|
||||
Platform: "platform",
|
||||
AccessToken: "access_token",
|
||||
AppId: "app_id",
|
||||
SecretKey: "secret_key",
|
||||
XhsUserId: "xhs_user_id",
|
||||
ContactCardMessage: "contact_card_message",
|
||||
NameCardMessage: "name_card_message",
|
||||
CardTriggerCount: "card_trigger_count",
|
||||
}
|
||||
|
||||
type Account struct {
|
||||
beans.SQLBaseDO `orm:",inline"`
|
||||
|
||||
DatasetIds []int64 `orm:"dataset_ids" json:"datasetIds" dc:"绑定的数据集ID列表"`
|
||||
DocumentIds []int64 `orm:"document_ids" json:"documentIds" dc:"绑定的文档ID列表"`
|
||||
AccountCode string `orm:"account_code" json:"accountCode" dc:"客服账号编码"`
|
||||
AccountName string `orm:"account_name" json:"accountName" dc:"客服账号名称"`
|
||||
Status account.Status `orm:"status" json:"status" dc:"客服账号状态"`
|
||||
Platform account.Platform `orm:"platform" json:"platform" dc:"客服平台"`
|
||||
Greeting string `orm:"greeting" json:"greeting" dc:"开场白"`
|
||||
KeywordOption []string `orm:"keyword_option" json:"keywordOption" dc:"关键词选项"`
|
||||
SelfIdentity string `orm:"self_identity" json:"selfIdentity" dc:"AI身份描述"`
|
||||
ExpandData *AccountExpandData `orm:"expand_data" json:"expandData" description:"扩展数据(JSONB)"`
|
||||
}
|
||||
|
||||
type AccountExpandData struct {
|
||||
Xhs XhsExpandData `orm:",inline" json:"xhs"`
|
||||
}
|
||||
|
||||
type XhsExpandData struct {
|
||||
// 小红书平台专属字段(仅platform=xiaohongshu时有效)
|
||||
AccessToken string `orm:"access_token" json:"accessToken" dc:"小红书AccessToken(14天有效期)"`
|
||||
AppId int64 `orm:"app_id" json:"appId" dc:"小红书应用ID"`
|
||||
SecretKey string `orm:"secret_key" json:"secretKey" dc:"小红书加解密密钥"`
|
||||
XhsUserId string `orm:"xhs_user_id" json:"xhsUserId" dc:"小红书用户ID"`
|
||||
ContactCardMessage string `orm:"contact_card_message" json:"contactCardMessage" dc:"留资卡文案"`
|
||||
NameCardMessage string `orm:"name_card_message" json:"nameCardMessage" dc:"名片文案"`
|
||||
CardTriggerCount int `orm:"card_trigger_count" json:"cardTriggerCount" dc:"卡片触发次数"`
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"gitea.redpowerfuture.com/red-future/common/beans"
|
||||
)
|
||||
|
||||
type accountUserDialogCol struct {
|
||||
beans.SQLBaseCol
|
||||
AccountId string
|
||||
UserId string
|
||||
DialogCount string
|
||||
}
|
||||
|
||||
var AccountUserDialogCol = accountUserDialogCol{
|
||||
SQLBaseCol: beans.DefSQLBaseCol,
|
||||
AccountId: "account_id",
|
||||
UserId: "user_id",
|
||||
DialogCount: "dialog_count",
|
||||
}
|
||||
|
||||
type AccountUserDialog struct {
|
||||
beans.SQLBaseDO `orm:",inline"`
|
||||
|
||||
AccountId int64 `orm:"account_id" json:"accountId" dc:"客服ID"`
|
||||
UserId string `orm:"user_id" json:"userId" dc:"用户ID"`
|
||||
DialogCount int64 `orm:"dialog_count" json:"dialogCount" dc:"对话次数"`
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitea.com/red-future/common/beans"
|
||||
)
|
||||
|
||||
const ConversationCollection = "conversation"
|
||||
|
||||
// Conversation 对话记录实体
|
||||
type Conversation struct {
|
||||
beans.MongoBaseDO `bson:",inline"` // 嵌入基础字段
|
||||
|
||||
// 业务字段
|
||||
UserId string `bson:"userId" json:"userId"` // 用户ID(含平台前缀)
|
||||
Platform string `bson:"platform" json:"platform"` // 平台标识
|
||||
SessionId string `bson:"sessionId" json:"sessionId"` // RAGFlow Session ID
|
||||
CustomerServiceId string `bson:"customerServiceId" json:"customerServiceId"` // 客服账号ID
|
||||
Role string `bson:"role" json:"role"` // 角色:user/assistant
|
||||
Content string `bson:"content" json:"content"` // 消息内容
|
||||
Question string `bson:"question" json:"question"` // 用户问题(兼容旧字段)
|
||||
Answer string `bson:"answer" json:"answer"` // AI 回复(兼容旧字段)
|
||||
MessageId string `bson:"messageId" json:"messageId"` // 消息ID
|
||||
MessageType string `bson:"messageType" json:"messageType"` // 消息类型(TEXT/IMAGE/VIDEO等)
|
||||
MsgTime *time.Time `bson:"msgTime" json:"msgTime"` // 消息时间
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitea.com/red-future/common/beans"
|
||||
)
|
||||
|
||||
// ConversationArchiveTempCollection 归档临时表名
|
||||
const ConversationArchiveTempCollection = "conversation_archive_temp"
|
||||
|
||||
// ConversationArchiveTemp 归档临时表实体(与 Conversation 结构相同)
|
||||
type ConversationArchiveTemp struct {
|
||||
beans.MongoBaseDO `bson:",inline"`
|
||||
|
||||
UserId string `bson:"userId" json:"userId"`
|
||||
Platform string `bson:"platform" json:"platform"`
|
||||
SessionId string `bson:"sessionId" json:"sessionId"`
|
||||
Question string `bson:"question" json:"question"`
|
||||
Answer string `bson:"answer" json:"answer"`
|
||||
MessageId string `bson:"messageId" json:"messageId"`
|
||||
MsgTime *time.Time `bson:"msgTime" json:"msgTime"` // 指针类型,与MongoBaseDO一致
|
||||
|
||||
// 原始文档 ID,用于删除原表数据
|
||||
OriginalId string `bson:"originalId" json:"originalId"`
|
||||
}
|
||||
|
||||
// ConversationES ES 索引文档结构
|
||||
type ConversationES struct {
|
||||
Id string `json:"id"` // MongoDB 原始 ID
|
||||
UserId string `json:"userId"` // 用户ID
|
||||
Platform string `json:"platform"` // 平台
|
||||
SessionId string `json:"sessionId"` // Session ID
|
||||
Question string `json:"question"` // 问题
|
||||
Answer string `json:"answer"` // 回复
|
||||
MessageId string `json:"messageId"` // 消息ID
|
||||
MsgTime time.Time `json:"msgTime"` // 消息时间
|
||||
TenantId string `json:"tenantId"` // 租户ID
|
||||
CreatedAt time.Time `json:"createdAt"` // 创建时间
|
||||
UpdatedAt time.Time `json:"updatedAt"` // 更新时间
|
||||
ArchivedAt time.Time `json:"archivedAt"` // 归档时间
|
||||
}
|
||||
|
||||
// ConversationESMapping ES 索引映射
|
||||
const ConversationESMapping = `
|
||||
{
|
||||
"settings": {
|
||||
"number_of_shards": 3,
|
||||
"number_of_replicas": 1
|
||||
},
|
||||
"mappings": {
|
||||
"properties": {
|
||||
"id": { "type": "keyword" },
|
||||
"userId": { "type": "keyword" },
|
||||
"platform": { "type": "keyword" },
|
||||
"sessionId": { "type": "keyword" },
|
||||
"question": { "type": "text", "analyzer": "standard" },
|
||||
"answer": { "type": "text", "analyzer": "standard" },
|
||||
"messageId": { "type": "keyword" },
|
||||
"msgTime": { "type": "date" },
|
||||
"tenantId": { "type": "keyword" },
|
||||
"createdAt": { "type": "date" },
|
||||
"updatedAt": { "type": "date" },
|
||||
"archivedAt": { "type": "date" }
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// ConversationESIndex ES 索引名
|
||||
const ConversationESIndex = "conversations"
|
||||
@@ -1,33 +0,0 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"gitea.com/red-future/common/beans"
|
||||
)
|
||||
|
||||
// CustomerServiceAccount 客服账号实体
|
||||
const CustomerServiceAccountCollection = "customer_service_account"
|
||||
|
||||
type CustomerServiceAccount struct {
|
||||
beans.MongoBaseDO `bson:",inline"` // 嵌入基础字段:Id, Creator, CreatedAt, Updater, UpdatedAt, TenantId, IsDeleted
|
||||
|
||||
// 业务字段
|
||||
AccountName string `bson:"accountName" json:"accountName"` // 客服账号名称(如cs_xhs_qixue)
|
||||
IsDisabled bool `bson:"isDisabled" json:"isDisabled"` // 是否禁用(true-禁用,false-启用)
|
||||
Greeting string `bson:"greeting" json:"greeting"` // 开场白(WebSocket连接时自动发送)
|
||||
Prompt string `bson:"-" json:"prompt"` // 提示词(不存储到MongoDB,查询时从ragflow_config关联获取)
|
||||
SelfIdentity string `bson:"selfIdentity" json:"selfIdentity"` // AI身份描述(前端显示为"AI身份")
|
||||
|
||||
// 绑定的资源(主体持有关系)
|
||||
SpeechcraftIds []string `bson:"speechcraftIds" json:"speechcraftIds"` // 绑定的话术ID列表
|
||||
ProductIds []string `bson:"productIds" json:"productIds"` // 绑定的产品ID列表
|
||||
Platform string `bson:"platform" json:"platform"` // 客服平台(如:xiaohongshu、douyin、kuaishou)
|
||||
|
||||
// 小红书平台专属字段(仅platform=xiaohongshu时有效)
|
||||
AccessToken string `bson:"accessToken,omitempty" json:"accessToken,omitempty"` // 小红书AccessToken(14天有效期)
|
||||
AppId int64 `bson:"appId,omitempty" json:"appId,omitempty"` // 小红书应用ID
|
||||
SecretKey string `bson:"secretKey,omitempty" json:"secretKey,omitempty"` // 小红书加解密密钥
|
||||
XhsUserId string `bson:"xhsUserId,omitempty" json:"xhsUserId,omitempty"` // 小红书用户ID
|
||||
ContactCardMessage string `bson:"contactCardMessage,omitempty" json:"contactCardMessage,omitempty"` // 留资卡文案(不同领域客服使用不同文案)
|
||||
NameCardMessage string `bson:"nameCardMessage,omitempty" json:"nameCardMessage,omitempty"` // 名片文案(不同领域客服使用不同文案)
|
||||
CardTriggerCount int `bson:"cardTriggerCount,omitempty" json:"cardTriggerCount,omitempty"` // 卡片触发次数(默认5,不同客服可定制)
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"gitea.com/red-future/common/beans"
|
||||
)
|
||||
|
||||
const DataCollection = "data"
|
||||
|
||||
type Data struct {
|
||||
beans.MongoBaseDO `bson:",inline"` // 嵌入基础字段:Id, Creator, CreatedAt, Updater, UpdatedAt, TenantId, IsDeleted
|
||||
|
||||
// 业务字段
|
||||
CustomerId string `bson:"customerId" json:"customerId"` // 客户ID
|
||||
AccountName string `bson:"accountName" json:"accountName"` // 客服账号名称
|
||||
CustomerServicePlatform string `bson:"customerServicePlatform" json:"customerServicePlatform"` // 客服平台
|
||||
CustomerServiceName string `bson:"customerServiceName" json:"customerServiceName"` // 客服名称
|
||||
IsInbound bool `bson:"isInbound" json:"isInbound"` // 用户是否点开了客服页面
|
||||
IsActive bool `bson:"isActive" json:"isActive"` // 用户是否开口询问
|
||||
IsServed bool `bson:"isServed" json:"isServed"` // 客服是否回答了用户
|
||||
HasSentContactCard bool `bson:"hasSentContactCard" json:"hasSentContactCard"` // 客服是否发送了联系卡
|
||||
HasSentNameCard bool `bson:"hasSentNameCard" json:"hasSentNameCard"` // 客服是否发送了名称卡
|
||||
HasLeftContactInfo bool `bson:"hasLeftContactInfo" json:"hasLeftContactInfo"` // 用户是否留下了联系信息
|
||||
SessionStartTime int64 `bson:"sessionStartTime" json:"sessionStartTime"` // 业务数据的时间
|
||||
MessageTime int64 `bson:"messageTime" json:"messageTime"` // 消息时间
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitea.com/red-future/common/beans"
|
||||
)
|
||||
|
||||
const DataStatisticsCollection = "data_statistics"
|
||||
|
||||
// DataStatistics 数据统计实体 (用于存储每天/每位客服的汇总数据)
|
||||
type DataStatistics struct {
|
||||
beans.MongoBaseDO `bson:",inline"` // 嵌入基础字段:Id, Creator, CreatedAt, Updater, UpdatedAt, TenantId, IsDeleted
|
||||
Date *time.Time `bson:"date" json:"date"` // 统计日期 (格式: YYYY-MM-DD)
|
||||
AccountName string `bson:"accountName" json:"accountName"` // 客服账号名称
|
||||
CustomerServiceName string `bson:"customerServiceName" json:"customerServiceName"` // 客服名称
|
||||
CustomerServicePlatform string `bson:"customerServicePlatform" json:"customerServicePlatform"` // 客服平台
|
||||
|
||||
// 以下字段由 Data 表中的 bool 字段累加统计得出
|
||||
InboundCount int `bson:"inboundCount" json:"inboundCount"` // 进线人数 (汇总 IsInbound)
|
||||
ActiveCount int `bson:"activeCount" json:"activeCount"` // 开口人数 (汇总 IsActive)
|
||||
ServedCount int `bson:"servedCount" json:"servedCount"` // 接待人数 (汇总 IsServed)
|
||||
ContactCardSentCount int `bson:"contactCardSentCount" json:"contactCardSentCount"` // 留资卡发送数量 (汇总 HasSentContactCard)
|
||||
NameCardSentCount int `bson:"nameCardSentCount" json:"nameCardSentCount"` // 名片发送数量 (汇总 HasSentNameCard)
|
||||
LeftContactInfoCount int `bson:"leftContactInfoCount" json:"leftContactInfoCount"` // 留资人数 (汇总 HasLeftContactInfo)
|
||||
|
||||
ResponseRate30s float64 `bson:"responseRate30s" json:"responseRate30s"` // 30秒响应率
|
||||
ResponseRate60s float64 `bson:"responseRate60s" json:"responseRate60s"` // 60秒响应率
|
||||
ResponseRate360s float64 `bson:"responseRate360s" json:"responseRate360s"` // 360秒响应率
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
// Package entity - 产品实体
|
||||
// 功能:定义产品表结构,支持多客服账号绑定、RAGFlow同步记录
|
||||
package entity
|
||||
|
||||
import (
|
||||
"gitea.com/red-future/common/beans"
|
||||
)
|
||||
|
||||
// Product 产品实体
|
||||
const ProductCollection = "product"
|
||||
|
||||
type Product struct {
|
||||
beans.MongoBaseDO `bson:",inline"` // 嵌入基础字段:Id, Creator, CreatedAt, Updater, UpdatedAt, TenantId, IsDeleted
|
||||
|
||||
// 业务字段
|
||||
Name string `bson:"name" json:"name"` // 产品名称
|
||||
Description string `bson:"description" json:"description"` // 产品详情
|
||||
|
||||
// 客服账号绑定(一个产品可以被多个账号使用)
|
||||
AccountNames []string `bson:"accountNames" json:"accountNames"` // 绑定的客服账号名称列表
|
||||
|
||||
// RAGFlow同步字段(租户级知识库,每个客服账号独立同步记录)
|
||||
RagSyncRecords []RagSyncRecord `bson:"ragSyncRecords" json:"ragSyncRecords"` // RAGFlow同步记录(按客服账号)
|
||||
RagLastSyncTime string `bson:"ragLastSyncTime" json:"ragLastSyncTime"` // 最后同步时间
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
// Package entity - RAGFlow同步相关实体
|
||||
// 功能:RAGFlow同步记录(话术和产品共用)
|
||||
package entity
|
||||
|
||||
// RagSyncRecord RAGFlow同步记录
|
||||
// 说明:租户级知识库,每个客服账号独立的文档ID
|
||||
type RagSyncRecord struct {
|
||||
AccountName string `bson:"accountName" json:"accountName"` // 客服账号名称
|
||||
RagDocumentId string `bson:"ragDocumentId" json:"ragDocumentId"` // RAGFlow文档ID(租户级知识库中的文档)
|
||||
RagSyncStatus string `bson:"ragSyncStatus" json:"ragSyncStatus"` // 同步状态:synced/pending/failed
|
||||
SyncTime string `bson:"syncTime" json:"syncTime"` // 同步时间
|
||||
RetryCount int `bson:"retryCount" json:"retryCount"` // 重试次数
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
// Package entity - RAGFlow配置实体
|
||||
// 功能:客服账号级RAGFlow配置,每个客服账号一个独立的对话实例
|
||||
// 架构说明:
|
||||
// - 租户级:一个知识库(dataset),存储所有话术
|
||||
// - 客服级:每个客服有独立的Chat实例,通过document_ids筛选使用哪些话术
|
||||
package entity
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitea.com/red-future/common/beans"
|
||||
)
|
||||
|
||||
const RAGFlowConfigCollection = "ragflow_config"
|
||||
|
||||
// RAGFlowConfig RAGFlow配置实体(客服账号级别)
|
||||
type RAGFlowConfig struct {
|
||||
beans.MongoBaseDO `bson:",inline"` // TenantId继承自基础DO
|
||||
|
||||
// 客服账号标识
|
||||
AccountName string `bson:"accountName" json:"accountName"` // 客服账号名称
|
||||
Platform string `bson:"platform" json:"platform"` // 平台(xiaohongshu、douyin)
|
||||
|
||||
// 租户级知识库(整个租户共享)
|
||||
DatasetId string `bson:"datasetId" json:"datasetId"` // 话术知识库ID(RAGFlow)
|
||||
DatasetIds []string `bson:"datasetIds" json:"datasetIds"` // Chat绑定的知识库ID列表(与RAGFlow API保持一致)
|
||||
DatasetName string `bson:"datasetName" json:"datasetName"` // 话术知识库名称
|
||||
|
||||
// 客服账号级对话实例(每个客服独立)
|
||||
ChatId string `bson:"chatId" json:"chatId"` // RAGFlow对话实例ID
|
||||
Prompt string `bson:"prompt" json:"prompt"` // 提示词内容(完整)
|
||||
|
||||
// 文档筛选(该客服使用哪些话术文档)
|
||||
DocumentIds []string `bson:"documentIds" json:"documentIds"` // 绑定的话术文档ID列表(从租户知识库中筛选)
|
||||
|
||||
// 检索参数
|
||||
SimilarityThreshold float64 `bson:"similarityThreshold" json:"similarityThreshold"` // 相似度阈值(默认0.2)
|
||||
KeywordsSimilarityWeight float64 `bson:"keywordsSimilarityWeight" json:"keywordsSimilarityWeight"` // 关键词权重(默认0.7)
|
||||
TopN int `bson:"topN" json:"topN"` // 返回chunk数量(默认8)
|
||||
EmptyResponse string `bson:"emptyResponse" json:"emptyResponse"` // 无匹配时回复
|
||||
|
||||
// 同步状态
|
||||
SyncStatus string `bson:"syncStatus" json:"syncStatus"` // synced/pending/failed
|
||||
LastSyncTime *time.Time `bson:"lastSyncTime" json:"lastSyncTime"` // 最后同步时间
|
||||
SyncError string `bson:"syncError" json:"syncError"` // 同步错误信息
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"customer-server/consts/scriptedSpeech"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/beans"
|
||||
)
|
||||
|
||||
type scriptedSpeechCol struct {
|
||||
beans.SQLBaseCol
|
||||
DatasetId string
|
||||
SceneType string
|
||||
QuestionContent string
|
||||
}
|
||||
|
||||
var ScriptedSpeechCol = scriptedSpeechCol{
|
||||
SQLBaseCol: beans.DefSQLBaseCol,
|
||||
DatasetId: "dataset_id",
|
||||
SceneType: "scene_type",
|
||||
QuestionContent: "question_content",
|
||||
}
|
||||
|
||||
type ScriptedSpeech struct {
|
||||
beans.SQLBaseDO `orm:",inline"`
|
||||
|
||||
DatasetId int64 `orm:"dataset_id" json:"datasetId" dc:"数据集ID"`
|
||||
SceneType scriptedSpeech.SceneType `orm:"scene_type" json:"sceneType" dc:"场景类型"`
|
||||
QuestionContent string `orm:"question_content" json:"questionContent" dc:"问题内容"`
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitea.com/red-future/common/beans"
|
||||
)
|
||||
|
||||
const SessionCollection = "session"
|
||||
|
||||
// SessionStatus 会话状态
|
||||
const (
|
||||
SessionStatusActive = "active" // 活跃中
|
||||
SessionStatusArchived = "archived" // 已归档
|
||||
)
|
||||
|
||||
// Session 会话实体
|
||||
type Session struct {
|
||||
beans.MongoBaseDO `bson:",inline"` // 嵌入基础字段
|
||||
|
||||
// 业务字段
|
||||
UserId string `bson:"userId" json:"userId"` // 用户ID(含平台前缀)
|
||||
Platform string `bson:"platform" json:"platform"` // 平台标识
|
||||
SessionId string `bson:"sessionId" json:"sessionId"` // RAGFlow Session ID
|
||||
Status string `bson:"status" json:"status"` // 状态:active/archived
|
||||
MessageCount int `bson:"messageCount" json:"messageCount"` // 消息数量
|
||||
LastActiveAt *time.Time `bson:"lastActiveAt" json:"lastActiveAt"` // 最后活跃时间
|
||||
ArchivedAt *time.Time `bson:"archivedAt" json:"archivedAt"` // 归档时间
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
// Package entity - 话术实体
|
||||
// 功能:定义话术表结构,支持多客服账号绑定、RAGFlow同步记录
|
||||
package entity
|
||||
|
||||
import (
|
||||
"gitea.com/red-future/common/beans"
|
||||
)
|
||||
|
||||
// Speechcraft 话术实体
|
||||
const SpeechcraftCollection = "speechcraft"
|
||||
|
||||
type Speechcraft struct {
|
||||
beans.MongoBaseDO `bson:",inline"` // 嵌入基础字段:Id, Creator, CreatedAt, Updater, UpdatedAt, TenantId, IsDeleted
|
||||
|
||||
// 原有字段
|
||||
Tag string `bson:"tag" json:"tag"` // 标签(用于分类)
|
||||
Content string `bson:"content" json:"content"` // 话术内容
|
||||
|
||||
// 状态机字段
|
||||
Stage int `bson:"stage" json:"stage"` // 触发阶段(0=初始)
|
||||
Status string `bson:"status" json:"status"` // 触发行为(click/keyword/空=任意)
|
||||
Keywords []string `bson:"keywords" json:"keywords"` // 触发关键字(空=任意)
|
||||
NextStage int `bson:"nextStage" json:"nextStage"` // 下一阶段(-1=结束)
|
||||
Platform string `bson:"platform" json:"platform"` // 平台(xiaohongshu)
|
||||
|
||||
// 业务分类
|
||||
Direction string `bson:"direction" json:"direction"` // 咨询方向(气血、减肥、护肤等)
|
||||
|
||||
// RAGFlow同步字段(租户级知识库,每个客服账号独立同步记录)
|
||||
RagSyncRecords []RagSyncRecord `bson:"ragSyncRecords" json:"ragSyncRecords"` // RAGFlow同步记录(按客服账号)
|
||||
RagLastSyncTime string `bson:"ragLastSyncTime" json:"ragLastSyncTime"` // 最后同步时间
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitea.com/red-future/common/beans"
|
||||
)
|
||||
|
||||
// UserStage 用户阶段实体(记录用户在话术引导流程中的当前阶段)
|
||||
const UserStageCollection = "user_stage"
|
||||
|
||||
type UserStage struct {
|
||||
beans.MongoBaseDO `bson:",inline"` // 嵌入基础字段
|
||||
|
||||
// 业务字段
|
||||
UserId string `bson:"userId" json:"userId"` // 用户ID(含平台前缀)
|
||||
Platform string `bson:"platform" json:"platform"` // 平台标识
|
||||
Stage int `bson:"stage" json:"stage"` // 当前阶段(0=初始)
|
||||
Status string `bson:"status" json:"status"` // 最近行为(click/keyword 等)
|
||||
LastMsgAt *time.Time `bson:"lastMsgAt" json:"lastMsgAt"` // 最后消息时间
|
||||
AccountName string `bson:"accountName" json:"accountName"` // 用户选择的方向对应的客服账号名称
|
||||
}
|
||||
|
||||
// 阶段常量
|
||||
const (
|
||||
StageInit = 0 // 初始
|
||||
StageAutoComment = 1 // 自动跟评
|
||||
StageClick = 2 // 点击
|
||||
StageAutoReply = 3 // 点击自动回复
|
||||
StageSendImage = 4 // 发图
|
||||
StageOpenDoor = 5 // 开口询问
|
||||
StageReply1 = 6 // 回复1
|
||||
StageReply2 = 7 // 回复2
|
||||
StageReply3 = 8 // 回复3
|
||||
StageReply4 = 9 // 回复4
|
||||
StageFollowUp = 10 // 追问
|
||||
StageEnd = 99 // 结束
|
||||
)
|
||||
|
||||
// 用户行为常量
|
||||
const (
|
||||
StatusIdle = "" // 空闲/任意
|
||||
StatusClick = "click" // 点击
|
||||
StatusKeyword = "keyword" // 发关键字
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/model/dto"
|
||||
"fmt"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
var (
|
||||
AccountHttpService = new(accountHttpService)
|
||||
)
|
||||
|
||||
type accountHttpService struct{}
|
||||
|
||||
func (s *accountHttpService) Connect(ctx context.Context, req *dto.AccountHttpConnectReq) (res *dto.AccountHttpConnectRes, err error) {
|
||||
// 获取客服账号信息
|
||||
accountInfo, err := SessionToolService.GetAccountInfo(ctx, req.AccountCode)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if g.IsEmpty(accountInfo) {
|
||||
return nil, fmt.Errorf("客服账号不存在")
|
||||
}
|
||||
|
||||
// 设置用户信息
|
||||
headers, err := SessionToolService.SetUserInfo(ctx, accountInfo.Creator, accountInfo.TenantId)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
content, err := SessionToolService.PushOpeningRemark(ctx, req.UserId, accountInfo, headers)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if !g.IsEmpty(content) {
|
||||
res = &dto.AccountHttpConnectRes{
|
||||
Content: content,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
dialogContent, err := SessionToolService.PushDialog(ctx, req.UserId, req.QuestionContent, accountInfo, headers)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if !g.IsEmpty(dialogContent) {
|
||||
res = &dto.AccountHttpConnectRes{
|
||||
Content: dialogContent,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Package service - 客服账号服务
|
||||
// 功能:客服账号的增删改查、状态切换
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/dao"
|
||||
"customer-server/model/dto"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
var (
|
||||
AccountService = new(accountService)
|
||||
)
|
||||
|
||||
type accountService struct{}
|
||||
|
||||
// Add 添加客服账号
|
||||
// 参数: ctx - 上下文,req - 添加客服账号请求
|
||||
// 返回: res - 添加成功后的客服账号ID,err - 错误信息
|
||||
// 功能: 创建新的客服账号记录
|
||||
func (s *accountService) Add(ctx context.Context, req *dto.AddAccountReq) (res *dto.AddAccountRes, err error) {
|
||||
// 检查账号名称是否已存在
|
||||
count, err := dao.Account.Count(ctx, &dto.ListAccountReq{
|
||||
AccountCode: req.AccountCode,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if count > 0 {
|
||||
err = gerror.Newf("客服账号名称已存在:%s", req.AccountName)
|
||||
return
|
||||
}
|
||||
|
||||
// 插入数据库
|
||||
id, err := dao.Account.Insert(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = &dto.AddAccountRes{Id: id}
|
||||
return
|
||||
}
|
||||
|
||||
// Update 更新客服账号
|
||||
// 参数: ctx - 上下文,req - 更新客服账号请求
|
||||
// 返回: err - 错误信息
|
||||
// 功能: 更新客服账号信息
|
||||
func (s *accountService) Update(ctx context.Context, req *dto.UpdateAccountReq) (err error) {
|
||||
_, err = dao.Account.Update(ctx, req)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete 删除客服账号
|
||||
// 参数: ctx - 上下文,req - 删除客服账号请求
|
||||
// 返回: err - 错误信息
|
||||
// 功能: 逻辑删除客服账号记录
|
||||
func (s *accountService) Delete(ctx context.Context, req *dto.DeleteAccountReq) (err error) {
|
||||
_, err = dao.Account.Delete(ctx, req)
|
||||
return
|
||||
}
|
||||
|
||||
// Get 获取单个客服账号
|
||||
// 参数: ctx - 上下文,req - 获取客服账号请求
|
||||
// 返回: res - 客服账号信息,err - 错误信息
|
||||
// 功能: 根据ID获取单个客服账号详情
|
||||
func (s *accountService) Get(ctx context.Context, req *dto.GetAccountReq) (res *dto.AccountVO, err error) {
|
||||
r, err := dao.Account.GetById(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = gconv.Struct(r, &res)
|
||||
return
|
||||
}
|
||||
|
||||
// List 获取客服账号列表
|
||||
// 参数: ctx - 上下文,req - 列表查询请求
|
||||
// 返回: res - 客服账号列表及分页信息,err - 错误信息
|
||||
// 功能: 分页查询客服账号记录
|
||||
func (s *accountService) List(ctx context.Context, req *dto.ListAccountReq) (res *dto.ListAccountRes, err error) {
|
||||
list, total, err := dao.Account.List(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = &dto.ListAccountRes{
|
||||
Total: total,
|
||||
}
|
||||
err = gconv.Struct(list, &res.List)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/consts/account"
|
||||
"customer-server/model/dto"
|
||||
"fmt"
|
||||
netHttp "net/http"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/beans"
|
||||
"gitea.redpowerfuture.com/red-future/common/jaeger"
|
||||
"github.com/gogf/gf/v2/container/gmap"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
"github.com/gogf/gf/v2/os/glog"
|
||||
"github.com/gogf/gf/v2/os/grpool"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// AccountWebSocket 全局单例
|
||||
var AccountWebSocket = &accountWebsocketService{
|
||||
connections: gmap.NewStrAnyMap(true),
|
||||
upgrader: websocket.Upgrader{
|
||||
CheckOrigin: func(r *netHttp.Request) bool {
|
||||
return true // 允许跨域
|
||||
},
|
||||
},
|
||||
workerPool: grpool.New(50), // 限制最大并发数为50
|
||||
}
|
||||
|
||||
type accountWebsocketService struct {
|
||||
connections *gmap.StrAnyMap
|
||||
upgrader websocket.Upgrader
|
||||
workerPool *grpool.Pool // 工作池限制goroutine数量
|
||||
}
|
||||
|
||||
// key: userId_platform
|
||||
// accountWsConnection WebSocket 连接信息
|
||||
type accountWsConnection struct {
|
||||
AccountInfo *dto.AccountVO
|
||||
UserId string
|
||||
Conn *websocket.Conn
|
||||
Headers map[string]string // 保存原始请求头
|
||||
}
|
||||
|
||||
// Connect 建立 WebSocket 连接
|
||||
func (s *accountWebsocketService) Connect(ctx context.Context, r *ghttp.Request, req *dto.AccountWebSocketConnectReq) error {
|
||||
// 使用原生upgrader升级WebSocket连接
|
||||
ws, err := s.upgrader.Upgrade(r.Response.Writer, r.Request, nil)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err, "WebSocket 升级失败")
|
||||
return err
|
||||
}
|
||||
|
||||
// 获取客服账号信息
|
||||
accountInfo, err := SessionToolService.GetAccountInfo(ctx, req.AccountCode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if g.IsEmpty(accountInfo) {
|
||||
return fmt.Errorf("客服账号不存在")
|
||||
}
|
||||
|
||||
// 创建完整的用户信息
|
||||
userInfo := &beans.User{
|
||||
UserName: accountInfo.Creator,
|
||||
TenantId: accountInfo.TenantId,
|
||||
}
|
||||
ctx = context.WithValue(ctx, "user", *userInfo)
|
||||
// 提取并保存请求头(在连接升级前)
|
||||
headers := make(map[string]string)
|
||||
// 提取其他headers
|
||||
for k, v := range r.Request.Header {
|
||||
if len(v) > 0 {
|
||||
headers[k] = v[0]
|
||||
}
|
||||
}
|
||||
// 将完整用户信息序列化为JSON,放到X-User-Info请求头
|
||||
userInfoJson, err := gjson.Encode(userInfo)
|
||||
if err != nil {
|
||||
glog.Errorf(ctx, "用户信息序列化失败: %v", err)
|
||||
} else {
|
||||
headers["X-User-Info"] = string(userInfoJson)
|
||||
glog.Debugf(ctx, "已添加用户信息到请求头: %s", string(userInfoJson))
|
||||
}
|
||||
|
||||
var key = fmt.Sprintf("account:%s:%s:%s", req.AccountCode, account.GetDescByCode(req.Platform), req.UserId)
|
||||
content, err := SessionToolService.PushOpeningRemark(ctx, req.UserId, accountInfo, headers)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !g.IsEmpty(content) {
|
||||
s.writeJSON(ws, &dto.WebSocketPushMsg{
|
||||
Type: "message",
|
||||
Message: content,
|
||||
})
|
||||
}
|
||||
|
||||
// 关闭旧连接
|
||||
if old := s.connections.Get(key); old != nil {
|
||||
old.(*accountWsConnection).Conn.Close()
|
||||
}
|
||||
|
||||
// 注册新连接(携带完整用户信息)
|
||||
s.connections.Set(key, &accountWsConnection{
|
||||
AccountInfo: accountInfo,
|
||||
UserId: req.UserId,
|
||||
Conn: ws,
|
||||
Headers: headers, // 保存请求头
|
||||
})
|
||||
|
||||
// 处理消息(阻塞)
|
||||
s.handleConnection(ctx, key, ws)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleConnection 处理 WebSocket 连接
|
||||
func (s *accountWebsocketService) handleConnection(ctx context.Context, key string, conn *websocket.Conn) {
|
||||
defer func() {
|
||||
s.connections.Remove(key)
|
||||
conn.Close()
|
||||
glog.Infof(ctx, "WebSocket 连接断开 - %s", key)
|
||||
}()
|
||||
|
||||
for {
|
||||
msgType, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
// 排除正常关闭情况:正常关闭、离开页面、无状态码关闭
|
||||
if websocket.IsUnexpectedCloseError(err,
|
||||
websocket.CloseNormalClosure,
|
||||
websocket.CloseGoingAway,
|
||||
websocket.CloseNoStatusReceived,
|
||||
) {
|
||||
jaeger.RecordError(ctx, err, "WebSocket 读取错误")
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if msgType != websocket.TextMessage {
|
||||
continue
|
||||
}
|
||||
|
||||
questionContent := gconv.String(message)
|
||||
glog.Infof(ctx, "收到 WebSocket 消息 - %s: %s", key, questionContent)
|
||||
|
||||
// 解析连接信息
|
||||
connInfo := s.connections.Get(key)
|
||||
if connInfo == nil {
|
||||
glog.Warningf(ctx, "WebSocket连接信息不存在 - %s", key)
|
||||
break
|
||||
}
|
||||
wsConn := connInfo.(*accountWsConnection)
|
||||
|
||||
// 发送ack告知用户正在处理
|
||||
s.writeJSON(conn, &dto.WebSocketPushMsg{Type: "ack", Message: "消息已接收,正在处理..."})
|
||||
|
||||
// 异步处理消息,避免阻塞WebSocket连接,使用工作池限制并发
|
||||
s.workerPool.Add(ctx, func(poolCtx context.Context) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
glog.Errorf(ctx, "WebSocket处理消息失败: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
var content string
|
||||
content, err = SessionToolService.PushDialog(ctx, wsConn.UserId, questionContent, wsConn.AccountInfo, wsConn.Headers)
|
||||
if err != nil {
|
||||
s.writeJSON(conn, &dto.WebSocketPushMsg{
|
||||
Type: "error",
|
||||
Content: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
// 发送答案给前端
|
||||
s.writeJSON(conn, &dto.WebSocketPushMsg{
|
||||
Type: "answer",
|
||||
Content: content,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// writeJSON 发送 JSON 消息(带错误处理)
|
||||
func (s *accountWebsocketService) writeJSON(conn *websocket.Conn, data interface{}) {
|
||||
jsonBytes, err := gjson.Encode(data)
|
||||
if err != nil {
|
||||
glog.Errorf(context.Background(), "JSON编码失败: %v", err)
|
||||
return
|
||||
}
|
||||
if err := conn.WriteMessage(websocket.TextMessage, jsonBytes); err != nil {
|
||||
glog.Errorf(context.Background(), "WebSocket写入失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *accountWebsocketService) AccountMsg(ctx context.Context, msg any) (err error) {
|
||||
msgStr := gconv.Map(msg)
|
||||
if g.IsEmpty(msgStr) {
|
||||
g.Log().Error(ctx, "DocsChunkMsg err:", "msg is empty")
|
||||
return
|
||||
}
|
||||
// 直接通过 key 获取连接
|
||||
connAny := s.connections.Get(gconv.String(msgStr["key"]))
|
||||
if connAny != nil {
|
||||
wsConn := connAny.(*accountWsConnection)
|
||||
s.writeJSON(wsConn.Conn, &dto.WebSocketPushMsg{
|
||||
Type: "delay_msg",
|
||||
Content: gconv.String(msgStr["data"]),
|
||||
})
|
||||
}
|
||||
g.Log().Info(ctx, "DocsChunkMsg:", msgStr)
|
||||
return
|
||||
}
|
||||
|
||||
// Close 释放所有资源
|
||||
func (s *accountWebsocketService) Close() {
|
||||
if s.workerPool != nil {
|
||||
s.workerPool.Close()
|
||||
glog.Info(context.Background(), "WebSocket工作池已关闭")
|
||||
}
|
||||
|
||||
// 关闭所有WebSocket连接
|
||||
s.connections.LockFunc(func(m map[string]interface{}) {
|
||||
for key, conn := range m {
|
||||
if wsConn, ok := conn.(*accountWsConnection); ok && wsConn.Conn != nil {
|
||||
wsConn.Conn.Close()
|
||||
glog.Infof(context.Background(), "强制关闭WebSocket连接 - %s", key)
|
||||
}
|
||||
}
|
||||
})
|
||||
s.connections.Clear()
|
||||
glog.Info(context.Background(), "WebSocket连接池已清空")
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
// Package service - 归档服务
|
||||
// 功能:对话记录从MongoDB归档到Elasticsearch,定时任务+手动触发
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/dao"
|
||||
"customer-server/model/entity"
|
||||
|
||||
"gitea.com/red-future/common/elasticsearch"
|
||||
"gitea.com/red-future/common/jaeger"
|
||||
"gitea.com/red-future/common/redis"
|
||||
"github.com/gogf/gf/v2/os/gcron"
|
||||
"github.com/gogf/gf/v2/os/glog"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
// archiveService 归档服务
|
||||
type archiveService struct{}
|
||||
|
||||
// ArchiveService 归档服务单例
|
||||
var ArchiveService = new(archiveService)
|
||||
|
||||
// 归档锁的键名和过期时间
|
||||
const (
|
||||
archiveLockKey = "archive:monthly:lock"
|
||||
archiveLockExpire = 3600 // 1 小时
|
||||
)
|
||||
|
||||
// MonthlyArchive 月度归档主流程
|
||||
// 参数: ctx - 上下文
|
||||
// 返回: err - 错误信息
|
||||
// 功能: 将MongoDB对话记录归档到Elasticsearch,流程:1.复制到临时表 2.删除原表 3.写入ES 4.删临时表
|
||||
// 注意: 使用分布式锁确保只有一个节点执行
|
||||
func (s *archiveService) MonthlyArchive(ctx context.Context) (err error) {
|
||||
// 获取分布式锁,确保只有一个节点执行归档
|
||||
if !redis.TryLock(ctx, archiveLockKey, archiveLockExpire) {
|
||||
glog.Info(ctx, "其他节点正在执行归档,本节点跳过")
|
||||
return
|
||||
}
|
||||
defer redis.Unlock(ctx, archiveLockKey)
|
||||
|
||||
beginTime := gtime.Now()
|
||||
glog.Info(ctx, "========== 开始月度归档 ==========")
|
||||
|
||||
// 计算归档时间范围
|
||||
now := gtime.Now()
|
||||
var archiveStart, archiveEnd *gtime.Time
|
||||
|
||||
testMode := GetConfigBool(ctx, "archive.testMode")
|
||||
if testMode {
|
||||
// 测试模式:归档最近 7 天
|
||||
archiveStart = now.AddDate(0, 0, -7)
|
||||
archiveEnd = now
|
||||
glog.Infof(ctx, "[测试模式] 归档时间范围: %s 至 %s(最近 7 天)",
|
||||
archiveStart.Format("Y-m-d H:i:s"),
|
||||
archiveEnd.Format("Y-m-d H:i:s"))
|
||||
} else {
|
||||
// 生产模式:归档上个月整月数据
|
||||
// 本月第一天 00:00:00
|
||||
archiveEnd = gtime.NewFromStr(now.Format("Y-m") + "-01 00:00:00")
|
||||
// 上个月第一天 00:00:00
|
||||
archiveStart = archiveEnd.AddDate(0, -1, 0)
|
||||
// 计算上个月天数
|
||||
daysInLastMonth := archiveEnd.AddDate(0, 0, -1).Day()
|
||||
glog.Infof(ctx, "归档时间范围: %s 至 %s(共 %d 天)",
|
||||
archiveStart.Format("Y-m-d H:i:s"),
|
||||
archiveEnd.AddDate(0, 0, -1).Format("Y-m-d 23:59:59"),
|
||||
daysInLastMonth)
|
||||
}
|
||||
|
||||
// Step 1: 复制数据到临时表
|
||||
glog.Info(ctx, "[Step 1/4] 复制数据到临时表...")
|
||||
copyCount, err := dao.Archive.CopyToTempByRange(ctx, archiveStart.Time, archiveEnd.Time)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err, "复制数据到临时表失败")
|
||||
return
|
||||
}
|
||||
if copyCount == 0 {
|
||||
glog.Info(ctx, "没有需要归档的数据,跳过")
|
||||
return
|
||||
}
|
||||
glog.Infof(ctx, "复制完成,共 %d 条记录", copyCount)
|
||||
|
||||
// Step 2: 删除原表数据
|
||||
glog.Info(ctx, "[Step 2/4] 删除原表数据...")
|
||||
deleteCount, err := dao.Archive.DeleteByTempIds(ctx)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err, "删除原表数据失败")
|
||||
// 不返回错误,继续尝试后续步骤
|
||||
}
|
||||
glog.Infof(ctx, "删除完成,共 %d 条记录", deleteCount)
|
||||
|
||||
// Step 3: 写入 ES
|
||||
glog.Info(ctx, "[Step 3/4] 写入 ES...")
|
||||
if err = s.writeToES(ctx); err != nil {
|
||||
jaeger.RecordError(ctx, err, "写入 ES 失败")
|
||||
// 不返回错误,临时表数据保留,下次可以重试
|
||||
return
|
||||
}
|
||||
|
||||
// Step 4: 删除临时表
|
||||
glog.Info(ctx, "[Step 4/4] 删除临时表...")
|
||||
if err = dao.Archive.DropTempCollection(ctx); err != nil {
|
||||
jaeger.RecordError(ctx, err, "删除临时表失败")
|
||||
return
|
||||
}
|
||||
|
||||
elapsed := gtime.Now().Sub(beginTime)
|
||||
glog.Infof(ctx, "========== 月度归档完成,耗时: %s ==========", elapsed)
|
||||
return
|
||||
}
|
||||
|
||||
// writeToES 将临时表数据写入 ES
|
||||
func (s *archiveService) writeToES(ctx context.Context) (err error) {
|
||||
// 确保索引存在
|
||||
if err = elasticsearch.CreateIndexIfNotExists(ctx, entity.ConversationESIndex, entity.ConversationESMapping); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 获取临时表数据
|
||||
tempData, err := dao.Archive.GetTempData(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if len(tempData) == 0 {
|
||||
glog.Info(ctx, "临时表无数据")
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为 ES 文档格式
|
||||
now := gtime.Now().Time
|
||||
docs := make([]interface{}, 0, len(tempData))
|
||||
for _, temp := range tempData {
|
||||
doc := entity.ConversationES{
|
||||
Id: temp.OriginalId,
|
||||
UserId: temp.UserId,
|
||||
Platform: temp.Platform,
|
||||
SessionId: temp.SessionId,
|
||||
Question: temp.Question,
|
||||
Answer: temp.Answer,
|
||||
MessageId: temp.MessageId,
|
||||
MsgTime: *temp.MsgTime, // 解引用指针类型
|
||||
TenantId: gconv.String(temp.TenantId),
|
||||
CreatedAt: *temp.CreatedAt, // 解引用指针类型
|
||||
UpdatedAt: *temp.UpdatedAt, // 解引用指针类型
|
||||
ArchivedAt: now,
|
||||
}
|
||||
docs = append(docs, doc)
|
||||
}
|
||||
|
||||
// 批量写入 ES
|
||||
batchSize := GetConfigInt(ctx, "archive.esBatchSize")
|
||||
for i := 0; i < len(docs); i += batchSize {
|
||||
end := i + batchSize
|
||||
if end > len(docs) {
|
||||
end = len(docs)
|
||||
}
|
||||
if err = elasticsearch.BulkIndex(ctx, entity.ConversationESIndex, docs[i:end]); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
glog.Infof(ctx, "ES 写入完成,共 %d 条记录", len(docs))
|
||||
return
|
||||
}
|
||||
|
||||
// ============== 定时任务(月度归档)==============
|
||||
|
||||
// StartArchiveCron 启动归档定时任务
|
||||
// 默认每月 1 号凌晨 3 点执行
|
||||
func (s *archiveService) StartCron(ctx context.Context) {
|
||||
cronExpr := GetConfigString(ctx, "archive.cron")
|
||||
enabled := GetConfigBool(ctx, "archive.enabled")
|
||||
|
||||
if !enabled {
|
||||
glog.Info(ctx, "月度归档定时任务已禁用")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, span := jaeger.NewSpan(ctx, "cron.archive.register")
|
||||
defer span.End()
|
||||
|
||||
_, err := gcron.Add(ctx, cronExpr, func(ctx context.Context) {
|
||||
ctx, span := jaeger.NewSpan(ctx, "cron.archive.monthly")
|
||||
defer span.End()
|
||||
|
||||
glog.Info(ctx, "月度归档定时任务开始执行")
|
||||
if err := s.MonthlyArchive(ctx); err != nil {
|
||||
jaeger.RecordError(ctx, err, "月度归档执行失败")
|
||||
}
|
||||
}, "monthly-archive")
|
||||
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err, "注册月度归档定时任务失败")
|
||||
return
|
||||
}
|
||||
|
||||
glog.Infof(ctx, "月度归档定时任务已启动 - Cron: %s", cronExpr)
|
||||
}
|
||||
|
||||
// RunNow 立即执行归档(用于手动触发或测试)
|
||||
func (s *archiveService) RunNow(ctx context.Context) error {
|
||||
return s.MonthlyArchive(ctx)
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/glog"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
"github.com/hashicorp/consul/api"
|
||||
)
|
||||
|
||||
var (
|
||||
configCache = make(map[string]interface{}) // 动态配置缓存
|
||||
configMu sync.RWMutex // 读写锁
|
||||
consulClient *api.Client // Consul客户端
|
||||
startOnce sync.Once // 确保只启动一次监听
|
||||
)
|
||||
|
||||
// InitConsulWatcher 初始化Consul配置监听
|
||||
func InitConsulWatcher(ctx context.Context) error {
|
||||
consulAddr := g.Cfg().MustGet(ctx, "consul.address").String()
|
||||
if consulAddr == "" {
|
||||
glog.Warning(ctx, "Consul未配置,使用本地config.yml")
|
||||
return nil
|
||||
}
|
||||
|
||||
config := api.DefaultConfig()
|
||||
config.Address = consulAddr
|
||||
client, err := api.NewClient(config)
|
||||
if err != nil {
|
||||
glog.Errorf(ctx, "Consul客户端初始化失败: %v", err)
|
||||
return err
|
||||
}
|
||||
consulClient = client
|
||||
|
||||
startOnce.Do(func() {
|
||||
go watchAllConfigsV2(ctx)
|
||||
glog.Info(ctx, "Consul配置监听已启动")
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// watchAllConfigsV2 监听Consul前缀下所有配置
|
||||
func watchAllConfigsV2(ctx context.Context) {
|
||||
const prefix = "customerService/"
|
||||
kv := consulClient.KV()
|
||||
var lastIndex uint64
|
||||
|
||||
for {
|
||||
pairs, meta, err := kv.List(prefix, &api.QueryOptions{
|
||||
WaitIndex: lastIndex,
|
||||
WaitTime: 5 * time.Minute,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
glog.Errorf(ctx, "Consul查询失败: %v", err)
|
||||
time.Sleep(5 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
if meta.LastIndex != lastIndex {
|
||||
lastIndex = meta.LastIndex
|
||||
updateConfigCacheDiff(ctx, pairs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// updateConfigCacheDiff 增量更新配置缓存
|
||||
func updateConfigCacheDiff(ctx context.Context, pairs api.KVPairs) {
|
||||
configMu.Lock()
|
||||
defer configMu.Unlock()
|
||||
|
||||
for _, pair := range pairs {
|
||||
key := strings.TrimPrefix(pair.Key, "customerService/")
|
||||
key = strings.ReplaceAll(key, "/", ".")
|
||||
|
||||
newVal := parseValue(pair.Value)
|
||||
oldVal, exists := configCache[key]
|
||||
|
||||
if !exists || oldVal != newVal {
|
||||
logConfigChange(ctx, key, gconv.String(oldVal), gconv.String(newVal))
|
||||
configCache[key] = newVal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseValue 自动推断配置值类型
|
||||
func parseValue(value []byte) interface{} {
|
||||
str := string(value)
|
||||
|
||||
if i, err := strconv.Atoi(str); err == nil {
|
||||
return i
|
||||
}
|
||||
|
||||
if b, err := strconv.ParseBool(str); err == nil {
|
||||
return b
|
||||
}
|
||||
|
||||
var arr []interface{}
|
||||
if err := json.Unmarshal(value, &arr); err == nil {
|
||||
return arr
|
||||
}
|
||||
|
||||
return str
|
||||
}
|
||||
|
||||
// GetConfigInt 读取int配置
|
||||
func GetConfigInt(ctx context.Context, key string) int {
|
||||
configMu.RLock()
|
||||
val, ok := configCache[key]
|
||||
configMu.RUnlock()
|
||||
|
||||
if ok {
|
||||
return gconv.Int(val)
|
||||
}
|
||||
|
||||
return g.Cfg().MustGet(ctx, key).Int()
|
||||
}
|
||||
|
||||
// GetConfigString 读取string配置
|
||||
func GetConfigString(ctx context.Context, key string) string {
|
||||
configMu.RLock()
|
||||
val, ok := configCache[key]
|
||||
configMu.RUnlock()
|
||||
|
||||
if ok {
|
||||
return gconv.String(val)
|
||||
}
|
||||
|
||||
return g.Cfg().MustGet(ctx, key).String()
|
||||
}
|
||||
|
||||
// GetConfigBool 读取bool配置
|
||||
func GetConfigBool(ctx context.Context, key string) bool {
|
||||
configMu.RLock()
|
||||
val, ok := configCache[key]
|
||||
configMu.RUnlock()
|
||||
|
||||
if ok {
|
||||
return gconv.Bool(val)
|
||||
}
|
||||
|
||||
return g.Cfg().MustGet(ctx, key).Bool()
|
||||
}
|
||||
|
||||
// GetConfigStringSlice 读取字符串数组配置
|
||||
func GetConfigStringSlice(ctx context.Context, key string) []string {
|
||||
configMu.RLock()
|
||||
val, ok := configCache[key]
|
||||
configMu.RUnlock()
|
||||
|
||||
if ok {
|
||||
if arr, ok := val.([]interface{}); ok {
|
||||
result := make([]string, len(arr))
|
||||
for i, v := range arr {
|
||||
result[i] = gconv.String(v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
return g.Cfg().MustGet(ctx, key).Strings()
|
||||
}
|
||||
|
||||
// GetInstanceConfigStringSlice 读取实例级字符串数组配置(支持实例级负载隔离)
|
||||
// 优先级:实例专用配置 > 全局Consul配置 > config.yml
|
||||
func GetInstanceConfigStringSlice(ctx context.Context, key string) []string {
|
||||
// 获取实例ID(环境变量优先,hostname备用)
|
||||
instanceID := os.Getenv("INSTANCE_ID")
|
||||
if instanceID == "" {
|
||||
hostname, err := os.Hostname()
|
||||
if err == nil {
|
||||
instanceID = hostname
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有实例ID,先查找实例专用配置
|
||||
if instanceID != "" {
|
||||
instanceKey := fmt.Sprintf("instance.%s.%s", instanceID, key)
|
||||
configMu.RLock()
|
||||
val, ok := configCache[instanceKey]
|
||||
configMu.RUnlock()
|
||||
|
||||
if ok {
|
||||
if arr, ok := val.([]interface{}); ok {
|
||||
result := make([]string, len(arr))
|
||||
for i, v := range arr {
|
||||
result[i] = gconv.String(v)
|
||||
}
|
||||
glog.Debugf(ctx, "🎯 使用实例专用配置: %s = %v", instanceKey, result)
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 未找到实例配置,fallback到全局配置
|
||||
return GetConfigStringSlice(ctx, key)
|
||||
}
|
||||
|
||||
// logConfigChange 记录配置变更
|
||||
func logConfigChange(ctx context.Context, key, oldVal, newVal string) {
|
||||
glog.Infof(ctx, "📝 配置变更: %s = %s → %s", key, oldVal, newVal)
|
||||
}
|
||||
@@ -1,353 +0,0 @@
|
||||
// Package service - 对话服务
|
||||
// 功能:处理RAGFlow响应、批量落库、卡片触发逻辑
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/dao"
|
||||
"customer-server/model/entity"
|
||||
|
||||
"gitea.com/red-future/common/jaeger"
|
||||
"gitea.com/red-future/common/rabbitmq"
|
||||
"gitea.com/red-future/common/redis"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/glog"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
// conversationService 对话服务(操作 conversation 表)
|
||||
type conversationService struct{}
|
||||
|
||||
// ConversationService 对话服务单例
|
||||
var ConversationService = new(conversationService)
|
||||
|
||||
// ============== RabbitMQ 消费者(RAGFlow 响应消息)==============
|
||||
|
||||
// ResponseConsumer RabbitMQ 响应消费者
|
||||
type ResponseConsumer struct {
|
||||
queueName string
|
||||
consumer *rabbitmq.Consumer
|
||||
}
|
||||
|
||||
// NewResponseConsumer 创建响应消费者
|
||||
// 参数: ctx - 上下文
|
||||
// 返回: ResponseConsumer - 响应消费者实例
|
||||
// 功能: 创建当前实例的唯一响应队列,支持多实例部署
|
||||
func NewResponseConsumer(ctx context.Context) *ResponseConsumer {
|
||||
// 从配置读取基础队列名(用于生成唯一实例队列名)
|
||||
baseQueue := GetConfigString(ctx, "rabbitmq.responseQueue")
|
||||
|
||||
// 生成当前实例的唯一队列名:ragflow.response.queue.{hostname}.{uuid8}
|
||||
// 支持多实例部署,每个实例有独立响应队列
|
||||
queueName := rabbitmq.GetInstanceQueueName(baseQueue)
|
||||
glog.Infof(ctx, "响应队列动态生成 - 实例队列: %s", queueName)
|
||||
|
||||
return &ResponseConsumer{
|
||||
queueName: queueName,
|
||||
}
|
||||
}
|
||||
|
||||
// Start 启动消费者
|
||||
// 参数: ctx - 上下文
|
||||
// 返回: err - 错误信息
|
||||
// 功能: 声明并绑定当前实例的响应队列,开始消费RAGFlow响应消息
|
||||
func (c *ResponseConsumer) Start(ctx context.Context) (err error) {
|
||||
glog.Infof(ctx, "RabbitMQ 响应消费者启动 - Queue: %s", c.queueName)
|
||||
|
||||
// 声明当前实例的动态响应队列
|
||||
if err = rabbitmq.DeclareQueue(ctx, &rabbitmq.QueueConfig{
|
||||
Name: c.queueName,
|
||||
Durable: true,
|
||||
}); err != nil {
|
||||
glog.Errorf(ctx, "声明动态响应队列失败: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// 绑定队列到 Exchange(使用队列名作为routing key,实现精确路由)
|
||||
// message发送消息时会使用队列名作为routing key
|
||||
if err = rabbitmq.BindQueue(ctx, &rabbitmq.BindingConfig{
|
||||
Queue: c.queueName,
|
||||
Exchange: "ragflow.response",
|
||||
RoutingKey: c.queueName, // 使用队列名,只接收发给自己的消息
|
||||
}); err != nil {
|
||||
glog.Errorf(ctx, "绑定动态响应队列失败: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
glog.Infof(ctx, "动态响应队列已绑定: %s -> ragflow.response (routingKey=#)", c.queueName)
|
||||
|
||||
c.consumer = rabbitmq.NewConsumer(c.queueName, handleResponse)
|
||||
return c.consumer.Start(ctx)
|
||||
}
|
||||
|
||||
// Stop 停止消费者
|
||||
// 参数: ctx - 上下文
|
||||
// 功能: 停止消费RAGFlow响应消息
|
||||
func (c *ResponseConsumer) Stop(ctx context.Context) {
|
||||
if c.consumer != nil {
|
||||
c.consumer.Stop(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// ============== 卡片触发配置(待接入小红书卡片接口后修改)==============
|
||||
//
|
||||
// 【用户状态存储】
|
||||
// 使用 Redis Hash 存储用户会话状态(阶段+对话计数),统一5分钟TTL
|
||||
// Key: ragflow:user:state:{userId}_{platform}
|
||||
// Fields: stage(阶段)、count(对话计数)
|
||||
//
|
||||
// 【状态定义】
|
||||
// 状态0:走AI模型 | 状态1:打招呼 | 状态2:业务咨询 | 状态3:发卡片
|
||||
//
|
||||
// 【卡片触发逻辑】
|
||||
// 对话轮数>=配置值时,更新状态为3,并发送卡片消息
|
||||
// 配置项:config.yml中的card.triggerCount(默认5轮)
|
||||
//
|
||||
// 【待接入小红书卡片 API 后修改位置】
|
||||
// checkAndSendCard() 函数中的 cardMessage 变量
|
||||
|
||||
const (
|
||||
// ConversationFlushDelaySeconds 对话缓存延时落库时间(秒)
|
||||
ConversationFlushDelaySeconds = 600 // 10分钟
|
||||
)
|
||||
|
||||
// ============== 延时落库消费者 ==============
|
||||
|
||||
// DelayedFlushMessage 延时落库消息(按sessionId)
|
||||
type DelayedFlushMessage struct {
|
||||
SessionId string `json:"sessionId"`
|
||||
}
|
||||
|
||||
// DelayedFlushConsumer 延时落库消费者
|
||||
type DelayedFlushConsumer struct {
|
||||
queueName string
|
||||
consumer *rabbitmq.Consumer
|
||||
}
|
||||
|
||||
// NewDelayedFlushConsumer 创建延时落库消费者
|
||||
func NewDelayedFlushConsumer(ctx context.Context) *DelayedFlushConsumer {
|
||||
return &DelayedFlushConsumer{
|
||||
queueName: "conversation.flush.queue",
|
||||
}
|
||||
}
|
||||
|
||||
// Start 启动消费者
|
||||
func (c *DelayedFlushConsumer) Start(ctx context.Context) (err error) {
|
||||
glog.Infof(ctx, "延时落库消费者启动 - Queue: %s", c.queueName)
|
||||
c.consumer = rabbitmq.NewConsumer(c.queueName, handleDelayedFlush)
|
||||
return c.consumer.Start(ctx)
|
||||
}
|
||||
|
||||
// Stop 停止消费者
|
||||
func (c *DelayedFlushConsumer) Stop(ctx context.Context) {
|
||||
if c.consumer != nil {
|
||||
c.consumer.Stop(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// handleDelayedFlush 处理延时落库消息
|
||||
func handleDelayedFlush(ctx context.Context, body []byte) error {
|
||||
var msg DelayedFlushMessage
|
||||
if err := gjson.DecodeTo(body, &msg); err != nil {
|
||||
glog.Errorf(ctx, "解析延时落库消息失败: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
glog.Infof(ctx, "收到延时落库消息 - SessionId: %s", msg.SessionId)
|
||||
|
||||
// 检查是否有未落库的缓存
|
||||
count, err := redis.GetCachedConversationCount(ctx, msg.SessionId)
|
||||
if err != nil {
|
||||
glog.Errorf(ctx, "获取缓存数量失败: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
glog.Debugf(ctx, "无需落库(缓存为空或已落库)- SessionId: %s", msg.SessionId)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 执行落库
|
||||
if err = flushConversationCache(ctx, msg.SessionId); err != nil {
|
||||
glog.Errorf(ctx, "延时落库失败: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
glog.Infof(ctx, "延时落库完成 - SessionId: %s", msg.SessionId)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 延时落库发布器(单例)
|
||||
var delayedFlushPublisher *rabbitmq.Publisher
|
||||
|
||||
// getDelayedFlushPublisher 获取延时落库发布器
|
||||
func getDelayedFlushPublisher() *rabbitmq.Publisher {
|
||||
if delayedFlushPublisher == nil {
|
||||
delayedFlushPublisher = rabbitmq.NewPublisher("conversation.flush.delayed", "flush")
|
||||
}
|
||||
return delayedFlushPublisher
|
||||
}
|
||||
|
||||
// sendDelayedFlushMessage 发送延时落库消息
|
||||
func sendDelayedFlushMessage(ctx context.Context, sessionId string) error {
|
||||
msg := &DelayedFlushMessage{
|
||||
SessionId: sessionId,
|
||||
}
|
||||
return getDelayedFlushPublisher().PublishDelayed(ctx, msg, ConversationFlushDelaySeconds)
|
||||
}
|
||||
|
||||
// handleResponse 处理 RabbitMQ 消息(幂等)
|
||||
// 落库逻辑:前5句缓存到Redis,第5句时批量落库MongoDB,超过5句不落库
|
||||
func handleResponse(ctx context.Context, body []byte) error {
|
||||
ctx, span := jaeger.NewSpan(ctx, "consumer.response")
|
||||
defer span.End()
|
||||
|
||||
glog.Infof(ctx, ">>> handleResponse 被调用,消息长度: %d", len(body))
|
||||
|
||||
// 解析消息到结构体
|
||||
var msg redis.ResponseStreamMessage
|
||||
if err := gjson.DecodeTo(body, &msg); err != nil {
|
||||
jaeger.RecordError(ctx, err, "解析响应消息失败")
|
||||
return err
|
||||
}
|
||||
|
||||
glog.Infof(ctx, "收到 RAGFlow 响应 - 用户: %s, MessageId: %s", msg.UserId, msg.MessageId)
|
||||
|
||||
// 1. 获取当前对话轮数
|
||||
state, err := redis.GetUserState(ctx, msg.UserId, msg.Platform)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err, "获取用户状态失败")
|
||||
}
|
||||
count := state.Count
|
||||
|
||||
// 2. 根据轮数决定落库策略
|
||||
cardTriggerCount := g.Cfg().MustGet(ctx, "card.triggerCount", 5).Int64()
|
||||
if count <= cardTriggerCount {
|
||||
// 前N句:缓存到Redis(按sessionId)
|
||||
msgTime := gtime.NewFromTimeStamp(msg.Timestamp).Time
|
||||
conversation := &entity.Conversation{
|
||||
UserId: msg.UserId,
|
||||
Platform: msg.Platform,
|
||||
SessionId: msg.SessionId,
|
||||
Question: msg.Question,
|
||||
Answer: msg.Content,
|
||||
MessageId: msg.MessageId,
|
||||
MsgTime: &msgTime, // 取地址赋值给指针类型
|
||||
}
|
||||
conversation.TenantId = msg.TenantId
|
||||
|
||||
// 序列化后缓存(使用sessionId作为key)
|
||||
data, _ := gjson.Encode(conversation)
|
||||
if cacheErr := redis.CacheConversation(ctx, msg.SessionId, data); cacheErr != nil {
|
||||
jaeger.RecordError(ctx, cacheErr, "缓存对话记录失败")
|
||||
} else {
|
||||
glog.Debugf(ctx, "对话已缓存到 Redis - SessionId: %s, 第 %d 轮", msg.SessionId, count)
|
||||
}
|
||||
|
||||
// 第1句时:发送10分钟延时落库消息(兜底)
|
||||
if count == 1 {
|
||||
if delayErr := sendDelayedFlushMessage(ctx, msg.SessionId); delayErr != nil {
|
||||
glog.Warningf(ctx, "发送延时落库消息失败: %v", delayErr)
|
||||
}
|
||||
}
|
||||
|
||||
// 第N句时:立即批量落库
|
||||
if count == cardTriggerCount {
|
||||
if flushErr := flushConversationCache(ctx, msg.SessionId); flushErr != nil {
|
||||
jaeger.RecordError(ctx, flushErr, "批量落库失败")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 超过N句:不落库(已发卡片)
|
||||
glog.Debugf(ctx, "第 %d 轮(>%d),跳过落库 - 用户: %s", count, cardTriggerCount, msg.UserId)
|
||||
}
|
||||
|
||||
// 3. 推送给 WebSocket 用户(无论是否落库都推送)
|
||||
glog.Infof(ctx, "准备推送 WebSocket - 用户: %s_%s, 内容长度: %d", msg.UserId, msg.Platform, len(msg.Content))
|
||||
if err = WebSocket.PushRAGFlowResponse(ctx, msg.TenantId, msg.UserId, msg.Platform, msg.Content); err != nil {
|
||||
jaeger.RecordError(ctx, err, "推送 WebSocket 失败")
|
||||
} else {
|
||||
glog.Infof(ctx, "WebSocket 推送成功 - 用户: %s_%s", msg.UserId, msg.Platform)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// flushConversationCache 将Redis缓存的对话批量落库到MongoDB
|
||||
func flushConversationCache(ctx context.Context, sessionId string) error {
|
||||
// 获取缓存的对话列表
|
||||
cached, err := redis.GetCachedConversations(ctx, sessionId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(cached) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 反序列化
|
||||
list := make([]*entity.Conversation, 0, len(cached))
|
||||
for _, data := range cached {
|
||||
var conv entity.Conversation
|
||||
if decErr := gjson.DecodeTo([]byte(data), &conv); decErr != nil {
|
||||
glog.Warningf(ctx, "反序列化对话失败: %v", decErr)
|
||||
continue
|
||||
}
|
||||
list = append(list, &conv)
|
||||
}
|
||||
|
||||
// 批量插入MongoDB
|
||||
if len(list) > 0 {
|
||||
if insertErr := dao.Conversation.BatchInsert(ctx, list); insertErr != nil {
|
||||
return insertErr
|
||||
}
|
||||
glog.Infof(ctx, "批量落库成功 - SessionId: %s, 共 %d 条", sessionId, len(list))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkCardBeforeProcess 检查对话轮数,达到阈值时发卡片
|
||||
// 返回 handled=true 表示已处理(发送卡片),调用方应跳过后续话术处理
|
||||
func checkCardBeforeProcess(ctx context.Context, tenantId, userId, platform string) (handled bool, err error) {
|
||||
// 获取用户当前状态
|
||||
state, err := redis.GetUserState(ctx, userId, platform)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 状态5(未选择方向)时不计数,等用户选择方向后再开始计数
|
||||
if state.Stage == 5 {
|
||||
glog.Debugf(ctx, "用户 %s_%s 处于状态5(未选择方向),跳过计数", userId, platform)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// 增加对话计数
|
||||
count, err := redis.IncrUserCount(ctx, userId, platform)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
glog.Infof(ctx, "用户 %s_%s 当前对话轮数: %d", userId, platform, count)
|
||||
|
||||
// 对话>=配置轮数,发卡片并跳过话术(从配置值开始就发卡片,不再调用AI)
|
||||
cardTriggerCount := g.Cfg().MustGet(ctx, "card.triggerCount", 5).Int64()
|
||||
if count >= cardTriggerCount {
|
||||
glog.Infof(ctx, "用户 %s_%s 对话第 %d 轮(>=%d),触发发送卡片", userId, platform, count, cardTriggerCount)
|
||||
|
||||
// 更新用户状态为3(发卡片状态)
|
||||
if updateErr := redis.SetUserStage(ctx, userId, platform, 3); updateErr != nil {
|
||||
jaeger.RecordError(ctx, updateErr, "更新用户状态为3失败")
|
||||
}
|
||||
|
||||
cardMessage := "请加一下卡片的联系方式,进行更专业的咨询" // TODO: 替换为实际卡片发送逻辑
|
||||
if pushErr := WebSocket.PushRAGFlowResponse(ctx, tenantId, userId, platform, cardMessage); pushErr != nil {
|
||||
jaeger.RecordError(ctx, pushErr, "推送卡片消息失败")
|
||||
glog.Errorf(ctx, "推送卡片失败 - 用户: %s_%s, 错误: %v", userId, platform, pushErr)
|
||||
err = pushErr
|
||||
return
|
||||
}
|
||||
glog.Infof(ctx, "卡片消息已推送 - 用户: %s_%s", userId, platform)
|
||||
handled = true
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1,855 +0,0 @@
|
||||
// Package service - 客服账号服务
|
||||
// 功能:客服账号的增删改查业务逻辑
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/dao"
|
||||
"customer-server/model/dto"
|
||||
"customer-server/model/entity"
|
||||
"customer-server/util"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.com/red-future/common/beans"
|
||||
commonMongo "gitea.com/red-future/common/db/mongo"
|
||||
"gitea.com/red-future/common/ragflow"
|
||||
"gitea.com/red-future/common/utils"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/text/gstr"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
var CustomerServiceAccount = new(customerServiceAccount)
|
||||
|
||||
type customerServiceAccount struct{}
|
||||
|
||||
// Add 添加客服账号
|
||||
func (s *customerServiceAccount) Add(ctx context.Context, req *dto.AddCustomerServiceAccountReq) (res *dto.AddCustomerServiceAccountRes, err error) {
|
||||
// 1. 检查客服ID是否已存在
|
||||
coll := commonMongo.GetDB().Collection(entity.CustomerServiceAccountCollection)
|
||||
filter := bson.M{"accountName": req.AccountName, "isDeleted": false}
|
||||
count, err := coll.CountDocuments(ctx, filter)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "检查客服ID是否存在失败")
|
||||
}
|
||||
if count > 0 {
|
||||
return nil, gerror.Newf("客服账号名称 '%s' 已存在,请使用其他名称", req.AccountName)
|
||||
}
|
||||
|
||||
// 2. 准备数据(但暂不写入MongoDB)
|
||||
data := &entity.CustomerServiceAccount{}
|
||||
if err = utils.Struct(req, data); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 调试日志:输出请求和转换后的数据
|
||||
g.Log().Infof(ctx, "[Add] 请求数据 - AccountName: %s, Platform: %s, Prompt: %v, Greeting: %v",
|
||||
req.AccountName, req.Platform, req.Prompt, req.Greeting)
|
||||
g.Log().Infof(ctx, "[Add] 转换后Entity - AccountName: %s, Platform: %s, Greeting: %s",
|
||||
data.AccountName, data.Platform, data.Greeting)
|
||||
|
||||
now := gtime.Now().Time
|
||||
data.CreatedAt = &now // 取地址赋值给指针类型
|
||||
data.UpdatedAt = &now // 取地址赋值给指针类型
|
||||
data.IsDeleted = false
|
||||
data.IsDisabled = false
|
||||
|
||||
// 获取或设置tenantId(统一转换为string存储)
|
||||
var tenantId string
|
||||
if req.TenantId != nil {
|
||||
tenantId = gconv.String(req.TenantId)
|
||||
g.Log().Infof(ctx, "使用手动指定的tenant_id: %v", req.TenantId)
|
||||
} else {
|
||||
// 从session获取tenantId
|
||||
tenantInfo, err := util.GetTenantInfo(ctx)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "无法获取租户信息")
|
||||
}
|
||||
tenantId = gconv.String(tenantInfo.TenantId)
|
||||
if tenantId == "" {
|
||||
return nil, gerror.New("无法获取租户ID")
|
||||
}
|
||||
}
|
||||
// 统一使用string类型存储到MongoDB
|
||||
data.TenantId = tenantId
|
||||
|
||||
// 3. 确保租户知识库存在(调用公共方法)
|
||||
datasetId, err := EnsureTenantDataset(ctx, tenantId)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "确保租户知识库存在失败: %v", err)
|
||||
return nil, gerror.Wrap(err, "确保租户知识库存在失败")
|
||||
}
|
||||
|
||||
// 4. 先插入客服账号(必须在创建Chat之前,因为createChatAndSaveConfig内部会通过accountName查询租户ID)
|
||||
err = dao.CustomerServiceAccount.Insert(ctx, data)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "插入客服账号失败")
|
||||
}
|
||||
|
||||
// 5. 处理话术绑定
|
||||
if len(req.SpeechcraftIds) > 0 {
|
||||
g.Log().Infof(ctx, "开始同步话术到RAGFlow: count=%d", len(req.SpeechcraftIds))
|
||||
for _, speechcraftId := range req.SpeechcraftIds {
|
||||
_, err := Speechcraft.SyncToRAGFlow(ctx, speechcraftId, req.AccountName, tenantId)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "同步话术到RAGFlow失败: speechcraft_id=%s, error=%v", speechcraftId, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 创建Chat并保存RAGFlow配置(内部会轮询等待文档解析完成)
|
||||
var assistantDesc string
|
||||
if req.SelfIdentity != nil {
|
||||
assistantDesc = *req.SelfIdentity
|
||||
}
|
||||
if err = s.createChatAndSaveConfig(ctx, req.AccountName, req.Platform, tenantId, datasetId, assistantDesc); err != nil {
|
||||
g.Log().Errorf(ctx, "创建Chat配置失败: %v", err)
|
||||
return nil, gerror.Wrap(err, "创建Chat配置失败")
|
||||
}
|
||||
|
||||
// 7. 如果提供了自定义提示词,调用UpdatePrompt更新
|
||||
if req.Prompt != nil && *req.Prompt != "" {
|
||||
g.Log().Infof(ctx, "创建完成,开始更新自定义提示词")
|
||||
updateReq := &dto.UpdatePromptReq{
|
||||
AccountName: req.AccountName,
|
||||
Prompt: *req.Prompt,
|
||||
}
|
||||
if _, err := RAGFlowConfig.UpdatePrompt(ctx, updateReq); err != nil {
|
||||
g.Log().Errorf(ctx, "更新自定义提示词失败: %v", err)
|
||||
// 不阻断创建流程,提示词可以后续修改
|
||||
} else {
|
||||
g.Log().Infof(ctx, "自定义提示词更新成功")
|
||||
}
|
||||
}
|
||||
|
||||
res = &dto.AddCustomerServiceAccountRes{Id: data.Id.Hex()}
|
||||
return
|
||||
}
|
||||
|
||||
// createChatAndSaveConfig 创建Chat并保存RAGFlow配置(会轮询等待文档解析完成)
|
||||
func (s *customerServiceAccount) createChatAndSaveConfig(ctx context.Context, accountName, platform, tenantId, datasetId, assistantDescription string) error {
|
||||
ragflowClient := ragflow.GetGlobalClient()
|
||||
|
||||
// 1. 轮询检查知识库中是否有解析完成的文档(最多60秒)
|
||||
g.Log().Infof(ctx, "等待知识库文档解析完成: dataset_id=%s", datasetId)
|
||||
maxWaitSeconds := 60
|
||||
|
||||
for i := 0; i < maxWaitSeconds; i++ {
|
||||
listReq := &ragflow.ListDocumentsReq{
|
||||
Page: 1,
|
||||
PageSize: 1,
|
||||
}
|
||||
listRes, err := ragflowClient.ListDocuments(ctx, datasetId, listReq)
|
||||
if err == nil && listRes != nil && len(listRes.Data.Docs) > 0 {
|
||||
doc := listRes.Data.Docs[0]
|
||||
|
||||
// 调试:输出所有状态字段
|
||||
if i == 0 || i%5 == 0 {
|
||||
g.Log().Infof(ctx, "文档状态详情: dataset_id=%s, doc_id=%s, RunStatus=%s, Status=%s, ChunkCount=%d, Progress=%.2f, ProgressMsg=%s",
|
||||
datasetId, doc.Id, doc.RunStatus, doc.Status, doc.ChunkCount, doc.Progress, doc.ProgressMsg)
|
||||
}
|
||||
|
||||
// 检查解析失败状态:Progress < 0 表示解析出错(如embedding模型无法访问)
|
||||
if doc.Progress < 0 {
|
||||
return gerror.Newf("RAGFlow文档解析失败(embedding服务不可用): dataset_id=%s, doc_id=%s, error=%s",
|
||||
datasetId, doc.Id, doc.ProgressMsg)
|
||||
}
|
||||
|
||||
// 检查解析完成的条件:ChunkCount > 0 表示已解析出分块
|
||||
if doc.ChunkCount > 0 {
|
||||
g.Log().Infof(ctx, "知识库文档解析完成: dataset_id=%s, doc_id=%s, chunk_count=%d, 耗时=%d秒",
|
||||
datasetId, doc.Id, doc.ChunkCount, i)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 超时检查
|
||||
if i >= maxWaitSeconds-1 {
|
||||
return gerror.Newf("等待文档解析超时: dataset_id=%s, 已等待%d秒", datasetId, maxWaitSeconds)
|
||||
}
|
||||
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
|
||||
// 2. 生成默认提示词、助理描述并创建Chat
|
||||
promptText := s.generateDefaultPrompt()
|
||||
modelName := s.getDefaultModelName(ctx)
|
||||
|
||||
// 使用传入的assistantDescription,如果为空则使用默认生成
|
||||
var assistantDesc string
|
||||
if assistantDescription != "" {
|
||||
assistantDesc = assistantDescription
|
||||
} else {
|
||||
assistantDesc = s.generateAssistantDescription(accountName, platform)
|
||||
}
|
||||
|
||||
// Chat名称:账号名_平台_租户ID
|
||||
chatName := fmt.Sprintf("%s_%s_%s", accountName, platform, tenantId)
|
||||
|
||||
chatReq := &ragflow.CreateChatReq{
|
||||
Name: chatName,
|
||||
Description: assistantDesc,
|
||||
DatasetIds: []string{datasetId},
|
||||
Prompt: &ragflow.PromptConfig{
|
||||
Prompt: promptText,
|
||||
SimilarityThreshold: 0.2,
|
||||
KeywordsSimilarityWeight: 0.7,
|
||||
TopN: 8,
|
||||
EmptyResponse: "",
|
||||
Variables: []map[string]interface{}{
|
||||
{
|
||||
"key": "knowledge",
|
||||
"optional": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
Llm: &ragflow.Llm{
|
||||
ModelName: modelName,
|
||||
},
|
||||
}
|
||||
|
||||
chat, err := ragflowClient.CreateChat(ctx, chatReq)
|
||||
if err != nil {
|
||||
// 如果Chat名称已存在,尝试查询现有Chat
|
||||
if strings.Contains(err.Error(), "Duplicated chat name") {
|
||||
g.Log().Warningf(ctx, "Chat名称已存在,尝试查询现有Chat: %s", chatName)
|
||||
|
||||
// 查询Chat列表,查找同名的Chat
|
||||
listReq := &ragflow.ListChatsReq{
|
||||
Page: 1,
|
||||
PageSize: 100,
|
||||
}
|
||||
listRes, listErr := ragflowClient.ListChats(ctx, listReq)
|
||||
if listErr != nil {
|
||||
return gerror.Wrapf(listErr, "查询Chat列表失败")
|
||||
}
|
||||
// 检查listRes和Data是否为nil:防止遍历nil切片导致空指针异常
|
||||
if listRes == nil || listRes.Data == nil {
|
||||
return gerror.New("查询Chat列表返回空对象")
|
||||
}
|
||||
|
||||
// 查找同名Chat
|
||||
for _, existingChat := range listRes.Data {
|
||||
if existingChat.Name == chatName {
|
||||
g.Log().Infof(ctx, "找到现有Chat: name=%s, id=%s,复用该Chat", chatName, existingChat.Id)
|
||||
chat = existingChat
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if chat == nil {
|
||||
return gerror.Newf("找不到同名Chat: %s", chatName)
|
||||
}
|
||||
} else {
|
||||
return gerror.Wrapf(err, "创建RAGFlow Chat失败")
|
||||
}
|
||||
}
|
||||
|
||||
// 最终检查chat是否为nil:防止CreateChat返回(nil, nil)或Duplicated情况下未找到同名Chat
|
||||
if chat == nil {
|
||||
return gerror.New("创建RAGFlow Chat返回空对象")
|
||||
}
|
||||
|
||||
// 先查询现有配置,保留用户的自定义设置
|
||||
coll := commonMongo.GetDB().Collection(entity.RAGFlowConfigCollection)
|
||||
filter := bson.M{"accountName": accountName, "isDeleted": false}
|
||||
var existingConfig entity.RAGFlowConfig
|
||||
findErr := coll.FindOne(ctx, filter).Decode(&existingConfig)
|
||||
|
||||
now := gtime.Now().Time
|
||||
|
||||
// 如果已有配置,保留用户自定义的prompt和参数
|
||||
if findErr == nil && !existingConfig.Id.IsZero() {
|
||||
g.Log().Infof(ctx, "保留现有配置的自定义设置,仅更新chat_id和dataset_id")
|
||||
|
||||
// 只更新必要字段,保留用户自定义内容
|
||||
update := bson.M{
|
||||
"$set": bson.M{
|
||||
"chatId": chat.Id,
|
||||
"datasetId": datasetId,
|
||||
"datasetIds": []string{datasetId},
|
||||
"syncStatus": "synced",
|
||||
"updatedAt": now,
|
||||
},
|
||||
}
|
||||
if _, err := coll.UpdateOne(ctx, filter, update); err != nil {
|
||||
return gerror.Wrap(err, "更新RAGFlowConfig失败")
|
||||
}
|
||||
} else {
|
||||
// 首次创建配置,使用默认值
|
||||
g.Log().Infof(ctx, "首次创建RAGFlow配置,使用默认提示词")
|
||||
|
||||
config := &entity.RAGFlowConfig{
|
||||
AccountName: accountName,
|
||||
Platform: platform,
|
||||
DatasetId: datasetId,
|
||||
DatasetIds: []string{datasetId},
|
||||
DatasetName: "租户话术知识库",
|
||||
ChatId: chat.Id,
|
||||
Prompt: promptText,
|
||||
DocumentIds: []string{},
|
||||
SimilarityThreshold: 0.2,
|
||||
KeywordsSimilarityWeight: 0.7,
|
||||
TopN: 8,
|
||||
EmptyResponse: "",
|
||||
SyncStatus: "synced",
|
||||
}
|
||||
// 统一使用string类型存储tenantId到MongoDB
|
||||
config.TenantId = tenantId
|
||||
config.CreatedAt = &now // 取地址赋值给指针类型
|
||||
config.UpdatedAt = &now // 取地址赋值给指针类型
|
||||
config.IsDeleted = false
|
||||
|
||||
update := bson.M{"$set": config}
|
||||
opts := options.UpdateOne().SetUpsert(true)
|
||||
if _, err := coll.UpdateOne(ctx, filter, update, opts); err != nil {
|
||||
return gerror.Wrap(err, "更新RAGFlowConfig失败")
|
||||
}
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "RAGFlowConfig创建/更新成功: account_name=%s, chat_id=%s, dataset_id=%s",
|
||||
accountName, chat.Id, datasetId)
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateDefaultPrompt 生成默认提示词(包含知识库引用)
|
||||
func (s *customerServiceAccount) generateDefaultPrompt() string {
|
||||
// 默认提示词已包含完整的知识库引用格式
|
||||
return `你是一个智能助手,请总结知识库的内容来回答问题,请列举知识库中的数据详细回答。当所有知识库内容都与问题无关时,你的回答必须包括"知识库中未找到您要的答案!"这句话。回答需要考虑聊天历史。
|
||||
|
||||
以下是知识库:
|
||||
{knowledge}
|
||||
以上是知识库。`
|
||||
}
|
||||
|
||||
// generateAssistantDescription 根据客服账号生成助理描述
|
||||
func (s *customerServiceAccount) generateAssistantDescription(accountName, platform string) string {
|
||||
// 根据账号名称判断业务方向
|
||||
lowerName := gstr.ToLower(accountName)
|
||||
|
||||
// 气血方向
|
||||
if gstr.Contains(lowerName, "qixue") || gstr.Contains(lowerName, "气血") {
|
||||
return "专业的女性健康顾问,专注于月经调理、气血养护等健康问题。以温暖关怀的态度为客户提供专业建议,帮助改善身体状况。"
|
||||
}
|
||||
|
||||
// 减肥方向
|
||||
if gstr.Contains(lowerName, "jianfei") || gstr.Contains(lowerName, "减肥") || gstr.Contains(lowerName, "shoushen") || gstr.Contains(lowerName, "瘦身") {
|
||||
return "专业的减肥瘦身顾问,提供科学健康的减肥方案和营养建议。以积极正面的态度鼓励客户坚持健康的生活方式,达成理想体型。"
|
||||
}
|
||||
|
||||
// 护肤方向
|
||||
if gstr.Contains(lowerName, "hufu") || gstr.Contains(lowerName, "护肤") || gstr.Contains(lowerName, "meirong") || gstr.Contains(lowerName, "美容") {
|
||||
return "专业的护肤美容顾问,为客户提供个性化的护肤方案和产品建议。帮助客户解决各类肌肤问题,重现健康光彩。"
|
||||
}
|
||||
|
||||
// 养生方向
|
||||
if gstr.Contains(lowerName, "yangsheng") || gstr.Contains(lowerName, "养生") || gstr.Contains(lowerName, "health") {
|
||||
return "专业的养生健康顾问,提供中医养生、日常保健等全方位健康建议。倡导健康生活方式,帮助客户提升整体身心健康。"
|
||||
}
|
||||
|
||||
// 默认通用描述
|
||||
return fmt.Sprintf("智能客服助理,为您提供专业的咨询服务。基于%s平台,随时解答您的疑问,提供贴心的服务体验。", platform)
|
||||
}
|
||||
|
||||
// getDefaultModelName 获取默认模型名称(Consul优先,config.yml兜底)
|
||||
func (s *customerServiceAccount) getDefaultModelName(ctx context.Context) string {
|
||||
// 使用统一配置读取方法(自动支持Consul动态配置)
|
||||
model := GetConfigString(ctx, "ragflow.default_model")
|
||||
if model != "" {
|
||||
return model
|
||||
}
|
||||
|
||||
// 硬编码兜底(理论上不会走到这里)
|
||||
return "qwen3-235b-a22b-instruct-2507"
|
||||
}
|
||||
|
||||
// Update 更新客服账号
|
||||
func (s *customerServiceAccount) Update(ctx context.Context, req *dto.UpdateCustomerServiceAccountReq) (err error) {
|
||||
// 调试日志:输出请求数据
|
||||
g.Log().Infof(ctx, "[Update] 请求数据 - Id: %s, AccountName: %s, Platform: %s, Prompt: %v, Greeting: %v",
|
||||
req.Id, req.AccountName, req.Platform, req.Prompt, req.Greeting)
|
||||
|
||||
// 1. 检查账号名称是否重复
|
||||
if req.AccountName != "" {
|
||||
existingAccount, err := dao.CustomerServiceAccount.FindByAccountName(ctx, req.AccountName)
|
||||
if err != nil && err != mongo.ErrNoDocuments {
|
||||
return err
|
||||
}
|
||||
if existingAccount != nil && existingAccount.Id.Hex() != req.Id {
|
||||
return gerror.Newf("客服账号名称 '%s' 已被其他账号使用,请使用其他名称", req.AccountName)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 更新基本信息(accountName, platform)
|
||||
if err = dao.CustomerServiceAccount.Update(ctx, req); err != nil {
|
||||
g.Log().Errorf(ctx, "[Update] 更新基本信息失败: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// 3. 查询客服账号获取accountName(用于后续更新)
|
||||
objectId, err := bson.ObjectIDFromHex(req.Id)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "无效的账号ID")
|
||||
}
|
||||
var account entity.CustomerServiceAccount
|
||||
filter := bson.M{"_id": objectId, "isDeleted": false}
|
||||
if err = dao.MongoDAO.FindOne(ctx, filter, &account, entity.CustomerServiceAccountCollection); err != nil {
|
||||
return gerror.Wrap(err, "查询客服账号失败")
|
||||
}
|
||||
|
||||
// 4. 如果提供了开场白,更新greeting字段到customer_service_account表
|
||||
if req.Greeting != nil {
|
||||
g.Log().Infof(ctx, "[Update] 更新开场白 - accountName: %s, greeting: %s", account.AccountName, *req.Greeting)
|
||||
updateReq := &dto.UpdateGreetingReq{
|
||||
AccountName: account.AccountName,
|
||||
Greeting: *req.Greeting,
|
||||
}
|
||||
if err = s.UpdateGreeting(ctx, updateReq); err != nil {
|
||||
g.Log().Errorf(ctx, "[Update] 更新开场白失败: %v", err)
|
||||
return gerror.Wrap(err, "更新开场白失败")
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 如果提供了提示词,更新RAGFlow配置
|
||||
if req.Prompt != nil {
|
||||
g.Log().Infof(ctx, "[Update] 更新提示词 - accountName: %s", account.AccountName)
|
||||
updatePromptReq := &dto.UpdatePromptReq{
|
||||
AccountName: account.AccountName,
|
||||
Prompt: *req.Prompt,
|
||||
}
|
||||
if _, err = RAGFlowConfig.UpdatePrompt(ctx, updatePromptReq); err != nil {
|
||||
g.Log().Errorf(ctx, "[Update] 更新提示词失败: %v", err)
|
||||
return gerror.Wrap(err, "更新提示词失败")
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 如果accountName、platform或selfIdentity有变化,同步更新RAGFlow Chat的name和description
|
||||
if req.AccountName != "" || req.Platform != "" || req.SelfIdentity != nil {
|
||||
if err = s.updateRAGFlowChatInfo(ctx, &account, req); err != nil {
|
||||
g.Log().Errorf(ctx, "[Update] 同步更新RAGFlow Chat失败: %v", err)
|
||||
return gerror.Wrap(err, "同步更新RAGFlow Chat失败")
|
||||
}
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "[Update] 客服账号更新成功 - accountName: %s", account.AccountName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateGreeting 更新开场白
|
||||
func (s *customerServiceAccount) UpdateGreeting(ctx context.Context, req *dto.UpdateGreetingReq) (err error) {
|
||||
// 获取租户ID(租户隔离)
|
||||
user, err := util.GetTenantInfo(ctx)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "获取租户信息失败")
|
||||
}
|
||||
|
||||
filter := bson.M{
|
||||
"accountName": req.AccountName,
|
||||
"tenantId": user.TenantId,
|
||||
"isDeleted": false,
|
||||
}
|
||||
update := bson.M{
|
||||
"$set": bson.M{
|
||||
"greeting": req.Greeting,
|
||||
"updatedAt": gtime.Now().Time,
|
||||
"updater": user.UserName,
|
||||
},
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "[UpdateGreeting] 开始更新开场白 - accountName: %s, tenantId: %v, greeting: %s",
|
||||
req.AccountName, user.TenantId, req.Greeting)
|
||||
g.Log().Infof(ctx, "[UpdateGreeting] 查询条件 - filter: %+v", filter)
|
||||
g.Log().Infof(ctx, "[UpdateGreeting] 更新内容 - update: %+v", update)
|
||||
|
||||
// 使用MongoDAO.UpdateOne(不需要token验证,避免双重验证冲突)
|
||||
matchedCount, modifiedCount, err := dao.MongoDAO.UpdateOne(ctx, filter, update, entity.CustomerServiceAccountCollection)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "[UpdateGreeting] 数据库更新失败 - err: %v", err)
|
||||
return gerror.Wrapf(err, "更新开场白失败")
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "[UpdateGreeting] 数据库更新结果 - matchedCount: %d, modifiedCount: %d", matchedCount, modifiedCount)
|
||||
|
||||
if modifiedCount == 0 {
|
||||
if matchedCount == 0 {
|
||||
g.Log().Errorf(ctx, "[UpdateGreeting] 未找到匹配记录 - accountName: %s, tenantId: %v", req.AccountName, user.TenantId)
|
||||
return gerror.Newf("客服账号不存在或不属于当前租户: accountName=%s", req.AccountName)
|
||||
}
|
||||
g.Log().Warningf(ctx, "[UpdateGreeting] 记录已存在但未修改(内容相同) - accountName: %s", req.AccountName)
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "[UpdateGreeting] ✅ 开场白更新成功 - accountName: %s, 更新记录数: %d", req.AccountName, modifiedCount)
|
||||
return
|
||||
}
|
||||
|
||||
// ToggleStatus 切换客服账号状态(启用/禁用)
|
||||
// 参数: ctx - 上下文,req - 状态切换请求(包含账号ID)
|
||||
// 返回: err - 错误信息
|
||||
// 功能: 在启用和禁用状态之间切换,禁用后不再接收用户消息
|
||||
func (s *customerServiceAccount) ToggleStatus(ctx context.Context, req *dto.ToggleCustomerServiceAccountStatusReq) (err error) {
|
||||
return dao.CustomerServiceAccount.ToggleStatus(ctx, req)
|
||||
}
|
||||
|
||||
// List 获取客服账号列表(关联查询prompt字段)
|
||||
// 参数: ctx - 上下文,req - 列表查询请求(支持分页、平台筛选、状态筛选)
|
||||
// 返回: res - 客服账号列表及分页信息(包含prompt字段),err - 错误信息
|
||||
// 功能: 分页查询客服账号,并从ragflow_config表关联查询每个账号的prompt(去除知识库引用部分)
|
||||
func (s *customerServiceAccount) List(ctx context.Context, req *dto.ListCustomerServiceAccountReq) (res *dto.ListCustomerServiceAccountRes, err error) {
|
||||
list, total, err := dao.CustomerServiceAccount.List(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 关联查询每个账号的prompt(从ragflow_config表)
|
||||
for i := range list {
|
||||
account := list[i]
|
||||
if config, configErr := dao.RAGFlowConfig.FindByAccountName(ctx, account.AccountName); configErr == nil && config != nil {
|
||||
// 去除知识库引用部分,只返回用户输入的业务提示词
|
||||
userPrompt := config.Prompt
|
||||
|
||||
// 查找知识库引用的起始位置(支持多种格式)
|
||||
knowledgePatterns := []string{
|
||||
"\n\n以下是知识库:",
|
||||
"\n\n以下是知识库",
|
||||
"\n以下是知识库:",
|
||||
"\n以下是知识库",
|
||||
}
|
||||
|
||||
// 找到最早出现的知识库引用位置并截断
|
||||
for _, pattern := range knowledgePatterns {
|
||||
if idx := gstr.Pos(userPrompt, pattern); idx >= 0 {
|
||||
userPrompt = gstr.SubStr(userPrompt, 0, idx)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 去除末尾空白
|
||||
userPrompt = gstr.TrimRight(userPrompt)
|
||||
|
||||
// 将处理后的prompt赋值给account
|
||||
list[i].Prompt = userPrompt
|
||||
g.Log().Debugf(ctx, "账号 %s 的prompt已关联(原始长度: %d, 处理后长度: %d)",
|
||||
account.AccountName, len(config.Prompt), len(userPrompt))
|
||||
}
|
||||
}
|
||||
|
||||
res = &dto.ListCustomerServiceAccountRes{
|
||||
List: list,
|
||||
Total: int(total),
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Delete 删除客服账号(软删除 + 删除RAGFlow Chat)
|
||||
// 参数: ctx - 上下文,req - 删除客服账号请求(包含账号ID)
|
||||
// 返回: res - 删除结果信息,err - 错误信息
|
||||
// 功能: 逻辑删除客服账号,同时删除RAGFlow中的Chat配置和ragflow_config记录
|
||||
func (s *customerServiceAccount) Delete(ctx context.Context, req *dto.DeleteCustomerServiceAccountReq) (res *dto.DeleteCustomerServiceAccountRes, err error) {
|
||||
res = &dto.DeleteCustomerServiceAccountRes{}
|
||||
|
||||
// 1. 查询客服账号信息(使用MongoDAO直接查询,避免租户过滤)
|
||||
objectId, err := bson.ObjectIDFromHex(req.Id)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "无效的账号ID")
|
||||
}
|
||||
|
||||
var account entity.CustomerServiceAccount
|
||||
filter := bson.M{"_id": objectId, "isDeleted": false}
|
||||
err = dao.MongoDAO.FindOne(ctx, filter, &account, entity.CustomerServiceAccountCollection)
|
||||
if err != nil {
|
||||
if err.Error() == "mongo: no documents in result" {
|
||||
return nil, gerror.New("客服账号不存在")
|
||||
}
|
||||
return nil, gerror.Wrap(err, "查询客服账号失败")
|
||||
}
|
||||
|
||||
// 2. 查询关联的RAGFlowConfig,获取chatId
|
||||
config, err := dao.RAGFlowConfig.FindByAccountName(ctx, account.AccountName)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "查询RAGFlowConfig失败: %v", err)
|
||||
}
|
||||
|
||||
// 3. 如果存在chatId,调用RAGFlow API删除Chat
|
||||
if config != nil && config.ChatId != "" {
|
||||
ragflowClient := ragflow.GetGlobalClient()
|
||||
if err := ragflowClient.DeleteChats(ctx, []string{config.ChatId}); err != nil {
|
||||
g.Log().Errorf(ctx, "删除RAGFlow Chat失败: chat_id=%s, error=%v", config.ChatId, err)
|
||||
// 不阻断删除流程,记录错误日志
|
||||
} else {
|
||||
g.Log().Infof(ctx, "RAGFlow Chat删除成功: chat_id=%s", config.ChatId)
|
||||
}
|
||||
|
||||
// 4. 软删除RAGFlowConfig记录
|
||||
filter := bson.M{"_id": config.Id, "isDeleted": false}
|
||||
update := bson.M{"$set": bson.M{"isDeleted": true}}
|
||||
if _, err := commonMongo.DB().Update(ctx, filter, update, entity.RAGFlowConfigCollection); err != nil {
|
||||
g.Log().Warningf(ctx, "软删除RAGFlowConfig失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 软删除客服账号
|
||||
if err := dao.CustomerServiceAccount.Delete(ctx, account.Id.Hex()); err != nil {
|
||||
return nil, gerror.Wrap(err, "删除客服账号失败")
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "客服账号删除成功: account_name=%s, chat_id=%s",
|
||||
account.AccountName, config.ChatId)
|
||||
|
||||
res.Success = true
|
||||
res.Message = "删除成功"
|
||||
return
|
||||
}
|
||||
|
||||
// getAccessibleSpeechcraftsInternal 内部方法:获取客服账号可访问的话术(动态权限)
|
||||
// 参数: ctx - 上下文,accountName - 客服账号名
|
||||
// 返回: speechcrafts - 话术列表,err - 错误信息
|
||||
// 功能: 根据客服账号的speechcraft_ids字段判断权限范围
|
||||
//
|
||||
// 空数组 = 返回该租户下所有话术(动态权限)
|
||||
// 非空 = 只返回指定ID的话术(指定权限)
|
||||
func (s *customerServiceAccount) getAccessibleSpeechcraftsInternal(ctx context.Context, accountName string) (speechcrafts []*entity.Speechcraft, err error) {
|
||||
// 1. 查询客服账号
|
||||
account, err := dao.CustomerServiceAccount.FindByAccountName(ctx, accountName)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrapf(err, "查询客服账号失败")
|
||||
}
|
||||
if account == nil {
|
||||
return nil, gerror.New("客服账号不存在")
|
||||
}
|
||||
|
||||
// 2. 根据speechcraft_ids判断权限范围
|
||||
if len(account.SpeechcraftIds) == 0 {
|
||||
// 空数组 = 动态权限,返回该租户下所有话术
|
||||
filter := bson.M{"isDeleted": false}
|
||||
page := &beans.Page{PageNum: 1, PageSize: 10000} // 查询所有话术
|
||||
orderBy := []beans.OrderBy{}
|
||||
_, err = commonMongo.DB().Find(ctx, filter, &speechcrafts, entity.SpeechcraftCollection, page, orderBy)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrapf(err, "查询所有话术失败")
|
||||
}
|
||||
g.Log().Infof(ctx, "客服账号使用动态权限:account_name=%s, speechcraft_count=%d", accountName, len(speechcrafts))
|
||||
} else {
|
||||
// 非空 = 指定权限,只返回指定ID的话术
|
||||
var objectIds []bson.ObjectID
|
||||
for _, idStr := range account.SpeechcraftIds {
|
||||
if oid, err := bson.ObjectIDFromHex(idStr); err == nil {
|
||||
objectIds = append(objectIds, oid)
|
||||
}
|
||||
}
|
||||
|
||||
if len(objectIds) > 0 {
|
||||
filter := bson.M{
|
||||
"_id": bson.M{"$in": objectIds},
|
||||
"isDeleted": false,
|
||||
}
|
||||
page := &beans.Page{PageNum: 1, PageSize: 10000} // 查询指定话术
|
||||
orderBy := []beans.OrderBy{}
|
||||
_, err = commonMongo.DB().Find(ctx, filter, &speechcrafts, entity.SpeechcraftCollection, page, orderBy)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrapf(err, "查询指定话术失败")
|
||||
}
|
||||
}
|
||||
g.Log().Infof(ctx, "客服账号使用指定权限:account_name=%s, allowed_count=%d, found_count=%d",
|
||||
accountName, len(account.SpeechcraftIds), len(speechcrafts))
|
||||
}
|
||||
|
||||
return speechcrafts, nil
|
||||
}
|
||||
|
||||
// GetAccessibleSpeechcrafts 获取客服账号可访问的话术列表(API接口)
|
||||
// 参数: ctx - 上下文,req - 查询请求(包含账号名)
|
||||
// 返回: res - 话术列表及权限类型(dynamic/specified),err - 错误信息
|
||||
// 功能: 查询客服账号可访问的话术,返回格式化的DTO列表和权限标识
|
||||
func (s *customerServiceAccount) GetAccessibleSpeechcrafts(ctx context.Context, req *dto.GetAccessibleSpeechcraftsReq) (res *dto.GetAccessibleSpeechcraftsRes, err error) {
|
||||
res = &dto.GetAccessibleSpeechcraftsRes{}
|
||||
|
||||
// 1. 查询客服账号
|
||||
account, err := dao.CustomerServiceAccount.FindByAccountName(ctx, req.AccountName)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrapf(err, "查询客服账号失败")
|
||||
}
|
||||
if account == nil {
|
||||
return nil, gerror.New("客服账号不存在")
|
||||
}
|
||||
|
||||
// 2. 获取话术列表
|
||||
speechcrafts, err := s.getAccessibleSpeechcraftsInternal(ctx, req.AccountName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 3. 转换为DTO格式
|
||||
for _, sc := range speechcrafts {
|
||||
item := dto.SpeechcraftItem{
|
||||
Id: sc.Id.Hex(),
|
||||
Tag: sc.Tag,
|
||||
Content: sc.Content,
|
||||
Direction: sc.Direction,
|
||||
Platform: sc.Platform,
|
||||
Keywords: sc.Keywords,
|
||||
}
|
||||
res.Speechcrafts = append(res.Speechcrafts, item)
|
||||
}
|
||||
|
||||
res.TotalCount = len(res.Speechcrafts)
|
||||
|
||||
// 4. 标记权限类型
|
||||
if len(account.SpeechcraftIds) == 0 {
|
||||
res.Permission = "dynamic" // 动态权限:所有话术
|
||||
} else {
|
||||
res.Permission = "specified" // 指定权限:限定话术
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// RecreateRAGFlowConfig 重新创建RAGFlow配置
|
||||
// 参数: ctx - 上下文,req - 重建请求(包含账号名和平台)
|
||||
// 返回: err - 错误信息
|
||||
// 功能: 删除旧配置并重新创建Dataset和Chat,用于修复创建客服账号时RAGFlow配置创建失败的情况,或chat被删除后自动重建
|
||||
func (s *customerServiceAccount) RecreateRAGFlowConfig(ctx context.Context, req *dto.RecreateRAGFlowConfigReq) (err error) {
|
||||
// 1. 查询客服账号
|
||||
account, err := dao.CustomerServiceAccount.FindByAccountName(ctx, req.AccountName)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "查询客服账号失败")
|
||||
}
|
||||
if account == nil {
|
||||
return gerror.Newf("客服账号 %s 不存在", req.AccountName)
|
||||
}
|
||||
|
||||
// 2. 获取租户ID
|
||||
tenantId := gconv.String(account.TenantId)
|
||||
if tenantId == "" {
|
||||
return gerror.New("客服账号的租户ID为空")
|
||||
}
|
||||
accountName := req.AccountName
|
||||
|
||||
// 3. 查询现有RAGFlow配置(带租户隔离)
|
||||
coll := commonMongo.GetDB().Collection(entity.RAGFlowConfigCollection)
|
||||
filter := bson.M{"accountName": accountName, "tenantId": account.TenantId, "isDeleted": false}
|
||||
var existingConfig entity.RAGFlowConfig
|
||||
err = coll.FindOne(ctx, filter).Decode(&existingConfig)
|
||||
|
||||
// 4. 如果已有配置且chat_id不为空,验证chat是否真实存在于RAGFlow
|
||||
needRecreate := false
|
||||
if err == nil && !existingConfig.Id.IsZero() && existingConfig.ChatId != "" {
|
||||
// 调用RAGFlow API验证chat是否存在(通过UpdateChat验证)
|
||||
ragflowClient := ragflow.GetGlobalClient()
|
||||
if ragflowClient != nil {
|
||||
// 尝试更新chat(即使不改任何参数,如果chat不存在会返回错误)
|
||||
updateReq := &ragflow.UpdateChatReq{
|
||||
DatasetIds: existingConfig.DatasetIds,
|
||||
}
|
||||
updateErr := ragflowClient.UpdateChat(ctx, existingConfig.ChatId, updateReq)
|
||||
if updateErr != nil {
|
||||
// chat不存在或更新失败,需要重建
|
||||
g.Log().Warningf(ctx, "RAGFlow中的Chat已被删除或不可用,需要重建 - accountName: %s, old_chat_id: %s, error: %v",
|
||||
accountName, existingConfig.ChatId, updateErr)
|
||||
needRecreate = true
|
||||
} else {
|
||||
// chat存在且有效,无需重建
|
||||
g.Log().Infof(ctx, "发现现有有效的RAGFlow配置,无需重建 - accountName: %s, chat_id: %s", accountName, existingConfig.ChatId)
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
// ragflowClient为空,无法验证,默认需要重建
|
||||
needRecreate = true
|
||||
}
|
||||
} else {
|
||||
// 配置不存在或chat_id为空,需要创建
|
||||
needRecreate = true
|
||||
}
|
||||
|
||||
// 5. 需要重建chat
|
||||
if !needRecreate {
|
||||
return nil
|
||||
}
|
||||
g.Log().Infof(ctx, "开始创建新Chat - accountName: %s", accountName)
|
||||
|
||||
// 6. 确保租户知识库存在(调用公共方法)
|
||||
datasetId, err := EnsureTenantDataset(ctx, tenantId)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "确保租户知识库存在失败: %v", err)
|
||||
return gerror.Wrap(err, "确保租户知识库存在失败")
|
||||
}
|
||||
|
||||
// 7. 创建Chat并保存RAGFlow配置(重建时使用账号的selfIdentity)
|
||||
if err = s.createChatAndSaveConfig(ctx, req.AccountName, req.Platform, tenantId, datasetId, account.SelfIdentity); err != nil {
|
||||
g.Log().Errorf(ctx, "创建Chat配置失败: %v", err)
|
||||
return gerror.Wrap(err, "创建Chat配置失败")
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "成功为客服账号 %s 创建RAGFlow配置", req.AccountName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateRAGFlowChatInfo 同步更新RAGFlow Chat的name和description
|
||||
func (s *customerServiceAccount) updateRAGFlowChatInfo(ctx context.Context, account *entity.CustomerServiceAccount, req *dto.UpdateCustomerServiceAccountReq) (err error) {
|
||||
// 1. 查询RAGFlow配置获取chatId
|
||||
config, err := dao.RAGFlowConfig.FindByAccountName(ctx, account.AccountName)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "查询RAGFlow配置失败,跳过同步: %v", err)
|
||||
return nil
|
||||
}
|
||||
if config == nil || config.ChatId == "" {
|
||||
g.Log().Warningf(ctx, "RAGFlow配置不存在或chatId为空,跳过同步")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 2. 构建新的chat名称和描述
|
||||
tenantId := gconv.String(account.TenantId)
|
||||
|
||||
// 使用更新后的accountName和platform,如果没有提供则使用原值
|
||||
accountName := account.AccountName
|
||||
platform := account.Platform
|
||||
if req.AccountName != "" {
|
||||
accountName = req.AccountName
|
||||
}
|
||||
if req.Platform != "" {
|
||||
platform = req.Platform
|
||||
}
|
||||
|
||||
newChatName := fmt.Sprintf("%s_%s_%s", accountName, platform, tenantId)
|
||||
|
||||
// 使用更新后的selfIdentity,如果没有提供则使用原值
|
||||
var newDescription string
|
||||
if req.SelfIdentity != nil {
|
||||
newDescription = *req.SelfIdentity
|
||||
} else {
|
||||
newDescription = account.SelfIdentity
|
||||
}
|
||||
|
||||
// 如果selfIdentity为空,使用默认生成方式
|
||||
if newDescription == "" {
|
||||
newDescription = s.generateAssistantDescription(accountName, platform)
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "[updateRAGFlowChatInfo] 同步更新RAGFlow Chat - chatId: %s, newName: %s, newDescription: %s",
|
||||
config.ChatId, newChatName, newDescription)
|
||||
|
||||
// 3. 调用RAGFlow API更新Chat
|
||||
ragflowClient := ragflow.GetGlobalClient()
|
||||
if ragflowClient == nil {
|
||||
return gerror.New("RAGFlow客户端未初始化")
|
||||
}
|
||||
|
||||
updateReq := &ragflow.UpdateChatReq{
|
||||
Name: newChatName,
|
||||
Description: newDescription,
|
||||
}
|
||||
|
||||
if err = ragflowClient.UpdateChat(ctx, config.ChatId, updateReq); err != nil {
|
||||
g.Log().Errorf(ctx, "[updateRAGFlowChatInfo] 更新RAGFlow Chat失败: %v", err)
|
||||
return gerror.Wrap(err, "更新RAGFlow Chat失败")
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "[updateRAGFlowChatInfo] ✅ 成功同步更新RAGFlow Chat")
|
||||
return nil
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
// Package service - 对话数据服务
|
||||
// 功能:对话记录查询、导出Excel
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/dao"
|
||||
"customer-server/model/dto"
|
||||
"customer-server/model/entity"
|
||||
|
||||
"gitea.com/red-future/common/utils"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
var Data = new(data)
|
||||
|
||||
type data struct{}
|
||||
|
||||
// Add 添加数据
|
||||
// 参数: ctx - 上下文,req - 添加数据请求
|
||||
// 返回: res - 添加成功后的数据ID,err - 错误信息
|
||||
// 功能: 创建新的数据记录
|
||||
func (s *data) Add(ctx context.Context, req *dto.AddDataReq) (res *dto.AddDataRes, err error) {
|
||||
data := &entity.Data{}
|
||||
if err = utils.Struct(req, data); err != nil {
|
||||
return
|
||||
}
|
||||
// 设置基础字段
|
||||
now := gtime.Now().Time
|
||||
data.CreatedAt = &now // 取地址赋值给指针类型
|
||||
data.UpdatedAt = &now // 取地址赋值给指针类型
|
||||
data.IsDeleted = false
|
||||
// 注意:Creator、Updater、TenantId 保持零值,不设置
|
||||
|
||||
if err = dao.Data.Insert(ctx, data); err != nil {
|
||||
return
|
||||
}
|
||||
res = &dto.AddDataRes{Id: data.Id.Hex()}
|
||||
return
|
||||
}
|
||||
|
||||
// Update 更新数据
|
||||
// 参数: ctx - 上下文,req - 更新数据请求
|
||||
// 返回: err - 错误信息
|
||||
// 功能: 更新数据记录内容
|
||||
func (s *data) Update(ctx context.Context, req *dto.UpdateDataReq) (err error) {
|
||||
return dao.Data.Update(ctx, req)
|
||||
}
|
||||
|
||||
// // Delete 删除数据
|
||||
// func (s *data) Delete(ctx context.Context, req *dto.DeleteDataReq) (err error) {
|
||||
// return dao.Data.Delete(ctx, req)
|
||||
// }
|
||||
|
||||
// List 获取数据列表
|
||||
// 参数: ctx - 上下文,req - 列表查询请求
|
||||
// 返回: res - 数据列表及分页信息,err - 错误信息
|
||||
// 功能: 分页查询数据记录
|
||||
func (s *data) List(ctx context.Context, req *dto.ListDataReq) (res *dto.ListDataRes, err error) {
|
||||
list, total, err := dao.Data.List(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = &dto.ListDataRes{
|
||||
List: list,
|
||||
Total: int(total),
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
// Package service - 数据统计服务
|
||||
// 功能:对话数据统计分析、报表生成
|
||||
package service
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"customer-server/dao"
|
||||
"customer-server/model/dto"
|
||||
"customer-server/model/entity"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gitea.com/red-future/common/utils"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
var DataStatistics = new(dataStatistics)
|
||||
|
||||
type dataStatistics struct{}
|
||||
|
||||
// Add 添加数据统计
|
||||
// 参数: ctx - 上下文,req - 添加数据统计请求
|
||||
// 返回: res - 添加成功后的统计ID,err - 错误信息
|
||||
// 功能: 创建新的数据统计记录
|
||||
func (s *dataStatistics) Add(ctx context.Context, req *dto.AddDataStatisticsReq) (res *dto.AddDataStatisticsRes, err error) {
|
||||
data := &entity.DataStatistics{}
|
||||
if err = utils.Struct(req, data); err != nil {
|
||||
return
|
||||
}
|
||||
// 使用 gtime 转换日期
|
||||
if dateTime := gtime.NewFromStr(req.Date); dateTime != nil {
|
||||
date := dateTime.Time
|
||||
data.Date = &date // 取地址赋值给指针类型
|
||||
} else {
|
||||
return nil, gerror.New("日期格式错误")
|
||||
}
|
||||
// 设置基础字段
|
||||
now := gtime.Now().Time
|
||||
data.CreatedAt = &now // 取地址赋值给指针类型
|
||||
data.UpdatedAt = &now // 取地址赋值给指针类型
|
||||
data.IsDeleted = false
|
||||
// 注意:Creator、Updater、TenantId 保持零值,不设置
|
||||
|
||||
if err = dao.DataStatistics.Insert(ctx, data); err != nil {
|
||||
return
|
||||
}
|
||||
res = &dto.AddDataStatisticsRes{Id: data.Id.Hex()}
|
||||
return
|
||||
}
|
||||
|
||||
// Update 更新数据统计
|
||||
// 参数: ctx - 上下文,req - 更新数据统计请求
|
||||
// 返回: err - 错误信息
|
||||
// 功能: 更新数据统计记录内容
|
||||
func (s *dataStatistics) Update(ctx context.Context, req *dto.UpdateDataStatisticsReq) (err error) {
|
||||
return dao.DataStatistics.Update(ctx, req)
|
||||
}
|
||||
|
||||
// List 获取数据统计列表
|
||||
// 参数: ctx - 上下文,req - 列表查询请求
|
||||
// 返回: res - 数据统计列表及分页信息,err - 错误信息
|
||||
// 功能: 分页查询数据统计记录
|
||||
func (s *dataStatistics) List(ctx context.Context, req *dto.ListDataStatisticsReq) (res *dto.ListDataStatisticsRes, err error) {
|
||||
list, total, err := dao.DataStatistics.List(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = &dto.ListDataStatisticsRes{
|
||||
List: list,
|
||||
Total: int(total),
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Export 导出数据统计为ZIP文件
|
||||
// 参数: ctx - 上下文,req - 导出请求
|
||||
// 返回: zipData - ZIP文件字节数组,filename - 文件名,err - 错误信息
|
||||
// 功能: 将数据统计导出为ZIP文件,包含Excel文件
|
||||
func (s *dataStatistics) Export(ctx context.Context, req *dto.ExportDataStatisticsReq) (zipData []byte, filename string, err error) {
|
||||
// 1. 查询所有符合条件的数据统计
|
||||
statistics, err := dao.DataStatistics.FindAllForExport(ctx, req)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
if len(statistics) == 0 {
|
||||
return nil, "", gerror.New("没有可导出的数据统计")
|
||||
}
|
||||
|
||||
// 2. 创建 ZIP 文件(内存中)
|
||||
var buf bytes.Buffer
|
||||
zipWriter := zip.NewWriter(&buf)
|
||||
defer zipWriter.Close()
|
||||
|
||||
// 3. 为每个数据统计生成 TXT 文件并添加到 ZIP
|
||||
for _, stat := range statistics {
|
||||
// 生成 TXT 内容
|
||||
txtContent := s.generateTxt(stat)
|
||||
|
||||
// 生成文件名(清理并替换特殊字符)
|
||||
dateStr := gtime.New(stat.Date).Format("Y-m-d")
|
||||
cleanName := strings.ToValidUTF8(stat.CustomerServiceName, "未命名")
|
||||
safeFilename := s.sanitizeFilename(cleanName)
|
||||
if safeFilename == "" {
|
||||
safeFilename = "statistics"
|
||||
}
|
||||
txtFilename := dateStr + "_" + safeFilename + "_" + stat.Id.Hex()[:8] + ".txt"
|
||||
|
||||
// 添加文件到 ZIP
|
||||
writer, err := zipWriter.Create(txtFilename)
|
||||
if err != nil {
|
||||
return nil, "", gerror.Newf("创建ZIP文件失败: %v", err)
|
||||
}
|
||||
|
||||
if _, err := writer.Write([]byte(txtContent)); err != nil {
|
||||
return nil, "", gerror.Newf("写入ZIP文件失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 生成下载文件名
|
||||
timestamp := gtime.Now().Format("Ymd_His")
|
||||
filename = "data_statistics_export_" + timestamp + ".zip"
|
||||
|
||||
return buf.Bytes(), filename, nil
|
||||
}
|
||||
|
||||
// generateTxt 生成数据统计的 TXT 内容
|
||||
func (s *dataStatistics) generateTxt(stat *entity.DataStatistics) string {
|
||||
var builder strings.Builder
|
||||
|
||||
builder.WriteString("日期: " + gtime.New(stat.Date).Format("Y-m-d") + "\n")
|
||||
builder.WriteString("客服ID: " + stat.AccountName + "\n")
|
||||
builder.WriteString("客服名称: " + stat.CustomerServiceName + "\n")
|
||||
builder.WriteString("客服平台: " + stat.CustomerServicePlatform + "\n")
|
||||
builder.WriteString("\n=== 统计数据 ===\n")
|
||||
builder.WriteString("进线数: " + gconv.String(stat.InboundCount) + "\n")
|
||||
builder.WriteString("开口数: " + gconv.String(stat.ActiveCount) + "\n")
|
||||
builder.WriteString("接待数: " + gconv.String(stat.ServedCount) + "\n")
|
||||
builder.WriteString("发名片数: " + gconv.String(stat.ContactCardSentCount) + "\n")
|
||||
builder.WriteString("发留资卡数: " + gconv.String(stat.NameCardSentCount) + "\n")
|
||||
builder.WriteString("留资数: " + gconv.String(stat.LeftContactInfoCount) + "\n")
|
||||
builder.WriteString("\n=== 响应率 ===\n")
|
||||
builder.WriteString("30s响应率: " + gconv.String(stat.ResponseRate30s) + "%\n")
|
||||
builder.WriteString("60s响应率: " + gconv.String(stat.ResponseRate60s) + "%\n")
|
||||
builder.WriteString("360s响应率: " + gconv.String(stat.ResponseRate360s) + "%\n")
|
||||
builder.WriteString("\n---\n")
|
||||
builder.WriteString("记录ID: " + stat.Id.Hex() + "\n")
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
// sanitizeFilename 清理文件名,移除或替换不安全的字符
|
||||
func (s *dataStatistics) sanitizeFilename(filename string) string {
|
||||
// 移除或替换特殊字符
|
||||
reg := regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
|
||||
safe := reg.ReplaceAllString(filename, "_")
|
||||
|
||||
// 限制文件名长度
|
||||
if len(safe) > 50 {
|
||||
safe = safe[:50]
|
||||
}
|
||||
|
||||
return strings.TrimSpace(safe)
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/dao"
|
||||
"customer-server/model/entity"
|
||||
"fmt"
|
||||
|
||||
"gitea.com/red-future/common/ragflow"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// EnsureTenantDataset 确保租户知识库存在
|
||||
// 逻辑:
|
||||
// 1. 用租户ID查找MongoDB中的知识库ID
|
||||
// 2. 测试知识库ID是否在RAGFlow中可用
|
||||
// 3. 如果不可用,就直接根据名字在RAGFlow中查找
|
||||
// 4. 找到了就把知识库ID存到MongoDB
|
||||
// 5. 如果名字也找不到,就创建新的
|
||||
// 6. 把新的ID和名字存到MongoDB
|
||||
func EnsureTenantDataset(ctx context.Context, tenantId string) (datasetId string, err error) {
|
||||
ragflowClient := ragflow.GetGlobalClient()
|
||||
datasetName := fmt.Sprintf("dataset_tenant_%s", tenantId)
|
||||
|
||||
// 1. 用租户ID查找MongoDB中的知识库ID
|
||||
datasetId, err = dao.RAGFlowConfig.FindDatasetIdByTenant(ctx, tenantId)
|
||||
if err != nil && err.Error() != "mongo: no documents in result" {
|
||||
return "", gerror.Wrap(err, "查询租户知识库ID失败")
|
||||
}
|
||||
|
||||
// 2. 测试知识库ID是否在RAGFlow中可用
|
||||
if datasetId != "" {
|
||||
g.Log().Infof(ctx, "租户%s已有知识库ID: %s,测试是否可用", tenantId, datasetId)
|
||||
|
||||
listReq := &ragflow.ListDatasetsReq{Page: 1, PageSize: 100}
|
||||
listRes, listErr := ragflowClient.ListDatasets(ctx, listReq)
|
||||
|
||||
if listErr == nil && listRes != nil {
|
||||
g.Log().Infof(ctx, "ListDatasets返回%d个知识库", len(listRes.Data))
|
||||
for i, ds := range listRes.Data {
|
||||
g.Log().Infof(ctx, "知识库[%d]: id=%s, name=%s", i, ds.Id, ds.Name)
|
||||
}
|
||||
|
||||
// 测试ID是否可用
|
||||
for _, ds := range listRes.Data {
|
||||
if ds.Id == datasetId {
|
||||
g.Log().Infof(ctx, "知识库ID可用: %s", datasetId)
|
||||
return datasetId, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 3. ID不可用,直接根据名字查找
|
||||
g.Log().Warningf(ctx, "知识库ID不可用,根据名字查找: %s", datasetName)
|
||||
for _, ds := range listRes.Data {
|
||||
if ds.Name == datasetName {
|
||||
datasetId = ds.Id
|
||||
g.Log().Infof(ctx, "找到同名知识库: name=%s, id=%s", datasetName, datasetId)
|
||||
|
||||
// 4. 把知识库ID存到MongoDB
|
||||
if updateErr := updateDatasetIdInMongo(ctx, tenantId, datasetId); updateErr != nil {
|
||||
g.Log().Errorf(ctx, "更新MongoDB失败: %v", updateErr)
|
||||
}
|
||||
|
||||
return datasetId, nil
|
||||
}
|
||||
}
|
||||
|
||||
g.Log().Warningf(ctx, "未找到同名知识库: %s", datasetName)
|
||||
} else {
|
||||
g.Log().Errorf(ctx, "ListDatasets失败: %v", listErr)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 名字也找不到,创建新的
|
||||
g.Log().Infof(ctx, "创建新知识库: name=%s", datasetName)
|
||||
|
||||
// 从config读取embedding模型,如果未配置则使用默认值
|
||||
embeddingModel := g.Cfg().MustGet(ctx, "ragflow.embedding_model", "text-embedding-v4").String()
|
||||
|
||||
createReq := &ragflow.CreateDatasetReq{
|
||||
Name: datasetName,
|
||||
EmbeddingModel: embeddingModel,
|
||||
ChunkMethod: "naive",
|
||||
}
|
||||
|
||||
dataset, err := ragflowClient.CreateDataset(ctx, createReq)
|
||||
|
||||
// 不依赖CreateDataset的返回值,因为RAGFlow可能返回code=0但data=null
|
||||
// 立即用ListDatasets查找新创建的知识库来确认
|
||||
g.Log().Infof(ctx, "创建请求已发送,立即查找确认: name=%s", datasetName)
|
||||
|
||||
listReq2 := &ragflow.ListDatasetsReq{Page: 1, PageSize: 100}
|
||||
listRes2, listErr2 := ragflowClient.ListDatasets(ctx, listReq2)
|
||||
|
||||
if listErr2 == nil && listRes2 != nil {
|
||||
for _, ds := range listRes2.Data {
|
||||
if ds.Name == datasetName {
|
||||
datasetId = ds.Id
|
||||
g.Log().Infof(ctx, "确认知识库已创建: id=%s, name=%s", datasetId, datasetName)
|
||||
goto saveRecord
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果ListDatasets也找不到,说明创建真的失败了
|
||||
if err != nil {
|
||||
return "", gerror.Wrapf(err, "创建知识库失败且无法确认")
|
||||
}
|
||||
if dataset == nil || dataset.Id == "" {
|
||||
return "", gerror.Newf("创建知识库失败,无法通过ListDatasets确认: name=%s", datasetName)
|
||||
}
|
||||
|
||||
// 使用CreateDataset返回的ID(兜底)
|
||||
datasetId = dataset.Id
|
||||
g.Log().Infof(ctx, "知识库创建成功: id=%s, name=%s", datasetId, datasetName)
|
||||
|
||||
saveRecord:
|
||||
|
||||
// 6. 把新的ID和名字存到MongoDB
|
||||
if err := saveSystemDatasetRecord(ctx, tenantId, datasetId, datasetName); err != nil {
|
||||
g.Log().Errorf(ctx, "保存到MongoDB失败: %v", err)
|
||||
return "", gerror.Wrap(err, "保存到MongoDB失败")
|
||||
}
|
||||
|
||||
return datasetId, nil
|
||||
}
|
||||
|
||||
// uploadPlaceholderDocument 上传占位文档
|
||||
func uploadPlaceholderDocument(ctx context.Context, datasetId, tenantId string) error {
|
||||
ragflowClient := ragflow.GetGlobalClient()
|
||||
placeholderContent := "欢迎使用智能客服系统。这是一个占位文档,实际产品和话术会在后续上传。"
|
||||
placeholderFilename := fmt.Sprintf("placeholder_tenant_%s.txt", tenantId)
|
||||
|
||||
documentId, err := ragflowClient.UploadDocumentFromText(ctx, datasetId, placeholderContent, placeholderFilename)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "上传占位文档失败")
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "占位文档上传成功: document_id=%s", documentId)
|
||||
|
||||
if parseErr := ragflowClient.ParseDocuments(ctx, datasetId, []string{documentId}); parseErr != nil {
|
||||
g.Log().Warningf(ctx, "解析占位文档失败: %v", parseErr)
|
||||
return parseErr
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "占位文档解析已启动(后台异步进行)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveSystemDatasetRecord 保存系统占位记录到MongoDB
|
||||
func saveSystemDatasetRecord(ctx context.Context, tenantId, datasetId, datasetName string) error {
|
||||
// 先检查是否已有记录
|
||||
existing, err := dao.RAGFlowConfig.FindDatasetIdByTenant(ctx, tenantId)
|
||||
if err == nil && existing != "" {
|
||||
g.Log().Infof(ctx, "租户%s已有知识库记录,无需创建系统占位记录", tenantId)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 创建系统占位记录
|
||||
systemAccountName := fmt.Sprintf("_system_tenant_%s", tenantId)
|
||||
config := &entity.RAGFlowConfig{
|
||||
AccountName: systemAccountName,
|
||||
Platform: "system",
|
||||
DatasetId: datasetId,
|
||||
DatasetIds: []string{datasetId},
|
||||
DatasetName: datasetName,
|
||||
ChatId: "", // 系统记录不需要Chat
|
||||
Prompt: "",
|
||||
DocumentIds: []string{},
|
||||
SimilarityThreshold: 0.2,
|
||||
KeywordsSimilarityWeight: 0.7,
|
||||
TopN: 8,
|
||||
EmptyResponse: "抱歉,我暂时无法回答这个问题。",
|
||||
SyncStatus: "synced",
|
||||
}
|
||||
// 统一使用string类型存储tenantId到MongoDB
|
||||
config.TenantId = tenantId
|
||||
config.IsDeleted = false
|
||||
|
||||
if err := dao.RAGFlowConfig.Insert(ctx, config); err != nil {
|
||||
return gerror.Wrap(err, "插入系统占位记录失败")
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "系统占位记录已保存: tenant_id=%s, dataset_id=%s", tenantId, datasetId)
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateDatasetIdInMongo 更新MongoDB中租户的所有datasetId记录
|
||||
func updateDatasetIdInMongo(ctx context.Context, tenantId, newDatasetId string) error {
|
||||
// 更新该租户所有ragflow_config记录的datasetId
|
||||
if err := dao.RAGFlowConfig.UpdateDatasetIdByTenant(ctx, tenantId, newDatasetId); err != nil {
|
||||
return gerror.Wrap(err, "更新租户datasetId失败")
|
||||
}
|
||||
g.Log().Infof(ctx, "已更新租户%s的所有记录的datasetId: %s", tenantId, newDatasetId)
|
||||
return nil
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.com/red-future/common/jaeger"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
)
|
||||
|
||||
// JaegerTestTemp 测试 Jaeger 错误记录(临时,测试后删除)
|
||||
func JaegerTestTemp(ctx context.Context, msg string) {
|
||||
ctx, span := jaeger.NewSpan(ctx, "test.jaeger.error")
|
||||
defer span.End()
|
||||
|
||||
err := gerror.New(msg)
|
||||
jaeger.RecordError(ctx, err, "测试Jaeger错误记录")
|
||||
}
|
||||
@@ -1,891 +0,0 @@
|
||||
// Package service - 产品服务
|
||||
// 功能:产品的增删改查、ZIP导入/导出、绑定/解绑客服账号、同步到RAGFlow、重试消费者
|
||||
package service
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"customer-server/dao"
|
||||
"customer-server/model/dto"
|
||||
"customer-server/model/entity"
|
||||
"customer-server/util"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"gitea.com/red-future/common/jaeger"
|
||||
"gitea.com/red-future/common/rabbitmq"
|
||||
"gitea.com/red-future/common/ragflow"
|
||||
"gitea.com/red-future/common/utils"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/glog"
|
||||
"github.com/gogf/gf/v2/os/grpool"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
var (
|
||||
Product = new(product)
|
||||
productGrpool = grpool.New(50) // 文档解析协程池,最大50并发
|
||||
)
|
||||
|
||||
type product struct{}
|
||||
|
||||
// Add 添加产品
|
||||
// 参数: ctx - 上下文,req - 添加产品请求(包含产品名称、描述、价格等)
|
||||
// 返回: res - 添加成功后的产品ID和RAGFlow文档ID,err - 错误信息
|
||||
// 功能: 创建产品记录并自动上传到RAGFlow产品知识库(独立于话术知识库)
|
||||
func (s *product) Add(ctx context.Context, req *dto.AddProductReq) (res *dto.AddProductRes, err error) {
|
||||
// 校验产品名称长度
|
||||
if utf8.RuneCountInString(req.Name) > 64 {
|
||||
return nil, gerror.New("产品名称必须在64字以内")
|
||||
}
|
||||
|
||||
// 校验产品详情长度
|
||||
if utf8.RuneCountInString(req.Description) > 8192 {
|
||||
return nil, gerror.New("产品详情必须在8192字以内")
|
||||
}
|
||||
|
||||
// 去重检查:同一租户下名称唯一
|
||||
existing, err := dao.Product.FindByName(ctx, req.Name)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "检查产品重复失败")
|
||||
}
|
||||
if existing != nil {
|
||||
return nil, gerror.Newf("产品名称已存在:name=%s, id=%s", req.Name, existing.Id.Hex())
|
||||
}
|
||||
|
||||
data := &entity.Product{}
|
||||
if err = utils.Struct(req, data); err != nil {
|
||||
return
|
||||
}
|
||||
// 先从token获取租户信息(在Insert之前)
|
||||
user, err := util.GetTenantInfo(ctx)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "获取租户信息失败")
|
||||
}
|
||||
tenantId := gconv.String(user.TenantId)
|
||||
if tenantId == "" {
|
||||
return nil, gerror.New("租户ID为空")
|
||||
}
|
||||
|
||||
// 确保产品知识库存在(检查话术知识库是否存在,作为租户是否初始化的标志)
|
||||
speechcraftDatasetId, err := dao.RAGFlowConfig.FindDatasetIdByTenant(ctx, tenantId)
|
||||
if err != nil || speechcraftDatasetId == "" {
|
||||
return nil, gerror.Newf("租户知识库不存在,请先创建客服账号: tenant_id=%s", tenantId)
|
||||
}
|
||||
|
||||
// 设置基础字段
|
||||
now := gtime.Now().Time
|
||||
data.CreatedAt = &now // 取地址赋值给指针类型
|
||||
data.UpdatedAt = &now // 取地址赋值给指针类型
|
||||
data.IsDeleted = false
|
||||
// 统一使用string类型存储tenantId到MongoDB
|
||||
data.TenantId = tenantId
|
||||
|
||||
// 插入产品到MongoDB
|
||||
_, err = dao.Product.Insert(ctx, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 确保租户知识库存在(产品和话术共享租户知识库)
|
||||
// 使用dataset_service提供的统一方法,自动处理创建、查找、保存等逻辑
|
||||
datasetId, err := EnsureTenantDataset(ctx, tenantId)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "确保租户知识库失败: %v", err)
|
||||
return nil, gerror.Wrap(err, "获取租户知识库失败")
|
||||
}
|
||||
g.Log().Infof(ctx, "租户%s的知识库ID: %s", tenantId, datasetId)
|
||||
|
||||
// 同步上传到RAGFlow
|
||||
ragflowClient := ragflow.GetGlobalClient()
|
||||
g.Log().Infof(ctx, "准备上传产品到RAGFlow: product_id=%s, dataset_id=%s, name=%s", data.Id.Hex(), datasetId, data.Name)
|
||||
filename := fmt.Sprintf("%s.txt", data.Name)
|
||||
documentId, err := ragflowClient.UploadDocumentFromText(ctx, datasetId, data.Description, filename)
|
||||
if err != nil {
|
||||
// 回滚:删除刚插入的产品
|
||||
dao.MongoDAO.Delete(ctx, bson.M{"_id": data.Id}, entity.ProductCollection)
|
||||
g.Log().Errorf(ctx, "产品上传RAGFlow失败: product_id=%s, dataset_id=%s, error=%v", data.Id.Hex(), datasetId, err)
|
||||
jaeger.RecordError(ctx, err, "产品上传RAGFlow失败")
|
||||
return nil, gerror.Wrap(err, "文档上传到知识库失败")
|
||||
}
|
||||
|
||||
// 异步触发解析(grpool自动管理goroutine生命周期,WithoutCancel保留追踪避免取消)
|
||||
productGrpool.Add(ctx, func(ctx context.Context) {
|
||||
parseCtx := context.WithoutCancel(ctx)
|
||||
if err := ragflowClient.ParseDocuments(parseCtx, datasetId, []string{documentId}); err != nil {
|
||||
g.Log().Errorf(parseCtx, "文档解析失败: document_id=%s, error=%v", documentId, err)
|
||||
} else {
|
||||
g.Log().Infof(parseCtx, "文档解析成功: document_id=%s", documentId)
|
||||
}
|
||||
})
|
||||
|
||||
// 更新MongoDB的RagSyncRecords数组(使用空accountName表示租户级文档)
|
||||
syncTime := gtime.Now().Format("Y-m-d H:i:s")
|
||||
record := entity.RagSyncRecord{
|
||||
AccountName: "", // 空表示租户级文档
|
||||
RagDocumentId: documentId,
|
||||
RagSyncStatus: "synced",
|
||||
SyncTime: syncTime,
|
||||
RetryCount: 0,
|
||||
}
|
||||
filter := bson.M{"_id": data.Id}
|
||||
update := bson.M{
|
||||
"$set": bson.M{
|
||||
"ragSyncRecords": []entity.RagSyncRecord{record},
|
||||
"ragLastSyncTime": syncTime,
|
||||
"updatedAt": gtime.Now().Time,
|
||||
},
|
||||
}
|
||||
if _, _, err = dao.MongoDAO.UpdateOne(ctx, filter, update, entity.ProductCollection); err != nil {
|
||||
g.Log().Errorf(ctx, "更新产品RagSyncRecords失败: %v", err)
|
||||
// 不回滚,文档已上传成功
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "产品添加成功并上传到知识库: product_id=%s, document_id=%s", data.Id.Hex(), documentId)
|
||||
res = &dto.AddProductRes{Id: data.Id.Hex()}
|
||||
return
|
||||
}
|
||||
|
||||
// Update 更新产品
|
||||
// 参数: ctx - 上下文,req - 更新产品请求(包含产品ID和待更新字段)
|
||||
// 返回: err - 错误信息
|
||||
// 功能: 更新产品信息并同步到RAGFlow,支持文档删除重建
|
||||
func (s *product) Update(ctx context.Context, req *dto.UpdateProductReq) (err error) {
|
||||
// 如果更新了产品名称,校验长度
|
||||
if req.Name != "" && utf8.RuneCountInString(req.Name) > 64 {
|
||||
return gerror.New("产品名称必须在64字以内")
|
||||
}
|
||||
|
||||
// 如果更新了产品详情,校验长度
|
||||
if req.Description != "" && utf8.RuneCountInString(req.Description) > 8192 {
|
||||
return gerror.New("产品详情必须在8192字以内")
|
||||
}
|
||||
|
||||
// 去重检查:如果修改名称,检查是否与其他产品重复
|
||||
if req.Name != "" {
|
||||
existing, err := dao.Product.FindByName(ctx, req.Name)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "检查产品重复失败")
|
||||
}
|
||||
if existing != nil && existing.Id.Hex() != req.Id {
|
||||
return gerror.Newf("产品名称已存在:name=%s, id=%s", req.Name, existing.Id.Hex())
|
||||
}
|
||||
}
|
||||
|
||||
return dao.Product.Update(ctx, req)
|
||||
}
|
||||
|
||||
// Delete 删除产品
|
||||
// 参数: ctx - 上下文,req - 删除产品请求(包含产品ID)
|
||||
// 返回: err - 错误信息
|
||||
// 功能: 逻辑删除产品记录并从RAGFlow移除对应文档
|
||||
func (s *product) Delete(ctx context.Context, req *dto.DeleteProductReq) (err error) {
|
||||
g.Log().Infof(ctx, "[Delete] 开始删除产品 - productId: %s", req.Id)
|
||||
|
||||
// 1. 查询产品,获取RAGFlow同步记录(使用原生查询,避免租户过滤)
|
||||
objectId, err := bson.ObjectIDFromHex(req.Id)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "无效的产品ID")
|
||||
}
|
||||
|
||||
var product entity.Product
|
||||
filter := bson.M{"_id": objectId, "isDeleted": false}
|
||||
err = dao.MongoDAO.FindOne(ctx, filter, &product, entity.ProductCollection)
|
||||
if err != nil {
|
||||
if err.Error() == "mongo: no documents in result" {
|
||||
return gerror.New("产品不存在")
|
||||
}
|
||||
return gerror.Wrap(err, "查询产品失败")
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "[Delete] 查询到产品 - name: %s, ragSyncRecords数量: %d", product.Name, len(product.RagSyncRecords))
|
||||
|
||||
// 2. 删除RAGFlow中的文档
|
||||
if len(product.RagSyncRecords) > 0 {
|
||||
ragflowClient := ragflow.GetGlobalClient()
|
||||
if ragflowClient != nil {
|
||||
tenantId := gconv.String(product.TenantId)
|
||||
|
||||
// 查询租户的dataset_id
|
||||
datasetId, err := dao.RAGFlowConfig.FindDatasetIdByTenant(ctx, tenantId)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "查询租户知识库ID失败: %v", err)
|
||||
} else if datasetId != "" {
|
||||
// 收集所有需要删除的document_id
|
||||
var documentIds []string
|
||||
for _, record := range product.RagSyncRecords {
|
||||
if record.RagDocumentId != "" {
|
||||
documentIds = append(documentIds, record.RagDocumentId)
|
||||
}
|
||||
}
|
||||
|
||||
// 批量删除RAGFlow文档
|
||||
if len(documentIds) > 0 {
|
||||
if err := ragflowClient.DeleteDocument(ctx, datasetId, documentIds); err != nil {
|
||||
g.Log().Errorf(ctx, "删除RAGFlow文档失败: %v, document_ids: %v", err, documentIds)
|
||||
// 不阻断删除流程,记录错误后继续
|
||||
} else {
|
||||
g.Log().Infof(ctx, "成功删除RAGFlow文档: count=%d", len(documentIds))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 软删除MongoDB记录
|
||||
return dao.Product.Delete(ctx, req)
|
||||
}
|
||||
|
||||
// List 获取产品列表
|
||||
// 参数: ctx - 上下文,req - 列表查询请求(支持分页、关键词搜索)
|
||||
// 返回: res - 产品列表及分页信息,err - 错误信息
|
||||
// 功能: 分页查询产品记录,支持按名称、描述模糊搜索
|
||||
func (s *product) List(ctx context.Context, req *dto.ListProductReq) (res *dto.ListProductRes, err error) {
|
||||
list, total, err := dao.Product.List(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = &dto.ListProductRes{
|
||||
List: list,
|
||||
Total: int(total),
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Export 导出产品为ZIP文件
|
||||
// 参数: ctx - 上下文,req - 导出请求(包含筛选条件)
|
||||
// 返回: zipData - ZIP文件字节数组,filename - 文件名,err - 错误信息
|
||||
// 功能: 将产品数据导出为ZIP文件,包含JSON格式的产品列表
|
||||
func (s *product) Export(ctx context.Context, req *dto.ExportProductReq) (zipData []byte, filename string, err error) {
|
||||
// 清理输入参数,防止非法 UTF-8 字符
|
||||
cleanName := strings.ToValidUTF8(req.Name, "")
|
||||
|
||||
// 1. 查询所有符合条件的产品
|
||||
products, err := dao.Product.FindAllForExport(ctx, cleanName)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
if len(products) == 0 {
|
||||
return nil, "", gerror.New("没有可导出的产品")
|
||||
}
|
||||
|
||||
// 清理所有产品数据,确保 UTF-8 有效(防止数据库中的脏数据)
|
||||
for i := range products {
|
||||
products[i].Name = strings.ToValidUTF8(products[i].Name, "")
|
||||
products[i].Description = strings.ToValidUTF8(products[i].Description, "")
|
||||
// RagDocumentId字段在RagSyncRecords中,不在Product主体
|
||||
}
|
||||
|
||||
// 2. 创建 ZIP 文件(内存中)
|
||||
var buf bytes.Buffer
|
||||
zipWriter := zip.NewWriter(&buf)
|
||||
defer zipWriter.Close()
|
||||
|
||||
// 3. 为每个产品生成 TXT 文件并添加到 ZIP
|
||||
for _, product := range products {
|
||||
// 生成 TXT 内容(产品详情)
|
||||
txtContent := s.generateTxt(product)
|
||||
|
||||
// 文件名就是产品名称(清理特殊字符)
|
||||
cleanName := strings.ToValidUTF8(product.Name, "未命名")
|
||||
safeFilename := s.sanitizeFilename(cleanName)
|
||||
if safeFilename == "" {
|
||||
safeFilename = "product"
|
||||
}
|
||||
txtFilename := safeFilename + ".txt"
|
||||
|
||||
// 添加文件到 ZIP
|
||||
writer, err := zipWriter.Create(txtFilename)
|
||||
if err != nil {
|
||||
return nil, "", gerror.Newf("创建ZIP文件失败: %v", err)
|
||||
}
|
||||
|
||||
if _, err := writer.Write([]byte(txtContent)); err != nil {
|
||||
return nil, "", gerror.Newf("写入ZIP文件失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 生成下载文件名
|
||||
timestamp := gtime.Now().Format("Ymd_His")
|
||||
filename = "products_export_" + timestamp + ".zip"
|
||||
|
||||
return buf.Bytes(), filename, nil
|
||||
}
|
||||
|
||||
// generateTxt 将产品转换为 TXT 格式
|
||||
// 新格式:文件名=产品名称,内容=产品详情
|
||||
func (s *product) generateTxt(product *entity.Product) string {
|
||||
// 清理产品详情,确保 UTF-8 有效
|
||||
cleanDescription := strings.ToValidUTF8(product.Description, "")
|
||||
|
||||
// 直接返回产品详情
|
||||
if cleanDescription != "" {
|
||||
return cleanDescription
|
||||
}
|
||||
|
||||
// 如果没有详情,返回空字符串
|
||||
return ""
|
||||
}
|
||||
|
||||
// sanitizeFilename 清理文件名中的特殊字符
|
||||
func (s *product) sanitizeFilename(name string) string {
|
||||
// 替换不安全的文件名字符
|
||||
replacer := map[rune]rune{
|
||||
'/': '_',
|
||||
'\\': '_',
|
||||
':': '_',
|
||||
'*': '_',
|
||||
'?': '_',
|
||||
'"': '_',
|
||||
'<': '_',
|
||||
'>': '_',
|
||||
'|': '_',
|
||||
}
|
||||
|
||||
// 预分配容量,避免循环中动态扩容
|
||||
result := make([]rune, 0, len(name))
|
||||
for _, char := range name {
|
||||
if newChar, exists := replacer[char]; exists {
|
||||
result = append(result, newChar)
|
||||
} else {
|
||||
result = append(result, char)
|
||||
}
|
||||
}
|
||||
|
||||
filename := string(result)
|
||||
// 限制文件名长度
|
||||
if utf8.RuneCountInString(filename) > 50 {
|
||||
runes := []rune(filename)
|
||||
filename = string(runes[:50])
|
||||
}
|
||||
|
||||
return filename
|
||||
}
|
||||
|
||||
// Import 从ZIP文件导入产品
|
||||
// 参数: ctx - 上下文,file - 上传的ZIP文件
|
||||
// 返回: res - 导入结果(成功和失败数量),err - 错误信息
|
||||
// 功能: 从ZIP文件批量导入产品数据并同步到RAGFlow,失败记录加入重试队列
|
||||
func (s *product) Import(ctx context.Context, file *multipart.FileHeader) (res *dto.ImportProductRes, err error) {
|
||||
res = &dto.ImportProductRes{
|
||||
SuccessCount: 0,
|
||||
FailCount: 0,
|
||||
FailReasons: []string{},
|
||||
}
|
||||
|
||||
// 1. 获取租户信息
|
||||
user, err := util.GetTenantInfo(ctx)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "获取租户信息失败")
|
||||
}
|
||||
tenantId := gconv.String(user.TenantId)
|
||||
if tenantId == "" {
|
||||
return nil, gerror.New("租户ID为空")
|
||||
}
|
||||
|
||||
// 2. 打开上传的文件
|
||||
uploadedFile, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, gerror.Newf("无法打开上传的文件: %v", err)
|
||||
}
|
||||
defer uploadedFile.Close()
|
||||
|
||||
// 3. 读取文件内容到内存
|
||||
fileData, err := io.ReadAll(uploadedFile)
|
||||
if err != nil {
|
||||
return nil, gerror.Newf("读取文件失败: %v", err)
|
||||
}
|
||||
|
||||
// 4. 解析 ZIP 文件
|
||||
zipReader, err := zip.NewReader(bytes.NewReader(fileData), int64(len(fileData)))
|
||||
if err != nil {
|
||||
return nil, gerror.Newf("无法解析ZIP文件: %v", err)
|
||||
}
|
||||
|
||||
// 4. 遍历 ZIP 中的所有文件
|
||||
for _, zipFile := range zipReader.File {
|
||||
// 只处理 .txt 文件
|
||||
if !strings.HasSuffix(strings.ToLower(zipFile.Name), ".txt") {
|
||||
continue
|
||||
}
|
||||
|
||||
// 读取 TXT 文件内容(产品详情)
|
||||
txtContent, err := s.readZipFile(zipFile)
|
||||
if err != nil {
|
||||
res.FailCount++
|
||||
res.FailReasons = append(res.FailReasons, "文件 "+zipFile.Name+" 读取失败: "+err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
// 从文件名提取产品名称(移除 .txt 后缀)
|
||||
productName := strings.TrimSuffix(zipFile.Name, ".txt")
|
||||
productName = strings.TrimSpace(productName)
|
||||
|
||||
// 创建产品数据
|
||||
productData, err := s.parseSimpleTxt(productName, txtContent)
|
||||
if err != nil {
|
||||
res.FailCount++
|
||||
res.FailReasons = append(res.FailReasons, "文件 "+zipFile.Name+" 解析失败: "+err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
// 校验产品名称和详情长度
|
||||
if utf8.RuneCountInString(productData.Name) > 64 {
|
||||
res.FailCount++
|
||||
res.FailReasons = append(res.FailReasons, "文件 "+zipFile.Name+": 产品名称超过64字")
|
||||
continue
|
||||
}
|
||||
if utf8.RuneCountInString(productData.Description) > 8192 {
|
||||
res.FailCount++
|
||||
res.FailReasons = append(res.FailReasons, "文件 "+zipFile.Name+": 产品详情超过8192字")
|
||||
continue
|
||||
}
|
||||
|
||||
// 设置基础字段
|
||||
now := gtime.Now().Time
|
||||
productData.CreatedAt = &now // 取地址赋值给指针类型
|
||||
productData.UpdatedAt = &now // 取地址赋值给指针类型
|
||||
productData.IsDeleted = false
|
||||
// 统一使用string类型存储tenantId到MongoDB
|
||||
productData.TenantId = tenantId
|
||||
|
||||
// 插入数据库
|
||||
_, err = dao.Product.Insert(ctx, productData)
|
||||
if err != nil {
|
||||
res.FailCount++
|
||||
res.FailReasons = append(res.FailReasons, "文件 "+zipFile.Name+" 数据库插入失败: "+err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
// 同步上传到RAGFlow产品知识库(使用外层已声明的tenantId变量)
|
||||
if tenantId != "" {
|
||||
datasetId := fmt.Sprintf("dataset_product_tenant_%s", tenantId)
|
||||
ragflowClient := ragflow.GetGlobalClient()
|
||||
if ragflowClient != nil {
|
||||
filename := fmt.Sprintf("%s.txt", productData.Name)
|
||||
documentId, uploadErr := ragflowClient.UploadDocumentFromText(ctx, datasetId, productData.Description, filename)
|
||||
if uploadErr != nil {
|
||||
// 上传失败:回滚删除MongoDB记录
|
||||
dao.MongoDAO.Delete(ctx, bson.M{"_id": productData.Id}, entity.ProductCollection)
|
||||
res.FailCount++
|
||||
res.FailReasons = append(res.FailReasons, "文件 "+zipFile.Name+" 上传知识库失败: "+uploadErr.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
// 更新ragDocumentId
|
||||
filter := bson.M{"_id": productData.Id}
|
||||
update := bson.M{"$set": bson.M{"ragDocumentId": documentId}}
|
||||
dao.MongoDAO.UpdateOne(ctx, filter, update, entity.ProductCollection)
|
||||
|
||||
g.Log().Infof(ctx, "ZIP产品上传成功: name=%s, document_id=%s", productData.Name, documentId)
|
||||
}
|
||||
}
|
||||
|
||||
res.SuccessCount++
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// readZipFile 读取 ZIP 文件中的单个文件内容
|
||||
func (s *product) readZipFile(file *zip.File) (string, error) {
|
||||
reader, err := file.Open()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
content, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(content), nil
|
||||
}
|
||||
|
||||
// parseSimpleTxt 解析简化格式的 TXT 文件
|
||||
// 文件名=产品名称,内容=产品详情
|
||||
func (s *product) parseSimpleTxt(productName string, description string) (*entity.Product, error) {
|
||||
product := &entity.Product{}
|
||||
|
||||
// 清理并验证产品名称
|
||||
product.Name = strings.TrimSpace(strings.ToValidUTF8(productName, ""))
|
||||
if product.Name == "" {
|
||||
return nil, gerror.New("产品名称不能为空")
|
||||
}
|
||||
|
||||
// 清理产品详情
|
||||
product.Description = strings.TrimSpace(strings.ToValidUTF8(description, ""))
|
||||
|
||||
return product, nil
|
||||
}
|
||||
|
||||
// parseTxt 解析 TXT 文件内容为产品实体(旧格式兼容)
|
||||
func (s *product) parseTxt(content string) (*entity.Product, error) {
|
||||
product := &entity.Product{}
|
||||
lines := strings.Split(content, "\n")
|
||||
|
||||
var inDescription bool
|
||||
var descriptionLines []string
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
|
||||
// 跳过空行
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 解析标题
|
||||
if strings.HasPrefix(line, "=== ") && strings.HasSuffix(line, " ===") {
|
||||
product.Name = strings.TrimSpace(line[4 : len(line)-4])
|
||||
continue
|
||||
}
|
||||
|
||||
// 跳过基本信息标记
|
||||
if line == "【基本信息】" {
|
||||
inDescription = false
|
||||
continue
|
||||
}
|
||||
|
||||
// 产品详情开始
|
||||
if line == "【产品详情】" {
|
||||
inDescription = true
|
||||
continue
|
||||
}
|
||||
|
||||
// 解析基本信息字段
|
||||
if !inDescription {
|
||||
// 跳过系统生成的字段(产品ID、创建时间等)
|
||||
if strings.HasPrefix(line, "产品ID:") ||
|
||||
strings.HasPrefix(line, "创建时间:") ||
|
||||
strings.HasPrefix(line, "更新时间:") ||
|
||||
strings.HasPrefix(line, "导出时间:") {
|
||||
continue
|
||||
}
|
||||
|
||||
// 解析 RAGFlow 文档 ID
|
||||
if strings.HasPrefix(line, "RAGFlow文档ID:") {
|
||||
// RAGFlow文档ID存储在RagSyncRecords中
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 收集产品详情
|
||||
if inDescription {
|
||||
// 跳过分隔线和导出时间
|
||||
if strings.Contains(line, "==================") || strings.HasPrefix(line, "导出时间:") {
|
||||
continue
|
||||
}
|
||||
if line != "暂无产品详情" {
|
||||
descriptionLines = append(descriptionLines, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 拼接产品详情
|
||||
product.Description = strings.Join(descriptionLines, "\n")
|
||||
|
||||
// 校验必填字段
|
||||
if product.Name == "" {
|
||||
return nil, gerror.New("产品名称不能为空")
|
||||
}
|
||||
|
||||
return product, nil
|
||||
}
|
||||
|
||||
// BindToCustomerServices 绑定产品到多个客服账号
|
||||
func (p *product) BindToCustomerServices(ctx context.Context, req *dto.BindProductReq) (res *dto.BindProductRes, err error) {
|
||||
res = &dto.BindProductRes{}
|
||||
|
||||
// 1. 查询产品
|
||||
product, err := dao.Product.GetById(ctx, req.ProductId)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrapf(err, "查询产品失败")
|
||||
}
|
||||
if product == nil {
|
||||
return nil, gerror.New("产品不存在")
|
||||
}
|
||||
|
||||
// 2. 构建已存在的绑定map(去重)
|
||||
existingMap := make(map[string]bool)
|
||||
for _, csId := range product.AccountNames {
|
||||
existingMap[csId] = true
|
||||
}
|
||||
|
||||
// 3. 过滤并添加新绑定
|
||||
var newBindings []string
|
||||
var failedIds []string
|
||||
|
||||
for _, csId := range req.AccountNames {
|
||||
// 检查去重:customer_service_id是否已存在
|
||||
if existingMap[csId] {
|
||||
failedIds = append(failedIds, csId)
|
||||
g.Log().Warningf(ctx, "客服账号 %s 已绑定该产品,跳过", csId)
|
||||
continue
|
||||
}
|
||||
|
||||
// 验证客服账号是否存在
|
||||
csAccount, err := dao.CustomerServiceAccount.FindByAccountName(ctx, csId)
|
||||
if err != nil || csAccount == nil {
|
||||
failedIds = append(failedIds, csId)
|
||||
g.Log().Warningf(ctx, "客服账号 %s 不存在或已删除,跳过", csId)
|
||||
continue
|
||||
}
|
||||
|
||||
newBindings = append(newBindings, csId)
|
||||
}
|
||||
|
||||
// 4. 如果没有新的绑定,直接返回
|
||||
if len(newBindings) == 0 {
|
||||
res.SuccessCount = 0
|
||||
res.FailedIds = failedIds
|
||||
res.Message = "所有客服账号均已绑定或不存在"
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// 5. 更新产品绑定
|
||||
product.AccountNames = append(product.AccountNames, newBindings...)
|
||||
if err = dao.Product.UpdateEntity(ctx, product); err != nil {
|
||||
return nil, gerror.Wrapf(err, "更新产品绑定失败")
|
||||
}
|
||||
|
||||
// 6. 同步到RAGFlow(自动创建知识库)
|
||||
for _, csId := range newBindings {
|
||||
// 获取客服账号信息以获取tenant_id
|
||||
csAccount, err := dao.CustomerServiceAccount.FindByAccountName(ctx, csId)
|
||||
if err != nil || csAccount == nil {
|
||||
g.Log().Errorf(ctx, "获取客服账号信息失败: %s", csId)
|
||||
continue
|
||||
}
|
||||
|
||||
// 同步到RAGFlow(会自动创建知识库)
|
||||
tenantId := gconv.String(csAccount.TenantId)
|
||||
_, err = p.SyncToRAGFlow(ctx, req.ProductId, csId, tenantId)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "同步到RAGFlow失败: product_id=%s, cs_id=%s, error=%v", req.ProductId, csId, err)
|
||||
// 不阻断绑定流程,失败会进入重试队列
|
||||
}
|
||||
}
|
||||
|
||||
res.SuccessCount = len(newBindings)
|
||||
res.FailedIds = failedIds
|
||||
res.Message = "绑定成功"
|
||||
return
|
||||
}
|
||||
|
||||
// UnbindFromCustomerService 从客服账号解绑产品
|
||||
func (p *product) UnbindFromCustomerService(ctx context.Context, req *dto.UnbindProductReq) (res *dto.UnbindProductRes, err error) {
|
||||
res = &dto.UnbindProductRes{}
|
||||
|
||||
product, err := dao.Product.GetById(ctx, req.ProductId)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrapf(err, "查询产品失败")
|
||||
}
|
||||
if product == nil {
|
||||
return nil, gerror.New("产品不存在")
|
||||
}
|
||||
|
||||
// 查找并移除绑定
|
||||
var newBindings []string
|
||||
found := false
|
||||
|
||||
for _, csId := range product.AccountNames {
|
||||
if csId == req.AccountName {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
newBindings = append(newBindings, csId)
|
||||
}
|
||||
|
||||
if !found {
|
||||
res.Success = false
|
||||
res.Message = "未找到该绑定关系"
|
||||
return res, nil
|
||||
}
|
||||
|
||||
product.AccountNames = newBindings
|
||||
if err = dao.Product.UpdateEntity(ctx, product); err != nil {
|
||||
return nil, gerror.Wrapf(err, "解绑失败")
|
||||
}
|
||||
|
||||
res.Success = true
|
||||
res.Message = "解绑成功"
|
||||
return
|
||||
}
|
||||
|
||||
// SyncToRAGFlow 同步产品到RAGFlow(租户级知识库)
|
||||
func (p *product) SyncToRAGFlow(ctx context.Context, productId, accountName, tenantId string) (documentId string, err error) {
|
||||
// 1. 查询产品
|
||||
product, err := dao.Product.GetById(ctx, productId)
|
||||
if err != nil {
|
||||
return "", gerror.Wrapf(err, "查询产品失败")
|
||||
}
|
||||
if product == nil {
|
||||
return "", gerror.New("产品不存在")
|
||||
}
|
||||
|
||||
// 2. 获取租户的产品知识库ID
|
||||
datasetId := fmt.Sprintf("dataset_product_tenant_%s", tenantId)
|
||||
|
||||
// 2.1 确保知识库存在,不存在则自动创建
|
||||
if err := p.ensureDatasetExists(ctx, datasetId, tenantId, "产品"); err != nil {
|
||||
return "", gerror.Wrapf(err, "确保知识库存在失败")
|
||||
}
|
||||
|
||||
// 3. 调用RAGFlow上传文档
|
||||
ragflowClient := ragflow.GetGlobalClient()
|
||||
filename := fmt.Sprintf("%s_%s.txt", product.Name, accountName)
|
||||
documentId, err = ragflowClient.UploadDocumentFromText(ctx, datasetId, product.Description, filename)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err, "产品上传RAGFlow失败")
|
||||
p.sendToRetryQueue(ctx, productId, accountName, tenantId, 0)
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 4. 更新MongoDB的RagSyncRecord
|
||||
now := gtime.Now().Format("Y-m-d H:i:s")
|
||||
updated := false
|
||||
for i := range product.RagSyncRecords {
|
||||
record := &product.RagSyncRecords[i]
|
||||
if record.AccountName == accountName {
|
||||
record.RagDocumentId = documentId
|
||||
record.RagSyncStatus = "synced"
|
||||
record.SyncTime = now
|
||||
record.RetryCount = 0
|
||||
updated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !updated {
|
||||
product.RagSyncRecords = append(product.RagSyncRecords, entity.RagSyncRecord{
|
||||
AccountName: accountName,
|
||||
RagDocumentId: documentId,
|
||||
RagSyncStatus: "synced",
|
||||
SyncTime: now,
|
||||
RetryCount: 0,
|
||||
})
|
||||
}
|
||||
|
||||
product.RagLastSyncTime = now
|
||||
if err = dao.Product.UpdateEntity(ctx, product); err != nil {
|
||||
return "", gerror.Wrapf(err, "更新产品同步状态失败")
|
||||
}
|
||||
|
||||
glog.Infof(ctx, "产品同步成功: product_id=%s, account_name=%s, document_id=%s", productId, accountName, documentId)
|
||||
return documentId, nil
|
||||
}
|
||||
|
||||
// ensureDatasetExists 已废弃,改用公共方法 EnsureTenantDataset
|
||||
// 保留此方法仅为兼容性,直接调用公共方法
|
||||
func (p *product) ensureDatasetExists(ctx context.Context, datasetId, tenantId, datasetType string) error {
|
||||
_, err := EnsureTenantDataset(ctx, tenantId)
|
||||
return err
|
||||
}
|
||||
|
||||
// sendToRetryQueue 发送到重试队列
|
||||
func (p *product) sendToRetryQueue(ctx context.Context, productId, accountName, tenantId string, retryCount int) {
|
||||
msg := dto.RAGFlowSyncRetryMsg{
|
||||
Type: "product",
|
||||
Id: productId,
|
||||
AccountName: accountName,
|
||||
TenantId: tenantId,
|
||||
RetryCount: retryCount,
|
||||
}
|
||||
|
||||
var delay int
|
||||
switch retryCount {
|
||||
case 0:
|
||||
delay = 5 * 60
|
||||
case 1:
|
||||
delay = 15 * 60
|
||||
case 2:
|
||||
delay = 60 * 60
|
||||
default:
|
||||
glog.Warningf(ctx, "产品同步重试次数超限,标记为失败: %s", productId)
|
||||
p.markSyncFailed(ctx, productId, accountName)
|
||||
return
|
||||
}
|
||||
|
||||
if err := rabbitmq.PublishWithDelay(ctx, "ragflow.sync.retry.product", msg, delay); err != nil {
|
||||
jaeger.RecordError(ctx, err, "发送RAGFlow重试消息失败")
|
||||
}
|
||||
}
|
||||
|
||||
// markSyncFailed 标记同步失败
|
||||
func (p *product) markSyncFailed(ctx context.Context, productId, accountName string) {
|
||||
product, err := dao.Product.GetById(ctx, productId)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for i := range product.RagSyncRecords {
|
||||
record := &product.RagSyncRecords[i]
|
||||
if record.AccountName == accountName {
|
||||
record.RagSyncStatus = "failed"
|
||||
record.SyncTime = gtime.Now().Format("Y-m-d H:i:s")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
dao.Product.UpdateEntity(ctx, product)
|
||||
}
|
||||
|
||||
// HandleRAGFlowSyncRetry RAGFlow同步重试消费者
|
||||
func (p *product) HandleRAGFlowSyncRetry(ctx context.Context, msg dto.RAGFlowSyncRetryMsg) error {
|
||||
glog.Infof(ctx, "处理RAGFlow同步重试: type=%s, id=%s, retry=%d", msg.Type, msg.Id, msg.RetryCount)
|
||||
|
||||
if msg.Type != "product" {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := p.SyncToRAGFlow(ctx, msg.Id, msg.AccountName, msg.TenantId)
|
||||
if err != nil {
|
||||
p.sendToRetryQueue(ctx, msg.Id, msg.AccountName, msg.TenantId, msg.RetryCount+1)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProductRetryConsumer 产品RAGFlow重试消费者
|
||||
type ProductRetryConsumer struct {
|
||||
queueName string
|
||||
consumer *rabbitmq.Consumer
|
||||
}
|
||||
|
||||
// NewProductRetryConsumer 创建产品RAGFlow重试消费者
|
||||
func NewProductRetryConsumer(ctx context.Context) *ProductRetryConsumer {
|
||||
return &ProductRetryConsumer{
|
||||
queueName: "ragflow.sync.retry.product",
|
||||
}
|
||||
}
|
||||
|
||||
// Start 启动消费者
|
||||
func (c *ProductRetryConsumer) Start(ctx context.Context) error {
|
||||
c.consumer = rabbitmq.NewConsumer(c.queueName, func(ctx context.Context, body []byte) error {
|
||||
var msg dto.RAGFlowSyncRetryMsg
|
||||
if err := json.Unmarshal(body, &msg); err != nil {
|
||||
return err
|
||||
}
|
||||
return Product.HandleRAGFlowSyncRetry(ctx, msg)
|
||||
})
|
||||
return c.consumer.Start(ctx)
|
||||
}
|
||||
|
||||
// Stop 停止消费者
|
||||
func (c *ProductRetryConsumer) Stop(ctx context.Context) {
|
||||
if c.consumer != nil {
|
||||
c.consumer.Stop(ctx)
|
||||
}
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
// Package service - RAGFlow配置服务
|
||||
// 功能:管理ragflow_config表,对话配置(chatId、提示词)、知识库映射(datasetId)
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/dao"
|
||||
"customer-server/model/dto"
|
||||
|
||||
"gitea.com/red-future/common/ragflow"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/text/gstr"
|
||||
)
|
||||
|
||||
var RAGFlowConfig = new(ragflowConfig)
|
||||
|
||||
type ragflowConfig struct{}
|
||||
|
||||
// Get 获取RAGFlow配置(配置不存在时自动重建)
|
||||
// 参数: ctx - 上下文,req - 查询请求(包含客服账号名)
|
||||
// 返回: res - RAGFlow配置信息(提示词、参数等),err - 错误信息
|
||||
// 功能: 查询客服账号的RAGFlow对话配置,不存在时自动重建
|
||||
func (s *ragflowConfig) Get(ctx context.Context, req *dto.GetRAGFlowConfigReq) (res *dto.GetRAGFlowConfigRes, err error) {
|
||||
config, err := dao.RAGFlowConfig.FindByAccountName(ctx, req.AccountName)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "查询RAGFlow配置失败")
|
||||
}
|
||||
|
||||
// 配置不存在,尝试自动重建
|
||||
if config == nil {
|
||||
g.Log().Warningf(ctx, "客服账号 %s 的RAGFlow配置不存在,尝试自动重建...", req.AccountName)
|
||||
|
||||
// 查询客服账号获取platform
|
||||
account, findErr := dao.CustomerServiceAccount.FindByAccountName(ctx, req.AccountName)
|
||||
if findErr != nil {
|
||||
return nil, gerror.Wrapf(findErr, "查询客服账号失败")
|
||||
}
|
||||
if account == nil {
|
||||
return nil, gerror.Newf("客服账号 %s 不存在,无法自动创建RAGFlow配置", req.AccountName)
|
||||
}
|
||||
|
||||
// 调用重建逻辑
|
||||
recreateReq := &dto.RecreateRAGFlowConfigReq{
|
||||
AccountName: req.AccountName,
|
||||
Platform: account.Platform,
|
||||
}
|
||||
if recreateErr := CustomerServiceAccount.RecreateRAGFlowConfig(ctx, recreateReq); recreateErr != nil {
|
||||
return nil, gerror.Wrapf(recreateErr, "自动重建RAGFlow配置失败")
|
||||
}
|
||||
|
||||
// 重新查询配置
|
||||
config, err = dao.RAGFlowConfig.FindByAccountName(ctx, req.AccountName)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "重建后查询配置失败")
|
||||
}
|
||||
if config == nil {
|
||||
return nil, gerror.New("重建后仍无法获取配置")
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "成功自动重建客服账号 %s 的RAGFlow配置", req.AccountName)
|
||||
}
|
||||
|
||||
// 返回给前端时去掉自动追加的知识库部分,只显示用户的业务话术
|
||||
userPrompt := config.Prompt
|
||||
knowledgePart := "\n\n以下是知识库:\n{knowledge}\n以上是知识库。"
|
||||
if gstr.Contains(userPrompt, knowledgePart) {
|
||||
userPrompt = gstr.Replace(userPrompt, knowledgePart, "")
|
||||
}
|
||||
|
||||
res = &dto.GetRAGFlowConfigRes{
|
||||
AccountName: config.AccountName,
|
||||
Platform: config.Platform,
|
||||
DatasetId: config.DatasetId,
|
||||
DatasetName: config.DatasetName,
|
||||
ChatId: config.ChatId,
|
||||
Prompt: userPrompt, // 只返回用户部分
|
||||
DocumentIds: config.DocumentIds,
|
||||
SimilarityThreshold: config.SimilarityThreshold,
|
||||
KeywordsSimilarityWeight: config.KeywordsSimilarityWeight,
|
||||
TopN: config.TopN,
|
||||
EmptyResponse: config.EmptyResponse,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// UpdatePrompt 更新提示词(配置不存在时自动重建)
|
||||
// 参数: ctx - 上下文,req - 更新提示词请求(包含客服账号名和新提示词)
|
||||
// 返回: res - 更新结果信息,err - 错误信息
|
||||
// 功能: 更新RAGFlow对话的系统提示词并同步到RAGFlow,影响AI回复风格
|
||||
func (s *ragflowConfig) UpdatePrompt(ctx context.Context, req *dto.UpdatePromptReq) (res *dto.UpdatePromptRes, err error) {
|
||||
// 1. 查询当前配置
|
||||
config, err := dao.RAGFlowConfig.FindByAccountName(ctx, req.AccountName)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "查询RAGFlow配置失败")
|
||||
}
|
||||
|
||||
// 配置不存在,尝试自动重建
|
||||
if config == nil {
|
||||
g.Log().Warningf(ctx, "客服账号 %s 的RAGFlow配置不存在,尝试自动重建...", req.AccountName)
|
||||
|
||||
// 查询客服账号获取platform
|
||||
account, findErr := dao.CustomerServiceAccount.FindByAccountName(ctx, req.AccountName)
|
||||
if findErr != nil {
|
||||
return nil, gerror.Wrapf(findErr, "查询客服账号失败")
|
||||
}
|
||||
if account == nil {
|
||||
return nil, gerror.Newf("客服账号 %s 不存在,无法自动创建RAGFlow配置", req.AccountName)
|
||||
}
|
||||
|
||||
// 调用重建逻辑
|
||||
recreateReq := &dto.RecreateRAGFlowConfigReq{
|
||||
AccountName: req.AccountName,
|
||||
Platform: account.Platform,
|
||||
}
|
||||
if recreateErr := CustomerServiceAccount.RecreateRAGFlowConfig(ctx, recreateReq); recreateErr != nil {
|
||||
return nil, gerror.Wrapf(recreateErr, "自动重建RAGFlow配置失败")
|
||||
}
|
||||
|
||||
// 重新查询配置
|
||||
config, err = dao.RAGFlowConfig.FindByAccountName(ctx, req.AccountName)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "重建后查询配置失败")
|
||||
}
|
||||
if config == nil {
|
||||
return nil, gerror.New("重建后仍无法获取配置")
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "成功自动重建客服账号 %s 的RAGFlow配置", req.AccountName)
|
||||
}
|
||||
|
||||
// 2. 更新字段并自动追加知识库引用
|
||||
// 用户只需输入业务话术,后端自动在末尾追加标准的知识库引用格式
|
||||
userPrompt := req.Prompt
|
||||
|
||||
// 先移除所有可能的知识库引用部分(使用查找并截断方式,与List方法保持一致)
|
||||
knowledgePatterns := []string{
|
||||
"\n\n以下是知识库:",
|
||||
"\n\n以下是知识库",
|
||||
"\n以下是知识库:",
|
||||
"\n以下是知识库",
|
||||
}
|
||||
|
||||
// 找到最早出现的知识库引用位置并截断
|
||||
for _, pattern := range knowledgePatterns {
|
||||
if idx := gstr.Pos(userPrompt, pattern); idx >= 0 {
|
||||
userPrompt = gstr.SubStr(userPrompt, 0, idx)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 去除末尾多余的空行
|
||||
userPrompt = gstr.TrimRight(userPrompt)
|
||||
|
||||
// 统一追加标准的知识库引用格式
|
||||
userPrompt = userPrompt + "\n\n以下是知识库:\n{knowledge}\n以上是知识库。"
|
||||
config.Prompt = userPrompt
|
||||
|
||||
if req.SimilarityThreshold != nil {
|
||||
config.SimilarityThreshold = *req.SimilarityThreshold
|
||||
}
|
||||
if req.KeywordsSimilarityWeight != nil {
|
||||
config.KeywordsSimilarityWeight = *req.KeywordsSimilarityWeight
|
||||
}
|
||||
if req.TopN != nil {
|
||||
config.TopN = *req.TopN
|
||||
}
|
||||
if req.EmptyResponse != nil {
|
||||
config.EmptyResponse = *req.EmptyResponse
|
||||
}
|
||||
now := gtime.Now().Time
|
||||
config.UpdatedAt = &now // 取地址赋值给指针类型
|
||||
|
||||
// 3. 调用RAGFlow API更新Chat配置
|
||||
ragflowClient := ragflow.GetGlobalClient()
|
||||
updateReq := &ragflow.UpdateChatReq{
|
||||
Prompt: &ragflow.PromptConfig{
|
||||
Prompt: config.Prompt,
|
||||
SimilarityThreshold: config.SimilarityThreshold,
|
||||
KeywordsSimilarityWeight: config.KeywordsSimilarityWeight,
|
||||
TopN: config.TopN,
|
||||
EmptyResponse: config.EmptyResponse,
|
||||
Variables: []map[string]interface{}{
|
||||
{
|
||||
"key": "knowledge",
|
||||
"optional": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
DatasetIds: []string{config.DatasetId},
|
||||
}
|
||||
|
||||
if err := ragflowClient.UpdateChat(ctx, config.ChatId, updateReq); err != nil {
|
||||
return nil, gerror.Wrapf(err, "调用RAGFlow API更新Chat失败")
|
||||
}
|
||||
|
||||
// 4. 更新MongoDB
|
||||
if err := dao.RAGFlowConfig.UpdateEntity(ctx, config); err != nil {
|
||||
return nil, gerror.Wrap(err, "更新MongoDB配置失败")
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "RAGFlow配置更新成功: account_name=%s, chat_id=%s",
|
||||
config.AccountName, config.ChatId)
|
||||
|
||||
res = &dto.UpdatePromptRes{
|
||||
Success: true,
|
||||
Message: "提示词更新成功",
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Package service - 预制话术服务
|
||||
// 功能:预制话术的增删改查
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/dao"
|
||||
"customer-server/model/dto"
|
||||
"fmt"
|
||||
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
var (
|
||||
ScriptedSpeechService = new(scriptedSpeechService)
|
||||
)
|
||||
|
||||
type scriptedSpeechService struct{}
|
||||
|
||||
// Add 添加预制话术
|
||||
// 参数: ctx - 上下文,req - 添加预制话术请求
|
||||
// 返回: res - 添加成功后的预制话术ID,err - 错误信息
|
||||
// 功能: 创建新的预制话术记录
|
||||
func (s *scriptedSpeechService) Add(ctx context.Context, req *dto.AddScriptedSpeechReq) (res *dto.AddScriptedSpeechRes, err error) {
|
||||
count, err := dao.ScriptedSpeech.Count(ctx, &dto.ListScriptedSpeechReq{
|
||||
DatasetId: req.DatasetId,
|
||||
SceneType: req.SceneType,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if count > 0 {
|
||||
err = fmt.Errorf("话术场景已存在")
|
||||
return
|
||||
}
|
||||
// 插入数据库
|
||||
id, err := dao.ScriptedSpeech.Insert(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = &dto.AddScriptedSpeechRes{Id: id}
|
||||
return
|
||||
}
|
||||
|
||||
// Update 更新预制话术
|
||||
// 参数: ctx - 上下文,req - 更新预制话术请求
|
||||
// 返回: err - 错误信息
|
||||
// 功能: 更新预制话术信息
|
||||
func (s *scriptedSpeechService) Update(ctx context.Context, req *dto.UpdateScriptedSpeechReq) (err error) {
|
||||
_, err = dao.ScriptedSpeech.Update(ctx, req)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete 删除预制话术
|
||||
// 参数: ctx - 上下文,req - 删除预制话术请求
|
||||
// 返回: err - 错误信息
|
||||
// 功能: 逻辑删除预制话术记录
|
||||
func (s *scriptedSpeechService) Delete(ctx context.Context, req *dto.DeleteScriptedSpeechReq) (err error) {
|
||||
_, err = dao.ScriptedSpeech.Delete(ctx, req)
|
||||
return
|
||||
}
|
||||
|
||||
// Get 获取单个预制话术
|
||||
// 参数: ctx - 上下文,req - 获取预制话术请求
|
||||
// 返回: res - 预制话术信息,err - 错误信息
|
||||
// 功能: 根据ID获取单个预制话术详情
|
||||
func (s *scriptedSpeechService) Get(ctx context.Context, req *dto.GetScriptedSpeechReq) (res *dto.ScriptedSpeechVO, err error) {
|
||||
r, err := dao.ScriptedSpeech.GetById(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = gconv.Struct(r, &res)
|
||||
return
|
||||
}
|
||||
|
||||
// List 获取预制话术列表
|
||||
// 参数: ctx - 上下文,req - 列表查询请求
|
||||
// 返回: res - 预制话术列表及分页信息,err - 错误信息
|
||||
// 功能: 分页查询预制话术记录
|
||||
func (s *scriptedSpeechService) List(ctx context.Context, req *dto.ListScriptedSpeechReq) (res *dto.ListScriptedSpeechRes, err error) {
|
||||
list, total, err := dao.ScriptedSpeech.List(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = &dto.ListScriptedSpeechRes{
|
||||
Total: total,
|
||||
}
|
||||
err = gconv.Struct(list, &res.List)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
// Package service - 会话服务
|
||||
// 功能:用户会话管理、状态维护
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/dao"
|
||||
|
||||
"gitea.com/red-future/common/jaeger"
|
||||
"gitea.com/red-future/common/rabbitmq"
|
||||
"gitea.com/red-future/common/redis"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/os/glog"
|
||||
)
|
||||
|
||||
// sessionService 会话服务(操作 session 表)
|
||||
type sessionService struct{}
|
||||
|
||||
// SessionService 会话服务单例
|
||||
var SessionService = new(sessionService)
|
||||
|
||||
// ============== RabbitMQ 消费者(归档延时消息)==============
|
||||
|
||||
// SessionArchiveConsumer 会话归档消费者
|
||||
type SessionArchiveConsumer struct {
|
||||
consumer *rabbitmq.Consumer
|
||||
}
|
||||
|
||||
// NewSessionArchiveConsumer 创建会话归档消费者
|
||||
func NewSessionArchiveConsumer(ctx context.Context) *SessionArchiveConsumer {
|
||||
queueName := GetConfigString(ctx, "archive.queue")
|
||||
return &SessionArchiveConsumer{
|
||||
consumer: rabbitmq.NewConsumer(queueName, handleSessionArchive),
|
||||
}
|
||||
}
|
||||
|
||||
// Start 启动消费者
|
||||
func (c *SessionArchiveConsumer) Start(ctx context.Context) (err error) {
|
||||
glog.Info(ctx, "会话归档消费者启动...")
|
||||
return c.consumer.Start(ctx)
|
||||
}
|
||||
|
||||
// Stop 停止消费者
|
||||
func (c *SessionArchiveConsumer) Stop(ctx context.Context) {
|
||||
c.consumer.Stop(ctx)
|
||||
}
|
||||
|
||||
// handleSessionArchive 处理会话归档消息
|
||||
func handleSessionArchive(ctx context.Context, body []byte) (err error) {
|
||||
ctx, span := jaeger.NewSpan(ctx, "consumer.session.archive")
|
||||
defer span.End()
|
||||
|
||||
var msg redis.ArchiveMessage
|
||||
if err = gjson.DecodeTo(body, &msg); err != nil {
|
||||
jaeger.RecordError(ctx, err, "解析归档消息失败")
|
||||
return
|
||||
}
|
||||
|
||||
glog.Infof(ctx, "收到归档消息 - 用户: %s, Session: %s", msg.UserId, msg.SessionId)
|
||||
|
||||
// 检查用户是否在归档发送后有活跃(60分钟内)
|
||||
isActive, err := redis.IsUserActive(ctx, msg.UserId, int64(redis.GetArchiveDelay()))
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err, "检查用户活跃状态失败")
|
||||
return
|
||||
}
|
||||
|
||||
if isActive {
|
||||
glog.Infof(ctx, "用户 %s 在归档期间有活跃,跳过归档", msg.UserId)
|
||||
return
|
||||
}
|
||||
|
||||
// 执行归档
|
||||
if err = dao.Session.Archive(ctx, msg.UserId, msg.SessionId); err != nil {
|
||||
jaeger.RecordError(ctx, err, "归档会话失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 清除 Session 缓存(需要tenantId)
|
||||
// TODO: ArchiveMessage需要添加TenantId字段
|
||||
redis.DelSessionCache(ctx, msg.TenantId, msg.UserId)
|
||||
|
||||
glog.Infof(ctx, "会话已归档 - 用户: %s, Session: %s", msg.UserId, msg.SessionId)
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/consts/account"
|
||||
"customer-server/consts/public"
|
||||
"customer-server/consts/scriptedSpeech"
|
||||
"customer-server/dao"
|
||||
"customer-server/model/dto"
|
||||
"customer-server/model/entity"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/beans"
|
||||
"gitea.redpowerfuture.com/red-future/common/http"
|
||||
"gitea.redpowerfuture.com/red-future/common/jaeger"
|
||||
"gitea.redpowerfuture.com/red-future/common/utils"
|
||||
gmq "github.com/bjang03/gmq/core/gmq"
|
||||
"github.com/bjang03/gmq/mq"
|
||||
"github.com/bjang03/gmq/types"
|
||||
"github.com/gogf/gf/v2/container/gvar"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
var SessionToolService = new(sessionToolService)
|
||||
|
||||
type sessionToolService struct{}
|
||||
|
||||
func (s *sessionToolService) PushOpeningRemark(ctx context.Context, userId string, accountInfo *dto.AccountVO, headers map[string]string) (content string, err error) {
|
||||
content = ""
|
||||
var sceneType = scriptedSpeech.SceneTypeOpeningRemark
|
||||
var key = fmt.Sprintf(public.AccountMsgKey, accountInfo.AccountCode, account.GetDescByCode(accountInfo.Platform), userId)
|
||||
get, err := g.Redis().Get(ctx, key)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if g.IsEmpty(get) {
|
||||
// 构建开场白内容
|
||||
if len(accountInfo.DatasetIds) > 1 {
|
||||
var datasetInfo *dto.RagListDatasetRes
|
||||
datasetInfo, err = SessionToolService.GetDatasetInfo(ctx, accountInfo.DatasetIds, headers)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if g.IsEmpty(datasetInfo) {
|
||||
err = fmt.Errorf("数据集不存在")
|
||||
return
|
||||
}
|
||||
var datasetDescriptions [][]string
|
||||
for _, dataset := range datasetInfo.List {
|
||||
datasetDescriptions = append(datasetDescriptions, []string{dataset.Name, gconv.String(dataset.Id)})
|
||||
}
|
||||
content, err = SessionToolService.BuildGreeting(ctx, userId, accountInfo.Greeting, datasetDescriptions, len(accountInfo.DatasetIds))
|
||||
} else {
|
||||
var datasetDescriptions [][]string
|
||||
for _, keyword := range accountInfo.KeywordOption {
|
||||
datasetDescriptions = append(datasetDescriptions, []string{keyword, gconv.String(accountInfo.DatasetIds[0])})
|
||||
}
|
||||
content, err = SessionToolService.BuildGreeting(ctx, userId, accountInfo.Greeting, datasetDescriptions, len(accountInfo.DatasetIds))
|
||||
}
|
||||
err = s.pushDelayMsg(ctx, key, sceneType.Code(), sceneType.Desc(), accountInfo.DatasetIds)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *sessionToolService) PushDialog(ctx context.Context, userId string, questionContent string, accountInfo *dto.AccountVO, headers map[string]string) (content string, err error) {
|
||||
content = ""
|
||||
// 删除延迟消息
|
||||
if err = s.DeleteDelayMsg(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
var key = fmt.Sprintf(public.AccountMsgKey, accountInfo.AccountCode, account.GetDescByCode(accountInfo.Platform), userId)
|
||||
get, err := g.Redis().Get(ctx, key)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if !g.IsEmpty(get) {
|
||||
|
||||
sceneType := scriptedSpeech.SceneTypeDialog
|
||||
var datasetIds []int64
|
||||
|
||||
var optionsMap *gvar.Var
|
||||
optionsMap, err = g.Redis().Get(ctx, fmt.Sprintf(public.AccountGreetingOptionsKey, userId))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
jsonStr := gconv.String(optionsMap)
|
||||
var data map[string]interface{}
|
||||
if err = gconv.Scan(jsonStr, &data); err != nil {
|
||||
return
|
||||
}
|
||||
for i, item := range data {
|
||||
// 把每一项转成 map
|
||||
if i == questionContent {
|
||||
m := gconv.Map(item)
|
||||
questionContent = gconv.String(m["datasetName"])
|
||||
datasetIds = gconv.Int64s(m["datasetId"])
|
||||
}
|
||||
}
|
||||
|
||||
if g.IsEmpty(datasetIds) {
|
||||
var datasetRes []int64
|
||||
datasetRes, err = s.getDatasetIdsByKeywords(ctx, questionContent, headers)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if len(datasetRes) > 0 {
|
||||
datasetIds = datasetRes
|
||||
} else {
|
||||
datasetIds = accountInfo.DatasetIds
|
||||
}
|
||||
}
|
||||
// 获取用户对话上下文
|
||||
var history []*dto.Message
|
||||
history, err = SessionToolService.GetUserHistory(ctx, userId)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("获取用户对话上下文失败: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取用户对话记录
|
||||
var accountUserDialog *entity.AccountUserDialog
|
||||
accountUserDialog, err = dao.AccountUserDialog.Get(ctx, &dto.GetAccountUserDialogReq{
|
||||
AccountId: accountInfo.Id,
|
||||
UserId: userId,
|
||||
})
|
||||
if err != nil {
|
||||
err = fmt.Errorf("获取用户对话记录失败: %w", err)
|
||||
return
|
||||
}
|
||||
if g.IsEmpty(accountUserDialog.Id) {
|
||||
// 保存用户对话记录
|
||||
if _, err = dao.AccountUserDialog.Insert(ctx, &dto.AddAccountUserDialogReq{
|
||||
AccountId: accountInfo.Id,
|
||||
UserId: userId,
|
||||
DialogCount: 1,
|
||||
}); err != nil {
|
||||
err = fmt.Errorf("保存用户对话记录失败: %w", err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if accountUserDialog.DialogCount >= g.Cfg().MustGet(ctx, "card.triggerCount").Int64() {
|
||||
// TODO 替换为实际卡片发送逻辑
|
||||
content = "请加一下卡片的联系方式,进行更专业的咨询"
|
||||
sceneType = scriptedSpeech.SceneTypeCardSend
|
||||
if _, err = SessionToolService.ClearUserHistory(ctx, userId); err != nil {
|
||||
err = fmt.Errorf("清除用户对话上下文失败: %w", err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 更新用户对话记录
|
||||
if _, err = dao.AccountUserDialog.Update(ctx, &dto.UpdateAccountUserDialogReq{
|
||||
Id: accountUserDialog.Id,
|
||||
DialogCount: 1,
|
||||
}); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if *sceneType.Code() != *scriptedSpeech.SceneTypeCardSend.Code() {
|
||||
// 通过HTTP调用rag服务的RAG查询接口
|
||||
var ragQuery *dto.RagQueryRes
|
||||
ragQuery, err = SessionToolService.GetRagQuery(ctx, questionContent, datasetIds, history, headers)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("调用rag服务的RAG查询接口失败: %w", err)
|
||||
return
|
||||
}
|
||||
content = ragQuery.Answer
|
||||
|
||||
// 保存用户对话上下文
|
||||
err = SessionToolService.SaveUserHistory(ctx, userId, []*dto.Message{
|
||||
{Role: "user", Content: questionContent},
|
||||
{Role: "assistant", Content: content},
|
||||
})
|
||||
if err != nil {
|
||||
err = fmt.Errorf("保存用户对话上下文失败: %w", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
err = s.pushDelayMsg(ctx, key, sceneType.Code(), sceneType.Desc(), datasetIds)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *sessionToolService) pushDelayMsg(ctx context.Context, key string, sceneTypeCode scriptedSpeech.SceneType, sceneTypeDesc string, datasetIds []int64) (err error) {
|
||||
err = g.Redis().SetEX(ctx, key, sceneTypeDesc, gconv.Int64(public.DialogTimeout*time.Second))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 获取追问话术内容
|
||||
var msg string
|
||||
if len(datasetIds) == 1 {
|
||||
scriptedSpeechInfo, err := SessionToolService.GetScriptedSpeechContent(ctx, datasetIds[0], sceneTypeCode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取追问话术内容失败: %w", err)
|
||||
}
|
||||
msg = scriptedSpeechInfo.QuestionContent
|
||||
}
|
||||
|
||||
if g.IsEmpty(msg) {
|
||||
if *sceneTypeCode == *scriptedSpeech.SceneTypeOpeningRemark.Code() {
|
||||
msg = public.SceneOpeningRemark
|
||||
} else if *sceneTypeCode == *scriptedSpeech.SceneTypeDialog.Code() {
|
||||
msg = public.SceneDialog
|
||||
} else if *sceneTypeCode == *scriptedSpeech.SceneTypeCardSend.Code() {
|
||||
msg = public.SceneCardSend
|
||||
}
|
||||
}
|
||||
|
||||
var msgMap = map[string]string{
|
||||
"key": key,
|
||||
"data": msg,
|
||||
}
|
||||
err = gmq.GetGmq(public.GmqMsgPluginsName).GmqPublishDelay(ctx, &mq.NatsPubDelayMessage{
|
||||
PubDelayMessage: types.PubDelayMessage{
|
||||
PubMessage: types.PubMessage{
|
||||
Topic: public.AccountFollowupTopic,
|
||||
Data: msgMap,
|
||||
},
|
||||
DelaySeconds: 60,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func (s *sessionToolService) DeleteDelayMsg(ctx context.Context) (err error) {
|
||||
return gmq.GetGmq(public.GmqMsgPluginsName).GmqDeleteDelay(ctx, &mq.NatsDelMessage{
|
||||
DelMessage: types.DelMessage{
|
||||
Topic: public.AccountFollowupTopic,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetAccountInfo 获取客服账号信息
|
||||
func (s *sessionToolService) GetAccountInfo(ctx context.Context, accountCode string) (res *dto.AccountVO, err error) {
|
||||
r, err := dao.Account.GetByAccountCode(ctx, &dto.GetByAccountCodeReq{
|
||||
AccountCode: accountCode,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取客服账号信息失败: %w", err)
|
||||
}
|
||||
err = gconv.Struct(r, &res)
|
||||
return
|
||||
}
|
||||
|
||||
// SetUserInfo 设置用户信息
|
||||
func (s *sessionToolService) SetUserInfo(ctx context.Context, creator string, tenantId uint64) (headers map[string]string, err error) {
|
||||
// 创建完整的用户信息
|
||||
userInfo := &beans.User{
|
||||
UserName: creator,
|
||||
TenantId: tenantId,
|
||||
}
|
||||
ctx = context.WithValue(ctx, "user", *userInfo)
|
||||
// 提取并保存请求头(在连接升级前)
|
||||
headers = make(map[string]string)
|
||||
// 提取其他headers
|
||||
if r := g.RequestFromCtx(ctx); r != nil {
|
||||
for k, v := range r.Request.Header {
|
||||
if len(v) > 0 {
|
||||
headers[k] = v[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
// 将完整用户信息序列化为JSON,放到X-User-Info请求头
|
||||
userInfoJson, err := gjson.Encode(userInfo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("用户信息序列化失败: %w", err)
|
||||
}
|
||||
headers["X-User-Info"] = string(userInfoJson)
|
||||
return
|
||||
}
|
||||
|
||||
// GetDatasetInfo 获取数据集信息
|
||||
func (s *sessionToolService) GetDatasetInfo(ctx context.Context, datasetIds []int64, headers map[string]string) (res *dto.RagListDatasetRes, err error) {
|
||||
// 通过HTTP调用rag服务的关键词查询接口
|
||||
res = &dto.RagListDatasetRes{}
|
||||
if err = http.Get(ctx, "rag/dataset/list", headers, &res, &dto.RagListDatasetReq{
|
||||
Ids: datasetIds,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("获取数据集信息失败: %w", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// BuildGreeting 构建问候语
|
||||
func (s *sessionToolService) BuildGreeting(ctx context.Context, userId, greeting string, options [][]string, datasetCount int) (content string, err error) {
|
||||
var sb strings.Builder
|
||||
// 问候语
|
||||
if datasetCount > 1 || greeting == "" {
|
||||
greeting = public.GreetingBegin
|
||||
}
|
||||
|
||||
sb.WriteString(greeting)
|
||||
sb.WriteByte('\n')
|
||||
// 拼接选项 1、xx 2、xx...
|
||||
var optionsMap = make(map[string]map[string]string, len(options))
|
||||
for i, opt := range options {
|
||||
optionsMap[gconv.String(i+1)] = map[string]string{
|
||||
"datasetId": opt[1],
|
||||
"datasetName": opt[0],
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%d、%s\n", i+1, opt))
|
||||
if i == len(options)-1 {
|
||||
sb.WriteString(fmt.Sprintf("%s\n", public.GreetingBetween))
|
||||
}
|
||||
}
|
||||
// 固定结尾
|
||||
sb.WriteString(public.GreetingEnd)
|
||||
content = sb.String()
|
||||
err = g.Redis().SetEX(ctx, fmt.Sprintf(public.AccountGreetingOptionsKey, userId), optionsMap, gconv.Int64(public.DialogTimeout*time.Second))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// GetScriptedSpeechContent 获取话术内容
|
||||
func (s *sessionToolService) GetScriptedSpeechContent(ctx context.Context, datasetId int64, sceneType scriptedSpeech.SceneType) (res *dto.ScriptedSpeechVO, err error) {
|
||||
r, err := dao.ScriptedSpeech.GetByDatasetIdAndSceneType(ctx, &dto.ListScriptedSpeechReq{
|
||||
DatasetId: datasetId,
|
||||
SceneType: sceneType,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = gconv.Struct(r, &res)
|
||||
return
|
||||
}
|
||||
|
||||
// GetRagQuery 获取rag查询结果
|
||||
func (s *sessionToolService) GetRagQuery(ctx context.Context, questionContent string, datasetIds []int64, history []*dto.Message, headers map[string]string) (res *dto.RagQueryRes, err error) {
|
||||
resp := new(dto.RagQueryRes)
|
||||
if err = http.Post(ctx, "rag/document/vector/ragQuery", headers, &resp, &dto.RagQueryReq{
|
||||
Content: questionContent,
|
||||
DatasetIds: datasetIds,
|
||||
History: history,
|
||||
TopK: 5,
|
||||
}); err != nil {
|
||||
return
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// SaveUserHistory 保存用户对话历史到Redis
|
||||
func (s *sessionToolService) SaveUserHistory(ctx context.Context, userKey string, newMessages []*dto.Message) (err error) {
|
||||
key := fmt.Sprintf(public.AccountDialogHistoryKey, userKey)
|
||||
|
||||
// 1. 先读旧历史
|
||||
var oldMessages []*dto.Message
|
||||
oldMessages, err = s.GetUserHistory(ctx, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. 合并
|
||||
allMessages := append(oldMessages, newMessages...)
|
||||
|
||||
// 3. 限制长度(保留最新 N 轮)
|
||||
maxMsgCount := 2 * g.Cfg().MustGet(ctx, "history.contextLimit", 5).Int()
|
||||
if len(allMessages) > maxMsgCount {
|
||||
allMessages = allMessages[len(allMessages)-maxMsgCount:]
|
||||
}
|
||||
|
||||
// 4. 存回Redis
|
||||
data, err := json.Marshal(allMessages)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return g.Redis().SetEX(ctx, key, data, gconv.Int64(public.DialogTimeout*time.Second))
|
||||
}
|
||||
|
||||
// GetUserHistory 从Redis获取用户历史
|
||||
func (s *sessionToolService) GetUserHistory(ctx context.Context, key string) ([]*dto.Message, error) {
|
||||
data, err := g.Redis().Get(ctx, key)
|
||||
if err != nil || data.IsEmpty() {
|
||||
return []*dto.Message{}, nil
|
||||
}
|
||||
|
||||
var messages []*dto.Message
|
||||
if err = json.Unmarshal(data.Bytes(), &messages); err != nil {
|
||||
return []*dto.Message{}, err
|
||||
}
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// ClearUserHistory 清空历史(可选)
|
||||
func (s *sessionToolService) ClearUserHistory(ctx context.Context, userKey string) (int64, error) {
|
||||
key := fmt.Sprintf(public.AccountDialogHistoryKey, userKey)
|
||||
return g.Redis().Del(ctx, key)
|
||||
}
|
||||
|
||||
// getDatasetIdsByKeywords 通过关键词查询数据集ID
|
||||
func (s *sessionToolService) getDatasetIdsByKeywords(ctx context.Context, questionContent string, headers map[string]string) (res []int64, err error) {
|
||||
// 1. 提取关键词
|
||||
keywords := s.extractKeywords(questionContent)
|
||||
g.Log().Infof(ctx, "提取关键词: %v", keywords)
|
||||
|
||||
// 通过HTTP调用rag服务的关键词查询接口
|
||||
respKeyword := &dto.RAGListKeywordRes{}
|
||||
if err = http.Get(ctx, "rag/keyword/list", headers, &respKeyword, &dto.RAGListKeywordReq{
|
||||
Words: keywords,
|
||||
}); err != nil {
|
||||
jaeger.RecordError(ctx, err, "RAG查询关键词失败")
|
||||
g.Log().Errorf(ctx, "RAG查询关键词失败: %v", err)
|
||||
return
|
||||
}
|
||||
var datasetIds []int64
|
||||
for _, v := range respKeyword.List {
|
||||
if !slices.Contains(datasetIds, v.DatasetId) {
|
||||
datasetIds = append(datasetIds, v.DatasetId)
|
||||
}
|
||||
}
|
||||
return datasetIds, nil
|
||||
}
|
||||
|
||||
// extractKeywords 提取关键词
|
||||
func (s *sessionToolService) extractKeywords(text string) []string {
|
||||
if text == "" {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// 使用gse分词工具提取关键词
|
||||
keywords := utils.GseTool.Extract(text, 5)
|
||||
|
||||
words := make([]string, 0, len(keywords))
|
||||
for _, kw := range keywords {
|
||||
if kw.Word != "" {
|
||||
words = append(words, kw.Word)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有提取到关键词,使用分词结果
|
||||
if len(words) == 0 {
|
||||
words = utils.GseTool.Cut(text)
|
||||
}
|
||||
|
||||
return words
|
||||
}
|
||||
@@ -1,653 +0,0 @@
|
||||
// Package service - 话术服务
|
||||
// 功能:话术的增删改查、绑定/解绑客服账号、同步到RAGFlow、重试消费者
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/dao"
|
||||
"customer-server/model/dto"
|
||||
"customer-server/model/entity"
|
||||
"customer-server/util"
|
||||
"fmt"
|
||||
|
||||
"gitea.com/red-future/common/db/mongo"
|
||||
"gitea.com/red-future/common/jaeger"
|
||||
"gitea.com/red-future/common/rabbitmq"
|
||||
"gitea.com/red-future/common/ragflow"
|
||||
"gitea.com/red-future/common/redis"
|
||||
"gitea.com/red-future/common/utils"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/glog"
|
||||
"github.com/gogf/gf/v2/os/grpool"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/text/gstr"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
var (
|
||||
Speechcraft = new(speechcraft)
|
||||
speechcraftGrpool = grpool.New(50) // 文档解析协程池,最大50并发
|
||||
)
|
||||
|
||||
type speechcraft struct{}
|
||||
|
||||
// Add 添加话术
|
||||
// 参数: ctx - 上下文,req - 添加话术请求(包含标题、内容、方向、标签等)
|
||||
// 返回: res - 添加成功后的话术ID和RAGFlow文档ID,err - 错误信息
|
||||
// 功能: 创建话术记录并自动上传到RAGFlow租户知识库,支持去重检查
|
||||
func (s *speechcraft) Add(ctx context.Context, req *dto.AddSpeechcraftReq) (res *dto.AddSpeechcraftRes, err error) {
|
||||
// 去重检查:同一租户下tag唯一
|
||||
if req.Tag != "" {
|
||||
existing, err := dao.Speechcraft.FindByTag(ctx, req.Tag)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "检查话术重复失败")
|
||||
}
|
||||
if existing != nil {
|
||||
return nil, gerror.Newf("话术tag已存在:tag=%s, id=%s", req.Tag, existing.Id.Hex())
|
||||
}
|
||||
}
|
||||
|
||||
// 先从token或请求获取租户信息(在Insert之前)
|
||||
var tenantId string
|
||||
if req.TenantId != nil {
|
||||
tenantId = gconv.String(req.TenantId)
|
||||
g.Log().Debugf(ctx, "使用请求中的TenantId: %v", req.TenantId)
|
||||
} else {
|
||||
user, err := util.GetTenantInfo(ctx)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "获取租户信息失败")
|
||||
}
|
||||
tenantId = gconv.String(user.TenantId)
|
||||
if tenantId == "" {
|
||||
return nil, gerror.New("租户ID为空")
|
||||
}
|
||||
}
|
||||
|
||||
// 查询租户知识库ID
|
||||
datasetId, err := dao.RAGFlowConfig.FindDatasetIdByTenant(ctx, tenantId)
|
||||
if err != nil || datasetId == "" {
|
||||
return nil, gerror.Newf("租户知识库不存在,请先创建客服账号: tenant_id=%s", tenantId)
|
||||
}
|
||||
|
||||
data := &entity.Speechcraft{}
|
||||
if err = utils.Struct(req, data); err != nil {
|
||||
return
|
||||
}
|
||||
// 设置基础字段
|
||||
now := gtime.Now().Time
|
||||
data.CreatedAt = &now // 取地址赋值给指针类型
|
||||
data.UpdatedAt = &now // 取地址赋值给指针类型
|
||||
data.IsDeleted = false
|
||||
// 统一使用string类型存储tenantId到MongoDB
|
||||
data.TenantId = tenantId
|
||||
|
||||
// 使用DAO封装的Insert方法
|
||||
id, err := dao.Speechcraft.Insert(ctx, data)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "插入话术失败")
|
||||
}
|
||||
data.Id = &id // 取地址赋值给指针类型
|
||||
|
||||
// 同步上传到RAGFlow
|
||||
ragflowClient := ragflow.GetGlobalClient()
|
||||
if ragflowClient == nil {
|
||||
// 回滚:删除刚插入的话术
|
||||
dao.MongoDAO.Delete(ctx, bson.M{"_id": data.Id}, entity.SpeechcraftCollection)
|
||||
return nil, gerror.New("RAGFlow客户端未初始化,请检查配置")
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "准备上传话术到RAGFlow: speechcraft_id=%s, dataset_id=%s, direction=%s, tag=%s",
|
||||
data.Id.Hex(), datasetId, data.Direction, data.Tag)
|
||||
filename := fmt.Sprintf("%s_%s.txt", data.Direction, data.Tag)
|
||||
documentId, err := ragflowClient.UploadDocumentFromText(ctx, datasetId, data.Content, filename)
|
||||
if err != nil {
|
||||
// 回滚:删除刚插入的话术
|
||||
dao.MongoDAO.Delete(ctx, bson.M{"_id": data.Id}, entity.SpeechcraftCollection)
|
||||
g.Log().Errorf(ctx, "话术上传RAGFlow失败: speechcraft_id=%s, dataset_id=%s, error=%v", data.Id.Hex(), datasetId, err)
|
||||
jaeger.RecordError(ctx, err, "话术上传RAGFlow失败")
|
||||
return nil, gerror.Wrap(err, "文档上传到知识库失败")
|
||||
}
|
||||
|
||||
// 异步触发解析(grpool自动管理goroutine生命周期,WithoutCancel保留追踪避免取消)
|
||||
speechcraftGrpool.Add(ctx, func(ctx context.Context) {
|
||||
parseCtx := context.WithoutCancel(ctx)
|
||||
if err := ragflowClient.ParseDocuments(parseCtx, datasetId, []string{documentId}); err != nil {
|
||||
g.Log().Errorf(parseCtx, "文档解析失败: document_id=%s, error=%v", documentId, err)
|
||||
} else {
|
||||
g.Log().Infof(parseCtx, "文档解析成功: document_id=%s", documentId)
|
||||
}
|
||||
})
|
||||
|
||||
// 更新MongoDB的RagSyncRecords数组(使用空accountName表示租户级文档)
|
||||
syncTime := gtime.Now().Format("Y-m-d H:i:s")
|
||||
record := entity.RagSyncRecord{
|
||||
AccountName: "", // 空表示租户级文档
|
||||
RagDocumentId: documentId,
|
||||
RagSyncStatus: "synced",
|
||||
SyncTime: syncTime,
|
||||
RetryCount: 0,
|
||||
}
|
||||
filter := bson.M{"_id": data.Id}
|
||||
update := bson.M{
|
||||
"$set": bson.M{
|
||||
"ragSyncRecords": []entity.RagSyncRecord{record},
|
||||
"ragLastSyncTime": syncTime,
|
||||
"updatedAt": gtime.Now().Time,
|
||||
},
|
||||
}
|
||||
if _, _, err = dao.MongoDAO.UpdateOne(ctx, filter, update, entity.SpeechcraftCollection); err != nil {
|
||||
g.Log().Errorf(ctx, "更新话术RagSyncRecords失败: %v", err)
|
||||
// 不回滚,文档已上传成功
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "话术添加成功并上传到知识库: speechcraft_id=%s, document_id=%s", data.Id.Hex(), documentId)
|
||||
res = &dto.AddSpeechcraftRes{Id: data.Id.Hex()}
|
||||
return
|
||||
}
|
||||
|
||||
// Update 更新话术
|
||||
// 参数: ctx - 上下文,req - 更新话术请求(包含话术ID和待更新字段)
|
||||
// 返回: err - 错误信息
|
||||
// 功能: 更新话术内容并同步到RAGFlow,支持文档删除重建
|
||||
func (s *speechcraft) Update(ctx context.Context, req *dto.UpdateSpeechcraftReq) (err error) {
|
||||
return dao.Speechcraft.Update(ctx, req)
|
||||
}
|
||||
|
||||
// Delete 删除话术
|
||||
// 参数: ctx - 上下文,req - 删除话术请求(包含话术ID)
|
||||
// 返回: err - 错误信息
|
||||
// 功能: 逻辑删除话术记录并从RAGFlow移除对应文档
|
||||
func (s *speechcraft) Delete(ctx context.Context, req *dto.DeleteSpeechcraftReq) (err error) {
|
||||
g.Log().Infof(ctx, "[Delete] 开始删除话术 - speechcraftId: %s", req.Id)
|
||||
|
||||
// 1. 查询话术,获取RAGFlow同步记录(使用原生查询,避免租户过滤)
|
||||
objectId, err := bson.ObjectIDFromHex(req.Id)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "无效的话术ID")
|
||||
}
|
||||
|
||||
var speechcraft entity.Speechcraft
|
||||
filter := bson.M{"_id": objectId, "isDeleted": false}
|
||||
err = dao.MongoDAO.FindOne(ctx, filter, &speechcraft, entity.SpeechcraftCollection)
|
||||
if err != nil {
|
||||
if err.Error() == "mongo: no documents in result" {
|
||||
return gerror.New("话术不存在")
|
||||
}
|
||||
return gerror.Wrap(err, "查询话术失败")
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "[Delete] 查询到话术 - tag: %s, ragSyncRecords数量: %d", speechcraft.Tag, len(speechcraft.RagSyncRecords))
|
||||
|
||||
// 2. 删除RAGFlow中的文档
|
||||
if len(speechcraft.RagSyncRecords) > 0 {
|
||||
ragflowClient := ragflow.GetGlobalClient()
|
||||
if ragflowClient != nil {
|
||||
tenantId := gconv.String(speechcraft.TenantId)
|
||||
|
||||
// 查询租户的dataset_id
|
||||
datasetId, err := dao.RAGFlowConfig.FindDatasetIdByTenant(ctx, tenantId)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "查询租户知识库ID失败: %v", err)
|
||||
} else if datasetId != "" {
|
||||
// 收集所有需要删除的document_id
|
||||
var documentIds []string
|
||||
for _, record := range speechcraft.RagSyncRecords {
|
||||
if record.RagDocumentId != "" {
|
||||
documentIds = append(documentIds, record.RagDocumentId)
|
||||
}
|
||||
}
|
||||
|
||||
// 批量删除RAGFlow文档
|
||||
if len(documentIds) > 0 {
|
||||
if err := ragflowClient.DeleteDocument(ctx, datasetId, documentIds); err != nil {
|
||||
g.Log().Errorf(ctx, "删除RAGFlow文档失败: %v, document_ids: %v", err, documentIds)
|
||||
// 不阻断删除流程,记录错误后继续
|
||||
} else {
|
||||
g.Log().Infof(ctx, "成功删除RAGFlow文档: count=%d", len(documentIds))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 软删除MongoDB记录
|
||||
return dao.Speechcraft.Delete(ctx, req)
|
||||
}
|
||||
|
||||
// List 获取话术列表
|
||||
// 参数: ctx - 上下文,req - 列表查询请求(支持分页、关键词搜索、平台筛选)
|
||||
// 返回: res - 话术列表及分页信息,err - 错误信息
|
||||
// 功能: 分页查询话术记录,支持按标题、内容模糊搜索和平台筛选
|
||||
func (s *speechcraft) List(ctx context.Context, req *dto.ListSpeechcraftReq) (res *dto.ListSpeechcraftRes, err error) {
|
||||
list, total, err := dao.Speechcraft.List(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = &dto.ListSpeechcraftRes{
|
||||
List: list,
|
||||
Total: int(total),
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Match 话术匹配(核心方法)
|
||||
// 根据用户当前阶段、行为、输入内容匹配话术
|
||||
//
|
||||
// func (s *speechcraft) Match(ctx context.Context, userId, platform, content, status string) (answer string, nextStage int, err error) {
|
||||
// // 1. 获取用户当前阶段
|
||||
// state, err := dao.UserStage.GetOrCreate(ctx, userId, platform)
|
||||
func (s *speechcraft) Match(ctx context.Context, userId, platform, tenantId, content, status string) (answer string, nextStage int, err error) {
|
||||
// 1. 获取用户当前状态(Redis,5分钟过期)
|
||||
userState, err := redis.GetUserState(ctx, userId, platform)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err, "获取用户状态失败")
|
||||
return
|
||||
}
|
||||
|
||||
glog.Infof(ctx, "话术匹配 - 用户: %s, 当前阶段: %d, 行为: %s, 内容: %s", userId, userState.Stage, status, content)
|
||||
|
||||
// 2. 状态3(发卡片状态):持续提示用户添加联系方式
|
||||
if userState.Stage == 3 {
|
||||
answer = "请加一下卡片的联系方式,进行更专业的咨询" // TODO: 替换为实际卡片内容
|
||||
nextStage = 3 // 保持状态3
|
||||
glog.Infof(ctx, "用户处于发卡片状态 - 用户: %s", userId)
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 检测用户是否想要联系方式(5次内立即发卡)
|
||||
if s.isRequestingContact(content) {
|
||||
glog.Infof(ctx, "检测到联系方式请求关键词 - 用户: %s, 内容: %s", userId, content)
|
||||
return s.handleCardRequest(ctx, userId, platform)
|
||||
}
|
||||
|
||||
// 5. 所有其他消息直接走RAGFlow(话术已上传到知识库)
|
||||
// 后端只负责:开场白(WebSocket连接时发送)+ 发卡片(上面的逻辑)
|
||||
glog.Infof(ctx, "无话术匹配,转发到RAGFlow - 用户: %s, 内容: %s", userId, content)
|
||||
nextStage = 0
|
||||
if updateErr := redis.SetUserStage(ctx, userId, platform, 0); updateErr != nil {
|
||||
jaeger.RecordError(ctx, updateErr, "更新用户阶段为0失败")
|
||||
}
|
||||
// answer为空,调用方会走RAGFlow
|
||||
return
|
||||
}
|
||||
|
||||
// ProcessAndPublish 处理用户消息并推送到Redis Stream
|
||||
// 参数: ctx - 上下文,userId - 用户ID,platform - 平台,tenantId - 租户ID,content - 消息内容,status - 用户行为状态,accountName - 客服账号名
|
||||
// 返回: isPushed - 是否成功推送话术匹配结果,err - 错误信息
|
||||
// 功能: 尝试匹配话术,匹配成功则直接推送话术内容,失败则转发到Redis Stream由RAGFlow处理
|
||||
func (s *speechcraft) ProcessAndPublish(ctx context.Context, userId, platform, tenantId, content, status, accountName string) (isPushed bool, err error) {
|
||||
// 1. 话术匹配
|
||||
answer, _, err := s.Match(ctx, userId, platform, tenantId, content, status)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 未匹配到话术,直接推送到Redis Stream
|
||||
if answer == "" {
|
||||
glog.Infof(ctx, "话术未匹配,转发到 AI 模型 - 用户: %s, 客服账号: %s", userId, accountName)
|
||||
|
||||
// 获取当前实例的动态响应队列名(自动生成,支持多实例部署)
|
||||
baseQueue := GetConfigString(ctx, "rabbitmq.responseQueue")
|
||||
replyQueue := rabbitmq.GetInstanceQueueName(baseQueue)
|
||||
messageId := userId + "_" + gconv.String(gtime.Now().TimestampNano())
|
||||
|
||||
// 构造Stream消息
|
||||
msg := &redis.SendStreamMessage{
|
||||
UserId: userId,
|
||||
Platform: platform,
|
||||
TenantId: tenantId,
|
||||
AccountName: accountName,
|
||||
Content: content,
|
||||
Timestamp: gtime.Now().Timestamp(),
|
||||
MessageId: messageId,
|
||||
ReplyQueue: replyQueue,
|
||||
}
|
||||
|
||||
// 检查是否有session缓存,无缓存说明已归档,需要读取历史
|
||||
if sessionId, _ := redis.GetSessionCache(ctx, tenantId, userId); sessionId == "" {
|
||||
if history, histErr := dao.Conversation.GetRecentHistory(ctx, userId, redis.GetHistoryContextLimit()); histErr == nil && len(history) > 0 {
|
||||
msg.History = history
|
||||
glog.Infof(ctx, "用户已归档,读取 %d 轮历史对话 - 用户: %s", len(history), userId)
|
||||
}
|
||||
}
|
||||
|
||||
// 写入Redis Stream
|
||||
var streamMsgId string
|
||||
streamMsgId, err = redis.AddToStream(ctx, redis.RAGFlowRequestStreamKey, msg)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err, "写入Stream失败")
|
||||
return
|
||||
}
|
||||
|
||||
glog.Infof(ctx, "消息已写入Stream - StreamID: %s, 用户: %s", streamMsgId, userId)
|
||||
isPushed = false // 未匹配话术,消息转发到AI处理
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 匹配到话术,直接推送 WebSocket(无需经过 RabbitMQ)
|
||||
if err = WebSocket.PushRAGFlowResponse(ctx, tenantId, userId, platform, answer); err != nil {
|
||||
jaeger.RecordError(ctx, err, "推送话术响应失败")
|
||||
return
|
||||
}
|
||||
|
||||
glog.Infof(ctx, "话术响应已推送 - 用户: %s, 话术长度: %d", userId, len(answer))
|
||||
isPushed = true // 已直接推送响应
|
||||
return
|
||||
}
|
||||
|
||||
// ResetUserStage 重置用户阶段
|
||||
func (s *speechcraft) ResetUserStage(ctx context.Context, userId, platform string) (err error) {
|
||||
return dao.UserStage.Reset(ctx, userId, platform)
|
||||
}
|
||||
|
||||
// isRequestingContact 检测用户是否想要联系方式(触发立即发卡)
|
||||
func (s *speechcraft) isRequestingContact(content string) bool {
|
||||
// 联系方式相关关键词
|
||||
contactKeywords := []string{
|
||||
"联系方式", "联系你", "联系",
|
||||
"微信", "VX", "vx", "wx", "WX",
|
||||
"电话", "手机号", "电话号码",
|
||||
"怎么找你", "如何联系", "加你",
|
||||
"私信", "私聊",
|
||||
}
|
||||
|
||||
// 清理内容(去除空格、标点)
|
||||
cleanContent := gstr.Trim(content)
|
||||
cleanContent = gstr.ToLower(cleanContent)
|
||||
|
||||
for _, keyword := range contactKeywords {
|
||||
if gstr.Contains(cleanContent, gstr.ToLower(keyword)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// handleCardRequest 处理用户请求联系方式(立即发卡片)
|
||||
func (s *speechcraft) handleCardRequest(ctx context.Context, userId, platform string) (answer string, nextStage int, err error) {
|
||||
// 更新用户状态为3(发卡片状态)
|
||||
if err = redis.SetUserStage(ctx, userId, platform, 3); err != nil {
|
||||
jaeger.RecordError(ctx, err, "更新用户状态为3失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 返回卡片话术
|
||||
answer = "请加一下卡片的联系方式,进行更专业的咨询" // TODO: 替换为实际卡片内容
|
||||
nextStage = 3
|
||||
|
||||
glog.Infof(ctx, "用户请求联系方式,立即发送卡片 - 用户: %s", userId)
|
||||
return
|
||||
}
|
||||
|
||||
// BindToCustomerServices 绑定话术到客服账号
|
||||
// 参数: ctx - 上下文,req - 绑定请求(包含话术ID和客服账号名列表)
|
||||
// 返回: res - 绑定结果(成功和失败的账号列表),err - 错误信息
|
||||
// 功能: 将话术同步到指定客服账号的RAGFlow知识库,批量处理并返回每个账号的结果
|
||||
func (s *speechcraft) BindToCustomerServices(ctx context.Context, req *dto.BindSpeechcraftReq) (res *dto.BindSpeechcraftRes, err error) {
|
||||
res = &dto.BindSpeechcraftRes{}
|
||||
|
||||
// 0. 参数验证
|
||||
if len(req.AccountNames) == 0 {
|
||||
return nil, gerror.New("客服账号ID列表不能为空")
|
||||
}
|
||||
|
||||
// 1. 查询话术(验证存在性和获取租户信息)
|
||||
r := g.RequestFromCtx(ctx)
|
||||
if r != nil {
|
||||
r.SetParam("accountName", req.AccountNames[0])
|
||||
}
|
||||
|
||||
speechcraft, err := dao.Speechcraft.GetById(ctx, req.SpeechcraftId)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrapf(err, "查询话术失败")
|
||||
}
|
||||
if speechcraft == nil {
|
||||
return nil, gerror.New("话术不存在")
|
||||
}
|
||||
|
||||
speechcraftTenantId := gconv.String(speechcraft.TenantId)
|
||||
|
||||
// 2. 遍历客服账号,更新每个账号的speechcraft_ids
|
||||
var newBindings []string
|
||||
var alreadyBound []string
|
||||
var notFound []string
|
||||
|
||||
for _, csId := range req.AccountNames {
|
||||
// 查询客服账号
|
||||
csAccount, err := dao.CustomerServiceAccount.FindByAccountName(ctx, csId)
|
||||
if err != nil || csAccount == nil {
|
||||
notFound = append(notFound, csId)
|
||||
g.Log().Warningf(ctx, "客服账号 %s 不存在或已删除,跳过", csId)
|
||||
continue
|
||||
}
|
||||
|
||||
// 租户隔离校验
|
||||
accountTenantId := gconv.String(csAccount.TenantId)
|
||||
if speechcraftTenantId != accountTenantId {
|
||||
g.Log().Warningf(ctx, "话术和客服账号不属于同一租户,跳过: speechcraft_tenant=%s, account_tenant=%s",
|
||||
speechcraftTenantId, accountTenantId)
|
||||
notFound = append(notFound, csId)
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查是否已绑定
|
||||
alreadyExists := false
|
||||
for _, existingId := range csAccount.SpeechcraftIds {
|
||||
if existingId == req.SpeechcraftId {
|
||||
alreadyExists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if alreadyExists {
|
||||
alreadyBound = append(alreadyBound, csId)
|
||||
g.Log().Warningf(ctx, "客服账号 %s 已绑定该话术,跳过", csId)
|
||||
continue
|
||||
}
|
||||
|
||||
// 添加到speechcraft_ids列表
|
||||
csAccount.SpeechcraftIds = append(csAccount.SpeechcraftIds, req.SpeechcraftId)
|
||||
|
||||
// 更新数据库
|
||||
filter := bson.M{"_id": csAccount.Id, "isDeleted": false}
|
||||
update := bson.M{"$set": bson.M{"speechcraftIds": csAccount.SpeechcraftIds}}
|
||||
if _, err := mongo.DB().Update(ctx, filter, update, entity.CustomerServiceAccountCollection); err != nil {
|
||||
g.Log().Errorf(ctx, "更新客服账号绑定失败: %s, error=%v", csId, err)
|
||||
notFound = append(notFound, csId)
|
||||
continue
|
||||
}
|
||||
|
||||
newBindings = append(newBindings, csId)
|
||||
}
|
||||
|
||||
// 3. 如果没有新的绑定,直接返回
|
||||
if len(newBindings) == 0 {
|
||||
res.SuccessCount = 0
|
||||
res.AlreadyBound = alreadyBound
|
||||
res.NotFound = notFound
|
||||
|
||||
if len(alreadyBound) > 0 && len(notFound) > 0 {
|
||||
res.Message = "部分客服账号已绑定,部分不存在"
|
||||
} else if len(alreadyBound) > 0 {
|
||||
res.Message = "所有客服账号已绑定,无需重复绑定"
|
||||
} else if len(notFound) > 0 {
|
||||
res.Message = "所有客服账号都不存在或租户不匹配"
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// 6. 同步到RAGFlow(自动创建知识库)
|
||||
for _, csId := range newBindings {
|
||||
// 获取客服账号信息以获取tenant_id
|
||||
csAccount, err := dao.CustomerServiceAccount.FindByAccountName(ctx, csId)
|
||||
if err != nil || csAccount == nil {
|
||||
g.Log().Errorf(ctx, "获取客服账号信息失败: %s", csId)
|
||||
continue
|
||||
}
|
||||
|
||||
// 同步到RAGFlow(会自动创建知识库)
|
||||
tenantId := gconv.String(csAccount.TenantId)
|
||||
g.Log().Infof(ctx, "客服账号租户信息: cs_id=%s, tenant_id=%v, tenant_id_type=%T", csId, csAccount.TenantId, csAccount.TenantId)
|
||||
_, err = s.SyncToRAGFlow(ctx, req.SpeechcraftId, csId, tenantId)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "同步到RAGFlow失败: speechcraft_id=%s, cs_id=%s, error=%v", req.SpeechcraftId, csId, err)
|
||||
// 不阻断绑定流程,失败会进入重试队列
|
||||
}
|
||||
}
|
||||
|
||||
res.SuccessCount = len(newBindings)
|
||||
res.AlreadyBound = alreadyBound
|
||||
res.NotFound = notFound
|
||||
|
||||
// 生成详细的响应消息
|
||||
if len(alreadyBound) > 0 || len(notFound) > 0 {
|
||||
res.Message = fmt.Sprintf("成功绑定%d个", len(newBindings))
|
||||
if len(alreadyBound) > 0 {
|
||||
res.Message += fmt.Sprintf(",%d个已绑定", len(alreadyBound))
|
||||
}
|
||||
if len(notFound) > 0 {
|
||||
res.Message += fmt.Sprintf(",%d个不存在", len(notFound))
|
||||
}
|
||||
} else {
|
||||
res.Message = fmt.Sprintf("成功绑定%d个客服账号", len(newBindings))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// UnbindFromCustomerService 从客服账号解绑话术
|
||||
// 参数: ctx - 上下文,req - 解绑请求(包含话术ID和客服账号名)
|
||||
// 返回: res - 解绑结果信息,err - 错误信息
|
||||
// 功能: 从客服账号的RAGFlow知识库中删除话术文档
|
||||
func (s *speechcraft) UnbindFromCustomerService(ctx context.Context, req *dto.UnbindSpeechcraftReq) (res *dto.UnbindSpeechcraftRes, err error) {
|
||||
res = &dto.UnbindSpeechcraftRes{}
|
||||
|
||||
// 1. 验证话术存在
|
||||
speechcraft, err := dao.Speechcraft.GetById(ctx, req.SpeechcraftId)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrapf(err, "查询话术失败")
|
||||
}
|
||||
if speechcraft == nil {
|
||||
return nil, gerror.New("话术不存在")
|
||||
}
|
||||
|
||||
// 2. 查询客服账号
|
||||
csAccount, err := dao.CustomerServiceAccount.FindByAccountName(ctx, req.AccountName)
|
||||
if err != nil || csAccount == nil {
|
||||
res.Success = false
|
||||
res.Message = "客服账号不存在"
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// 3. 从 speechcraft_ids 中移除话术ID
|
||||
var newSpeechcraftIds []string
|
||||
found := false
|
||||
for _, scId := range csAccount.SpeechcraftIds {
|
||||
if scId == req.SpeechcraftId {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
newSpeechcraftIds = append(newSpeechcraftIds, scId)
|
||||
}
|
||||
|
||||
if !found {
|
||||
res.Success = false
|
||||
res.Message = "未找到该绑定关系"
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// 4. 更新数据库
|
||||
filter := bson.M{"_id": csAccount.Id, "isDeleted": false}
|
||||
update := bson.M{"$set": bson.M{"speechcraftIds": newSpeechcraftIds}}
|
||||
if _, err := mongo.DB().Update(ctx, filter, update, entity.CustomerServiceAccountCollection); err != nil {
|
||||
return nil, gerror.Wrapf(err, "解绑失败")
|
||||
}
|
||||
|
||||
res.Success = true
|
||||
res.Message = "解绑成功"
|
||||
return
|
||||
}
|
||||
|
||||
// SyncToRAGFlow 同步话术到RAGFlow
|
||||
// 参数: ctx - 上下文,speechcraftId - 话术ID,accountName - 客服账号名,tenantId - 租户ID
|
||||
// 返回: documentId - RAGFlow文档ID,err - 错误信息
|
||||
// 功能: 将话术上传到指定客服账号的RAGFlow知识库,失败时自动加入重试队列
|
||||
func (s *speechcraft) SyncToRAGFlow(ctx context.Context, speechcraftId, accountName, tenantId string) (documentId string, err error) {
|
||||
// 1. 查询话术
|
||||
speechcraft, err := dao.Speechcraft.GetById(ctx, speechcraftId)
|
||||
if err != nil {
|
||||
return "", gerror.Wrapf(err, "查询话术失败")
|
||||
}
|
||||
if speechcraft == nil {
|
||||
return "", gerror.New("话术不存在")
|
||||
}
|
||||
|
||||
// 2. 确保知识库存在,获取真实的datasetId
|
||||
datasetId, err := s.ensureDatasetExists(ctx, tenantId, "话术")
|
||||
if err != nil {
|
||||
return "", gerror.Wrapf(err, "确保知识库存在失败")
|
||||
}
|
||||
|
||||
// 3. 调用RAGFlow上传文档
|
||||
ragflowClient := ragflow.GetGlobalClient()
|
||||
filename := fmt.Sprintf("%s_%s_%s.txt", speechcraft.Direction, speechcraft.Tag, accountName)
|
||||
documentId, err = ragflowClient.UploadDocumentFromText(ctx, datasetId, speechcraft.Content, filename)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err, "话术上传RAGFlow失败")
|
||||
return "", gerror.Wrap(err, "话术上传RAGFlow失败")
|
||||
}
|
||||
|
||||
// 3.1 上传成功后立即调用解析接口
|
||||
g.Log().Infof(ctx, "文档上传成功,开始解析: document_id=%s", documentId)
|
||||
if err = ragflowClient.ParseDocuments(ctx, datasetId, []string{documentId}); err != nil {
|
||||
// 解析失败只记录日志,不影响绑定流程(文档已上传,可以手动重试解析)
|
||||
g.Log().Errorf(ctx, "文档解析失败: document_id=%s, error=%v", documentId, err)
|
||||
jaeger.RecordError(ctx, err, "文档解析失败")
|
||||
} else {
|
||||
g.Log().Infof(ctx, "文档解析请求已发送: document_id=%s", documentId)
|
||||
}
|
||||
|
||||
// 4. 更新MongoDB的RagSyncRecord
|
||||
now := gtime.Now().Format("Y-m-d H:i:s")
|
||||
updated := false
|
||||
for i := range speechcraft.RagSyncRecords {
|
||||
record := &speechcraft.RagSyncRecords[i]
|
||||
if record.AccountName == accountName {
|
||||
record.RagDocumentId = documentId
|
||||
record.RagSyncStatus = "synced"
|
||||
record.SyncTime = now
|
||||
record.RetryCount = 0
|
||||
updated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有找到记录,新增
|
||||
if !updated {
|
||||
speechcraft.RagSyncRecords = append(speechcraft.RagSyncRecords, entity.RagSyncRecord{
|
||||
AccountName: accountName,
|
||||
RagDocumentId: documentId,
|
||||
RagSyncStatus: "synced",
|
||||
SyncTime: now,
|
||||
RetryCount: 0,
|
||||
})
|
||||
}
|
||||
|
||||
if err = dao.Speechcraft.UpdateEntity(ctx, speechcraft); err != nil {
|
||||
return "", gerror.Wrapf(err, "更新话术同步状态失败")
|
||||
}
|
||||
|
||||
// 注意:不再更新Chat的datasetIds,因为创建Chat时已经绑定了知识库
|
||||
// 话术文档上传到知识库后,Chat会自动使用该知识库的内容
|
||||
|
||||
glog.Infof(ctx, "话术同步成功: speechcraft_id=%s, account_name=%s, document_id=%s", speechcraftId, accountName, documentId)
|
||||
return documentId, nil
|
||||
}
|
||||
|
||||
// ensureDatasetExists 已废弃,改用公共方法 EnsureTenantDataset
|
||||
// 保留此方法仅为兼容性,直接调用公共方法
|
||||
func (s *speechcraft) ensureDatasetExists(ctx context.Context, tenantId, datasetType string) (datasetId string, err error) {
|
||||
return EnsureTenantDataset(ctx, tenantId)
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
// Package service - Webhook服务
|
||||
// 功能:接收并处理平台(小红书、抖音)的webhook消息
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/dao"
|
||||
"customer-server/model/dto"
|
||||
|
||||
"gitea.com/red-future/common/jaeger"
|
||||
"gitea.com/red-future/common/rabbitmq"
|
||||
"gitea.com/red-future/common/redis"
|
||||
"gitea.com/red-future/common/utils"
|
||||
"github.com/gogf/gf/v2/os/glog"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
var Webhook = new(webhookService)
|
||||
|
||||
type webhookService struct{}
|
||||
|
||||
// Receive 接收平台消息并写入队列
|
||||
func (s *webhookService) Receive(ctx context.Context, req *dto.WebhookReceiveReq) (res *dto.WebhookReceiveRes, err error) {
|
||||
glog.Infof(ctx, "收到 Webhook 消息 - 平台: %s, 用户: %s, 内容: %s", req.Platform, req.UserId, req.Content)
|
||||
|
||||
// 生成消息ID
|
||||
now := gtime.Now()
|
||||
messageId := req.Platform + "_" + req.UserId + "_" + gconv.String(now.TimestampNano())
|
||||
if req.MsgId != "" {
|
||||
messageId = req.MsgId // 使用平台消息ID(便于去重)
|
||||
}
|
||||
|
||||
// 从 token 获取租户ID
|
||||
var tenantId string
|
||||
if user, userErr := utils.GetUserInfo(ctx); userErr == nil {
|
||||
tenantId = gconv.String(user.TenantId)
|
||||
}
|
||||
|
||||
// 构造消息
|
||||
userId := req.Platform + "_" + req.UserId // 添加平台前缀
|
||||
// 获取当前实例的动态响应队列名(自动生成,支持多实例部署)
|
||||
baseQueue := GetConfigString(ctx, "rabbitmq.responseQueue")
|
||||
replyQueue := rabbitmq.GetInstanceQueueName(baseQueue)
|
||||
msg := &redis.SendStreamMessage{
|
||||
UserId: userId,
|
||||
TenantId: tenantId,
|
||||
Content: req.Content,
|
||||
Timestamp: now.Timestamp(),
|
||||
MessageId: messageId,
|
||||
Platform: req.Platform,
|
||||
AccountId: req.AccountId,
|
||||
ReplyQueue: replyQueue,
|
||||
}
|
||||
|
||||
// 检查是否有 session 缓存,无缓存说明已归档,需要读取历史
|
||||
if sessionId, _ := redis.GetSessionCache(ctx, tenantId, userId); sessionId == "" {
|
||||
if history, histErr := dao.Conversation.GetRecentHistory(ctx, userId, redis.GetHistoryContextLimit()); histErr == nil && len(history) > 0 {
|
||||
msg.History = history
|
||||
glog.Infof(ctx, "用户已归档,读取 %d 轮历史对话 - 用户: %s", len(history), userId)
|
||||
}
|
||||
}
|
||||
|
||||
// 写入 Redis Stream
|
||||
streamMsgId, err := redis.AddToStream(ctx, redis.RAGFlowRequestStreamKey, msg)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err, "写入 Stream 失败")
|
||||
return
|
||||
}
|
||||
|
||||
glog.Infof(ctx, "消息已写入 Stream - MessageID: %s", streamMsgId)
|
||||
|
||||
res = &dto.WebhookReceiveRes{
|
||||
Success: true,
|
||||
MsgId: streamMsgId,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// GetHistory 查询用户对话记录
|
||||
func (s *webhookService) GetHistory(ctx context.Context, req *dto.ConversationListReq) (res *dto.ConversationListRes, err error) {
|
||||
list, err := dao.Conversation.FindByUserId(ctx, req.UserId, req.Limit)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err, "查询对话记录失败")
|
||||
return
|
||||
}
|
||||
|
||||
res = &dto.ConversationListRes{
|
||||
List: make([]*dto.ConversationItem, 0, len(list)),
|
||||
}
|
||||
|
||||
for _, item := range list {
|
||||
res.List = append(res.List, &dto.ConversationItem{
|
||||
Question: item.Question,
|
||||
Answer: item.Answer,
|
||||
MsgTime: gtime.New(item.MsgTime).Format("Y-m-d H:i:s"),
|
||||
SessionId: item.SessionId,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1,506 +0,0 @@
|
||||
// Package service - WebSocket服务
|
||||
// 功能:WebSocket连接管理、消息推送、心跳维护
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/dao"
|
||||
"customer-server/model/dto"
|
||||
"customer-server/model/entity"
|
||||
"customer-server/util"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
commonMongo "gitea.com/red-future/common/db/mongo"
|
||||
"gitea.com/red-future/common/jaeger"
|
||||
"gitea.com/red-future/common/rabbitmq"
|
||||
"gitea.com/red-future/common/redis"
|
||||
"gitea.com/red-future/common/utils"
|
||||
"github.com/gogf/gf/v2/container/gmap"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
"github.com/gogf/gf/v2/os/glog"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
"github.com/gorilla/websocket"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
// WebSocket 全局单例
|
||||
var WebSocket = &websocketService{
|
||||
connections: gmap.NewStrAnyMap(true),
|
||||
upgrader: websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true // 允许跨域
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// GoFrame 并发安全 Map
|
||||
type websocketService struct {
|
||||
connections *gmap.StrAnyMap
|
||||
upgrader websocket.Upgrader
|
||||
}
|
||||
|
||||
// key: userId_platform
|
||||
// wsConnection WebSocket 连接信息
|
||||
type wsConnection struct {
|
||||
UserId string
|
||||
Platform string
|
||||
TenantId string
|
||||
AccountName string // 客服账号ID
|
||||
Conn *websocket.Conn
|
||||
CreatedAt int64
|
||||
}
|
||||
|
||||
// 租户ID缓存 Key 前缀和过期时间
|
||||
const (
|
||||
tenantCacheKeyPrefix = "tenant:custsvc:"
|
||||
tenantCacheExpire = 300 // 5分钟
|
||||
)
|
||||
|
||||
// resolveTenantId 获取租户ID(兼容仅有accountName的场景)
|
||||
// 参数: ctx - 上下文,r - HTTP请求对象
|
||||
// 返回: tenantId - 租户ID,err - 错误信息
|
||||
// 功能: 优先从token获取,其次从客服账号查询,支持缓存
|
||||
func (s *websocketService) resolveTenantId(ctx context.Context, r *ghttp.Request) (tenantId string, err error) {
|
||||
// 1. 优先从 token 获取
|
||||
if user, userErr := util.GetTenantInfo(ctx); userErr == nil {
|
||||
if id := gconv.String(user.TenantId); id != "" {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
|
||||
if r == nil {
|
||||
return "", gerror.New("无法获取租户信息:缺少请求上下文")
|
||||
}
|
||||
|
||||
// 2. 从请求参数获取 accountName
|
||||
custId := r.Get("accountName").String()
|
||||
if custId == "" {
|
||||
custId = r.Get("account_name").String()
|
||||
}
|
||||
if custId == "" {
|
||||
return "", gerror.New("缺少 accountName 参数")
|
||||
}
|
||||
|
||||
// 2. 从 Redis 缓存查询
|
||||
cacheKey := tenantCacheKeyPrefix + custId
|
||||
if cached, _ := redis.RedisClient().Get(ctx, cacheKey); !cached.IsEmpty() {
|
||||
return cached.String(), nil
|
||||
}
|
||||
|
||||
// 3. Redis 未命中,查询 MongoDB
|
||||
coll := commonMongo.GetDB().Collection(entity.CustomerServiceAccountCollection)
|
||||
var doc struct {
|
||||
TenantId interface{} `bson:"tenantId"`
|
||||
}
|
||||
|
||||
filters := []bson.M{
|
||||
{"accountName": custId, "isDeleted": false},
|
||||
{"accountName": custId}, // 兼容旧数据未设置 isDeleted
|
||||
}
|
||||
|
||||
if objectId, objErr := bson.ObjectIDFromHex(custId); objErr == nil {
|
||||
filters = append(filters,
|
||||
bson.M{"_id": objectId, "isDeleted": false},
|
||||
bson.M{"_id": objectId},
|
||||
)
|
||||
}
|
||||
|
||||
for _, filter := range filters {
|
||||
if err = coll.FindOne(ctx, filter).Decode(&doc); err == nil {
|
||||
tenantId = gconv.String(doc.TenantId)
|
||||
if tenantId == "" {
|
||||
return "", gerror.Newf("客服账号 %s 未配置 tenantId", custId)
|
||||
}
|
||||
// 4. 写入 Redis 缓存
|
||||
redis.RedisClient().SetEX(ctx, cacheKey, tenantId, tenantCacheExpire)
|
||||
return
|
||||
}
|
||||
if !errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return "", gerror.Newf("客服账号 %s 不存在", custId)
|
||||
}
|
||||
|
||||
// Connect 建立 WebSocket 连接
|
||||
func (s *websocketService) Connect(ctx context.Context, r *ghttp.Request, userId, platform string) error {
|
||||
// 使用原生upgrader升级WebSocket连接
|
||||
ws, err := s.upgrader.Upgrade(r.Response.Writer, r.Request, nil)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err, "WebSocket 升级失败")
|
||||
return err
|
||||
}
|
||||
defer ws.Close()
|
||||
|
||||
tenantId, err := s.resolveTenantId(ctx, r)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err, "获取租户ID失败")
|
||||
return err
|
||||
}
|
||||
|
||||
// 读取accountName参数(客服账号名称)
|
||||
accountName := r.Get("accountName").String()
|
||||
if accountName == "" {
|
||||
accountName = r.Get("account_name").String()
|
||||
}
|
||||
|
||||
glog.Infof(ctx, "WebSocket 连接建立 - 用户: %s, 平台: %s, 租户: %s, 客服账号: %s", userId, platform, tenantId, accountName)
|
||||
|
||||
// key格式: tenantId:userId_platform (确保租户隔离)
|
||||
key := tenantId + ":" + userId + "_" + platform
|
||||
|
||||
// 关闭旧连接
|
||||
if old := s.connections.Get(key); old != nil {
|
||||
old.(*wsConnection).Conn.Close()
|
||||
}
|
||||
|
||||
// 注册新连接(携带 TenantId 和 AccountName)
|
||||
s.connections.Set(key, &wsConnection{
|
||||
UserId: userId,
|
||||
Platform: platform,
|
||||
TenantId: tenantId,
|
||||
AccountName: accountName,
|
||||
Conn: ws,
|
||||
CreatedAt: gtime.Now().Timestamp(),
|
||||
})
|
||||
|
||||
// 发送开场白(连接建立后立即推送)
|
||||
if accountName != "" {
|
||||
greeting := s.getGreeting(ctx, accountName, tenantId)
|
||||
if greeting != "" {
|
||||
s.writeJSON(ws, &dto.WebSocketPushMsg{
|
||||
Type: "message",
|
||||
Message: greeting,
|
||||
})
|
||||
glog.Infof(ctx, "已发送开场白 - 用户: %s, 客服账号: %s, 长度: %d", userId, accountName, len(greeting))
|
||||
} else {
|
||||
glog.Warningf(ctx, "客服账号未配置开场白 - accountName: %s, tenantId: %s", accountName, tenantId)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理消息(阻塞)
|
||||
s.handleConnection(ctx, key, ws)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleConnection 处理 WebSocket 连接
|
||||
func (s *websocketService) handleConnection(ctx context.Context, key string, conn *websocket.Conn) {
|
||||
defer func() {
|
||||
s.connections.Remove(key)
|
||||
conn.Close()
|
||||
glog.Infof(ctx, "WebSocket 连接断开 - %s", key)
|
||||
}()
|
||||
|
||||
for {
|
||||
msgType, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
// 排除正常关闭情况:正常关闭、离开页面、无状态码关闭
|
||||
if websocket.IsUnexpectedCloseError(err,
|
||||
websocket.CloseNormalClosure,
|
||||
websocket.CloseGoingAway,
|
||||
websocket.CloseNoStatusReceived,
|
||||
) {
|
||||
jaeger.RecordError(ctx, err, "WebSocket 读取错误")
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if msgType != websocket.TextMessage {
|
||||
continue
|
||||
}
|
||||
|
||||
content := gconv.String(message)
|
||||
glog.Infof(ctx, "收到 WebSocket 消息 - %s: %s", key, content)
|
||||
|
||||
// 解析 userId
|
||||
connInfo := s.connections.Get(key)
|
||||
if connInfo == nil {
|
||||
break
|
||||
}
|
||||
wsConn := connInfo.(*wsConnection)
|
||||
|
||||
// 先检查对话轮数,>5 则只发卡片,跳过话术
|
||||
// checkCardBeforeProcess 已推送卡片消息,无需ack
|
||||
if handled, err := checkCardBeforeProcess(ctx, wsConn.TenantId, wsConn.UserId, wsConn.Platform); err != nil {
|
||||
jaeger.RecordError(ctx, err, "卡片检查失败")
|
||||
} else if handled {
|
||||
continue
|
||||
}
|
||||
|
||||
// 话术匹配并发布响应
|
||||
// status 暂时为空,表示任意行为匹配
|
||||
// isPushed=true表示已直接推送响应(话术匹配),无需ack
|
||||
// isPushed=false表示转发到RAGFlow,需要ack告知用户正在处理
|
||||
|
||||
// 创建带有accountName的context,供GetTenantInfo使用
|
||||
newCtx := ctx
|
||||
if wsConn.AccountName != "" {
|
||||
newCtx = context.WithValue(ctx, "accountName", wsConn.AccountName)
|
||||
}
|
||||
|
||||
isPushed, err := Speechcraft.ProcessAndPublish(newCtx, wsConn.UserId, wsConn.Platform, wsConn.TenantId, content, "", wsConn.AccountName)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err, "话术处理失败")
|
||||
s.writeJSON(conn, &dto.WebSocketPushMsg{Type: "error", Message: "消息处理失败"})
|
||||
continue
|
||||
}
|
||||
|
||||
// 只在转发到RAGFlow时发送ack(Go直接返回的不需要ack)
|
||||
if !isPushed {
|
||||
s.writeJSON(conn, &dto.WebSocketPushMsg{Type: "ack", Message: "消息已接收,正在处理..."})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writeJSON 发送 JSON 消息
|
||||
func (s *websocketService) writeJSON(conn *websocket.Conn, data interface{}) {
|
||||
jsonBytes, _ := gjson.Encode(data)
|
||||
conn.WriteMessage(websocket.TextMessage, jsonBytes)
|
||||
}
|
||||
|
||||
// getGreeting 获取客服账号的开场白
|
||||
func (s *websocketService) getGreeting(ctx context.Context, accountName, tenantId string) string {
|
||||
glog.Infof(ctx, "查询开场白 - accountName: %s, tenantId: %s", accountName, tenantId)
|
||||
|
||||
// 复用dao层方法,保持查询逻辑一致
|
||||
account, err := dao.CustomerServiceAccount.FindByAccountName(ctx, accountName)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err, "查询客服账号开场白失败")
|
||||
glog.Errorf(ctx, "查询开场白失败: %v", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
if account == nil {
|
||||
glog.Warningf(ctx, "客服账号不存在: accountName=%s", accountName)
|
||||
return ""
|
||||
}
|
||||
|
||||
// 详细输出查询结果
|
||||
glog.Infof(ctx, "查询到客服账号: Id=%s, AccountName=%s, TenantId=%v, Greeting长度=%d, Platform=%s",
|
||||
account.Id.Hex(), account.AccountName, account.TenantId, len(account.Greeting), account.Platform)
|
||||
|
||||
return account.Greeting
|
||||
}
|
||||
|
||||
// Send 发送消息到 Redis Stream(HTTP 接口)
|
||||
func (s *websocketService) Send(ctx context.Context, req *dto.WebSocketSendReq) (*dto.WebSocketSendRes, error) {
|
||||
// 从 token 获取租户ID
|
||||
var tenantId string
|
||||
if user, err := utils.GetUserInfo(ctx); err == nil {
|
||||
tenantId = gconv.String(user.TenantId)
|
||||
}
|
||||
|
||||
messageId, err := s.sendToStream(ctx, req.UserId, tenantId, req.Content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.WebSocketSendRes{MessageId: messageId}, nil
|
||||
}
|
||||
|
||||
// sendToStream 发送消息到 Redis Stream
|
||||
// 如果用户无 session 缓存(已归档),则从 MongoDB 读取历史对话一起发送
|
||||
func (s *websocketService) sendToStream(ctx context.Context, userId, tenantId, content string) (string, error) {
|
||||
now := gtime.Now()
|
||||
platform := "xiaohongshu" // 默认平台
|
||||
|
||||
// 获取当前实例的动态响应队列名(自动生成,支持多实例部署)
|
||||
baseQueue := GetConfigString(ctx, "rabbitmq.responseQueue")
|
||||
replyQueue := rabbitmq.GetInstanceQueueName(baseQueue)
|
||||
|
||||
// 获取accountName(优先使用用户选择的方向映射)
|
||||
var accountName string
|
||||
var chatId string
|
||||
|
||||
// 1. 尝试从用户状态获取(用户选择方向后的映射)
|
||||
if userState, stateErr := redis.GetUserState(ctx, userId, platform); stateErr == nil && userState.AccountName != "" {
|
||||
accountName = userState.AccountName
|
||||
glog.Infof(ctx, "使用用户选择方向的客服账号: %s", accountName)
|
||||
} else {
|
||||
// 2. 从连接信息获取(默认)
|
||||
key := tenantId + ":" + userId + "_" + platform
|
||||
connInfo := s.connections.Get(key)
|
||||
if connInfo != nil {
|
||||
wsConn := connInfo.(*wsConnection)
|
||||
accountName = wsConn.AccountName
|
||||
glog.Infof(ctx, "使用连接默认客服账号: %s", accountName)
|
||||
}
|
||||
}
|
||||
|
||||
// 根据accountName查询ragflow_config获取chat_id
|
||||
if accountName != "" {
|
||||
config, err := dao.RAGFlowConfig.FindByAccountName(ctx, accountName)
|
||||
if err == nil && config != nil {
|
||||
chatId = config.ChatId
|
||||
glog.Infof(ctx, "查询到chatId: accountName=%s, chatId=%s", accountName, chatId)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果未找到chatId,报错(应该从ragflowconfig表重建session)
|
||||
if chatId == "" {
|
||||
return "", gerror.New("chatId未找到,需要重建RAGFlow session")
|
||||
}
|
||||
|
||||
msg := &redis.SendStreamMessage{
|
||||
UserId: userId,
|
||||
TenantId: tenantId,
|
||||
AccountName: accountName,
|
||||
ChatId: chatId,
|
||||
Content: content,
|
||||
Timestamp: now.Timestamp(),
|
||||
MessageId: userId + "_" + gconv.String(now.TimestampNano()),
|
||||
ReplyQueue: replyQueue,
|
||||
}
|
||||
|
||||
// 检查是否有 session 缓存,无缓存说明已归档,需要读取历史
|
||||
if sessionId, _ := redis.GetSessionCache(ctx, tenantId, userId); sessionId == "" {
|
||||
// 从 MongoDB 读取历史对话
|
||||
if history, err := dao.Conversation.GetRecentHistory(ctx, userId, redis.GetHistoryContextLimit()); err == nil && len(history) > 0 {
|
||||
msg.History = history
|
||||
glog.Infof(ctx, "用户已归档,读取 %d 轮历史对话 - 用户: %s", len(history), userId)
|
||||
}
|
||||
}
|
||||
|
||||
messageId, err := redis.AddToStream(ctx, redis.RAGFlowRequestStreamKey, msg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
glog.Infof(ctx, "消息已发送到 Stream - MessageID: %s, 用户: %s", messageId, userId)
|
||||
return messageId, nil
|
||||
}
|
||||
|
||||
// SendToUser 发送消息给指定用户
|
||||
func (s *websocketService) SendToUser(ctx context.Context, tenantId, userId, platform string, data interface{}) error {
|
||||
// key格式: tenantId:userId_platform
|
||||
key := tenantId + ":" + userId + "_" + platform
|
||||
connInfo := s.connections.Get(key)
|
||||
if connInfo == nil {
|
||||
glog.Warningf(ctx, "用户不在线 - %s", key)
|
||||
return nil
|
||||
}
|
||||
|
||||
s.writeJSON(connInfo.(*wsConnection).Conn, data)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Broadcast 广播消息给所有连接
|
||||
func (s *websocketService) Broadcast(ctx context.Context, content string) {
|
||||
msg := &dto.WebSocketPushMsg{Type: "broadcast", Content: content}
|
||||
msgBytes := gjson.MustEncode(msg)
|
||||
|
||||
s.connections.Iterator(func(key string, value interface{}) bool {
|
||||
conn := value.(*wsConnection)
|
||||
if err := conn.Conn.WriteMessage(websocket.TextMessage, msgBytes); err != nil {
|
||||
jaeger.RecordError(ctx, err, "广播消息失败 - "+key)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// GetOnlineUsers 获取在线用户列表
|
||||
func (s *websocketService) GetOnlineUsers() *dto.WebSocketOnlineRes {
|
||||
users := make([]dto.WebSocketOnlineUserRes, 0, s.connections.Size())
|
||||
|
||||
s.connections.Iterator(func(_ string, value interface{}) bool {
|
||||
conn := value.(*wsConnection)
|
||||
users = append(users, dto.WebSocketOnlineUserRes{
|
||||
UserId: conn.UserId,
|
||||
Platform: conn.Platform,
|
||||
CreatedAt: conn.CreatedAt,
|
||||
})
|
||||
return true
|
||||
})
|
||||
|
||||
return &dto.WebSocketOnlineRes{
|
||||
Count: len(users),
|
||||
Users: users,
|
||||
}
|
||||
}
|
||||
|
||||
// PushRAGFlowResponse 推送 RAGFlow 响应给用户
|
||||
func (s *websocketService) PushRAGFlowResponse(ctx context.Context, tenantId, userId, platform, content string) error {
|
||||
return s.SendToUser(ctx, tenantId, userId, platform, &dto.WebSocketPushMsg{Type: "answer", Content: content})
|
||||
}
|
||||
|
||||
// ============== RabbitMQ 消费者(追问延时消息)==============
|
||||
|
||||
// FollowUpConsumer 追问消费者
|
||||
type FollowUpConsumer struct {
|
||||
consumer *rabbitmq.Consumer
|
||||
}
|
||||
|
||||
// NewFollowUpConsumer 创建追问消费者
|
||||
func NewFollowUpConsumer(ctx context.Context) *FollowUpConsumer {
|
||||
queueName := GetConfigString(ctx, "followUp.queue")
|
||||
return &FollowUpConsumer{
|
||||
consumer: rabbitmq.NewConsumer(queueName, handleFollowUp),
|
||||
}
|
||||
}
|
||||
|
||||
// Start 启动消费者
|
||||
func (c *FollowUpConsumer) Start(ctx context.Context) (err error) {
|
||||
glog.Info(ctx, "追问消费者启动...")
|
||||
return c.consumer.Start(ctx)
|
||||
}
|
||||
|
||||
// Stop 停止消费者
|
||||
func (c *FollowUpConsumer) Stop(ctx context.Context) {
|
||||
c.consumer.Stop(ctx)
|
||||
}
|
||||
|
||||
// handleFollowUp 处理追问消息
|
||||
func handleFollowUp(ctx context.Context, body []byte) (err error) {
|
||||
ctx, span := jaeger.NewSpan(ctx, "consumer.followup")
|
||||
defer span.End()
|
||||
|
||||
var msg redis.FollowUpMessage
|
||||
if err = gjson.DecodeTo(body, &msg); err != nil {
|
||||
jaeger.RecordError(ctx, err, "解析追问消息失败")
|
||||
return
|
||||
}
|
||||
|
||||
glog.Infof(ctx, "收到追问消息 - 用户: %s, 类型: %d", msg.UserId, msg.FollowUpType)
|
||||
|
||||
// 检查用户状态,如果在状态5(方向选择)或状态3(发卡片),跳过追问
|
||||
userState, err := redis.GetUserState(ctx, msg.UserId, msg.Platform)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err, "获取用户状态失败")
|
||||
return
|
||||
}
|
||||
|
||||
if userState.Stage == 5 {
|
||||
glog.Infof(ctx, "用户 %s 在方向选择状态,跳过追问", msg.UserId)
|
||||
return
|
||||
}
|
||||
|
||||
if userState.Stage == 3 {
|
||||
glog.Infof(ctx, "用户 %s 在发卡片状态,跳过追问", msg.UserId)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查用户是否在追问发送后有活跃
|
||||
isActive, err := redis.IsUserActive(ctx, msg.UserId, int64(redis.GetFollowUpDelay(msg.FollowUpType)))
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err, "检查用户活跃状态失败")
|
||||
return
|
||||
}
|
||||
|
||||
if isActive {
|
||||
glog.Infof(ctx, "用户 %s 在追问期间有活跃,跳过追问", msg.UserId)
|
||||
return
|
||||
}
|
||||
|
||||
// 发送追问消息给用户
|
||||
if err = WebSocket.PushRAGFlowResponse(ctx, msg.TenantId, msg.UserId, msg.Platform, msg.Content); err != nil {
|
||||
jaeger.RecordError(ctx, err, "推送追问消息失败")
|
||||
}
|
||||
|
||||
glog.Infof(ctx, "追问消息已发送 - 租户: %s, 用户: %s, 内容: %s", msg.TenantId, msg.UserId, msg.Content)
|
||||
return
|
||||
}
|
||||
@@ -1,459 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"customer-server/dao"
|
||||
"customer-server/model/dto"
|
||||
"customer-server/model/entity"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
commonMongo "gitea.com/red-future/common/db/mongo"
|
||||
"gitea.com/red-future/common/jaeger"
|
||||
"gitea.com/red-future/common/rabbitmq"
|
||||
"gitea.com/red-future/common/redis"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
var Xiaohongshu = new(xiaohongshu)
|
||||
|
||||
type xiaohongshu struct{}
|
||||
|
||||
const (
|
||||
XhsApiBaseUrl = "https://adapi.xiaohongshu.com"
|
||||
XhsPlatformName = "xiaohongshu"
|
||||
XhsEncryptSplit = "~split~"
|
||||
)
|
||||
|
||||
// ==================== 加解密工具 ====================
|
||||
|
||||
// Encrypt AES加密
|
||||
// 参数: ctx - 上下文,content - 待加密内容,secretKey - 密钥(Base64编码)
|
||||
// 返回: res - 加密后的字符串(Base64编码),err - 错误信息
|
||||
// 功能: 使用AES-CBC模式加密内容,用于小红书API签名
|
||||
func (s *xiaohongshu) Encrypt(ctx context.Context, content, secretKey string) (res string, err error) {
|
||||
keyBytes, err := base64.StdEncoding.DecodeString(secretKey)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(keyBytes)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
iv := make([]byte, aes.BlockSize)
|
||||
if _, err = io.ReadFull(rand.Reader, iv); err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
stream := cipher.NewCBCEncrypter(block, iv)
|
||||
contentBytes := []byte(content)
|
||||
paddedContent := pkcs5Padding(contentBytes, aes.BlockSize)
|
||||
cipherText := make([]byte, len(paddedContent))
|
||||
stream.CryptBlocks(cipherText, paddedContent)
|
||||
|
||||
ivBase64 := base64.StdEncoding.EncodeToString(iv)
|
||||
cipherBase64 := base64.StdEncoding.EncodeToString(cipherText)
|
||||
res = fmt.Sprintf("%s%s%s", ivBase64, XhsEncryptSplit, cipherBase64)
|
||||
return
|
||||
}
|
||||
|
||||
func (s *xiaohongshu) Decrypt(ctx context.Context, cipherText, secretKey string) (res string, err error) {
|
||||
keyBytes, err := base64.StdEncoding.DecodeString(secretKey)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.Split(cipherText, XhsEncryptSplit)
|
||||
if len(parts) != 2 {
|
||||
err = errors.New("invalid cipher text format")
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
iv, err := base64.StdEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
encrypted, err := base64.StdEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(keyBytes)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(encrypted)%aes.BlockSize != 0 {
|
||||
err = errors.New("cipher text is not a multiple of block size")
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
stream := cipher.NewCBCDecrypter(block, iv)
|
||||
decrypted := make([]byte, len(encrypted))
|
||||
stream.CryptBlocks(decrypted, encrypted)
|
||||
|
||||
decrypted, err = pkcs5Unpadding(decrypted)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
res = string(decrypted)
|
||||
return
|
||||
}
|
||||
|
||||
func pkcs5Padding(data []byte, blockSize int) []byte {
|
||||
padding := blockSize - len(data)%blockSize
|
||||
padText := make([]byte, padding)
|
||||
for i := range padText {
|
||||
padText[i] = byte(padding)
|
||||
}
|
||||
return append(data, padText...)
|
||||
}
|
||||
|
||||
func pkcs5Unpadding(data []byte) (res []byte, err error) {
|
||||
length := len(data)
|
||||
if length == 0 {
|
||||
err = errors.New("invalid padding size")
|
||||
return
|
||||
}
|
||||
padding := int(data[length-1])
|
||||
if padding > length {
|
||||
err = errors.New("invalid padding size")
|
||||
return
|
||||
}
|
||||
res = data[:length-padding]
|
||||
return
|
||||
}
|
||||
|
||||
// ==================== 账号绑定管理 ====================
|
||||
|
||||
func (s *xiaohongshu) HandleBindAccount(ctx context.Context, req *dto.XhsBindAccountReq) (err error) {
|
||||
var account entity.CustomerServiceAccount
|
||||
filter := bson.M{"platform": XhsPlatformName, "isDeleted": false}
|
||||
if err = dao.MongoDAO.FindOne(ctx, filter, &account, entity.CustomerServiceAccountCollection); err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
decrypted, err := s.Decrypt(ctx, req.Content, account.SecretKey)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
var bindData dto.XhsBindAccountDecrypted
|
||||
if err = json.Unmarshal([]byte(decrypted), &bindData); err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
update := bson.M{
|
||||
"$set": bson.M{
|
||||
"accessToken": bindData.Token,
|
||||
"appId": bindData.AppId,
|
||||
"xhsUserId": bindData.UserId,
|
||||
"updatedAt": gtime.Now().Time,
|
||||
},
|
||||
}
|
||||
filter = bson.M{"_id": account.Id}
|
||||
_, err = commonMongo.GetDB().Collection(entity.CustomerServiceAccountCollection).UpdateOne(ctx, filter, update)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "[小红书] 绑定账户成功: userId=%s, nickName=%s", bindData.UserId, bindData.NickName)
|
||||
return
|
||||
}
|
||||
|
||||
func (s *xiaohongshu) HandleUnbindAccount(ctx context.Context, req *dto.XhsUnbindAccountReq) (err error) {
|
||||
var account entity.CustomerServiceAccount
|
||||
filter := bson.M{"platform": XhsPlatformName, "isDeleted": false}
|
||||
if err = dao.MongoDAO.FindOne(ctx, filter, &account, entity.CustomerServiceAccountCollection); err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
decrypted, err := s.Decrypt(ctx, req.Content, account.SecretKey)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
var unbindData dto.XhsUnbindAccountDecrypted
|
||||
if err = json.Unmarshal([]byte(decrypted), &unbindData); err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
update := bson.M{
|
||||
"$set": bson.M{
|
||||
"accessToken": "",
|
||||
"xhsUserId": "",
|
||||
"updatedAt": gtime.Now().Time,
|
||||
},
|
||||
}
|
||||
filter = bson.M{"_id": account.Id}
|
||||
_, err = commonMongo.GetDB().Collection(entity.CustomerServiceAccountCollection).UpdateOne(ctx, filter, update)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "[小红书] 解绑账户成功: userId=%s", unbindData.UserId)
|
||||
return
|
||||
}
|
||||
|
||||
// ==================== 消息收发 ====================
|
||||
|
||||
func (s *xiaohongshu) HandleReceiveMessage(ctx context.Context, req *dto.XhsReceiveMessageReq) (err error) {
|
||||
accountId, err := s.getAccountIdByPlatform(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var account entity.CustomerServiceAccount
|
||||
filter := bson.M{"_id": accountId, "isDeleted": false}
|
||||
if err = dao.MongoDAO.FindOne(ctx, filter, &account, entity.CustomerServiceAccountCollection); err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
decrypted, err := s.Decrypt(ctx, req.Content, account.SecretKey)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
var conversation entity.Conversation
|
||||
id := bson.NewObjectID()
|
||||
conversation.Id = &id // 取地址赋值给指针类型
|
||||
conversation.SessionId = fmt.Sprintf("%s_%s", req.FromUserId, XhsPlatformName)
|
||||
conversation.UserId = req.FromUserId
|
||||
conversation.CustomerServiceId = accountId.Hex()
|
||||
conversation.Role = "user"
|
||||
conversation.Platform = XhsPlatformName
|
||||
conversation.MessageId = req.MessageId
|
||||
conversation.MessageType = req.MessageType
|
||||
now := gtime.Now().Time
|
||||
conversation.CreatedAt = &now // 取地址赋值给指针类型
|
||||
|
||||
switch req.MessageType {
|
||||
case "TEXT":
|
||||
var textContent dto.XhsTextContent
|
||||
if err = json.Unmarshal([]byte(decrypted), &textContent); err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
conversation.Content = textContent.Text
|
||||
case "IMAGE":
|
||||
var imgContent dto.XhsImageContent
|
||||
if err = json.Unmarshal([]byte(decrypted), &imgContent); err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
conversation.Content = fmt.Sprintf("[图片]%s", imgContent.Link)
|
||||
case "VIDEO":
|
||||
var videoContent dto.XhsVideoContent
|
||||
if err = json.Unmarshal([]byte(decrypted), &videoContent); err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
conversation.Content = fmt.Sprintf("[视频]%s", videoContent.Link)
|
||||
case "CARD":
|
||||
var cardContent dto.XhsCardContent
|
||||
if err = json.Unmarshal([]byte(decrypted), &cardContent); err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
conversation.Content = fmt.Sprintf("[卡片-%s]%s", cardContent.ContentType, cardContent.Id)
|
||||
case "REVOKE":
|
||||
var revokeContent dto.XhsRevokeContent
|
||||
if err = json.Unmarshal([]byte(decrypted), &revokeContent); err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
conversation.Content = fmt.Sprintf("[撤回消息]%s", revokeContent.MessageId)
|
||||
case "HINT":
|
||||
conversation.Content = "[系统提示消息]"
|
||||
case "SMILES":
|
||||
conversation.Content = "[表情消息]"
|
||||
default:
|
||||
conversation.Content = fmt.Sprintf("[%s类型消息]", req.MessageType)
|
||||
}
|
||||
|
||||
_, err = commonMongo.GetDB().Collection(entity.ConversationCollection).InsertOne(ctx, conversation)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "[小红书] 接收消息成功: sessionId=%s, messageType=%s", conversation.SessionId, req.MessageType)
|
||||
|
||||
if req.MessageType == "TEXT" {
|
||||
asyncCtx := context.WithoutCancel(ctx)
|
||||
go s.processUserMessage(asyncCtx, &account, &conversation)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *xiaohongshu) SendMessage(ctx context.Context, account *entity.CustomerServiceAccount, toUserId, content string) (err error) {
|
||||
textContent := dto.XhsTextContent{Text: content}
|
||||
contentJson, err := json.Marshal(textContent)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
encrypted, err := s.Encrypt(ctx, string(contentJson), account.SecretKey)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
now := gtime.Now()
|
||||
sendReq := dto.XhsSendMessageReq{
|
||||
UserId: account.XhsUserId,
|
||||
RequestId: fmt.Sprintf("%d", now.UnixNano()),
|
||||
MessageType: "TEXT",
|
||||
FromUserId: account.XhsUserId,
|
||||
ToUserId: toUserId,
|
||||
ThirdAccountId: account.Id.Hex(),
|
||||
Timestamp: now.UnixMilli(),
|
||||
Content: encrypted,
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/api/open/im/third/send", XhsApiBaseUrl)
|
||||
client := g.Client()
|
||||
client.SetHeader("Access-Token", account.AccessToken)
|
||||
client.SetHeader("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Post(ctx, url, sendReq)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
defer resp.Close()
|
||||
|
||||
var sendRes dto.XhsSendMessageRes
|
||||
if err = json.Unmarshal(resp.ReadAll(), &sendRes); err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
if sendRes.Code != 0 {
|
||||
err = fmt.Errorf("发送消息失败: code=%d, msg=%s", sendRes.Code, sendRes.Msg)
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
var conversation entity.Conversation
|
||||
id2 := bson.NewObjectID()
|
||||
conversation.Id = &id2 // 取地址赋值给指针类型
|
||||
conversation.SessionId = fmt.Sprintf("%s_%s", toUserId, XhsPlatformName)
|
||||
conversation.UserId = toUserId
|
||||
conversation.CustomerServiceId = account.Id.Hex()
|
||||
conversation.Role = "assistant"
|
||||
conversation.Platform = XhsPlatformName
|
||||
conversation.MessageId = sendRes.Data.MessageId
|
||||
conversation.MessageType = "TEXT"
|
||||
conversation.Content = content
|
||||
now2 := gtime.Now().Time
|
||||
conversation.CreatedAt = &now2 // 取地址赋值给指针类型
|
||||
|
||||
_, err = commonMongo.GetDB().Collection(entity.ConversationCollection).InsertOne(ctx, conversation)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "[小红书] 发送消息成功: toUserId=%s, messageId=%s", toUserId, sendRes.Data.MessageId)
|
||||
return
|
||||
}
|
||||
|
||||
func (s *xiaohongshu) GenerateSignature(ctx context.Context, secretKey, requestBody string) (res string) {
|
||||
h := sha256.New()
|
||||
h.Write([]byte(secretKey + requestBody))
|
||||
res = hex.EncodeToString(h.Sum(nil))
|
||||
return
|
||||
}
|
||||
|
||||
// ==================== 私有方法 ====================
|
||||
|
||||
func (s *xiaohongshu) getAccountIdByPlatform(ctx context.Context) (res bson.ObjectID, err error) {
|
||||
var account entity.CustomerServiceAccount
|
||||
filter := bson.M{"platform": XhsPlatformName, "isDeleted": false}
|
||||
if err = dao.MongoDAO.FindOne(ctx, filter, &account, entity.CustomerServiceAccountCollection); err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
res = *account.Id // 解引用指针类型
|
||||
return
|
||||
}
|
||||
|
||||
func (s *xiaohongshu) processUserMessage(ctx context.Context, account *entity.CustomerServiceAccount, conversation *entity.Conversation) {
|
||||
if err := s.sendToRAGFlowStream(ctx, account, conversation); err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
g.Log().Errorf(ctx, "[小红书] 发送到RAGFlow Stream失败: %v", err)
|
||||
return
|
||||
}
|
||||
g.Log().Infof(ctx, "[小红书] 消息已发送到RAGFlow Stream: userId=%s", conversation.UserId)
|
||||
}
|
||||
|
||||
func (s *xiaohongshu) sendToRAGFlowStream(ctx context.Context, account *entity.CustomerServiceAccount, conversation *entity.Conversation) (err error) {
|
||||
baseQueue := GetConfigString(ctx, "rabbitmq.responseQueue")
|
||||
replyQueue := rabbitmq.GetInstanceQueueName(baseQueue)
|
||||
|
||||
msg := &redis.SendStreamMessage{
|
||||
UserId: fmt.Sprintf("%s_%s", XhsPlatformName, conversation.UserId),
|
||||
TenantId: gconv.String(account.TenantId),
|
||||
Content: conversation.Content,
|
||||
Timestamp: gtime.New(conversation.CreatedAt).Timestamp(),
|
||||
MessageId: conversation.MessageId,
|
||||
Platform: XhsPlatformName,
|
||||
AccountId: account.Id.Hex(),
|
||||
AccountName: account.AccountName,
|
||||
ReplyQueue: replyQueue,
|
||||
}
|
||||
|
||||
if sessionId, _ := redis.GetSessionCache(ctx, gconv.String(account.TenantId), msg.UserId); sessionId == "" {
|
||||
if history, histErr := dao.Conversation.GetRecentHistory(ctx, msg.UserId, redis.GetHistoryContextLimit()); histErr == nil && len(history) > 0 {
|
||||
msg.History = history
|
||||
g.Log().Infof(ctx, "[小红书] 用户已归档,读取 %d 轮历史对话", len(history))
|
||||
}
|
||||
}
|
||||
|
||||
streamMsgId, err := redis.AddToStream(ctx, redis.RAGFlowRequestStreamKey, msg)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "[小红书] 消息已写入Stream: streamMsgId=%s, sessionId=%s", streamMsgId, conversation.SessionId)
|
||||
return
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gcfg"
|
||||
"github.com/gogf/gf/v2/os/gctx"
|
||||
)
|
||||
|
||||
type Dataset struct {
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type ListDatasetsRes struct {
|
||||
Code int `json:"code"`
|
||||
Data []Dataset `json:"data"`
|
||||
}
|
||||
|
||||
type DeleteReq struct {
|
||||
Ids []string `json:"ids"`
|
||||
}
|
||||
|
||||
type CommonResponse struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
ctx := gctx.New()
|
||||
|
||||
fmt.Println("🚀 RAGFlow知识库清理工具(简化版)")
|
||||
fmt.Println("=" + strings.Repeat("=", 50))
|
||||
|
||||
// 设置配置文件路径
|
||||
g.Cfg().GetAdapter().(*gcfg.AdapterFile).SetPath("../")
|
||||
|
||||
// 读取配置
|
||||
baseURL := g.Cfg().MustGet(ctx, "ragflow.base_url").String()
|
||||
apiKey := g.Cfg().MustGet(ctx, "ragflow.api_key").String()
|
||||
|
||||
if baseURL == "" || apiKey == "" {
|
||||
fmt.Println("❌ RAGFlow配置缺失,请检查../config.yaml")
|
||||
fmt.Println(" 需要配置: ragflow.base_url 和 ragflow.api_key")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
baseURL = strings.TrimSuffix(baseURL, "/")
|
||||
fmt.Printf("📡 连接到RAGFlow: %s\n", baseURL)
|
||||
|
||||
// 创建HTTP客户端
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
// 1. 列出所有知识库
|
||||
listURL := baseURL + "/api/v1/datasets?page=1&page_size=1000"
|
||||
req, err := http.NewRequest("GET", listURL, nil)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ 创建请求失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ 查询知识库列表失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var listRes ListDatasetsRes
|
||||
if err := json.Unmarshal(body, &listRes); err != nil {
|
||||
fmt.Printf("❌ 解析响应失败: %v\n", err)
|
||||
fmt.Printf("响应内容: %s\n", string(body))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if len(listRes.Data) == 0 {
|
||||
fmt.Println("✅ 没有找到任何知识库,无需清理")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// 显示知识库列表
|
||||
fmt.Printf("\n📚 发现 %d 个知识库:\n", len(listRes.Data))
|
||||
for i, dataset := range listRes.Data {
|
||||
fmt.Printf(" %d. ID: %s, Name: %s\n", i+1, dataset.Id, dataset.Name)
|
||||
}
|
||||
|
||||
// 二次确认
|
||||
fmt.Printf("\n⚠️ 警告:即将删除所有 %d 个知识库!\n", len(listRes.Data))
|
||||
fmt.Print("请输入 'YES' 确认删除: ")
|
||||
|
||||
var confirm string
|
||||
fmt.Scanln(&confirm)
|
||||
|
||||
if confirm != "YES" {
|
||||
fmt.Println("❌ 取消删除操作")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// 2. 批量删除
|
||||
var datasetIds []string
|
||||
for _, dataset := range listRes.Data {
|
||||
datasetIds = append(datasetIds, dataset.Id)
|
||||
}
|
||||
|
||||
deleteReq := DeleteReq{Ids: datasetIds}
|
||||
reqBody, _ := json.Marshal(deleteReq)
|
||||
|
||||
deleteURL := baseURL + "/api/v1/datasets"
|
||||
delReq, err := http.NewRequest("DELETE", deleteURL, bytes.NewBuffer(reqBody))
|
||||
if err != nil {
|
||||
fmt.Printf("❌ 创建删除请求失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
delReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
delReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
fmt.Println("\n🗑️ 开始删除知识库...")
|
||||
delResp, err := client.Do(delReq)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ 删除失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer delResp.Body.Close()
|
||||
|
||||
delBody, _ := io.ReadAll(delResp.Body)
|
||||
var commonRes CommonResponse
|
||||
if err := json.Unmarshal(delBody, &commonRes); err != nil {
|
||||
fmt.Printf("❌ 解析删除响应失败: %v\n", err)
|
||||
fmt.Printf("响应内容: %s\n", string(delBody))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if commonRes.Code != 0 {
|
||||
fmt.Printf("❌ 删除失败: %s\n", commonRes.Message)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("✅ 成功删除 %d 个知识库!\n", len(datasetIds))
|
||||
fmt.Println("=" + strings.Repeat("=", 50))
|
||||
}
|
||||
+138
-1
@@ -1 +1,138 @@
|
||||
-----------张斌2025-06-16 15:00:00--------------
|
||||
-----------张斌2025-06-16 15:00:00--------------
|
||||
|
||||
--------------------pgsql创建customer_server_account表语句---------------------------
|
||||
-- 客服账号表(RAG智能客服)
|
||||
CREATE TABLE IF NOT EXISTS customer_server_account (
|
||||
-- 基础字段(完全对齐项目规范)
|
||||
id BIGINT PRIMARY KEY, -- 主键ID(非自增)
|
||||
tenant_id BIGINT NOT NULL DEFAULT 0, -- 租户ID int8
|
||||
creator VARCHAR(64) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updater VARCHAR(64) NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted_at timestamp(6),
|
||||
|
||||
-- 业务字段
|
||||
account_code VARCHAR(128) NOT NULL, -- 客服账号编码
|
||||
account_name VARCHAR(128) NOT NULL, -- 客服账号名称
|
||||
status SMALLINT NOT NULL DEFAULT 1, -- 状态:1启用/0停用
|
||||
platform VARCHAR(32) NOT NULL, -- 客服平台
|
||||
greeting TEXT DEFAULT '', -- 开场白
|
||||
keyword_option TEXT[] DEFAULT '{}', -- 关键词选项数组
|
||||
self_identity TEXT DEFAULT '', -- AI身份描述
|
||||
dataset_ids BIGINT[] DEFAULT '{}', -- 绑定的数据集ID列表
|
||||
document_ids BIGINT[] DEFAULT '{}', -- 绑定的文档ID列表
|
||||
expand_data JSONB DEFAULT '{}'::JSONB -- 扩展数据(JSONB)
|
||||
);
|
||||
|
||||
-- 索引(高频查询)
|
||||
CREATE INDEX idx_csa_tenant_id ON customer_server_account(tenant_id);
|
||||
CREATE INDEX idx_csa_account_code ON customer_server_account(account_code);
|
||||
CREATE INDEX idx_csa_account_name ON customer_server_account(account_name);
|
||||
CREATE INDEX idx_csa_status ON customer_server_account(status);
|
||||
CREATE INDEX idx_csa_platform ON customer_server_account(platform);
|
||||
CREATE INDEX idx_csa_deleted_at ON customer_server_account(deleted_at);
|
||||
|
||||
-- 表和字段注释
|
||||
COMMENT ON TABLE customer_server_account IS '客服账号表(RAG智能客服配置)';
|
||||
COMMENT ON COLUMN customer_server_account.id IS '主键ID(非自增)';
|
||||
COMMENT ON COLUMN customer_server_account.tenant_id IS '租户ID';
|
||||
COMMENT ON COLUMN customer_server_account.creator IS '创建人';
|
||||
COMMENT ON COLUMN customer_server_account.created_at IS '创建时间';
|
||||
COMMENT ON COLUMN customer_server_account.updater IS '更新人';
|
||||
COMMENT ON COLUMN customer_server_account.updated_at IS '更新时间';
|
||||
COMMENT ON COLUMN customer_server_account.deleted_at IS '删除时间(软删)';
|
||||
COMMENT ON COLUMN customer_server_account.account_code IS '客服账号编码';
|
||||
COMMENT ON COLUMN customer_server_account.account_name IS '客服账号名称';
|
||||
COMMENT ON COLUMN customer_server_account.status IS '客服账号状态';
|
||||
COMMENT ON COLUMN customer_server_account.platform IS '客服平台';
|
||||
COMMENT ON COLUMN customer_server_account.greeting IS '开场白';
|
||||
COMMENT ON COLUMN customer_server_account.keyword_option IS '关键词选项数组';
|
||||
COMMENT ON COLUMN customer_server_account.self_identity IS 'AI身份描述';
|
||||
COMMENT ON COLUMN customer_server_account.dataset_ids IS '绑定的数据集ID列表';
|
||||
COMMENT ON COLUMN customer_server_account.document_ids IS '绑定的文档ID列表';
|
||||
COMMENT ON COLUMN customer_server_account.expand_data IS '扩展数据(JSONB)';
|
||||
|
||||
--------------------pgsql创建customer_server_account表语句---------------------------
|
||||
|
||||
--------------------pgsql创建customer_server_scripted_speech表语句---------------------------
|
||||
-- 客服话术表(自定义话术)
|
||||
CREATE TABLE IF NOT EXISTS customer_server_scripted_speech (
|
||||
-- 基础字段(完全对齐项目规范)
|
||||
id BIGINT PRIMARY KEY, -- 主键ID(非自增)
|
||||
tenant_id BIGINT NOT NULL DEFAULT 0, -- 租户ID int8
|
||||
creator VARCHAR(64) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updater VARCHAR(64) NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted_at timestamp(6),
|
||||
|
||||
-- 业务字段
|
||||
dataset_id BIGINT NOT NULL, -- 数据集ID
|
||||
scene_type SMALLINT NOT NULL, -- 场景类型
|
||||
question_content TEXT NOT NULL -- 问题内容
|
||||
);
|
||||
|
||||
-- 索引(高频查询)
|
||||
CREATE INDEX idx_csss_tenant_id ON customer_server_scripted_speech(tenant_id);
|
||||
CREATE INDEX idx_csss_dataset_id ON customer_server_scripted_speech(dataset_id);
|
||||
CREATE INDEX idx_csss_deleted_at ON customer_server_scripted_speech(deleted_at);
|
||||
|
||||
-- 表和字段注释
|
||||
COMMENT ON TABLE customer_server_scripted_speech IS '客服话术表(自定义话术)';
|
||||
COMMENT ON COLUMN customer_server_scripted_speech.id IS '主键ID(非自增)';
|
||||
COMMENT ON COLUMN customer_server_scripted_speech.tenant_id IS '租户ID';
|
||||
COMMENT ON COLUMN customer_server_scripted_speech.creator IS '创建人';
|
||||
COMMENT ON COLUMN customer_server_scripted_speech.created_at IS '创建时间';
|
||||
COMMENT ON COLUMN customer_server_scripted_speech.updater IS '更新人';
|
||||
COMMENT ON COLUMN customer_server_scripted_speech.updated_at IS '更新时间';
|
||||
COMMENT ON COLUMN customer_server_scripted_speech.deleted_at IS '删除时间(软删)';
|
||||
COMMENT ON COLUMN customer_server_scripted_speech.dataset_id IS '数据集ID';
|
||||
COMMENT ON COLUMN customer_server_scripted_speech.scene_type IS '场景类型';
|
||||
COMMENT ON COLUMN customer_server_scripted_speech.question_content IS '问题内容';
|
||||
|
||||
--------------------pgsql创建customer_server_scripted_speech表语句---------------------------
|
||||
|
||||
--------------------pgsql创建customer_server_account_user_dialog表语句---------------------------
|
||||
-- 客服账号用户对话统计表
|
||||
CREATE TABLE IF NOT EXISTS customer_server_account_user_dialog (
|
||||
-- 基础字段(完全对齐项目规范)
|
||||
id BIGINT PRIMARY KEY, -- 主键ID(非自增)
|
||||
tenant_id BIGINT NOT NULL DEFAULT 0, -- 租户ID int8
|
||||
creator VARCHAR(64) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updater VARCHAR(64) NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted_at timestamp(6),
|
||||
|
||||
-- 业务字段
|
||||
account_id BIGINT NOT NULL, -- 客服ID
|
||||
user_id VARCHAR(255) NOT NULL, -- 用户ID
|
||||
dialog_count BIGINT NOT NULL DEFAULT 0 -- 对话次数
|
||||
);
|
||||
|
||||
-- 唯一索引:租户 + 客服ID + 用户ID 唯一
|
||||
CREATE UNIQUE INDEX uk_csaud_tenant_account_user
|
||||
ON customer_server_account_user_dialog(tenant_id, account_id, user_id)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
-- 索引(高频查询)
|
||||
CREATE INDEX idx_csaud_tenant_id ON customer_server_account_user_dialog(tenant_id);
|
||||
CREATE INDEX idx_csaud_account_id ON customer_server_account_user_dialog(account_id);
|
||||
CREATE INDEX idx_csaud_user_id ON customer_server_account_user_dialog(user_id);
|
||||
CREATE INDEX idx_csaud_deleted_at ON customer_server_account_user_dialog(deleted_at);
|
||||
|
||||
-- 表和字段注释
|
||||
COMMENT ON TABLE customer_server_account_user_dialog IS '客服账号用户对话统计表';
|
||||
COMMENT ON COLUMN customer_server_account_user_dialog.id IS '主键ID(非自增)';
|
||||
COMMENT ON COLUMN customer_server_account_user_dialog.tenant_id IS '租户ID';
|
||||
COMMENT ON COLUMN customer_server_account_user_dialog.creator IS '创建人';
|
||||
COMMENT ON COLUMN customer_server_account_user_dialog.created_at IS '创建时间';
|
||||
COMMENT ON COLUMN customer_server_account_user_dialog.updater IS '更新人';
|
||||
COMMENT ON COLUMN customer_server_account_user_dialog.updated_at IS '更新时间';
|
||||
COMMENT ON COLUMN customer_server_account_user_dialog.deleted_at IS '删除时间(软删)';
|
||||
COMMENT ON COLUMN customer_server_account_user_dialog.account_id IS '客服ID';
|
||||
COMMENT ON COLUMN customer_server_account_user_dialog.user_id IS '用户ID';
|
||||
COMMENT ON COLUMN customer_server_account_user_dialog.dialog_count IS '对话次数';
|
||||
|
||||
--------------------pgsql创建customer_server_account_user_dialog表语句---------------------------
|
||||
@@ -1,74 +0,0 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.com/red-future/common/beans"
|
||||
"gitea.com/red-future/common/db/mongo"
|
||||
"gitea.com/red-future/common/redis"
|
||||
"gitea.com/red-future/common/utils"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// GetTenantInfo 获取租户信息
|
||||
// 优先从 token 获取,失败则从请求参数 customerServiceId 查询 customer_service_account 表
|
||||
func GetTenantInfo(ctx context.Context) (user beans.User, err error) {
|
||||
// 1. 优先从 token 获取
|
||||
user, err = utils.GetUserInfo(ctx)
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. token 获取失败,尝试从请求参数或context获取 accountName
|
||||
var accountName string
|
||||
|
||||
// 2.1 尝试从request获取(HTTP请求场景)
|
||||
req := g.RequestFromCtx(ctx)
|
||||
if req != nil {
|
||||
accountName = req.Get("accountName").String()
|
||||
if accountName == "" {
|
||||
accountName = req.Get("account_name").String()
|
||||
}
|
||||
}
|
||||
|
||||
// 2.2 request不存在或未获取到,尝试从context.Value获取(WebSocket场景)
|
||||
if accountName == "" {
|
||||
if val := ctx.Value("accountName"); val != nil {
|
||||
if str, ok := val.(string); ok {
|
||||
accountName = str
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if accountName == "" {
|
||||
return user, gerror.New("无法获取租户信息:无 token 且无 accountName 参数")
|
||||
}
|
||||
|
||||
// 3. 先查Redis缓存(accountName -> tenantId映射)
|
||||
cacheKey := fmt.Sprintf("tenant:account:%s", accountName)
|
||||
cached, cacheErr := redis.RedisClient().Get(ctx, cacheKey)
|
||||
if cacheErr == nil && !g.IsEmpty(cached) {
|
||||
user.TenantId = cached.Interface()
|
||||
user.UserName = accountName
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// 4. 缓存未命中,查询 customer_service_account 表
|
||||
filter := bson.M{"accountName": accountName, "isDeleted": false}
|
||||
var account struct {
|
||||
TenantId interface{} `bson:"tenantId"`
|
||||
}
|
||||
if findErr := mongo.GetDB().Collection("customer_service_account").FindOne(ctx, filter).Decode(&account); findErr != nil {
|
||||
return user, gerror.Newf("通过 accountName 查询租户失败: %v", findErr)
|
||||
}
|
||||
|
||||
// 5. 写入缓存(3分钟过期,避免数据长时间不一致)
|
||||
redis.RedisClient().SetEX(ctx, cacheKey, account.TenantId, 180)
|
||||
|
||||
user.TenantId = account.TenantId
|
||||
user.UserName = accountName
|
||||
return user, nil
|
||||
}
|
||||
Reference in New Issue
Block a user