27 Commits
Author SHA1 Message Date
admin fc8c70829d gitea,gitea-runner写入docker-compose 2026-08-06 09:40:32 +08:00
admin 86b488d9bd ng代理minio支持websocket 2026-07-08 09:55:33 +08:00
admin a765526150 common版本更新 2026-06-25 17:07:11 +08:00
admin 7d8ad88fac common版本更新 2026-06-25 17:05:12 +08:00
admin 57316d927e common版本升级 2026-06-23 18:31:58 +08:00
admin 3f81d5d91b ci/cd调整 2026-06-10 15:51:41 +08:00
admin fa297939e0 common版本更新 2026-04-24 08:57:28 +08:00
admin 9eaa5d1f3d common版本更新 2026-04-22 18:00:31 +08:00
19904408334 9f54f95264 feat: 优化客服会话及延迟消息逻辑 2026-04-16 15:48:26 +08:00
19904408334 d99481cdb0 chore: 移除未使用的配置导入 2026-04-11 18:45:57 +08:00
19904408334 0917d40d42 chore: 移除未使用的配置导入 2026-04-11 18:40:07 +08:00
19904408334 f8927afa9c feat: 新增账号编码和HTTP连接功能 2026-04-11 18:22:52 +08:00
admin 2f5c4f7e54 增加dockerfile配置 2026-04-09 17:42:55 +08:00
admin 94a7480118 增加dockerfile配置 2026-04-09 16:14:31 +08:00
admin 58477cf7fb 测试服务器IP配置变更 2026-04-08 22:14:21 +08:00
19904408334 fe812097dd feat: 支持多数据库配置与PGVector检索 2026-04-03 17:59:03 +08:00
19904408334 3180fd065e feat: 添加客服账号管理及WebSocket功能 2026-04-03 17:54:28 +08:00
19904408334 9598aec184 Merge branch 'dev' of http://116.204.74.41:3000/red-future/customer-server into dev
# Conflicts:
#	go.mod
2026-04-03 17:53:23 +08:00
19904408334 7416bf8d96 feat: 添加客服账号管理及WebSocket功能 2026-04-03 17:52:09 +08:00
admin f389065798 golang版本升级1.26.0 2026-04-03 11:39:33 +08:00
admin c87cde60d2 golang版本升级1.26.0 2026-04-03 11:30:12 +08:00
admin 425728b22f golang版本升级1.26.0 2026-04-03 11:05:03 +08:00
admin 65dc04322c Dockerfile 2026-04-01 13:49:13 +08:00
admin 6008ddf643 Dockerfile 2026-04-01 13:16:31 +08:00
admin eae825726e Merge remote-tracking branch 'origin/dev' into dev
# Conflicts:
#	go.sum
2026-04-01 13:05:42 +08:00
admin 98c1c6d31f Dockerfile 2026-04-01 13:04:57 +08:00
19904408334 b7cce0befa feat: 添加数据库配置和相关模型定义 2026-03-31 14:44:14 +08:00
99 changed files with 2401 additions and 10135 deletions
+1
View File
@@ -0,0 +1 @@
.git
+25
View File
@@ -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
View File
@@ -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
-53
View File
@@ -1,53 +0,0 @@
# 各咨询方向的欢迎话术配置
# 当用户在状态5选择方向后,会自动发送对应的欢迎语
welcomes:
乳腺贴: |
🙋‍♀️ 欢迎姐妹们来到药济堂!我们是一个拥有10年经验的大健康专业团队🏅,专注于保守调理乳腺问题,已经成功帮助超过1万位姐妹轻松调理乳腺健康❤️。
如果您有结节或增生的困扰,欢迎随时咨询!
请回复下面的数字,让我帮助您分析结节情况:
结节
增生
点击👇获取更快速的服务!
肝病: |
你好,我是黄医生,有什么肝脏方面的问题我可以帮助您吗?无论是肝病、乙肝、丙肝,还是肝硬化腹水、脂肪肝、酒精肝,我都会为您提供专业的建议。😷
肝病的类型很多,常见症状有乏力、食欲减退和肝区不适等。为了更好地帮您,我需要了解一些详细的信息,比如:
您现在有腹胀或腹水的情况吗?
是第一次出现还是反复出现呢?
目前是早期还是中晚期呢?
有没有病毒性肝炎的病史呢?
请您留一下联系方式,我可以发送您的报告,并给您详细解读肝病治疗方案和成功案例。📋
车膜: |
🎉亲爱的车主,欢迎来到6膜王!
🚗 我们在车膜行业深耕十年,拥有2家千平米的门店和30+专业团队,致力于为您提供最优质的服务!
✨ 无论是隐形车衣还是改色膜,我们都能精准适配您的需求。留下您的【车型➕VX】,我们的资深顾问会立即为您匹配专属方案、膜材讲解以及报价!❤️
毛孔: |
啊啊~亲爱的姐妹们,最近有很多小伙伴在问我关于毛孔的问题!我之前也是毛孔大到妆容卡粉,真的是烦恼不断。😩 但在经过一段时间的摸索后,我终于找到了合适的方法,效果真的很好,差不多一个月就改善了许多!(亲测有效!)💖
如果需要帮助,可以随时告诉我,我非常乐意分享我的经验给你们哦!
免税店: |
Hi,长春的宝子们~💕欢迎锁定小红提免税集合店!
这里有你想要的一切:美妆、香水、包包、首饰和大牌护肤品,正品保真不踩雷,价格特别美丽哦✨
全城顺丰包邮,购物更方便!期待你们来逛快来发现更多惊喜吧!💖
门店地址:长春市绿园区皓月大路吾悦广场1楼 1036号,等你来哦!🌟
减肥: |
你好呀,姐妹~你也有肉肉的困扰吗?我现在已经掉了二十多斤了,至今都没有反弹!
想要方法的话可以直接回复"1",我分享给你哦~
(💗未成年发育期、哺乳期的姐妹我就不推荐啦)
气血: |
亲爱的,欢迎光临!🌸
如果你有月经不调或气血不足的问题,随时可以问我哦!
停经闭经
痛经难忍
量少
经期不准
💗只需回复数字,我们会为你提供专业建议! 🌟如需更多帮助,点击下方咨询专业老师,我们一起寻求解决方案~
+39
View File
@@ -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 "未知平台"
}
+26
View File
@@ -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}
}
+16
View File
@@ -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不确认)
)
+18
View File
@@ -0,0 +1,18 @@
package public
// 欢迎语
const (
GreetingBegin = "您好,很高兴为您服务!请问有什么可以帮您?"
GreetingBetween = "💗回复数字就好~"
GreetingEnd = "🌟也可直接点击下方咨询专业老师~"
)
// 追问
const (
SceneOpeningRemark = "宝子,刚才给您发的信息您有看到吗?有任何问题都能直接问我,加微信也能更方便沟通~"
SceneDialog = "看您暂时没回复,是不是还有什么疑问?加微信我详细给您说明~"
SceneCardSend = "宝子,加上没~要及时加哦,不然卡片容易失效哒✨"
)
// 对话超时时间
const DialogTimeout = 10
+8
View File
@@ -0,0 +1,8 @@
package public
// sql 数据库表名
const (
TableNameAccount = "account"
TableNameAccountUserDialog = "account_user_dialog"
TableNameScriptedSpeech = "scripted_speech"
)
+39
View File
@@ -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 "未知场景类型"
}
-591
View File
@@ -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
}
-47
View File
@@ -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
}
+64
View File
@@ -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
}
+16
View File
@@ -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
}
-29
View File
@@ -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
}
-52
View File
@@ -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
}
-62
View File
@@ -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
}
-35
View File
@@ -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:"版本"`
}
-96
View File
@@ -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
}
-31
View File
@@ -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
}
-64
View File
@@ -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",
})
}
+64
View File
@@ -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
}
-69
View File
@@ -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
}
-41
View File
@@ -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
}
-52
View File
@@ -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
}
-111
View File
@@ -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
}
+91
View File
@@ -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
}
+60
View File
@@ -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
}
-158
View File
@@ -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{})
}
-140
View File
@@ -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
}
-197
View File
@@ -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层已经设置了TenantIdcommonmongo.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->falsefalse->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
View File
@@ -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
}
-199
View File
@@ -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
}
-75
View File
@@ -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
}
-185
View File
@@ -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
}
-135
View File
@@ -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
}
+92
View File
@@ -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
}
-99
View File
@@ -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
}
-301
View File
@@ -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层已经设置了TenantIdmongo.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
}
-143
View File
@@ -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
}
+65 -47
View File
@@ -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
)
+153 -124
View File
@@ -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=
+32 -61
View File
@@ -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 服务已停止")
}
+96
View File
@@ -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:"客服账号"`
}
+32
View File
@@ -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"`
}
+28
View File
@@ -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:"对话次数"`
}
-13
View File
@@ -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"` // 执行结果消息
}
-107
View File
@@ -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"`
}
-74
View File
@@ -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"`
}
-73
View File
@@ -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"` // 筛选:结束日期
}
-102
View File
@@ -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"`
}
-19
View File
@@ -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"`
}
-41
View File
@@ -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"`
}
-29
View File
@@ -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"` // 当前重试次数
}
+70
View File
@@ -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"`
}
+63
View File
@@ -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:"更新时间"`
}
-101
View File
@@ -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"`
}
-51
View File
@@ -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 -1
View File
@@ -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"
)
-427
View File
@@ -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"`
}
+79
View File
@@ -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:"小红书AccessToken14天有效期)"`
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:"卡片触发次数"`
}
+27
View File
@@ -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:"对话次数"`
}
-27
View File
@@ -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"` // 消息时间
}
-71
View File
@@ -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"
-33
View File
@@ -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"` // 小红书AccessToken14天有效期)
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,不同客服可定制)
}
-25
View File
@@ -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"` // 消息时间
}
-30
View File
@@ -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秒响应率
}
-25
View File
@@ -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"` // 最后同步时间
}
-13
View File
@@ -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"` // 重试次数
}
-46
View File
@@ -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"` // 话术知识库IDRAGFlow
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"` // 同步错误信息
}
+29
View File
@@ -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:"问题内容"`
}
-29
View File
@@ -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"` // 归档时间
}
-32
View File
@@ -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"` // 最后同步时间
}
-45
View File
@@ -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" // 发关键字
)
+56
View File
@@ -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
}
+92
View File
@@ -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
}
+234
View File
@@ -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连接池已清空")
}
-207
View File
@@ -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)
}
-211
View File
@@ -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)
}
-353
View File
@@ -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
}
-855
View File
@@ -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
}
-69
View File
@@ -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
}
-168
View File
@@ -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)
}
-197
View File
@@ -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
}
-17
View File
@@ -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错误记录")
}
-891
View File
@@ -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文档IDerr - 错误信息
// 功能: 创建产品记录并自动上传到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)
}
}
-211
View File
@@ -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
}
+91
View File
@@ -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
}
-85
View File
@@ -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
}
+448
View File
@@ -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
}
-653
View File
@@ -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文档IDerr - 错误信息
// 功能: 创建话术记录并自动上传到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 - 用户IDplatform - 平台,tenantId - 租户IDcontent - 消息内容,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 - 话术IDaccountName - 客服账号名,tenantId - 租户ID
// 返回: documentId - RAGFlow文档IDerr - 错误信息
// 功能: 将话术上传到指定客服账号的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)
}
-101
View File
@@ -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
}
-506
View File
@@ -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 - 租户IDerr - 错误信息
// 功能: 优先从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 StreamHTTP 接口)
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
}
-459
View File
@@ -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
}
-152
View File
@@ -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
View File
@@ -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表语句---------------------------
-74
View File
@@ -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
}