From 830f75a33478c61220533de4e385c3b0694d5c79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=96=8C?= <259278618@qq.com> Date: Sat, 14 Mar 2026 10:02:49 +0800 Subject: [PATCH] 1 --- .gitignore | 1 + README.md | 2 - config.yml | 109 +++ config/welcome_messages.yaml | 53 ++ consts/errors.go | 5 + consts/redis_key.go | 4 + consumer/ragflow_processor.go | 591 ++++++++++++ consumer/recreate_chat.go | 47 + controller/archive_controller.go | 29 + .../customer_service_account_controller.go | 87 ++ controller/data_controller.go | 52 + controller/data_statistics_controller.go | 62 ++ controller/health_controller.go | 35 + controller/product_controller.go | 96 ++ controller/ragflow_config_controller.go | 31 + controller/ragflow_controller.go | 64 ++ controller/speechcraft_controller.go | 69 ++ controller/webhook_controller.go | 41 + controller/websocket_controller.go | 52 + controller/xiaohongshu_controller.go | 111 +++ dao/archive_dao.go | 158 ++++ dao/conversation_dao.go | 140 +++ dao/customer_service_account_dao.go | 197 ++++ dao/data_dao.go | 167 ++++ dao/data_statistics_dao.go | 199 ++++ dao/mongo_dao.go | 75 ++ dao/product_dao.go | 185 ++++ dao/ragflow_config_dao.go | 135 +++ dao/session_dao.go | 99 ++ dao/speechcraft_dao.go | 301 ++++++ dao/user_stage_dao.go | 143 +++ go.mod | 93 ++ go.sum | 470 +++++++++ main.go | 95 ++ model/dto/archive_dto.go | 13 + model/dto/customer_service_account_dto.go | 107 +++ model/dto/data_dto.go | 74 ++ model/dto/data_statistics_dto.go | 73 ++ model/dto/product_dto.go | 102 ++ model/dto/ragflow_chat_dto.go | 19 + model/dto/ragflow_config_dto.go | 41 + model/dto/ragflow_sync_dto.go | 29 + model/dto/speechcraft_dto.go | 101 ++ model/dto/webhook_dto.go | 51 + model/dto/websocket_dto.go | 58 ++ model/dto/xiaohongshu_dto.go | 427 +++++++++ model/entity/conversation.go | 27 + model/entity/conversation_archive.go | 71 ++ model/entity/customer_service_account.go | 33 + model/entity/data.go | 25 + model/entity/data_statistics.go | 30 + model/entity/product.go | 25 + model/entity/ragflow_binding.go | 13 + model/entity/ragflow_config.go | 46 + model/entity/session.go | 29 + model/entity/speechcraft.go | 32 + model/entity/user_stage.go | 45 + service/archive_service.go | 207 ++++ service/config_service.go | 211 +++++ service/conversation_service.go | 353 +++++++ service/customer_service_account_service.go | 855 +++++++++++++++++ service/data_service.go | 69 ++ service/data_statistics_service.go | 168 ++++ service/dataset_service.go | 197 ++++ service/jaegertesttemp.go | 17 + service/product_service.go | 891 ++++++++++++++++++ service/ragflow_config_service.go | 211 +++++ service/session_service.go | 85 ++ service/speechcraft_service.go | 653 +++++++++++++ service/webhook_service.go | 101 ++ service/websocket_service.go | 506 ++++++++++ service/xiaohongshu_service.go | 459 +++++++++ tools/delete_datasets_simple.go | 152 +++ update.sql | 1 + util/util.go | 74 ++ 75 files changed, 10677 insertions(+), 2 deletions(-) create mode 100644 .gitignore delete mode 100644 README.md create mode 100644 config.yml create mode 100644 config/welcome_messages.yaml create mode 100644 consts/errors.go create mode 100644 consts/redis_key.go create mode 100644 consumer/ragflow_processor.go create mode 100644 consumer/recreate_chat.go create mode 100644 controller/archive_controller.go create mode 100644 controller/customer_service_account_controller.go create mode 100644 controller/data_controller.go create mode 100644 controller/data_statistics_controller.go create mode 100644 controller/health_controller.go create mode 100644 controller/product_controller.go create mode 100644 controller/ragflow_config_controller.go create mode 100644 controller/ragflow_controller.go create mode 100644 controller/speechcraft_controller.go create mode 100644 controller/webhook_controller.go create mode 100644 controller/websocket_controller.go create mode 100644 controller/xiaohongshu_controller.go create mode 100644 dao/archive_dao.go create mode 100644 dao/conversation_dao.go create mode 100644 dao/customer_service_account_dao.go create mode 100644 dao/data_dao.go create mode 100644 dao/data_statistics_dao.go create mode 100644 dao/mongo_dao.go create mode 100644 dao/product_dao.go create mode 100644 dao/ragflow_config_dao.go create mode 100644 dao/session_dao.go create mode 100644 dao/speechcraft_dao.go create mode 100644 dao/user_stage_dao.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 model/dto/archive_dto.go create mode 100644 model/dto/customer_service_account_dto.go create mode 100644 model/dto/data_dto.go create mode 100644 model/dto/data_statistics_dto.go create mode 100644 model/dto/product_dto.go create mode 100644 model/dto/ragflow_chat_dto.go create mode 100644 model/dto/ragflow_config_dto.go create mode 100644 model/dto/ragflow_sync_dto.go create mode 100644 model/dto/speechcraft_dto.go create mode 100644 model/dto/webhook_dto.go create mode 100644 model/dto/websocket_dto.go create mode 100644 model/dto/xiaohongshu_dto.go create mode 100644 model/entity/conversation.go create mode 100644 model/entity/conversation_archive.go create mode 100644 model/entity/customer_service_account.go create mode 100644 model/entity/data.go create mode 100644 model/entity/data_statistics.go create mode 100644 model/entity/product.go create mode 100644 model/entity/ragflow_binding.go create mode 100644 model/entity/ragflow_config.go create mode 100644 model/entity/session.go create mode 100644 model/entity/speechcraft.go create mode 100644 model/entity/user_stage.go create mode 100644 service/archive_service.go create mode 100644 service/config_service.go create mode 100644 service/conversation_service.go create mode 100644 service/customer_service_account_service.go create mode 100644 service/data_service.go create mode 100644 service/data_statistics_service.go create mode 100644 service/dataset_service.go create mode 100644 service/jaegertesttemp.go create mode 100644 service/product_service.go create mode 100644 service/ragflow_config_service.go create mode 100644 service/session_service.go create mode 100644 service/speechcraft_service.go create mode 100644 service/webhook_service.go create mode 100644 service/websocket_service.go create mode 100644 service/xiaohongshu_service.go create mode 100644 tools/delete_datasets_simple.go create mode 100644 update.sql create mode 100644 util/util.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..61069b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/.idea/* \ No newline at end of file diff --git a/README.md b/README.md deleted file mode 100644 index f3674f2..0000000 --- a/README.md +++ /dev/null @@ -1,2 +0,0 @@ -# customer-server - diff --git a/config.yml b/config.yml new file mode 100644 index 0000000..d1a0f95 --- /dev/null +++ b/config.yml @@ -0,0 +1,109 @@ +server: + address: ":3000" + name: "customer-server" + + +jwt: + secret: "abcdefghijklmnopqrstuvwxyz" + +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" + database: "customer_service" + logger: + level: "all" + stdout: true + +redis: + default: + address: 116.204.74.41:6379 + db: 0 + idleTimeout: "60s" + maxConnLifetime: "90s" + waitTimeout: "60s" + dialTimeout: "30s" + readTimeout: "30s" + writeTimeout: "30s" + 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 + +jaeger: + addr: 116.204.74.41: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) + +followUp: + enable: true + queue: "followup.queue" + exchange: "followup.delayed" + routingKey: "followup" + delays: [30, 60, 180] # 三次追问延时(秒):第一次30s,第二次60s,第三次180s + contents: + - "还有其他问题吗?" + - "如果需要帮助,随时告诉我~" + - "我一直在线,有问题随时找我~" + +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 # 触发发送卡片的对话轮数(对话>=此值时触发) + +# 历史上下文配置 +history: + contextLimit: 5 # 读取历史对话轮数(用于新Session上下文注入) + +# Elasticsearch 配置 +elasticsearch: + addresses: + - "http://116.204.74.41:9200" + username: "" + password: "" + +database: + logger: + level: "none" # 关闭数据库日志 + stdout: false diff --git a/config/welcome_messages.yaml b/config/welcome_messages.yaml new file mode 100644 index 0000000..0286482 --- /dev/null +++ b/config/welcome_messages.yaml @@ -0,0 +1,53 @@ +# 各咨询方向的欢迎话术配置 +# 当用户在状态5选择方向后,会自动发送对应的欢迎语 + +welcomes: + 乳腺贴: | + 🙋‍♀️ 欢迎姐妹们来到药济堂!我们是一个拥有10年经验的大健康专业团队🏅,专注于保守调理乳腺问题,已经成功帮助超过1万位姐妹轻松调理乳腺健康❤️。 + 如果您有结节或增生的困扰,欢迎随时咨询! + 请回复下面的数字,让我帮助您分析结节情况: + + 结节 + 增生 + 点击👇获取更快速的服务! + + 肝病: | + 你好,我是黄医生,有什么肝脏方面的问题我可以帮助您吗?无论是肝病、乙肝、丙肝,还是肝硬化腹水、脂肪肝、酒精肝,我都会为您提供专业的建议。😷 + 肝病的类型很多,常见症状有乏力、食欲减退和肝区不适等。为了更好地帮您,我需要了解一些详细的信息,比如: + + 您现在有腹胀或腹水的情况吗? + 是第一次出现还是反复出现呢? + 目前是早期还是中晚期呢? + 有没有病毒性肝炎的病史呢? + 请您留一下联系方式,我可以发送您的报告,并给您详细解读肝病治疗方案和成功案例。📋 + + 车膜: | + 🎉亲爱的车主,欢迎来到6膜王! + 🚗 我们在车膜行业深耕十年,拥有2家千平米的门店和30+专业团队,致力于为您提供最优质的服务! + ✨ 无论是隐形车衣还是改色膜,我们都能精准适配您的需求。留下您的【车型➕VX】,我们的资深顾问会立即为您匹配专属方案、膜材讲解以及报价!❤️ + + 毛孔: | + 啊啊~亲爱的姐妹们,最近有很多小伙伴在问我关于毛孔的问题!我之前也是毛孔大到妆容卡粉,真的是烦恼不断。😩 但在经过一段时间的摸索后,我终于找到了合适的方法,效果真的很好,差不多一个月就改善了许多!(亲测有效!)💖 + 如果需要帮助,可以随时告诉我,我非常乐意分享我的经验给你们哦! + + 免税店: | + Hi,长春的宝子们~💕欢迎锁定小红提免税集合店! + 这里有你想要的一切:美妆、香水、包包、首饰和大牌护肤品,正品保真不踩雷,价格特别美丽哦✨ + 全城顺丰包邮,购物更方便!期待你们来逛快来发现更多惊喜吧!💖 + + 门店地址:长春市绿园区皓月大路吾悦广场1楼 1036号,等你来哦!🌟 + + 减肥: | + 你好呀,姐妹~你也有肉肉的困扰吗?我现在已经掉了二十多斤了,至今都没有反弹! + 想要方法的话可以直接回复"1",我分享给你哦~ + (💗未成年发育期、哺乳期的姐妹我就不推荐啦) + + 气血: | + 亲爱的,欢迎光临!🌸 + 如果你有月经不调或气血不足的问题,随时可以问我哦! + + 停经闭经 + 痛经难忍 + 量少 + 经期不准 + 💗只需回复数字,我们会为你提供专业建议! 🌟如需更多帮助,点击下方咨询专业老师,我们一起寻求解决方案~ diff --git a/consts/errors.go b/consts/errors.go new file mode 100644 index 0000000..25df9da --- /dev/null +++ b/consts/errors.go @@ -0,0 +1,5 @@ +package consts + +const ReClick = "操作过于频繁,请稍后再试。" +const NoRow = "未找到可用数据。" +const GenerateQrCodeFail = "生成二维码失败。" diff --git a/consts/redis_key.go b/consts/redis_key.go new file mode 100644 index 0000000..200cfb1 --- /dev/null +++ b/consts/redis_key.go @@ -0,0 +1,4 @@ +package consts + +const QrCodeCount = "qrCodeCount:order:%s" +const QrCode = "qrCode:order:%s" diff --git a/consumer/ragflow_processor.go b/consumer/ragflow_processor.go new file mode 100644 index 0000000..ac6e43c --- /dev/null +++ b/consumer/ragflow_processor.go @@ -0,0 +1,591 @@ +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 +} diff --git a/consumer/recreate_chat.go b/consumer/recreate_chat.go new file mode 100644 index 0000000..0dd4c81 --- /dev/null +++ b/consumer/recreate_chat.go @@ -0,0 +1,47 @@ +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 +} diff --git a/controller/archive_controller.go b/controller/archive_controller.go new file mode 100644 index 0000000..2d1a7c1 --- /dev/null +++ b/controller/archive_controller.go @@ -0,0 +1,29 @@ +// 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 +} diff --git a/controller/customer_service_account_controller.go b/controller/customer_service_account_controller.go new file mode 100644 index 0000000..41a1e85 --- /dev/null +++ b/controller/customer_service_account_controller.go @@ -0,0 +1,87 @@ +// 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 +} diff --git a/controller/data_controller.go b/controller/data_controller.go new file mode 100644 index 0000000..35c34f8 --- /dev/null +++ b/controller/data_controller.go @@ -0,0 +1,52 @@ +// 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 +} diff --git a/controller/data_statistics_controller.go b/controller/data_statistics_controller.go new file mode 100644 index 0000000..e824d48 --- /dev/null +++ b/controller/data_statistics_controller.go @@ -0,0 +1,62 @@ +// 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 +} diff --git a/controller/health_controller.go b/controller/health_controller.go new file mode 100644 index 0000000..d7d8e3e --- /dev/null +++ b/controller/health_controller.go @@ -0,0 +1,35 @@ +// 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:"版本"` +} diff --git a/controller/product_controller.go b/controller/product_controller.go new file mode 100644 index 0000000..4d9a709 --- /dev/null +++ b/controller/product_controller.go @@ -0,0 +1,96 @@ +// 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 +} diff --git a/controller/ragflow_config_controller.go b/controller/ragflow_config_controller.go new file mode 100644 index 0000000..2c8abc3 --- /dev/null +++ b/controller/ragflow_config_controller.go @@ -0,0 +1,31 @@ +// 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 +} diff --git a/controller/ragflow_controller.go b/controller/ragflow_controller.go new file mode 100644 index 0000000..2d50930 --- /dev/null +++ b/controller/ragflow_controller.go @@ -0,0 +1,64 @@ +// 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", + }) +} diff --git a/controller/speechcraft_controller.go b/controller/speechcraft_controller.go new file mode 100644 index 0000000..b8031e5 --- /dev/null +++ b/controller/speechcraft_controller.go @@ -0,0 +1,69 @@ +// 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 +} diff --git a/controller/webhook_controller.go b/controller/webhook_controller.go new file mode 100644 index 0000000..15d89f2 --- /dev/null +++ b/controller/webhook_controller.go @@ -0,0 +1,41 @@ +// 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 +} diff --git a/controller/websocket_controller.go b/controller/websocket_controller.go new file mode 100644 index 0000000..69af6b0 --- /dev/null +++ b/controller/websocket_controller.go @@ -0,0 +1,52 @@ +// 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 +} diff --git a/controller/xiaohongshu_controller.go b/controller/xiaohongshu_controller.go new file mode 100644 index 0000000..feb2aa4 --- /dev/null +++ b/controller/xiaohongshu_controller.go @@ -0,0 +1,111 @@ +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 +} diff --git a/dao/archive_dao.go b/dao/archive_dao.go new file mode 100644 index 0000000..b413468 --- /dev/null +++ b/dao/archive_dao.go @@ -0,0 +1,158 @@ +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{}) +} diff --git a/dao/conversation_dao.go b/dao/conversation_dao.go new file mode 100644 index 0000000..b5e20b7 --- /dev/null +++ b/dao/conversation_dao.go @@ -0,0 +1,140 @@ +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 +} diff --git a/dao/customer_service_account_dao.go b/dao/customer_service_account_dao.go new file mode 100644 index 0000000..6593be3 --- /dev/null +++ b/dao/customer_service_account_dao.go @@ -0,0 +1,197 @@ +package dao + +import ( + "context" + "customer-server/model/dto" + "customer-server/model/entity" + "fmt" + + "gitea.com/red-future/common/beans" + "gitea.com/red-future/common/db/mongo" + "gitea.com/red-future/common/redis" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" + "go.mongodb.org/mongo-driver/v2/bson" +) + +var CustomerServiceAccount = new(customerServiceAccount) + +type customerServiceAccount struct{} + +// FindByAccountName 根据accountName查询 +// 使用 MongoDAO(不需要token验证) +func (d *customerServiceAccount) FindByAccountName(ctx context.Context, accountName string) (account *entity.CustomerServiceAccount, err error) { + filter := bson.M{"accountName": accountName, "isDeleted": false} + + var result entity.CustomerServiceAccount + err = MongoDAO.FindOne(ctx, filter, &result, entity.CustomerServiceAccountCollection) + if err != nil { + return nil, err + } + + // 如果未找到记录,result 是零值 + if result.Id.IsZero() { + return nil, nil + } + + account = &result + return +} + +// Insert 插入客服账号 +func (d *customerServiceAccount) Insert(ctx context.Context, data *entity.CustomerServiceAccount) (err error) { + // 统一使用commonmongo.DB().Insert,自动清除缓存 + // service层已经设置了TenantId,commonmongo.DB().Insert不会覆盖已有值 + ids, err := mongo.DB().Insert(ctx, []interface{}{data}, entity.CustomerServiceAccountCollection) + if err != nil { + return + } + if len(ids) > 0 { + if oid, ok := ids[0].(bson.ObjectID); ok { + data.Id = &oid // 取地址赋值给指针类型 + } + } + return +} + +// Update 更新客服账号 +func (d *customerServiceAccount) Update(ctx context.Context, req *dto.UpdateCustomerServiceAccountReq) (err error) { + objectId, err := bson.ObjectIDFromHex(req.Id) + if err != nil { + return + } + filter := bson.M{"_id": objectId} + + // 如果accountName变更,需要清理旧的缓存 + if !g.IsEmpty(req.AccountName) { + // 先查出旧的accountName + var oldAccount entity.CustomerServiceAccount + if findErr := MongoDAO.FindOne(ctx, filter, &oldAccount, entity.CustomerServiceAccountCollection); findErr == nil { + // 清理旧accountName的缓存 + oldCacheKey := fmt.Sprintf("tenant:account:%s", oldAccount.AccountName) + redis.RedisClient().Del(ctx, oldCacheKey) + } + } + + updateFields := bson.M{} + if !g.IsEmpty(req.AccountName) { + updateFields["accountName"] = req.AccountName + } + if !g.IsEmpty(req.Platform) { + updateFields["platform"] = req.Platform + } + if req.SelfIdentity != nil { + updateFields["selfIdentity"] = *req.SelfIdentity + } + + // 如果有字段需要更新,则执行 MongoDB 更新操作 + if len(updateFields) > 0 { + _, err = mongo.DB().Update(ctx, filter, bson.M{"$set": updateFields}, entity.CustomerServiceAccountCollection) + } + return +} + +// Delete 软删除客服账号 +func (d *customerServiceAccount) Delete(ctx context.Context, id string) (err error) { + objectId, err := bson.ObjectIDFromHex(id) + if err != nil { + return + } + + filter := bson.M{"_id": objectId, "isDeleted": false} + + // 删除前先查出accountName,清理缓存 + var account entity.CustomerServiceAccount + if findErr := MongoDAO.FindOne(ctx, filter, &account, entity.CustomerServiceAccountCollection); findErr == nil { + cacheKey := fmt.Sprintf("tenant:account:%s", account.AccountName) + redis.RedisClient().Del(ctx, cacheKey) + } + + update := bson.M{"$set": bson.M{"isDeleted": true, "updatedAt": gtime.Now().Time}} + _, err = mongo.DB().Update(ctx, filter, update, entity.CustomerServiceAccountCollection) + if err != nil { + return gerror.Wrap(err, "删除客服账号失败") + } + + return +} + +// ToggleStatus 切换客服账号状态(1 启用,0 禁用) +func (d *customerServiceAccount) ToggleStatus(ctx context.Context, req *dto.ToggleCustomerServiceAccountStatusReq) (err error) { + objectId, err := bson.ObjectIDFromHex(req.Id) + if err != nil { + return + } + filter := bson.M{"_id": objectId} + + // 先查出当前状态 + var account entity.CustomerServiceAccount + if err = MongoDAO.FindOne(ctx, filter, &account, entity.CustomerServiceAccountCollection); err != nil { + return + } + + // 计算新的状态:true->false,false->true(切换禁用状态) + newIsDisabled := true + if !account.Id.IsZero() && account.IsDisabled { + newIsDisabled = false + } + + _, err = mongo.DB().Update(ctx, filter, bson.M{"$set": bson.M{"isDisabled": newIsDisabled}}, entity.CustomerServiceAccountCollection) + return +} + +// buildListFilter 构建列表查询的过滤条件 +func (d *customerServiceAccount) buildListFilter(req *dto.ListCustomerServiceAccountReq) bson.M { + filter := bson.M{} + if !g.IsEmpty(req.AccountName) { + filter["accountName"] = bson.M{"$regex": req.AccountName, "$options": "i"} // $regex模糊查询,忽略大小写 + } + if req.IsDisabled != nil { + filter["isDisabled"] = *req.IsDisabled + } + if !g.IsEmpty(req.Platform) { + filter["platform"] = req.Platform + } + return filter +} + +// checkTotalCount 检查总数 +func (d *customerServiceAccount) checkTotalCount(ctx context.Context, filter bson.M) (total int64, err error) { + total, err = mongo.DB().Count(ctx, filter, entity.CustomerServiceAccountCollection) + return +} + +// List 获取客服账号列表(包含所有账号,含已禁用账号) +func (d *customerServiceAccount) List(ctx context.Context, req *dto.ListCustomerServiceAccountReq) (list []*entity.CustomerServiceAccount, total int64, err error) { + // 构建查询过滤条件 + filter := d.buildListFilter(req) + + // 检查总数 + total, err = d.checkTotalCount(ctx, filter) + if err != nil { + return + } + + // 分页参数处理 + pageNum := req.PageNum + if pageNum <= 0 { + pageNum = 1 + } + pageSize := req.PageSize + if pageSize <= 0 { + pageSize = 20 + } + + // 使用统一的mongo.DB().Find方法(支持分页和排序) + page := &beans.Page{ + PageNum: int64(pageNum), + PageSize: int64(pageSize), + } + orderBy := []beans.OrderBy{ + {Field: "createdAt", Order: beans.Desc}, // 按创建时间倒序 + } + + _, err = mongo.DB().Find(ctx, filter, &list, entity.CustomerServiceAccountCollection, page, orderBy) + return +} diff --git a/dao/data_dao.go b/dao/data_dao.go new file mode 100644 index 0000000..87feb67 --- /dev/null +++ b/dao/data_dao.go @@ -0,0 +1,167 @@ +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 +} diff --git a/dao/data_statistics_dao.go b/dao/data_statistics_dao.go new file mode 100644 index 0000000..8b897dd --- /dev/null +++ b/dao/data_statistics_dao.go @@ -0,0 +1,199 @@ +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 +} diff --git a/dao/mongo_dao.go b/dao/mongo_dao.go new file mode 100644 index 0000000..3358859 --- /dev/null +++ b/dao/mongo_dao.go @@ -0,0 +1,75 @@ +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 +} diff --git a/dao/product_dao.go b/dao/product_dao.go new file mode 100644 index 0000000..26a4831 --- /dev/null +++ b/dao/product_dao.go @@ -0,0 +1,185 @@ +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 +} diff --git a/dao/ragflow_config_dao.go b/dao/ragflow_config_dao.go new file mode 100644 index 0000000..73b63e0 --- /dev/null +++ b/dao/ragflow_config_dao.go @@ -0,0 +1,135 @@ +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 +} diff --git a/dao/session_dao.go b/dao/session_dao.go new file mode 100644 index 0000000..b4aba7d --- /dev/null +++ b/dao/session_dao.go @@ -0,0 +1,99 @@ +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 +} diff --git a/dao/speechcraft_dao.go b/dao/speechcraft_dao.go new file mode 100644 index 0000000..5843211 --- /dev/null +++ b/dao/speechcraft_dao.go @@ -0,0 +1,301 @@ +package dao + +import ( + "context" + "customer-server/model/dto" + "customer-server/model/entity" + "strings" + + "gitea.com/red-future/common/beans" + "gitea.com/red-future/common/db/mongo" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" + "github.com/gogf/gf/v2/util/grand" + "go.mongodb.org/mongo-driver/v2/bson" +) + +var Speechcraft = new(speechcraft) + +type speechcraft struct{} + +// Insert 插入话术 +func (d *speechcraft) Insert(ctx context.Context, data *entity.Speechcraft) (id bson.ObjectID, err error) { + // 统一使用mongo.DB().Insert,自动清除缓存 + // service层已经设置了TenantId,mongo.DB().Insert不会覆盖已有值 + ids, err := mongo.DB().Insert(ctx, []interface{}{data}, entity.SpeechcraftCollection) + if err != nil { + return + } + if len(ids) > 0 { + if oid, ok := ids[0].(bson.ObjectID); ok { + id = oid + data.Id = &oid // 取地址赋值给指针类型 + } + } + return +} + +// Update 更新话术 +func (d *speechcraft) Update(ctx context.Context, req *dto.UpdateSpeechcraftReq) (err error) { + objectId, err := bson.ObjectIDFromHex(req.Id) + if err != nil { + return + } + updateFields := bson.M{} + if !g.IsEmpty(req.Tag) { + updateFields["tag"] = req.Tag + } + if !g.IsEmpty(req.Content) { + updateFields["content"] = req.Content + } + // 状态机字段 + if req.Stage != nil { + updateFields["stage"] = *req.Stage + } + if req.Status != nil { + updateFields["status"] = *req.Status + } + if req.Keywords != nil { + updateFields["keywords"] = req.Keywords + } + if req.NextStage != nil { + updateFields["nextStage"] = *req.NextStage + } + if req.Platform != nil { + updateFields["platform"] = *req.Platform + } + + if len(updateFields) > 0 { + _, err = mongo.DB().Update(ctx, bson.M{"_id": objectId, "isDeleted": false}, bson.M{"$set": updateFields}, entity.SpeechcraftCollection) + } + return +} + +// Delete 软删除话术 +func (d *speechcraft) Delete(ctx context.Context, req *dto.DeleteSpeechcraftReq) (err error) { + objectId, err := bson.ObjectIDFromHex(req.Id) + if err != nil { + return + } + + filter := bson.M{"_id": objectId, "isDeleted": false} + update := bson.M{"$set": bson.M{"isDeleted": true, "updatedAt": gtime.Now().Time}} + _, err = mongo.DB().Update(ctx, filter, update, entity.SpeechcraftCollection) + if err != nil { + return gerror.Wrap(err, "删除话术失败") + } + + return +} + +// buildListFilter 构建列表查询的过滤条件 +func (d *speechcraft) buildListFilter(req *dto.ListSpeechcraftReq) bson.M { + filter := bson.M{"isDeleted": false} + if !g.IsEmpty(req.Tag) { + filter["tag"] = bson.M{"$regex": req.Tag} + } + if !g.IsEmpty(req.Content) { + filter["content"] = bson.M{"$regex": req.Content} + } + if req.Stage != nil { + filter["stage"] = *req.Stage + } + if !g.IsEmpty(req.Platform) { + filter["platform"] = req.Platform + } + return filter +} + +// checkTotalCount 检查总数 +func (d *speechcraft) checkTotalCount(ctx context.Context, filter bson.M) (total int64, err error) { + total, err = mongo.DB().Count(ctx, filter, entity.SpeechcraftCollection) + return +} + +// List 获取话术列表(排除已删除) +func (d *speechcraft) List(ctx context.Context, req *dto.ListSpeechcraftReq) (list []*entity.Speechcraft, total int64, err error) { + // 构建查询过滤条件 + filter := d.buildListFilter(req) + + // 检查总数 + total, err = d.checkTotalCount(ctx, filter) + if err != nil { + return + } + + // 分页参数处理 + pageNum := req.PageNum + if pageNum <= 0 { + pageNum = 1 + } + pageSize := req.PageSize + if pageSize <= 0 { + pageSize = 20 + } + + // 使用统一的mongo.DB().Find方法(支持分页和排序) + page := &beans.Page{ + PageNum: int64(pageNum), + PageSize: int64(pageSize), + } + orderBy := []beans.OrderBy{ + {Field: "createdAt", Order: beans.Desc}, // 按创建时间倒序 + } + + _, err = mongo.DB().Find(ctx, filter, &list, entity.SpeechcraftCollection, page, orderBy) + return +} + +// MatchByStage 根据阶段和用户输入匹配话术 +// 匹配逻辑:阶段匹配 + 行为匹配(可选)+ 关键字匹配(可选) +// 从匹配结果中随机选择一条(话术池随机) +func (d *speechcraft) MatchByStage(ctx context.Context, stage int, status, content, platform string) (script *entity.Speechcraft, err error) { + // 查询该阶段的所有话术 + filter := bson.M{ + "stage": stage, + "isDeleted": false, + } + if !g.IsEmpty(platform) { + filter["platform"] = platform + } + + var list []*entity.Speechcraft + // 使用mongo.DB().Find会自动从token或accountName获取tenantId并过滤 + // 查询所有匹配的话术(不分页) + page := &beans.Page{ + PageNum: 1, + PageSize: 10000, // 话术匹配场景:设置足够大的PageSize + } + orderBy := []beans.OrderBy{} // 无需排序,后续会随机选择 + if _, err = mongo.DB().Find(ctx, filter, &list, entity.SpeechcraftCollection, page, orderBy); err != nil { + return nil, err + } + + // 收集所有匹配的话术 + matched := make([]*entity.Speechcraft, 0, len(list)) + for _, item := range list { + // 行为匹配(空=任意行为都匹配) + if !g.IsEmpty(item.Status) && item.Status != status { + continue + } + + // 关键字匹配(空=任意内容都匹配) + if len(item.Keywords) > 0 && !d.matchKeywords(content, item.Keywords) { + continue + } + + // 匹配成功,加入候选池 + matched = append(matched, item) + } + + // 从候选池随机选择一条 + if len(matched) > 0 { + script = matched[grand.Intn(len(matched))] + } + return +} + +// matchKeywords 检查内容是否包含任一关键字 +func (d *speechcraft) matchKeywords(content string, keywords []string) bool { + for _, kw := range keywords { + if g.IsEmpty(kw) { + continue + } + if strings.Contains(content, kw) { + return true + } + } + return false +} + +// FindByTag 根据tag查询话术(用于去重检查,同一租户下tag唯一) +func (d *speechcraft) FindByTag(ctx context.Context, tag string) (speechcraft *entity.Speechcraft, err error) { + filter := bson.M{ + "tag": tag, + "isDeleted": false, + } + err = mongo.DB().FindOne(ctx, filter, &speechcraft, entity.SpeechcraftCollection) + if err != nil { + if err.Error() == "mongo: no documents in result" { + return nil, nil + } + return nil, err + } + return +} + +// FindByTagAndPlatform 根据tag和platform查询话术(用于去重检查) +func (d *speechcraft) FindByTagAndPlatform(ctx context.Context, tag, platform string) (speechcraft *entity.Speechcraft, err error) { + filter := bson.M{ + "tag": tag, + "platform": platform, + "isDeleted": false, + } + err = mongo.DB().FindOne(ctx, filter, &speechcraft, entity.SpeechcraftCollection) + if err != nil { + if err.Error() == "mongo: no documents in result" { + return nil, nil + } + return nil, err + } + return +} + +// GetById 根据ID查询话术 +// 使用 MongoDAO(不需要token验证) +func (d *speechcraft) GetById(ctx context.Context, id string) (speechcraft *entity.Speechcraft, err error) { + objectId, err := bson.ObjectIDFromHex(id) + if err != nil { + return + } + filter := bson.M{"_id": objectId, "isDeleted": false} + + var result entity.Speechcraft + err = MongoDAO.FindOne(ctx, filter, &result, entity.SpeechcraftCollection) + if err != nil { + return nil, err + } + + // 如果未找到记录,result 是零值 + if result.Id.IsZero() { + return nil, nil + } + + speechcraft = &result + return +} + +// UpdateEntity 更新话术实体(用于绑定/解绑/同步等场景) +func (d *speechcraft) UpdateEntity(ctx context.Context, speechcraft *entity.Speechcraft) (err error) { + filter := bson.M{"_id": speechcraft.Id, "isDeleted": false} + + // 将实体转换为bson.M + updateDoc := bson.M{} + data, _ := bson.Marshal(speechcraft) + bson.Unmarshal(data, &updateDoc) + + _, err = mongo.DB().Update(ctx, filter, bson.M{"$set": updateDoc}, entity.SpeechcraftCollection) + return +} + +// FindByStage 查询指定阶段的所有话术 +func (d *speechcraft) FindByStage(ctx context.Context, stage int, platform string) (list []*entity.Speechcraft, err error) { + filter := bson.M{ + "stage": stage, + "isDeleted": false, + } + if !g.IsEmpty(platform) { + filter["platform"] = platform + } + // 使用统一的mongo.DB().Find方法(支持分页和排序) + page := &beans.Page{ + PageNum: 1, + PageSize: 10000, // 查询所有话术(不分页) + } + orderBy := []beans.OrderBy{ + {Field: "priority", Order: beans.Desc}, // 按优先级倒序 + } + _, err = mongo.DB().Find(ctx, filter, &list, entity.SpeechcraftCollection, page, orderBy) + return +} diff --git a/dao/user_stage_dao.go b/dao/user_stage_dao.go new file mode 100644 index 0000000..7fcddb9 --- /dev/null +++ b/dao/user_stage_dao.go @@ -0,0 +1,143 @@ +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 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e767071 --- /dev/null +++ b/go.mod @@ -0,0 +1,93 @@ +module customer-server + +go 1.25.7 + +replace gitea.com/red-future/common => ../common + +require ( + gitea.com/red-future/common v0.0.2 + 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 +) + +require ( + github.com/BurntSushi/toml v1.5.0 // indirect + github.com/armon/go-metrics v0.4.1 // 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/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/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/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 + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/golang/glog v1.2.5 // indirect + 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/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/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/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/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/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/pkg/errors v0.9.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/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 + 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/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 + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c7b3c6e --- /dev/null +++ b/go.sum @@ -0,0 +1,470 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +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/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= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= +github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= +github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA= +github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +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/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/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= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= +github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= +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/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= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgraph-io/badger/v4 v4.2.0 h1:kJrlajbXXL9DFTNuhhu9yCx7JJa4qpYWxtE8BzuWsEs= +github.com/dgraph-io/badger/v4 v4.2.0/go.mod h1:qfCqhPoWDFJRx1gp5QwwyGo8xk1lbHUxvK9nK0OGAak= +github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8= +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= +github.com/emirpasic/gods/v2 v2.0.0-alpha h1:dwFlh8pBg1VMOXWGipNMRt8v96dKAIvBehtCt6OtunU= +github.com/emirpasic/gods/v2 v2.0.0-alpha/go.mod h1:W0y4M2dtBB9U5z3YlghmpuUhiaZT2h6yoeE+C1sCp6A= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +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/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= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +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-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/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/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= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/glog v1.2.5 h1:DrW6hGnjIhtvhOIiAKT6Psh/Kd/ldepEa81DKeiRJ5I= +github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +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/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= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +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/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/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= +github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.5.0 h1:bI2ocEMgcVlz55Oj1xZNBsVi900c7II+fWDyV9o+13c= +github.com/hashicorp/go-hclog v1.5.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= +github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-msgpack v0.5.5 h1:i9R9JSrqIz0QVLz3sz+i3YJdT7TTSLcfLLzJi9aZTuI= +github.com/hashicorp/go-msgpack v0.5.5/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= +github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0SyteCQc= +github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A= +github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +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/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= +github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= +github.com/hashicorp/mdns v1.0.4/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc= +github.com/hashicorp/memberlist v0.5.0 h1:EtYPN8DpAURiapus508I4n9CzHs2W+8NZGbmmR/prTM= +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/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= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +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/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-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/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/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= +github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +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/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= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= +github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= +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/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/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/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.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +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.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +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/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= +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/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= +go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= +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= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +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/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/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/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-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= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +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/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= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +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/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= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +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/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/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= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +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/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= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +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/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/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/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= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +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= +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= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/main.go b/main.go new file mode 100644 index 0000000..d67fdfb --- /dev/null +++ b/main.go @@ -0,0 +1,95 @@ +package main + +import ( + "context" + "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" + _ "github.com/gogf/gf/contrib/nosql/redis/v2" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/glog" +) + +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接口 + }) + + // 初始化消费者管理器并注册所有消费者 + 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) + } + + // 初始化 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) + } + + // 监听系统信号,支持 Ctrl+C 优雅退出 + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + glog.Info(ctx, "收到停止信号,正在关闭服务...") + + // 停止所有消费者并关闭RabbitMQ连接 + mgr.Stop() + + glog.Info(ctx, "customerservice 服务已停止") +} diff --git a/model/dto/archive_dto.go b/model/dto/archive_dto.go new file mode 100644 index 0000000..f49cd1e --- /dev/null +++ b/model/dto/archive_dto.go @@ -0,0 +1,13 @@ +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"` // 执行结果消息 +} diff --git a/model/dto/customer_service_account_dto.go b/model/dto/customer_service_account_dto.go new file mode 100644 index 0000000..e5a5c84 --- /dev/null +++ b/model/dto/customer_service_account_dto.go @@ -0,0 +1,107 @@ +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"` +} diff --git a/model/dto/data_dto.go b/model/dto/data_dto.go new file mode 100644 index 0000000..791f9b1 --- /dev/null +++ b/model/dto/data_dto.go @@ -0,0 +1,74 @@ +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"` +} diff --git a/model/dto/data_statistics_dto.go b/model/dto/data_statistics_dto.go new file mode 100644 index 0000000..02eb91d --- /dev/null +++ b/model/dto/data_statistics_dto.go @@ -0,0 +1,73 @@ +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)"` // 筛选:结束日期 +} diff --git a/model/dto/product_dto.go b/model/dto/product_dto.go new file mode 100644 index 0000000..04314ea --- /dev/null +++ b/model/dto/product_dto.go @@ -0,0 +1,102 @@ +// 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"` +} diff --git a/model/dto/ragflow_chat_dto.go b/model/dto/ragflow_chat_dto.go new file mode 100644 index 0000000..090cbbe --- /dev/null +++ b/model/dto/ragflow_chat_dto.go @@ -0,0 +1,19 @@ +// 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"` +} diff --git a/model/dto/ragflow_config_dto.go b/model/dto/ragflow_config_dto.go new file mode 100644 index 0000000..659aed5 --- /dev/null +++ b/model/dto/ragflow_config_dto.go @@ -0,0 +1,41 @@ +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"` +} diff --git a/model/dto/ragflow_sync_dto.go b/model/dto/ragflow_sync_dto.go new file mode 100644 index 0000000..0d6bec7 --- /dev/null +++ b/model/dto/ragflow_sync_dto.go @@ -0,0 +1,29 @@ +// 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"` // 当前重试次数 +} diff --git a/model/dto/speechcraft_dto.go b/model/dto/speechcraft_dto.go new file mode 100644 index 0000000..ab7357c --- /dev/null +++ b/model/dto/speechcraft_dto.go @@ -0,0 +1,101 @@ +// 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"` +} diff --git a/model/dto/webhook_dto.go b/model/dto/webhook_dto.go new file mode 100644 index 0000000..aa38f0a --- /dev/null +++ b/model/dto/webhook_dto.go @@ -0,0 +1,51 @@ +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"` +} diff --git a/model/dto/websocket_dto.go b/model/dto/websocket_dto.go new file mode 100644 index 0000000..3b5467d --- /dev/null +++ b/model/dto/websocket_dto.go @@ -0,0 +1,58 @@ +package dto + +import ( + "gitea.com/red-future/common/beans" + "github.com/gogf/gf/v2/frame/g" +) + +// WebSocketConnectReq WebSocket 连接请求 +type WebSocketConnectReq struct { + g.Meta `path:"/connect" method:"get" tags:"WebSocket" summary:"WebSocket连接" dc:"建立WebSocket连接,用于实时消息推送"` + UserId string `p:"user_id" v:"required#用户ID不能为空" dc:"用户ID"` + Platform string `p:"platform" d:"xiaohongshu" dc:"平台(xiaohongshu/douyin/kuaishou)"` +} + +type WebSocketConnectRes = beans.ResponseEmpty + +// WebSocketSendReq 发送消息请求 +type WebSocketSendReq struct { + g.Meta `path:"/send" method:"post" tags:"WebSocket" summary:"发送消息" dc:"发送消息到Redis Stream,message服务会消费并处理"` + UserId string `json:"userId" v:"required#用户ID不能为空" dc:"用户ID"` + Content string `json:"content" v:"required#消息内容不能为空" dc:"消息内容"` +} + +type WebSocketSendRes struct { + MessageId string `json:"messageId" dc:"消息ID"` +} + +// WebSocketBroadcastReq 广播消息请求 +type WebSocketBroadcastReq struct { + g.Meta `path:"/broadcast" method:"post" tags:"WebSocket" summary:"广播消息" dc:"向所有WebSocket连接广播消息(测试用)"` + Content string `json:"content" v:"required#消息内容不能为空" dc:"消息内容"` +} + +type WebSocketBroadcastRes = beans.ResponseEmpty + +// WebSocketOnlineReq 获取在线用户请求 +type WebSocketOnlineReq struct { + g.Meta `path:"/online" method:"get" tags:"WebSocket" summary:"获取在线用户" dc:"获取当前所有WebSocket在线用户"` +} + +type WebSocketOnlineRes struct { + Count int `json:"count" dc:"在线用户数"` + Users []WebSocketOnlineUserRes `json:"users" dc:"在线用户列表"` +} + +type WebSocketOnlineUserRes struct { + UserId string `json:"userId" dc:"用户ID"` + Platform string `json:"platform" dc:"平台"` + CreatedAt int64 `json:"createdAt" dc:"连接时间戳"` +} + +// WebSocketPushMsg WebSocket 推送消息结构 +type WebSocketPushMsg struct { + Type string `json:"type"` // 消息类型: ack, error, broadcast, answer + MessageId string `json:"message_id,omitempty"` // 消息ID(ack 时返回) + Message string `json:"message,omitempty"` // 提示消息 + Content string `json:"content,omitempty"` // 内容(broadcast/answer 时返回) +} diff --git a/model/dto/xiaohongshu_dto.go b/model/dto/xiaohongshu_dto.go new file mode 100644 index 0000000..2acf66b --- /dev/null +++ b/model/dto/xiaohongshu_dto.go @@ -0,0 +1,427 @@ +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"` +} diff --git a/model/entity/conversation.go b/model/entity/conversation.go new file mode 100644 index 0000000..04c6fd8 --- /dev/null +++ b/model/entity/conversation.go @@ -0,0 +1,27 @@ +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"` // 消息时间 +} diff --git a/model/entity/conversation_archive.go b/model/entity/conversation_archive.go new file mode 100644 index 0000000..7d577a7 --- /dev/null +++ b/model/entity/conversation_archive.go @@ -0,0 +1,71 @@ +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" diff --git a/model/entity/customer_service_account.go b/model/entity/customer_service_account.go new file mode 100644 index 0000000..b9d9f5a --- /dev/null +++ b/model/entity/customer_service_account.go @@ -0,0 +1,33 @@ +package entity + +import ( + "gitea.com/red-future/common/beans" +) + +// CustomerServiceAccount 客服账号实体 +const CustomerServiceAccountCollection = "customer_service_account" + +type CustomerServiceAccount struct { + beans.MongoBaseDO `bson:",inline"` // 嵌入基础字段:Id, Creator, CreatedAt, Updater, UpdatedAt, TenantId, IsDeleted + + // 业务字段 + AccountName string `bson:"accountName" json:"accountName"` // 客服账号名称(如cs_xhs_qixue) + IsDisabled bool `bson:"isDisabled" json:"isDisabled"` // 是否禁用(true-禁用,false-启用) + Greeting string `bson:"greeting" json:"greeting"` // 开场白(WebSocket连接时自动发送) + Prompt string `bson:"-" json:"prompt"` // 提示词(不存储到MongoDB,查询时从ragflow_config关联获取) + SelfIdentity string `bson:"selfIdentity" json:"selfIdentity"` // AI身份描述(前端显示为"AI身份") + + // 绑定的资源(主体持有关系) + SpeechcraftIds []string `bson:"speechcraftIds" json:"speechcraftIds"` // 绑定的话术ID列表 + ProductIds []string `bson:"productIds" json:"productIds"` // 绑定的产品ID列表 + Platform string `bson:"platform" json:"platform"` // 客服平台(如:xiaohongshu、douyin、kuaishou) + + // 小红书平台专属字段(仅platform=xiaohongshu时有效) + AccessToken string `bson:"accessToken,omitempty" json:"accessToken,omitempty"` // 小红书AccessToken(14天有效期) + AppId int64 `bson:"appId,omitempty" json:"appId,omitempty"` // 小红书应用ID + SecretKey string `bson:"secretKey,omitempty" json:"secretKey,omitempty"` // 小红书加解密密钥 + XhsUserId string `bson:"xhsUserId,omitempty" json:"xhsUserId,omitempty"` // 小红书用户ID + ContactCardMessage string `bson:"contactCardMessage,omitempty" json:"contactCardMessage,omitempty"` // 留资卡文案(不同领域客服使用不同文案) + NameCardMessage string `bson:"nameCardMessage,omitempty" json:"nameCardMessage,omitempty"` // 名片文案(不同领域客服使用不同文案) + CardTriggerCount int `bson:"cardTriggerCount,omitempty" json:"cardTriggerCount,omitempty"` // 卡片触发次数(默认5,不同客服可定制) +} diff --git a/model/entity/data.go b/model/entity/data.go new file mode 100644 index 0000000..45b9518 --- /dev/null +++ b/model/entity/data.go @@ -0,0 +1,25 @@ +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"` // 消息时间 +} diff --git a/model/entity/data_statistics.go b/model/entity/data_statistics.go new file mode 100644 index 0000000..5925ae9 --- /dev/null +++ b/model/entity/data_statistics.go @@ -0,0 +1,30 @@ +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秒响应率 +} diff --git a/model/entity/product.go b/model/entity/product.go new file mode 100644 index 0000000..0f14f62 --- /dev/null +++ b/model/entity/product.go @@ -0,0 +1,25 @@ +// 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"` // 最后同步时间 +} diff --git a/model/entity/ragflow_binding.go b/model/entity/ragflow_binding.go new file mode 100644 index 0000000..ad4f065 --- /dev/null +++ b/model/entity/ragflow_binding.go @@ -0,0 +1,13 @@ +// 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"` // 重试次数 +} diff --git a/model/entity/ragflow_config.go b/model/entity/ragflow_config.go new file mode 100644 index 0000000..6bb0ae0 --- /dev/null +++ b/model/entity/ragflow_config.go @@ -0,0 +1,46 @@ +// Package entity - RAGFlow配置实体 +// 功能:客服账号级RAGFlow配置,每个客服账号一个独立的对话实例 +// 架构说明: +// - 租户级:一个知识库(dataset),存储所有话术 +// - 客服级:每个客服有独立的Chat实例,通过document_ids筛选使用哪些话术 +package entity + +import ( + "time" + + "gitea.com/red-future/common/beans" +) + +const RAGFlowConfigCollection = "ragflow_config" + +// RAGFlowConfig RAGFlow配置实体(客服账号级别) +type RAGFlowConfig struct { + beans.MongoBaseDO `bson:",inline"` // TenantId继承自基础DO + + // 客服账号标识 + AccountName string `bson:"accountName" json:"accountName"` // 客服账号名称 + Platform string `bson:"platform" json:"platform"` // 平台(xiaohongshu、douyin) + + // 租户级知识库(整个租户共享) + DatasetId string `bson:"datasetId" json:"datasetId"` // 话术知识库ID(RAGFlow) + DatasetIds []string `bson:"datasetIds" json:"datasetIds"` // Chat绑定的知识库ID列表(与RAGFlow API保持一致) + DatasetName string `bson:"datasetName" json:"datasetName"` // 话术知识库名称 + + // 客服账号级对话实例(每个客服独立) + ChatId string `bson:"chatId" json:"chatId"` // RAGFlow对话实例ID + Prompt string `bson:"prompt" json:"prompt"` // 提示词内容(完整) + + // 文档筛选(该客服使用哪些话术文档) + DocumentIds []string `bson:"documentIds" json:"documentIds"` // 绑定的话术文档ID列表(从租户知识库中筛选) + + // 检索参数 + SimilarityThreshold float64 `bson:"similarityThreshold" json:"similarityThreshold"` // 相似度阈值(默认0.2) + KeywordsSimilarityWeight float64 `bson:"keywordsSimilarityWeight" json:"keywordsSimilarityWeight"` // 关键词权重(默认0.7) + TopN int `bson:"topN" json:"topN"` // 返回chunk数量(默认8) + EmptyResponse string `bson:"emptyResponse" json:"emptyResponse"` // 无匹配时回复 + + // 同步状态 + SyncStatus string `bson:"syncStatus" json:"syncStatus"` // synced/pending/failed + LastSyncTime *time.Time `bson:"lastSyncTime" json:"lastSyncTime"` // 最后同步时间 + SyncError string `bson:"syncError" json:"syncError"` // 同步错误信息 +} diff --git a/model/entity/session.go b/model/entity/session.go new file mode 100644 index 0000000..f756660 --- /dev/null +++ b/model/entity/session.go @@ -0,0 +1,29 @@ +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"` // 归档时间 +} diff --git a/model/entity/speechcraft.go b/model/entity/speechcraft.go new file mode 100644 index 0000000..d540ee8 --- /dev/null +++ b/model/entity/speechcraft.go @@ -0,0 +1,32 @@ +// 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"` // 最后同步时间 +} diff --git a/model/entity/user_stage.go b/model/entity/user_stage.go new file mode 100644 index 0000000..62de2ac --- /dev/null +++ b/model/entity/user_stage.go @@ -0,0 +1,45 @@ +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" // 发关键字 +) diff --git a/service/archive_service.go b/service/archive_service.go new file mode 100644 index 0000000..d9dfd6e --- /dev/null +++ b/service/archive_service.go @@ -0,0 +1,207 @@ +// 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) +} diff --git a/service/config_service.go b/service/config_service.go new file mode 100644 index 0000000..c312dec --- /dev/null +++ b/service/config_service.go @@ -0,0 +1,211 @@ +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) +} diff --git a/service/conversation_service.go b/service/conversation_service.go new file mode 100644 index 0000000..769d84a --- /dev/null +++ b/service/conversation_service.go @@ -0,0 +1,353 @@ +// 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 +} diff --git a/service/customer_service_account_service.go b/service/customer_service_account_service.go new file mode 100644 index 0000000..70e16f1 --- /dev/null +++ b/service/customer_service_account_service.go @@ -0,0 +1,855 @@ +// 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 +} diff --git a/service/data_service.go b/service/data_service.go new file mode 100644 index 0000000..ec945e9 --- /dev/null +++ b/service/data_service.go @@ -0,0 +1,69 @@ +// 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 +} diff --git a/service/data_statistics_service.go b/service/data_statistics_service.go new file mode 100644 index 0000000..b72e519 --- /dev/null +++ b/service/data_statistics_service.go @@ -0,0 +1,168 @@ +// 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) +} diff --git a/service/dataset_service.go b/service/dataset_service.go new file mode 100644 index 0000000..9df8f92 --- /dev/null +++ b/service/dataset_service.go @@ -0,0 +1,197 @@ +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 +} diff --git a/service/jaegertesttemp.go b/service/jaegertesttemp.go new file mode 100644 index 0000000..664b57b --- /dev/null +++ b/service/jaegertesttemp.go @@ -0,0 +1,17 @@ +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错误记录") +} diff --git a/service/product_service.go b/service/product_service.go new file mode 100644 index 0000000..d22b6fc --- /dev/null +++ b/service/product_service.go @@ -0,0 +1,891 @@ +// Package service - 产品服务 +// 功能:产品的增删改查、ZIP导入/导出、绑定/解绑客服账号、同步到RAGFlow、重试消费者 +package service + +import ( + "archive/zip" + "bytes" + "context" + "customer-server/dao" + "customer-server/model/dto" + "customer-server/model/entity" + "customer-server/util" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "strings" + "unicode/utf8" + + "gitea.com/red-future/common/jaeger" + "gitea.com/red-future/common/rabbitmq" + "gitea.com/red-future/common/ragflow" + "gitea.com/red-future/common/utils" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/glog" + "github.com/gogf/gf/v2/os/grpool" + "github.com/gogf/gf/v2/os/gtime" + "github.com/gogf/gf/v2/util/gconv" + "go.mongodb.org/mongo-driver/v2/bson" +) + +var ( + Product = new(product) + productGrpool = grpool.New(50) // 文档解析协程池,最大50并发 +) + +type product struct{} + +// Add 添加产品 +// 参数: ctx - 上下文,req - 添加产品请求(包含产品名称、描述、价格等) +// 返回: res - 添加成功后的产品ID和RAGFlow文档ID,err - 错误信息 +// 功能: 创建产品记录并自动上传到RAGFlow产品知识库(独立于话术知识库) +func (s *product) Add(ctx context.Context, req *dto.AddProductReq) (res *dto.AddProductRes, err error) { + // 校验产品名称长度 + if utf8.RuneCountInString(req.Name) > 64 { + return nil, gerror.New("产品名称必须在64字以内") + } + + // 校验产品详情长度 + if utf8.RuneCountInString(req.Description) > 8192 { + return nil, gerror.New("产品详情必须在8192字以内") + } + + // 去重检查:同一租户下名称唯一 + existing, err := dao.Product.FindByName(ctx, req.Name) + if err != nil { + return nil, gerror.Wrap(err, "检查产品重复失败") + } + if existing != nil { + return nil, gerror.Newf("产品名称已存在:name=%s, id=%s", req.Name, existing.Id.Hex()) + } + + data := &entity.Product{} + if err = utils.Struct(req, data); err != nil { + return + } + // 先从token获取租户信息(在Insert之前) + user, err := util.GetTenantInfo(ctx) + if err != nil { + return nil, gerror.Wrap(err, "获取租户信息失败") + } + tenantId := gconv.String(user.TenantId) + if tenantId == "" { + return nil, gerror.New("租户ID为空") + } + + // 确保产品知识库存在(检查话术知识库是否存在,作为租户是否初始化的标志) + speechcraftDatasetId, err := dao.RAGFlowConfig.FindDatasetIdByTenant(ctx, tenantId) + if err != nil || speechcraftDatasetId == "" { + return nil, gerror.Newf("租户知识库不存在,请先创建客服账号: tenant_id=%s", tenantId) + } + + // 设置基础字段 + now := gtime.Now().Time + data.CreatedAt = &now // 取地址赋值给指针类型 + data.UpdatedAt = &now // 取地址赋值给指针类型 + data.IsDeleted = false + // 统一使用string类型存储tenantId到MongoDB + data.TenantId = tenantId + + // 插入产品到MongoDB + _, err = dao.Product.Insert(ctx, data) + if err != nil { + return nil, err + } + + // 确保租户知识库存在(产品和话术共享租户知识库) + // 使用dataset_service提供的统一方法,自动处理创建、查找、保存等逻辑 + datasetId, err := EnsureTenantDataset(ctx, tenantId) + if err != nil { + g.Log().Errorf(ctx, "确保租户知识库失败: %v", err) + return nil, gerror.Wrap(err, "获取租户知识库失败") + } + g.Log().Infof(ctx, "租户%s的知识库ID: %s", tenantId, datasetId) + + // 同步上传到RAGFlow + ragflowClient := ragflow.GetGlobalClient() + g.Log().Infof(ctx, "准备上传产品到RAGFlow: product_id=%s, dataset_id=%s, name=%s", data.Id.Hex(), datasetId, data.Name) + filename := fmt.Sprintf("%s.txt", data.Name) + documentId, err := ragflowClient.UploadDocumentFromText(ctx, datasetId, data.Description, filename) + if err != nil { + // 回滚:删除刚插入的产品 + dao.MongoDAO.Delete(ctx, bson.M{"_id": data.Id}, entity.ProductCollection) + g.Log().Errorf(ctx, "产品上传RAGFlow失败: product_id=%s, dataset_id=%s, error=%v", data.Id.Hex(), datasetId, err) + jaeger.RecordError(ctx, err, "产品上传RAGFlow失败") + return nil, gerror.Wrap(err, "文档上传到知识库失败") + } + + // 异步触发解析(grpool自动管理goroutine生命周期,WithoutCancel保留追踪避免取消) + productGrpool.Add(ctx, func(ctx context.Context) { + parseCtx := context.WithoutCancel(ctx) + if err := ragflowClient.ParseDocuments(parseCtx, datasetId, []string{documentId}); err != nil { + g.Log().Errorf(parseCtx, "文档解析失败: document_id=%s, error=%v", documentId, err) + } else { + g.Log().Infof(parseCtx, "文档解析成功: document_id=%s", documentId) + } + }) + + // 更新MongoDB的RagSyncRecords数组(使用空accountName表示租户级文档) + syncTime := gtime.Now().Format("Y-m-d H:i:s") + record := entity.RagSyncRecord{ + AccountName: "", // 空表示租户级文档 + RagDocumentId: documentId, + RagSyncStatus: "synced", + SyncTime: syncTime, + RetryCount: 0, + } + filter := bson.M{"_id": data.Id} + update := bson.M{ + "$set": bson.M{ + "ragSyncRecords": []entity.RagSyncRecord{record}, + "ragLastSyncTime": syncTime, + "updatedAt": gtime.Now().Time, + }, + } + if _, _, err = dao.MongoDAO.UpdateOne(ctx, filter, update, entity.ProductCollection); err != nil { + g.Log().Errorf(ctx, "更新产品RagSyncRecords失败: %v", err) + // 不回滚,文档已上传成功 + } + + g.Log().Infof(ctx, "产品添加成功并上传到知识库: product_id=%s, document_id=%s", data.Id.Hex(), documentId) + res = &dto.AddProductRes{Id: data.Id.Hex()} + return +} + +// Update 更新产品 +// 参数: ctx - 上下文,req - 更新产品请求(包含产品ID和待更新字段) +// 返回: err - 错误信息 +// 功能: 更新产品信息并同步到RAGFlow,支持文档删除重建 +func (s *product) Update(ctx context.Context, req *dto.UpdateProductReq) (err error) { + // 如果更新了产品名称,校验长度 + if req.Name != "" && utf8.RuneCountInString(req.Name) > 64 { + return gerror.New("产品名称必须在64字以内") + } + + // 如果更新了产品详情,校验长度 + if req.Description != "" && utf8.RuneCountInString(req.Description) > 8192 { + return gerror.New("产品详情必须在8192字以内") + } + + // 去重检查:如果修改名称,检查是否与其他产品重复 + if req.Name != "" { + existing, err := dao.Product.FindByName(ctx, req.Name) + if err != nil { + return gerror.Wrap(err, "检查产品重复失败") + } + if existing != nil && existing.Id.Hex() != req.Id { + return gerror.Newf("产品名称已存在:name=%s, id=%s", req.Name, existing.Id.Hex()) + } + } + + return dao.Product.Update(ctx, req) +} + +// Delete 删除产品 +// 参数: ctx - 上下文,req - 删除产品请求(包含产品ID) +// 返回: err - 错误信息 +// 功能: 逻辑删除产品记录并从RAGFlow移除对应文档 +func (s *product) Delete(ctx context.Context, req *dto.DeleteProductReq) (err error) { + g.Log().Infof(ctx, "[Delete] 开始删除产品 - productId: %s", req.Id) + + // 1. 查询产品,获取RAGFlow同步记录(使用原生查询,避免租户过滤) + objectId, err := bson.ObjectIDFromHex(req.Id) + if err != nil { + return gerror.Wrap(err, "无效的产品ID") + } + + var product entity.Product + filter := bson.M{"_id": objectId, "isDeleted": false} + err = dao.MongoDAO.FindOne(ctx, filter, &product, entity.ProductCollection) + if err != nil { + if err.Error() == "mongo: no documents in result" { + return gerror.New("产品不存在") + } + return gerror.Wrap(err, "查询产品失败") + } + + g.Log().Infof(ctx, "[Delete] 查询到产品 - name: %s, ragSyncRecords数量: %d", product.Name, len(product.RagSyncRecords)) + + // 2. 删除RAGFlow中的文档 + if len(product.RagSyncRecords) > 0 { + ragflowClient := ragflow.GetGlobalClient() + if ragflowClient != nil { + tenantId := gconv.String(product.TenantId) + + // 查询租户的dataset_id + datasetId, err := dao.RAGFlowConfig.FindDatasetIdByTenant(ctx, tenantId) + if err != nil { + g.Log().Warningf(ctx, "查询租户知识库ID失败: %v", err) + } else if datasetId != "" { + // 收集所有需要删除的document_id + var documentIds []string + for _, record := range product.RagSyncRecords { + if record.RagDocumentId != "" { + documentIds = append(documentIds, record.RagDocumentId) + } + } + + // 批量删除RAGFlow文档 + if len(documentIds) > 0 { + if err := ragflowClient.DeleteDocument(ctx, datasetId, documentIds); err != nil { + g.Log().Errorf(ctx, "删除RAGFlow文档失败: %v, document_ids: %v", err, documentIds) + // 不阻断删除流程,记录错误后继续 + } else { + g.Log().Infof(ctx, "成功删除RAGFlow文档: count=%d", len(documentIds)) + } + } + } + } + } + + // 3. 软删除MongoDB记录 + return dao.Product.Delete(ctx, req) +} + +// List 获取产品列表 +// 参数: ctx - 上下文,req - 列表查询请求(支持分页、关键词搜索) +// 返回: res - 产品列表及分页信息,err - 错误信息 +// 功能: 分页查询产品记录,支持按名称、描述模糊搜索 +func (s *product) List(ctx context.Context, req *dto.ListProductReq) (res *dto.ListProductRes, err error) { + list, total, err := dao.Product.List(ctx, req) + if err != nil { + return + } + res = &dto.ListProductRes{ + List: list, + Total: int(total), + } + return +} + +// Export 导出产品为ZIP文件 +// 参数: ctx - 上下文,req - 导出请求(包含筛选条件) +// 返回: zipData - ZIP文件字节数组,filename - 文件名,err - 错误信息 +// 功能: 将产品数据导出为ZIP文件,包含JSON格式的产品列表 +func (s *product) Export(ctx context.Context, req *dto.ExportProductReq) (zipData []byte, filename string, err error) { + // 清理输入参数,防止非法 UTF-8 字符 + cleanName := strings.ToValidUTF8(req.Name, "") + + // 1. 查询所有符合条件的产品 + products, err := dao.Product.FindAllForExport(ctx, cleanName) + if err != nil { + return nil, "", err + } + + if len(products) == 0 { + return nil, "", gerror.New("没有可导出的产品") + } + + // 清理所有产品数据,确保 UTF-8 有效(防止数据库中的脏数据) + for i := range products { + products[i].Name = strings.ToValidUTF8(products[i].Name, "") + products[i].Description = strings.ToValidUTF8(products[i].Description, "") + // RagDocumentId字段在RagSyncRecords中,不在Product主体 + } + + // 2. 创建 ZIP 文件(内存中) + var buf bytes.Buffer + zipWriter := zip.NewWriter(&buf) + defer zipWriter.Close() + + // 3. 为每个产品生成 TXT 文件并添加到 ZIP + for _, product := range products { + // 生成 TXT 内容(产品详情) + txtContent := s.generateTxt(product) + + // 文件名就是产品名称(清理特殊字符) + cleanName := strings.ToValidUTF8(product.Name, "未命名") + safeFilename := s.sanitizeFilename(cleanName) + if safeFilename == "" { + safeFilename = "product" + } + txtFilename := safeFilename + ".txt" + + // 添加文件到 ZIP + writer, err := zipWriter.Create(txtFilename) + if err != nil { + return nil, "", gerror.Newf("创建ZIP文件失败: %v", err) + } + + if _, err := writer.Write([]byte(txtContent)); err != nil { + return nil, "", gerror.Newf("写入ZIP文件失败: %v", err) + } + } + + // 5. 生成下载文件名 + timestamp := gtime.Now().Format("Ymd_His") + filename = "products_export_" + timestamp + ".zip" + + return buf.Bytes(), filename, nil +} + +// generateTxt 将产品转换为 TXT 格式 +// 新格式:文件名=产品名称,内容=产品详情 +func (s *product) generateTxt(product *entity.Product) string { + // 清理产品详情,确保 UTF-8 有效 + cleanDescription := strings.ToValidUTF8(product.Description, "") + + // 直接返回产品详情 + if cleanDescription != "" { + return cleanDescription + } + + // 如果没有详情,返回空字符串 + return "" +} + +// sanitizeFilename 清理文件名中的特殊字符 +func (s *product) sanitizeFilename(name string) string { + // 替换不安全的文件名字符 + replacer := map[rune]rune{ + '/': '_', + '\\': '_', + ':': '_', + '*': '_', + '?': '_', + '"': '_', + '<': '_', + '>': '_', + '|': '_', + } + + // 预分配容量,避免循环中动态扩容 + result := make([]rune, 0, len(name)) + for _, char := range name { + if newChar, exists := replacer[char]; exists { + result = append(result, newChar) + } else { + result = append(result, char) + } + } + + filename := string(result) + // 限制文件名长度 + if utf8.RuneCountInString(filename) > 50 { + runes := []rune(filename) + filename = string(runes[:50]) + } + + return filename +} + +// Import 从ZIP文件导入产品 +// 参数: ctx - 上下文,file - 上传的ZIP文件 +// 返回: res - 导入结果(成功和失败数量),err - 错误信息 +// 功能: 从ZIP文件批量导入产品数据并同步到RAGFlow,失败记录加入重试队列 +func (s *product) Import(ctx context.Context, file *multipart.FileHeader) (res *dto.ImportProductRes, err error) { + res = &dto.ImportProductRes{ + SuccessCount: 0, + FailCount: 0, + FailReasons: []string{}, + } + + // 1. 获取租户信息 + user, err := util.GetTenantInfo(ctx) + if err != nil { + return nil, gerror.Wrap(err, "获取租户信息失败") + } + tenantId := gconv.String(user.TenantId) + if tenantId == "" { + return nil, gerror.New("租户ID为空") + } + + // 2. 打开上传的文件 + uploadedFile, err := file.Open() + if err != nil { + return nil, gerror.Newf("无法打开上传的文件: %v", err) + } + defer uploadedFile.Close() + + // 3. 读取文件内容到内存 + fileData, err := io.ReadAll(uploadedFile) + if err != nil { + return nil, gerror.Newf("读取文件失败: %v", err) + } + + // 4. 解析 ZIP 文件 + zipReader, err := zip.NewReader(bytes.NewReader(fileData), int64(len(fileData))) + if err != nil { + return nil, gerror.Newf("无法解析ZIP文件: %v", err) + } + + // 4. 遍历 ZIP 中的所有文件 + for _, zipFile := range zipReader.File { + // 只处理 .txt 文件 + if !strings.HasSuffix(strings.ToLower(zipFile.Name), ".txt") { + continue + } + + // 读取 TXT 文件内容(产品详情) + txtContent, err := s.readZipFile(zipFile) + if err != nil { + res.FailCount++ + res.FailReasons = append(res.FailReasons, "文件 "+zipFile.Name+" 读取失败: "+err.Error()) + continue + } + + // 从文件名提取产品名称(移除 .txt 后缀) + productName := strings.TrimSuffix(zipFile.Name, ".txt") + productName = strings.TrimSpace(productName) + + // 创建产品数据 + productData, err := s.parseSimpleTxt(productName, txtContent) + if err != nil { + res.FailCount++ + res.FailReasons = append(res.FailReasons, "文件 "+zipFile.Name+" 解析失败: "+err.Error()) + continue + } + + // 校验产品名称和详情长度 + if utf8.RuneCountInString(productData.Name) > 64 { + res.FailCount++ + res.FailReasons = append(res.FailReasons, "文件 "+zipFile.Name+": 产品名称超过64字") + continue + } + if utf8.RuneCountInString(productData.Description) > 8192 { + res.FailCount++ + res.FailReasons = append(res.FailReasons, "文件 "+zipFile.Name+": 产品详情超过8192字") + continue + } + + // 设置基础字段 + now := gtime.Now().Time + productData.CreatedAt = &now // 取地址赋值给指针类型 + productData.UpdatedAt = &now // 取地址赋值给指针类型 + productData.IsDeleted = false + // 统一使用string类型存储tenantId到MongoDB + productData.TenantId = tenantId + + // 插入数据库 + _, err = dao.Product.Insert(ctx, productData) + if err != nil { + res.FailCount++ + res.FailReasons = append(res.FailReasons, "文件 "+zipFile.Name+" 数据库插入失败: "+err.Error()) + continue + } + + // 同步上传到RAGFlow产品知识库(使用外层已声明的tenantId变量) + if tenantId != "" { + datasetId := fmt.Sprintf("dataset_product_tenant_%s", tenantId) + ragflowClient := ragflow.GetGlobalClient() + if ragflowClient != nil { + filename := fmt.Sprintf("%s.txt", productData.Name) + documentId, uploadErr := ragflowClient.UploadDocumentFromText(ctx, datasetId, productData.Description, filename) + if uploadErr != nil { + // 上传失败:回滚删除MongoDB记录 + dao.MongoDAO.Delete(ctx, bson.M{"_id": productData.Id}, entity.ProductCollection) + res.FailCount++ + res.FailReasons = append(res.FailReasons, "文件 "+zipFile.Name+" 上传知识库失败: "+uploadErr.Error()) + continue + } + + // 更新ragDocumentId + filter := bson.M{"_id": productData.Id} + update := bson.M{"$set": bson.M{"ragDocumentId": documentId}} + dao.MongoDAO.UpdateOne(ctx, filter, update, entity.ProductCollection) + + g.Log().Infof(ctx, "ZIP产品上传成功: name=%s, document_id=%s", productData.Name, documentId) + } + } + + res.SuccessCount++ + } + + return res, nil +} + +// readZipFile 读取 ZIP 文件中的单个文件内容 +func (s *product) readZipFile(file *zip.File) (string, error) { + reader, err := file.Open() + if err != nil { + return "", err + } + defer reader.Close() + + content, err := io.ReadAll(reader) + if err != nil { + return "", err + } + + return string(content), nil +} + +// parseSimpleTxt 解析简化格式的 TXT 文件 +// 文件名=产品名称,内容=产品详情 +func (s *product) parseSimpleTxt(productName string, description string) (*entity.Product, error) { + product := &entity.Product{} + + // 清理并验证产品名称 + product.Name = strings.TrimSpace(strings.ToValidUTF8(productName, "")) + if product.Name == "" { + return nil, gerror.New("产品名称不能为空") + } + + // 清理产品详情 + product.Description = strings.TrimSpace(strings.ToValidUTF8(description, "")) + + return product, nil +} + +// parseTxt 解析 TXT 文件内容为产品实体(旧格式兼容) +func (s *product) parseTxt(content string) (*entity.Product, error) { + product := &entity.Product{} + lines := strings.Split(content, "\n") + + var inDescription bool + var descriptionLines []string + + for _, line := range lines { + line = strings.TrimSpace(line) + + // 跳过空行 + if line == "" { + continue + } + + // 解析标题 + if strings.HasPrefix(line, "=== ") && strings.HasSuffix(line, " ===") { + product.Name = strings.TrimSpace(line[4 : len(line)-4]) + continue + } + + // 跳过基本信息标记 + if line == "【基本信息】" { + inDescription = false + continue + } + + // 产品详情开始 + if line == "【产品详情】" { + inDescription = true + continue + } + + // 解析基本信息字段 + if !inDescription { + // 跳过系统生成的字段(产品ID、创建时间等) + if strings.HasPrefix(line, "产品ID:") || + strings.HasPrefix(line, "创建时间:") || + strings.HasPrefix(line, "更新时间:") || + strings.HasPrefix(line, "导出时间:") { + continue + } + + // 解析 RAGFlow 文档 ID + if strings.HasPrefix(line, "RAGFlow文档ID:") { + // RAGFlow文档ID存储在RagSyncRecords中 + continue + } + } + + // 收集产品详情 + if inDescription { + // 跳过分隔线和导出时间 + if strings.Contains(line, "==================") || strings.HasPrefix(line, "导出时间:") { + continue + } + if line != "暂无产品详情" { + descriptionLines = append(descriptionLines, line) + } + } + } + + // 拼接产品详情 + product.Description = strings.Join(descriptionLines, "\n") + + // 校验必填字段 + if product.Name == "" { + return nil, gerror.New("产品名称不能为空") + } + + return product, nil +} + +// BindToCustomerServices 绑定产品到多个客服账号 +func (p *product) BindToCustomerServices(ctx context.Context, req *dto.BindProductReq) (res *dto.BindProductRes, err error) { + res = &dto.BindProductRes{} + + // 1. 查询产品 + product, err := dao.Product.GetById(ctx, req.ProductId) + if err != nil { + return nil, gerror.Wrapf(err, "查询产品失败") + } + if product == nil { + return nil, gerror.New("产品不存在") + } + + // 2. 构建已存在的绑定map(去重) + existingMap := make(map[string]bool) + for _, csId := range product.AccountNames { + existingMap[csId] = true + } + + // 3. 过滤并添加新绑定 + var newBindings []string + var failedIds []string + + for _, csId := range req.AccountNames { + // 检查去重:customer_service_id是否已存在 + if existingMap[csId] { + failedIds = append(failedIds, csId) + g.Log().Warningf(ctx, "客服账号 %s 已绑定该产品,跳过", csId) + continue + } + + // 验证客服账号是否存在 + csAccount, err := dao.CustomerServiceAccount.FindByAccountName(ctx, csId) + if err != nil || csAccount == nil { + failedIds = append(failedIds, csId) + g.Log().Warningf(ctx, "客服账号 %s 不存在或已删除,跳过", csId) + continue + } + + newBindings = append(newBindings, csId) + } + + // 4. 如果没有新的绑定,直接返回 + if len(newBindings) == 0 { + res.SuccessCount = 0 + res.FailedIds = failedIds + res.Message = "所有客服账号均已绑定或不存在" + return res, nil + } + + // 5. 更新产品绑定 + product.AccountNames = append(product.AccountNames, newBindings...) + if err = dao.Product.UpdateEntity(ctx, product); err != nil { + return nil, gerror.Wrapf(err, "更新产品绑定失败") + } + + // 6. 同步到RAGFlow(自动创建知识库) + for _, csId := range newBindings { + // 获取客服账号信息以获取tenant_id + csAccount, err := dao.CustomerServiceAccount.FindByAccountName(ctx, csId) + if err != nil || csAccount == nil { + g.Log().Errorf(ctx, "获取客服账号信息失败: %s", csId) + continue + } + + // 同步到RAGFlow(会自动创建知识库) + tenantId := gconv.String(csAccount.TenantId) + _, err = p.SyncToRAGFlow(ctx, req.ProductId, csId, tenantId) + if err != nil { + g.Log().Errorf(ctx, "同步到RAGFlow失败: product_id=%s, cs_id=%s, error=%v", req.ProductId, csId, err) + // 不阻断绑定流程,失败会进入重试队列 + } + } + + res.SuccessCount = len(newBindings) + res.FailedIds = failedIds + res.Message = "绑定成功" + return +} + +// UnbindFromCustomerService 从客服账号解绑产品 +func (p *product) UnbindFromCustomerService(ctx context.Context, req *dto.UnbindProductReq) (res *dto.UnbindProductRes, err error) { + res = &dto.UnbindProductRes{} + + product, err := dao.Product.GetById(ctx, req.ProductId) + if err != nil { + return nil, gerror.Wrapf(err, "查询产品失败") + } + if product == nil { + return nil, gerror.New("产品不存在") + } + + // 查找并移除绑定 + var newBindings []string + found := false + + for _, csId := range product.AccountNames { + if csId == req.AccountName { + found = true + continue + } + newBindings = append(newBindings, csId) + } + + if !found { + res.Success = false + res.Message = "未找到该绑定关系" + return res, nil + } + + product.AccountNames = newBindings + if err = dao.Product.UpdateEntity(ctx, product); err != nil { + return nil, gerror.Wrapf(err, "解绑失败") + } + + res.Success = true + res.Message = "解绑成功" + return +} + +// SyncToRAGFlow 同步产品到RAGFlow(租户级知识库) +func (p *product) SyncToRAGFlow(ctx context.Context, productId, accountName, tenantId string) (documentId string, err error) { + // 1. 查询产品 + product, err := dao.Product.GetById(ctx, productId) + if err != nil { + return "", gerror.Wrapf(err, "查询产品失败") + } + if product == nil { + return "", gerror.New("产品不存在") + } + + // 2. 获取租户的产品知识库ID + datasetId := fmt.Sprintf("dataset_product_tenant_%s", tenantId) + + // 2.1 确保知识库存在,不存在则自动创建 + if err := p.ensureDatasetExists(ctx, datasetId, tenantId, "产品"); err != nil { + return "", gerror.Wrapf(err, "确保知识库存在失败") + } + + // 3. 调用RAGFlow上传文档 + ragflowClient := ragflow.GetGlobalClient() + filename := fmt.Sprintf("%s_%s.txt", product.Name, accountName) + documentId, err = ragflowClient.UploadDocumentFromText(ctx, datasetId, product.Description, filename) + if err != nil { + jaeger.RecordError(ctx, err, "产品上传RAGFlow失败") + p.sendToRetryQueue(ctx, productId, accountName, tenantId, 0) + return "", err + } + + // 4. 更新MongoDB的RagSyncRecord + now := gtime.Now().Format("Y-m-d H:i:s") + updated := false + for i := range product.RagSyncRecords { + record := &product.RagSyncRecords[i] + if record.AccountName == accountName { + record.RagDocumentId = documentId + record.RagSyncStatus = "synced" + record.SyncTime = now + record.RetryCount = 0 + updated = true + break + } + } + + if !updated { + product.RagSyncRecords = append(product.RagSyncRecords, entity.RagSyncRecord{ + AccountName: accountName, + RagDocumentId: documentId, + RagSyncStatus: "synced", + SyncTime: now, + RetryCount: 0, + }) + } + + product.RagLastSyncTime = now + if err = dao.Product.UpdateEntity(ctx, product); err != nil { + return "", gerror.Wrapf(err, "更新产品同步状态失败") + } + + glog.Infof(ctx, "产品同步成功: product_id=%s, account_name=%s, document_id=%s", productId, accountName, documentId) + return documentId, nil +} + +// ensureDatasetExists 已废弃,改用公共方法 EnsureTenantDataset +// 保留此方法仅为兼容性,直接调用公共方法 +func (p *product) ensureDatasetExists(ctx context.Context, datasetId, tenantId, datasetType string) error { + _, err := EnsureTenantDataset(ctx, tenantId) + return err +} + +// sendToRetryQueue 发送到重试队列 +func (p *product) sendToRetryQueue(ctx context.Context, productId, accountName, tenantId string, retryCount int) { + msg := dto.RAGFlowSyncRetryMsg{ + Type: "product", + Id: productId, + AccountName: accountName, + TenantId: tenantId, + RetryCount: retryCount, + } + + var delay int + switch retryCount { + case 0: + delay = 5 * 60 + case 1: + delay = 15 * 60 + case 2: + delay = 60 * 60 + default: + glog.Warningf(ctx, "产品同步重试次数超限,标记为失败: %s", productId) + p.markSyncFailed(ctx, productId, accountName) + return + } + + if err := rabbitmq.PublishWithDelay(ctx, "ragflow.sync.retry.product", msg, delay); err != nil { + jaeger.RecordError(ctx, err, "发送RAGFlow重试消息失败") + } +} + +// markSyncFailed 标记同步失败 +func (p *product) markSyncFailed(ctx context.Context, productId, accountName string) { + product, err := dao.Product.GetById(ctx, productId) + if err != nil { + return + } + + for i := range product.RagSyncRecords { + record := &product.RagSyncRecords[i] + if record.AccountName == accountName { + record.RagSyncStatus = "failed" + record.SyncTime = gtime.Now().Format("Y-m-d H:i:s") + break + } + } + + dao.Product.UpdateEntity(ctx, product) +} + +// HandleRAGFlowSyncRetry RAGFlow同步重试消费者 +func (p *product) HandleRAGFlowSyncRetry(ctx context.Context, msg dto.RAGFlowSyncRetryMsg) error { + glog.Infof(ctx, "处理RAGFlow同步重试: type=%s, id=%s, retry=%d", msg.Type, msg.Id, msg.RetryCount) + + if msg.Type != "product" { + return nil + } + + _, err := p.SyncToRAGFlow(ctx, msg.Id, msg.AccountName, msg.TenantId) + if err != nil { + p.sendToRetryQueue(ctx, msg.Id, msg.AccountName, msg.TenantId, msg.RetryCount+1) + return err + } + + return nil +} + +// ProductRetryConsumer 产品RAGFlow重试消费者 +type ProductRetryConsumer struct { + queueName string + consumer *rabbitmq.Consumer +} + +// NewProductRetryConsumer 创建产品RAGFlow重试消费者 +func NewProductRetryConsumer(ctx context.Context) *ProductRetryConsumer { + return &ProductRetryConsumer{ + queueName: "ragflow.sync.retry.product", + } +} + +// Start 启动消费者 +func (c *ProductRetryConsumer) Start(ctx context.Context) error { + c.consumer = rabbitmq.NewConsumer(c.queueName, func(ctx context.Context, body []byte) error { + var msg dto.RAGFlowSyncRetryMsg + if err := json.Unmarshal(body, &msg); err != nil { + return err + } + return Product.HandleRAGFlowSyncRetry(ctx, msg) + }) + return c.consumer.Start(ctx) +} + +// Stop 停止消费者 +func (c *ProductRetryConsumer) Stop(ctx context.Context) { + if c.consumer != nil { + c.consumer.Stop(ctx) + } +} diff --git a/service/ragflow_config_service.go b/service/ragflow_config_service.go new file mode 100644 index 0000000..c823e79 --- /dev/null +++ b/service/ragflow_config_service.go @@ -0,0 +1,211 @@ +// 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 +} diff --git a/service/session_service.go b/service/session_service.go new file mode 100644 index 0000000..7861d17 --- /dev/null +++ b/service/session_service.go @@ -0,0 +1,85 @@ +// 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 +} diff --git a/service/speechcraft_service.go b/service/speechcraft_service.go new file mode 100644 index 0000000..362af62 --- /dev/null +++ b/service/speechcraft_service.go @@ -0,0 +1,653 @@ +// Package service - 话术服务 +// 功能:话术的增删改查、绑定/解绑客服账号、同步到RAGFlow、重试消费者 +package service + +import ( + "context" + "customer-server/dao" + "customer-server/model/dto" + "customer-server/model/entity" + "customer-server/util" + "fmt" + + "gitea.com/red-future/common/db/mongo" + "gitea.com/red-future/common/jaeger" + "gitea.com/red-future/common/rabbitmq" + "gitea.com/red-future/common/ragflow" + "gitea.com/red-future/common/redis" + "gitea.com/red-future/common/utils" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/glog" + "github.com/gogf/gf/v2/os/grpool" + "github.com/gogf/gf/v2/os/gtime" + "github.com/gogf/gf/v2/text/gstr" + "github.com/gogf/gf/v2/util/gconv" + "go.mongodb.org/mongo-driver/v2/bson" +) + +var ( + Speechcraft = new(speechcraft) + speechcraftGrpool = grpool.New(50) // 文档解析协程池,最大50并发 +) + +type speechcraft struct{} + +// Add 添加话术 +// 参数: ctx - 上下文,req - 添加话术请求(包含标题、内容、方向、标签等) +// 返回: res - 添加成功后的话术ID和RAGFlow文档ID,err - 错误信息 +// 功能: 创建话术记录并自动上传到RAGFlow租户知识库,支持去重检查 +func (s *speechcraft) Add(ctx context.Context, req *dto.AddSpeechcraftReq) (res *dto.AddSpeechcraftRes, err error) { + // 去重检查:同一租户下tag唯一 + if req.Tag != "" { + existing, err := dao.Speechcraft.FindByTag(ctx, req.Tag) + if err != nil { + return nil, gerror.Wrap(err, "检查话术重复失败") + } + if existing != nil { + return nil, gerror.Newf("话术tag已存在:tag=%s, id=%s", req.Tag, existing.Id.Hex()) + } + } + + // 先从token或请求获取租户信息(在Insert之前) + var tenantId string + if req.TenantId != nil { + tenantId = gconv.String(req.TenantId) + g.Log().Debugf(ctx, "使用请求中的TenantId: %v", req.TenantId) + } else { + user, err := util.GetTenantInfo(ctx) + if err != nil { + return nil, gerror.Wrap(err, "获取租户信息失败") + } + tenantId = gconv.String(user.TenantId) + if tenantId == "" { + return nil, gerror.New("租户ID为空") + } + } + + // 查询租户知识库ID + datasetId, err := dao.RAGFlowConfig.FindDatasetIdByTenant(ctx, tenantId) + if err != nil || datasetId == "" { + return nil, gerror.Newf("租户知识库不存在,请先创建客服账号: tenant_id=%s", tenantId) + } + + data := &entity.Speechcraft{} + if err = utils.Struct(req, data); err != nil { + return + } + // 设置基础字段 + now := gtime.Now().Time + data.CreatedAt = &now // 取地址赋值给指针类型 + data.UpdatedAt = &now // 取地址赋值给指针类型 + data.IsDeleted = false + // 统一使用string类型存储tenantId到MongoDB + data.TenantId = tenantId + + // 使用DAO封装的Insert方法 + id, err := dao.Speechcraft.Insert(ctx, data) + if err != nil { + return nil, gerror.Wrap(err, "插入话术失败") + } + data.Id = &id // 取地址赋值给指针类型 + + // 同步上传到RAGFlow + ragflowClient := ragflow.GetGlobalClient() + if ragflowClient == nil { + // 回滚:删除刚插入的话术 + dao.MongoDAO.Delete(ctx, bson.M{"_id": data.Id}, entity.SpeechcraftCollection) + return nil, gerror.New("RAGFlow客户端未初始化,请检查配置") + } + + g.Log().Infof(ctx, "准备上传话术到RAGFlow: speechcraft_id=%s, dataset_id=%s, direction=%s, tag=%s", + data.Id.Hex(), datasetId, data.Direction, data.Tag) + filename := fmt.Sprintf("%s_%s.txt", data.Direction, data.Tag) + documentId, err := ragflowClient.UploadDocumentFromText(ctx, datasetId, data.Content, filename) + if err != nil { + // 回滚:删除刚插入的话术 + dao.MongoDAO.Delete(ctx, bson.M{"_id": data.Id}, entity.SpeechcraftCollection) + g.Log().Errorf(ctx, "话术上传RAGFlow失败: speechcraft_id=%s, dataset_id=%s, error=%v", data.Id.Hex(), datasetId, err) + jaeger.RecordError(ctx, err, "话术上传RAGFlow失败") + return nil, gerror.Wrap(err, "文档上传到知识库失败") + } + + // 异步触发解析(grpool自动管理goroutine生命周期,WithoutCancel保留追踪避免取消) + speechcraftGrpool.Add(ctx, func(ctx context.Context) { + parseCtx := context.WithoutCancel(ctx) + if err := ragflowClient.ParseDocuments(parseCtx, datasetId, []string{documentId}); err != nil { + g.Log().Errorf(parseCtx, "文档解析失败: document_id=%s, error=%v", documentId, err) + } else { + g.Log().Infof(parseCtx, "文档解析成功: document_id=%s", documentId) + } + }) + + // 更新MongoDB的RagSyncRecords数组(使用空accountName表示租户级文档) + syncTime := gtime.Now().Format("Y-m-d H:i:s") + record := entity.RagSyncRecord{ + AccountName: "", // 空表示租户级文档 + RagDocumentId: documentId, + RagSyncStatus: "synced", + SyncTime: syncTime, + RetryCount: 0, + } + filter := bson.M{"_id": data.Id} + update := bson.M{ + "$set": bson.M{ + "ragSyncRecords": []entity.RagSyncRecord{record}, + "ragLastSyncTime": syncTime, + "updatedAt": gtime.Now().Time, + }, + } + if _, _, err = dao.MongoDAO.UpdateOne(ctx, filter, update, entity.SpeechcraftCollection); err != nil { + g.Log().Errorf(ctx, "更新话术RagSyncRecords失败: %v", err) + // 不回滚,文档已上传成功 + } + + g.Log().Infof(ctx, "话术添加成功并上传到知识库: speechcraft_id=%s, document_id=%s", data.Id.Hex(), documentId) + res = &dto.AddSpeechcraftRes{Id: data.Id.Hex()} + return +} + +// Update 更新话术 +// 参数: ctx - 上下文,req - 更新话术请求(包含话术ID和待更新字段) +// 返回: err - 错误信息 +// 功能: 更新话术内容并同步到RAGFlow,支持文档删除重建 +func (s *speechcraft) Update(ctx context.Context, req *dto.UpdateSpeechcraftReq) (err error) { + return dao.Speechcraft.Update(ctx, req) +} + +// Delete 删除话术 +// 参数: ctx - 上下文,req - 删除话术请求(包含话术ID) +// 返回: err - 错误信息 +// 功能: 逻辑删除话术记录并从RAGFlow移除对应文档 +func (s *speechcraft) Delete(ctx context.Context, req *dto.DeleteSpeechcraftReq) (err error) { + g.Log().Infof(ctx, "[Delete] 开始删除话术 - speechcraftId: %s", req.Id) + + // 1. 查询话术,获取RAGFlow同步记录(使用原生查询,避免租户过滤) + objectId, err := bson.ObjectIDFromHex(req.Id) + if err != nil { + return gerror.Wrap(err, "无效的话术ID") + } + + var speechcraft entity.Speechcraft + filter := bson.M{"_id": objectId, "isDeleted": false} + err = dao.MongoDAO.FindOne(ctx, filter, &speechcraft, entity.SpeechcraftCollection) + if err != nil { + if err.Error() == "mongo: no documents in result" { + return gerror.New("话术不存在") + } + return gerror.Wrap(err, "查询话术失败") + } + + g.Log().Infof(ctx, "[Delete] 查询到话术 - tag: %s, ragSyncRecords数量: %d", speechcraft.Tag, len(speechcraft.RagSyncRecords)) + + // 2. 删除RAGFlow中的文档 + if len(speechcraft.RagSyncRecords) > 0 { + ragflowClient := ragflow.GetGlobalClient() + if ragflowClient != nil { + tenantId := gconv.String(speechcraft.TenantId) + + // 查询租户的dataset_id + datasetId, err := dao.RAGFlowConfig.FindDatasetIdByTenant(ctx, tenantId) + if err != nil { + g.Log().Warningf(ctx, "查询租户知识库ID失败: %v", err) + } else if datasetId != "" { + // 收集所有需要删除的document_id + var documentIds []string + for _, record := range speechcraft.RagSyncRecords { + if record.RagDocumentId != "" { + documentIds = append(documentIds, record.RagDocumentId) + } + } + + // 批量删除RAGFlow文档 + if len(documentIds) > 0 { + if err := ragflowClient.DeleteDocument(ctx, datasetId, documentIds); err != nil { + g.Log().Errorf(ctx, "删除RAGFlow文档失败: %v, document_ids: %v", err, documentIds) + // 不阻断删除流程,记录错误后继续 + } else { + g.Log().Infof(ctx, "成功删除RAGFlow文档: count=%d", len(documentIds)) + } + } + } + } + } + + // 3. 软删除MongoDB记录 + return dao.Speechcraft.Delete(ctx, req) +} + +// List 获取话术列表 +// 参数: ctx - 上下文,req - 列表查询请求(支持分页、关键词搜索、平台筛选) +// 返回: res - 话术列表及分页信息,err - 错误信息 +// 功能: 分页查询话术记录,支持按标题、内容模糊搜索和平台筛选 +func (s *speechcraft) List(ctx context.Context, req *dto.ListSpeechcraftReq) (res *dto.ListSpeechcraftRes, err error) { + list, total, err := dao.Speechcraft.List(ctx, req) + if err != nil { + return + } + res = &dto.ListSpeechcraftRes{ + List: list, + Total: int(total), + } + return +} + +// Match 话术匹配(核心方法) +// 根据用户当前阶段、行为、输入内容匹配话术 +// +// func (s *speechcraft) Match(ctx context.Context, userId, platform, content, status string) (answer string, nextStage int, err error) { +// // 1. 获取用户当前阶段 +// state, err := dao.UserStage.GetOrCreate(ctx, userId, platform) +func (s *speechcraft) Match(ctx context.Context, userId, platform, tenantId, content, status string) (answer string, nextStage int, err error) { + // 1. 获取用户当前状态(Redis,5分钟过期) + userState, err := redis.GetUserState(ctx, userId, platform) + if err != nil { + jaeger.RecordError(ctx, err, "获取用户状态失败") + return + } + + glog.Infof(ctx, "话术匹配 - 用户: %s, 当前阶段: %d, 行为: %s, 内容: %s", userId, userState.Stage, status, content) + + // 2. 状态3(发卡片状态):持续提示用户添加联系方式 + if userState.Stage == 3 { + answer = "请加一下卡片的联系方式,进行更专业的咨询" // TODO: 替换为实际卡片内容 + nextStage = 3 // 保持状态3 + glog.Infof(ctx, "用户处于发卡片状态 - 用户: %s", userId) + return + } + + // 4. 检测用户是否想要联系方式(5次内立即发卡) + if s.isRequestingContact(content) { + glog.Infof(ctx, "检测到联系方式请求关键词 - 用户: %s, 内容: %s", userId, content) + return s.handleCardRequest(ctx, userId, platform) + } + + // 5. 所有其他消息直接走RAGFlow(话术已上传到知识库) + // 后端只负责:开场白(WebSocket连接时发送)+ 发卡片(上面的逻辑) + glog.Infof(ctx, "无话术匹配,转发到RAGFlow - 用户: %s, 内容: %s", userId, content) + nextStage = 0 + if updateErr := redis.SetUserStage(ctx, userId, platform, 0); updateErr != nil { + jaeger.RecordError(ctx, updateErr, "更新用户阶段为0失败") + } + // answer为空,调用方会走RAGFlow + return +} + +// ProcessAndPublish 处理用户消息并推送到Redis Stream +// 参数: ctx - 上下文,userId - 用户ID,platform - 平台,tenantId - 租户ID,content - 消息内容,status - 用户行为状态,accountName - 客服账号名 +// 返回: isPushed - 是否成功推送话术匹配结果,err - 错误信息 +// 功能: 尝试匹配话术,匹配成功则直接推送话术内容,失败则转发到Redis Stream由RAGFlow处理 +func (s *speechcraft) ProcessAndPublish(ctx context.Context, userId, platform, tenantId, content, status, accountName string) (isPushed bool, err error) { + // 1. 话术匹配 + answer, _, err := s.Match(ctx, userId, platform, tenantId, content, status) + if err != nil { + return + } + + // 2. 未匹配到话术,直接推送到Redis Stream + if answer == "" { + glog.Infof(ctx, "话术未匹配,转发到 AI 模型 - 用户: %s, 客服账号: %s", userId, accountName) + + // 获取当前实例的动态响应队列名(自动生成,支持多实例部署) + baseQueue := GetConfigString(ctx, "rabbitmq.responseQueue") + replyQueue := rabbitmq.GetInstanceQueueName(baseQueue) + messageId := userId + "_" + gconv.String(gtime.Now().TimestampNano()) + + // 构造Stream消息 + msg := &redis.SendStreamMessage{ + UserId: userId, + Platform: platform, + TenantId: tenantId, + AccountName: accountName, + Content: content, + Timestamp: gtime.Now().Timestamp(), + MessageId: messageId, + ReplyQueue: replyQueue, + } + + // 检查是否有session缓存,无缓存说明已归档,需要读取历史 + if sessionId, _ := redis.GetSessionCache(ctx, tenantId, userId); sessionId == "" { + if history, histErr := dao.Conversation.GetRecentHistory(ctx, userId, redis.GetHistoryContextLimit()); histErr == nil && len(history) > 0 { + msg.History = history + glog.Infof(ctx, "用户已归档,读取 %d 轮历史对话 - 用户: %s", len(history), userId) + } + } + + // 写入Redis Stream + var streamMsgId string + streamMsgId, err = redis.AddToStream(ctx, redis.RAGFlowRequestStreamKey, msg) + if err != nil { + jaeger.RecordError(ctx, err, "写入Stream失败") + return + } + + glog.Infof(ctx, "消息已写入Stream - StreamID: %s, 用户: %s", streamMsgId, userId) + isPushed = false // 未匹配话术,消息转发到AI处理 + return + } + + // 3. 匹配到话术,直接推送 WebSocket(无需经过 RabbitMQ) + if err = WebSocket.PushRAGFlowResponse(ctx, tenantId, userId, platform, answer); err != nil { + jaeger.RecordError(ctx, err, "推送话术响应失败") + return + } + + glog.Infof(ctx, "话术响应已推送 - 用户: %s, 话术长度: %d", userId, len(answer)) + isPushed = true // 已直接推送响应 + return +} + +// ResetUserStage 重置用户阶段 +func (s *speechcraft) ResetUserStage(ctx context.Context, userId, platform string) (err error) { + return dao.UserStage.Reset(ctx, userId, platform) +} + +// isRequestingContact 检测用户是否想要联系方式(触发立即发卡) +func (s *speechcraft) isRequestingContact(content string) bool { + // 联系方式相关关键词 + contactKeywords := []string{ + "联系方式", "联系你", "联系", + "微信", "VX", "vx", "wx", "WX", + "电话", "手机号", "电话号码", + "怎么找你", "如何联系", "加你", + "私信", "私聊", + } + + // 清理内容(去除空格、标点) + cleanContent := gstr.Trim(content) + cleanContent = gstr.ToLower(cleanContent) + + for _, keyword := range contactKeywords { + if gstr.Contains(cleanContent, gstr.ToLower(keyword)) { + return true + } + } + + return false +} + +// handleCardRequest 处理用户请求联系方式(立即发卡片) +func (s *speechcraft) handleCardRequest(ctx context.Context, userId, platform string) (answer string, nextStage int, err error) { + // 更新用户状态为3(发卡片状态) + if err = redis.SetUserStage(ctx, userId, platform, 3); err != nil { + jaeger.RecordError(ctx, err, "更新用户状态为3失败") + return + } + + // 返回卡片话术 + answer = "请加一下卡片的联系方式,进行更专业的咨询" // TODO: 替换为实际卡片内容 + nextStage = 3 + + glog.Infof(ctx, "用户请求联系方式,立即发送卡片 - 用户: %s", userId) + return +} + +// BindToCustomerServices 绑定话术到客服账号 +// 参数: ctx - 上下文,req - 绑定请求(包含话术ID和客服账号名列表) +// 返回: res - 绑定结果(成功和失败的账号列表),err - 错误信息 +// 功能: 将话术同步到指定客服账号的RAGFlow知识库,批量处理并返回每个账号的结果 +func (s *speechcraft) BindToCustomerServices(ctx context.Context, req *dto.BindSpeechcraftReq) (res *dto.BindSpeechcraftRes, err error) { + res = &dto.BindSpeechcraftRes{} + + // 0. 参数验证 + if len(req.AccountNames) == 0 { + return nil, gerror.New("客服账号ID列表不能为空") + } + + // 1. 查询话术(验证存在性和获取租户信息) + r := g.RequestFromCtx(ctx) + if r != nil { + r.SetParam("accountName", req.AccountNames[0]) + } + + speechcraft, err := dao.Speechcraft.GetById(ctx, req.SpeechcraftId) + if err != nil { + return nil, gerror.Wrapf(err, "查询话术失败") + } + if speechcraft == nil { + return nil, gerror.New("话术不存在") + } + + speechcraftTenantId := gconv.String(speechcraft.TenantId) + + // 2. 遍历客服账号,更新每个账号的speechcraft_ids + var newBindings []string + var alreadyBound []string + var notFound []string + + for _, csId := range req.AccountNames { + // 查询客服账号 + csAccount, err := dao.CustomerServiceAccount.FindByAccountName(ctx, csId) + if err != nil || csAccount == nil { + notFound = append(notFound, csId) + g.Log().Warningf(ctx, "客服账号 %s 不存在或已删除,跳过", csId) + continue + } + + // 租户隔离校验 + accountTenantId := gconv.String(csAccount.TenantId) + if speechcraftTenantId != accountTenantId { + g.Log().Warningf(ctx, "话术和客服账号不属于同一租户,跳过: speechcraft_tenant=%s, account_tenant=%s", + speechcraftTenantId, accountTenantId) + notFound = append(notFound, csId) + continue + } + + // 检查是否已绑定 + alreadyExists := false + for _, existingId := range csAccount.SpeechcraftIds { + if existingId == req.SpeechcraftId { + alreadyExists = true + break + } + } + + if alreadyExists { + alreadyBound = append(alreadyBound, csId) + g.Log().Warningf(ctx, "客服账号 %s 已绑定该话术,跳过", csId) + continue + } + + // 添加到speechcraft_ids列表 + csAccount.SpeechcraftIds = append(csAccount.SpeechcraftIds, req.SpeechcraftId) + + // 更新数据库 + filter := bson.M{"_id": csAccount.Id, "isDeleted": false} + update := bson.M{"$set": bson.M{"speechcraftIds": csAccount.SpeechcraftIds}} + if _, err := mongo.DB().Update(ctx, filter, update, entity.CustomerServiceAccountCollection); err != nil { + g.Log().Errorf(ctx, "更新客服账号绑定失败: %s, error=%v", csId, err) + notFound = append(notFound, csId) + continue + } + + newBindings = append(newBindings, csId) + } + + // 3. 如果没有新的绑定,直接返回 + if len(newBindings) == 0 { + res.SuccessCount = 0 + res.AlreadyBound = alreadyBound + res.NotFound = notFound + + if len(alreadyBound) > 0 && len(notFound) > 0 { + res.Message = "部分客服账号已绑定,部分不存在" + } else if len(alreadyBound) > 0 { + res.Message = "所有客服账号已绑定,无需重复绑定" + } else if len(notFound) > 0 { + res.Message = "所有客服账号都不存在或租户不匹配" + } + return res, nil + } + + // 6. 同步到RAGFlow(自动创建知识库) + for _, csId := range newBindings { + // 获取客服账号信息以获取tenant_id + csAccount, err := dao.CustomerServiceAccount.FindByAccountName(ctx, csId) + if err != nil || csAccount == nil { + g.Log().Errorf(ctx, "获取客服账号信息失败: %s", csId) + continue + } + + // 同步到RAGFlow(会自动创建知识库) + tenantId := gconv.String(csAccount.TenantId) + g.Log().Infof(ctx, "客服账号租户信息: cs_id=%s, tenant_id=%v, tenant_id_type=%T", csId, csAccount.TenantId, csAccount.TenantId) + _, err = s.SyncToRAGFlow(ctx, req.SpeechcraftId, csId, tenantId) + if err != nil { + g.Log().Errorf(ctx, "同步到RAGFlow失败: speechcraft_id=%s, cs_id=%s, error=%v", req.SpeechcraftId, csId, err) + // 不阻断绑定流程,失败会进入重试队列 + } + } + + res.SuccessCount = len(newBindings) + res.AlreadyBound = alreadyBound + res.NotFound = notFound + + // 生成详细的响应消息 + if len(alreadyBound) > 0 || len(notFound) > 0 { + res.Message = fmt.Sprintf("成功绑定%d个", len(newBindings)) + if len(alreadyBound) > 0 { + res.Message += fmt.Sprintf(",%d个已绑定", len(alreadyBound)) + } + if len(notFound) > 0 { + res.Message += fmt.Sprintf(",%d个不存在", len(notFound)) + } + } else { + res.Message = fmt.Sprintf("成功绑定%d个客服账号", len(newBindings)) + } + return +} + +// UnbindFromCustomerService 从客服账号解绑话术 +// 参数: ctx - 上下文,req - 解绑请求(包含话术ID和客服账号名) +// 返回: res - 解绑结果信息,err - 错误信息 +// 功能: 从客服账号的RAGFlow知识库中删除话术文档 +func (s *speechcraft) UnbindFromCustomerService(ctx context.Context, req *dto.UnbindSpeechcraftReq) (res *dto.UnbindSpeechcraftRes, err error) { + res = &dto.UnbindSpeechcraftRes{} + + // 1. 验证话术存在 + speechcraft, err := dao.Speechcraft.GetById(ctx, req.SpeechcraftId) + if err != nil { + return nil, gerror.Wrapf(err, "查询话术失败") + } + if speechcraft == nil { + return nil, gerror.New("话术不存在") + } + + // 2. 查询客服账号 + csAccount, err := dao.CustomerServiceAccount.FindByAccountName(ctx, req.AccountName) + if err != nil || csAccount == nil { + res.Success = false + res.Message = "客服账号不存在" + return res, nil + } + + // 3. 从 speechcraft_ids 中移除话术ID + var newSpeechcraftIds []string + found := false + for _, scId := range csAccount.SpeechcraftIds { + if scId == req.SpeechcraftId { + found = true + continue + } + newSpeechcraftIds = append(newSpeechcraftIds, scId) + } + + if !found { + res.Success = false + res.Message = "未找到该绑定关系" + return res, nil + } + + // 4. 更新数据库 + filter := bson.M{"_id": csAccount.Id, "isDeleted": false} + update := bson.M{"$set": bson.M{"speechcraftIds": newSpeechcraftIds}} + if _, err := mongo.DB().Update(ctx, filter, update, entity.CustomerServiceAccountCollection); err != nil { + return nil, gerror.Wrapf(err, "解绑失败") + } + + res.Success = true + res.Message = "解绑成功" + return +} + +// SyncToRAGFlow 同步话术到RAGFlow +// 参数: ctx - 上下文,speechcraftId - 话术ID,accountName - 客服账号名,tenantId - 租户ID +// 返回: documentId - RAGFlow文档ID,err - 错误信息 +// 功能: 将话术上传到指定客服账号的RAGFlow知识库,失败时自动加入重试队列 +func (s *speechcraft) SyncToRAGFlow(ctx context.Context, speechcraftId, accountName, tenantId string) (documentId string, err error) { + // 1. 查询话术 + speechcraft, err := dao.Speechcraft.GetById(ctx, speechcraftId) + if err != nil { + return "", gerror.Wrapf(err, "查询话术失败") + } + if speechcraft == nil { + return "", gerror.New("话术不存在") + } + + // 2. 确保知识库存在,获取真实的datasetId + datasetId, err := s.ensureDatasetExists(ctx, tenantId, "话术") + if err != nil { + return "", gerror.Wrapf(err, "确保知识库存在失败") + } + + // 3. 调用RAGFlow上传文档 + ragflowClient := ragflow.GetGlobalClient() + filename := fmt.Sprintf("%s_%s_%s.txt", speechcraft.Direction, speechcraft.Tag, accountName) + documentId, err = ragflowClient.UploadDocumentFromText(ctx, datasetId, speechcraft.Content, filename) + if err != nil { + jaeger.RecordError(ctx, err, "话术上传RAGFlow失败") + return "", gerror.Wrap(err, "话术上传RAGFlow失败") + } + + // 3.1 上传成功后立即调用解析接口 + g.Log().Infof(ctx, "文档上传成功,开始解析: document_id=%s", documentId) + if err = ragflowClient.ParseDocuments(ctx, datasetId, []string{documentId}); err != nil { + // 解析失败只记录日志,不影响绑定流程(文档已上传,可以手动重试解析) + g.Log().Errorf(ctx, "文档解析失败: document_id=%s, error=%v", documentId, err) + jaeger.RecordError(ctx, err, "文档解析失败") + } else { + g.Log().Infof(ctx, "文档解析请求已发送: document_id=%s", documentId) + } + + // 4. 更新MongoDB的RagSyncRecord + now := gtime.Now().Format("Y-m-d H:i:s") + updated := false + for i := range speechcraft.RagSyncRecords { + record := &speechcraft.RagSyncRecords[i] + if record.AccountName == accountName { + record.RagDocumentId = documentId + record.RagSyncStatus = "synced" + record.SyncTime = now + record.RetryCount = 0 + updated = true + break + } + } + + // 如果没有找到记录,新增 + if !updated { + speechcraft.RagSyncRecords = append(speechcraft.RagSyncRecords, entity.RagSyncRecord{ + AccountName: accountName, + RagDocumentId: documentId, + RagSyncStatus: "synced", + SyncTime: now, + RetryCount: 0, + }) + } + + if err = dao.Speechcraft.UpdateEntity(ctx, speechcraft); err != nil { + return "", gerror.Wrapf(err, "更新话术同步状态失败") + } + + // 注意:不再更新Chat的datasetIds,因为创建Chat时已经绑定了知识库 + // 话术文档上传到知识库后,Chat会自动使用该知识库的内容 + + glog.Infof(ctx, "话术同步成功: speechcraft_id=%s, account_name=%s, document_id=%s", speechcraftId, accountName, documentId) + return documentId, nil +} + +// ensureDatasetExists 已废弃,改用公共方法 EnsureTenantDataset +// 保留此方法仅为兼容性,直接调用公共方法 +func (s *speechcraft) ensureDatasetExists(ctx context.Context, tenantId, datasetType string) (datasetId string, err error) { + return EnsureTenantDataset(ctx, tenantId) +} diff --git a/service/webhook_service.go b/service/webhook_service.go new file mode 100644 index 0000000..ed6736d --- /dev/null +++ b/service/webhook_service.go @@ -0,0 +1,101 @@ +// 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 +} diff --git a/service/websocket_service.go b/service/websocket_service.go new file mode 100644 index 0000000..a0ff73c --- /dev/null +++ b/service/websocket_service.go @@ -0,0 +1,506 @@ +// Package service - WebSocket服务 +// 功能:WebSocket连接管理、消息推送、心跳维护 +package service + +import ( + "context" + "customer-server/dao" + "customer-server/model/dto" + "customer-server/model/entity" + "customer-server/util" + "errors" + "net/http" + + commonMongo "gitea.com/red-future/common/db/mongo" + "gitea.com/red-future/common/jaeger" + "gitea.com/red-future/common/rabbitmq" + "gitea.com/red-future/common/redis" + "gitea.com/red-future/common/utils" + "github.com/gogf/gf/v2/container/gmap" + "github.com/gogf/gf/v2/encoding/gjson" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/net/ghttp" + "github.com/gogf/gf/v2/os/glog" + "github.com/gogf/gf/v2/os/gtime" + "github.com/gogf/gf/v2/util/gconv" + "github.com/gorilla/websocket" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +// WebSocket 全局单例 +var WebSocket = &websocketService{ + connections: gmap.NewStrAnyMap(true), + upgrader: websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true // 允许跨域 + }, + }, +} + +// GoFrame 并发安全 Map +type websocketService struct { + connections *gmap.StrAnyMap + upgrader websocket.Upgrader +} + +// key: userId_platform +// wsConnection WebSocket 连接信息 +type wsConnection struct { + UserId string + Platform string + TenantId string + AccountName string // 客服账号ID + Conn *websocket.Conn + CreatedAt int64 +} + +// 租户ID缓存 Key 前缀和过期时间 +const ( + tenantCacheKeyPrefix = "tenant:custsvc:" + tenantCacheExpire = 300 // 5分钟 +) + +// resolveTenantId 获取租户ID(兼容仅有accountName的场景) +// 参数: ctx - 上下文,r - HTTP请求对象 +// 返回: tenantId - 租户ID,err - 错误信息 +// 功能: 优先从token获取,其次从客服账号查询,支持缓存 +func (s *websocketService) resolveTenantId(ctx context.Context, r *ghttp.Request) (tenantId string, err error) { + // 1. 优先从 token 获取 + if user, userErr := util.GetTenantInfo(ctx); userErr == nil { + if id := gconv.String(user.TenantId); id != "" { + return id, nil + } + } + + if r == nil { + return "", gerror.New("无法获取租户信息:缺少请求上下文") + } + + // 2. 从请求参数获取 accountName + custId := r.Get("accountName").String() + if custId == "" { + custId = r.Get("account_name").String() + } + if custId == "" { + return "", gerror.New("缺少 accountName 参数") + } + + // 2. 从 Redis 缓存查询 + cacheKey := tenantCacheKeyPrefix + custId + if cached, _ := redis.RedisClient().Get(ctx, cacheKey); !cached.IsEmpty() { + return cached.String(), nil + } + + // 3. Redis 未命中,查询 MongoDB + coll := commonMongo.GetDB().Collection(entity.CustomerServiceAccountCollection) + var doc struct { + TenantId interface{} `bson:"tenantId"` + } + + filters := []bson.M{ + {"accountName": custId, "isDeleted": false}, + {"accountName": custId}, // 兼容旧数据未设置 isDeleted + } + + if objectId, objErr := bson.ObjectIDFromHex(custId); objErr == nil { + filters = append(filters, + bson.M{"_id": objectId, "isDeleted": false}, + bson.M{"_id": objectId}, + ) + } + + for _, filter := range filters { + if err = coll.FindOne(ctx, filter).Decode(&doc); err == nil { + tenantId = gconv.String(doc.TenantId) + if tenantId == "" { + return "", gerror.Newf("客服账号 %s 未配置 tenantId", custId) + } + // 4. 写入 Redis 缓存 + redis.RedisClient().SetEX(ctx, cacheKey, tenantId, tenantCacheExpire) + return + } + if !errors.Is(err, mongo.ErrNoDocuments) { + return + } + } + + return "", gerror.Newf("客服账号 %s 不存在", custId) +} + +// Connect 建立 WebSocket 连接 +func (s *websocketService) Connect(ctx context.Context, r *ghttp.Request, userId, platform string) error { + // 使用原生upgrader升级WebSocket连接 + ws, err := s.upgrader.Upgrade(r.Response.Writer, r.Request, nil) + if err != nil { + jaeger.RecordError(ctx, err, "WebSocket 升级失败") + return err + } + defer ws.Close() + + tenantId, err := s.resolveTenantId(ctx, r) + if err != nil { + jaeger.RecordError(ctx, err, "获取租户ID失败") + return err + } + + // 读取accountName参数(客服账号名称) + accountName := r.Get("accountName").String() + if accountName == "" { + accountName = r.Get("account_name").String() + } + + glog.Infof(ctx, "WebSocket 连接建立 - 用户: %s, 平台: %s, 租户: %s, 客服账号: %s", userId, platform, tenantId, accountName) + + // key格式: tenantId:userId_platform (确保租户隔离) + key := tenantId + ":" + userId + "_" + platform + + // 关闭旧连接 + if old := s.connections.Get(key); old != nil { + old.(*wsConnection).Conn.Close() + } + + // 注册新连接(携带 TenantId 和 AccountName) + s.connections.Set(key, &wsConnection{ + UserId: userId, + Platform: platform, + TenantId: tenantId, + AccountName: accountName, + Conn: ws, + CreatedAt: gtime.Now().Timestamp(), + }) + + // 发送开场白(连接建立后立即推送) + if accountName != "" { + greeting := s.getGreeting(ctx, accountName, tenantId) + if greeting != "" { + s.writeJSON(ws, &dto.WebSocketPushMsg{ + Type: "message", + Message: greeting, + }) + glog.Infof(ctx, "已发送开场白 - 用户: %s, 客服账号: %s, 长度: %d", userId, accountName, len(greeting)) + } else { + glog.Warningf(ctx, "客服账号未配置开场白 - accountName: %s, tenantId: %s", accountName, tenantId) + } + } + + // 处理消息(阻塞) + s.handleConnection(ctx, key, ws) + return nil +} + +// handleConnection 处理 WebSocket 连接 +func (s *websocketService) handleConnection(ctx context.Context, key string, conn *websocket.Conn) { + defer func() { + s.connections.Remove(key) + conn.Close() + glog.Infof(ctx, "WebSocket 连接断开 - %s", key) + }() + + for { + msgType, message, err := conn.ReadMessage() + if err != nil { + // 排除正常关闭情况:正常关闭、离开页面、无状态码关闭 + if websocket.IsUnexpectedCloseError(err, + websocket.CloseNormalClosure, + websocket.CloseGoingAway, + websocket.CloseNoStatusReceived, + ) { + jaeger.RecordError(ctx, err, "WebSocket 读取错误") + } + break + } + + if msgType != websocket.TextMessage { + continue + } + + content := gconv.String(message) + glog.Infof(ctx, "收到 WebSocket 消息 - %s: %s", key, content) + + // 解析 userId + connInfo := s.connections.Get(key) + if connInfo == nil { + break + } + wsConn := connInfo.(*wsConnection) + + // 先检查对话轮数,>5 则只发卡片,跳过话术 + // checkCardBeforeProcess 已推送卡片消息,无需ack + if handled, err := checkCardBeforeProcess(ctx, wsConn.TenantId, wsConn.UserId, wsConn.Platform); err != nil { + jaeger.RecordError(ctx, err, "卡片检查失败") + } else if handled { + continue + } + + // 话术匹配并发布响应 + // status 暂时为空,表示任意行为匹配 + // isPushed=true表示已直接推送响应(话术匹配),无需ack + // isPushed=false表示转发到RAGFlow,需要ack告知用户正在处理 + + // 创建带有accountName的context,供GetTenantInfo使用 + newCtx := ctx + if wsConn.AccountName != "" { + newCtx = context.WithValue(ctx, "accountName", wsConn.AccountName) + } + + isPushed, err := Speechcraft.ProcessAndPublish(newCtx, wsConn.UserId, wsConn.Platform, wsConn.TenantId, content, "", wsConn.AccountName) + if err != nil { + jaeger.RecordError(ctx, err, "话术处理失败") + s.writeJSON(conn, &dto.WebSocketPushMsg{Type: "error", Message: "消息处理失败"}) + continue + } + + // 只在转发到RAGFlow时发送ack(Go直接返回的不需要ack) + if !isPushed { + s.writeJSON(conn, &dto.WebSocketPushMsg{Type: "ack", Message: "消息已接收,正在处理..."}) + } + } +} + +// writeJSON 发送 JSON 消息 +func (s *websocketService) writeJSON(conn *websocket.Conn, data interface{}) { + jsonBytes, _ := gjson.Encode(data) + conn.WriteMessage(websocket.TextMessage, jsonBytes) +} + +// getGreeting 获取客服账号的开场白 +func (s *websocketService) getGreeting(ctx context.Context, accountName, tenantId string) string { + glog.Infof(ctx, "查询开场白 - accountName: %s, tenantId: %s", accountName, tenantId) + + // 复用dao层方法,保持查询逻辑一致 + account, err := dao.CustomerServiceAccount.FindByAccountName(ctx, accountName) + if err != nil { + jaeger.RecordError(ctx, err, "查询客服账号开场白失败") + glog.Errorf(ctx, "查询开场白失败: %v", err) + return "" + } + + if account == nil { + glog.Warningf(ctx, "客服账号不存在: accountName=%s", accountName) + return "" + } + + // 详细输出查询结果 + glog.Infof(ctx, "查询到客服账号: Id=%s, AccountName=%s, TenantId=%v, Greeting长度=%d, Platform=%s", + account.Id.Hex(), account.AccountName, account.TenantId, len(account.Greeting), account.Platform) + + return account.Greeting +} + +// Send 发送消息到 Redis Stream(HTTP 接口) +func (s *websocketService) Send(ctx context.Context, req *dto.WebSocketSendReq) (*dto.WebSocketSendRes, error) { + // 从 token 获取租户ID + var tenantId string + if user, err := utils.GetUserInfo(ctx); err == nil { + tenantId = gconv.String(user.TenantId) + } + + messageId, err := s.sendToStream(ctx, req.UserId, tenantId, req.Content) + if err != nil { + return nil, err + } + return &dto.WebSocketSendRes{MessageId: messageId}, nil +} + +// sendToStream 发送消息到 Redis Stream +// 如果用户无 session 缓存(已归档),则从 MongoDB 读取历史对话一起发送 +func (s *websocketService) sendToStream(ctx context.Context, userId, tenantId, content string) (string, error) { + now := gtime.Now() + platform := "xiaohongshu" // 默认平台 + + // 获取当前实例的动态响应队列名(自动生成,支持多实例部署) + baseQueue := GetConfigString(ctx, "rabbitmq.responseQueue") + replyQueue := rabbitmq.GetInstanceQueueName(baseQueue) + + // 获取accountName(优先使用用户选择的方向映射) + var accountName string + var chatId string + + // 1. 尝试从用户状态获取(用户选择方向后的映射) + if userState, stateErr := redis.GetUserState(ctx, userId, platform); stateErr == nil && userState.AccountName != "" { + accountName = userState.AccountName + glog.Infof(ctx, "使用用户选择方向的客服账号: %s", accountName) + } else { + // 2. 从连接信息获取(默认) + key := tenantId + ":" + userId + "_" + platform + connInfo := s.connections.Get(key) + if connInfo != nil { + wsConn := connInfo.(*wsConnection) + accountName = wsConn.AccountName + glog.Infof(ctx, "使用连接默认客服账号: %s", accountName) + } + } + + // 根据accountName查询ragflow_config获取chat_id + if accountName != "" { + config, err := dao.RAGFlowConfig.FindByAccountName(ctx, accountName) + if err == nil && config != nil { + chatId = config.ChatId + glog.Infof(ctx, "查询到chatId: accountName=%s, chatId=%s", accountName, chatId) + } + } + + // 如果未找到chatId,报错(应该从ragflowconfig表重建session) + if chatId == "" { + return "", gerror.New("chatId未找到,需要重建RAGFlow session") + } + + msg := &redis.SendStreamMessage{ + UserId: userId, + TenantId: tenantId, + AccountName: accountName, + ChatId: chatId, + Content: content, + Timestamp: now.Timestamp(), + MessageId: userId + "_" + gconv.String(now.TimestampNano()), + ReplyQueue: replyQueue, + } + + // 检查是否有 session 缓存,无缓存说明已归档,需要读取历史 + if sessionId, _ := redis.GetSessionCache(ctx, tenantId, userId); sessionId == "" { + // 从 MongoDB 读取历史对话 + if history, err := dao.Conversation.GetRecentHistory(ctx, userId, redis.GetHistoryContextLimit()); err == nil && len(history) > 0 { + msg.History = history + glog.Infof(ctx, "用户已归档,读取 %d 轮历史对话 - 用户: %s", len(history), userId) + } + } + + messageId, err := redis.AddToStream(ctx, redis.RAGFlowRequestStreamKey, msg) + if err != nil { + return "", err + } + + glog.Infof(ctx, "消息已发送到 Stream - MessageID: %s, 用户: %s", messageId, userId) + return messageId, nil +} + +// SendToUser 发送消息给指定用户 +func (s *websocketService) SendToUser(ctx context.Context, tenantId, userId, platform string, data interface{}) error { + // key格式: tenantId:userId_platform + key := tenantId + ":" + userId + "_" + platform + connInfo := s.connections.Get(key) + if connInfo == nil { + glog.Warningf(ctx, "用户不在线 - %s", key) + return nil + } + + s.writeJSON(connInfo.(*wsConnection).Conn, data) + return nil +} + +// Broadcast 广播消息给所有连接 +func (s *websocketService) Broadcast(ctx context.Context, content string) { + msg := &dto.WebSocketPushMsg{Type: "broadcast", Content: content} + msgBytes := gjson.MustEncode(msg) + + s.connections.Iterator(func(key string, value interface{}) bool { + conn := value.(*wsConnection) + if err := conn.Conn.WriteMessage(websocket.TextMessage, msgBytes); err != nil { + jaeger.RecordError(ctx, err, "广播消息失败 - "+key) + } + return true + }) +} + +// GetOnlineUsers 获取在线用户列表 +func (s *websocketService) GetOnlineUsers() *dto.WebSocketOnlineRes { + users := make([]dto.WebSocketOnlineUserRes, 0, s.connections.Size()) + + s.connections.Iterator(func(_ string, value interface{}) bool { + conn := value.(*wsConnection) + users = append(users, dto.WebSocketOnlineUserRes{ + UserId: conn.UserId, + Platform: conn.Platform, + CreatedAt: conn.CreatedAt, + }) + return true + }) + + return &dto.WebSocketOnlineRes{ + Count: len(users), + Users: users, + } +} + +// PushRAGFlowResponse 推送 RAGFlow 响应给用户 +func (s *websocketService) PushRAGFlowResponse(ctx context.Context, tenantId, userId, platform, content string) error { + return s.SendToUser(ctx, tenantId, userId, platform, &dto.WebSocketPushMsg{Type: "answer", Content: content}) +} + +// ============== RabbitMQ 消费者(追问延时消息)============== + +// FollowUpConsumer 追问消费者 +type FollowUpConsumer struct { + consumer *rabbitmq.Consumer +} + +// NewFollowUpConsumer 创建追问消费者 +func NewFollowUpConsumer(ctx context.Context) *FollowUpConsumer { + queueName := GetConfigString(ctx, "followUp.queue") + return &FollowUpConsumer{ + consumer: rabbitmq.NewConsumer(queueName, handleFollowUp), + } +} + +// Start 启动消费者 +func (c *FollowUpConsumer) Start(ctx context.Context) (err error) { + glog.Info(ctx, "追问消费者启动...") + return c.consumer.Start(ctx) +} + +// Stop 停止消费者 +func (c *FollowUpConsumer) Stop(ctx context.Context) { + c.consumer.Stop(ctx) +} + +// handleFollowUp 处理追问消息 +func handleFollowUp(ctx context.Context, body []byte) (err error) { + ctx, span := jaeger.NewSpan(ctx, "consumer.followup") + defer span.End() + + var msg redis.FollowUpMessage + if err = gjson.DecodeTo(body, &msg); err != nil { + jaeger.RecordError(ctx, err, "解析追问消息失败") + return + } + + glog.Infof(ctx, "收到追问消息 - 用户: %s, 类型: %d", msg.UserId, msg.FollowUpType) + + // 检查用户状态,如果在状态5(方向选择)或状态3(发卡片),跳过追问 + userState, err := redis.GetUserState(ctx, msg.UserId, msg.Platform) + if err != nil { + jaeger.RecordError(ctx, err, "获取用户状态失败") + return + } + + if userState.Stage == 5 { + glog.Infof(ctx, "用户 %s 在方向选择状态,跳过追问", msg.UserId) + return + } + + if userState.Stage == 3 { + glog.Infof(ctx, "用户 %s 在发卡片状态,跳过追问", msg.UserId) + return + } + + // 检查用户是否在追问发送后有活跃 + isActive, err := redis.IsUserActive(ctx, msg.UserId, int64(redis.GetFollowUpDelay(msg.FollowUpType))) + if err != nil { + jaeger.RecordError(ctx, err, "检查用户活跃状态失败") + return + } + + if isActive { + glog.Infof(ctx, "用户 %s 在追问期间有活跃,跳过追问", msg.UserId) + return + } + + // 发送追问消息给用户 + if err = WebSocket.PushRAGFlowResponse(ctx, msg.TenantId, msg.UserId, msg.Platform, msg.Content); err != nil { + jaeger.RecordError(ctx, err, "推送追问消息失败") + } + + glog.Infof(ctx, "追问消息已发送 - 租户: %s, 用户: %s, 内容: %s", msg.TenantId, msg.UserId, msg.Content) + return +} diff --git a/service/xiaohongshu_service.go b/service/xiaohongshu_service.go new file mode 100644 index 0000000..aadc0f9 --- /dev/null +++ b/service/xiaohongshu_service.go @@ -0,0 +1,459 @@ +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 +} diff --git a/tools/delete_datasets_simple.go b/tools/delete_datasets_simple.go new file mode 100644 index 0000000..1d26a25 --- /dev/null +++ b/tools/delete_datasets_simple.go @@ -0,0 +1,152 @@ +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)) +} diff --git a/update.sql b/update.sql new file mode 100644 index 0000000..807d512 --- /dev/null +++ b/update.sql @@ -0,0 +1 @@ +-----------张斌2025-06-16 15:00:00-------------- \ No newline at end of file diff --git a/util/util.go b/util/util.go new file mode 100644 index 0000000..b990fb8 --- /dev/null +++ b/util/util.go @@ -0,0 +1,74 @@ +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 +}