From f8927afa9c7573d41fcbbfb9534dea2772e5974e Mon Sep 17 00:00:00 2001 From: qhd <1766646056@qq.com> Date: Sat, 11 Apr 2026 18:22:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E7=BC=96=E7=A0=81=E5=92=8CHTTP=E8=BF=9E=E6=8E=A5=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config.yml | 97 +- config/welcome_messages.yaml | 53 -- consts/account/platform.go | 12 + consts/public/msg_key.go | 12 + consts/public/table_name.go | 5 +- consts/scriptedSpeech/scene_type.go | 40 + consumer/ragflow_processor.go | 591 ------------ consumer/recreate_chat.go | 47 - controller/account_http_controller.go | 16 + controller/account_websocket_controller.go | 2 +- 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/scripted_speech_controller.go | 10 +- controller/speechcraft_controller.go | 69 -- controller/webhook_controller.go | 41 - controller/websocket_controller.go | 52 - controller/xiaohongshu_controller.go | 111 --- dao/account_dao.go | 11 +- dao/account_user_dialog_dao.go | 60 ++ 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/scripted_speech_dao.go | 25 +- dao/session_dao.go | 99 -- dao/speechcraft_dao.go | 301 ------ dao/user_stage_dao.go | 143 --- go.mod | 38 +- go.sum | 100 +- main.go | 91 +- model/dto/account_dto.go | 68 +- model/dto/account_http_dto.go | 32 + model/dto/account_user_dialog_dto.go | 28 + model/dto/account_websocket_dto.go | 14 - 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/scripted_speech_dto.go | 33 +- model/dto/session_tool_dto.go | 63 ++ model/dto/speechcraft_dto.go | 101 -- model/dto/webhook_dto.go | 51 - model/dto/xiaohongshu_dto.go | 427 --------- model/entity/account.go | 25 +- model/entity/account_user_dialog.go | 27 + 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/scripted_speech.go | 15 +- model/entity/session.go | 29 - model/entity/speechcraft.go | 32 - model/entity/user_stage.go | 45 - service/account_http_service.go | 68 ++ service/account_service.go | 2 +- service/account_websocket_service.go | 213 +++-- 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/scripted_speech_service.go | 27 +- service/session_service.go | 85 -- service/session_tool_service.go | 399 ++++++++ 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 | 68 +- util/util.go | 75 -- 94 files changed, 1213 insertions(+), 10230 deletions(-) delete mode 100644 config/welcome_messages.yaml create mode 100644 consts/public/msg_key.go create mode 100644 consts/scriptedSpeech/scene_type.go delete mode 100644 consumer/ragflow_processor.go delete mode 100644 consumer/recreate_chat.go create mode 100644 controller/account_http_controller.go delete mode 100644 controller/archive_controller.go delete mode 100644 controller/customer_service_account_controller.go delete mode 100644 controller/data_controller.go delete mode 100644 controller/data_statistics_controller.go delete mode 100644 controller/health_controller.go delete mode 100644 controller/product_controller.go delete mode 100644 controller/ragflow_config_controller.go delete mode 100644 controller/ragflow_controller.go delete mode 100644 controller/speechcraft_controller.go delete mode 100644 controller/webhook_controller.go delete mode 100644 controller/websocket_controller.go delete mode 100644 controller/xiaohongshu_controller.go create mode 100644 dao/account_user_dialog_dao.go delete mode 100644 dao/archive_dao.go delete mode 100644 dao/conversation_dao.go delete mode 100644 dao/customer_service_account_dao.go delete mode 100644 dao/data_dao.go delete mode 100644 dao/data_statistics_dao.go delete mode 100644 dao/mongo_dao.go delete mode 100644 dao/product_dao.go delete mode 100644 dao/ragflow_config_dao.go delete mode 100644 dao/session_dao.go delete mode 100644 dao/speechcraft_dao.go delete mode 100644 dao/user_stage_dao.go create mode 100644 model/dto/account_http_dto.go create mode 100644 model/dto/account_user_dialog_dto.go delete mode 100644 model/dto/account_websocket_dto.go delete mode 100644 model/dto/archive_dto.go delete mode 100644 model/dto/customer_service_account_dto.go delete mode 100644 model/dto/data_dto.go delete mode 100644 model/dto/data_statistics_dto.go delete mode 100644 model/dto/product_dto.go delete mode 100644 model/dto/ragflow_chat_dto.go delete mode 100644 model/dto/ragflow_config_dto.go delete mode 100644 model/dto/ragflow_sync_dto.go create mode 100644 model/dto/session_tool_dto.go delete mode 100644 model/dto/speechcraft_dto.go delete mode 100644 model/dto/webhook_dto.go delete mode 100644 model/dto/xiaohongshu_dto.go create mode 100644 model/entity/account_user_dialog.go delete mode 100644 model/entity/conversation.go delete mode 100644 model/entity/conversation_archive.go delete mode 100644 model/entity/customer_service_account.go delete mode 100644 model/entity/data.go delete mode 100644 model/entity/data_statistics.go delete mode 100644 model/entity/product.go delete mode 100644 model/entity/ragflow_binding.go delete mode 100644 model/entity/ragflow_config.go delete mode 100644 model/entity/session.go delete mode 100644 model/entity/speechcraft.go delete mode 100644 model/entity/user_stage.go create mode 100644 service/account_http_service.go delete mode 100644 service/archive_service.go delete mode 100644 service/config_service.go delete mode 100644 service/conversation_service.go delete mode 100644 service/customer_service_account_service.go delete mode 100644 service/data_service.go delete mode 100644 service/data_statistics_service.go delete mode 100644 service/dataset_service.go delete mode 100644 service/jaegertesttemp.go delete mode 100644 service/product_service.go delete mode 100644 service/ragflow_config_service.go delete mode 100644 service/session_service.go create mode 100644 service/session_tool_service.go delete mode 100644 service/speechcraft_service.go delete mode 100644 service/webhook_service.go delete mode 100644 service/websocket_service.go delete mode 100644 service/xiaohongshu_service.go delete mode 100644 tools/delete_datasets_simple.go delete mode 100644 util/util.go diff --git a/config.yml b/config.yml index 1f1122c..0bf874b 100644 --- a/config.yml +++ b/config.yml @@ -1,7 +1,6 @@ server: address: ":3000" name: "customer-server" - workerId: 1 cache: localTTL: 60 redisTTL: 300 @@ -9,14 +8,14 @@ cache: database: default: - type: "pgsql" - host: "localhost" - port: "5432" + host: "116.204.74.41" + port: "15432" user: "postgres" - pass: "123456" - name: "customer_server" + pass: "Bjang09@686^*^" + name: "tenant-1" prefix: "customer_server_" # (可选)表名前缀 role: "master" # (可选)数据库主从角色(master/slave),默认为master。如果不使用应用主从机制请不配置或留空即可。 - debug: false # (可选)开启调试模式 + debug: true # (可选)开启调试模式 dryRun: false # (可选)ORM空跑(只读不写) charset: "utf8" # (可选)数据库编码(如: utf8mb4/utf8/gbk/gb2312),一般设置为utf8mb4。默认为utf8。 timezone: "Asia/Shanghai" # (可选)时区配置,例如:Local @@ -29,11 +28,11 @@ database: deletedAt: "deleted_at" # (可选)软删除时间字段名称 timeMaintainDisabled: false # (可选)是否完全关闭时间更新特性,为true时CreatedAt/UpdatedAt/DeletedAt都将失效 - type: "pgsql" - host: "localhost" - port: "5432" + host: "116.204.74.41" + port: "15432" user: "postgres" - pass: "123456" - name: "customer_server" + pass: "Bjang09@686^*^" + name: "tenant-1" prefix: "customer_server_" # (可选)表名前缀 role: "slave" # (可选)数据库主从角色(master/slave),默认为master。如果不使用应用主从机制请不配置或留空即可。 debug: false # (可选)开启调试模式 @@ -48,23 +47,32 @@ database: updatedAt: "updated_at" # (可选)自动更新时间字段名称 deletedAt: "deleted_at" # (可选)软删除时间字段名称 timeMaintainDisabled: false # (可选)是否完全关闭时间更新特性,为true时CreatedAt/UpdatedAt/DeletedAt都将失效 - -jwt: - secret: "abcdefghijklmnopqrstuvwxyz" + tenant-1: + - type: "pgsql" + host: "116.204.74.41" + port: "15432" + user: "postgres" + pass: "Bjang09@686^*^" + name: "tenant-1" + prefix: "customer_server_" # (可选)表名前缀 + role: "master" # (可选)数据库主从角色(master/slave),默认为master。如果不使用应用主从机制请不配置或留空即可。 + debug: true # (可选)开启调试模式 + dryRun: false # (可选)ORM空跑(只读不写) + charset: "utf8" # (可选)数据库编码(如: utf8mb4/utf8/gbk/gb2312),一般设置为utf8mb4。默认为utf8。 + timezone: "Asia/Shanghai" # (可选)时区配置,例如:Local + maxIdle: 5 # (可选)连接池最大闲置的连接数(默认10) + maxOpen: 20 # (可选)连接池最大打开的连接数(默认无限制) + maxLifetime: "30s" # (可选)连接对象可重复使用的时间长度(默认30秒) + maxIdleConnTime: "30s" # (可选,v2.10新增)连接池中空闲连接的最大生存时间(默认30秒)。可以通过配置文件或SetConnMaxIdleTime方法设置,避免长时间空闲连接占用资源。 + createdAt: "created_at" # (可选)自动创建时间字段名称 + updatedAt: "updated_at" # (可选)自动更新时间字段名称 + deletedAt: "deleted_at" # (可选)软删除时间字段名称 + timeMaintainDisabled: false # (可选)是否完全关闭时间更新特性,为true时CreatedAt/UpdatedAt/DeletedAt都将失效 rate: limit: 200 burst: 300 -# --- 华为云服务器(116.204.74.41:17027,带认证) --- -#mongo: -# default: -# address: "mongodb://root:M0ng0DB%40Pass2026%21@116.204.74.41:17027?authSource=admin" -# database: "customer_service" -# logger: -# level: "all" -# stdout: true - # --- 内网服务器(192.168.3.200:27017,无认证) --- mongo: default: @@ -76,7 +84,7 @@ mongo: redis: default: - address: localhost:6379 + address: 116.204.74.41:6379 db: 0 idleTimeout: "60s" maxConnLifetime: "90s" @@ -87,28 +95,21 @@ redis: maxActive: 100 consul: - address: localhost:8500 + address: 116.204.74.41:8500 + +jaeger: + addr: 116.204.74.41:4318 rabbitmq: host: localhost port: 5672 username: admin password: 123456 - # 响应队列配置(从message迁移) - responseExchange: "ragflow.response" - responseQueue: "ragflow.response.queue" - responseRoutingKey: "#" # 匹配所有routing key - # 不配置instanceName,直接使用os.Hostname()获取容器名/主机名作为实例ID -jaeger: - addr: localhost: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) +nats: + default: + addr: localhost + port: 4222 followUp: enable: true @@ -121,19 +122,6 @@ followUp: - "如果需要帮助,随时告诉我~" - "我一直在线,有问题随时找我~" -archive: - enable: true - queue: "archive.queue" - exchange: "archive.delayed" - routingKey: "archive" - delay: 3600 # 归档延时(秒),默认1小时 - # 月度归档配置 - enabled: true # 是否启用月度归档 - cron: "0 0 0 1 * *" # 每月1号0点执行(归档上个月整月数据) - mongoBatchSize: 1000 # 从原表复制到临时表的批量大小 - esBatchSize: 500 # 从临时表写入 ES 的批量大小 - testMode: true # 测试模式:true=归档最近7天,false=归档上个月(生产环境设为false) - # 卡片触发配置 card: triggerCount: 5 # 触发发送卡片的对话轮数(对话>=此值时触发) @@ -141,10 +129,3 @@ card: # 历史上下文配置 history: contextLimit: 5 # 读取历史对话轮数(用于新Session上下文注入) - -# Elasticsearch 配置 -elasticsearch: - addresses: - - "http://116.204.74.41:9200" - username: "" - password: "" diff --git a/config/welcome_messages.yaml b/config/welcome_messages.yaml deleted file mode 100644 index 0286482..0000000 --- a/config/welcome_messages.yaml +++ /dev/null @@ -1,53 +0,0 @@ -# 各咨询方向的欢迎话术配置 -# 当用户在状态5选择方向后,会自动发送对应的欢迎语 - -welcomes: - 乳腺贴: | - 🙋‍♀️ 欢迎姐妹们来到药济堂!我们是一个拥有10年经验的大健康专业团队🏅,专注于保守调理乳腺问题,已经成功帮助超过1万位姐妹轻松调理乳腺健康❤️。 - 如果您有结节或增生的困扰,欢迎随时咨询! - 请回复下面的数字,让我帮助您分析结节情况: - - 结节 - 增生 - 点击👇获取更快速的服务! - - 肝病: | - 你好,我是黄医生,有什么肝脏方面的问题我可以帮助您吗?无论是肝病、乙肝、丙肝,还是肝硬化腹水、脂肪肝、酒精肝,我都会为您提供专业的建议。😷 - 肝病的类型很多,常见症状有乏力、食欲减退和肝区不适等。为了更好地帮您,我需要了解一些详细的信息,比如: - - 您现在有腹胀或腹水的情况吗? - 是第一次出现还是反复出现呢? - 目前是早期还是中晚期呢? - 有没有病毒性肝炎的病史呢? - 请您留一下联系方式,我可以发送您的报告,并给您详细解读肝病治疗方案和成功案例。📋 - - 车膜: | - 🎉亲爱的车主,欢迎来到6膜王! - 🚗 我们在车膜行业深耕十年,拥有2家千平米的门店和30+专业团队,致力于为您提供最优质的服务! - ✨ 无论是隐形车衣还是改色膜,我们都能精准适配您的需求。留下您的【车型➕VX】,我们的资深顾问会立即为您匹配专属方案、膜材讲解以及报价!❤️ - - 毛孔: | - 啊啊~亲爱的姐妹们,最近有很多小伙伴在问我关于毛孔的问题!我之前也是毛孔大到妆容卡粉,真的是烦恼不断。😩 但在经过一段时间的摸索后,我终于找到了合适的方法,效果真的很好,差不多一个月就改善了许多!(亲测有效!)💖 - 如果需要帮助,可以随时告诉我,我非常乐意分享我的经验给你们哦! - - 免税店: | - Hi,长春的宝子们~💕欢迎锁定小红提免税集合店! - 这里有你想要的一切:美妆、香水、包包、首饰和大牌护肤品,正品保真不踩雷,价格特别美丽哦✨ - 全城顺丰包邮,购物更方便!期待你们来逛快来发现更多惊喜吧!💖 - - 门店地址:长春市绿园区皓月大路吾悦广场1楼 1036号,等你来哦!🌟 - - 减肥: | - 你好呀,姐妹~你也有肉肉的困扰吗?我现在已经掉了二十多斤了,至今都没有反弹! - 想要方法的话可以直接回复"1",我分享给你哦~ - (💗未成年发育期、哺乳期的姐妹我就不推荐啦) - - 气血: | - 亲爱的,欢迎光临!🌸 - 如果你有月经不调或气血不足的问题,随时可以问我哦! - - 停经闭经 - 痛经难忍 - 量少 - 经期不准 - 💗只需回复数字,我们会为你提供专业建议! 🌟如需更多帮助,点击下方咨询专业老师,我们一起寻求解决方案~ diff --git a/consts/account/platform.go b/consts/account/platform.go index af266a7..da374f0 100644 --- a/consts/account/platform.go +++ b/consts/account/platform.go @@ -6,6 +6,11 @@ var ( PlatformXHS = newPlatform(gconv.PtrString("xiaohongshu"), "小红书") PlatformDY = newPlatform(gconv.PtrString("douyin"), "抖音") PlatformKS = newPlatform(gconv.PtrString("kuaishou"), "快手") + platformMap = map[Platform]platform{ + gconv.PtrString("xiaohongshu"): PlatformXHS, + gconv.PtrString("douyin"): PlatformDY, + gconv.PtrString("kuaishou"): PlatformKS, + } ) type Platform *string @@ -25,3 +30,10 @@ func (s platform) Desc() string { func newPlatform(code Platform, desc string) platform { return platform{code: code, desc: desc} } + +func GetDescByCode(code Platform) string { + if p, ok := platformMap[code]; ok { + return p.Desc() + } + return "未知平台" +} diff --git a/consts/public/msg_key.go b/consts/public/msg_key.go new file mode 100644 index 0000000..98af16e --- /dev/null +++ b/consts/public/msg_key.go @@ -0,0 +1,12 @@ +package public + +const GmqMsgPluginsName = "gmq_msg" + +const AccountDialogKeyUserId = "account:dialog:%s" + +const ( + AccountFollowupTopic = "account:followup:stream" // 请求 Stream 键名(与发消息的key一致) + AccountFollowupConsumer = "account-followup-consumer" // 消费者名称(唯一标识) + AccountFollowupCount = 1 // 批处理大小(每次读取1条) + AccountFollowupAck = false // ACK是否自动确认(true自动确认,false不确认) +) diff --git a/consts/public/table_name.go b/consts/public/table_name.go index e5b4019..7263b6f 100644 --- a/consts/public/table_name.go +++ b/consts/public/table_name.go @@ -2,6 +2,7 @@ package public // sql 数据库表名 const ( - TableNameAccount = "account" - TableNameScriptedSpeech = "scripted_speech" + TableNameAccount = "account" + TableNameAccountUserDialog = "account_user_dialog" + TableNameScriptedSpeech = "scripted_speech" ) diff --git a/consts/scriptedSpeech/scene_type.go b/consts/scriptedSpeech/scene_type.go new file mode 100644 index 0000000..c0318b3 --- /dev/null +++ b/consts/scriptedSpeech/scene_type.go @@ -0,0 +1,40 @@ +package scriptedSpeech + +import "github.com/gogf/gf/v2/util/gconv" + +var ( + SceneTypeOpeningRemark = newSceneType(gconv.PtrInt8(1), "开场白无回应") + SceneTypeDialog = newSceneType(gconv.PtrInt8(2), "对话中途无回应") + SceneTypeCardSend = newSceneType(gconv.PtrInt8(3), "卡片发送后无回应") + + sceneTypeMap = map[SceneType]sceneType{ + gconv.PtrInt8(1): SceneTypeOpeningRemark, + gconv.PtrInt8(2): SceneTypeDialog, + gconv.PtrInt8(3): SceneTypeCardSend, + } +) + +type SceneType *int8 + +type sceneType struct { + code SceneType + desc string +} + +func (s sceneType) Code() SceneType { + return s.code +} +func (s sceneType) Desc() string { + return s.desc +} + +func newSceneType(code SceneType, desc string) sceneType { + return sceneType{code: code, desc: desc} +} + +func GetDescByCode(code SceneType) string { + if p, ok := sceneTypeMap[code]; ok { + return p.Desc() + } + return "未知场景类型" +} diff --git a/consumer/ragflow_processor.go b/consumer/ragflow_processor.go deleted file mode 100644 index ac6e43c..0000000 --- a/consumer/ragflow_processor.go +++ /dev/null @@ -1,591 +0,0 @@ -package consumer - -import ( - "context" - "strings" - - "gitea.com/red-future/common/db/mongo" - "gitea.com/red-future/common/rabbitmq" - "gitea.com/red-future/common/ragflow" - "gitea.com/red-future/common/redis" - "github.com/gogf/gf/v2/encoding/gjson" - "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/os/gfile" - "github.com/gogf/gf/v2/os/glog" - "github.com/gogf/gf/v2/os/gtime" - "github.com/gogf/gf/v2/util/gconv" -) - -// processorConfig 处理器配置(避免重复读取配置) -type processorConfig struct { - chatId string // RAGFlow Chat ID(默认) - responseExchange string // 响应队列 Exchange - responseRoutingKey string // 响应队列 RoutingKey - followUpExchange string // 追问队列 Exchange - followUpRoutingKey string // 追问队列 RoutingKey - archiveExchange string // 归档队列 Exchange - archiveRoutingKey string // 归档队列 RoutingKey - // Mock模式已删除,不再用于压测 -} - -var cfg *processorConfig - -// responsePublisher 响应消息发布器(单例) -var responsePublisher *rabbitmq.Publisher - -// followUpPublisher 追问消息发布器(单例) -var followUpPublisher *rabbitmq.Publisher - -// archivePublisher 归档消息发布器(单例) -var archivePublisher *rabbitmq.Publisher - -// Processor 消息处理器 -type Processor struct { - processor *ragflow.QueueProcessor -} - -// NewProcessor 创建消息处理器 -func NewProcessor(ctx context.Context) (*Processor, error) { - // 从配置读取参数 - streamKey := g.Cfg().MustGet(ctx, "stream.streamKey").String() - groupName := g.Cfg().MustGet(ctx, "stream.groupName").String() - consumerName := g.Cfg().MustGet(ctx, "stream.consumerName").String() - batchSize := g.Cfg().MustGet(ctx, "stream.batchSize", 200).Int64() - blockTimeout := g.Cfg().MustGet(ctx, "stream.blockTimeout", 2000).Int64() - - // 初始化处理器配置(单例) - cfg = &processorConfig{ - chatId: g.Cfg().MustGet(ctx, "ragflow.chat_id").String(), - responseExchange: g.Cfg().MustGet(ctx, "rabbitmq.responseExchange", "ragflow.response").String(), - responseRoutingKey: g.Cfg().MustGet(ctx, "rabbitmq.responseRoutingKey", "response").String(), - followUpExchange: g.Cfg().MustGet(ctx, "followUp.exchange", "followup.delayed").String(), - followUpRoutingKey: g.Cfg().MustGet(ctx, "followUp.routingKey", "followup").String(), - archiveExchange: g.Cfg().MustGet(ctx, "archive.exchange", "archive.delayed").String(), - archiveRoutingKey: g.Cfg().MustGet(ctx, "archive.routingKey", "archive").String(), - } - - // 咨询方向配置已从Consul自动加载(common/config包init时自动执行) - - // 初始化响应发布器 - responsePublisher = rabbitmq.NewPublisher(cfg.responseExchange, cfg.responseRoutingKey) - glog.Infof(ctx, "响应发布器已初始化 - Exchange: %s, RoutingKey: %s", cfg.responseExchange, cfg.responseRoutingKey) - - // 初始化追问发布器 - followUpPublisher = rabbitmq.NewPublisher(cfg.followUpExchange, cfg.followUpRoutingKey) - glog.Info(ctx, "追问发布器已初始化") - - // 初始化归档发布器 - archivePublisher = rabbitmq.NewPublisher(cfg.archiveExchange, cfg.archiveRoutingKey) - glog.Info(ctx, "归档发布器已初始化") - - // 创建消息处理器(批量读取 + 并发发送,削峰填谷) - return &Processor{ - processor: ragflow.NewQueueProcessor( - streamKey, - groupName, - consumerName, - blockTimeout, - batchSize, - handleMessage, - ), - }, nil -} - -// Start 启动消息处理 -func (p *Processor) Start(ctx context.Context) error { - glog.Info(ctx, "开始消费消息...") - return p.processor.Start(ctx) -} - -// Stop 停止消息处理 -func (p *Processor) Stop() { - p.processor.Stop() -} - -// getChatIdByAccountName 根据客服账号名称从ragflow_config表查询chat_id -func getChatIdByAccountName(ctx context.Context, tenantId, accountName string) string { - if accountName == "" { - return "" - } - - db := mongo.GetDB() - if db == nil { - glog.Error(ctx, "MongoDB未初始化") - return "" - } - - collection := db.Collection("ragflow_config") - - // 从MongoDB查询ragflow_config(先尝试字符串tenantId) - filter := map[string]interface{}{ - "accountName": accountName, - "isDeleted": false, - } - - if tenantId != "" { - filter["tenantId"] = tenantId - } - - var config struct { - ChatId string `json:"chatId" bson:"chatId"` - } - - err := collection.FindOne(ctx, filter).Decode(&config) - - // 如果未找到且tenantId可以转为数字,尝试用数字查询(兼容MongoDB中存储为int的情况) - if err != nil && tenantId != "" { - tenantIdInt := gconv.Int(tenantId) - if tenantIdInt > 0 { - filter["tenantId"] = tenantIdInt - err = collection.FindOne(ctx, filter).Decode(&config) - } - } - - if err != nil { - glog.Warningf(ctx, "未找到客服账号对应的RAGFlow配置 - 账号: %s, tenantId: %s, err: %v", accountName, tenantId, err) - return "" - } - - glog.Infof(ctx, "使用客服账号对应的chat_id - 账号: %s, chat_id: %s", accountName, config.ChatId) - return config.ChatId -} - -// getChatIdByDirection 根据用户选择的咨询方向获取对应的chat_id(从Consul读取) -func getChatIdByDirection(ctx context.Context, userId, platform string) string { - // 从Redis获取用户状态 - userState, err := redis.GetUserState(ctx, userId, platform) - if err != nil || userState.Direction == "" { - // 无方向或获取失败,返回默认chat_id - if cfg != nil { - return cfg.chatId - } - return "" - } - - // 直接使用accountName查询 - chatId := "" - if chatId != "" { - glog.Infof(ctx, "使用咨询方向对应的chat_id - 用户: %s, 方向: %s, chat_id: %s", userId, userState.Direction, chatId) - return chatId - } - - // 未找到匹配方向,返回默认chat_id - glog.Warningf(ctx, "未找到方向对应的chat_id,使用默认 - 用户: %s, 方向: %s", userId, userState.Direction) - if cfg != nil { - return cfg.chatId - } - return "" -} - -// HandleMessageHTTP 处理HTTP请求的消息(导出供controller调用) -func HandleMessageHTTP(ctx context.Context, message map[string]interface{}) error { - return handleMessage(ctx, message) -} - -// handleMessage 处理单条消息 -func handleMessage(ctx context.Context, message map[string]interface{}) (err error) { - // gconv.Map转换结构体时使用驼峰字段名,而非json标签 - userId := gconv.String(message["UserId"]) - content := gconv.String(message["Content"]) - messageId := gconv.String(message["MessageId"]) - platform := gconv.String(message["Platform"]) - tenantId := gconv.String(message["TenantId"]) - accountId := gconv.String(message["AccountId"]) // 客服账号ID - accountName := gconv.String(message["AccountName"]) // 客服账号名称(如cs_xhs_qixue) - messageChatId := gconv.String(message["ChatId"]) // 消息中携带的chat_id(从ragflow_config查询) - if platform == "" { - platform = "xiaohongshu" // 默认平台 - } - - // 解析历史对话(由 customerservice 从 MongoDB 读取后携带) - var history []redis.HistoryMessage - if historyData := message["History"]; historyData != nil { - _ = gjson.New(historyData).Scan(&history) - } - - glog.Infof(ctx, "处理消息 - 用户: %s, 客服账号: %s, ChatId: %s, 内容: %s, 历史轮数: %d", - userId, accountName, messageChatId, content, len(history)) - - var answer string - var sessionId string - startTime := gtime.Now() - - // 调用RAGFlow处理消息 - { - // 1. 获取chat_id(优先级:消息携带 > 客服账号查询 > 用户方向查询 > 默认) - var chatId string - if messageChatId != "" { - chatId = messageChatId - glog.Infof(ctx, "使用消息携带的chat_id: %s", chatId) - } else if accountName != "" { - chatId = getChatIdByAccountName(ctx, tenantId, accountName) - } - if chatId == "" { - chatId = getChatIdByDirection(ctx, userId, platform) - } - - // 2. 检测chatId是否变更不再需要,因为缓存key已包含chatId - // 不同chatId自动使用不同的session缓存 } - - // 3. 获取或创建 Session(使用正确的chat_id,包含租户隔离) - var isNewSession bool - sessionId, isNewSession, err = getOrCreateSession(ctx, tenantId, userId, chatId) - if err != nil { - // 检测Chat权限错误(assistant/chat不属于当前API Key) - if strings.Contains(err.Error(), "do not own the assistant") || strings.Contains(err.Error(), "don't own the chat") || strings.Contains(err.Error(), "doesn't exist") || strings.Contains(err.Error(), "not found") { - glog.Warningf(ctx, "创建Session时检测到Chat权限错误,尝试自动重建 - 用户: %s, accountName: %s, chat_id: %s", userId, accountName, chatId) - - // 调用重建逻辑 - if accountName != "" { - if recreateErr := recreateChatIfNeeded(ctx, tenantId, accountName, platform); recreateErr != nil { - glog.Errorf(ctx, "自动重建Chat失败: %v", recreateErr) - return - } - - // 重新查询chat_id - newChatId := getChatIdByAccountName(ctx, tenantId, accountName) - if newChatId == "" { - glog.Errorf(ctx, "重建Chat后仍无法获取chat_id") - return - } - chatId = newChatId - glog.Infof(ctx, "Chat重建成功,新chat_id: %s", chatId) - - // 清理旧session缓存 - redis.DelSessionCache(ctx, tenantId, userId) - - // 使用新chat_id重新创建session - sessionId, isNewSession, err = getOrCreateSession(ctx, tenantId, userId, chatId) - if err != nil { - glog.Errorf(ctx, "使用新Chat创建Session失败: %v", err) - return - } - } else { - glog.Errorf(ctx, "Chat权限错误但缺少accountName,无法自动重建") - return - } - } else { - glog.Errorf(ctx, "获取 Session 失败: %v", err) - return - } - } - - // 3. 调用 RAGFlow API - client := ragflow.GetGlobalClient() - if client == nil { - glog.Error(ctx, "RAGFlow 客户端未初始化") - return - } - - // 如果是新 Session 且有历史对话,把历史拼接到问题中调用 RAGFlow - if isNewSession && len(history) > 0 { - var newSessionId string - newSessionId, answer, err = callWithHistory(ctx, client, userId, content, history, chatId) - if err != nil { - glog.Errorf(ctx, "带历史调用 RAGFlow 失败: %v", err) - return - } - // 更新 Session 缓存(包含租户隔离) - if newSessionId != "" { - redis.SetSessionCache(ctx, tenantId, userId, newSessionId) - sessionId = newSessionId - } - } else { - // 已有 Session 或无历史,使用普通 API(RAGFlow 内部维护上下文) - var res *ragflow.ChatCompletionRes - res, err = client.ChatCompletion(ctx, chatId, &ragflow.ChatCompletionReq{ - Question: content, - SessionId: sessionId, - UserId: userId, - Stream: false, - }) - if err != nil { - // 检测Chat不存在或权限错误(RAGFlow中Chat被删除或不属于当前API Key) - if strings.Contains(err.Error(), "doesn't exist") || strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "404") || strings.Contains(err.Error(), "don't own the chat") { - glog.Warningf(ctx, "检测到Chat不存在,尝试自动重建 - 用户: %s, accountName: %s, chat_id: %s", userId, accountName, chatId) - - // 调用重建逻辑(需要accountName) - if accountName != "" { - if recreateErr := recreateChatIfNeeded(ctx, tenantId, accountName, platform); recreateErr != nil { - glog.Errorf(ctx, "自动重建Chat失败: %v", recreateErr) - return - } - - // 重新查询chat_id - newChatId := getChatIdByAccountName(ctx, tenantId, accountName) - if newChatId == "" { - glog.Errorf(ctx, "重建Chat后仍无法获取chat_id") - return - } - chatId = newChatId - glog.Infof(ctx, "Chat重建成功,新chat_id: %s", chatId) - - // 清理旧session缓存 - redis.DelSessionCache(ctx, tenantId, userId) - - // 重新创建session并调用 - sessionId, _, err = getOrCreateSession(ctx, tenantId, userId, chatId) - if err != nil { - glog.Errorf(ctx, "创建新Session失败: %v", err) - return - } - - // 重试调用 - res, err = client.ChatCompletion(ctx, chatId, &ragflow.ChatCompletionReq{ - Question: content, - SessionId: sessionId, - UserId: userId, - Stream: false, - }) - if err != nil { - glog.Errorf(ctx, "使用新Chat重试失败: %v", err) - return - } - } else { - glog.Errorf(ctx, "Chat不存在但缺少accountName,无法自动重建") - return - } - // 检测session ownership错误(session不属于当前chat_id) - } else if strings.Contains(err.Error(), "don't own the session") { - glog.Warningf(ctx, "Session不属于当前chat_id,清理缓存并重新创建 - 用户: %s, 旧session: %s, chat_id: %s", userId, sessionId, chatId) - // 清理缓存(包含租户隔离) - redis.DelSessionCache(ctx, tenantId, userId) - // 重新创建session并调用 - sessionId, _, err = getOrCreateSession(ctx, tenantId, userId, chatId) - if err != nil { - glog.Errorf(ctx, "重新创建Session失败: %v", err) - return - } - // 重试调用 - res, err = client.ChatCompletion(ctx, chatId, &ragflow.ChatCompletionReq{ - Question: content, - SessionId: sessionId, - UserId: userId, - Stream: false, - }) - if err != nil { - glog.Errorf(ctx, "重试调用RAGFlow失败: %v", err) - return - } - } else { - glog.Errorf(ctx, "调用 RAGFlow 失败: %v", err) - return - } - } - answer = res.Data.Answer - - // 更新 Session 缓存(如果 RAGFlow 返回了新的 session_id,包含租户隔离) - if res.Data.SessionId != "" && res.Data.SessionId != sessionId { - redis.SetSessionCache(ctx, tenantId, userId, res.Data.SessionId) - sessionId = res.Data.SessionId - } - } - } - - // 计算耗时并写入文件 - endTime := gtime.Now() - elapsed := endTime.Sub(startTime) - // 截取回复前20字 - answerPreview := answer - if len([]rune(answerPreview)) > 20 { - answerPreview = string([]rune(answerPreview)[:20]) + "..." - } - glog.Infof(ctx, "回复 - 用户: %s, 耗时: %s, 回复: %s", userId, elapsed, answerPreview) - // 日志格式:发送时间 | 响应时间 | 耗时 | 用户 | 问题 | 回复前20字 - // 写入时间日志,失败时记录警告但不影响主流程 - logContent := startTime.Format("Y-m-d H:i:s") + "\t" + endTime.Format("Y-m-d H:i:s") + "\t" + elapsed.String() + "\t" + userId + "\t" + content + "\t" + answerPreview + "\n" - if err := gfile.PutContentsAppend("timelog/ragflow_time.log", logContent); err != nil { - glog.Warningf(ctx, "写入时间日志失败: %v", err) - } - - // 3. 更新用户最后活跃时间 - redis.SetSessionLastActive(ctx, userId) - - // 6. 原样写入 RabbitMQ 结果队列(透传 TenantId、AccountId、AccountName) - responseMsg := &redis.ResponseStreamMessage{ - UserId: userId, - Platform: platform, - TenantId: tenantId, - AccountId: accountId, - AccountName: accountName, - Question: content, - Content: answer, - SessionId: sessionId, - Timestamp: gtime.Now().Timestamp(), - MessageId: messageId, - } - - // 读取请求中的ReplyQueue字段,支持多实例独立队列 - replyQueue := gconv.String(message["reply_queue"]) - if replyQueue != "" && replyQueue != cfg.responseRoutingKey { - // 使用自定义响应队列(多实例场景) - // routing key使用队列名,实现精确路由(避免广播到所有实例) - glog.Infof(ctx, "使用自定义响应队列: %s - 用户: %s", replyQueue, userId) - customPublisher := rabbitmq.NewPublisher(cfg.responseExchange, replyQueue) - if err = customPublisher.PublishWithRoutingKey(ctx, replyQueue, responseMsg); err != nil { - glog.Errorf(ctx, "写入自定义响应队列失败: %v", err) - return - } - glog.Infof(ctx, "响应已写入 RabbitMQ - 用户: %s, routingKey: %s (队列名)", userId, replyQueue) - } else { - // 使用默认响应队列(单实例或旧版本兼容) - // routing key使用:tenantId.userId - routingKey := tenantId + "." + userId - if err = responsePublisher.PublishWithRoutingKey(ctx, routingKey, responseMsg); err != nil { - glog.Errorf(ctx, "写入 RabbitMQ 结果队列失败: %v", err) - return - } - glog.Infof(ctx, "响应已写入 RabbitMQ - 用户: %s, routingKey: %s (租户.用户)", userId, routingKey) - } - - // 7. 发送追问消息到延时队列 - sendFollowUpMessages(ctx, userId, platform) - - // 8. 发送归档消息到延时队列(60分钟后) - sendArchiveMessage(ctx, userId, platform, sessionId, tenantId) - - glog.Infof(ctx, "消息处理完成 - 用户: %s", userId) - return -} - -// sendArchiveMessage 发送归档消息到延时队列 -func sendArchiveMessage(ctx context.Context, userId, platform, sessionId, tenantId string) { - msg := &redis.ArchiveMessage{ - UserId: userId, - Platform: platform, - SessionId: sessionId, - TenantId: tenantId, - Timestamp: gtime.Now().Timestamp(), - } - - if err := archivePublisher.PublishDelayed(ctx, msg, redis.GetArchiveDelay()); err != nil { - glog.Errorf(ctx, "发送归档消息失败: %v", err) - } -} - -// callWithHistory 带历史上下文调用 RAGFlow(用于新 Session) -// history 由 customerservice 从 MongoDB 读取后通过消息携带 -// 使用 RAGFlow 原生 API,把历史对话拼接到问题中,保留提示词和知识库功能 -// chatId 为用户选择方向对应的chat_id,而非默认chat_id -func callWithHistory(ctx context.Context, client *ragflow.Client, userId, content string, history []redis.HistoryMessage, chatId string) (sessionId, answer string, err error) { - // 构建带历史上下文的问题 - var question string - if len(history) > 0 { - // 限制历史对话长度:最多3轮或总字符数不超过8000(避免超过RAGFlow输入限制) - const maxHistoryRounds = 3 - const maxHistoryChars = 8000 - - var builder strings.Builder - builder.WriteString("[以下是之前的对话历史,请参考]\n") - - // 限制历史轮数 - historyToUse := history - if len(history) > maxHistoryRounds { - historyToUse = history[len(history)-maxHistoryRounds:] // 只取最近3轮 - glog.Infof(ctx, "历史对话超过%d轮,截取最近%d轮 - 用户: %s", len(history), maxHistoryRounds, userId) - } - - // 拼接历史对话,同时检查字符数限制 - for _, h := range historyToUse { - builder.WriteString("用户: ") - builder.WriteString(h.Question) - builder.WriteString("\nAI: ") - builder.WriteString(h.Answer) - builder.WriteString("\n") - - // 检查是否超过字符数限制 - if builder.Len() > maxHistoryChars { - glog.Warningf(ctx, "历史对话超过%d字符,停止追加 - 用户: %s", maxHistoryChars, userId) - break - } - } - - builder.WriteString("\n[当前问题]\n") - builder.WriteString(content) - question = builder.String() - } else { - question = content - } - - glog.Infof(ctx, "注入 %d 轮历史对话上下文 - chat_id: %s", len(history), chatId) - - // 先创建新 session(使用方向对应的chat_id) - session, err := client.CreateSession(ctx, chatId, &ragflow.CreateSessionReq{ - Name: "session_" + userId, - UserId: userId, - }) - if err != nil { - glog.Errorf(ctx, "创建 Session 失败: %v", err) - return - } - sessionId = session.Id - - // 使用 RAGFlow 原生 API(保留提示词和知识库,使用方向对应的chat_id) - res, err := client.ChatCompletion(ctx, chatId, &ragflow.ChatCompletionReq{ - Question: question, - SessionId: sessionId, - UserId: userId, - Stream: false, - }) - if err != nil { - return - } - - answer = res.Data.Answer - return -} - -// sendFollowUpMessages 发送追问消息到延时队列 -func sendFollowUpMessages(ctx context.Context, userId, platform string) { - now := gtime.Now().Timestamp() - - for followUpType := redis.FollowUpType1; followUpType <= redis.FollowUpType3; followUpType++ { - msg := &redis.FollowUpMessage{ - UserId: userId, - Platform: platform, - Content: redis.GetFollowUpContent(followUpType), - FollowUpType: followUpType, - Timestamp: now, - } - - if err := followUpPublisher.PublishDelayed(ctx, msg, redis.GetFollowUpDelay(followUpType)); err != nil { - glog.Errorf(ctx, "发送追问消息失败 - 类型: %d, 错误: %v", followUpType, err) - } - } -} - -// getOrCreateSession 获取或创建 RAGFlow Session(支持租户隔离) -// 返回 isNew=true 表示是新创建的 session,需要注入历史上下文 -func getOrCreateSession(ctx context.Context, tenantId, userId, chatId string) (sessionId string, isNew bool, err error) { - // 先从缓存获取(包含租户隔离) - if sessionId, err = redis.GetSessionCache(ctx, tenantId, userId); err != nil { - return - } - if sessionId != "" { - glog.Infof(ctx, "使用缓存的session - 租户: %s, 用户: %s, chat_id: %s, session: %s", tenantId, userId, chatId, sessionId) - return // 已有 session,不是新的 - } - - // 缓存不存在,创建新 Session(使用传入的chat_id) - client := ragflow.GetGlobalClient() - if client == nil { - return - } - - glog.Infof(ctx, "创建新session - 租户: %s, 用户: %s, chat_id: %s", tenantId, userId, chatId) - session, err := client.CreateSession(ctx, chatId, &ragflow.CreateSessionReq{ - Name: "session_" + tenantId + "_" + userId, - UserId: userId, - }) - if err != nil { - return - } - - sessionId = session.Id - isNew = true // 标记为新创建 - - // 缓存 Session ID(包含租户隔离) - redis.SetSessionCache(ctx, tenantId, userId, sessionId) - glog.Infof(ctx, "新session已创建并缓存 - 租户: %s, 用户: %s, session: %s", tenantId, userId, sessionId) - return -} diff --git a/consumer/recreate_chat.go b/consumer/recreate_chat.go deleted file mode 100644 index 0dd4c81..0000000 --- a/consumer/recreate_chat.go +++ /dev/null @@ -1,47 +0,0 @@ -package consumer - -import ( - "context" - - "github.com/gogf/gf/v2/encoding/gjson" - "github.com/gogf/gf/v2/errors/gerror" - "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/os/glog" -) - -// recreateChatIfNeeded 检测并重建Chat配置 -// 调用customerservice的recreateRAGFlow接口,由customerservice负责MongoDB操作 -func recreateChatIfNeeded(ctx context.Context, tenantId, accountName, platform string) error { - glog.Infof(ctx, "开始自动重建Chat - accountName: %s, platform: %s, tenantId: %s", accountName, platform, tenantId) - - // 1. 从配置获取customerservice地址(避免consul.GetInstanceAddr在Stream context中panic) - customerserviceAddr := g.Cfg().MustGet(ctx, "customerservice.address", "customerservice:3000").String() - - // 2. 调用customerservice的recreateRAGFlow接口 - url := "http://" + customerserviceAddr + "/customer/service/account/recreateRAGFlow" - reqBody := g.Map{ - "accountName": accountName, - "platform": platform, - } - - glog.Infof(ctx, "调用customerservice recreateRAGFlow接口 - URL: %s, 请求: %+v", url, reqBody) - - client := g.Client() - resp, err := client.Post(ctx, url, reqBody) - if err != nil { - return gerror.Wrapf(err, "调用recreateRAGFlow接口失败") - } - defer resp.Close() - - // 3. 解析响应 - respBody := resp.ReadAllString() - result := gjson.New(respBody) - - if result.Get("code").Int() != 0 { - errMsg := result.Get("message").String() - return gerror.Newf("recreateRAGFlow失败: %s", errMsg) - } - - glog.Infof(ctx, "Chat自动重建完成 - accountName: %s", accountName) - return nil -} diff --git a/controller/account_http_controller.go b/controller/account_http_controller.go new file mode 100644 index 0000000..f4109f3 --- /dev/null +++ b/controller/account_http_controller.go @@ -0,0 +1,16 @@ +package controller + +import ( + "context" + "customer-server/model/dto" + "customer-server/service" +) + +var AccountHttp = new(accountHttp) + +type accountHttp struct{} + +func (c *accountHttp) Connect(ctx context.Context, req *dto.AccountHttpConnectReq) (res *dto.AccountHttpConnectRes, err error) { + res, err = service.AccountHttpService.Connect(ctx, req) + return +} diff --git a/controller/account_websocket_controller.go b/controller/account_websocket_controller.go index 8264f5f..1a8523f 100644 --- a/controller/account_websocket_controller.go +++ b/controller/account_websocket_controller.go @@ -21,6 +21,6 @@ type accountWebSocket struct{} // 功能: 升级HTTP连接为WebSocket,建立实时通信通道 func (c *accountWebSocket) Connect(ctx context.Context, req *dto.AccountWebSocketConnectReq) (res *beans.ResponseEmpty, err error) { r := g.RequestFromCtx(ctx) - err = service.AccountWebSocket.Connect(ctx, r, req.AccountName, req.Platform) + err = service.AccountWebSocket.Connect(ctx, r, req) return } diff --git a/controller/archive_controller.go b/controller/archive_controller.go deleted file mode 100644 index 2d1a7c1..0000000 --- a/controller/archive_controller.go +++ /dev/null @@ -1,29 +0,0 @@ -// Package controller - 归档控制器 -// 功能:手动触发对话记录从MongoDB归档到Elasticsearch -package controller - -import ( - "context" - "customer-server/model/dto" - "customer-server/service" -) - -var Archive = new(archive) - -type archive struct{} - -// RunNow 手动触发归档 -// 参数: req - 归档请求(可选参数) -// 返回: res - 归档执行结果信息 -// 功能: 手动触发月度归档任务,将MongoDB对话记录迁移到Elasticsearch -// 路由: POST /archive/run -func (c *archive) RunNow(ctx context.Context, req *dto.ArchiveRunReq) (res *dto.ArchiveRunRes, err error) { - err = service.ArchiveService.RunNow(ctx) - if err != nil { - return - } - res = &dto.ArchiveRunRes{ - Message: "归档任务执行完成", - } - return -} diff --git a/controller/customer_service_account_controller.go b/controller/customer_service_account_controller.go deleted file mode 100644 index 41a1e85..0000000 --- a/controller/customer_service_account_controller.go +++ /dev/null @@ -1,87 +0,0 @@ -// Package controller - 客服账号控制器 -// 功能:客服账号的增删改查 -package controller - -import ( - "context" - "customer-server/model/dto" - "customer-server/service" - - "gitea.com/red-future/common/beans" -) - -var CustomerServiceAccount = new(customerServiceAccount) - -type customerServiceAccount struct{} - -// Add 添加客服账号 -// 参数: req - 添加客服账号请求,包含账号名、平台、开场白等信息 -// 返回: res - 添加成功后的账号ID等信息 -// 功能: 创建客服账号并自动创建对应的RAGFlow配置(Dataset、Chat) -func (c *customerServiceAccount) Add(ctx context.Context, req *dto.AddCustomerServiceAccountReq) (res *dto.AddCustomerServiceAccountRes, err error) { - res, err = service.CustomerServiceAccount.Add(ctx, req) - return -} - -// Update 更新客服账号 -// 参数: req - 更新客服账号请求,包含账号ID和待更新字段 -// 返回: res - 空响应(成功则err为nil) -// 功能: 更新客服账号基本信息(不包含开场白和RAGFlow配置) -func (c *customerServiceAccount) Update(ctx context.Context, req *dto.UpdateCustomerServiceAccountReq) (res *beans.ResponseEmpty, err error) { - err = service.CustomerServiceAccount.Update(ctx, req) - return -} - -// ToggleStatus 切换客服账号状态(启用/禁用) -// 参数: req - 状态切换请求,包含账号ID -// 返回: res - 空响应(成功则err为nil) -// 功能: 在启用和禁用状态之间切换,禁用后不再接收用户消息 -func (c *customerServiceAccount) ToggleStatus(ctx context.Context, req *dto.ToggleCustomerServiceAccountStatusReq) (res *beans.ResponseEmpty, err error) { - err = service.CustomerServiceAccount.ToggleStatus(ctx, req) - return -} - -// List 获取客服账号列表 -// 参数: req - 列表查询请求,支持分页、平台筛选、状态筛选 -// 返回: res - 客服账号列表及分页信息 -// 功能: 分页查询客服账号,支持按平台、状态筛选 -func (c *customerServiceAccount) List(ctx context.Context, req *dto.ListCustomerServiceAccountReq) (res *dto.ListCustomerServiceAccountRes, err error) { - res, err = service.CustomerServiceAccount.List(ctx, req) - return -} - -// Delete 删除客服账号 -// 参数: req - 删除客服账号请求,包含账号ID -// 返回: res - 删除结果信息 -// 功能: 逻辑删除客服账号,同时删除关联的RAGFlow配置 -func (c *customerServiceAccount) Delete(ctx context.Context, req *dto.DeleteCustomerServiceAccountReq) (res *dto.DeleteCustomerServiceAccountRes, err error) { - res, err = service.CustomerServiceAccount.Delete(ctx, req) - return -} - -// GetAccessibleSpeechcrafts 获取客服账号可访问的话术列表 -// 参数: req - 查询请求,包含账号名 -// 返回: res - 该账号可访问的话术列表 -// 功能: 查询绑定到该客服账号的所有话术 -func (c *customerServiceAccount) GetAccessibleSpeechcrafts(ctx context.Context, req *dto.GetAccessibleSpeechcraftsReq) (res *dto.GetAccessibleSpeechcraftsRes, err error) { - res, err = service.CustomerServiceAccount.GetAccessibleSpeechcrafts(ctx, req) - return -} - -// RecreateRAGFlowConfig 重新创建RAGFlow配置 -// 参数: req - 重建请求,包含账号名 -// 返回: res - 空响应(成功则err为nil) -// 功能: 删除旧配置并重新创建Dataset和Chat,用于修复配置异常 -func (c *customerServiceAccount) RecreateRAGFlowConfig(ctx context.Context, req *dto.RecreateRAGFlowConfigReq) (res *beans.ResponseEmpty, err error) { - err = service.CustomerServiceAccount.RecreateRAGFlowConfig(ctx, req) - return -} - -// UpdateGreeting 更新开场白 -// 参数: req - 更新开场白请求,包含账号名和新开场白内容 -// 返回: res - 空响应(成功则err为nil) -// 功能: 更新客服账号的开场白,用户连接WebSocket时推送 -func (c *customerServiceAccount) UpdateGreeting(ctx context.Context, req *dto.UpdateGreetingReq) (res *beans.ResponseEmpty, err error) { - err = service.CustomerServiceAccount.UpdateGreeting(ctx, req) - return -} diff --git a/controller/data_controller.go b/controller/data_controller.go deleted file mode 100644 index 35c34f8..0000000 --- a/controller/data_controller.go +++ /dev/null @@ -1,52 +0,0 @@ -// Package controller - 对话数据控制器 -// 功能:对话记录的查询、导出 -package controller - -import ( - "context" - "customer-server/model/dto" - "customer-server/service" - - "gitea.com/red-future/common/beans" -) - -var Data = new(data) - -type data struct{} - -// Add 添加数据 -// 参数: req - 添加数据请求,包含日期和数据内容 -// 返回: res - 添加成功后的数据ID等信息 -// 功能: 创建新的数据记录 -func (c *data) Add(ctx context.Context, req *dto.AddDataReq) (res *dto.AddDataRes, err error) { - res, err = service.Data.Add(ctx, req) - return -} - -// Update 更新数据 -// 参数: req - 更新数据请求,包含数据ID和待更新字段 -// 返回: res - 空响应(成功则err为nil) -// 功能: 更新数据记录内容 -func (c *data) Update(ctx context.Context, req *dto.UpdateDataReq) (res *beans.ResponseEmpty, err error) { - err = service.Data.Update(ctx, req) - return -} - -// // Delete 删除数据 -// func (c *data) Delete(ctx context.Context, req *dto.DeleteDataReq) (res *beans.ResponseEmpty, err error) { -// err = service.Data.Delete(ctx, req) -// if err != nil { -// return -// } -// res = &beans.ResponseEmpty{} -// return -// } - -// List 获取数据列表 -// 参数: req - 列表查询请求,支持分页、日期范围筛选 -// 返回: res - 数据列表及分页信息 -// 功能: 分页查询数据记录,支持按日期范围筛选 -func (c *data) List(ctx context.Context, req *dto.ListDataReq) (res *dto.ListDataRes, err error) { - res, err = service.Data.List(ctx, req) - return -} diff --git a/controller/data_statistics_controller.go b/controller/data_statistics_controller.go deleted file mode 100644 index e824d48..0000000 --- a/controller/data_statistics_controller.go +++ /dev/null @@ -1,62 +0,0 @@ -// Package controller - 数据统计控制器 -// 功能:对话数据的统计分析、报表导出 -package controller - -import ( - "context" - "customer-server/model/dto" - "customer-server/service" - - "gitea.com/red-future/common/beans" - "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/util/gconv" -) - -var DataStatistics = new(dataStatistics) - -type dataStatistics struct{} - -// Add 添加数据统计 -// 参数: req - 添加数据统计请求,包含日期和统计数据 -// 返回: res - 添加成功后的统计ID等信息 -// 功能: 创建新的数据统计记录 -func (c *dataStatistics) Add(ctx context.Context, req *dto.AddDataStatisticsReq) (res *dto.AddDataStatisticsRes, err error) { - res, err = service.DataStatistics.Add(ctx, req) - return -} - -// Update 更新数据统计 -// 参数: req - 更新数据统计请求,包含统计ID和待更新字段 -// 返回: res - 空响应(成功则err为nil) -// 功能: 更新数据统计记录内容 -func (c *dataStatistics) Update(ctx context.Context, req *dto.UpdateDataStatisticsReq) (res *beans.ResponseEmpty, err error) { - err = service.DataStatistics.Update(ctx, req) - return -} - -// List 获取数据统计列表 -// 参数: req - 列表查询请求,支持分页、日期范围筛选 -// 返回: res - 数据统计列表及分页信息 -// 功能: 分页查询数据统计记录,支持按日期范围筛选 -func (c *dataStatistics) List(ctx context.Context, req *dto.ListDataStatisticsReq) (res *dto.ListDataStatisticsRes, err error) { - res, err = service.DataStatistics.List(ctx, req) - return -} - -// Export 导出数据统计 -// 参数: req - 导出请求,包含筛选条件 -// 返回: res - 空响应(直接写入Response流) -// 功能: 将数据统计导出为ZIP文件,包含Excel文件 -func (c *dataStatistics) Export(ctx context.Context, req *dto.ExportDataStatisticsReq) (res *beans.ResponseEmpty, err error) { - zipData, filename, err := service.DataStatistics.Export(ctx, req) - if err != nil { - return - } - - r := g.RequestFromCtx(ctx) - r.Response.Header().Set("Content-Type", "application/zip") - r.Response.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"") - r.Response.Header().Set("Content-Length", gconv.String(len(zipData))) - r.Response.Write(zipData) - return -} diff --git a/controller/health_controller.go b/controller/health_controller.go deleted file mode 100644 index d7d8e3e..0000000 --- a/controller/health_controller.go +++ /dev/null @@ -1,35 +0,0 @@ -// Package controller - 健康检查控制器 -// 功能:服务健康状态检查接口 -package controller - -import ( - "context" - - "github.com/gogf/gf/v2/frame/g" -) - -var Health = new(health) - -type health struct{} - -// Check 健康检查 -func (c *health) Check(ctx context.Context, req *HealthCheckReq) (res *HealthCheckRes, err error) { - res = &HealthCheckRes{ - Status: "ok", - Service: "customerservice", - Version: "v1.0.0", - } - return -} - -// HealthCheckReq 健康检查请求 -type HealthCheckReq struct { - g.Meta `path:"/" method:"get" tags:"Health" summary:"健康检查" dc:"检查服务是否正常运行"` -} - -// HealthCheckRes 健康检查响应 -type HealthCheckRes struct { - Status string `json:"status" dc:"状态"` - Service string `json:"service" dc:"服务名称"` - Version string `json:"version" dc:"版本"` -} diff --git a/controller/product_controller.go b/controller/product_controller.go deleted file mode 100644 index 4d9a709..0000000 --- a/controller/product_controller.go +++ /dev/null @@ -1,96 +0,0 @@ -// Package controller - 产品控制器 -// 功能:产品的增删改查、导入/导出ZIP、绑定/解绑客服账号 -package controller - -import ( - "context" - "customer-server/model/dto" - "customer-server/service" - - "gitea.com/red-future/common/beans" - "github.com/gogf/gf/v2/errors/gerror" - "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/util/gconv" -) - -var Product = new(product) - -type product struct{} - -// Add 添加产品 -// 参数: req - 添加产品请求,包含产品名称、描述、价格等信息 -// 返回: res - 添加成功后的产品ID等信息 -// 功能: 创建新产品记录并同步到RAGFlow知识库 -func (c *product) Add(ctx context.Context, req *dto.AddProductReq) (res *dto.AddProductRes, err error) { - res, err = service.Product.Add(ctx, req) - return -} - -// Update 更新产品 -// 参数: req - 更新产品请求,包含产品ID和待更新字段 -// 返回: res - 空响应(成功则err为nil) -// 功能: 更新产品信息并同步到RAGFlow -func (c *product) Update(ctx context.Context, req *dto.UpdateProductReq) (res *beans.ResponseEmpty, err error) { - err = service.Product.Update(ctx, req) - return -} - -// Delete 删除产品 -// 参数: req - 删除产品请求,包含产品ID -// 返回: res - 空响应(成功则err为nil) -// 功能: 逻辑删除产品记录并从RAGFlow移除 -func (c *product) Delete(ctx context.Context, req *dto.DeleteProductReq) (res *beans.ResponseEmpty, err error) { - err = service.Product.Delete(ctx, req) - return -} - -// List 获取产品列表 -// 参数: req - 列表查询请求,支持分页、关键词搜索 -// 返回: res - 产品列表及分页信息 -// 功能: 分页查询产品记录,支持按名称、描述搜索 -func (c *product) List(ctx context.Context, req *dto.ListProductReq) (res *dto.ListProductRes, err error) { - res, err = service.Product.List(ctx, req) - return -} - -// Export 导出产品为ZIP文件 -// 参数: req - 导出请求,包含筛选条件 -// 返回: res - 空响应(直接写入Response流) -// 功能: 将产品数据导出为ZIP文件,包含JSON数据 -func (c *product) Export(ctx context.Context, req *dto.ExportProductReq) (res *dto.ExportProductRes, err error) { - // 调用Service层导出 - zipData, filename, err := service.Product.Export(ctx, req) - if err != nil { - return nil, err - } - - // 获取原始 Response 对象以设置响应头和写入文件 - r := g.RequestFromCtx(ctx) - - // 设置响应头为文件下载 - r.Response.Header().Set("Content-Type", "application/zip") - r.Response.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"") - r.Response.Header().Set("Content-Length", gconv.String(len(zipData))) - - // 写入 ZIP 数据 - r.Response.Write(zipData) - return -} - -// Import 导入产品(从ZIP文件) -// 参数: req - 导入请求(实际从multipart/form-data获取文件) -// 返回: res - 导入结果,包含成功和失败数量 -// 功能: 从ZIP文件批量导入产品数据并同步到RAGFlow -func (c *product) Import(ctx context.Context, req *dto.ImportProductReq) (res *dto.ImportProductRes, err error) { - r := g.RequestFromCtx(ctx) - - // 获取上传的文件(虽然 req 中定义了 File,但实际需要从 Request 中获取) - file := r.GetUploadFile("file") - if file == nil { - return nil, gerror.New("请上传ZIP文件") - } - - // 调用 Service 层处理导入(传递 FileHeader) - res, err = service.Product.Import(ctx, file.FileHeader) - return -} diff --git a/controller/ragflow_config_controller.go b/controller/ragflow_config_controller.go deleted file mode 100644 index 2c8abc3..0000000 --- a/controller/ragflow_config_controller.go +++ /dev/null @@ -1,31 +0,0 @@ -// Package controller - RAGFlow配置控制器 -// 功能:管理ragflow_config表,更新对话配置提示词 -package controller - -import ( - "context" - "customer-server/model/dto" - "customer-server/service" -) - -var RAGFlowConfig = new(ragflowConfig) - -type ragflowConfig struct{} - -// Get 获取RAGFlow配置 -// 参数: req - 查询请求,包含客服账号名 -// 返回: res - RAGFlow配置信息,包含提示词、参数等 -// 功能: 查询指定客服账号的RAGFlow对话配置 -func (c *ragflowConfig) Get(ctx context.Context, req *dto.GetRAGFlowConfigReq) (res *dto.GetRAGFlowConfigRes, err error) { - res, err = service.RAGFlowConfig.Get(ctx, req) - return -} - -// UpdatePrompt 更新提示词 -// 参数: req - 更新提示词请求,包含客服账号名和新提示词内容 -// 返回: res - 更新结果信息 -// 功能: 更新RAGFlow对话的系统提示词,影响AI回复风格 -func (c *ragflowConfig) UpdatePrompt(ctx context.Context, req *dto.UpdatePromptReq) (res *dto.UpdatePromptRes, err error) { - res, err = service.RAGFlowConfig.UpdatePrompt(ctx, req) - return -} diff --git a/controller/ragflow_controller.go b/controller/ragflow_controller.go deleted file mode 100644 index 2d50930..0000000 --- a/controller/ragflow_controller.go +++ /dev/null @@ -1,64 +0,0 @@ -// Package controller - RAGFlow控制器 -// 功能:接收来自外部的RAGFlow请求(测试/调试用) -package controller - -import ( - "customer-server/consumer" - - "gitea.com/red-future/common/redis" - "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/net/ghttp" -) - -type ragflow struct{} - -var RAGFlow = new(ragflow) - -// Process 处理RAGFlow请求 -// 参数: r - HTTP请求对象 -// 功能: 接收外部RAGFlow消息请求,直接调用consumer处理逻辑(用于测试) -// 注意: 正常流程不经过此接口,而是直接消费Redis Stream -func (c *ragflow) Process(r *ghttp.Request) { - ctx := r.Context() - - // 调试:打印原始请求体 - bodyBytes := r.GetBody() - g.Log().Infof(ctx, "收到原始请求体: %s", string(bodyBytes)) - - var req redis.SendStreamMessage - if err := r.Parse(&req); err != nil { - r.Response.WriteJsonExit(g.Map{ - "code": 400, - "msg": "参数错误: " + err.Error(), - }) - return - } - - g.Log().Infof(ctx, "收到RAGFlow请求 - 用户: %s, 内容: %s", req.UserId, req.Content) - - // 直接调用consumer的处理逻辑 - message := map[string]interface{}{ - "UserId": req.UserId, - "Content": req.Content, - "MessageId": req.MessageId, - "Platform": req.Platform, - "TenantId": req.TenantId, - "AccountName": req.AccountName, - "ChatId": req.ChatId, - "ReplyQueue": req.ReplyQueue, - "History": req.History, - } - - if err := consumer.HandleMessageHTTP(ctx, message); err != nil { - r.Response.WriteJsonExit(g.Map{ - "code": 500, - "msg": "处理失败: " + err.Error(), - }) - return - } - - r.Response.WriteJsonExit(g.Map{ - "code": 0, - "msg": "success", - }) -} diff --git a/controller/scripted_speech_controller.go b/controller/scripted_speech_controller.go index 7fd287c..c1436c0 100644 --- a/controller/scripted_speech_controller.go +++ b/controller/scripted_speech_controller.go @@ -20,7 +20,7 @@ type scriptedSpeech struct{} // 返回: res - 添加成功后的预制话术ID等信息 // 功能: 创建新的预制话术记录 func (c *scriptedSpeech) Add(ctx context.Context, req *dto.AddScriptedSpeechReq) (res *dto.AddScriptedSpeechRes, err error) { - res, err = service.ScriptedSpeech.Add(ctx, req) + res, err = service.ScriptedSpeechService.Add(ctx, req) return } @@ -29,7 +29,7 @@ func (c *scriptedSpeech) Add(ctx context.Context, req *dto.AddScriptedSpeechReq) // 返回: res - 空响应(成功则err为nil) // 功能: 更新预制话术信息 func (c *scriptedSpeech) Update(ctx context.Context, req *dto.UpdateScriptedSpeechReq) (res *beans.ResponseEmpty, err error) { - err = service.ScriptedSpeech.Update(ctx, req) + err = service.ScriptedSpeechService.Update(ctx, req) return } @@ -38,7 +38,7 @@ func (c *scriptedSpeech) Update(ctx context.Context, req *dto.UpdateScriptedSpee // 返回: res - 空响应(成功则err为nil) // 功能: 逻辑删除预制话术记录 func (c *scriptedSpeech) Delete(ctx context.Context, req *dto.DeleteScriptedSpeechReq) (res *beans.ResponseEmpty, err error) { - err = service.ScriptedSpeech.Delete(ctx, req) + err = service.ScriptedSpeechService.Delete(ctx, req) return } @@ -47,7 +47,7 @@ func (c *scriptedSpeech) Delete(ctx context.Context, req *dto.DeleteScriptedSpee // 返回: res - 预制话术信息 // 功能: 根据ID获取单个预制话术详情 func (c *scriptedSpeech) Get(ctx context.Context, req *dto.GetScriptedSpeechReq) (res *dto.ScriptedSpeechVO, err error) { - res, err = service.ScriptedSpeech.Get(ctx, req) + res, err = service.ScriptedSpeechService.Get(ctx, req) return } @@ -59,6 +59,6 @@ func (c *scriptedSpeech) List(ctx context.Context, req *dto.ListScriptedSpeechRe if g.IsEmpty(req.Page) { req.Page = &beans.Page{PageNum: 1, PageSize: 20} } - res, err = service.ScriptedSpeech.List(ctx, req) + res, err = service.ScriptedSpeechService.List(ctx, req) return } diff --git a/controller/speechcraft_controller.go b/controller/speechcraft_controller.go deleted file mode 100644 index b8031e5..0000000 --- a/controller/speechcraft_controller.go +++ /dev/null @@ -1,69 +0,0 @@ -// Package controller - 话术控制器 -// 功能:话术的增删改查、绑定/解绑客服账号 -package controller - -import ( - "context" - "customer-server/model/dto" - "customer-server/service" - - "gitea.com/red-future/common/beans" -) - -var Speechcraft = new(speechcraft) - -type speechcraft struct{} - -// Add 添加话术 -// 参数: req - 添加话术请求,包含话术标题、内容等信息 -// 返回: res - 添加成功后的话术ID等信息 -// 功能: 创建新的话术记录并同步到RAGFlow知识库 -func (c *speechcraft) Add(ctx context.Context, req *dto.AddSpeechcraftReq) (res *dto.AddSpeechcraftRes, err error) { - res, err = service.Speechcraft.Add(ctx, req) - return -} - -// Update 更新话术 -// 参数: req - 更新话术请求,包含话术ID和待更新字段 -// 返回: res - 空响应(成功则err为nil) -// 功能: 更新话术内容并同步到RAGFlow -func (c *speechcraft) Update(ctx context.Context, req *dto.UpdateSpeechcraftReq) (res *beans.ResponseEmpty, err error) { - err = service.Speechcraft.Update(ctx, req) - return -} - -// Delete 删除话术 -// 参数: req - 删除话术请求,包含话术ID -// 返回: res - 空响应(成功则err为nil) -// 功能: 逻辑删除话术记录并从RAGFlow移除 -func (c *speechcraft) Delete(ctx context.Context, req *dto.DeleteSpeechcraftReq) (res *beans.ResponseEmpty, err error) { - err = service.Speechcraft.Delete(ctx, req) - return -} - -// List 获取话术列表 -// 参数: req - 列表查询请求,支持分页、关键词搜索 -// 返回: res - 话术列表及分页信息 -// 功能: 分页查询话术记录,支持按标题、内容搜索 -func (c *speechcraft) List(ctx context.Context, req *dto.ListSpeechcraftReq) (res *dto.ListSpeechcraftRes, err error) { - res, err = service.Speechcraft.List(ctx, req) - return -} - -// Bind 绑定话术到客服账号 -// 参数: req - 绑定请求,包含话术ID和客服账号列表 -// 返回: res - 绑定结果,包含成功和失败的账号信息 -// 功能: 将话术绑定到指定客服账号,更新账号的RAGFlow配置 -func (c *speechcraft) Bind(ctx context.Context, req *dto.BindSpeechcraftReq) (res *dto.BindSpeechcraftRes, err error) { - res, err = service.Speechcraft.BindToCustomerServices(ctx, req) - return -} - -// Unbind 解绑话术 -// 参数: req - 解绑请求,包含话术ID和客服账号 -// 返回: res - 解绑结果信息 -// 功能: 从客服账号解绑指定话术,更新RAGFlow配置 -func (c *speechcraft) Unbind(ctx context.Context, req *dto.UnbindSpeechcraftReq) (res *dto.UnbindSpeechcraftRes, err error) { - res, err = service.Speechcraft.UnbindFromCustomerService(ctx, req) - return -} diff --git a/controller/webhook_controller.go b/controller/webhook_controller.go deleted file mode 100644 index 15d89f2..0000000 --- a/controller/webhook_controller.go +++ /dev/null @@ -1,41 +0,0 @@ -// Package controller - Webhook控制器 -// 功能:接收平台(小红书、抖音)的webhook消息 -package controller - -import ( - "context" - "customer-server/model/dto" - "customer-server/service" - - "github.com/gogf/gf/v2/frame/g" -) - -var Webhook = new(webhook) - -type webhook struct{} - -// Receive 接收Webhook消息 -// 参数: req - Webhook消息请求,包含平台、用户ID、内容等 -// 返回: res - 处理结果 -// 功能: 接收来自各平台的Webhook消息推送,统一处理后推送到Redis Stream -func (c *webhook) Receive(ctx context.Context, req *dto.WebhookReceiveReq) (res *dto.WebhookReceiveRes, err error) { - res, err = service.Webhook.Receive(ctx, req) - return -} - -// Verify 平台回调验证 -// 参数: req - 回调请求,包含echostr等 -// 返回: res - 处理结果 -// 功能: 验证平台回调请求,返回echostr -func (c *webhook) Verify(ctx context.Context, req *dto.WebhookCallbackReq) (res *dto.WebhookCallbackRes, err error) { - r := g.RequestFromCtx(ctx) - // 直接返回 echostr(微信/抖音等平台验证方式) - r.Response.Write(req.Echostr) - return -} - -// History 查询对话记录 -func (c *webhook) History(ctx context.Context, req *dto.ConversationListReq) (res *dto.ConversationListRes, err error) { - res, err = service.Webhook.GetHistory(ctx, req) - return -} diff --git a/controller/websocket_controller.go b/controller/websocket_controller.go deleted file mode 100644 index 69af6b0..0000000 --- a/controller/websocket_controller.go +++ /dev/null @@ -1,52 +0,0 @@ -// Package controller - WebSocket控制器 -// 功能:WebSocket连接管理、实时消息推送 -package controller - -import ( - "context" - "customer-server/model/dto" - "customer-server/service" - - "github.com/gogf/gf/v2/frame/g" -) - -var Websocket = new(websocket) - -type websocket struct{} - -// Connect WebSocket连接 -// 参数: req - WebSocket连接请求,包含用户ID和平台信息 -// 返回: res - 连接结果(实际通过WebSocket协议通信) -// 功能: 升级HTTP连接为WebSocket,建立实时通信通道 -func (c *websocket) Connect(ctx context.Context, req *dto.WebSocketConnectReq) (res *dto.WebSocketConnectRes, err error) { - r := g.RequestFromCtx(ctx) - err = service.WebSocket.Connect(ctx, r, req.UserId, req.Platform) - return -} - -// Send 发送消息到Redis Stream -// 参数: req - 发送消息请求,包含用户ID、内容等 -// 返回: res - 发送结果,包含消息ID -// 功能: 将用户消息推送到Redis Stream,由RAGFlow处理器消费 -func (c *websocket) Send(ctx context.Context, req *dto.WebSocketSendReq) (res *dto.WebSocketSendRes, err error) { - res, err = service.WebSocket.Send(ctx, req) - return -} - -// Broadcast 广播消息给所有连接 -// 参数: req - 广播请求,包含广播内容 -// 返回: res - 广播结果(空响应) -// 功能: 向所有在线WebSocket连接广播消息 -func (c *websocket) Broadcast(ctx context.Context, req *dto.WebSocketBroadcastReq) (res *dto.WebSocketBroadcastRes, err error) { - service.WebSocket.Broadcast(ctx, req.Content) - return -} - -// Online 获取在线用户 -// 参数: req - 查询请求(无参数) -// 返回: res - 在线用户列表 -// 功能: 查询当前所有WebSocket在线用户信息 -func (c *websocket) Online(ctx context.Context, req *dto.WebSocketOnlineReq) (res *dto.WebSocketOnlineRes, err error) { - res = service.WebSocket.GetOnlineUsers() - return -} diff --git a/controller/xiaohongshu_controller.go b/controller/xiaohongshu_controller.go deleted file mode 100644 index feb2aa4..0000000 --- a/controller/xiaohongshu_controller.go +++ /dev/null @@ -1,111 +0,0 @@ -package controller - -import ( - "context" - "customer-server/model/dto" - "customer-server/service" - - "github.com/gogf/gf/v2/frame/g" -) - -var XiaohongshuController = new(xiaohongshuController) - -type xiaohongshuController struct{} - -// BindAccount 绑定小红书账户 -// 参数: req - 绑定账户请求,包含账户信息和授权码 -// 返回: res - 处理结果 -// 功能: 处理小红书账户绑定,保存授权码到客服账号 -func (c *xiaohongshuController) BindAccount(ctx context.Context, req *XhsBindAccountReq) (res *dto.XhsCommonRes, err error) { - if err = service.Xiaohongshu.HandleBindAccount(ctx, &req.XhsBindAccountReq); err != nil { - return - } - res = &dto.XhsCommonRes{Code: 0, Msg: "成功", Success: true} - return -} - -// UnbindAccount 解绑小红书账户 -// 参数: req - 解绑账户请求,包含账户信息和授权码 -// 返回: res - 处理结果 -// 功能: 处理小红书账户解绑,删除授权码从客服账号 -func (c *xiaohongshuController) UnbindAccount(ctx context.Context, req *XhsUnbindAccountReq) (res *dto.XhsCommonRes, err error) { - if err = service.Xiaohongshu.HandleUnbindAccount(ctx, &req.XhsUnbindAccountReq); err != nil { - return - } - res = &dto.XhsCommonRes{Code: 0, Msg: "成功", Success: true} - return -} - -// BindKosUser 绑定小红书KOS用户 -// 参数: req - KOS绑定请求,包含绑定状态和token -// 返回: res - 处理结果 -// 功能: 处理小红书KOS授权回调,保存授权token到客服账号 -func (c *xiaohongshuController) BindKosUser(ctx context.Context, req *XhsBindKosUserReq) (res *dto.XhsCommonRes, err error) { - g.Log().Infof(ctx, "[小红书] 收到KOS绑定通知: %s", req.Content) - res = &dto.XhsCommonRes{Code: 0, Msg: "成功", Success: true} - return -} - -// ReceiveMessage 接收小红书消息 -// 参数: req - 消息请求,包含消息内容和用户信息 -// 返回: res - 处理结果 -// 功能: 接收小红书消息,自动回复或转发到RAGFlow处理 -func (c *xiaohongshuController) ReceiveMessage(ctx context.Context, req *XhsReceiveMessageReq) (res *dto.XhsCommonRes, err error) { - if err = service.Xiaohongshu.HandleReceiveMessage(ctx, &req.XhsReceiveMessageReq); err != nil { - return - } - res = &dto.XhsCommonRes{Code: 0, Msg: "成功", Success: true} - return -} - -// IntentCommentPush 接收意向评论推送 -// 参数: req - 评论推送请求,包含评论ID、内容、用户信息 -// 返回: res - 处理结果 -// 功能: 接收小红书意向评论,自动回复或转发到RAGFlow处理 -func (c *xiaohongshuController) IntentCommentPush(ctx context.Context, req *XhsIntentCommentPushReq) (res *dto.XhsCommonRes, err error) { - g.Log().Infof(ctx, "[小红书] 收到意向评论推送: commentId=%s, content=%s", req.CommentId, req.CommentContent) - res = &dto.XhsCommonRes{Code: 0, Msg: "成功", Success: true} - return -} - -// PushLead 接收留资推送 -// 参数: req - 留资推送请求,包含用户留资信息 -// 返回: res - 处理结果 -// 功能: 接收小红书用户留资信息,记录到系统并触发后续流程 -func (c *xiaohongshuController) PushLead(ctx context.Context, req *XhsPushLeadReq) (res *dto.XhsCommonRes, err error) { - g.Log().Infof(ctx, "[小红书] 收到留资推送: userId=%s, pushType=%d", req.UserId, req.PushType) - res = &dto.XhsCommonRes{Code: 0, Msg: "成功", Success: true} - return -} - -// ==================== 请求结构(路由元数据) ==================== - -type XhsBindAccountReq struct { - g.Meta `path:"/api/open/im/third/bind_account" method:"post" tags:"Xiaohongshu" summary:"小红书绑定账户通知"` - dto.XhsBindAccountReq -} - -type XhsUnbindAccountReq struct { - g.Meta `path:"/api/open/im/third/unbind_account" method:"post" tags:"Xiaohongshu" summary:"小红书解绑账户通知"` - dto.XhsUnbindAccountReq -} - -type XhsBindKosUserReq struct { - g.Meta `path:"/api/open/im/auth/bind_user/event" method:"post" tags:"Xiaohongshu" summary:"小红书KOS绑定事件"` - dto.XhsBindKosUserReq -} - -type XhsReceiveMessageReq struct { - g.Meta `path:"/api/open/im/send" method:"post" tags:"Xiaohongshu" summary:"小红书接收消息"` - dto.XhsReceiveMessageReq -} - -type XhsIntentCommentPushReq struct { - g.Meta `path:"/api/open/intent/comment" method:"post" tags:"Xiaohongshu" summary:"小红书意向评论推送"` - dto.XhsIntentCommentPushReq -} - -type XhsPushLeadReq struct { - g.Meta `path:"/api/open/im/push_lead" method:"post" tags:"Xiaohongshu" summary:"小红书留资推送"` - dto.XhsPushLeadReq -} diff --git a/dao/account_dao.go b/dao/account_dao.go index 500be27..baecc5c 100644 --- a/dao/account_dao.go +++ b/dao/account_dao.go @@ -28,7 +28,7 @@ func (d *account) Insert(ctx context.Context, req *dto.AddAccountReq) (id int64, } func (d *account) Update(ctx context.Context, req *dto.UpdateAccountReq) (rows int64, err error) { - r, err := gfdb.DB(ctx).Model(ctx, public.TableNameAccount).Data(&req).Where(entity.AccountCol.Id, req.Id).Update() + r, err := gfdb.DB(ctx).Model(ctx, public.TableNameAccount).Data(&req).Where(entity.AccountCol.Id, req.Id).OmitEmpty().Update() if err != nil { return } @@ -44,7 +44,7 @@ func (d *account) Delete(ctx context.Context, req *dto.DeleteAccountReq) (rows i } func (d *account) Count(ctx context.Context, req *dto.ListAccountReq) (count int, err error) { - count, err = gfdb.DB(ctx).Model(ctx, public.TableNameAccount).OmitEmpty().Where(entity.AccountCol.AccountName, req.AccountName).Count() + count, err = gfdb.DB(ctx).Model(ctx, public.TableNameAccount).OmitEmpty().Where(entity.AccountCol.AccountCode, req.AccountCode).Count() return } @@ -64,6 +64,7 @@ func (d *account) List(ctx context.Context, req *dto.ListAccountReq, fields ...s if !g.IsEmpty(req.Keyword) { model.WhereLike(entity.AccountCol.AccountName, "%"+req.Keyword+"%") } + model.Where(entity.AccountCol.AccountCode, req.AccountCode) model.Where(entity.AccountCol.Status, req.Status) model.Where(entity.AccountCol.Platform, req.Platform) model.OrderDesc(entity.AccountCol.CreatedAt) @@ -79,9 +80,9 @@ func (d *account) List(ctx context.Context, req *dto.ListAccountReq, fields ...s return } -// GetByAccountName 根据账号名称查询客服账号(GoFrame框架原声,绕过用户信息校验) -func (d *account) GetByAccountName(ctx context.Context, req *dto.GetByAccountNameReq, fields ...string) (res *entity.Account, err error) { - r, err := gfdb.DB(ctx).Model(ctx, public.TableNameAccount).NoTenantId(ctx).Where(entity.AccountCol.AccountName, req.AccountName).Fields(fields).One() +// GetByAccountCode 根据客服账号编码查询(不带租户id) +func (d *account) GetByAccountCode(ctx context.Context, req *dto.GetByAccountCodeReq, fields ...string) (res *entity.Account, err error) { + r, err := gfdb.DB(ctx).Model(ctx, public.TableNameAccount).NoTenantId(ctx).Where(entity.AccountCol.AccountCode, req.AccountCode).Fields(fields).One() if err != nil { return } diff --git a/dao/account_user_dialog_dao.go b/dao/account_user_dialog_dao.go new file mode 100644 index 0000000..6db5413 --- /dev/null +++ b/dao/account_user_dialog_dao.go @@ -0,0 +1,60 @@ +package dao + +import ( + "context" + "customer-server/consts/public" + "customer-server/model/dto" + "customer-server/model/entity" + + "gitea.com/red-future/common/db/gfdb" + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/util/gconv" +) + +var AccountUserDialog = new(accountUserDialog) + +type accountUserDialog struct{} + +func (d *accountUserDialog) Insert(ctx context.Context, req *dto.AddAccountUserDialogReq) (id int64, err error) { + var e *entity.AccountUserDialog + if err = gconv.Struct(req, &e); err != nil { + return + } + result, err := gfdb.DB(ctx).Model(ctx, public.TableNameAccountUserDialog).Insert(e) + if err != nil { + return + } + return result.LastInsertId() +} + +func (d *accountUserDialog) Update(ctx context.Context, req *dto.UpdateAccountUserDialogReq) (rows int64, err error) { + r, err := gfdb.DB(ctx).Model(ctx, public.TableNameAccountUserDialog).Data(&req).Where(entity.AccountUserDialogCol.Id, req.Id).OmitEmpty(). + Data(entity.AccountUserDialogCol.DialogCount, &gdb.Counter{ + Field: entity.AccountUserDialogCol.DialogCount, + Value: gconv.Float64(req.DialogCount), + }).Update() + if err != nil { + return + } + return r.RowsAffected() +} + +func (d *accountUserDialog) Delete(ctx context.Context, req *dto.DeleteAccountUserDialogReq) (rows int64, err error) { + r, err := gfdb.DB(ctx).Model(ctx, public.TableNameAccountUserDialog).Where(entity.AccountUserDialogCol.Id, req.Id).Delete() + if err != nil { + return + } + return r.RowsAffected() +} + +func (d *accountUserDialog) Get(ctx context.Context, req *dto.GetAccountUserDialogReq) (res *entity.AccountUserDialog, err error) { + r, err := gfdb.DB(ctx).Model(ctx, public.TableNameAccountUserDialog).OmitEmpty(). + Where(entity.AccountUserDialogCol.UserId, req.UserId). + Where(entity.AccountUserDialogCol.AccountId, req.AccountId). + One() + if err != nil { + return + } + err = gconv.Struct(r, &res) + return +} diff --git a/dao/archive_dao.go b/dao/archive_dao.go deleted file mode 100644 index b413468..0000000 --- a/dao/archive_dao.go +++ /dev/null @@ -1,158 +0,0 @@ -package dao - -import ( - "context" - "customer-server/model/entity" - "time" - - "gitea.com/red-future/common/db/mongo" - - "github.com/gogf/gf/v2/frame/g" - "go.mongodb.org/mongo-driver/v2/bson" -) - -// archive 归档 DAO -type archive struct{} - -// Archive 归档 DAO 单例 -var Archive = new(archive) - -// CopyToTempByRange 将指定时间范围的数据复制到临时表 -// startTime: 开始时间(包含),endTime: 结束时间(不包含) -func (d *archive) CopyToTempByRange(ctx context.Context, startTime, endTime time.Time) (count int64, err error) { - db := mongo.GetDB() - - // 查询指定时间范围的数据 - filter := bson.M{ - "createdAt": bson.M{ - "$gte": startTime, - "$lt": endTime, - }, - "isDeleted": false, - } - - cursor, err := db.Collection(entity.ConversationCollection).Find(ctx, filter) - if err != nil { - return - } - defer cursor.Close(ctx) - - // 批量插入临时表 - batchSize := g.Cfg().MustGet(ctx, "archive.mongoBatchSize", 1000).Int() - var docs []interface{} - for cursor.Next(ctx) { - var conv entity.Conversation - if err = cursor.Decode(&conv); err != nil { - return - } - - // 转换为临时表结构 - temp := entity.ConversationArchiveTemp{ - MongoBaseDO: conv.MongoBaseDO, - UserId: conv.UserId, - Platform: conv.Platform, - SessionId: conv.SessionId, - Question: conv.Question, - Answer: conv.Answer, - MessageId: conv.MessageId, - MsgTime: conv.MsgTime, - OriginalId: conv.Id.Hex(), // 保存原始 ID - } - // 清空 ID,让 MongoDB 自动生成新 ID - temp.Id = nil - docs = append(docs, temp) - - // 批量插入 - if len(docs) >= batchSize { - if _, err = db.Collection(entity.ConversationArchiveTempCollection).InsertMany(ctx, docs); err != nil { - return - } - count += int64(len(docs)) - docs = docs[:0] - } - } - - // 插入剩余数据 - if len(docs) > 0 { - if _, err = db.Collection(entity.ConversationArchiveTempCollection).InsertMany(ctx, docs); err != nil { - return - } - count += int64(len(docs)) - } - - return -} - -// DeleteByTempIds 根据临时表中的 originalId 删除原表数据 -func (d *archive) DeleteByTempIds(ctx context.Context) (count int64, err error) { - db := mongo.GetDB() - - // 从临时表获取所有 originalId - cursor, err := db.Collection(entity.ConversationArchiveTempCollection).Find(ctx, bson.M{}) - if err != nil { - return - } - defer cursor.Close(ctx) - - var ids []bson.ObjectID - for cursor.Next(ctx) { - var temp entity.ConversationArchiveTemp - if err = cursor.Decode(&temp); err != nil { - return - } - if oid, parseErr := bson.ObjectIDFromHex(temp.OriginalId); parseErr == nil { - ids = append(ids, oid) - } - - // 每 1000 条批量删除一次 - if len(ids) >= 1000 { - result, delErr := db.Collection(entity.ConversationCollection).DeleteMany(ctx, bson.M{ - "_id": bson.M{"$in": ids}, - }) - if delErr != nil { - err = delErr - return - } - count += result.DeletedCount - ids = ids[:0] - } - } - - // 删除剩余数据 - if len(ids) > 0 { - result, delErr := db.Collection(entity.ConversationCollection).DeleteMany(ctx, bson.M{ - "_id": bson.M{"$in": ids}, - }) - if delErr != nil { - err = delErr - return - } - count += result.DeletedCount - } - - return -} - -// GetTempData 获取临时表数据(用于写入 ES) -func (d *archive) GetTempData(ctx context.Context) (data []*entity.ConversationArchiveTemp, err error) { - db := mongo.GetDB() - - cursor, err := db.Collection(entity.ConversationArchiveTempCollection).Find(ctx, bson.M{}) - if err != nil { - return - } - defer cursor.Close(ctx) - - err = cursor.All(ctx, &data) - return -} - -// DropTempCollection 删除临时表 -func (d *archive) DropTempCollection(ctx context.Context) (err error) { - return mongo.GetDB().Collection(entity.ConversationArchiveTempCollection).Drop(ctx) -} - -// CountTemp 统计临时表记录数 -func (d *archive) CountTemp(ctx context.Context) (count int64, err error) { - return mongo.GetDB().Collection(entity.ConversationArchiveTempCollection).CountDocuments(ctx, bson.M{}) -} diff --git a/dao/conversation_dao.go b/dao/conversation_dao.go deleted file mode 100644 index b5e20b7..0000000 --- a/dao/conversation_dao.go +++ /dev/null @@ -1,140 +0,0 @@ -package dao - -import ( - "context" - "customer-server/model/entity" - - "gitea.com/red-future/common/db/mongo" - - "gitea.com/red-future/common/redis" - "github.com/gogf/gf/v2/os/gtime" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo/options" -) - -var Conversation = new(conversation) - -type conversation struct{} - -// BatchInsert 批量插入对话记录 -func (d *conversation) BatchInsert(ctx context.Context, list []*entity.Conversation) (err error) { - if len(list) == 0 { - return - } - now := gtime.Now().Time - docs := make([]interface{}, 0, len(list)) - for _, data := range list { - docs = append(docs, bson.M{ - "userId": data.UserId, - "platform": data.Platform, - "sessionId": data.SessionId, - "question": data.Question, - "answer": data.Answer, - "messageId": data.MessageId, - "msgTime": data.MsgTime, - "tenantId": data.TenantId, - "creator": "system", - "createdAt": now, - "updater": "system", - "updatedAt": now, - "isDeleted": false, - }) - } - _, err = mongo.GetDB().Collection(entity.ConversationCollection).InsertMany(ctx, docs) - return -} - -// UpsertByMessageId 幂等插入对话记录(使用 message_id 做唯一键,防止重复消费) -func (d *conversation) UpsertByMessageId(ctx context.Context, data *entity.Conversation) (inserted bool, err error) { - filter := bson.M{"messageId": data.MessageId} - now := gtime.Now().Time - - update := bson.M{ - "$setOnInsert": bson.M{ - "userId": data.UserId, - "platform": data.Platform, - "sessionId": data.SessionId, - "question": data.Question, - "answer": data.Answer, - "messageId": data.MessageId, - "msgTime": data.MsgTime, - "creator": "system", - "createdAt": now, - "tenantId": data.TenantId, // 使用传入的租户ID - "isDeleted": false, - }, - "$set": bson.M{ - "updater": "system", - "updatedAt": now, - }, - } - - opts := options.UpdateOne().SetUpsert(true) - result, err := mongo.GetDB().Collection(entity.ConversationCollection).UpdateOne(ctx, filter, update, opts) - if err != nil { - return - } - - // UpsertedCount > 0 表示是新插入,否则是已存在(幂等跳过) - inserted = result.UpsertedCount > 0 - return -} - -// FindByUserId 根据用户ID查询对话记录 -func (d *conversation) FindByUserId(ctx context.Context, userId string, limit int64) (list []*entity.Conversation, err error) { - filter := bson.M{"userId": userId, "isDeleted": false} - opts := options.Find().SetSort(bson.D{{Key: "msgTime", Value: -1}}).SetLimit(limit) - - cursor, err := mongo.GetDB().Collection(entity.ConversationCollection).Find(ctx, filter, opts) - if err != nil { - return - } - defer cursor.Close(ctx) - - err = cursor.All(ctx, &list) - return -} - -// FindBySessionId 根据 Session ID 查询对话记录 -func (d *conversation) FindBySessionId(ctx context.Context, sessionId string) (list []*entity.Conversation, err error) { - filter := bson.M{"sessionId": sessionId, "isDeleted": false} - opts := options.Find().SetSort(bson.D{{Key: "msgTime", Value: 1}}) - - cursor, err := mongo.GetDB().Collection(entity.ConversationCollection).Find(ctx, filter, opts) - if err != nil { - return - } - defer cursor.Close(ctx) - - err = cursor.All(ctx, &list) - return -} - -// GetRecentHistory 获取用户最近 N 轮历史对话(用于上下文注入) -// 返回 redis.HistoryMessage 切片,按时间正序排列 -func (d *conversation) GetRecentHistory(ctx context.Context, userId string, limit int64) (history []redis.HistoryMessage, err error) { - filter := bson.M{"userId": userId, "isDeleted": false} - // 先按时间倒序取最近 N 条,再反转为正序 - opts := options.Find().SetSort(bson.D{{Key: "msgTime", Value: -1}}).SetLimit(limit) - - cursor, err := mongo.GetDB().Collection(entity.ConversationCollection).Find(ctx, filter, opts) - if err != nil { - return - } - defer cursor.Close(ctx) - - var list []*entity.Conversation - if err = cursor.All(ctx, &list); err != nil { - return - } - - // 反转为时间正序 - history = make([]redis.HistoryMessage, len(list)) - for i, conv := range list { - history[len(list)-1-i] = redis.HistoryMessage{ - Question: conv.Question, - Answer: conv.Answer, - } - } - return -} diff --git a/dao/customer_service_account_dao.go b/dao/customer_service_account_dao.go deleted file mode 100644 index 6593be3..0000000 --- a/dao/customer_service_account_dao.go +++ /dev/null @@ -1,197 +0,0 @@ -package dao - -import ( - "context" - "customer-server/model/dto" - "customer-server/model/entity" - "fmt" - - "gitea.com/red-future/common/beans" - "gitea.com/red-future/common/db/mongo" - "gitea.com/red-future/common/redis" - "github.com/gogf/gf/v2/errors/gerror" - "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/os/gtime" - "go.mongodb.org/mongo-driver/v2/bson" -) - -var CustomerServiceAccount = new(customerServiceAccount) - -type customerServiceAccount struct{} - -// FindByAccountName 根据accountName查询 -// 使用 MongoDAO(不需要token验证) -func (d *customerServiceAccount) FindByAccountName(ctx context.Context, accountName string) (account *entity.CustomerServiceAccount, err error) { - filter := bson.M{"accountName": accountName, "isDeleted": false} - - var result entity.CustomerServiceAccount - err = MongoDAO.FindOne(ctx, filter, &result, entity.CustomerServiceAccountCollection) - if err != nil { - return nil, err - } - - // 如果未找到记录,result 是零值 - if result.Id.IsZero() { - return nil, nil - } - - account = &result - return -} - -// Insert 插入客服账号 -func (d *customerServiceAccount) Insert(ctx context.Context, data *entity.CustomerServiceAccount) (err error) { - // 统一使用commonmongo.DB().Insert,自动清除缓存 - // service层已经设置了TenantId,commonmongo.DB().Insert不会覆盖已有值 - ids, err := mongo.DB().Insert(ctx, []interface{}{data}, entity.CustomerServiceAccountCollection) - if err != nil { - return - } - if len(ids) > 0 { - if oid, ok := ids[0].(bson.ObjectID); ok { - data.Id = &oid // 取地址赋值给指针类型 - } - } - return -} - -// Update 更新客服账号 -func (d *customerServiceAccount) Update(ctx context.Context, req *dto.UpdateCustomerServiceAccountReq) (err error) { - objectId, err := bson.ObjectIDFromHex(req.Id) - if err != nil { - return - } - filter := bson.M{"_id": objectId} - - // 如果accountName变更,需要清理旧的缓存 - if !g.IsEmpty(req.AccountName) { - // 先查出旧的accountName - var oldAccount entity.CustomerServiceAccount - if findErr := MongoDAO.FindOne(ctx, filter, &oldAccount, entity.CustomerServiceAccountCollection); findErr == nil { - // 清理旧accountName的缓存 - oldCacheKey := fmt.Sprintf("tenant:account:%s", oldAccount.AccountName) - redis.RedisClient().Del(ctx, oldCacheKey) - } - } - - updateFields := bson.M{} - if !g.IsEmpty(req.AccountName) { - updateFields["accountName"] = req.AccountName - } - if !g.IsEmpty(req.Platform) { - updateFields["platform"] = req.Platform - } - if req.SelfIdentity != nil { - updateFields["selfIdentity"] = *req.SelfIdentity - } - - // 如果有字段需要更新,则执行 MongoDB 更新操作 - if len(updateFields) > 0 { - _, err = mongo.DB().Update(ctx, filter, bson.M{"$set": updateFields}, entity.CustomerServiceAccountCollection) - } - return -} - -// Delete 软删除客服账号 -func (d *customerServiceAccount) Delete(ctx context.Context, id string) (err error) { - objectId, err := bson.ObjectIDFromHex(id) - if err != nil { - return - } - - filter := bson.M{"_id": objectId, "isDeleted": false} - - // 删除前先查出accountName,清理缓存 - var account entity.CustomerServiceAccount - if findErr := MongoDAO.FindOne(ctx, filter, &account, entity.CustomerServiceAccountCollection); findErr == nil { - cacheKey := fmt.Sprintf("tenant:account:%s", account.AccountName) - redis.RedisClient().Del(ctx, cacheKey) - } - - update := bson.M{"$set": bson.M{"isDeleted": true, "updatedAt": gtime.Now().Time}} - _, err = mongo.DB().Update(ctx, filter, update, entity.CustomerServiceAccountCollection) - if err != nil { - return gerror.Wrap(err, "删除客服账号失败") - } - - return -} - -// ToggleStatus 切换客服账号状态(1 启用,0 禁用) -func (d *customerServiceAccount) ToggleStatus(ctx context.Context, req *dto.ToggleCustomerServiceAccountStatusReq) (err error) { - objectId, err := bson.ObjectIDFromHex(req.Id) - if err != nil { - return - } - filter := bson.M{"_id": objectId} - - // 先查出当前状态 - var account entity.CustomerServiceAccount - if err = MongoDAO.FindOne(ctx, filter, &account, entity.CustomerServiceAccountCollection); err != nil { - return - } - - // 计算新的状态:true->false,false->true(切换禁用状态) - newIsDisabled := true - if !account.Id.IsZero() && account.IsDisabled { - newIsDisabled = false - } - - _, err = mongo.DB().Update(ctx, filter, bson.M{"$set": bson.M{"isDisabled": newIsDisabled}}, entity.CustomerServiceAccountCollection) - return -} - -// buildListFilter 构建列表查询的过滤条件 -func (d *customerServiceAccount) buildListFilter(req *dto.ListCustomerServiceAccountReq) bson.M { - filter := bson.M{} - if !g.IsEmpty(req.AccountName) { - filter["accountName"] = bson.M{"$regex": req.AccountName, "$options": "i"} // $regex模糊查询,忽略大小写 - } - if req.IsDisabled != nil { - filter["isDisabled"] = *req.IsDisabled - } - if !g.IsEmpty(req.Platform) { - filter["platform"] = req.Platform - } - return filter -} - -// checkTotalCount 检查总数 -func (d *customerServiceAccount) checkTotalCount(ctx context.Context, filter bson.M) (total int64, err error) { - total, err = mongo.DB().Count(ctx, filter, entity.CustomerServiceAccountCollection) - return -} - -// List 获取客服账号列表(包含所有账号,含已禁用账号) -func (d *customerServiceAccount) List(ctx context.Context, req *dto.ListCustomerServiceAccountReq) (list []*entity.CustomerServiceAccount, total int64, err error) { - // 构建查询过滤条件 - filter := d.buildListFilter(req) - - // 检查总数 - total, err = d.checkTotalCount(ctx, filter) - if err != nil { - return - } - - // 分页参数处理 - pageNum := req.PageNum - if pageNum <= 0 { - pageNum = 1 - } - pageSize := req.PageSize - if pageSize <= 0 { - pageSize = 20 - } - - // 使用统一的mongo.DB().Find方法(支持分页和排序) - page := &beans.Page{ - PageNum: int64(pageNum), - PageSize: int64(pageSize), - } - orderBy := []beans.OrderBy{ - {Field: "createdAt", Order: beans.Desc}, // 按创建时间倒序 - } - - _, err = mongo.DB().Find(ctx, filter, &list, entity.CustomerServiceAccountCollection, page, orderBy) - return -} diff --git a/dao/data_dao.go b/dao/data_dao.go deleted file mode 100644 index 87feb67..0000000 --- a/dao/data_dao.go +++ /dev/null @@ -1,167 +0,0 @@ -package dao - -import ( - "context" - "customer-server/model/dto" - "customer-server/model/entity" - - "gitea.com/red-future/common/beans" - "gitea.com/red-future/common/db/mongo" - "github.com/gogf/gf/v2/errors/gerror" - "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/os/gtime" - "go.mongodb.org/mongo-driver/v2/bson" -) - -var Data = new(data) - -type data struct{} - -// Insert 插入数据 -func (d *data) Insert(ctx context.Context, data *entity.Data) (err error) { - _, err = mongo.DB().Insert(ctx, []interface{}{data}, entity.DataCollection) - return -} - -// Update 更新数据 -func (d *data) Update(ctx context.Context, req *dto.UpdateDataReq) (err error) { - objectId, err := bson.ObjectIDFromHex(req.Id) - if err != nil { - return - } - filter := bson.M{"_id": objectId} - - // 构建动态更新字段 - updateFields := bson.M{} - if !g.IsEmpty(req.CustomerId) { - updateFields["customerId"] = req.CustomerId - } - if !g.IsEmpty(req.AccountName) { - updateFields["accountName"] = req.AccountName - } - if req.IsInbound != nil { - updateFields["isInbound"] = *req.IsInbound - } - if req.IsActive != nil { - updateFields["isActive"] = *req.IsActive - } - if req.IsServed != nil { - updateFields["isServed"] = *req.IsServed - } - if req.HasSentContactCard != nil { - updateFields["hasSentContactCard"] = *req.HasSentContactCard - } - if req.HasSentNameCard != nil { - updateFields["hasSentNameCard"] = *req.HasSentNameCard - } - if req.HasLeftContactInfo != nil { - updateFields["hasLeftContactInfo"] = *req.HasLeftContactInfo - } - - if len(updateFields) > 0 { - _, err = mongo.DB().Update(ctx, filter, bson.M{"$set": updateFields}, entity.DataCollection) - } - return -} - -// // Delete 删除数据 -// func (d *data) Delete(ctx context.Context, req *dto.DeleteDataReq) (err error) { -// objectId, err := bson.ObjectIDFromHex(req.Id) -// if err != nil { -// return -// } -// filter := bson.M{"_id": objectId} -// _, err = mongo.DB().Delete(ctx, filter, d.collection) -// return -// } - -// buildListFilter 构建列表查询的过滤条件 -func (d *data) buildListFilter(req *dto.ListDataReq) bson.M { - filter := bson.M{} - if !g.IsEmpty(req.CustomerId) { - filter["customerId"] = req.CustomerId - } - if !g.IsEmpty(req.AccountName) { - filter["accountName"] = *req.AccountName - } - - // 处理时间范围筛选 - if !g.IsEmpty(req.StartDate) || !g.IsEmpty(req.EndDate) { - timeFilter := bson.M{} - - // 开始日期:大于等于当天 00:00:00(时间戳秒) - if !g.IsEmpty(req.StartDate) { - // 将日期字符串转换为时间戳(秒) - startTime, err := parseDate(req.StartDate) - if err == nil { - timeFilter["$gte"] = startTime - } - } - - // 结束日期:小于等于当天 23:59:59(时间戳秒) - if !g.IsEmpty(req.EndDate) { - // 将日期字符串转换为时间戳(秒)+ 86399(一天的最后一秒) - endTime, err := parseDate(req.EndDate) - if err == nil { - timeFilter["$lte"] = endTime + 86399 // 加上一天的秒数 - 1 - } - } - - if len(timeFilter) > 0 { - filter["sessionStartTime"] = timeFilter - } - } - - return filter -} - -// checkTotalCount 检查总数 -func (d *data) checkTotalCount(ctx context.Context, filter bson.M) (total int64, err error) { - total, err = mongo.DB().Count(ctx, filter, entity.DataCollection) - return -} - -// List 获取数据列表 -func (d *data) List(ctx context.Context, req *dto.ListDataReq) (list []*entity.Data, total int64, err error) { - // 构建查询过滤条件 - filter := d.buildListFilter(req) - - // 检查总数 - total, err = d.checkTotalCount(ctx, filter) - if err != nil { - return - } - - // 分页参数处理 - pageNum := req.PageNum - if pageNum <= 0 { - pageNum = 1 - } - pageSize := req.PageSize - if pageSize <= 0 { - pageSize = 20 - } - - // 使用统一的mongo.DB().Find方法(支持分页和排序) - page := &beans.Page{ - PageNum: int64(pageNum), - PageSize: int64(pageSize), - } - orderBy := []beans.OrderBy{ - {Field: "sessionStartTime", Order: beans.Desc}, // 按会话开始时间倒序 - } - - _, err = mongo.DB().Find(ctx, filter, &list, entity.DataCollection, page, orderBy) - return -} - -// parseDate 将日期字符串(YYYY-MM-DD)转换为时间戳(秒) -func parseDate(dateStr string) (int64, error) { - // 使用 gtime 解析日期字符串 - t := gtime.NewFromStr(dateStr) - if t == nil { - return 0, gerror.New("日期格式错误") - } - // 返回时间戳(秒) - return t.Timestamp(), nil -} diff --git a/dao/data_statistics_dao.go b/dao/data_statistics_dao.go deleted file mode 100644 index 8b897dd..0000000 --- a/dao/data_statistics_dao.go +++ /dev/null @@ -1,199 +0,0 @@ -package dao - -import ( - "context" - "customer-server/model/dto" - "customer-server/model/entity" - - "gitea.com/red-future/common/beans" - "gitea.com/red-future/common/db/mongo" - "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/os/gtime" - "go.mongodb.org/mongo-driver/v2/bson" -) - -// dataStatistics DAO 单例 -var DataStatistics = new(dataStatistics) - -type dataStatistics struct{} - -// Insert 插入数据统计 -func (d *dataStatistics) Insert(ctx context.Context, data *entity.DataStatistics) (err error) { - // 如果 ID 为空,生成一个新的 ObjectID - if data.Id == nil || data.Id.IsZero() { - newId := bson.NewObjectID() - data.Id = &newId // 取地址赋值给指针类型 - } - - // 使用 common/db/mongo.DB().Insert,自动添加 tenantId、creator、updater 等字段 - // 确保查询时能通过 tenantId 正确过滤数据 - _, err = mongo.DB().Insert(ctx, []interface{}{data}, entity.DataStatisticsCollection) - return -} - -// Update 更新数据统计 -func (d *dataStatistics) Update(ctx context.Context, req *dto.UpdateDataStatisticsReq) (err error) { - objectId, err := bson.ObjectIDFromHex(req.Id) - if err != nil { - return - } - filter := bson.M{"_id": objectId} - - updateFields := bson.M{} - // 使用 gconv 和 gtime 转换日期 - if !g.IsEmpty(req.Date) { - if dateTime := gtime.NewFromStr(req.Date); dateTime != nil { - updateFields["date"] = dateTime.Time - } - } - if !g.IsEmpty(req.AccountName) { - updateFields["accountName"] = req.AccountName - } - if !g.IsEmpty(req.CustomerServiceName) { - updateFields["customerServiceName"] = req.CustomerServiceName - } - if !g.IsEmpty(req.CustomerServicePlatform) { - updateFields["customerServicePlatform"] = req.CustomerServicePlatform - } - if req.InboundCount != nil { - updateFields["inboundCount"] = *req.InboundCount - } - if req.ActiveCount != nil { - updateFields["activeCount"] = *req.ActiveCount - } - if req.ServedCount != nil { - updateFields["servedCount"] = *req.ServedCount - } - if req.ContactCardSentCount != nil { - updateFields["contactCardSentCount"] = *req.ContactCardSentCount - } - if req.NameCardSentCount != nil { - updateFields["nameCardSentCount"] = *req.NameCardSentCount - } - if req.LeftContactInfoCount != nil { - updateFields["leftContactInfoCount"] = *req.LeftContactInfoCount - } - if req.ResponseRate30s != nil { - updateFields["responseRate30s"] = *req.ResponseRate30s - } - if req.ResponseRate60s != nil { - updateFields["responseRate60s"] = *req.ResponseRate60s - } - if req.ResponseRate360s != nil { - updateFields["responseRate360s"] = *req.ResponseRate360s - } - - if len(updateFields) > 0 { - _, err = mongo.DB().Update(ctx, filter, bson.M{"$set": updateFields}, entity.DataStatisticsCollection) - } - return -} - -// buildListFilter 构建列表查询的过滤条件 -func (d *dataStatistics) buildListFilter(req *dto.ListDataStatisticsReq) bson.M { - filter := bson.M{} - - // 客服平台筛选 - if !g.IsEmpty(req.CustomerServicePlatform) { - filter["customerServicePlatform"] = req.CustomerServicePlatform - } - - // 日期范围筛选:支持单独传 StartDate 或 EndDate,或同时传两者 - // 前端传入字符串格式(YYYY-MM-DD),需要转换为 time.Time 进行比较 - if !g.IsEmpty(req.StartDate) || !g.IsEmpty(req.EndDate) { - dateFilter := bson.M{} - if !g.IsEmpty(req.StartDate) { - // 使用 gtime 转换,设置为当天 00:00:00 - if startTime := gtime.NewFromStr(req.StartDate); startTime != nil { - dateFilter["$gte"] = startTime.Time - } - } - if !g.IsEmpty(req.EndDate) { - // 使用 gtime 转换,设置为当天 23:59:59 - if endTime := gtime.NewFromStr(req.EndDate + " 23:59:59"); endTime != nil { - dateFilter["$lte"] = endTime.Time - } - } - filter["date"] = dateFilter - } - - return filter -} - -// checkTotalCount 检查总数 -func (d *dataStatistics) checkTotalCount(ctx context.Context, filter bson.M) (total int64, err error) { - total, err = mongo.DB().Count(ctx, filter, entity.DataStatisticsCollection) - return -} - -// List 获取数据统计列表 -func (d *dataStatistics) List(ctx context.Context, req *dto.ListDataStatisticsReq) (list []*entity.DataStatistics, total int64, err error) { - // 构建查询过滤条件 - filter := d.buildListFilter(req) - - // 检查总数 - total, err = d.checkTotalCount(ctx, filter) - if err != nil { - return - } - - pageNum := req.PageNum - if pageNum <= 0 { - pageNum = 1 - } - pageSize := req.PageSize - if pageSize <= 0 { - pageSize = 20 - } - - // 使用统一的mongo.DB().Find方法(支持分页和排序) - page := &beans.Page{ - PageNum: int64(pageNum), - PageSize: int64(pageSize), - } - orderBy := []beans.OrderBy{ - {Field: "date", Order: beans.Desc}, // 按日期倒序 - } - - _, err = mongo.DB().Find(ctx, filter, &list, entity.DataStatisticsCollection, page, orderBy) - return -} - -// FindAllForExport 查询所有符合条件的数据统计(用于导出,不分页,需要租户过滤) -func (d *dataStatistics) FindAllForExport(ctx context.Context, req *dto.ExportDataStatisticsReq) (list []*entity.DataStatistics, err error) { - // 构建查询过滤条件(复用 buildListFilter 逻辑) - filter := bson.M{} - - // 客服平台筛选 - if !g.IsEmpty(req.CustomerServicePlatform) { - filter["customerServicePlatform"] = req.CustomerServicePlatform - } - - // 日期范围筛选 - if !g.IsEmpty(req.StartDate) || !g.IsEmpty(req.EndDate) { - dateFilter := bson.M{} - if !g.IsEmpty(req.StartDate) { - if startTime := gtime.NewFromStr(req.StartDate); startTime != nil { - dateFilter["$gte"] = startTime.Time - } - } - if !g.IsEmpty(req.EndDate) { - if endTime := gtime.NewFromStr(req.EndDate + " 23:59:59"); endTime != nil { - dateFilter["$lte"] = endTime.Time - } - } - filter["date"] = dateFilter - } - - // 使用 mongo.DB().Find 查询,自动添加 tenantId 过滤,确保租户数据隔离 - // 不分页,设置一个足够大的PageSize - page := &beans.Page{ - PageNum: 1, - PageSize: 100000, // 导出场景:设置足够大的PageSize - } - orderBy := []beans.OrderBy{ - {Field: "date", Order: beans.Desc}, // 按日期倒序 - } - _, err = mongo.DB().Find(ctx, filter, &list, entity.DataStatisticsCollection, page, orderBy) - return list, err -} diff --git a/dao/mongo_dao.go b/dao/mongo_dao.go deleted file mode 100644 index 3358859..0000000 --- a/dao/mongo_dao.go +++ /dev/null @@ -1,75 +0,0 @@ -package dao - -import ( - "context" - - commonMongo "gitea.com/red-future/common/db/mongo" - "go.mongodb.org/mongo-driver/v2/bson" -) - -// MongoDAO MongoDB原生查询(不需要token验证) -var MongoDAO = new(mongoDAO) - -type mongoDAO struct{} - -// FindOne 原生查询单条记录(不需要token验证) -// 未找到记录时返回 nil error,调用方需检查 result 是否为零值 -func (m *mongoDAO) FindOne(ctx context.Context, filter bson.M, result interface{}, collectionName string) error { - db := commonMongo.GetDB() - collection := db.Collection(collectionName) - - err := collection.FindOne(ctx, filter).Decode(result) - if err != nil { - if err.Error() == "mongo: no documents in result" { - return nil // 未找到记录,返回nil而不是错误 - } - return err - } - return nil -} - -// InsertOne 原生插入单条记录(不需要token验证) -func (m *mongoDAO) InsertOne(ctx context.Context, document interface{}, collectionName string) (interface{}, error) { - db := commonMongo.GetDB() - collection := db.Collection(collectionName) - - result, err := collection.InsertOne(ctx, document) - if err != nil { - return nil, err - } - return result.InsertedID, nil -} - -// UpdateOne 原生更新单条记录(不需要token验证) -// 返回 matchedCount(匹配到的记录数)和 modifiedCount(实际修改的记录数) -func (m *mongoDAO) UpdateOne(ctx context.Context, filter bson.M, update bson.M, collectionName string) (matchedCount int64, modifiedCount int64, err error) { - db := commonMongo.GetDB() - collection := db.Collection(collectionName) - - result, err := collection.UpdateOne(ctx, filter, update) - if err != nil { - return 0, 0, err - } - return result.MatchedCount, result.ModifiedCount, nil -} - -// UpdateMany 原生批量更新记录(不需要token验证) -func (m *mongoDAO) UpdateMany(ctx context.Context, filter bson.M, update bson.M, collectionName string) (matchedCount int64, modifiedCount int64, err error) { - db := commonMongo.GetDB() - collection := db.Collection(collectionName) - - result, err := collection.UpdateMany(ctx, filter, update) - if err != nil { - return 0, 0, err - } - return result.MatchedCount, result.ModifiedCount, nil -} - -// Delete 原生删除记录(不需要token验证,用于回滚操作) -func (m *mongoDAO) Delete(ctx context.Context, filter bson.M, collectionName string) error { - db := commonMongo.GetDB() - collection := db.Collection(collectionName) - - _, err := collection.DeleteOne(ctx, filter) - return err -} diff --git a/dao/product_dao.go b/dao/product_dao.go deleted file mode 100644 index 26a4831..0000000 --- a/dao/product_dao.go +++ /dev/null @@ -1,185 +0,0 @@ -package dao - -import ( - "context" - "customer-server/model/dto" - "customer-server/model/entity" - - "gitea.com/red-future/common/beans" - "gitea.com/red-future/common/db/mongo" - "github.com/gogf/gf/v2/errors/gerror" - "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/os/gtime" - "go.mongodb.org/mongo-driver/v2/bson" -) - -var Product = new(product) - -type product struct{} - -// Insert 插入产品 -// 注意:mongo.DB().Insert不会自动将生成的ID回写到原始对象 -// 必须手动从返回的InsertedIDs中提取并赋值给data.Id,否则后续访问data.Id会触发空指针异常 -func (d *product) Insert(ctx context.Context, data *entity.Product) (id bson.ObjectID, err error) { - ids, err := mongo.DB().Insert(ctx, []interface{}{data}, entity.ProductCollection) - if err != nil { - return - } - // 从返回的ID列表中提取ObjectID并回写到data.Id - if len(ids) > 0 { - if oid, ok := ids[0].(bson.ObjectID); ok { - id = oid - data.Id = &oid // 回写ID到原始对象,防止后续访问时空指针异常 - } - } - return -} - -// Update 更新产品 -func (d *product) Update(ctx context.Context, req *dto.UpdateProductReq) (err error) { - objectId, err := bson.ObjectIDFromHex(req.Id) - if err != nil { - return - } - filter := bson.M{"_id": objectId, "isDeleted": false} - - updateFields := bson.M{} - if !g.IsEmpty(req.Name) { - updateFields["name"] = req.Name - } - if !g.IsEmpty(req.Description) { - updateFields["description"] = req.Description - } - // 自动更新时间为当前时间 - updateFields["updatedAt"] = gtime.Now().Time - - if len(updateFields) > 0 { - _, err = mongo.DB().Update(ctx, filter, bson.M{"$set": updateFields}, entity.ProductCollection) - } - return -} - -// Delete 软删除产品(设置 IsDeleted=true) -func (d *product) Delete(ctx context.Context, req *dto.DeleteProductReq) (err error) { - objectId, err := bson.ObjectIDFromHex(req.Id) - if err != nil { - return - } - filter := bson.M{"_id": objectId, "isDeleted": false} - update := bson.M{"$set": bson.M{"isDeleted": true, "updatedAt": gtime.Now().Time}} - _, err = mongo.DB().Update(ctx, filter, update, entity.ProductCollection) - if err != nil { - return gerror.Wrap(err, "删除产品失败") - } - - return -} - -// buildListFilter 构建列表查询的过滤条件 -func (d *product) buildListFilter(req *dto.ListProductReq) bson.M { - filter := bson.M{"isDeleted": false} - if !g.IsEmpty(req.Name) { - filter["name"] = bson.M{"$regex": req.Name} - } - return filter -} - -// checkTotalCount 检查总数 -func (d *product) checkTotalCount(ctx context.Context, filter bson.M) (total int64, err error) { - total, err = mongo.DB().Count(ctx, filter, entity.ProductCollection) - return -} - -// FindByName 根据名称查询产品(用于去重检查) -func (d *product) FindByName(ctx context.Context, name string) (product *entity.Product, err error) { - filter := bson.M{ - "name": name, - "isDeleted": false, - } - err = mongo.DB().FindOne(ctx, filter, &product, entity.ProductCollection) - if err != nil { - if err.Error() == "mongo: no documents in result" { - return nil, nil - } - return nil, err - } - return -} - -// List 获取产品列表(排除已删除) -func (d *product) List(ctx context.Context, req *dto.ListProductReq) (list []*entity.Product, total int64, err error) { - // 构建查询过滤条件 - filter := d.buildListFilter(req) - - // 检查总数 - total, err = d.checkTotalCount(ctx, filter) - if err != nil { - return - } - - // 分页参数处理 - pageNum := req.PageNum - if pageNum <= 0 { - pageNum = 1 - } - pageSize := req.PageSize - if pageSize <= 0 { - pageSize = 20 - } - - // 使用统一的mongo.DB().Find方法(支持分页和排序) - page := &beans.Page{ - PageNum: int64(pageNum), - PageSize: int64(pageSize), - } - orderBy := []beans.OrderBy{ - {Field: "createdAt", Order: beans.Desc}, // 按创建时间倒序 - } - - _, err = mongo.DB().Find(ctx, filter, &list, entity.ProductCollection, page, orderBy) - return -} - -// GetById 根据ID获取产品 -func (d *product) GetById(ctx context.Context, id string) (product *entity.Product, err error) { - objectId, err := bson.ObjectIDFromHex(id) - if err != nil { - return - } - filter := bson.M{"_id": objectId, "isDeleted": false} - err = mongo.DB().FindOne(ctx, filter, &product, entity.ProductCollection) - return -} - -// FindAllForExport 查询所有产品用于导出(不分页) -func (d *product) FindAllForExport(ctx context.Context, name string) (list []*entity.Product, err error) { - filter := bson.M{} - if !g.IsEmpty(name) { - filter["name"] = bson.M{"$regex": name, "$options": "i"} // 模糊查询,忽略大小写 - } - - // 使用 mongo.DB().Find(会自动过滤租户和已删除数据) - // 导出场景:不分页,设置足够大的PageSize - page := &beans.Page{ - PageNum: 1, - PageSize: 100000, // 导出场景:设置足够大的PageSize - } - orderBy := []beans.OrderBy{ - {Field: "createdAt", Order: beans.Desc}, // 按创建时间倒序 - } - _, err = mongo.DB().Find(ctx, filter, &list, entity.ProductCollection, page, orderBy) - return -} - -// UpdateEntity 更新产品实体(用于绑定/解绑/同步等场景) -func (d *product) UpdateEntity(ctx context.Context, product *entity.Product) (err error) { - filter := bson.M{"_id": product.Id, "isDeleted": false} - - // 将实体转换为bson.M - updateDoc := bson.M{} - data, _ := bson.Marshal(product) - bson.Unmarshal(data, &updateDoc) - - _, err = mongo.DB().Update(ctx, filter, bson.M{"$set": updateDoc}, entity.ProductCollection) - return -} diff --git a/dao/ragflow_config_dao.go b/dao/ragflow_config_dao.go deleted file mode 100644 index 73b63e0..0000000 --- a/dao/ragflow_config_dao.go +++ /dev/null @@ -1,135 +0,0 @@ -package dao - -import ( - "context" - "customer-server/model/entity" - - "gitea.com/red-future/common/db/mongo" - "github.com/gogf/gf/v2/errors/gerror" - "github.com/gogf/gf/v2/util/gconv" - "go.mongodb.org/mongo-driver/v2/bson" -) - -var RAGFlowConfig = new(ragflowConfig) - -type ragflowConfig struct{} - -// FindByAccountName 根据客服账号名称查询配置(带租户隔离,兼容tenantId类型不一致) -func (d *ragflowConfig) FindByAccountName(ctx context.Context, accountName string) (*entity.RAGFlowConfig, error) { - // 先查询客服账号获取tenantId - account, err := CustomerServiceAccount.FindByAccountName(ctx, accountName) - if err != nil { - return nil, err - } - if account == nil { - return nil, nil - } - - // 使用accountName + tenantId查询RAGFlow配置(租户隔离) - // 先尝试原始类型查询 - filter := bson.M{"accountName": accountName, "tenantId": account.TenantId, "isDeleted": false} - var config entity.RAGFlowConfig - err = MongoDAO.FindOne(ctx, filter, &config, entity.RAGFlowConfigCollection) - - // 如果未找到且tenantId可以转为string,尝试用string查询(兼容性处理) - if (err != nil || config.Id == nil || config.Id.IsZero()) && account.TenantId != nil { - tenantIdStr := gconv.String(account.TenantId) - if tenantIdStr != "" { - filter = bson.M{"accountName": accountName, "tenantId": tenantIdStr, "isDeleted": false} - err = MongoDAO.FindOne(ctx, filter, &config, entity.RAGFlowConfigCollection) - if err != nil { - return nil, err - } - } - } - - if config.Id.IsZero() { - return nil, nil - } - return &config, nil -} - -// FindDatasetIdByTenant 根据租户ID查询知识库ID(从任意一条RAGFlowConfig记录中获取) -func (d *ragflowConfig) FindDatasetIdByTenant(ctx context.Context, tenantId string) (datasetId string, err error) { - // 先尝试字符串查询 - filter := bson.M{"tenantId": tenantId, "isDeleted": false} - var config entity.RAGFlowConfig - err = MongoDAO.FindOne(ctx, filter, &config, entity.RAGFlowConfigCollection) - - // 如果未找到且tenantId可以转为数字,尝试用数字查询(兼容MongoDB中可能存储为int的情况) - if err != nil || config.Id.IsZero() { - tenantIdInt := gconv.Int(tenantId) - if tenantIdInt > 0 { - filter = bson.M{"tenantId": tenantIdInt, "isDeleted": false} - err = MongoDAO.FindOne(ctx, filter, &config, entity.RAGFlowConfigCollection) - if err != nil { - return "", err - } - } - } - - if config.Id.IsZero() { - return "", nil // 未找到记录 - } - return config.DatasetId, nil -} - -// Insert 插入配置 -func (d *ragflowConfig) Insert(ctx context.Context, config *entity.RAGFlowConfig) error { - _, err := mongo.DB().Insert(ctx, []interface{}{config}, entity.RAGFlowConfigCollection) - return err -} - -// UpdateEntity 更新配置(避免双重token验证冲突) -func (d *ragflowConfig) UpdateEntity(ctx context.Context, config *entity.RAGFlowConfig) error { - filter := bson.M{"_id": config.Id, "isDeleted": false} - - // 将实体转换为bson.M - updateDoc := bson.M{} - data, _ := bson.Marshal(config) - bson.Unmarshal(data, &updateDoc) - - update := bson.M{"$set": updateDoc} - // 使用MongoDAO.UpdateOne(不需要token验证) - _, _, err := MongoDAO.UpdateOne(ctx, filter, update, entity.RAGFlowConfigCollection) - if err != nil { - return gerror.Wrap(err, "更新RAGFlow配置失败") - } - return nil -} - -// UpdateDocumentIds 更新文档ID列表(避免双重token验证冲突) -func (d *ragflowConfig) UpdateDocumentIds(ctx context.Context, accountName string, documentIds []string) error { - filter := bson.M{"accountName": accountName, "isDeleted": false} - update := bson.M{"$set": bson.M{"documentIds": documentIds}} - // 使用MongoDAO.UpdateOne(不需要token验证) - _, _, err := MongoDAO.UpdateOne(ctx, filter, update, entity.RAGFlowConfigCollection) - return err -} - -// UpdateDatasetIdByTenant 批量更新租户的所有datasetId记录(兼容tenantId类型不一致) -func (d *ragflowConfig) UpdateDatasetIdByTenant(ctx context.Context, tenantId, newDatasetId string) error { - // 先尝试字符串查询 - filter := bson.M{"tenantId": tenantId, "isDeleted": false} - update := bson.M{"$set": bson.M{"datasetId": newDatasetId}} - matchedCount, _, err := MongoDAO.UpdateMany(ctx, filter, update, entity.RAGFlowConfigCollection) - - // 如果未匹配到且tenantId可以转为数字,尝试用数字查询(兼容MongoDB中可能存储为int的情况) - if (err != nil || matchedCount == 0) && gconv.Int(tenantId) > 0 { - filter = bson.M{"tenantId": gconv.Int(tenantId), "isDeleted": false} - matchedCount, _, err = MongoDAO.UpdateMany(ctx, filter, update, entity.RAGFlowConfigCollection) - if err != nil { - return gerror.Wrap(err, "批量更新datasetId失败(数字类型尝试)") - } - } - - if err != nil { - return gerror.Wrap(err, "批量更新datasetId失败") - } - - if matchedCount == 0 { - return gerror.Newf("未找到租户%s的记录", tenantId) - } - - return nil -} diff --git a/dao/scripted_speech_dao.go b/dao/scripted_speech_dao.go index f3593dc..d5e85c3 100644 --- a/dao/scripted_speech_dao.go +++ b/dao/scripted_speech_dao.go @@ -27,7 +27,7 @@ func (d *scriptedSpeech) Insert(ctx context.Context, req *dto.AddScriptedSpeechR } func (d *scriptedSpeech) Update(ctx context.Context, req *dto.UpdateScriptedSpeechReq) (rows int64, err error) { - r, err := gfdb.DB(ctx).Model(ctx, public.TableNameScriptedSpeech).Data(&req).Where(entity.ScriptedSpeechCol.Id, req.Id).Update() + r, err := gfdb.DB(ctx).Model(ctx, public.TableNameScriptedSpeech).Data(&req).Where(entity.ScriptedSpeechCol.Id, req.Id).OmitEmpty().Update() if err != nil { return } @@ -42,6 +42,14 @@ func (d *scriptedSpeech) Delete(ctx context.Context, req *dto.DeleteScriptedSpee return r.RowsAffected() } +func (d *scriptedSpeech) Count(ctx context.Context, req *dto.ListScriptedSpeechReq) (count int, err error) { + count, err = gfdb.DB(ctx).Model(ctx, public.TableNameScriptedSpeech).OmitEmpty(). + Where(entity.ScriptedSpeechCol.DatasetId, req.DatasetId). + Where(entity.ScriptedSpeechCol.SceneType, req.SceneType). + Count() + return +} + // GetById 根据ID查询预制话术 func (d *scriptedSpeech) GetById(ctx context.Context, req *dto.GetScriptedSpeechReq, fields ...string) (res *entity.ScriptedSpeech, err error) { r, err := gfdb.DB(ctx).Model(ctx, public.TableNameScriptedSpeech).Where(entity.ScriptedSpeechCol.Id, req.Id).Fields(fields).One() @@ -52,11 +60,24 @@ func (d *scriptedSpeech) GetById(ctx context.Context, req *dto.GetScriptedSpeech return } +// GetByDatasetIdAndSceneType 根据数据集ID和场景类型查询预制话术 +func (d *scriptedSpeech) GetByDatasetIdAndSceneType(ctx context.Context, req *dto.ListScriptedSpeechReq, fields ...string) (res *entity.ScriptedSpeech, err error) { + r, err := gfdb.DB(ctx).Model(ctx, public.TableNameScriptedSpeech).Fields(fields). + Where(entity.ScriptedSpeechCol.DatasetId, req.DatasetId). + Where(entity.ScriptedSpeechCol.SceneType, req.SceneType). + One() + if err != nil { + return + } + err = r.Struct(&res) + return +} + // List 获取预制话术列表 func (d *scriptedSpeech) List(ctx context.Context, req *dto.ListScriptedSpeechReq, fields ...string) (res []*entity.ScriptedSpeech, total int, err error) { model := gfdb.DB(ctx).Model(ctx, public.TableNameScriptedSpeech).Fields(fields).OmitEmpty() - model.Where(entity.ScriptedSpeechCol.AccountId, req.AccountId) model.Where(entity.ScriptedSpeechCol.DatasetId, req.DatasetId) + model.Where(entity.ScriptedSpeechCol.SceneType, req.SceneType) model.OrderDesc(entity.ScriptedSpeechCol.CreatedAt) if req.Page != nil { model.Page(int(req.Page.PageNum), int(req.Page.PageSize)) diff --git a/dao/session_dao.go b/dao/session_dao.go deleted file mode 100644 index b4aba7d..0000000 --- a/dao/session_dao.go +++ /dev/null @@ -1,99 +0,0 @@ -package dao - -import ( - "context" - "customer-server/model/entity" - - "gitea.com/red-future/common/db/mongo" - - "github.com/gogf/gf/v2/os/gtime" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo/options" -) - -var Session = new(session) - -type session struct{} - -// Upsert 更新或插入会话(根据 userId + sessionId) -// 注意:消费者调用,无 HTTP 上下文,直接使用原生 MongoDB 操作 -func (d *session) Upsert(ctx context.Context, data *entity.Session) (err error) { - filter := bson.M{ - "userId": data.UserId, - "sessionId": data.SessionId, - "isDeleted": false, - } - - now := gtime.Now().Time - update := bson.M{ - "$set": bson.M{ - "platform": data.Platform, - "status": data.Status, - "lastActiveAt": data.LastActiveAt, - "updater": "system", - "updatedAt": now, - }, - "$inc": bson.M{ - "messageCount": 1, - }, - "$setOnInsert": bson.M{ - "creator": "system", - "createdAt": now, - "isDeleted": false, - }, - } - - opts := options.UpdateOne().SetUpsert(true) - _, err = mongo.GetDB().Collection(entity.SessionCollection).UpdateOne(ctx, filter, update, opts) - return -} - -// Archive 归档会话 -// 注意:消费者调用,无 HTTP 上下文,直接使用原生 MongoDB 操作 -func (d *session) Archive(ctx context.Context, userId, sessionId string) (err error) { - filter := bson.M{ - "userId": userId, - "sessionId": sessionId, - "isDeleted": false, - } - - now := gtime.Now().Time - update := bson.M{ - "$set": bson.M{ - "status": entity.SessionStatusArchived, - "archivedAt": now, - "updater": "system", - "updatedAt": now, - }, - } - - _, err = mongo.GetDB().Collection(entity.SessionCollection).UpdateOne(ctx, filter, update) - return -} - -// FindByUserId 根据用户ID查询会话列表 -func (d *session) FindByUserId(ctx context.Context, userId string, limit int64) (list []*entity.Session, err error) { - filter := bson.M{"userId": userId, "isDeleted": false} - opts := options.Find().SetSort(bson.D{{Key: "lastActiveAt", Value: -1}}).SetLimit(limit) - - cursor, err := mongo.GetDB().Collection(entity.SessionCollection).Find(ctx, filter, opts) - if err != nil { - return - } - defer cursor.Close(ctx) - - err = cursor.All(ctx, &list) - return -} - -// FindActiveByUserId 查询用户活跃会话 -func (d *session) FindActiveByUserId(ctx context.Context, userId string) (data *entity.Session, err error) { - filter := bson.M{ - "userId": userId, - "status": entity.SessionStatusActive, - "isDeleted": false, - } - - err = mongo.GetDB().Collection(entity.SessionCollection).FindOne(ctx, filter).Decode(&data) - return -} diff --git a/dao/speechcraft_dao.go b/dao/speechcraft_dao.go deleted file mode 100644 index 5843211..0000000 --- a/dao/speechcraft_dao.go +++ /dev/null @@ -1,301 +0,0 @@ -package dao - -import ( - "context" - "customer-server/model/dto" - "customer-server/model/entity" - "strings" - - "gitea.com/red-future/common/beans" - "gitea.com/red-future/common/db/mongo" - "github.com/gogf/gf/v2/errors/gerror" - "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/os/gtime" - "github.com/gogf/gf/v2/util/grand" - "go.mongodb.org/mongo-driver/v2/bson" -) - -var Speechcraft = new(speechcraft) - -type speechcraft struct{} - -// Insert 插入话术 -func (d *speechcraft) Insert(ctx context.Context, data *entity.Speechcraft) (id bson.ObjectID, err error) { - // 统一使用mongo.DB().Insert,自动清除缓存 - // service层已经设置了TenantId,mongo.DB().Insert不会覆盖已有值 - ids, err := mongo.DB().Insert(ctx, []interface{}{data}, entity.SpeechcraftCollection) - if err != nil { - return - } - if len(ids) > 0 { - if oid, ok := ids[0].(bson.ObjectID); ok { - id = oid - data.Id = &oid // 取地址赋值给指针类型 - } - } - return -} - -// Update 更新话术 -func (d *speechcraft) Update(ctx context.Context, req *dto.UpdateSpeechcraftReq) (err error) { - objectId, err := bson.ObjectIDFromHex(req.Id) - if err != nil { - return - } - updateFields := bson.M{} - if !g.IsEmpty(req.Tag) { - updateFields["tag"] = req.Tag - } - if !g.IsEmpty(req.Content) { - updateFields["content"] = req.Content - } - // 状态机字段 - if req.Stage != nil { - updateFields["stage"] = *req.Stage - } - if req.Status != nil { - updateFields["status"] = *req.Status - } - if req.Keywords != nil { - updateFields["keywords"] = req.Keywords - } - if req.NextStage != nil { - updateFields["nextStage"] = *req.NextStage - } - if req.Platform != nil { - updateFields["platform"] = *req.Platform - } - - if len(updateFields) > 0 { - _, err = mongo.DB().Update(ctx, bson.M{"_id": objectId, "isDeleted": false}, bson.M{"$set": updateFields}, entity.SpeechcraftCollection) - } - return -} - -// Delete 软删除话术 -func (d *speechcraft) Delete(ctx context.Context, req *dto.DeleteSpeechcraftReq) (err error) { - objectId, err := bson.ObjectIDFromHex(req.Id) - if err != nil { - return - } - - filter := bson.M{"_id": objectId, "isDeleted": false} - update := bson.M{"$set": bson.M{"isDeleted": true, "updatedAt": gtime.Now().Time}} - _, err = mongo.DB().Update(ctx, filter, update, entity.SpeechcraftCollection) - if err != nil { - return gerror.Wrap(err, "删除话术失败") - } - - return -} - -// buildListFilter 构建列表查询的过滤条件 -func (d *speechcraft) buildListFilter(req *dto.ListSpeechcraftReq) bson.M { - filter := bson.M{"isDeleted": false} - if !g.IsEmpty(req.Tag) { - filter["tag"] = bson.M{"$regex": req.Tag} - } - if !g.IsEmpty(req.Content) { - filter["content"] = bson.M{"$regex": req.Content} - } - if req.Stage != nil { - filter["stage"] = *req.Stage - } - if !g.IsEmpty(req.Platform) { - filter["platform"] = req.Platform - } - return filter -} - -// checkTotalCount 检查总数 -func (d *speechcraft) checkTotalCount(ctx context.Context, filter bson.M) (total int64, err error) { - total, err = mongo.DB().Count(ctx, filter, entity.SpeechcraftCollection) - return -} - -// List 获取话术列表(排除已删除) -func (d *speechcraft) List(ctx context.Context, req *dto.ListSpeechcraftReq) (list []*entity.Speechcraft, total int64, err error) { - // 构建查询过滤条件 - filter := d.buildListFilter(req) - - // 检查总数 - total, err = d.checkTotalCount(ctx, filter) - if err != nil { - return - } - - // 分页参数处理 - pageNum := req.PageNum - if pageNum <= 0 { - pageNum = 1 - } - pageSize := req.PageSize - if pageSize <= 0 { - pageSize = 20 - } - - // 使用统一的mongo.DB().Find方法(支持分页和排序) - page := &beans.Page{ - PageNum: int64(pageNum), - PageSize: int64(pageSize), - } - orderBy := []beans.OrderBy{ - {Field: "createdAt", Order: beans.Desc}, // 按创建时间倒序 - } - - _, err = mongo.DB().Find(ctx, filter, &list, entity.SpeechcraftCollection, page, orderBy) - return -} - -// MatchByStage 根据阶段和用户输入匹配话术 -// 匹配逻辑:阶段匹配 + 行为匹配(可选)+ 关键字匹配(可选) -// 从匹配结果中随机选择一条(话术池随机) -func (d *speechcraft) MatchByStage(ctx context.Context, stage int, status, content, platform string) (script *entity.Speechcraft, err error) { - // 查询该阶段的所有话术 - filter := bson.M{ - "stage": stage, - "isDeleted": false, - } - if !g.IsEmpty(platform) { - filter["platform"] = platform - } - - var list []*entity.Speechcraft - // 使用mongo.DB().Find会自动从token或accountName获取tenantId并过滤 - // 查询所有匹配的话术(不分页) - page := &beans.Page{ - PageNum: 1, - PageSize: 10000, // 话术匹配场景:设置足够大的PageSize - } - orderBy := []beans.OrderBy{} // 无需排序,后续会随机选择 - if _, err = mongo.DB().Find(ctx, filter, &list, entity.SpeechcraftCollection, page, orderBy); err != nil { - return nil, err - } - - // 收集所有匹配的话术 - matched := make([]*entity.Speechcraft, 0, len(list)) - for _, item := range list { - // 行为匹配(空=任意行为都匹配) - if !g.IsEmpty(item.Status) && item.Status != status { - continue - } - - // 关键字匹配(空=任意内容都匹配) - if len(item.Keywords) > 0 && !d.matchKeywords(content, item.Keywords) { - continue - } - - // 匹配成功,加入候选池 - matched = append(matched, item) - } - - // 从候选池随机选择一条 - if len(matched) > 0 { - script = matched[grand.Intn(len(matched))] - } - return -} - -// matchKeywords 检查内容是否包含任一关键字 -func (d *speechcraft) matchKeywords(content string, keywords []string) bool { - for _, kw := range keywords { - if g.IsEmpty(kw) { - continue - } - if strings.Contains(content, kw) { - return true - } - } - return false -} - -// FindByTag 根据tag查询话术(用于去重检查,同一租户下tag唯一) -func (d *speechcraft) FindByTag(ctx context.Context, tag string) (speechcraft *entity.Speechcraft, err error) { - filter := bson.M{ - "tag": tag, - "isDeleted": false, - } - err = mongo.DB().FindOne(ctx, filter, &speechcraft, entity.SpeechcraftCollection) - if err != nil { - if err.Error() == "mongo: no documents in result" { - return nil, nil - } - return nil, err - } - return -} - -// FindByTagAndPlatform 根据tag和platform查询话术(用于去重检查) -func (d *speechcraft) FindByTagAndPlatform(ctx context.Context, tag, platform string) (speechcraft *entity.Speechcraft, err error) { - filter := bson.M{ - "tag": tag, - "platform": platform, - "isDeleted": false, - } - err = mongo.DB().FindOne(ctx, filter, &speechcraft, entity.SpeechcraftCollection) - if err != nil { - if err.Error() == "mongo: no documents in result" { - return nil, nil - } - return nil, err - } - return -} - -// GetById 根据ID查询话术 -// 使用 MongoDAO(不需要token验证) -func (d *speechcraft) GetById(ctx context.Context, id string) (speechcraft *entity.Speechcraft, err error) { - objectId, err := bson.ObjectIDFromHex(id) - if err != nil { - return - } - filter := bson.M{"_id": objectId, "isDeleted": false} - - var result entity.Speechcraft - err = MongoDAO.FindOne(ctx, filter, &result, entity.SpeechcraftCollection) - if err != nil { - return nil, err - } - - // 如果未找到记录,result 是零值 - if result.Id.IsZero() { - return nil, nil - } - - speechcraft = &result - return -} - -// UpdateEntity 更新话术实体(用于绑定/解绑/同步等场景) -func (d *speechcraft) UpdateEntity(ctx context.Context, speechcraft *entity.Speechcraft) (err error) { - filter := bson.M{"_id": speechcraft.Id, "isDeleted": false} - - // 将实体转换为bson.M - updateDoc := bson.M{} - data, _ := bson.Marshal(speechcraft) - bson.Unmarshal(data, &updateDoc) - - _, err = mongo.DB().Update(ctx, filter, bson.M{"$set": updateDoc}, entity.SpeechcraftCollection) - return -} - -// FindByStage 查询指定阶段的所有话术 -func (d *speechcraft) FindByStage(ctx context.Context, stage int, platform string) (list []*entity.Speechcraft, err error) { - filter := bson.M{ - "stage": stage, - "isDeleted": false, - } - if !g.IsEmpty(platform) { - filter["platform"] = platform - } - // 使用统一的mongo.DB().Find方法(支持分页和排序) - page := &beans.Page{ - PageNum: 1, - PageSize: 10000, // 查询所有话术(不分页) - } - orderBy := []beans.OrderBy{ - {Field: "priority", Order: beans.Desc}, // 按优先级倒序 - } - _, err = mongo.DB().Find(ctx, filter, &list, entity.SpeechcraftCollection, page, orderBy) - return -} diff --git a/dao/user_stage_dao.go b/dao/user_stage_dao.go deleted file mode 100644 index 7fcddb9..0000000 --- a/dao/user_stage_dao.go +++ /dev/null @@ -1,143 +0,0 @@ -package dao - -import ( - "context" - "customer-server/model/entity" - - "gitea.com/red-future/common/db/mongo" - "github.com/gogf/gf/v2/os/gtime" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo/options" -) - -var UserStage = new(userStage) - -type userStage struct{} - -// GetOrCreate 获取用户阶段,不存在则创建 -func (d *userStage) GetOrCreate(ctx context.Context, userId, platform string) (state *entity.UserStage, err error) { - filter := bson.M{ - "userId": userId, - "platform": platform, - "isDeleted": false, - } - - state = &entity.UserStage{} - if err = mongo.DB().FindOne(ctx, filter, state, entity.UserStageCollection); err == nil { - return - } - - // 不存在则创建 - now := gtime.Now().Time - state = &entity.UserStage{ - UserId: userId, - Platform: platform, - Stage: entity.StageInit, - Status: entity.StatusIdle, - LastMsgAt: &now, // 取地址赋值给指针类型 - } - state.CreatedAt = &now // 取地址赋值给指针类型 - state.UpdatedAt = &now // 取地址赋值给指针类型 - state.IsDeleted = false - - _, err = mongo.DB().Insert(ctx, []interface{}{state}, entity.UserStageCollection) - return -} - -// UpdateStage 更新用户阶段 -func (d *userStage) UpdateStage(ctx context.Context, userId, platform string, stage int) (err error) { - filter := bson.M{ - "userId": userId, - "platform": platform, - "isDeleted": false, - } - update := bson.M{ - "$set": bson.M{ - "stage": stage, - "lastMsgAt": gtime.Now().Time, - "updatedAt": gtime.Now().Time, - }, - } - _, err = mongo.DB().Update(ctx, filter, update, entity.UserStageCollection) - return -} - -// UpdateStatus 更新用户行为 -func (d *userStage) UpdateStatus(ctx context.Context, userId, platform, status string) (err error) { - filter := bson.M{ - "userId": userId, - "platform": platform, - "isDeleted": false, - } - update := bson.M{ - "$set": bson.M{ - "status": status, - "lastMsgAt": gtime.Now().Time, - "updatedAt": gtime.Now().Time, - }, - } - _, err = mongo.DB().Update(ctx, filter, update, entity.UserStageCollection) - return -} - -// UpdateStageAndStatus 同时更新阶段和行为 -func (d *userStage) UpdateStageAndStatus(ctx context.Context, userId, platform string, stage int, status string) (err error) { - filter := bson.M{ - "userId": userId, - "platform": platform, - "isDeleted": false, - } - update := bson.M{ - "$set": bson.M{ - "stage": stage, - "status": status, - "lastMsgAt": gtime.Now().Time, - "updatedAt": gtime.Now().Time, - }, - } - _, err = mongo.DB().Update(ctx, filter, update, entity.UserStageCollection) - return -} - -// Reset 重置用户阶段到初始状态 -func (d *userStage) Reset(ctx context.Context, userId, platform string) (err error) { - return d.UpdateStageAndStatus(ctx, userId, platform, entity.StageInit, entity.StatusIdle) -} - -// FindByUser 查询用户阶段 -func (d *userStage) FindByUser(ctx context.Context, userId, platform string) (state *entity.UserStage, err error) { - filter := bson.M{ - "userId": userId, - "platform": platform, - "isDeleted": false, - } - state = &entity.UserStage{} - err = mongo.DB().FindOne(ctx, filter, state, entity.UserStageCollection) - return -} - -// Upsert 更新或插入用户阶段 -func (d *userStage) Upsert(ctx context.Context, userId, platform string, stage int, status string) (err error) { - filter := bson.M{ - "userId": userId, - "platform": platform, - } - now := gtime.Now().Time - update := bson.M{ - "$set": bson.M{ - "stage": stage, - "status": status, - "lastMsgAt": now, - "updatedAt": now, - "isDeleted": false, - }, - "$setOnInsert": bson.M{ - "userId": userId, - "platform": platform, - "createdAt": now, - }, - } - opts := options.UpdateOne().SetUpsert(true) - _, err = mongo.GetDB().Collection(entity.UserStageCollection).UpdateOne(ctx, filter, update, opts) - return -} diff --git a/go.mod b/go.mod index 11dd4aa..a13bc47 100644 --- a/go.mod +++ b/go.mod @@ -4,14 +4,14 @@ go 1.26.0 replace gitea.com/red-future/common v0.0.12 => ../common +replace github.com/bjang03/gmq => ../gmq require ( gitea.com/red-future/common v0.0.12 + github.com/bjang03/gmq v0.0.0-20260302085007-c7d0e3be8af4 github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.0 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 ( @@ -28,8 +28,13 @@ require ( 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/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/go-ego/gse v1.0.2 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/go-viper/mapstructure/v2 v2.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 @@ -43,6 +48,7 @@ require ( 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/consul/api v1.33.4 // 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 @@ -51,28 +57,28 @@ require ( 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/klauspost/compress v1.18.4 // indirect + github.com/leodido/go-urn v1.4.0 // indirect github.com/lib/pq v1.10.9 // indirect github.com/magiconair/properties v1.8.10 // indirect - github.com/mailru/easyjson v0.9.0 // 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/nats-io/nats.go v1.49.0 // indirect + github.com/nats-io/nkeys v0.4.15 // indirect + github.com/nats-io/nuid v1.0.1 // indirect github.com/olekukonko/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/redis/go-redis/v9 v9.18.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/spf13/cast v1.10.0 // 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 + github.com/vcaesar/cedar v0.30.0 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // 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 @@ -82,12 +88,12 @@ require ( 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 + go.uber.org/atomic v1.11.0 // indirect + golang.org/x/crypto v0.48.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 + golang.org/x/net v0.49.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.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 diff --git a/go.sum b/go.sum index 9840c96..6d69062 100644 --- a/go.sum +++ b/go.sum @@ -17,6 +17,8 @@ github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24 github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/bjang03/gmq v0.0.0-20260302085007-c7d0e3be8af4 h1:EP4hzerj17BB1UFPoJZYj7n/jU7SQKh+RhVdkg5doG4= +github.com/bjang03/gmq v0.0.0-20260302085007-c7d0e3be8af4/go.mod h1:Y7TwWGuV4Cw97WUDaM7x+NC4kyFx1z44WAvNwJV3HV8= 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= @@ -61,10 +63,14 @@ github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL 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/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/go-ego/gse v1.0.2 h1:+27lYFPhQEhA9igtdOsJPRKYL/k3TwYsxBF5jr6KFv4= +github.com/go-ego/gse v1.0.2/go.mod h1:Fy35G+q7VV7Et1zIKO8o/sW1kkugV3znXap/lF/11zc= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= @@ -74,6 +80,14 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= @@ -180,15 +194,15 @@ github.com/hashicorp/memberlist v0.5.0 h1:EtYPN8DpAURiapus508I4n9CzHs2W+8NZGbmmR github.com/hashicorp/memberlist v0.5.0/go.mod h1:yvyXLpo0QaGE59Y7hDTsTzDD25JYBZ4mHgHUZ8lrOI0= github.com/hashicorp/serf v0.10.1 h1:Z1H2J60yRKvfDYAOZLd2MU0ND4AH/WDz7xYHDWQsIPY= github.com/hashicorp/serf v0.10.1/go.mod h1:yL2t6BqATOLGc5HF7qbFkTfXoPIY0WZdWHfEvMqbG+4= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= +github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -198,12 +212,12 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 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.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= -github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= 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= @@ -234,14 +248,18 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= +github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw= +github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4= +github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= github.com/olekukonko/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= @@ -268,8 +286,8 @@ github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsT 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/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= 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= @@ -280,6 +298,8 @@ github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUt github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= @@ -294,17 +314,14 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD github.com/tiger1103/gfast-token v1.0.10 h1:fNiBE/Dq5iTHvTGlCx3DmXa2o4hr0NtumFpffZ39k6s= github.com/tiger1103/gfast-token v1.0.10/go.mod h1:a/21mxmj7zFeNvjhZSC0XpEAFHfb1aT2k6DXnufFU1s= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= -github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= -github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= -github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= -github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= -github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= -github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= -github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= -github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/vcaesar/cedar v0.30.0 h1:9fSDpM7FTjjUdPiBUUa0MWYMRGSEcqgFXvppZcZ4d7Y= +github.com/vcaesar/cedar v0.30.0/go.mod h1:lyuGvALuZZDPNXwpzv/9LyxW+8Y6faN7zauFezNsnik= +github.com/vcaesar/tt v0.20.1 h1:D/jUeeVCNbq3ad8M7hhtB3J9x5RZ6I1n1eZ0BJp7M+4= +github.com/vcaesar/tt v0.20.1/go.mod h1:cH2+AwGAJm19Wa6xvEa+0r+sXDJBT0QgNQey6mwqLeU= github.com/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= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= 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= @@ -327,6 +344,8 @@ go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJr 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/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= @@ -334,9 +353,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= -golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= 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= @@ -345,9 +363,8 @@ golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvx golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= -golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= 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= @@ -362,9 +379,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= 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= @@ -373,9 +389,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 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= @@ -394,27 +409,21 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= 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= @@ -424,9 +433,8 @@ golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= -golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= 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= diff --git a/main.go b/main.go index d16c135..e4faf45 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "customer-server/consts/public" "customer-server/controller" "customer-server/service" "os" @@ -9,10 +10,12 @@ import ( "syscall" _ "gitea.com/red-future/common/config" - "gitea.com/red-future/common/http" "gitea.com/red-future/common/jaeger" - "gitea.com/red-future/common/rabbitmq" + "gitea.com/red-future/common/utils" + gmq "github.com/bjang03/gmq/core/gmq" + "github.com/bjang03/gmq/mq" + "github.com/bjang03/gmq/types" _ "github.com/gogf/gf/contrib/drivers/pgsql/v2" _ "github.com/gogf/gf/contrib/nosql/redis/v2" "github.com/gogf/gf/v2/frame/g" @@ -23,78 +26,42 @@ 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.Account, controller.ScriptedSpeech, controller.AccountWebsocket, - controller.Health, - controller.Archive, - controller.CustomerServiceAccount, - controller.Data, - controller.DataStatistics, - controller.Product, - controller.Speechcraft, - controller.Websocket, - controller.Webhook, - controller.RAGFlowConfig, - controller.RAGFlow, // RAGFlow HTTP处理接口(从message迁移) - controller.XiaohongshuController, // 小红书Webhook接口 + controller.AccountHttp, }) - //// 初始化消费者管理器并注册所有消费者 - //mgr := rabbitmq.NewConsumerManager(ctx) - // - //// 注册响应消费者 - //responseConsumer := service.NewResponseConsumer(ctx) - //mgr.Register("响应消费者", responseConsumer.Start, responseConsumer.Stop) - // - //// 注册追问消费者 - //followUpConsumer := service.NewFollowUpConsumer(ctx) - //mgr.Register("追问消费者", followUpConsumer.Start, followUpConsumer.Stop) - // - //// 注册会话归档消费者 - //sessionArchiveConsumer := service.NewSessionArchiveConsumer(ctx) - //mgr.Register("会话归档消费者", sessionArchiveConsumer.Start, sessionArchiveConsumer.Stop) - // - //// 注册延时落库消费者 - //delayedFlushConsumer := service.NewDelayedFlushConsumer(ctx) - //mgr.Register("延时落库消费者", delayedFlushConsumer.Start, delayedFlushConsumer.Stop) - // - //// 初始化并启动所有消费者 - //if err := mgr.Init(); err != nil { - // glog.Fatalf(ctx, "消费者管理器初始化失败: %v", err) - //} - // - //// 初始化 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) - //} + if err := utils.InitGseTool(ctx); err != nil { + g.Log().Error(ctx, "gse 分词工具初始化失败:", err) + } + + gmq.GmqRegister(public.GmqMsgPluginsName, &mq.NatsConn{ + NatsConfig: mq.NatsConfig{ + Addr: g.Cfg().MustGet(ctx, "nats.default.addr").String(), + Port: g.Cfg().MustGet(ctx, "nats.default.port").String(), + }, + }) + if err := gmq.GetGmq(public.GmqMsgPluginsName).GmqSubscribe(ctx, &mq.NatsSubMessage{ + SubMessage: types.SubMessage{ + Topic: public.AccountFollowupTopic, + ConsumerName: public.AccountFollowupConsumer, + AutoAck: public.AccountFollowupAck, + FetchCount: public.AccountFollowupCount, + HandleFunc: service.AccountWebSocket.AccountMsg, + }, + IsDelayMsg: true, + }); err != nil { + return + } // 监听系统信号,支持 Ctrl+C 优雅退出 quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit - glog.Info(ctx, "收到停止信号,正在关闭服务...") + glog.Info(ctx, "正在关闭服务...") - // 停止所有消费者并关闭RabbitMQ连接 - //mgr.Stop() - - glog.Info(ctx, "customerservice 服务已停止") } diff --git a/model/dto/account_dto.go b/model/dto/account_dto.go index 5a6234d..077ce85 100644 --- a/model/dto/account_dto.go +++ b/model/dto/account_dto.go @@ -13,14 +13,15 @@ import ( type AddAccountReq struct { g.Meta `path:"/add" method:"post" tags:"客服账号管理" summary:"添加客服账号" dc:"创建新的客服账号"` - DatasetIds []int64 `json:"datasetIds" v:"required#数据集ID不能为空" dc:"数据集ID列表"` - DocumentIds []int64 `json:"documentIds" v:"required#文档ID不能为空" dc:"文档ID列表"` - AccountName string `json:"accountName" v:"required#客服账号名称不能为空"` - Status account.Status `json:"status" dc:"客服账号状态"` - Greeting string `json:"greeting" dc:"开场白"` - Prompt []string `json:"prompt" dc:"提示词"` - SelfIdentity string `json:"selfIdentity" dc:"AI身份描述"` - Platform account.Platform `json:"platform" v:"required#客服平台不能为空" dc:"客服平台"` + DatasetIds []int64 `json:"datasetIds" v:"required#数据集ID不能为空" dc:"数据集ID列表"` + DocumentIds []int64 `json:"documentIds" v:"required#文档ID不能为空" dc:"文档ID列表"` + AccountCode string `json:"accountCode" v:"required#账号编码不能为空" dc:"账号编码"` + AccountName string `json:"accountName" dc:"客服账号名称"` + Status account.Status `json:"status" dc:"客服账号状态"` + Greeting string `json:"greeting" dc:"开场白"` + KeywordOption []string `json:"keywordOption" dc:"关键词选项"` + SelfIdentity string `json:"selfIdentity" dc:"AI身份描述"` + Platform account.Platform `json:"platform" v:"required#客服平台不能为空" dc:"客服平台"` } type AddAccountRes struct { @@ -31,15 +32,16 @@ type AddAccountRes struct { type UpdateAccountReq struct { g.Meta `path:"/update" method:"post" tags:"客服账号管理" summary:"更新客服账号" dc:"更新客服账号信息"` - Id int64 `json:"id" v:"required#客服账号ID不能为空" dc:"客服账号ID"` - DatasetIds []int64 `json:"datasetIds" dc:"数据集ID列表"` - DocumentIds []int64 `json:"documentIds" dc:"文档ID列表"` - AccountName string `json:"accountName" dc:"客服账号名称"` - Status account.Status `json:"status" dc:"客服账号状态"` - Greeting string `json:"greeting" dc:"开场白"` - Prompt []string `json:"prompt" dc:"提示词"` - SelfIdentity string `json:"selfIdentity" dc:"AI身份描述"` - Platform account.Platform `json:"platform" dc:"客服平台"` + Id int64 `json:"id" v:"required#客服账号ID不能为空" dc:"客服账号ID"` + DatasetIds []int64 `json:"datasetIds" dc:"数据集ID列表"` + DocumentIds []int64 `json:"documentIds" dc:"文档ID列表"` + AccountCode string `json:"accountCode" dc:"账号编码"` + AccountName string `json:"accountName" dc:"客服账号名称"` + Status account.Status `json:"status" dc:"客服账号状态"` + Greeting string `json:"greeting" dc:"开场白"` + KeywordOption []string `json:"keywordOption" dc:"关键词选项"` + SelfIdentity string `json:"selfIdentity" dc:"AI身份描述"` + Platform account.Platform `json:"platform" dc:"客服平台"` } // DeleteAccountReq 删除客服账号 @@ -59,6 +61,7 @@ type ListAccountReq struct { g.Meta `path:"/list" method:"get" tags:"客服账号管理" summary:"获取客服账号列表" dc:"分页查询客服账号,支持按账号名称、状态、平台筛选"` Page *beans.Page `json:"page"` Keyword string `json:"keyword" dc:"关键字"` + AccountCode string `json:"accountCode" dc:"账号编码"` AccountName string `json:"accountName" dc:"客服账号名称"` Status account.Status `json:"status" dc:"客服账号状态"` Platform account.Platform `json:"platform" dc:"客服平台"` @@ -71,20 +74,23 @@ type ListAccountRes struct { // AccountVO 客服账号视图对象 type AccountVO struct { - Id int64 `json:"id,string"` - DatasetIds []int64 `json:"datasetIds,string"` - DocumentIds []int64 `json:"documentIds,string"` - AccountName string `json:"accountName"` - Status account.Status `json:"status"` - Greeting string `json:"greeting"` - Prompt []string `json:"prompt"` - SelfIdentity string `json:"selfIdentity"` - Platform account.Platform `json:"platform"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` + Id int64 `json:"id,string"` + DatasetIds []int64 `json:"datasetIds,string"` + DocumentIds []int64 `json:"documentIds,string"` + AccountCode string `json:"accountCode"` + AccountName string `json:"accountName"` + Status account.Status `json:"status"` + Greeting string `json:"greeting"` + KeywordOption []string `json:"keywordOption"` + SelfIdentity string `json:"selfIdentity"` + Platform account.Platform `json:"platform"` + TenantId uint64 `json:"tenantId"` + Creator string `json:"creator"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` } -// GetByAccountNameReq 根据账号名称获取客服账号 -type GetByAccountNameReq struct { - AccountName string `json:"accountName" dc:"客服账号名称"` +// GetByAccountCodeReq 根据账号名称获取客服账号 +type GetByAccountCodeReq struct { + AccountCode string `json:"accountCode" dc:"客服账号"` } diff --git a/model/dto/account_http_dto.go b/model/dto/account_http_dto.go new file mode 100644 index 0000000..d244696 --- /dev/null +++ b/model/dto/account_http_dto.go @@ -0,0 +1,32 @@ +package dto + +import ( + "customer-server/consts/account" + + "github.com/gogf/gf/v2/frame/g" +) + +type AccountHttpConnectReq struct { + g.Meta `path:"/accountConnect" method:"get" tags:"AccountHttp" summary:"Http连接" dc:"发送Http请求,用于消息推送"` + UserId string `json:"userId" v:"required#用户ID不能为空" dc:"用户ID"` + AccountCode string `json:"accountCode" v:"required#客服账号不能为空" dc:"客服账号"` + Platform account.Platform `json:"platform" v:"required#平台不能为空" dc:"平台"` + QuestionContent string `json:"questionContent" dc:"问题内容"` +} + +type AccountHttpConnectRes struct { + Content string `json:"content"` +} + +// AccountWebSocketConnectReq WebSocket 连接请求 +type AccountWebSocketConnectReq struct { + g.Meta `path:"/accountConnect" method:"get" tags:"AccountWebSocket" summary:"WebSocket连接" dc:"建立WebSocket连接,用于实时消息推送"` + UserId string `json:"userId" v:"required#用户ID不能为空" dc:"用户ID"` + AccountCode string `json:"accountCode" v:"required#客服账号不能为空" dc:"客服账号"` + Platform account.Platform `json:"platform" v:"required#平台不能为空" dc:"平台"` + QuestionContent string `json:"questionContent" dc:"问题内容"` +} + +type AccountWebSocketConnectRes struct { + Content string `json:"content"` +} diff --git a/model/dto/account_user_dialog_dto.go b/model/dto/account_user_dialog_dto.go new file mode 100644 index 0000000..cf37601 --- /dev/null +++ b/model/dto/account_user_dialog_dto.go @@ -0,0 +1,28 @@ +package dto + +type AddAccountUserDialogReq struct { + AccountId int64 `json:"accountId" v:"required#accountId不能为空" dc:"客服id"` + UserId string `json:"userId" v:"required#userId不能为空" dc:"用户id"` + DialogCount int64 `json:"dialogCount" v:"required#dialogCount不能为空" dc:"对话次数"` +} + +type UpdateAccountUserDialogReq struct { + Id int64 `json:"id" v:"required#id不能为空"` + DialogCount int64 `json:"dialogCount" v:"required#dialogCount不能为空" dc:"对话次数"` +} + +type DeleteAccountUserDialogReq struct { + Id int64 `json:"id" v:"required#id不能为空"` +} + +type GetAccountUserDialogReq struct { + AccountId int64 `json:"accountId" v:"required#accountId不能为空" dc:"客服id"` + UserId string `json:"userId" v:"required#userId不能为空" dc:"用户id"` +} + +type AccountUserDialogVO struct { + Id int64 `json:"id,string" dc:"id"` + AccountId int64 `json:"accountId,string" dc:"客服id"` + UserId string `json:"userId" dc:"用户id"` + DialogCount int64 `json:"dialogCount" dc:"对话次数"` +} diff --git a/model/dto/account_websocket_dto.go b/model/dto/account_websocket_dto.go deleted file mode 100644 index 572fc10..0000000 --- a/model/dto/account_websocket_dto.go +++ /dev/null @@ -1,14 +0,0 @@ -package dto - -import ( - "customer-server/consts/account" - - "github.com/gogf/gf/v2/frame/g" -) - -// AccountWebSocketConnectReq WebSocket 连接请求 -type AccountWebSocketConnectReq struct { - g.Meta `path:"/accountConnect" method:"get" tags:"AccountWebSocket" summary:"WebSocket连接" dc:"建立WebSocket连接,用于实时消息推送"` - AccountName string `json:"accountName" v:"required#客服账号不能为空" dc:"客服账号"` - Platform account.Platform `json:"platform" v:"required#平台不能为空" dc:"平台"` -} diff --git a/model/dto/archive_dto.go b/model/dto/archive_dto.go deleted file mode 100644 index f49cd1e..0000000 --- a/model/dto/archive_dto.go +++ /dev/null @@ -1,13 +0,0 @@ -package dto - -import "github.com/gogf/gf/v2/frame/g" - -// ArchiveRunReq 手动触发归档请求 -type ArchiveRunReq struct { - g.Meta `path:"/run" method:"post" tags:"归档管理" summary:"手动触发归档" dc:"立即执行月度归档任务"` -} - -// ArchiveRunRes 手动触发归档响应 -type ArchiveRunRes struct { - Message string `json:"message"` // 执行结果消息 -} diff --git a/model/dto/customer_service_account_dto.go b/model/dto/customer_service_account_dto.go deleted file mode 100644 index e5a5c84..0000000 --- a/model/dto/customer_service_account_dto.go +++ /dev/null @@ -1,107 +0,0 @@ -package dto - -import ( - "customer-server/model/entity" - - "gitea.com/red-future/common/beans" - "github.com/gogf/gf/v2/frame/g" -) - -// AddCustomerServiceAccountReq 添加客服账号 -type AddCustomerServiceAccountReq struct { - g.Meta `path:"/add" method:"post" tags:"客服账号管理" summary:"添加客服账号" dc:"创建新的客服账号"` // 路由: POST /customerserviceaccount/add - AccountName string `json:"accountName" v:"required" dc:"客服账号名称" example:"cs_xhs_qixue"` - Platform string `json:"platform" v:"required" dc:"客服平台" example:"小红书"` - Prompt *string `json:"prompt" dc:"可选:自定义提示词"` - Greeting *string `json:"greeting" dc:"可选:开场白"` - SelfIdentity *string `json:"selfIdentity" dc:"可选:AI身份描述"` - SpeechcraftIds []string `json:"speechcraftIds" dc:"绑定的话术ID列表(为空则绑定该租户下所有话术)"` - TenantId interface{} `json:"tenantId" dc:"租户ID(可选,不传则使用session中的租户ID)"` -} - -type AddCustomerServiceAccountRes struct { - Id string `json:"id" dc:"新创建的客服账号ID"` -} - -// UpdateCustomerServiceAccountReq 更新客服账号 -type UpdateCustomerServiceAccountReq struct { - g.Meta `path:"/update" method:"post" tags:"客服账号管理" summary:"更新客服账号" dc:"更新客服账号信息"` // 路由: POST /customerserviceaccount/update - Id string `json:"id" v:"required" dc:"客服账号ID"` - AccountName string `json:"accountName" dc:"客服账号名称"` - Platform string `json:"platform" dc:"客服平台"` - Prompt *string `json:"prompt" dc:"自定义提示词(可选)"` - Greeting *string `json:"greeting" dc:"开场白(可选)"` - SelfIdentity *string `json:"selfIdentity" dc:"AI身份描述(可选)"` -} - -// ToggleCustomerServiceAccountStatusReq 切换客服账号状态(启用/禁用) -type ToggleCustomerServiceAccountStatusReq struct { - g.Meta `path:"/toggleStatus" method:"post" tags:"客服账号管理" summary:"切换客服账号状态" dc:"根据当前状态在启用/禁用之间切换客服账号"` // 路由: POST /customerserviceaccount/toggleStatus - Id string `json:"id" v:"required" dc:"客服账号ID"` -} - -// GetCustomerServiceAccountReq 获取客服账号详情 -type GetCustomerServiceAccountReq struct { - g.Meta `path:"/one" method:"get" tags:"客服账号管理" summary:"获取客服账号详情" dc:"根据ID获取单个客服账号信息"` // 路由: GET /customerserviceaccount/one - Id string `json:"id" v:"required" dc:"客服账号ID"` -} - -// ListCustomerServiceAccountReq 获取客服账号列表 -type ListCustomerServiceAccountReq struct { - g.Meta `path:"/list" method:"get" tags:"客服账号管理" summary:"获取客服账号列表" dc:"分页查询客服账号列表,支持多条件筛选"` // 路由: GET /customerserviceaccount/list - beans.Page - AccountName string `json:"accountName" dc:"客服账号名称"` - IsDisabled *bool `json:"isDisabled" dc:"筛选:是否禁用(true-禁用,false-启用)"` - Platform string `json:"platform" dc:"筛选:平台"` -} - -// RecreateRAGFlowConfigReq 重新创建RAGFlow配置 -type RecreateRAGFlowConfigReq struct { - g.Meta `path:"/recreateRAGFlow" method:"post" tags:"客服账号管理" summary:"重新创建RAGFlow配置" dc:"为已存在的客服账号重新创建RAGFlow知识库和Chat配置"` // 路由: POST /customerserviceaccount/recreateRAGFlow - AccountName string `json:"accountName" v:"required" dc:"客服账号名称" example:"cs_xhs_qixue"` - Platform string `json:"platform" v:"required" dc:"客服平台" example:"xiaohongshu"` -} - -// UpdateGreetingReq 更新开场白 -type UpdateGreetingReq struct { - g.Meta `path:"/updateGreeting" method:"post" tags:"客服账号管理" summary:"更新开场白" dc:"更新客服账号的WebSocket连接开场白"` // 路由: POST /customerserviceaccount/updateGreeting - AccountName string `json:"accountName" v:"required" dc:"客服账号名称" example:"cs_xhs_qixue"` - Greeting string `json:"greeting" v:"required" dc:"开场白内容" example:"亲爱的,欢迎光临!🌸\n如果你有月经不调或气血不足的问题,随时可以问我哦!"` -} - -type ListCustomerServiceAccountRes struct { - List []*entity.CustomerServiceAccount `json:"list" dc:"客服账号列表"` - Total int `json:"total" dc:"总记录数"` -} - -// DeleteCustomerServiceAccountReq 删除客服账号 -type DeleteCustomerServiceAccountReq struct { - g.Meta `path:"/delete" method:"post" tags:"客服账号管理" summary:"删除客服账号" dc:"软删除客服账号,同时删除RAGFlow Chat实例"` - Id string `json:"id" v:"required" dc:"客服账号ID"` -} - -type DeleteCustomerServiceAccountRes struct { - Success bool `json:"success" dc:"是否成功"` - Message string `json:"message" dc:"提示信息"` -} - -// GetAccessibleSpeechcraftsReq 获取客服账号可访问的话术列表 -type GetAccessibleSpeechcraftsReq struct { - g.Meta `path:"/speechcrafts/:accountName" method:"get" tags:"客服账号管理" summary:"获取可访问话术" dc:"获取客服账号可访问的话术列表(动态权限)"` - AccountName string `json:"accountName" v:"required" dc:"客服账号名称"` -} - -type GetAccessibleSpeechcraftsRes struct { - Speechcrafts []SpeechcraftItem `json:"speechcrafts" dc:"话术列表"` - TotalCount int `json:"totalCount" dc:"总数"` - Permission string `json:"permission" dc:"权限类型:dynamic=所有话术,specified=指定话术"` -} - -type SpeechcraftItem struct { - Id string `json:"id"` - Tag string `json:"tag"` - Content string `json:"content"` - Direction string `json:"direction"` - Platform string `json:"platform"` - Keywords []string `json:"keywords"` -} diff --git a/model/dto/data_dto.go b/model/dto/data_dto.go deleted file mode 100644 index 791f9b1..0000000 --- a/model/dto/data_dto.go +++ /dev/null @@ -1,74 +0,0 @@ -package dto - -import ( - "customer-server/model/entity" - - "gitea.com/red-future/common/beans" - "github.com/gogf/gf/v2/frame/g" -) - -// AddDataReq 添加数据 -type AddDataReq struct { - g.Meta `path:"/add" method:"post" tags:"数据管理" summary:"添加数据" dc:"记录客服与客户的交互数据"` // 路由: POST /data/add - CustomerId string `json:"customerId" v:"required"` // 客户ID - AccountName string `json:"accountName" v:"required"` // 客服账号名称 - CustomerServicePlatform string `json:"customerServicePlatform" v:"required"` // 客服平台 - CustomerServiceName string `json:"customerServiceName" v:"required"` // 客服名称 - IsInbound bool `json:"isInbound"` // 是否进线 - IsActive bool `json:"isActive"` // 是否活跃 - IsServed bool `json:"isServed"` // 是否接待 - HasSentContactCard bool `json:"hasSentContactCard"` // 是否发联系卡 - HasSentNameCard bool `json:"hasSentNameCard"` // 是否发名片 - HasLeftContactInfo bool `json:"hasLeftContactInfo"` // 是否留资 - SessionStartTime int64 `json:"sessionStartTime"` // 会话开始时间 - MessageTime int64 `json:"messageTime"` // 消息时间 -} - -type AddDataRes struct { - Id string `json:"id"` -} - -// UpdateDataReq 更新数据 -type UpdateDataReq struct { - g.Meta `path:"/update" method:"post" tags:"数据管理" summary:"更新数据" dc:"更新客服交互数据"` // 路由: POST /data/update - Id string `json:"id" v:"required"` // ID - CustomerId string `json:"customerId"` - AccountName string `json:"accountName"` - CustomerServicePlatform string `json:"customerServicePlatform"` - CustomerServiceName string `json:"customerServiceName"` - IsInbound *bool `json:"isInbound"` // 使用指针以区分 false 和未传值 - IsActive *bool `json:"isActive"` - IsServed *bool `json:"isServed"` - HasSentContactCard *bool `json:"hasSentContactCard"` - HasSentNameCard *bool `json:"hasSentNameCard"` - HasLeftContactInfo *bool `json:"hasLeftContactInfo"` - SessionStartTime int64 `json:"sessionStartTime"` - MessageTime int64 `json:"messageTime"` -} - -// // DeleteDataReq 删除数据 -// type DeleteDataReq struct { -// g.Meta `path:"/delete" method:"post" tags:"数据管理" summary:"删除数据" dc:"删除指定数据记录"` // 路由: POST /data/delete -// Id string `json:"id" v:"required"` // ID -// } - -// GetDataReq 获取单个数据 -type GetDataReq struct { - g.Meta `path:"/one" method:"get" tags:"数据管理" summary:"获取数据详情" dc:"根据ID获取单条数据记录"` // 路由: GET /data/one - Id string `json:"id" v:"required"` // ID -} - -// ListDataReq 获取数据列表 -type ListDataReq struct { - g.Meta `path:"/list" method:"get" tags:"数据管理" summary:"获取数据列表" dc:"分页查询交互数据,支持按客户、客服、时间筛选"` // 路由: GET /data/list - beans.Page - CustomerId string `json:"customerId" p:"customerId" dc:"筛选:客户ID"` // 筛选:客户ID - AccountName *string `json:"accountName" dc:"客服账号名称"` // 筛选:客服ID - StartDate string `json:"startDate" p:"startDate" dc:"筛选:开始日期(YYYY-MM-DD)"` // 筛选:开始日期 - EndDate string `json:"endDate" p:"endDate" dc:"筛选:结束日期(YYYY-MM-DD)"` // 筛选:结束日期 -} - -type ListDataRes struct { - List []*entity.Data `json:"list"` - Total int `json:"total"` -} diff --git a/model/dto/data_statistics_dto.go b/model/dto/data_statistics_dto.go deleted file mode 100644 index 02eb91d..0000000 --- a/model/dto/data_statistics_dto.go +++ /dev/null @@ -1,73 +0,0 @@ -package dto - -import ( - "customer-server/model/entity" - - "gitea.com/red-future/common/beans" - "github.com/gogf/gf/v2/frame/g" -) - -// AddDataStatisticsReq 添加数据统计 -type AddDataStatisticsReq struct { - g.Meta `path:"/add" method:"post" tags:"数据统计" summary:"添加统计数据" dc:"记录每日客服的汇总统计数据"` // 路由: POST /data/statistics/add - Date string `json:"date" v:"required|date-format:2006-01-02"` // 日期,格式:YYYY-MM-DD - AccountName string `json:"accountName" v:"required"` // 客服账号名称 - CustomerServiceName string `json:"customerServiceName" v:"required"` // 客服名称 - CustomerServicePlatform string `json:"customerServicePlatform" v:"required"` // 客服平台 - InboundCount int `json:"inboundCount"` // 进线数 - ActiveCount int `json:"activeCount"` // 开口数 - ServedCount int `json:"servedCount"` // 接待数 - ContactCardSentCount int `json:"contactCardSentCount"` // 发名片数 - NameCardSentCount int `json:"nameCardSentCount"` // 发留资卡数 - LeftContactInfoCount int `json:"leftContactInfoCount"` // 留资数 - ResponseRate30s float64 `json:"responseRate30s"` // 30s响应率 - ResponseRate60s float64 `json:"responseRate60s"` // 60s响应率 - ResponseRate360s float64 `json:"responseRate360s"` // 3分钟响应率 -} - -type AddDataStatisticsRes struct { - Id string `json:"id"` -} - -// UpdateDataStatisticsReq 更新数据统计 -type UpdateDataStatisticsReq struct { - g.Meta `path:"/update" method:"post" tags:"数据统计" summary:"更新统计数据" dc:"更新每日统计数据"` // 路由: POST /data/statistics/update - Id string `json:"id" v:"required"` - Date string `json:"date" v:"date-format:2006-01-02"` - AccountName string `json:"accountName"` - CustomerServiceName string `json:"customerServiceName"` - CustomerServicePlatform string `json:"customerServicePlatform"` - InboundCount *int `json:"inboundCount"` - ActiveCount *int `json:"activeCount"` - ServedCount *int `json:"servedCount"` - ContactCardSentCount *int `json:"contactCardSentCount"` - NameCardSentCount *int `json:"nameCardSentCount"` - LeftContactInfoCount *int `json:"leftContactInfoCount"` - ResponseRate30s *float64 `json:"responseRate30s"` - ResponseRate60s *float64 `json:"responseRate60s"` - ResponseRate360s *float64 `json:"responseRate360s"` -} - -// ListDataStatisticsReq 获取数据统计列表 -type ListDataStatisticsReq struct { - g.Meta `path:"/list" method:"get" tags:"数据统计" summary:"获取统计列表" dc:"分页查询统计数据,支持按平台、日期范围筛选"` // 路由: GET /data/statistics/list - beans.Page - // 同时写json和p ,同时支持get的 query解析和post的json解析 - CustomerServicePlatform string `json:"customerServicePlatform" p:"customerServicePlatform" dc:"筛选:客服平台"` // 筛选:客服平台 - StartDate string `json:"startDate" p:"startDate" dc:"筛选:开始日期(YYYY-MM-DD)"` // 筛选:开始日期 - EndDate string `json:"endDate" p:"endDate" dc:"筛选:结束日期(YYYY-MM-DD)"` // 筛选:结束日期 -} - -type ListDataStatisticsRes struct { - List []*entity.DataStatistics `json:"list"` - Total int `json:"total"` -} - -// ExportDataStatisticsReq 导出请求复用列表请求(忽略分页参数) -type ExportDataStatisticsReq struct { - g.Meta `path:"/export" method:"get" tags:"数据统计" summary:"导出统计数据" dc:"导出数据统计为ZIP文件(包含TXT)"` // 路由: GET /data/statistics/export - // 同时写json和p,同时支持get的query解析和post的json解析 - CustomerServicePlatform string `json:"customerServicePlatform" p:"customerServicePlatform" dc:"筛选:客服平台"` // 筛选:客服平台 - StartDate string `json:"startDate" p:"startDate" dc:"筛选:开始日期(YYYY-MM-DD)"` // 筛选:开始日期 - EndDate string `json:"endDate" p:"endDate" dc:"筛选:结束日期(YYYY-MM-DD)"` // 筛选:结束日期 -} diff --git a/model/dto/product_dto.go b/model/dto/product_dto.go deleted file mode 100644 index 04314ea..0000000 --- a/model/dto/product_dto.go +++ /dev/null @@ -1,102 +0,0 @@ -// Package dto - 产品DTO -// 功能:产品的增删改查、导入/导出、绑定/解绑客服账号的请求响应结构体 -package dto - -import ( - "customer-server/model/entity" - - "gitea.com/red-future/common/beans" - "github.com/gogf/gf/v2/frame/g" -) - -// AddProductReq 添加产品 -type AddProductReq struct { - g.Meta `path:"/add" method:"post" tags:"产品管理" summary:"添加产品" dc:"创建新的产品信息"` // 路由: POST /product/add - Name string `json:"name" v:"required"` // 产品名称 - Description string `json:"description"` // 产品详情 -} - -type AddProductRes struct { - Id string `json:"id"` -} - -// UpdateProductReq 更新产品 -type UpdateProductReq struct { - g.Meta `path:"/update" method:"post" tags:"产品管理" summary:"更新产品" dc:"更新产品信息"` // 路由: POST /product/update - Id string `json:"id" v:"required"` // ID - Name string `json:"name"` // 产品名称 - Description string `json:"description"` // 产品详情 -} - -// DeleteProductReq 删除产品 -type DeleteProductReq struct { - g.Meta `path:"/delete" method:"post" tags:"产品管理" summary:"删除产品" dc:"删除指定产品"` // 路由: POST /product/delete - Id string `json:"id" v:"required"` // ID -} - -// GetProductReq 获取单个产品 -type GetProductReq struct { - g.Meta `path:"/one" method:"get" tags:"产品管理" summary:"获取产品详情" dc:"根据ID获取单个产品信息"` // 路由: GET /product/one - Id string `p:"id" v:"required"` // ID -} - -// ListProductReq 获取产品列表 -type ListProductReq struct { - g.Meta `path:"/list" method:"get" tags:"产品管理" summary:"获取产品列表" dc:"分页查询产品列表,支持按名称筛选"` // 路由: GET /product/list - beans.Page - Name string `p:"name"` // 筛选:产品名称 -} - -type ListProductRes struct { - List []*entity.Product `json:"list"` - Total int `json:"total"` -} - -// ExportProductReq 导出产品 -type ExportProductReq struct { - g.Meta `path:"/export" method:"get" tags:"产品管理" summary:"导出产品" dc:"导出产品为TXT格式的ZIP压缩包,支持按名称筛选" produces:"application/zip"` // 路由: GET /product/export - Name string `p:"name" dc:"筛选:产品名称(支持模糊查询,为空则导出所有)"` // 筛选:产品名称 -} - -type ExportProductRes struct { - // 实际返回的是文件流,这个结构体只是为了 Swagger 文档 -} - -// ImportProductReq 导入产品 -type ImportProductReq struct { - g.Meta `path:"/import" method:"post" tags:"产品管理" summary:"导入产品" dc:"上传ZIP文件批量导入产品(TXT格式)" consumes:"multipart/form-data"` // 路由: POST /product/import - File string `json:"file" type:"file" dc:"上传的ZIP文件" v:"required"` // ZIP文件 -} - -type ImportProductRes struct { - SuccessCount int `json:"successCount"` // 成功导入数量 - FailCount int `json:"failCount"` // 失败数量 - FailReasons []string `json:"failReasons"` // 失败原因列表 -} - -// BindProductReq 绑定产品到客服账号请求 -type BindProductReq struct { - g.Meta `path:"/bind" method:"post" tags:"产品管理" summary:"绑定产品" dc:"将产品绑定到客服账号"` - ProductId string `json:"productId" v:"required#产品ID不能为空"` - AccountNames []string `json:"accountNames" v:"required" dc:"客服账号名称列表"` -} - -// BindProductRes 绑定产品响应 -type BindProductRes struct { - SuccessCount int `json:"successCount"` // 成功绑定数量 - FailedIds []string `json:"failedIds"` // 失败的客服账号ID列表 - Message string `json:"message"` // 提示信息 -} - -// UnbindProductReq 解绑产品请求 -type UnbindProductReq struct { - g.Meta `path:"/unbind" method:"post" tags:"产品管理" summary:"解绑产品" dc:"将产品从客服账号解绑"` - ProductId string `json:"productId" v:"required#产品ID不能为空"` - AccountName string `json:"accountName" v:"required" dc:"客服账号名称"` -} - -// UnbindProductRes 解绑产品响应 -type UnbindProductRes struct { - Success bool `json:"success"` - Message string `json:"message"` -} diff --git a/model/dto/ragflow_chat_dto.go b/model/dto/ragflow_chat_dto.go deleted file mode 100644 index 090cbbe..0000000 --- a/model/dto/ragflow_chat_dto.go +++ /dev/null @@ -1,19 +0,0 @@ -// Package dto - RAGFlow对话配置DTO -// 功能:定义更新对话配置提示词的请求响应结构体 -package dto - -// UpdateChatPromptReq 更新对话配置提示词请求 -type UpdateChatPromptReq struct { - ChatId string `json:"chatId" v:"required#对话配置ID不能为空"` - Prompt string `json:"prompt" v:"required#提示词不能为空"` - SimilarityThreshold float64 `json:"similarityThreshold"` // 相似度阈值(0.0-1.0,默认0.2) - KeywordsSimilarityWeight float64 `json:"keywordsSimilarityWeight"` // 关键词权重(0.0-1.0,默认0.7) - TopN int `json:"topN"` // 返回chunk数量(默认8) - EmptyResponse string `json:"emptyResponse"` // 无匹配时回复 -} - -// UpdateChatPromptRes 更新对话配置提示词响应 -type UpdateChatPromptRes struct { - Success bool `json:"success"` - Message string `json:"message"` -} diff --git a/model/dto/ragflow_config_dto.go b/model/dto/ragflow_config_dto.go deleted file mode 100644 index 659aed5..0000000 --- a/model/dto/ragflow_config_dto.go +++ /dev/null @@ -1,41 +0,0 @@ -package dto - -import "github.com/gogf/gf/v2/frame/g" - -// GetRAGFlowConfigReq 获取RAGFlow配置请求 -type GetRAGFlowConfigReq struct { - g.Meta `path:"/get" method:"get" tags:"RAGFlow配置" summary:"获取RAGFlow配置"` - AccountName string `json:"accountName" v:"required" dc:"客服账号名称"` -} - -// GetRAGFlowConfigRes 获取RAGFlow配置响应 -type GetRAGFlowConfigRes struct { - AccountName string `json:"accountName"` - Platform string `json:"platform"` - DatasetId string `json:"datasetId"` - DatasetName string `json:"datasetName"` - ChatId string `json:"chatId"` - Prompt string `json:"prompt"` - DocumentIds []string `json:"documentIds"` - SimilarityThreshold float64 `json:"similarityThreshold"` - KeywordsSimilarityWeight float64 `json:"keywordsSimilarityWeight"` - TopN int `json:"topN"` - EmptyResponse string `json:"emptyResponse"` -} - -// UpdatePromptReq 更新提示词请求 -type UpdatePromptReq struct { - g.Meta `path:"/updatePrompt" method:"post" tags:"RAGFlow配置" summary:"更新提示词"` - AccountName string `json:"accountName" v:"required" dc:"客服账号名称"` - Prompt string `json:"prompt" v:"required" dc:"提示词内容"` - SimilarityThreshold *float64 `json:"similarityThreshold" dc:"相似度阈值"` - KeywordsSimilarityWeight *float64 `json:"keywordsSimilarityWeight" dc:"关键词权重"` - TopN *int `json:"topN" dc:"返回chunk数量"` - EmptyResponse *string `json:"emptyResponse" dc:"无匹配时回复"` -} - -// UpdatePromptRes 更新提示词响应 -type UpdatePromptRes struct { - Success bool `json:"success"` - Message string `json:"message"` -} diff --git a/model/dto/ragflow_sync_dto.go b/model/dto/ragflow_sync_dto.go deleted file mode 100644 index 0d6bec7..0000000 --- a/model/dto/ragflow_sync_dto.go +++ /dev/null @@ -1,29 +0,0 @@ -// Package dto - RAGFlow同步相关DTO -// 功能:定义RAGFlow同步、重试消息的请求响应结构体 -package dto - -// SyncToRAGFlowReq 同步到RAGFlow请求 -type SyncToRAGFlowReq struct { - SpeechcraftIds []string `json:"speechcraftIds" v:"required#话术ID列表不能为空"` // 话术ID列表 - ProductIds []string `json:"productIds"` // 产品ID列表(可选) - ForceSync bool `json:"forceSync"` // 是否强制重新同步(已同步的也重新上传) -} - -// SyncToRAGFlowRes 同步到RAGFlow响应 -type SyncToRAGFlowRes struct { - TotalCount int `json:"totalCount"` // 总数 - SuccessCount int `json:"successCount"` // 成功数量 - FailedCount int `json:"failedCount"` // 失败数量 - FailedIds []string `json:"failedIds"` // 失败的ID列表 - Message string `json:"message"` // 提示信息 -} - -// RAGFlowSyncRetryMsg RAGFlow同步重试消息(RabbitMQ) -// 说明:租户级知识库,dataset_id从租户配置中获取 -type RAGFlowSyncRetryMsg struct { - Type string `json:"type"` // speechcraft or product - Id string `json:"id"` // 话术/产品ID - AccountName string `json:"accountName"` // 客服账号名称 - TenantId string `json:"tenantId"` // 租户ID(用于查询知识库) - RetryCount int `json:"retryCount"` // 当前重试次数 -} diff --git a/model/dto/scripted_speech_dto.go b/model/dto/scripted_speech_dto.go index beb38c6..423680b 100644 --- a/model/dto/scripted_speech_dto.go +++ b/model/dto/scripted_speech_dto.go @@ -3,6 +3,8 @@ package dto import ( + "customer-server/consts/scriptedSpeech" + "gitea.com/red-future/common/beans" "github.com/gogf/gf/v2/frame/g" ) @@ -11,10 +13,9 @@ import ( type AddScriptedSpeechReq struct { g.Meta `path:"/add" method:"post" tags:"预制话术管理" summary:"添加预制话术" dc:"创建新的预制话术"` - AccountId int64 `json:"accountId" v:"required#账号ID不能为空" dc:"账号ID"` - DatasetId int64 `json:"datasetId" v:"required#数据集ID不能为空" dc:"数据集ID"` - QuestionContent string `json:"questionContent" v:"required#问题内容不能为空" dc:"问题内容"` - AnswerContent string `json:"answerContent" v:"required#回答内容不能为空" dc:"回答内容"` + DatasetId int64 `json:"datasetId" v:"required#数据集ID不能为空" dc:"数据集ID"` + SceneType scriptedSpeech.SceneType `json:"sceneType" v:"required#场景类型不能为空" dc:"场景类型"` + QuestionContent string `json:"questionContent" v:"required#问题内容不能为空" dc:"问题内容"` } type AddScriptedSpeechRes struct { @@ -26,10 +27,7 @@ type UpdateScriptedSpeechReq struct { g.Meta `path:"/update" method:"post" tags:"预制话术管理" summary:"更新预制话术" dc:"更新预制话术内容"` Id int64 `json:"id" v:"required#预制话术ID不能为空" dc:"预制话术ID"` - AccountId *int64 `json:"accountId" dc:"账号ID"` - DatasetId *int64 `json:"datasetId" dc:"数据集ID"` QuestionContent string `json:"questionContent" dc:"问题内容"` - AnswerContent string `json:"answerContent" dc:"回答内容"` } // DeleteScriptedSpeechReq 删除预制话术 @@ -50,9 +48,10 @@ type GetScriptedSpeechReq struct { type ListScriptedSpeechReq struct { g.Meta `path:"/list" method:"get" tags:"预制话术管理" summary:"获取预制话术列表" dc:"分页查询预制话术,支持按账号ID、数据集ID筛选"` - Page *beans.Page `json:"page"` - AccountId int64 `json:"accountId" dc:"账号ID"` - DatasetId int64 `json:"datasetId" dc:"数据集ID"` + Page *beans.Page `json:"page"` + DatasetId int64 `json:"datasetId" dc:"数据集ID"` + DatasetIds []int64 `json:"datasetIds" dc:"数据集ID列表"` + SceneType scriptedSpeech.SceneType `json:"sceneType" dc:"场景类型"` } type ListScriptedSpeechRes struct { @@ -62,12 +61,10 @@ type ListScriptedSpeechRes struct { // ScriptedSpeechVO 预制话术视图对象 type ScriptedSpeechVO struct { - Id int64 `json:"id,string"` - TenantId uint64 `json:"tenantId,string"` - AccountId int64 `json:"accountId,string"` - DatasetId int64 `json:"datasetId,string"` - QuestionContent string `json:"questionContent"` - AnswerContent string `json:"answerContent"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` + Id int64 `json:"id,string"` + DatasetId int64 `json:"datasetId,string"` + SceneType scriptedSpeech.SceneType `json:"sceneType"` + QuestionContent string `json:"questionContent"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` } diff --git a/model/dto/session_tool_dto.go b/model/dto/session_tool_dto.go new file mode 100644 index 0000000..74a0bc5 --- /dev/null +++ b/model/dto/session_tool_dto.go @@ -0,0 +1,63 @@ +package dto + +import ( + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +// RagListDatasetReq 数据集列表请求 +type RagListDatasetReq struct { + Ids []int64 `json:"ids" dc:"数据集ID列表"` +} + +// RagListDatasetRes 数据集列表响应 +type RagListDatasetRes struct { + List []*RagDatasetVO `json:"list"` + Total int `json:"total"` +} + +type RagDatasetVO struct { + Id int64 `json:"id,string" dc:"id"` + Name string `json:"name" dc:"数据集名称"` + Description string `json:"description" dc:"数据集描述"` +} + +// RagQueryReq RAG查询请求 +type RagQueryReq struct { + g.Meta `path:"/ragQuery" method:"post" tags:"RAG查询" summary:"执行RAG查询" dc:"执行RAG查询"` + + Content string `json:"content" v:"required#查询内容不能为空" dc:"用户问题"` + DatasetIds []int64 `json:"datasetIds" dc:"数据集ID"` + History []*Message `json:"history" dc:"历史对话"` + TopK int `json:"topK" d:"5" dc:"检索topK,默认5"` +} + +type Message struct { + Role string `json:"role"` + Content string `json:"content"` +} + +// RagQueryRes RAG查询响应 +type RagQueryRes struct { + Answer string `json:"answer" dc:"生成的答案"` +} + +type RAGListKeywordReq struct { + Words []string `json:"words"` +} + +// RAGListKeywordRes 关键词列表响应 +type RAGListKeywordRes struct { + List []*RAGKeywordVO `json:"list"` + Total int `json:"total"` +} + +type RAGKeywordVO struct { + Id int64 `json:"id,string" dc:"id"` + Word string `json:"word" dc:"关键词名称"` + Weight int16 `json:"weight" dc:"权重"` + DatasetId int64 `json:"datasetId,string" dc:"数据集ID"` + DocumentId int64 `json:"documentId,string" dc:"文档ID"` + CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` + UpdatedAt *gtime.Time `json:"updatedAt" dc:"更新时间"` +} diff --git a/model/dto/speechcraft_dto.go b/model/dto/speechcraft_dto.go deleted file mode 100644 index ab7357c..0000000 --- a/model/dto/speechcraft_dto.go +++ /dev/null @@ -1,101 +0,0 @@ -// Package dto - 话术DTO -// 功能:话术的增删改查、绑定/解绑客服账号的请求响应结构体 -package dto - -import ( - "customer-server/model/entity" - - "gitea.com/red-future/common/beans" - "github.com/gogf/gf/v2/frame/g" -) - -// AddSpeechcraftReq 添加话术 -type AddSpeechcraftReq struct { - g.Meta `path:"/add" method:"post" tags:"话术管理" summary:"添加话术" dc:"创建新的客服话术模板"` // 路由: POST /speechcraft/add - Tag string `json:"tag" v:"required"` // 标签 - Content string `json:"content" v:"required"` // 内容 - Direction string `json:"direction"` // 咨询方向(气血、减肥、护肤等) - AccountName string `json:"accountName,omitempty"` // 客服账号名称(可选,用于绕过gateway时查询tenantId) - TenantId interface{} `json:"tenantId,omitempty"` // 租户ID(可选,不传则使用默认值1) - - // 状态机字段 - Stage int `json:"stage"` // 触发阶段(0=初始) - Status string `json:"status"` // 触发行为(click/keyword/空=任意) - Keywords []string `json:"keywords"` // 触发关键字(空=任意) - NextStage int `json:"nextStage"` // 下一阶段(-1=结束) - Platform string `json:"platform"` // 平台(xiaohongshu) -} - -type AddSpeechcraftRes struct { - Id string `json:"id"` -} - -// UpdateSpeechcraftReq 更新话术 -type UpdateSpeechcraftReq struct { - g.Meta `path:"/update" method:"post" tags:"话术管理" summary:"更新话术" dc:"更新话术模板内容"` // 路由: POST /speechcraft/update - Id string `json:"id" v:"required"` // ID - Tag string `json:"tag"` // 标签 - Content string `json:"content"` // 内容 - - // 状态机字段 - Stage *int `json:"stage"` // 触发阶段 - Status *string `json:"status"` // 触发行为 - Keywords []string `json:"keywords"` // 触发关键字 - NextStage *int `json:"nextStage"` // 下一阶段 - Platform *string `json:"platform"` // 平台 -} - -// DeleteSpeechcraftReq 删除话术 -type DeleteSpeechcraftReq struct { - g.Meta `path:"/delete" method:"post" tags:"话术管理" summary:"删除话术" dc:"删除指定话术模板"` // 路由: POST /speechcraft/delete - Id string `json:"id" v:"required"` // ID -} - -// GetSpeechcraftReq 获取单个话术 -type GetSpeechcraftReq struct { - g.Meta `path:"/one" method:"get" tags:"话术管理" summary:"获取话术详情" dc:"根据ID获取单个话术模板"` // 路由: GET /speechcraft/one - Id string `json:"id" v:"required"` // ID -} - -// ListSpeechcraftReq 获取话术列表 -type ListSpeechcraftReq struct { - g.Meta `path:"/list" method:"get" tags:"话术管理" summary:"获取话术列表" dc:"分页查询话术模板,支持按标签、内容筛选"` // 路由: GET /speechcraft/list - beans.Page - Tag string `json:"tag"` // 筛选:标签 - Content string `json:"content"` // 筛选:内容 - Stage *int `json:"stage"` // 筛选:阶段 - Platform string `json:"platform"` // 筛选:平台 -} - -type ListSpeechcraftRes struct { - List []*entity.Speechcraft `json:"list"` - Total int `json:"total"` -} - -// BindSpeechcraftReq 绑定话术到客服账号请求 -type BindSpeechcraftReq struct { - g.Meta `path:"/bind" method:"post" tags:"话术管理" summary:"绑定话术" dc:"将话术绑定到客服账号"` - SpeechcraftId string `json:"speechcraftId" v:"required#话术ID不能为空"` - AccountNames []string `json:"accountNames" v:"required#客服账号名称列表不能为空"` -} - -// BindSpeechcraftRes 绑定话术响应 -type BindSpeechcraftRes struct { - SuccessCount int `json:"successCount"` // 成功绑定数量 - AlreadyBound []string `json:"alreadyBound"` // 已绑定的客服账号ID - NotFound []string `json:"notFound"` // 不存在的客服账号ID - Message string `json:"message"` // 提示信息 -} - -// UnbindSpeechcraftReq 解绑话术请求 -type UnbindSpeechcraftReq struct { - g.Meta `path:"/unbind" method:"post" tags:"话术管理" summary:"解绑话术" dc:"将话术从客服账号解绑"` - SpeechcraftId string `json:"speechcraftId" v:"required#话术ID不能为空"` - AccountName string `json:"accountName" v:"required#客服账号名称不能为空"` -} - -// UnbindSpeechcraftRes 解绑话术响应 -type UnbindSpeechcraftRes struct { - Success bool `json:"success"` - Message string `json:"message"` -} diff --git a/model/dto/webhook_dto.go b/model/dto/webhook_dto.go deleted file mode 100644 index aa38f0a..0000000 --- a/model/dto/webhook_dto.go +++ /dev/null @@ -1,51 +0,0 @@ -package dto - -import "github.com/gogf/gf/v2/frame/g" - -// WebhookReceiveReq 接收平台消息请求 -type WebhookReceiveReq struct { - g.Meta `path:"/:platform" method:"post" tags:"Webhook" summary:"接收平台消息" dc:"接收小红书/抖音等平台推送的用户消息"` - Platform string `p:"platform" v:"required#平台标识不能为空" dc:"平台标识(xiaohongshu/douyin/kuaishou)"` - UserId string `json:"userId" v:"required#用户ID不能为空" dc:"平台用户ID"` - AccountId string `json:"accountId" dc:"客服账号ID"` - Content string `json:"content" v:"required#消息内容不能为空" dc:"消息内容"` - MsgId string `json:"msgId" dc:"平台消息ID(用于去重)"` - Timestamp int64 `json:"timestamp" dc:"消息时间戳"` -} - -type WebhookReceiveRes struct { - Success bool `json:"success" dc:"是否成功"` - MsgId string `json:"msg_id,omitempty" dc:"消息ID"` -} - -// WebhookCallbackReq 平台回调验证请求(部分平台需要) -type WebhookCallbackReq struct { - g.Meta `path:"/:platform/verify" method:"get" tags:"Webhook" summary:"回调验证" dc:"平台回调验证(部分平台接入时需要)"` - Platform string `p:"platform" dc:"平台标识"` - Signature string `p:"signature" dc:"签名"` - Timestamp string `p:"timestamp" dc:"时间戳"` - Nonce string `p:"nonce" dc:"随机数"` - Echostr string `p:"echostr" dc:"回显字符串"` -} - -type WebhookCallbackRes struct { - g.Meta `mime:"text/plain"` -} - -// ConversationListReq 对话记录查询请求 -type ConversationListReq struct { - g.Meta `path:"/history" method:"get" tags:"Webhook" summary:"查询对话记录" dc:"查询用户历史对话记录"` - UserId string `p:"user_id" v:"required#用户ID不能为空" dc:"用户ID"` - Limit int64 `p:"limit" d:"20" dc:"返回数量(默认20)"` -} - -type ConversationListRes struct { - List []*ConversationItem `json:"list" dc:"对话记录列表"` -} - -type ConversationItem struct { - Question string `json:"question" dc:"用户问题"` - Answer string `json:"answer" dc:"AI回复"` - MsgTime string `json:"msgTime" dc:"消息时间"` - SessionId string `json:"sessionId" dc:"会话ID"` -} diff --git a/model/dto/xiaohongshu_dto.go b/model/dto/xiaohongshu_dto.go deleted file mode 100644 index 2acf66b..0000000 --- a/model/dto/xiaohongshu_dto.go +++ /dev/null @@ -1,427 +0,0 @@ -package dto - -// ==================== 通用响应结构 ==================== - -type XhsCommonRes struct { - Code int `json:"code"` - Msg string `json:"msg"` - Success bool `json:"success"` -} - -// ==================== 绑定/解绑账户通知(接收) ==================== - -type XhsBindAccountReq struct { - Content string `json:"content" v:"required"` // 加密后内容 -} - -type XhsBindAccountDecrypted struct { - UserId string `json:"user_id"` - NickName string `json:"nick_name"` - AppId int64 `json:"app_id"` - Token string `json:"token"` -} - -type XhsUnbindAccountReq struct { - Content string `json:"content" v:"required"` // 加密后内容 -} - -type XhsUnbindAccountDecrypted struct { - UserId string `json:"user_id"` - AppId int64 `json:"app_id"` - AccountCode string `json:"account_code"` -} - -type XhsBindKosUserReq struct { - Content string `json:"content" v:"required"` // 加密后内容 -} - -// KOS账户绑定事件 -type XhsBindKosUserDecrypted struct { - UserId string `json:"user_id"` - AuthStatus int `json:"auth_status"` // 2-已生效,4-已取消 - KosNickName string `json:"kos_nick_name"` - KosUserId string `json:"kos_user_id"` - KosAvatarImg string `json:"kos_avatar_img"` -} - -// ==================== 查询KOS授权绑定列表(发送) ==================== - -type XhsQueryBindUsersReq struct { - UserId string `json:"user_id" v:"required"` - PageNum int `json:"page_num" v:"required"` - PageSize int `json:"page_size" v:"required"` -} - -type XhsQueryBindUsersRes struct { - Code int `json:"code"` - Msg string `json:"msg"` - Success bool `json:"success"` - Data struct { - Total int64 `json:"total"` - KosUserList []struct { - UserId string `json:"user_id"` - NickName string `json:"nick_name"` - AvatarImg string `json:"avatar_img"` - } `json:"kos_user_list"` - } `json:"data"` -} - -// ==================== 发送消息接口(发送) ==================== - -type XhsSendMessageReq struct { - UserId string `json:"user_id" v:"required"` - RequestId string `json:"request_id" v:"required"` - MessageType string `json:"message_type" v:"required"` // TEXT/IMAGE/VIDEO/CARD/REVOKE - FromUserId string `json:"from_user_id" v:"required"` - ToUserId string `json:"to_user_id" v:"required"` - ThirdAccountId string `json:"third_account_id" v:"required"` - Timestamp int64 `json:"timestamp" v:"required"` - Content string `json:"content" v:"required"` // 加密后内容 -} - -type XhsSendMessageRes struct { - Code int `json:"code"` - Msg string `json:"msg"` - Success bool `json:"success"` - Data struct { - RequestId string `json:"request_id"` - MessageId string `json:"message_id"` - } `json:"data"` -} - -// 消息内容解密后结构 -type XhsTextContent struct { - Text string `json:"text"` -} - -type XhsImageContent struct { - Link string `json:"link"` - Size struct { - Width int `json:"width"` - Height int `json:"height"` - } `json:"size"` -} - -type XhsVideoContent struct { - Duration int64 `json:"duration"` - VideoSize int64 `json:"video_size"` - Cover string `json:"cover"` - Link string `json:"link"` - Size struct { - Width int `json:"width"` - Height int `json:"height"` - } `json:"size"` -} - -// 通用卡片内容(用于switch分支解析) -type XhsCardContent struct { - Id string `json:"id"` - ContentType string `json:"content_type"` -} - -type XhsCardNoteContent struct { - NoteId string `json:"note_id"` - ContentType string `json:"content_type"` // note -} - -type XhsCardPageContent struct { - PageId string `json:"page_id"` - ContentType string `json:"content_type"` // common -} - -type XhsCardCommentContent struct { - CommentId string `json:"comment_id"` - Content string `json:"content"` - ContentType string `json:"content_type"` // purchaseComments -} - -type XhsCardSocialContent struct { - Id string `json:"id"` - ContentType string `json:"content_type"` // social_card -} - -type XhsCardLeadContent struct { - Id string `json:"id"` - ContentType string `json:"content_type"` // lead_card -} - -type XhsCardTradeContent struct { - Id string `json:"id"` - ContentType string `json:"content_type"` // tradeBusinessCard -} - -type XhsRevokeContent struct { - MessageId string `json:"message_id"` -} - -// ==================== 接收消息接口(接收Webhook) ==================== - -type XhsReceiveMessageReq struct { - MessageId string `json:"message_id" v:"required"` - MessageType string `json:"message_type" v:"required"` // TEXT/IMAGE/VIDEO/CARD/HINT/REVOKE/SMILES - MessageSource int `json:"message_source" v:"required"` // 1-C2B用户,2-C2B系统,3-B2C系统 - FromUserId string `json:"from_user_id" v:"required"` - ToUserId string `json:"to_user_id" v:"required"` - Timestamp int64 `json:"timestamp" v:"required"` - Content string `json:"content" v:"required"` // 加密后内容 - UserInfo []XhsReceiveMessageUser `json:"user_info"` -} - -type XhsReceiveMessageUser struct { - UserId string `json:"user_id"` - Nickname string `json:"nickname"` - HeaderImage string `json:"header_image"` -} - -// 接收消息-卡片内容(笔记) -type XhsReceiveCardNoteContent struct { - Title string `json:"title"` - Cover string `json:"cover"` - Link string `json:"link"` - ContentType string `json:"content_type"` // note - UserInfo struct { - Nickname string `json:"nickname"` - HeaderImage string `json:"header_image"` - } `json:"user_info"` -} - -// 接收消息-卡片内容(落地页) -type XhsReceiveCardPageContent struct { - Title string `json:"title"` - Cover string `json:"cover"` - Link string `json:"link"` - Desc string `json:"desc"` - ContentType string `json:"content_type"` // common -} - -// 接收消息-卡片内容(名片) -type XhsReceiveCardSocialContent struct { - Id string `json:"id"` - Title string `json:"title"` - Name string `json:"name"` - Image string `json:"image"` - ContentType string `json:"content_type"` // social_card -} - -// 接收消息-卡片内容(留资卡) -type XhsReceiveCardLeadContent struct { - Id string `json:"id"` - Title string `json:"title"` - Name string `json:"name"` - Image string `json:"image"` - ContentType string `json:"content_type"` // lead_card -} - -// 接收消息-卡片内容(交易卡) -type XhsReceiveCardTradeContent struct { - Id string `json:"id"` - Title string `json:"title"` - SubTitle string `json:"sub_title"` - Image string `json:"image"` - LinkPlatform string `json:"link_platform"` // wx_mini/ctrip/meituan - ContentType string `json:"content_type"` // tradeBusinessCard -} - -// 接收消息-HINT内容 -type XhsReceiveHintContent struct { - Text string `json:"text"` -} - -// ==================== 查询物料接口(发送) ==================== - -// 查询落地页列表 -type XhsQueryPageListReq struct { - UserId string `json:"user_id" v:"required"` - PageNum int `json:"page_num" v:"required"` - PageSize int `json:"page_size" v:"required"` -} - -type XhsQueryPageListRes struct { - Code int `json:"code"` - Msg string `json:"msg"` - Success bool `json:"success"` - Data struct { - Total int64 `json:"total"` - List []struct { - Title string `json:"title"` - CreateTime int64 `json:"create_time"` - PageId string `json:"page_id"` - PageDesc string `json:"page_desc"` - PageUrl string `json:"page_url"` - Cover string `json:"cover"` - } `json:"list"` - } `json:"data"` -} - -// 查询名片/留资卡/交易卡列表 -type XhsQueryMaterialListReq struct { - UserId string `json:"user_id" v:"required"` - PageNum int `json:"page_num" v:"required"` - PageSize int `json:"page_size" v:"required"` - Type int `json:"type" v:"required"` // 4-名片 5-留资卡 7-交易卡 -} - -type XhsQueryMaterialListRes struct { - Code int `json:"code"` - Msg string `json:"msg"` - Success bool `json:"success"` - Data struct { - Total int64 `json:"total"` - List []struct { - Id string `json:"id"` - Name string `json:"name"` - Title string `json:"title"` - Image string `json:"image"` - Ext struct { - SocialCardExt *struct { - CardType int `json:"card_type"` // 1-微信、2-钉钉、3-电话、4-企微 5-留资卡 - } `json:"social_card_ext,omitempty"` - TradeCardExt *struct { - SubTitle string `json:"sub_title"` - LinkPlatform string `json:"link_platform"` // ctrip/meituan/wx_mini - } `json:"trade_card_ext,omitempty"` - } `json:"ext"` - } `json:"list"` - } `json:"data"` -} - -// 查询笔记列表 -type XhsQueryNoteListReq struct { - UserId string `json:"user_id" v:"required"` - NoteId string `json:"note_id,omitempty"` - PageNum int `json:"page_num" v:"required"` - PageSize int `json:"page_size" v:"required"` -} - -type XhsQueryNoteListRes struct { - Code int `json:"code"` - Msg string `json:"msg"` - Success bool `json:"success"` - Data struct { - Total int64 `json:"total"` - List []struct { - NoteId string `json:"note_id"` - PublishTime int64 `json:"publish_time"` - Link string `json:"link"` - Title string `json:"title"` - Cover string `json:"cover"` - UserInfo struct { - Nickname string `json:"nickname"` - HeaderImage string `json:"header_image"` - } `json:"user_info"` - } `json:"list"` - } `json:"data"` -} - -// 查询意向评论列表 -type XhsQueryCommentListReq struct { - UserId string `json:"user_id" v:"required"` - PageNum int `json:"page_num" v:"required"` - PageSize int `json:"page_size" v:"required"` - BeginTime string `json:"begin_time,omitempty"` - EndTime string `json:"end_time,omitempty"` -} - -type XhsQueryCommentListRes struct { - Code int `json:"code"` - Msg string `json:"msg"` - Success bool `json:"success"` - Data struct { - Total int64 `json:"total"` - List []struct { - NoteId string `json:"note_id"` - Cover string `json:"cover"` - NoteTitle string `json:"note_title"` - NoteAuthorUserId string `json:"note_author_user_id"` - CommentContent string `json:"comment_content"` - CommentTime int64 `json:"comment_time"` - CommentUserName string `json:"comment_user_name"` - CommentUserId string `json:"comment_user_id"` - CommentId string `json:"comment_id"` - UniqId string `json:"uniq_id"` - ReplyState int `json:"reply_state"` - ReplyThirdAccountId string `json:"reply_third_account_id"` - } `json:"list"` - } `json:"data"` -} - -// ==================== 意向评论推送(接收Webhook) ==================== - -type XhsIntentCommentPushReq struct { - NoteId string `json:"note_id"` - Cover string `json:"cover"` - NoteTitle string `json:"note_title"` - NoteAuthorUserId string `json:"note_author_user_id"` - CommentContent string `json:"comment_content"` - CommentTime int64 `json:"comment_time"` - CommentUserName string `json:"comment_user_name"` - CommentUserId string `json:"comment_user_id"` - CommentId string `json:"comment_id"` - UniqId string `json:"uniq_id"` - ReplyState int `json:"reply_state"` - ReplyThirdAccountId string `json:"reply_third_account_id"` -} - -// ==================== 留资数据回传(发送) ==================== - -type XhsBackLeadResultReq struct { - UserId string `json:"user_id" v:"required"` - RequestId string `json:"request_id" v:"required"` - BrandUserId string `json:"brand_user_id" v:"required"` - CUserId string `json:"c_user_id" v:"required"` - ThirdBackSource string `json:"third_back_source" v:"required"` - Timestamp int64 `json:"timestamp" v:"required"` - Content string `json:"content" v:"required"` // 加密后内容 -} - -type XhsBackLeadResultDecrypted struct { - PhoneNum string `json:"phone_num"` - Wechat string `json:"wechat"` - Remark string `json:"remark"` - Area string `json:"area"` - City string `json:"city"` - CustomerServiceName string `json:"customer_service_name"` - Time int64 `json:"time"` - ExtList []struct { - Key string `json:"key"` - Name string `json:"name"` - Value string `json:"value"` - } `json:"extList"` -} - -type XhsBackLeadResultRes struct { - Code int `json:"code"` - Msg string `json:"msg"` - Success bool `json:"success"` - Data struct { - RequestId string `json:"request_id"` - } `json:"data"` -} - -// ==================== 留资和广告归因数据推送(接收Webhook) ==================== - -type XhsPushLeadReq struct { - UserId string `json:"user_id"` - BrandUserId string `json:"brand_user_id"` - KosUserId string `json:"kos_user_id"` - ConvTime string `json:"conv_time"` - AdvertiserName string `json:"advertiser_name"` - AdvertiserId string `json:"advertiser_id"` - CampaignName string `json:"campaign_name"` - CampaignId string `json:"campaign_id"` - CreativityName string `json:"creativity_name"` - CreativityId string `json:"creativity_id"` - LeadsTag string `json:"leads_tag"` - Area string `json:"area"` - PhoneNum string `json:"phone_num"` - Wechat string `json:"wechat"` - Remark string `json:"remark"` - PushType int `json:"push_type"` // 1-进线 2-开口 3-留资 4-留资归因 - WechatCopy int `json:"wechat_copy"` - LinkId string `json:"link_id"` - LinkName string `json:"link_name"` - CustomerChannel string `json:"customer_channel"` - MsgAppOpen int `json:"msg_app_open"` - WechatType int `json:"wechat_type"` // 1-文本 2-图片链接 - DecryptUserId string `json:"decrypt_user_id"` -} diff --git a/model/entity/account.go b/model/entity/account.go index a6ef8d8..b98c263 100644 --- a/model/entity/account.go +++ b/model/entity/account.go @@ -11,10 +11,11 @@ type accountCol struct { DatasetIds string DocumentIds string SpeechcraftIds string + AccountCode string AccountName string Status string Greeting string - Prompt string + KeywordOption string SelfIdentity string Platform string AccessToken string @@ -31,10 +32,11 @@ var AccountCol = accountCol{ DatasetIds: "dataset_ids", DocumentIds: "document_ids", SpeechcraftIds: "speechcraft_ids", + AccountCode: "account_code", AccountName: "account_name", Status: "status", Greeting: "greeting", - Prompt: "prompt", + KeywordOption: "keyword_option", SelfIdentity: "self_identity", Platform: "platform", AccessToken: "access_token", @@ -49,15 +51,16 @@ var AccountCol = accountCol{ type Account struct { beans.SQLBaseDO `orm:",inline"` - DatasetIds []int64 `orm:"dataset_ids" json:"datasetIds" dc:"绑定的数据集ID列表"` - DocumentIds []int64 `orm:"document_ids" json:"documentIds" dc:"绑定的文档ID列表"` - AccountName string `orm:"account_name" json:"accountName" dc:"客服账号名称"` - Status account.Status `orm:"status" json:"status" dc:"客服账号状态"` - Platform account.Platform `orm:"platform" json:"platform" dc:"客服平台"` - Greeting string `orm:"greeting" json:"greeting" dc:"开场白"` - Prompt []string `orm:"prompt" json:"prompt" dc:"提示词"` - SelfIdentity string `orm:"self_identity" json:"selfIdentity" dc:"AI身份描述"` - ExpandData *AccountExpandData `orm:"expand_data" json:"expandData" description:"扩展数据(JSONB)"` + DatasetIds []int64 `orm:"dataset_ids" json:"datasetIds" dc:"绑定的数据集ID列表"` + DocumentIds []int64 `orm:"document_ids" json:"documentIds" dc:"绑定的文档ID列表"` + AccountCode string `orm:"account_code" json:"accountCode" dc:"客服账号编码"` + AccountName string `orm:"account_name" json:"accountName" dc:"客服账号名称"` + Status account.Status `orm:"status" json:"status" dc:"客服账号状态"` + Platform account.Platform `orm:"platform" json:"platform" dc:"客服平台"` + Greeting string `orm:"greeting" json:"greeting" dc:"开场白"` + KeywordOption []string `orm:"keyword_option" json:"keywordOption" dc:"关键词选项"` + SelfIdentity string `orm:"self_identity" json:"selfIdentity" dc:"AI身份描述"` + ExpandData *AccountExpandData `orm:"expand_data" json:"expandData" description:"扩展数据(JSONB)"` } type AccountExpandData struct { diff --git a/model/entity/account_user_dialog.go b/model/entity/account_user_dialog.go new file mode 100644 index 0000000..83e6bc7 --- /dev/null +++ b/model/entity/account_user_dialog.go @@ -0,0 +1,27 @@ +package entity + +import ( + "gitea.com/red-future/common/beans" +) + +type accountUserDialogCol struct { + beans.SQLBaseCol + AccountId string + UserId string + DialogCount string +} + +var AccountUserDialogCol = accountUserDialogCol{ + SQLBaseCol: beans.DefSQLBaseCol, + AccountId: "account_id", + UserId: "user_id", + DialogCount: "dialog_count", +} + +type AccountUserDialog struct { + beans.SQLBaseDO `orm:",inline"` + + AccountId int64 `orm:"account_id" json:"accountId" dc:"客服ID"` + UserId string `orm:"user_id" json:"userId" dc:"用户ID"` + DialogCount int64 `orm:"dialog_count" json:"dialogCount" dc:"对话次数"` +} diff --git a/model/entity/conversation.go b/model/entity/conversation.go deleted file mode 100644 index 04c6fd8..0000000 --- a/model/entity/conversation.go +++ /dev/null @@ -1,27 +0,0 @@ -package entity - -import ( - "time" - - "gitea.com/red-future/common/beans" -) - -const ConversationCollection = "conversation" - -// Conversation 对话记录实体 -type Conversation struct { - beans.MongoBaseDO `bson:",inline"` // 嵌入基础字段 - - // 业务字段 - UserId string `bson:"userId" json:"userId"` // 用户ID(含平台前缀) - Platform string `bson:"platform" json:"platform"` // 平台标识 - SessionId string `bson:"sessionId" json:"sessionId"` // RAGFlow Session ID - CustomerServiceId string `bson:"customerServiceId" json:"customerServiceId"` // 客服账号ID - Role string `bson:"role" json:"role"` // 角色:user/assistant - Content string `bson:"content" json:"content"` // 消息内容 - Question string `bson:"question" json:"question"` // 用户问题(兼容旧字段) - Answer string `bson:"answer" json:"answer"` // AI 回复(兼容旧字段) - MessageId string `bson:"messageId" json:"messageId"` // 消息ID - MessageType string `bson:"messageType" json:"messageType"` // 消息类型(TEXT/IMAGE/VIDEO等) - MsgTime *time.Time `bson:"msgTime" json:"msgTime"` // 消息时间 -} diff --git a/model/entity/conversation_archive.go b/model/entity/conversation_archive.go deleted file mode 100644 index 7d577a7..0000000 --- a/model/entity/conversation_archive.go +++ /dev/null @@ -1,71 +0,0 @@ -package entity - -import ( - "time" - - "gitea.com/red-future/common/beans" -) - -// ConversationArchiveTempCollection 归档临时表名 -const ConversationArchiveTempCollection = "conversation_archive_temp" - -// ConversationArchiveTemp 归档临时表实体(与 Conversation 结构相同) -type ConversationArchiveTemp struct { - beans.MongoBaseDO `bson:",inline"` - - UserId string `bson:"userId" json:"userId"` - Platform string `bson:"platform" json:"platform"` - SessionId string `bson:"sessionId" json:"sessionId"` - Question string `bson:"question" json:"question"` - Answer string `bson:"answer" json:"answer"` - MessageId string `bson:"messageId" json:"messageId"` - MsgTime *time.Time `bson:"msgTime" json:"msgTime"` // 指针类型,与MongoBaseDO一致 - - // 原始文档 ID,用于删除原表数据 - OriginalId string `bson:"originalId" json:"originalId"` -} - -// ConversationES ES 索引文档结构 -type ConversationES struct { - Id string `json:"id"` // MongoDB 原始 ID - UserId string `json:"userId"` // 用户ID - Platform string `json:"platform"` // 平台 - SessionId string `json:"sessionId"` // Session ID - Question string `json:"question"` // 问题 - Answer string `json:"answer"` // 回复 - MessageId string `json:"messageId"` // 消息ID - MsgTime time.Time `json:"msgTime"` // 消息时间 - TenantId string `json:"tenantId"` // 租户ID - CreatedAt time.Time `json:"createdAt"` // 创建时间 - UpdatedAt time.Time `json:"updatedAt"` // 更新时间 - ArchivedAt time.Time `json:"archivedAt"` // 归档时间 -} - -// ConversationESMapping ES 索引映射 -const ConversationESMapping = ` -{ - "settings": { - "number_of_shards": 3, - "number_of_replicas": 1 - }, - "mappings": { - "properties": { - "id": { "type": "keyword" }, - "userId": { "type": "keyword" }, - "platform": { "type": "keyword" }, - "sessionId": { "type": "keyword" }, - "question": { "type": "text", "analyzer": "standard" }, - "answer": { "type": "text", "analyzer": "standard" }, - "messageId": { "type": "keyword" }, - "msgTime": { "type": "date" }, - "tenantId": { "type": "keyword" }, - "createdAt": { "type": "date" }, - "updatedAt": { "type": "date" }, - "archivedAt": { "type": "date" } - } - } -} -` - -// ConversationESIndex ES 索引名 -const ConversationESIndex = "conversations" diff --git a/model/entity/customer_service_account.go b/model/entity/customer_service_account.go deleted file mode 100644 index b9d9f5a..0000000 --- a/model/entity/customer_service_account.go +++ /dev/null @@ -1,33 +0,0 @@ -package entity - -import ( - "gitea.com/red-future/common/beans" -) - -// CustomerServiceAccount 客服账号实体 -const CustomerServiceAccountCollection = "customer_service_account" - -type CustomerServiceAccount struct { - beans.MongoBaseDO `bson:",inline"` // 嵌入基础字段:Id, Creator, CreatedAt, Updater, UpdatedAt, TenantId, IsDeleted - - // 业务字段 - AccountName string `bson:"accountName" json:"accountName"` // 客服账号名称(如cs_xhs_qixue) - IsDisabled bool `bson:"isDisabled" json:"isDisabled"` // 是否禁用(true-禁用,false-启用) - Greeting string `bson:"greeting" json:"greeting"` // 开场白(WebSocket连接时自动发送) - Prompt string `bson:"-" json:"prompt"` // 提示词(不存储到MongoDB,查询时从ragflow_config关联获取) - SelfIdentity string `bson:"selfIdentity" json:"selfIdentity"` // AI身份描述(前端显示为"AI身份") - - // 绑定的资源(主体持有关系) - SpeechcraftIds []string `bson:"speechcraftIds" json:"speechcraftIds"` // 绑定的话术ID列表 - ProductIds []string `bson:"productIds" json:"productIds"` // 绑定的产品ID列表 - Platform string `bson:"platform" json:"platform"` // 客服平台(如:xiaohongshu、douyin、kuaishou) - - // 小红书平台专属字段(仅platform=xiaohongshu时有效) - AccessToken string `bson:"accessToken,omitempty" json:"accessToken,omitempty"` // 小红书AccessToken(14天有效期) - AppId int64 `bson:"appId,omitempty" json:"appId,omitempty"` // 小红书应用ID - SecretKey string `bson:"secretKey,omitempty" json:"secretKey,omitempty"` // 小红书加解密密钥 - XhsUserId string `bson:"xhsUserId,omitempty" json:"xhsUserId,omitempty"` // 小红书用户ID - ContactCardMessage string `bson:"contactCardMessage,omitempty" json:"contactCardMessage,omitempty"` // 留资卡文案(不同领域客服使用不同文案) - NameCardMessage string `bson:"nameCardMessage,omitempty" json:"nameCardMessage,omitempty"` // 名片文案(不同领域客服使用不同文案) - CardTriggerCount int `bson:"cardTriggerCount,omitempty" json:"cardTriggerCount,omitempty"` // 卡片触发次数(默认5,不同客服可定制) -} diff --git a/model/entity/data.go b/model/entity/data.go deleted file mode 100644 index 45b9518..0000000 --- a/model/entity/data.go +++ /dev/null @@ -1,25 +0,0 @@ -package entity - -import ( - "gitea.com/red-future/common/beans" -) - -const DataCollection = "data" - -type Data struct { - beans.MongoBaseDO `bson:",inline"` // 嵌入基础字段:Id, Creator, CreatedAt, Updater, UpdatedAt, TenantId, IsDeleted - - // 业务字段 - CustomerId string `bson:"customerId" json:"customerId"` // 客户ID - AccountName string `bson:"accountName" json:"accountName"` // 客服账号名称 - CustomerServicePlatform string `bson:"customerServicePlatform" json:"customerServicePlatform"` // 客服平台 - CustomerServiceName string `bson:"customerServiceName" json:"customerServiceName"` // 客服名称 - IsInbound bool `bson:"isInbound" json:"isInbound"` // 用户是否点开了客服页面 - IsActive bool `bson:"isActive" json:"isActive"` // 用户是否开口询问 - IsServed bool `bson:"isServed" json:"isServed"` // 客服是否回答了用户 - HasSentContactCard bool `bson:"hasSentContactCard" json:"hasSentContactCard"` // 客服是否发送了联系卡 - HasSentNameCard bool `bson:"hasSentNameCard" json:"hasSentNameCard"` // 客服是否发送了名称卡 - HasLeftContactInfo bool `bson:"hasLeftContactInfo" json:"hasLeftContactInfo"` // 用户是否留下了联系信息 - SessionStartTime int64 `bson:"sessionStartTime" json:"sessionStartTime"` // 业务数据的时间 - MessageTime int64 `bson:"messageTime" json:"messageTime"` // 消息时间 -} diff --git a/model/entity/data_statistics.go b/model/entity/data_statistics.go deleted file mode 100644 index 5925ae9..0000000 --- a/model/entity/data_statistics.go +++ /dev/null @@ -1,30 +0,0 @@ -package entity - -import ( - "time" - - "gitea.com/red-future/common/beans" -) - -const DataStatisticsCollection = "data_statistics" - -// DataStatistics 数据统计实体 (用于存储每天/每位客服的汇总数据) -type DataStatistics struct { - beans.MongoBaseDO `bson:",inline"` // 嵌入基础字段:Id, Creator, CreatedAt, Updater, UpdatedAt, TenantId, IsDeleted - Date *time.Time `bson:"date" json:"date"` // 统计日期 (格式: YYYY-MM-DD) - AccountName string `bson:"accountName" json:"accountName"` // 客服账号名称 - CustomerServiceName string `bson:"customerServiceName" json:"customerServiceName"` // 客服名称 - CustomerServicePlatform string `bson:"customerServicePlatform" json:"customerServicePlatform"` // 客服平台 - - // 以下字段由 Data 表中的 bool 字段累加统计得出 - InboundCount int `bson:"inboundCount" json:"inboundCount"` // 进线人数 (汇总 IsInbound) - ActiveCount int `bson:"activeCount" json:"activeCount"` // 开口人数 (汇总 IsActive) - ServedCount int `bson:"servedCount" json:"servedCount"` // 接待人数 (汇总 IsServed) - ContactCardSentCount int `bson:"contactCardSentCount" json:"contactCardSentCount"` // 留资卡发送数量 (汇总 HasSentContactCard) - NameCardSentCount int `bson:"nameCardSentCount" json:"nameCardSentCount"` // 名片发送数量 (汇总 HasSentNameCard) - LeftContactInfoCount int `bson:"leftContactInfoCount" json:"leftContactInfoCount"` // 留资人数 (汇总 HasLeftContactInfo) - - ResponseRate30s float64 `bson:"responseRate30s" json:"responseRate30s"` // 30秒响应率 - ResponseRate60s float64 `bson:"responseRate60s" json:"responseRate60s"` // 60秒响应率 - ResponseRate360s float64 `bson:"responseRate360s" json:"responseRate360s"` // 360秒响应率 -} diff --git a/model/entity/product.go b/model/entity/product.go deleted file mode 100644 index 0f14f62..0000000 --- a/model/entity/product.go +++ /dev/null @@ -1,25 +0,0 @@ -// Package entity - 产品实体 -// 功能:定义产品表结构,支持多客服账号绑定、RAGFlow同步记录 -package entity - -import ( - "gitea.com/red-future/common/beans" -) - -// Product 产品实体 -const ProductCollection = "product" - -type Product struct { - beans.MongoBaseDO `bson:",inline"` // 嵌入基础字段:Id, Creator, CreatedAt, Updater, UpdatedAt, TenantId, IsDeleted - - // 业务字段 - Name string `bson:"name" json:"name"` // 产品名称 - Description string `bson:"description" json:"description"` // 产品详情 - - // 客服账号绑定(一个产品可以被多个账号使用) - AccountNames []string `bson:"accountNames" json:"accountNames"` // 绑定的客服账号名称列表 - - // RAGFlow同步字段(租户级知识库,每个客服账号独立同步记录) - RagSyncRecords []RagSyncRecord `bson:"ragSyncRecords" json:"ragSyncRecords"` // RAGFlow同步记录(按客服账号) - RagLastSyncTime string `bson:"ragLastSyncTime" json:"ragLastSyncTime"` // 最后同步时间 -} diff --git a/model/entity/ragflow_binding.go b/model/entity/ragflow_binding.go deleted file mode 100644 index ad4f065..0000000 --- a/model/entity/ragflow_binding.go +++ /dev/null @@ -1,13 +0,0 @@ -// Package entity - RAGFlow同步相关实体 -// 功能:RAGFlow同步记录(话术和产品共用) -package entity - -// RagSyncRecord RAGFlow同步记录 -// 说明:租户级知识库,每个客服账号独立的文档ID -type RagSyncRecord struct { - AccountName string `bson:"accountName" json:"accountName"` // 客服账号名称 - RagDocumentId string `bson:"ragDocumentId" json:"ragDocumentId"` // RAGFlow文档ID(租户级知识库中的文档) - RagSyncStatus string `bson:"ragSyncStatus" json:"ragSyncStatus"` // 同步状态:synced/pending/failed - SyncTime string `bson:"syncTime" json:"syncTime"` // 同步时间 - RetryCount int `bson:"retryCount" json:"retryCount"` // 重试次数 -} diff --git a/model/entity/ragflow_config.go b/model/entity/ragflow_config.go deleted file mode 100644 index 6bb0ae0..0000000 --- a/model/entity/ragflow_config.go +++ /dev/null @@ -1,46 +0,0 @@ -// Package entity - RAGFlow配置实体 -// 功能:客服账号级RAGFlow配置,每个客服账号一个独立的对话实例 -// 架构说明: -// - 租户级:一个知识库(dataset),存储所有话术 -// - 客服级:每个客服有独立的Chat实例,通过document_ids筛选使用哪些话术 -package entity - -import ( - "time" - - "gitea.com/red-future/common/beans" -) - -const RAGFlowConfigCollection = "ragflow_config" - -// RAGFlowConfig RAGFlow配置实体(客服账号级别) -type RAGFlowConfig struct { - beans.MongoBaseDO `bson:",inline"` // TenantId继承自基础DO - - // 客服账号标识 - AccountName string `bson:"accountName" json:"accountName"` // 客服账号名称 - Platform string `bson:"platform" json:"platform"` // 平台(xiaohongshu、douyin) - - // 租户级知识库(整个租户共享) - DatasetId string `bson:"datasetId" json:"datasetId"` // 话术知识库ID(RAGFlow) - DatasetIds []string `bson:"datasetIds" json:"datasetIds"` // Chat绑定的知识库ID列表(与RAGFlow API保持一致) - DatasetName string `bson:"datasetName" json:"datasetName"` // 话术知识库名称 - - // 客服账号级对话实例(每个客服独立) - ChatId string `bson:"chatId" json:"chatId"` // RAGFlow对话实例ID - Prompt string `bson:"prompt" json:"prompt"` // 提示词内容(完整) - - // 文档筛选(该客服使用哪些话术文档) - DocumentIds []string `bson:"documentIds" json:"documentIds"` // 绑定的话术文档ID列表(从租户知识库中筛选) - - // 检索参数 - SimilarityThreshold float64 `bson:"similarityThreshold" json:"similarityThreshold"` // 相似度阈值(默认0.2) - KeywordsSimilarityWeight float64 `bson:"keywordsSimilarityWeight" json:"keywordsSimilarityWeight"` // 关键词权重(默认0.7) - TopN int `bson:"topN" json:"topN"` // 返回chunk数量(默认8) - EmptyResponse string `bson:"emptyResponse" json:"emptyResponse"` // 无匹配时回复 - - // 同步状态 - SyncStatus string `bson:"syncStatus" json:"syncStatus"` // synced/pending/failed - LastSyncTime *time.Time `bson:"lastSyncTime" json:"lastSyncTime"` // 最后同步时间 - SyncError string `bson:"syncError" json:"syncError"` // 同步错误信息 -} diff --git a/model/entity/scripted_speech.go b/model/entity/scripted_speech.go index 13502af..ce0fca2 100644 --- a/model/entity/scripted_speech.go +++ b/model/entity/scripted_speech.go @@ -1,30 +1,29 @@ package entity import ( + "customer-server/consts/scriptedSpeech" + "gitea.com/red-future/common/beans" ) type scriptedSpeechCol struct { beans.SQLBaseCol - AccountId string DatasetId string + SceneType string QuestionContent string - AnswerContent string } var ScriptedSpeechCol = scriptedSpeechCol{ SQLBaseCol: beans.DefSQLBaseCol, - AccountId: "account_id", DatasetId: "dataset_id", + SceneType: "scene_type", QuestionContent: "question_content", - AnswerContent: "answer_content", } type ScriptedSpeech struct { beans.SQLBaseDO `orm:",inline"` - AccountId int64 `orm:"account_id" json:"accountId" dc:"账号ID"` - DatasetId int64 `orm:"dataset_id" json:"datasetId" dc:"数据集ID"` - QuestionContent string `orm:"question_content" json:"questionContent" dc:"问题内容"` - AnswerContent string `orm:"answer_content" json:"answerContent" dc:"回答内容"` + DatasetId int64 `orm:"dataset_id" json:"datasetId" dc:"数据集ID"` + SceneType scriptedSpeech.SceneType `orm:"scene_type" json:"sceneType" dc:"场景类型"` + QuestionContent string `orm:"question_content" json:"questionContent" dc:"问题内容"` } diff --git a/model/entity/session.go b/model/entity/session.go deleted file mode 100644 index f756660..0000000 --- a/model/entity/session.go +++ /dev/null @@ -1,29 +0,0 @@ -package entity - -import ( - "time" - - "gitea.com/red-future/common/beans" -) - -const SessionCollection = "session" - -// SessionStatus 会话状态 -const ( - SessionStatusActive = "active" // 活跃中 - SessionStatusArchived = "archived" // 已归档 -) - -// Session 会话实体 -type Session struct { - beans.MongoBaseDO `bson:",inline"` // 嵌入基础字段 - - // 业务字段 - UserId string `bson:"userId" json:"userId"` // 用户ID(含平台前缀) - Platform string `bson:"platform" json:"platform"` // 平台标识 - SessionId string `bson:"sessionId" json:"sessionId"` // RAGFlow Session ID - Status string `bson:"status" json:"status"` // 状态:active/archived - MessageCount int `bson:"messageCount" json:"messageCount"` // 消息数量 - LastActiveAt *time.Time `bson:"lastActiveAt" json:"lastActiveAt"` // 最后活跃时间 - ArchivedAt *time.Time `bson:"archivedAt" json:"archivedAt"` // 归档时间 -} diff --git a/model/entity/speechcraft.go b/model/entity/speechcraft.go deleted file mode 100644 index d540ee8..0000000 --- a/model/entity/speechcraft.go +++ /dev/null @@ -1,32 +0,0 @@ -// Package entity - 话术实体 -// 功能:定义话术表结构,支持多客服账号绑定、RAGFlow同步记录 -package entity - -import ( - "gitea.com/red-future/common/beans" -) - -// Speechcraft 话术实体 -const SpeechcraftCollection = "speechcraft" - -type Speechcraft struct { - beans.MongoBaseDO `bson:",inline"` // 嵌入基础字段:Id, Creator, CreatedAt, Updater, UpdatedAt, TenantId, IsDeleted - - // 原有字段 - Tag string `bson:"tag" json:"tag"` // 标签(用于分类) - Content string `bson:"content" json:"content"` // 话术内容 - - // 状态机字段 - Stage int `bson:"stage" json:"stage"` // 触发阶段(0=初始) - Status string `bson:"status" json:"status"` // 触发行为(click/keyword/空=任意) - Keywords []string `bson:"keywords" json:"keywords"` // 触发关键字(空=任意) - NextStage int `bson:"nextStage" json:"nextStage"` // 下一阶段(-1=结束) - Platform string `bson:"platform" json:"platform"` // 平台(xiaohongshu) - - // 业务分类 - Direction string `bson:"direction" json:"direction"` // 咨询方向(气血、减肥、护肤等) - - // RAGFlow同步字段(租户级知识库,每个客服账号独立同步记录) - RagSyncRecords []RagSyncRecord `bson:"ragSyncRecords" json:"ragSyncRecords"` // RAGFlow同步记录(按客服账号) - RagLastSyncTime string `bson:"ragLastSyncTime" json:"ragLastSyncTime"` // 最后同步时间 -} diff --git a/model/entity/user_stage.go b/model/entity/user_stage.go deleted file mode 100644 index 62de2ac..0000000 --- a/model/entity/user_stage.go +++ /dev/null @@ -1,45 +0,0 @@ -package entity - -import ( - "time" - - "gitea.com/red-future/common/beans" -) - -// UserStage 用户阶段实体(记录用户在话术引导流程中的当前阶段) -const UserStageCollection = "user_stage" - -type UserStage struct { - beans.MongoBaseDO `bson:",inline"` // 嵌入基础字段 - - // 业务字段 - UserId string `bson:"userId" json:"userId"` // 用户ID(含平台前缀) - Platform string `bson:"platform" json:"platform"` // 平台标识 - Stage int `bson:"stage" json:"stage"` // 当前阶段(0=初始) - Status string `bson:"status" json:"status"` // 最近行为(click/keyword 等) - LastMsgAt *time.Time `bson:"lastMsgAt" json:"lastMsgAt"` // 最后消息时间 - AccountName string `bson:"accountName" json:"accountName"` // 用户选择的方向对应的客服账号名称 -} - -// 阶段常量 -const ( - StageInit = 0 // 初始 - StageAutoComment = 1 // 自动跟评 - StageClick = 2 // 点击 - StageAutoReply = 3 // 点击自动回复 - StageSendImage = 4 // 发图 - StageOpenDoor = 5 // 开口询问 - StageReply1 = 6 // 回复1 - StageReply2 = 7 // 回复2 - StageReply3 = 8 // 回复3 - StageReply4 = 9 // 回复4 - StageFollowUp = 10 // 追问 - StageEnd = 99 // 结束 -) - -// 用户行为常量 -const ( - StatusIdle = "" // 空闲/任意 - StatusClick = "click" // 点击 - StatusKeyword = "keyword" // 发关键字 -) diff --git a/service/account_http_service.go b/service/account_http_service.go new file mode 100644 index 0000000..c57cc0c --- /dev/null +++ b/service/account_http_service.go @@ -0,0 +1,68 @@ +package service + +import ( + "context" + "customer-server/consts/public" + "customer-server/model/dto" + "fmt" + + gmq "github.com/bjang03/gmq/core/gmq" + "github.com/bjang03/gmq/mq" + "github.com/bjang03/gmq/types" + "github.com/gogf/gf/v2/frame/g" +) + +var ( + AccountHttpService = new(accountHttpService) +) + +type accountHttpService struct{} + +func (s *accountHttpService) DeleteDelayMsg(ctx context.Context) (err error) { + return gmq.GetGmq(public.GmqMsgPluginsName).GmqDeleteDelay(ctx, &mq.NatsDelMessage{ + DelMessage: types.DelMessage{ + Topic: public.AccountFollowupTopic, + }, + }) +} + +func (s *accountHttpService) Connect(ctx context.Context, req *dto.AccountHttpConnectReq) (res *dto.AccountHttpConnectRes, err error) { + // 获取客服账号信息 + accountInfo, err := SessionToolService.GetAccountInfo(ctx, req.AccountCode) + if err != nil { + return + } + if g.IsEmpty(accountInfo) { + return nil, fmt.Errorf("客服账号不存在") + } + + // 设置用户信息 + headers, err := SessionToolService.SetUserInfo(ctx, accountInfo.Creator, accountInfo.TenantId) + if err != nil { + return + } + + content, err := SessionToolService.PushOpeningRemark(ctx, req.UserId, accountInfo, headers) + if err != nil { + return + } + if !g.IsEmpty(content) { + res = &dto.AccountHttpConnectRes{ + Content: content, + } + return + } + + dialogContent, err := SessionToolService.PushDialog(ctx, req.UserId, req.QuestionContent, accountInfo, headers) + if err != nil { + return + } + if !g.IsEmpty(dialogContent) { + res = &dto.AccountHttpConnectRes{ + Content: dialogContent, + } + return + } + + return +} diff --git a/service/account_service.go b/service/account_service.go index 19201ce..bed697e 100644 --- a/service/account_service.go +++ b/service/account_service.go @@ -24,7 +24,7 @@ type accountService struct{} func (s *accountService) Add(ctx context.Context, req *dto.AddAccountReq) (res *dto.AddAccountRes, err error) { // 检查账号名称是否已存在 count, err := dao.Account.Count(ctx, &dto.ListAccountReq{ - AccountName: req.AccountName, + AccountCode: req.AccountCode, }) if err != nil { return diff --git a/service/account_websocket_service.go b/service/account_websocket_service.go index 50259e6..5e0bb90 100644 --- a/service/account_websocket_service.go +++ b/service/account_websocket_service.go @@ -3,19 +3,18 @@ package service import ( "context" "customer-server/consts/account" - "customer-server/dao" "customer-server/model/dto" - "customer-server/model/entity" - "errors" - "net/http" + "fmt" + netHttp "net/http" + "gitea.com/red-future/common/beans" "gitea.com/red-future/common/jaeger" "github.com/gogf/gf/v2/container/gmap" "github.com/gogf/gf/v2/encoding/gjson" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/net/ghttp" "github.com/gogf/gf/v2/os/glog" - "github.com/gogf/gf/v2/os/gtime" + "github.com/gogf/gf/v2/os/grpool" "github.com/gogf/gf/v2/util/gconv" "github.com/gorilla/websocket" ) @@ -24,73 +23,92 @@ import ( var AccountWebSocket = &accountWebsocketService{ connections: gmap.NewStrAnyMap(true), upgrader: websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { + CheckOrigin: func(r *netHttp.Request) bool { return true // 允许跨域 }, }, + workerPool: grpool.New(50), // 限制最大并发数为50 } type accountWebsocketService struct { connections *gmap.StrAnyMap upgrader websocket.Upgrader + workerPool *grpool.Pool // 工作池限制goroutine数量 } // key: userId_platform // accountWsConnection WebSocket 连接信息 type accountWsConnection struct { + AccountInfo *dto.AccountVO UserId string - Platform account.Platform - TenantId uint64 - AccountName string // 客服账号ID Conn *websocket.Conn - CreatedAt int64 + Headers map[string]string // 保存原始请求头 } // Connect 建立 WebSocket 连接 -func (s *accountWebsocketService) Connect(ctx context.Context, r *ghttp.Request, accountName string, platform account.Platform) error { +func (s *accountWebsocketService) Connect(ctx context.Context, r *ghttp.Request, req *dto.AccountWebSocketConnectReq) error { // 使用原生upgrader升级WebSocket连接 ws, err := s.upgrader.Upgrade(r.Response.Writer, r.Request, nil) if err != nil { jaeger.RecordError(ctx, err, "WebSocket 升级失败") return err } - defer ws.Close() - if g.IsEmpty(accountName) { - return errors.New("accountName is empty") - } - res, err := s.getGreeting(ctx, accountName) + + // 获取客服账号信息 + accountInfo, err := SessionToolService.GetAccountInfo(ctx, req.AccountCode) if err != nil { return err } - if g.IsEmpty(&res) { - return errors.New("account is empty") - } - if !g.IsEmpty(res.Greeting) { - s.writeJSON(ws, &dto.WebSocketPushMsg{ - Type: "message", - Message: res.Greeting, - }) - glog.Infof(ctx, "已发送开场白 - 用户: %v, 客服账号: %s, 长度: %d", res.Id, accountName, len(res.Greeting)) - } else { - glog.Warningf(ctx, "客服账号未配置开场白 - accountName: %s, tenantId: %v", accountName, res.TenantId) + if g.IsEmpty(accountInfo) { + return fmt.Errorf("客服账号不存在") } - // key格式: tenantId:userId_platform (确保租户隔离) - key := gconv.String(res.TenantId) + ":" + gconv.String(res.Creator) + ":" + gconv.String(platform) + // 创建完整的用户信息 + userInfo := &beans.User{ + UserName: accountInfo.Creator, + TenantId: accountInfo.TenantId, + } + ctx = context.WithValue(ctx, "user", *userInfo) + // 提取并保存请求头(在连接升级前) + headers := make(map[string]string) + // 提取其他headers + for k, v := range r.Request.Header { + if len(v) > 0 { + headers[k] = v[0] + } + } + // 将完整用户信息序列化为JSON,放到X-User-Info请求头 + userInfoJson, err := gjson.Encode(userInfo) + if err != nil { + glog.Errorf(ctx, "用户信息序列化失败: %v", err) + } else { + headers["X-User-Info"] = string(userInfoJson) + glog.Debugf(ctx, "已添加用户信息到请求头: %s", string(userInfoJson)) + } + + var key = fmt.Sprintf("account:%s:%s:%s", req.AccountCode, account.GetDescByCode(req.Platform), req.UserId) + content, err := SessionToolService.PushOpeningRemark(ctx, req.UserId, accountInfo, headers) + if err != nil { + return err + } + if !g.IsEmpty(content) { + s.writeJSON(ws, &dto.WebSocketPushMsg{ + Type: "message", + Message: content, + }) + } // 关闭旧连接 if old := s.connections.Get(key); old != nil { old.(*accountWsConnection).Conn.Close() } - // 注册新连接(携带 TenantId 和 AccountName) + // 注册新连接(携带完整用户信息) s.connections.Set(key, &accountWsConnection{ - UserId: res.Creator, - Platform: platform, - TenantId: res.TenantId, - AccountName: accountName, + AccountInfo: accountInfo, + UserId: req.UserId, Conn: ws, - CreatedAt: gtime.Now().Timestamp(), + Headers: headers, // 保存请求头 }) // 处理消息(阻塞) @@ -124,64 +142,93 @@ func (s *accountWebsocketService) handleConnection(ctx context.Context, key stri continue } - content := gconv.String(message) - glog.Infof(ctx, "收到 WebSocket 消息 - %s: %s", key, content) + questionContent := gconv.String(message) + glog.Infof(ctx, "收到 WebSocket 消息 - %s: %s", key, questionContent) - // 解析 userId - //connInfo := s.connections.Get(key) - //if connInfo == nil { - // break - //} - //wsConn := connInfo.(*accountWsConnection) + // 解析连接信息 + connInfo := s.connections.Get(key) + if connInfo == nil { + glog.Warningf(ctx, "WebSocket连接信息不存在 - %s", key) + break + } + wsConn := connInfo.(*accountWsConnection) - // 先检查对话轮数,>5 则只发卡片,跳过话术 - //checkCardBeforeProcess 已推送卡片消息,无需ack - //if handled, err := checkCardBeforeProcess(ctx, wsConn.TenantId, wsConn.UserId, wsConn.Platform); err != nil { - // jaeger.RecordError(ctx, err, "卡片检查失败") - //} else if handled { - // continue - //} + // 发送ack告知用户正在处理 + s.writeJSON(conn, &dto.WebSocketPushMsg{Type: "ack", Message: "消息已接收,正在处理..."}) - // 话术匹配并发布响应 - // status 暂时为空,表示任意行为匹配 - // isPushed=true表示已直接推送响应(话术匹配),无需ack - // isPushed=false表示转发到RAGFlow,需要ack告知用户正在处理 + // 异步处理消息,避免阻塞WebSocket连接,使用工作池限制并发 + s.workerPool.Add(ctx, func(poolCtx context.Context) { + defer func() { + if r := recover(); r != nil { + glog.Errorf(ctx, "WebSocket处理消息失败: %v", r) + } + }() - // 创建带有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: "消息已接收,正在处理..."}) - //} + var content string + content, err = SessionToolService.PushDialog(ctx, wsConn.UserId, questionContent, wsConn.AccountInfo, wsConn.Headers) + if err != nil { + s.writeJSON(conn, &dto.WebSocketPushMsg{ + Type: "error", + Content: err.Error(), + }) + return + } + // 发送答案给前端 + s.writeJSON(conn, &dto.WebSocketPushMsg{ + Type: "answer", + Content: content, + }) + }) } } -// writeJSON 发送 JSON 消息 +// writeJSON 发送 JSON 消息(带错误处理) func (s *accountWebsocketService) writeJSON(conn *websocket.Conn, data interface{}) { - jsonBytes, _ := gjson.Encode(data) - conn.WriteMessage(websocket.TextMessage, jsonBytes) -} - -// getGreeting 获取客服账号的开场白 -func (s *accountWebsocketService) getGreeting(ctx context.Context, accountName string) (res *entity.Account, err error) { - res, err = dao.Account.GetByAccountName(ctx, &dto.GetByAccountNameReq{ - AccountName: accountName, - }) + jsonBytes, err := gjson.Encode(data) if err != nil { - jaeger.RecordError(ctx, err, "查询客服账号开场白失败") - glog.Errorf(ctx, "查询开场白失败: %v", err) + glog.Errorf(context.Background(), "JSON编码失败: %v", err) return } + if err := conn.WriteMessage(websocket.TextMessage, jsonBytes); err != nil { + glog.Errorf(context.Background(), "WebSocket写入失败: %v", err) + } +} + +func (s *accountWebsocketService) AccountMsg(ctx context.Context, msg any) (err error) { + msgStr := gconv.Map(msg) + if g.IsEmpty(msgStr) { + g.Log().Error(ctx, "DocsChunkMsg err:", "msg is empty") + return + } + // 直接通过 key 获取连接 + connAny := s.connections.Get(gconv.String(msgStr["key"])) + if connAny != nil { + wsConn := connAny.(*accountWsConnection) + s.writeJSON(wsConn.Conn, &dto.WebSocketPushMsg{ + Type: "delay_msg", + Content: gconv.String(msgStr["data"]), + }) + } + g.Log().Info(ctx, "DocsChunkMsg:", msgStr) return } + +// Close 释放所有资源 +func (s *accountWebsocketService) Close() { + if s.workerPool != nil { + s.workerPool.Close() + glog.Info(context.Background(), "WebSocket工作池已关闭") + } + + // 关闭所有WebSocket连接 + s.connections.LockFunc(func(m map[string]interface{}) { + for key, conn := range m { + if wsConn, ok := conn.(*accountWsConnection); ok && wsConn.Conn != nil { + wsConn.Conn.Close() + glog.Infof(context.Background(), "强制关闭WebSocket连接 - %s", key) + } + } + }) + s.connections.Clear() + glog.Info(context.Background(), "WebSocket连接池已清空") +} diff --git a/service/archive_service.go b/service/archive_service.go deleted file mode 100644 index d9dfd6e..0000000 --- a/service/archive_service.go +++ /dev/null @@ -1,207 +0,0 @@ -// Package service - 归档服务 -// 功能:对话记录从MongoDB归档到Elasticsearch,定时任务+手动触发 -package service - -import ( - "context" - "customer-server/dao" - "customer-server/model/entity" - - "gitea.com/red-future/common/elasticsearch" - "gitea.com/red-future/common/jaeger" - "gitea.com/red-future/common/redis" - "github.com/gogf/gf/v2/os/gcron" - "github.com/gogf/gf/v2/os/glog" - "github.com/gogf/gf/v2/os/gtime" - "github.com/gogf/gf/v2/util/gconv" -) - -// archiveService 归档服务 -type archiveService struct{} - -// ArchiveService 归档服务单例 -var ArchiveService = new(archiveService) - -// 归档锁的键名和过期时间 -const ( - archiveLockKey = "archive:monthly:lock" - archiveLockExpire = 3600 // 1 小时 -) - -// MonthlyArchive 月度归档主流程 -// 参数: ctx - 上下文 -// 返回: err - 错误信息 -// 功能: 将MongoDB对话记录归档到Elasticsearch,流程:1.复制到临时表 2.删除原表 3.写入ES 4.删临时表 -// 注意: 使用分布式锁确保只有一个节点执行 -func (s *archiveService) MonthlyArchive(ctx context.Context) (err error) { - // 获取分布式锁,确保只有一个节点执行归档 - if !redis.TryLock(ctx, archiveLockKey, archiveLockExpire) { - glog.Info(ctx, "其他节点正在执行归档,本节点跳过") - return - } - defer redis.Unlock(ctx, archiveLockKey) - - beginTime := gtime.Now() - glog.Info(ctx, "========== 开始月度归档 ==========") - - // 计算归档时间范围 - now := gtime.Now() - var archiveStart, archiveEnd *gtime.Time - - testMode := GetConfigBool(ctx, "archive.testMode") - if testMode { - // 测试模式:归档最近 7 天 - archiveStart = now.AddDate(0, 0, -7) - archiveEnd = now - glog.Infof(ctx, "[测试模式] 归档时间范围: %s 至 %s(最近 7 天)", - archiveStart.Format("Y-m-d H:i:s"), - archiveEnd.Format("Y-m-d H:i:s")) - } else { - // 生产模式:归档上个月整月数据 - // 本月第一天 00:00:00 - archiveEnd = gtime.NewFromStr(now.Format("Y-m") + "-01 00:00:00") - // 上个月第一天 00:00:00 - archiveStart = archiveEnd.AddDate(0, -1, 0) - // 计算上个月天数 - daysInLastMonth := archiveEnd.AddDate(0, 0, -1).Day() - glog.Infof(ctx, "归档时间范围: %s 至 %s(共 %d 天)", - archiveStart.Format("Y-m-d H:i:s"), - archiveEnd.AddDate(0, 0, -1).Format("Y-m-d 23:59:59"), - daysInLastMonth) - } - - // Step 1: 复制数据到临时表 - glog.Info(ctx, "[Step 1/4] 复制数据到临时表...") - copyCount, err := dao.Archive.CopyToTempByRange(ctx, archiveStart.Time, archiveEnd.Time) - if err != nil { - jaeger.RecordError(ctx, err, "复制数据到临时表失败") - return - } - if copyCount == 0 { - glog.Info(ctx, "没有需要归档的数据,跳过") - return - } - glog.Infof(ctx, "复制完成,共 %d 条记录", copyCount) - - // Step 2: 删除原表数据 - glog.Info(ctx, "[Step 2/4] 删除原表数据...") - deleteCount, err := dao.Archive.DeleteByTempIds(ctx) - if err != nil { - jaeger.RecordError(ctx, err, "删除原表数据失败") - // 不返回错误,继续尝试后续步骤 - } - glog.Infof(ctx, "删除完成,共 %d 条记录", deleteCount) - - // Step 3: 写入 ES - glog.Info(ctx, "[Step 3/4] 写入 ES...") - if err = s.writeToES(ctx); err != nil { - jaeger.RecordError(ctx, err, "写入 ES 失败") - // 不返回错误,临时表数据保留,下次可以重试 - return - } - - // Step 4: 删除临时表 - glog.Info(ctx, "[Step 4/4] 删除临时表...") - if err = dao.Archive.DropTempCollection(ctx); err != nil { - jaeger.RecordError(ctx, err, "删除临时表失败") - return - } - - elapsed := gtime.Now().Sub(beginTime) - glog.Infof(ctx, "========== 月度归档完成,耗时: %s ==========", elapsed) - return -} - -// writeToES 将临时表数据写入 ES -func (s *archiveService) writeToES(ctx context.Context) (err error) { - // 确保索引存在 - if err = elasticsearch.CreateIndexIfNotExists(ctx, entity.ConversationESIndex, entity.ConversationESMapping); err != nil { - return - } - - // 获取临时表数据 - tempData, err := dao.Archive.GetTempData(ctx) - if err != nil { - return - } - - if len(tempData) == 0 { - glog.Info(ctx, "临时表无数据") - return - } - - // 转换为 ES 文档格式 - now := gtime.Now().Time - docs := make([]interface{}, 0, len(tempData)) - for _, temp := range tempData { - doc := entity.ConversationES{ - Id: temp.OriginalId, - UserId: temp.UserId, - Platform: temp.Platform, - SessionId: temp.SessionId, - Question: temp.Question, - Answer: temp.Answer, - MessageId: temp.MessageId, - MsgTime: *temp.MsgTime, // 解引用指针类型 - TenantId: gconv.String(temp.TenantId), - CreatedAt: *temp.CreatedAt, // 解引用指针类型 - UpdatedAt: *temp.UpdatedAt, // 解引用指针类型 - ArchivedAt: now, - } - docs = append(docs, doc) - } - - // 批量写入 ES - batchSize := GetConfigInt(ctx, "archive.esBatchSize") - for i := 0; i < len(docs); i += batchSize { - end := i + batchSize - if end > len(docs) { - end = len(docs) - } - if err = elasticsearch.BulkIndex(ctx, entity.ConversationESIndex, docs[i:end]); err != nil { - return - } - } - - glog.Infof(ctx, "ES 写入完成,共 %d 条记录", len(docs)) - return -} - -// ============== 定时任务(月度归档)============== - -// StartArchiveCron 启动归档定时任务 -// 默认每月 1 号凌晨 3 点执行 -func (s *archiveService) StartCron(ctx context.Context) { - cronExpr := GetConfigString(ctx, "archive.cron") - enabled := GetConfigBool(ctx, "archive.enabled") - - if !enabled { - glog.Info(ctx, "月度归档定时任务已禁用") - return - } - - ctx, span := jaeger.NewSpan(ctx, "cron.archive.register") - defer span.End() - - _, err := gcron.Add(ctx, cronExpr, func(ctx context.Context) { - ctx, span := jaeger.NewSpan(ctx, "cron.archive.monthly") - defer span.End() - - glog.Info(ctx, "月度归档定时任务开始执行") - if err := s.MonthlyArchive(ctx); err != nil { - jaeger.RecordError(ctx, err, "月度归档执行失败") - } - }, "monthly-archive") - - if err != nil { - jaeger.RecordError(ctx, err, "注册月度归档定时任务失败") - return - } - - glog.Infof(ctx, "月度归档定时任务已启动 - Cron: %s", cronExpr) -} - -// RunNow 立即执行归档(用于手动触发或测试) -func (s *archiveService) RunNow(ctx context.Context) error { - return s.MonthlyArchive(ctx) -} diff --git a/service/config_service.go b/service/config_service.go deleted file mode 100644 index c312dec..0000000 --- a/service/config_service.go +++ /dev/null @@ -1,211 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "fmt" - "os" - "strconv" - "strings" - "sync" - "time" - - "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/os/glog" - "github.com/gogf/gf/v2/util/gconv" - "github.com/hashicorp/consul/api" -) - -var ( - configCache = make(map[string]interface{}) // 动态配置缓存 - configMu sync.RWMutex // 读写锁 - consulClient *api.Client // Consul客户端 - startOnce sync.Once // 确保只启动一次监听 -) - -// InitConsulWatcher 初始化Consul配置监听 -func InitConsulWatcher(ctx context.Context) error { - consulAddr := g.Cfg().MustGet(ctx, "consul.address").String() - if consulAddr == "" { - glog.Warning(ctx, "Consul未配置,使用本地config.yml") - return nil - } - - config := api.DefaultConfig() - config.Address = consulAddr - client, err := api.NewClient(config) - if err != nil { - glog.Errorf(ctx, "Consul客户端初始化失败: %v", err) - return err - } - consulClient = client - - startOnce.Do(func() { - go watchAllConfigsV2(ctx) - glog.Info(ctx, "Consul配置监听已启动") - }) - - return nil -} - -// watchAllConfigsV2 监听Consul前缀下所有配置 -func watchAllConfigsV2(ctx context.Context) { - const prefix = "customerService/" - kv := consulClient.KV() - var lastIndex uint64 - - for { - pairs, meta, err := kv.List(prefix, &api.QueryOptions{ - WaitIndex: lastIndex, - WaitTime: 5 * time.Minute, - }) - - if err != nil { - glog.Errorf(ctx, "Consul查询失败: %v", err) - time.Sleep(5 * time.Second) - continue - } - - if meta.LastIndex != lastIndex { - lastIndex = meta.LastIndex - updateConfigCacheDiff(ctx, pairs) - } - } -} - -// updateConfigCacheDiff 增量更新配置缓存 -func updateConfigCacheDiff(ctx context.Context, pairs api.KVPairs) { - configMu.Lock() - defer configMu.Unlock() - - for _, pair := range pairs { - key := strings.TrimPrefix(pair.Key, "customerService/") - key = strings.ReplaceAll(key, "/", ".") - - newVal := parseValue(pair.Value) - oldVal, exists := configCache[key] - - if !exists || oldVal != newVal { - logConfigChange(ctx, key, gconv.String(oldVal), gconv.String(newVal)) - configCache[key] = newVal - } - } -} - -// parseValue 自动推断配置值类型 -func parseValue(value []byte) interface{} { - str := string(value) - - if i, err := strconv.Atoi(str); err == nil { - return i - } - - if b, err := strconv.ParseBool(str); err == nil { - return b - } - - var arr []interface{} - if err := json.Unmarshal(value, &arr); err == nil { - return arr - } - - return str -} - -// GetConfigInt 读取int配置 -func GetConfigInt(ctx context.Context, key string) int { - configMu.RLock() - val, ok := configCache[key] - configMu.RUnlock() - - if ok { - return gconv.Int(val) - } - - return g.Cfg().MustGet(ctx, key).Int() -} - -// GetConfigString 读取string配置 -func GetConfigString(ctx context.Context, key string) string { - configMu.RLock() - val, ok := configCache[key] - configMu.RUnlock() - - if ok { - return gconv.String(val) - } - - return g.Cfg().MustGet(ctx, key).String() -} - -// GetConfigBool 读取bool配置 -func GetConfigBool(ctx context.Context, key string) bool { - configMu.RLock() - val, ok := configCache[key] - configMu.RUnlock() - - if ok { - return gconv.Bool(val) - } - - return g.Cfg().MustGet(ctx, key).Bool() -} - -// GetConfigStringSlice 读取字符串数组配置 -func GetConfigStringSlice(ctx context.Context, key string) []string { - configMu.RLock() - val, ok := configCache[key] - configMu.RUnlock() - - if ok { - if arr, ok := val.([]interface{}); ok { - result := make([]string, len(arr)) - for i, v := range arr { - result[i] = gconv.String(v) - } - return result - } - } - - return g.Cfg().MustGet(ctx, key).Strings() -} - -// GetInstanceConfigStringSlice 读取实例级字符串数组配置(支持实例级负载隔离) -// 优先级:实例专用配置 > 全局Consul配置 > config.yml -func GetInstanceConfigStringSlice(ctx context.Context, key string) []string { - // 获取实例ID(环境变量优先,hostname备用) - instanceID := os.Getenv("INSTANCE_ID") - if instanceID == "" { - hostname, err := os.Hostname() - if err == nil { - instanceID = hostname - } - } - - // 如果有实例ID,先查找实例专用配置 - if instanceID != "" { - instanceKey := fmt.Sprintf("instance.%s.%s", instanceID, key) - configMu.RLock() - val, ok := configCache[instanceKey] - configMu.RUnlock() - - if ok { - if arr, ok := val.([]interface{}); ok { - result := make([]string, len(arr)) - for i, v := range arr { - result[i] = gconv.String(v) - } - glog.Debugf(ctx, "🎯 使用实例专用配置: %s = %v", instanceKey, result) - return result - } - } - } - - // 未找到实例配置,fallback到全局配置 - return GetConfigStringSlice(ctx, key) -} - -// logConfigChange 记录配置变更 -func logConfigChange(ctx context.Context, key, oldVal, newVal string) { - glog.Infof(ctx, "📝 配置变更: %s = %s → %s", key, oldVal, newVal) -} diff --git a/service/conversation_service.go b/service/conversation_service.go deleted file mode 100644 index 769d84a..0000000 --- a/service/conversation_service.go +++ /dev/null @@ -1,353 +0,0 @@ -// Package service - 对话服务 -// 功能:处理RAGFlow响应、批量落库、卡片触发逻辑 -package service - -import ( - "context" - "customer-server/dao" - "customer-server/model/entity" - - "gitea.com/red-future/common/jaeger" - "gitea.com/red-future/common/rabbitmq" - "gitea.com/red-future/common/redis" - "github.com/gogf/gf/v2/encoding/gjson" - "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/os/glog" - "github.com/gogf/gf/v2/os/gtime" -) - -// conversationService 对话服务(操作 conversation 表) -type conversationService struct{} - -// ConversationService 对话服务单例 -var ConversationService = new(conversationService) - -// ============== RabbitMQ 消费者(RAGFlow 响应消息)============== - -// ResponseConsumer RabbitMQ 响应消费者 -type ResponseConsumer struct { - queueName string - consumer *rabbitmq.Consumer -} - -// NewResponseConsumer 创建响应消费者 -// 参数: ctx - 上下文 -// 返回: ResponseConsumer - 响应消费者实例 -// 功能: 创建当前实例的唯一响应队列,支持多实例部署 -func NewResponseConsumer(ctx context.Context) *ResponseConsumer { - // 从配置读取基础队列名(用于生成唯一实例队列名) - baseQueue := GetConfigString(ctx, "rabbitmq.responseQueue") - - // 生成当前实例的唯一队列名:ragflow.response.queue.{hostname}.{uuid8} - // 支持多实例部署,每个实例有独立响应队列 - queueName := rabbitmq.GetInstanceQueueName(baseQueue) - glog.Infof(ctx, "响应队列动态生成 - 实例队列: %s", queueName) - - return &ResponseConsumer{ - queueName: queueName, - } -} - -// Start 启动消费者 -// 参数: ctx - 上下文 -// 返回: err - 错误信息 -// 功能: 声明并绑定当前实例的响应队列,开始消费RAGFlow响应消息 -func (c *ResponseConsumer) Start(ctx context.Context) (err error) { - glog.Infof(ctx, "RabbitMQ 响应消费者启动 - Queue: %s", c.queueName) - - // 声明当前实例的动态响应队列 - if err = rabbitmq.DeclareQueue(ctx, &rabbitmq.QueueConfig{ - Name: c.queueName, - Durable: true, - }); err != nil { - glog.Errorf(ctx, "声明动态响应队列失败: %v", err) - return err - } - - // 绑定队列到 Exchange(使用队列名作为routing key,实现精确路由) - // message发送消息时会使用队列名作为routing key - if err = rabbitmq.BindQueue(ctx, &rabbitmq.BindingConfig{ - Queue: c.queueName, - Exchange: "ragflow.response", - RoutingKey: c.queueName, // 使用队列名,只接收发给自己的消息 - }); err != nil { - glog.Errorf(ctx, "绑定动态响应队列失败: %v", err) - return err - } - - glog.Infof(ctx, "动态响应队列已绑定: %s -> ragflow.response (routingKey=#)", c.queueName) - - c.consumer = rabbitmq.NewConsumer(c.queueName, handleResponse) - return c.consumer.Start(ctx) -} - -// Stop 停止消费者 -// 参数: ctx - 上下文 -// 功能: 停止消费RAGFlow响应消息 -func (c *ResponseConsumer) Stop(ctx context.Context) { - if c.consumer != nil { - c.consumer.Stop(ctx) - } -} - -// ============== 卡片触发配置(待接入小红书卡片接口后修改)============== -// -// 【用户状态存储】 -// 使用 Redis Hash 存储用户会话状态(阶段+对话计数),统一5分钟TTL -// Key: ragflow:user:state:{userId}_{platform} -// Fields: stage(阶段)、count(对话计数) -// -// 【状态定义】 -// 状态0:走AI模型 | 状态1:打招呼 | 状态2:业务咨询 | 状态3:发卡片 -// -// 【卡片触发逻辑】 -// 对话轮数>=配置值时,更新状态为3,并发送卡片消息 -// 配置项:config.yml中的card.triggerCount(默认5轮) -// -// 【待接入小红书卡片 API 后修改位置】 -// checkAndSendCard() 函数中的 cardMessage 变量 - -const ( - // ConversationFlushDelaySeconds 对话缓存延时落库时间(秒) - ConversationFlushDelaySeconds = 600 // 10分钟 -) - -// ============== 延时落库消费者 ============== - -// DelayedFlushMessage 延时落库消息(按sessionId) -type DelayedFlushMessage struct { - SessionId string `json:"sessionId"` -} - -// DelayedFlushConsumer 延时落库消费者 -type DelayedFlushConsumer struct { - queueName string - consumer *rabbitmq.Consumer -} - -// NewDelayedFlushConsumer 创建延时落库消费者 -func NewDelayedFlushConsumer(ctx context.Context) *DelayedFlushConsumer { - return &DelayedFlushConsumer{ - queueName: "conversation.flush.queue", - } -} - -// Start 启动消费者 -func (c *DelayedFlushConsumer) Start(ctx context.Context) (err error) { - glog.Infof(ctx, "延时落库消费者启动 - Queue: %s", c.queueName) - c.consumer = rabbitmq.NewConsumer(c.queueName, handleDelayedFlush) - return c.consumer.Start(ctx) -} - -// Stop 停止消费者 -func (c *DelayedFlushConsumer) Stop(ctx context.Context) { - if c.consumer != nil { - c.consumer.Stop(ctx) - } -} - -// handleDelayedFlush 处理延时落库消息 -func handleDelayedFlush(ctx context.Context, body []byte) error { - var msg DelayedFlushMessage - if err := gjson.DecodeTo(body, &msg); err != nil { - glog.Errorf(ctx, "解析延时落库消息失败: %v", err) - return err - } - - glog.Infof(ctx, "收到延时落库消息 - SessionId: %s", msg.SessionId) - - // 检查是否有未落库的缓存 - count, err := redis.GetCachedConversationCount(ctx, msg.SessionId) - if err != nil { - glog.Errorf(ctx, "获取缓存数量失败: %v", err) - return err - } - - if count == 0 { - glog.Debugf(ctx, "无需落库(缓存为空或已落库)- SessionId: %s", msg.SessionId) - return nil - } - - // 执行落库 - if err = flushConversationCache(ctx, msg.SessionId); err != nil { - glog.Errorf(ctx, "延时落库失败: %v", err) - return err - } - - glog.Infof(ctx, "延时落库完成 - SessionId: %s", msg.SessionId) - return nil -} - -// 延时落库发布器(单例) -var delayedFlushPublisher *rabbitmq.Publisher - -// getDelayedFlushPublisher 获取延时落库发布器 -func getDelayedFlushPublisher() *rabbitmq.Publisher { - if delayedFlushPublisher == nil { - delayedFlushPublisher = rabbitmq.NewPublisher("conversation.flush.delayed", "flush") - } - return delayedFlushPublisher -} - -// sendDelayedFlushMessage 发送延时落库消息 -func sendDelayedFlushMessage(ctx context.Context, sessionId string) error { - msg := &DelayedFlushMessage{ - SessionId: sessionId, - } - return getDelayedFlushPublisher().PublishDelayed(ctx, msg, ConversationFlushDelaySeconds) -} - -// handleResponse 处理 RabbitMQ 消息(幂等) -// 落库逻辑:前5句缓存到Redis,第5句时批量落库MongoDB,超过5句不落库 -func handleResponse(ctx context.Context, body []byte) error { - ctx, span := jaeger.NewSpan(ctx, "consumer.response") - defer span.End() - - glog.Infof(ctx, ">>> handleResponse 被调用,消息长度: %d", len(body)) - - // 解析消息到结构体 - var msg redis.ResponseStreamMessage - if err := gjson.DecodeTo(body, &msg); err != nil { - jaeger.RecordError(ctx, err, "解析响应消息失败") - return err - } - - glog.Infof(ctx, "收到 RAGFlow 响应 - 用户: %s, MessageId: %s", msg.UserId, msg.MessageId) - - // 1. 获取当前对话轮数 - state, err := redis.GetUserState(ctx, msg.UserId, msg.Platform) - if err != nil { - jaeger.RecordError(ctx, err, "获取用户状态失败") - } - count := state.Count - - // 2. 根据轮数决定落库策略 - cardTriggerCount := g.Cfg().MustGet(ctx, "card.triggerCount", 5).Int64() - if count <= cardTriggerCount { - // 前N句:缓存到Redis(按sessionId) - msgTime := gtime.NewFromTimeStamp(msg.Timestamp).Time - conversation := &entity.Conversation{ - UserId: msg.UserId, - Platform: msg.Platform, - SessionId: msg.SessionId, - Question: msg.Question, - Answer: msg.Content, - MessageId: msg.MessageId, - MsgTime: &msgTime, // 取地址赋值给指针类型 - } - conversation.TenantId = msg.TenantId - - // 序列化后缓存(使用sessionId作为key) - data, _ := gjson.Encode(conversation) - if cacheErr := redis.CacheConversation(ctx, msg.SessionId, data); cacheErr != nil { - jaeger.RecordError(ctx, cacheErr, "缓存对话记录失败") - } else { - glog.Debugf(ctx, "对话已缓存到 Redis - SessionId: %s, 第 %d 轮", msg.SessionId, count) - } - - // 第1句时:发送10分钟延时落库消息(兜底) - if count == 1 { - if delayErr := sendDelayedFlushMessage(ctx, msg.SessionId); delayErr != nil { - glog.Warningf(ctx, "发送延时落库消息失败: %v", delayErr) - } - } - - // 第N句时:立即批量落库 - if count == cardTriggerCount { - if flushErr := flushConversationCache(ctx, msg.SessionId); flushErr != nil { - jaeger.RecordError(ctx, flushErr, "批量落库失败") - } - } - } else { - // 超过N句:不落库(已发卡片) - glog.Debugf(ctx, "第 %d 轮(>%d),跳过落库 - 用户: %s", count, cardTriggerCount, msg.UserId) - } - - // 3. 推送给 WebSocket 用户(无论是否落库都推送) - glog.Infof(ctx, "准备推送 WebSocket - 用户: %s_%s, 内容长度: %d", msg.UserId, msg.Platform, len(msg.Content)) - if err = WebSocket.PushRAGFlowResponse(ctx, msg.TenantId, msg.UserId, msg.Platform, msg.Content); err != nil { - jaeger.RecordError(ctx, err, "推送 WebSocket 失败") - } else { - glog.Infof(ctx, "WebSocket 推送成功 - 用户: %s_%s", msg.UserId, msg.Platform) - } - - return nil -} - -// flushConversationCache 将Redis缓存的对话批量落库到MongoDB -func flushConversationCache(ctx context.Context, sessionId string) error { - // 获取缓存的对话列表 - cached, err := redis.GetCachedConversations(ctx, sessionId) - if err != nil { - return err - } - if len(cached) == 0 { - return nil - } - - // 反序列化 - list := make([]*entity.Conversation, 0, len(cached)) - for _, data := range cached { - var conv entity.Conversation - if decErr := gjson.DecodeTo([]byte(data), &conv); decErr != nil { - glog.Warningf(ctx, "反序列化对话失败: %v", decErr) - continue - } - list = append(list, &conv) - } - - // 批量插入MongoDB - if len(list) > 0 { - if insertErr := dao.Conversation.BatchInsert(ctx, list); insertErr != nil { - return insertErr - } - glog.Infof(ctx, "批量落库成功 - SessionId: %s, 共 %d 条", sessionId, len(list)) - } - return nil -} - -// checkCardBeforeProcess 检查对话轮数,达到阈值时发卡片 -// 返回 handled=true 表示已处理(发送卡片),调用方应跳过后续话术处理 -func checkCardBeforeProcess(ctx context.Context, tenantId, userId, platform string) (handled bool, err error) { - // 获取用户当前状态 - state, err := redis.GetUserState(ctx, userId, platform) - if err != nil { - return - } - - // 状态5(未选择方向)时不计数,等用户选择方向后再开始计数 - if state.Stage == 5 { - glog.Debugf(ctx, "用户 %s_%s 处于状态5(未选择方向),跳过计数", userId, platform) - return false, nil - } - - // 增加对话计数 - count, err := redis.IncrUserCount(ctx, userId, platform) - if err != nil { - return - } - - glog.Infof(ctx, "用户 %s_%s 当前对话轮数: %d", userId, platform, count) - - // 对话>=配置轮数,发卡片并跳过话术(从配置值开始就发卡片,不再调用AI) - cardTriggerCount := g.Cfg().MustGet(ctx, "card.triggerCount", 5).Int64() - if count >= cardTriggerCount { - glog.Infof(ctx, "用户 %s_%s 对话第 %d 轮(>=%d),触发发送卡片", userId, platform, count, cardTriggerCount) - - // 更新用户状态为3(发卡片状态) - if updateErr := redis.SetUserStage(ctx, userId, platform, 3); updateErr != nil { - jaeger.RecordError(ctx, updateErr, "更新用户状态为3失败") - } - - cardMessage := "请加一下卡片的联系方式,进行更专业的咨询" // TODO: 替换为实际卡片发送逻辑 - if pushErr := WebSocket.PushRAGFlowResponse(ctx, tenantId, userId, platform, cardMessage); pushErr != nil { - jaeger.RecordError(ctx, pushErr, "推送卡片消息失败") - glog.Errorf(ctx, "推送卡片失败 - 用户: %s_%s, 错误: %v", userId, platform, pushErr) - err = pushErr - return - } - glog.Infof(ctx, "卡片消息已推送 - 用户: %s_%s", userId, platform) - handled = true - } - return -} diff --git a/service/customer_service_account_service.go b/service/customer_service_account_service.go deleted file mode 100644 index 70e16f1..0000000 --- a/service/customer_service_account_service.go +++ /dev/null @@ -1,855 +0,0 @@ -// Package service - 客服账号服务 -// 功能:客服账号的增删改查业务逻辑 -package service - -import ( - "context" - "customer-server/dao" - "customer-server/model/dto" - "customer-server/model/entity" - "customer-server/util" - "fmt" - "strings" - "time" - - "gitea.com/red-future/common/beans" - commonMongo "gitea.com/red-future/common/db/mongo" - "gitea.com/red-future/common/ragflow" - "gitea.com/red-future/common/utils" - "github.com/gogf/gf/v2/errors/gerror" - "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/os/gtime" - "github.com/gogf/gf/v2/text/gstr" - "github.com/gogf/gf/v2/util/gconv" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" - "go.mongodb.org/mongo-driver/v2/mongo/options" -) - -var CustomerServiceAccount = new(customerServiceAccount) - -type customerServiceAccount struct{} - -// Add 添加客服账号 -func (s *customerServiceAccount) Add(ctx context.Context, req *dto.AddCustomerServiceAccountReq) (res *dto.AddCustomerServiceAccountRes, err error) { - // 1. 检查客服ID是否已存在 - coll := commonMongo.GetDB().Collection(entity.CustomerServiceAccountCollection) - filter := bson.M{"accountName": req.AccountName, "isDeleted": false} - count, err := coll.CountDocuments(ctx, filter) - if err != nil { - return nil, gerror.Wrap(err, "检查客服ID是否存在失败") - } - if count > 0 { - return nil, gerror.Newf("客服账号名称 '%s' 已存在,请使用其他名称", req.AccountName) - } - - // 2. 准备数据(但暂不写入MongoDB) - data := &entity.CustomerServiceAccount{} - if err = utils.Struct(req, data); err != nil { - return - } - - // 调试日志:输出请求和转换后的数据 - g.Log().Infof(ctx, "[Add] 请求数据 - AccountName: %s, Platform: %s, Prompt: %v, Greeting: %v", - req.AccountName, req.Platform, req.Prompt, req.Greeting) - g.Log().Infof(ctx, "[Add] 转换后Entity - AccountName: %s, Platform: %s, Greeting: %s", - data.AccountName, data.Platform, data.Greeting) - - now := gtime.Now().Time - data.CreatedAt = &now // 取地址赋值给指针类型 - data.UpdatedAt = &now // 取地址赋值给指针类型 - data.IsDeleted = false - data.IsDisabled = false - - // 获取或设置tenantId(统一转换为string存储) - var tenantId string - if req.TenantId != nil { - tenantId = gconv.String(req.TenantId) - g.Log().Infof(ctx, "使用手动指定的tenant_id: %v", req.TenantId) - } else { - // 从session获取tenantId - tenantInfo, err := util.GetTenantInfo(ctx) - if err != nil { - return nil, gerror.Wrap(err, "无法获取租户信息") - } - tenantId = gconv.String(tenantInfo.TenantId) - if tenantId == "" { - return nil, gerror.New("无法获取租户ID") - } - } - // 统一使用string类型存储到MongoDB - data.TenantId = tenantId - - // 3. 确保租户知识库存在(调用公共方法) - datasetId, err := EnsureTenantDataset(ctx, tenantId) - if err != nil { - g.Log().Errorf(ctx, "确保租户知识库存在失败: %v", err) - return nil, gerror.Wrap(err, "确保租户知识库存在失败") - } - - // 4. 先插入客服账号(必须在创建Chat之前,因为createChatAndSaveConfig内部会通过accountName查询租户ID) - err = dao.CustomerServiceAccount.Insert(ctx, data) - if err != nil { - return nil, gerror.Wrap(err, "插入客服账号失败") - } - - // 5. 处理话术绑定 - if len(req.SpeechcraftIds) > 0 { - g.Log().Infof(ctx, "开始同步话术到RAGFlow: count=%d", len(req.SpeechcraftIds)) - for _, speechcraftId := range req.SpeechcraftIds { - _, err := Speechcraft.SyncToRAGFlow(ctx, speechcraftId, req.AccountName, tenantId) - if err != nil { - g.Log().Errorf(ctx, "同步话术到RAGFlow失败: speechcraft_id=%s, error=%v", speechcraftId, err) - } - } - } - - // 6. 创建Chat并保存RAGFlow配置(内部会轮询等待文档解析完成) - var assistantDesc string - if req.SelfIdentity != nil { - assistantDesc = *req.SelfIdentity - } - if err = s.createChatAndSaveConfig(ctx, req.AccountName, req.Platform, tenantId, datasetId, assistantDesc); err != nil { - g.Log().Errorf(ctx, "创建Chat配置失败: %v", err) - return nil, gerror.Wrap(err, "创建Chat配置失败") - } - - // 7. 如果提供了自定义提示词,调用UpdatePrompt更新 - if req.Prompt != nil && *req.Prompt != "" { - g.Log().Infof(ctx, "创建完成,开始更新自定义提示词") - updateReq := &dto.UpdatePromptReq{ - AccountName: req.AccountName, - Prompt: *req.Prompt, - } - if _, err := RAGFlowConfig.UpdatePrompt(ctx, updateReq); err != nil { - g.Log().Errorf(ctx, "更新自定义提示词失败: %v", err) - // 不阻断创建流程,提示词可以后续修改 - } else { - g.Log().Infof(ctx, "自定义提示词更新成功") - } - } - - res = &dto.AddCustomerServiceAccountRes{Id: data.Id.Hex()} - return -} - -// createChatAndSaveConfig 创建Chat并保存RAGFlow配置(会轮询等待文档解析完成) -func (s *customerServiceAccount) createChatAndSaveConfig(ctx context.Context, accountName, platform, tenantId, datasetId, assistantDescription string) error { - ragflowClient := ragflow.GetGlobalClient() - - // 1. 轮询检查知识库中是否有解析完成的文档(最多60秒) - g.Log().Infof(ctx, "等待知识库文档解析完成: dataset_id=%s", datasetId) - maxWaitSeconds := 60 - - for i := 0; i < maxWaitSeconds; i++ { - listReq := &ragflow.ListDocumentsReq{ - Page: 1, - PageSize: 1, - } - listRes, err := ragflowClient.ListDocuments(ctx, datasetId, listReq) - if err == nil && listRes != nil && len(listRes.Data.Docs) > 0 { - doc := listRes.Data.Docs[0] - - // 调试:输出所有状态字段 - if i == 0 || i%5 == 0 { - g.Log().Infof(ctx, "文档状态详情: dataset_id=%s, doc_id=%s, RunStatus=%s, Status=%s, ChunkCount=%d, Progress=%.2f, ProgressMsg=%s", - datasetId, doc.Id, doc.RunStatus, doc.Status, doc.ChunkCount, doc.Progress, doc.ProgressMsg) - } - - // 检查解析失败状态:Progress < 0 表示解析出错(如embedding模型无法访问) - if doc.Progress < 0 { - return gerror.Newf("RAGFlow文档解析失败(embedding服务不可用): dataset_id=%s, doc_id=%s, error=%s", - datasetId, doc.Id, doc.ProgressMsg) - } - - // 检查解析完成的条件:ChunkCount > 0 表示已解析出分块 - if doc.ChunkCount > 0 { - g.Log().Infof(ctx, "知识库文档解析完成: dataset_id=%s, doc_id=%s, chunk_count=%d, 耗时=%d秒", - datasetId, doc.Id, doc.ChunkCount, i) - break - } - } - - // 超时检查 - if i >= maxWaitSeconds-1 { - return gerror.Newf("等待文档解析超时: dataset_id=%s, 已等待%d秒", datasetId, maxWaitSeconds) - } - - time.Sleep(time.Second) - } - - // 2. 生成默认提示词、助理描述并创建Chat - promptText := s.generateDefaultPrompt() - modelName := s.getDefaultModelName(ctx) - - // 使用传入的assistantDescription,如果为空则使用默认生成 - var assistantDesc string - if assistantDescription != "" { - assistantDesc = assistantDescription - } else { - assistantDesc = s.generateAssistantDescription(accountName, platform) - } - - // Chat名称:账号名_平台_租户ID - chatName := fmt.Sprintf("%s_%s_%s", accountName, platform, tenantId) - - chatReq := &ragflow.CreateChatReq{ - Name: chatName, - Description: assistantDesc, - DatasetIds: []string{datasetId}, - Prompt: &ragflow.PromptConfig{ - Prompt: promptText, - SimilarityThreshold: 0.2, - KeywordsSimilarityWeight: 0.7, - TopN: 8, - EmptyResponse: "", - Variables: []map[string]interface{}{ - { - "key": "knowledge", - "optional": true, - }, - }, - }, - Llm: &ragflow.Llm{ - ModelName: modelName, - }, - } - - chat, err := ragflowClient.CreateChat(ctx, chatReq) - if err != nil { - // 如果Chat名称已存在,尝试查询现有Chat - if strings.Contains(err.Error(), "Duplicated chat name") { - g.Log().Warningf(ctx, "Chat名称已存在,尝试查询现有Chat: %s", chatName) - - // 查询Chat列表,查找同名的Chat - listReq := &ragflow.ListChatsReq{ - Page: 1, - PageSize: 100, - } - listRes, listErr := ragflowClient.ListChats(ctx, listReq) - if listErr != nil { - return gerror.Wrapf(listErr, "查询Chat列表失败") - } - // 检查listRes和Data是否为nil:防止遍历nil切片导致空指针异常 - if listRes == nil || listRes.Data == nil { - return gerror.New("查询Chat列表返回空对象") - } - - // 查找同名Chat - for _, existingChat := range listRes.Data { - if existingChat.Name == chatName { - g.Log().Infof(ctx, "找到现有Chat: name=%s, id=%s,复用该Chat", chatName, existingChat.Id) - chat = existingChat - break - } - } - - if chat == nil { - return gerror.Newf("找不到同名Chat: %s", chatName) - } - } else { - return gerror.Wrapf(err, "创建RAGFlow Chat失败") - } - } - - // 最终检查chat是否为nil:防止CreateChat返回(nil, nil)或Duplicated情况下未找到同名Chat - if chat == nil { - return gerror.New("创建RAGFlow Chat返回空对象") - } - - // 先查询现有配置,保留用户的自定义设置 - coll := commonMongo.GetDB().Collection(entity.RAGFlowConfigCollection) - filter := bson.M{"accountName": accountName, "isDeleted": false} - var existingConfig entity.RAGFlowConfig - findErr := coll.FindOne(ctx, filter).Decode(&existingConfig) - - now := gtime.Now().Time - - // 如果已有配置,保留用户自定义的prompt和参数 - if findErr == nil && !existingConfig.Id.IsZero() { - g.Log().Infof(ctx, "保留现有配置的自定义设置,仅更新chat_id和dataset_id") - - // 只更新必要字段,保留用户自定义内容 - update := bson.M{ - "$set": bson.M{ - "chatId": chat.Id, - "datasetId": datasetId, - "datasetIds": []string{datasetId}, - "syncStatus": "synced", - "updatedAt": now, - }, - } - if _, err := coll.UpdateOne(ctx, filter, update); err != nil { - return gerror.Wrap(err, "更新RAGFlowConfig失败") - } - } else { - // 首次创建配置,使用默认值 - g.Log().Infof(ctx, "首次创建RAGFlow配置,使用默认提示词") - - config := &entity.RAGFlowConfig{ - AccountName: accountName, - Platform: platform, - DatasetId: datasetId, - DatasetIds: []string{datasetId}, - DatasetName: "租户话术知识库", - ChatId: chat.Id, - Prompt: promptText, - DocumentIds: []string{}, - SimilarityThreshold: 0.2, - KeywordsSimilarityWeight: 0.7, - TopN: 8, - EmptyResponse: "", - SyncStatus: "synced", - } - // 统一使用string类型存储tenantId到MongoDB - config.TenantId = tenantId - config.CreatedAt = &now // 取地址赋值给指针类型 - config.UpdatedAt = &now // 取地址赋值给指针类型 - config.IsDeleted = false - - update := bson.M{"$set": config} - opts := options.UpdateOne().SetUpsert(true) - if _, err := coll.UpdateOne(ctx, filter, update, opts); err != nil { - return gerror.Wrap(err, "更新RAGFlowConfig失败") - } - } - - g.Log().Infof(ctx, "RAGFlowConfig创建/更新成功: account_name=%s, chat_id=%s, dataset_id=%s", - accountName, chat.Id, datasetId) - return nil -} - -// generateDefaultPrompt 生成默认提示词(包含知识库引用) -func (s *customerServiceAccount) generateDefaultPrompt() string { - // 默认提示词已包含完整的知识库引用格式 - return `你是一个智能助手,请总结知识库的内容来回答问题,请列举知识库中的数据详细回答。当所有知识库内容都与问题无关时,你的回答必须包括"知识库中未找到您要的答案!"这句话。回答需要考虑聊天历史。 - -以下是知识库: -{knowledge} -以上是知识库。` -} - -// generateAssistantDescription 根据客服账号生成助理描述 -func (s *customerServiceAccount) generateAssistantDescription(accountName, platform string) string { - // 根据账号名称判断业务方向 - lowerName := gstr.ToLower(accountName) - - // 气血方向 - if gstr.Contains(lowerName, "qixue") || gstr.Contains(lowerName, "气血") { - return "专业的女性健康顾问,专注于月经调理、气血养护等健康问题。以温暖关怀的态度为客户提供专业建议,帮助改善身体状况。" - } - - // 减肥方向 - if gstr.Contains(lowerName, "jianfei") || gstr.Contains(lowerName, "减肥") || gstr.Contains(lowerName, "shoushen") || gstr.Contains(lowerName, "瘦身") { - return "专业的减肥瘦身顾问,提供科学健康的减肥方案和营养建议。以积极正面的态度鼓励客户坚持健康的生活方式,达成理想体型。" - } - - // 护肤方向 - if gstr.Contains(lowerName, "hufu") || gstr.Contains(lowerName, "护肤") || gstr.Contains(lowerName, "meirong") || gstr.Contains(lowerName, "美容") { - return "专业的护肤美容顾问,为客户提供个性化的护肤方案和产品建议。帮助客户解决各类肌肤问题,重现健康光彩。" - } - - // 养生方向 - if gstr.Contains(lowerName, "yangsheng") || gstr.Contains(lowerName, "养生") || gstr.Contains(lowerName, "health") { - return "专业的养生健康顾问,提供中医养生、日常保健等全方位健康建议。倡导健康生活方式,帮助客户提升整体身心健康。" - } - - // 默认通用描述 - return fmt.Sprintf("智能客服助理,为您提供专业的咨询服务。基于%s平台,随时解答您的疑问,提供贴心的服务体验。", platform) -} - -// getDefaultModelName 获取默认模型名称(Consul优先,config.yml兜底) -func (s *customerServiceAccount) getDefaultModelName(ctx context.Context) string { - // 使用统一配置读取方法(自动支持Consul动态配置) - model := GetConfigString(ctx, "ragflow.default_model") - if model != "" { - return model - } - - // 硬编码兜底(理论上不会走到这里) - return "qwen3-235b-a22b-instruct-2507" -} - -// Update 更新客服账号 -func (s *customerServiceAccount) Update(ctx context.Context, req *dto.UpdateCustomerServiceAccountReq) (err error) { - // 调试日志:输出请求数据 - g.Log().Infof(ctx, "[Update] 请求数据 - Id: %s, AccountName: %s, Platform: %s, Prompt: %v, Greeting: %v", - req.Id, req.AccountName, req.Platform, req.Prompt, req.Greeting) - - // 1. 检查账号名称是否重复 - if req.AccountName != "" { - existingAccount, err := dao.CustomerServiceAccount.FindByAccountName(ctx, req.AccountName) - if err != nil && err != mongo.ErrNoDocuments { - return err - } - if existingAccount != nil && existingAccount.Id.Hex() != req.Id { - return gerror.Newf("客服账号名称 '%s' 已被其他账号使用,请使用其他名称", req.AccountName) - } - } - - // 2. 更新基本信息(accountName, platform) - if err = dao.CustomerServiceAccount.Update(ctx, req); err != nil { - g.Log().Errorf(ctx, "[Update] 更新基本信息失败: %v", err) - return err - } - - // 3. 查询客服账号获取accountName(用于后续更新) - objectId, err := bson.ObjectIDFromHex(req.Id) - if err != nil { - return gerror.Wrap(err, "无效的账号ID") - } - var account entity.CustomerServiceAccount - filter := bson.M{"_id": objectId, "isDeleted": false} - if err = dao.MongoDAO.FindOne(ctx, filter, &account, entity.CustomerServiceAccountCollection); err != nil { - return gerror.Wrap(err, "查询客服账号失败") - } - - // 4. 如果提供了开场白,更新greeting字段到customer_service_account表 - if req.Greeting != nil { - g.Log().Infof(ctx, "[Update] 更新开场白 - accountName: %s, greeting: %s", account.AccountName, *req.Greeting) - updateReq := &dto.UpdateGreetingReq{ - AccountName: account.AccountName, - Greeting: *req.Greeting, - } - if err = s.UpdateGreeting(ctx, updateReq); err != nil { - g.Log().Errorf(ctx, "[Update] 更新开场白失败: %v", err) - return gerror.Wrap(err, "更新开场白失败") - } - } - - // 5. 如果提供了提示词,更新RAGFlow配置 - if req.Prompt != nil { - g.Log().Infof(ctx, "[Update] 更新提示词 - accountName: %s", account.AccountName) - updatePromptReq := &dto.UpdatePromptReq{ - AccountName: account.AccountName, - Prompt: *req.Prompt, - } - if _, err = RAGFlowConfig.UpdatePrompt(ctx, updatePromptReq); err != nil { - g.Log().Errorf(ctx, "[Update] 更新提示词失败: %v", err) - return gerror.Wrap(err, "更新提示词失败") - } - } - - // 6. 如果accountName、platform或selfIdentity有变化,同步更新RAGFlow Chat的name和description - if req.AccountName != "" || req.Platform != "" || req.SelfIdentity != nil { - if err = s.updateRAGFlowChatInfo(ctx, &account, req); err != nil { - g.Log().Errorf(ctx, "[Update] 同步更新RAGFlow Chat失败: %v", err) - return gerror.Wrap(err, "同步更新RAGFlow Chat失败") - } - } - - g.Log().Infof(ctx, "[Update] 客服账号更新成功 - accountName: %s", account.AccountName) - return nil -} - -// UpdateGreeting 更新开场白 -func (s *customerServiceAccount) UpdateGreeting(ctx context.Context, req *dto.UpdateGreetingReq) (err error) { - // 获取租户ID(租户隔离) - user, err := util.GetTenantInfo(ctx) - if err != nil { - return gerror.Wrap(err, "获取租户信息失败") - } - - filter := bson.M{ - "accountName": req.AccountName, - "tenantId": user.TenantId, - "isDeleted": false, - } - update := bson.M{ - "$set": bson.M{ - "greeting": req.Greeting, - "updatedAt": gtime.Now().Time, - "updater": user.UserName, - }, - } - - g.Log().Infof(ctx, "[UpdateGreeting] 开始更新开场白 - accountName: %s, tenantId: %v, greeting: %s", - req.AccountName, user.TenantId, req.Greeting) - g.Log().Infof(ctx, "[UpdateGreeting] 查询条件 - filter: %+v", filter) - g.Log().Infof(ctx, "[UpdateGreeting] 更新内容 - update: %+v", update) - - // 使用MongoDAO.UpdateOne(不需要token验证,避免双重验证冲突) - matchedCount, modifiedCount, err := dao.MongoDAO.UpdateOne(ctx, filter, update, entity.CustomerServiceAccountCollection) - if err != nil { - g.Log().Errorf(ctx, "[UpdateGreeting] 数据库更新失败 - err: %v", err) - return gerror.Wrapf(err, "更新开场白失败") - } - - g.Log().Infof(ctx, "[UpdateGreeting] 数据库更新结果 - matchedCount: %d, modifiedCount: %d", matchedCount, modifiedCount) - - if modifiedCount == 0 { - if matchedCount == 0 { - g.Log().Errorf(ctx, "[UpdateGreeting] 未找到匹配记录 - accountName: %s, tenantId: %v", req.AccountName, user.TenantId) - return gerror.Newf("客服账号不存在或不属于当前租户: accountName=%s", req.AccountName) - } - g.Log().Warningf(ctx, "[UpdateGreeting] 记录已存在但未修改(内容相同) - accountName: %s", req.AccountName) - } - - g.Log().Infof(ctx, "[UpdateGreeting] ✅ 开场白更新成功 - accountName: %s, 更新记录数: %d", req.AccountName, modifiedCount) - return -} - -// ToggleStatus 切换客服账号状态(启用/禁用) -// 参数: ctx - 上下文,req - 状态切换请求(包含账号ID) -// 返回: err - 错误信息 -// 功能: 在启用和禁用状态之间切换,禁用后不再接收用户消息 -func (s *customerServiceAccount) ToggleStatus(ctx context.Context, req *dto.ToggleCustomerServiceAccountStatusReq) (err error) { - return dao.CustomerServiceAccount.ToggleStatus(ctx, req) -} - -// List 获取客服账号列表(关联查询prompt字段) -// 参数: ctx - 上下文,req - 列表查询请求(支持分页、平台筛选、状态筛选) -// 返回: res - 客服账号列表及分页信息(包含prompt字段),err - 错误信息 -// 功能: 分页查询客服账号,并从ragflow_config表关联查询每个账号的prompt(去除知识库引用部分) -func (s *customerServiceAccount) List(ctx context.Context, req *dto.ListCustomerServiceAccountReq) (res *dto.ListCustomerServiceAccountRes, err error) { - list, total, err := dao.CustomerServiceAccount.List(ctx, req) - if err != nil { - return - } - - // 关联查询每个账号的prompt(从ragflow_config表) - for i := range list { - account := list[i] - if config, configErr := dao.RAGFlowConfig.FindByAccountName(ctx, account.AccountName); configErr == nil && config != nil { - // 去除知识库引用部分,只返回用户输入的业务提示词 - userPrompt := config.Prompt - - // 查找知识库引用的起始位置(支持多种格式) - knowledgePatterns := []string{ - "\n\n以下是知识库:", - "\n\n以下是知识库", - "\n以下是知识库:", - "\n以下是知识库", - } - - // 找到最早出现的知识库引用位置并截断 - for _, pattern := range knowledgePatterns { - if idx := gstr.Pos(userPrompt, pattern); idx >= 0 { - userPrompt = gstr.SubStr(userPrompt, 0, idx) - break - } - } - - // 去除末尾空白 - userPrompt = gstr.TrimRight(userPrompt) - - // 将处理后的prompt赋值给account - list[i].Prompt = userPrompt - g.Log().Debugf(ctx, "账号 %s 的prompt已关联(原始长度: %d, 处理后长度: %d)", - account.AccountName, len(config.Prompt), len(userPrompt)) - } - } - - res = &dto.ListCustomerServiceAccountRes{ - List: list, - Total: int(total), - } - return -} - -// Delete 删除客服账号(软删除 + 删除RAGFlow Chat) -// 参数: ctx - 上下文,req - 删除客服账号请求(包含账号ID) -// 返回: res - 删除结果信息,err - 错误信息 -// 功能: 逻辑删除客服账号,同时删除RAGFlow中的Chat配置和ragflow_config记录 -func (s *customerServiceAccount) Delete(ctx context.Context, req *dto.DeleteCustomerServiceAccountReq) (res *dto.DeleteCustomerServiceAccountRes, err error) { - res = &dto.DeleteCustomerServiceAccountRes{} - - // 1. 查询客服账号信息(使用MongoDAO直接查询,避免租户过滤) - objectId, err := bson.ObjectIDFromHex(req.Id) - if err != nil { - return nil, gerror.Wrap(err, "无效的账号ID") - } - - var account entity.CustomerServiceAccount - filter := bson.M{"_id": objectId, "isDeleted": false} - err = dao.MongoDAO.FindOne(ctx, filter, &account, entity.CustomerServiceAccountCollection) - if err != nil { - if err.Error() == "mongo: no documents in result" { - return nil, gerror.New("客服账号不存在") - } - return nil, gerror.Wrap(err, "查询客服账号失败") - } - - // 2. 查询关联的RAGFlowConfig,获取chatId - config, err := dao.RAGFlowConfig.FindByAccountName(ctx, account.AccountName) - if err != nil { - g.Log().Warningf(ctx, "查询RAGFlowConfig失败: %v", err) - } - - // 3. 如果存在chatId,调用RAGFlow API删除Chat - if config != nil && config.ChatId != "" { - ragflowClient := ragflow.GetGlobalClient() - if err := ragflowClient.DeleteChats(ctx, []string{config.ChatId}); err != nil { - g.Log().Errorf(ctx, "删除RAGFlow Chat失败: chat_id=%s, error=%v", config.ChatId, err) - // 不阻断删除流程,记录错误日志 - } else { - g.Log().Infof(ctx, "RAGFlow Chat删除成功: chat_id=%s", config.ChatId) - } - - // 4. 软删除RAGFlowConfig记录 - filter := bson.M{"_id": config.Id, "isDeleted": false} - update := bson.M{"$set": bson.M{"isDeleted": true}} - if _, err := commonMongo.DB().Update(ctx, filter, update, entity.RAGFlowConfigCollection); err != nil { - g.Log().Warningf(ctx, "软删除RAGFlowConfig失败: %v", err) - } - } - - // 5. 软删除客服账号 - if err := dao.CustomerServiceAccount.Delete(ctx, account.Id.Hex()); err != nil { - return nil, gerror.Wrap(err, "删除客服账号失败") - } - - g.Log().Infof(ctx, "客服账号删除成功: account_name=%s, chat_id=%s", - account.AccountName, config.ChatId) - - res.Success = true - res.Message = "删除成功" - return -} - -// getAccessibleSpeechcraftsInternal 内部方法:获取客服账号可访问的话术(动态权限) -// 参数: ctx - 上下文,accountName - 客服账号名 -// 返回: speechcrafts - 话术列表,err - 错误信息 -// 功能: 根据客服账号的speechcraft_ids字段判断权限范围 -// -// 空数组 = 返回该租户下所有话术(动态权限) -// 非空 = 只返回指定ID的话术(指定权限) -func (s *customerServiceAccount) getAccessibleSpeechcraftsInternal(ctx context.Context, accountName string) (speechcrafts []*entity.Speechcraft, err error) { - // 1. 查询客服账号 - account, err := dao.CustomerServiceAccount.FindByAccountName(ctx, accountName) - if err != nil { - return nil, gerror.Wrapf(err, "查询客服账号失败") - } - if account == nil { - return nil, gerror.New("客服账号不存在") - } - - // 2. 根据speechcraft_ids判断权限范围 - if len(account.SpeechcraftIds) == 0 { - // 空数组 = 动态权限,返回该租户下所有话术 - filter := bson.M{"isDeleted": false} - page := &beans.Page{PageNum: 1, PageSize: 10000} // 查询所有话术 - orderBy := []beans.OrderBy{} - _, err = commonMongo.DB().Find(ctx, filter, &speechcrafts, entity.SpeechcraftCollection, page, orderBy) - if err != nil { - return nil, gerror.Wrapf(err, "查询所有话术失败") - } - g.Log().Infof(ctx, "客服账号使用动态权限:account_name=%s, speechcraft_count=%d", accountName, len(speechcrafts)) - } else { - // 非空 = 指定权限,只返回指定ID的话术 - var objectIds []bson.ObjectID - for _, idStr := range account.SpeechcraftIds { - if oid, err := bson.ObjectIDFromHex(idStr); err == nil { - objectIds = append(objectIds, oid) - } - } - - if len(objectIds) > 0 { - filter := bson.M{ - "_id": bson.M{"$in": objectIds}, - "isDeleted": false, - } - page := &beans.Page{PageNum: 1, PageSize: 10000} // 查询指定话术 - orderBy := []beans.OrderBy{} - _, err = commonMongo.DB().Find(ctx, filter, &speechcrafts, entity.SpeechcraftCollection, page, orderBy) - if err != nil { - return nil, gerror.Wrapf(err, "查询指定话术失败") - } - } - g.Log().Infof(ctx, "客服账号使用指定权限:account_name=%s, allowed_count=%d, found_count=%d", - accountName, len(account.SpeechcraftIds), len(speechcrafts)) - } - - return speechcrafts, nil -} - -// GetAccessibleSpeechcrafts 获取客服账号可访问的话术列表(API接口) -// 参数: ctx - 上下文,req - 查询请求(包含账号名) -// 返回: res - 话术列表及权限类型(dynamic/specified),err - 错误信息 -// 功能: 查询客服账号可访问的话术,返回格式化的DTO列表和权限标识 -func (s *customerServiceAccount) GetAccessibleSpeechcrafts(ctx context.Context, req *dto.GetAccessibleSpeechcraftsReq) (res *dto.GetAccessibleSpeechcraftsRes, err error) { - res = &dto.GetAccessibleSpeechcraftsRes{} - - // 1. 查询客服账号 - account, err := dao.CustomerServiceAccount.FindByAccountName(ctx, req.AccountName) - if err != nil { - return nil, gerror.Wrapf(err, "查询客服账号失败") - } - if account == nil { - return nil, gerror.New("客服账号不存在") - } - - // 2. 获取话术列表 - speechcrafts, err := s.getAccessibleSpeechcraftsInternal(ctx, req.AccountName) - if err != nil { - return nil, err - } - - // 3. 转换为DTO格式 - for _, sc := range speechcrafts { - item := dto.SpeechcraftItem{ - Id: sc.Id.Hex(), - Tag: sc.Tag, - Content: sc.Content, - Direction: sc.Direction, - Platform: sc.Platform, - Keywords: sc.Keywords, - } - res.Speechcrafts = append(res.Speechcrafts, item) - } - - res.TotalCount = len(res.Speechcrafts) - - // 4. 标记权限类型 - if len(account.SpeechcraftIds) == 0 { - res.Permission = "dynamic" // 动态权限:所有话术 - } else { - res.Permission = "specified" // 指定权限:限定话术 - } - - return res, nil -} - -// RecreateRAGFlowConfig 重新创建RAGFlow配置 -// 参数: ctx - 上下文,req - 重建请求(包含账号名和平台) -// 返回: err - 错误信息 -// 功能: 删除旧配置并重新创建Dataset和Chat,用于修复创建客服账号时RAGFlow配置创建失败的情况,或chat被删除后自动重建 -func (s *customerServiceAccount) RecreateRAGFlowConfig(ctx context.Context, req *dto.RecreateRAGFlowConfigReq) (err error) { - // 1. 查询客服账号 - account, err := dao.CustomerServiceAccount.FindByAccountName(ctx, req.AccountName) - if err != nil { - return gerror.Wrap(err, "查询客服账号失败") - } - if account == nil { - return gerror.Newf("客服账号 %s 不存在", req.AccountName) - } - - // 2. 获取租户ID - tenantId := gconv.String(account.TenantId) - if tenantId == "" { - return gerror.New("客服账号的租户ID为空") - } - accountName := req.AccountName - - // 3. 查询现有RAGFlow配置(带租户隔离) - coll := commonMongo.GetDB().Collection(entity.RAGFlowConfigCollection) - filter := bson.M{"accountName": accountName, "tenantId": account.TenantId, "isDeleted": false} - var existingConfig entity.RAGFlowConfig - err = coll.FindOne(ctx, filter).Decode(&existingConfig) - - // 4. 如果已有配置且chat_id不为空,验证chat是否真实存在于RAGFlow - needRecreate := false - if err == nil && !existingConfig.Id.IsZero() && existingConfig.ChatId != "" { - // 调用RAGFlow API验证chat是否存在(通过UpdateChat验证) - ragflowClient := ragflow.GetGlobalClient() - if ragflowClient != nil { - // 尝试更新chat(即使不改任何参数,如果chat不存在会返回错误) - updateReq := &ragflow.UpdateChatReq{ - DatasetIds: existingConfig.DatasetIds, - } - updateErr := ragflowClient.UpdateChat(ctx, existingConfig.ChatId, updateReq) - if updateErr != nil { - // chat不存在或更新失败,需要重建 - g.Log().Warningf(ctx, "RAGFlow中的Chat已被删除或不可用,需要重建 - accountName: %s, old_chat_id: %s, error: %v", - accountName, existingConfig.ChatId, updateErr) - needRecreate = true - } else { - // chat存在且有效,无需重建 - g.Log().Infof(ctx, "发现现有有效的RAGFlow配置,无需重建 - accountName: %s, chat_id: %s", accountName, existingConfig.ChatId) - return nil - } - } else { - // ragflowClient为空,无法验证,默认需要重建 - needRecreate = true - } - } else { - // 配置不存在或chat_id为空,需要创建 - needRecreate = true - } - - // 5. 需要重建chat - if !needRecreate { - return nil - } - g.Log().Infof(ctx, "开始创建新Chat - accountName: %s", accountName) - - // 6. 确保租户知识库存在(调用公共方法) - datasetId, err := EnsureTenantDataset(ctx, tenantId) - if err != nil { - g.Log().Errorf(ctx, "确保租户知识库存在失败: %v", err) - return gerror.Wrap(err, "确保租户知识库存在失败") - } - - // 7. 创建Chat并保存RAGFlow配置(重建时使用账号的selfIdentity) - if err = s.createChatAndSaveConfig(ctx, req.AccountName, req.Platform, tenantId, datasetId, account.SelfIdentity); err != nil { - g.Log().Errorf(ctx, "创建Chat配置失败: %v", err) - return gerror.Wrap(err, "创建Chat配置失败") - } - - g.Log().Infof(ctx, "成功为客服账号 %s 创建RAGFlow配置", req.AccountName) - return nil -} - -// updateRAGFlowChatInfo 同步更新RAGFlow Chat的name和description -func (s *customerServiceAccount) updateRAGFlowChatInfo(ctx context.Context, account *entity.CustomerServiceAccount, req *dto.UpdateCustomerServiceAccountReq) (err error) { - // 1. 查询RAGFlow配置获取chatId - config, err := dao.RAGFlowConfig.FindByAccountName(ctx, account.AccountName) - if err != nil { - g.Log().Warningf(ctx, "查询RAGFlow配置失败,跳过同步: %v", err) - return nil - } - if config == nil || config.ChatId == "" { - g.Log().Warningf(ctx, "RAGFlow配置不存在或chatId为空,跳过同步") - return nil - } - - // 2. 构建新的chat名称和描述 - tenantId := gconv.String(account.TenantId) - - // 使用更新后的accountName和platform,如果没有提供则使用原值 - accountName := account.AccountName - platform := account.Platform - if req.AccountName != "" { - accountName = req.AccountName - } - if req.Platform != "" { - platform = req.Platform - } - - newChatName := fmt.Sprintf("%s_%s_%s", accountName, platform, tenantId) - - // 使用更新后的selfIdentity,如果没有提供则使用原值 - var newDescription string - if req.SelfIdentity != nil { - newDescription = *req.SelfIdentity - } else { - newDescription = account.SelfIdentity - } - - // 如果selfIdentity为空,使用默认生成方式 - if newDescription == "" { - newDescription = s.generateAssistantDescription(accountName, platform) - } - - g.Log().Infof(ctx, "[updateRAGFlowChatInfo] 同步更新RAGFlow Chat - chatId: %s, newName: %s, newDescription: %s", - config.ChatId, newChatName, newDescription) - - // 3. 调用RAGFlow API更新Chat - ragflowClient := ragflow.GetGlobalClient() - if ragflowClient == nil { - return gerror.New("RAGFlow客户端未初始化") - } - - updateReq := &ragflow.UpdateChatReq{ - Name: newChatName, - Description: newDescription, - } - - if err = ragflowClient.UpdateChat(ctx, config.ChatId, updateReq); err != nil { - g.Log().Errorf(ctx, "[updateRAGFlowChatInfo] 更新RAGFlow Chat失败: %v", err) - return gerror.Wrap(err, "更新RAGFlow Chat失败") - } - - g.Log().Infof(ctx, "[updateRAGFlowChatInfo] ✅ 成功同步更新RAGFlow Chat") - return nil -} diff --git a/service/data_service.go b/service/data_service.go deleted file mode 100644 index ec945e9..0000000 --- a/service/data_service.go +++ /dev/null @@ -1,69 +0,0 @@ -// Package service - 对话数据服务 -// 功能:对话记录查询、导出Excel -package service - -import ( - "context" - "customer-server/dao" - "customer-server/model/dto" - "customer-server/model/entity" - - "gitea.com/red-future/common/utils" - "github.com/gogf/gf/v2/os/gtime" -) - -var Data = new(data) - -type data struct{} - -// Add 添加数据 -// 参数: ctx - 上下文,req - 添加数据请求 -// 返回: res - 添加成功后的数据ID,err - 错误信息 -// 功能: 创建新的数据记录 -func (s *data) Add(ctx context.Context, req *dto.AddDataReq) (res *dto.AddDataRes, err error) { - data := &entity.Data{} - if err = utils.Struct(req, data); err != nil { - return - } - // 设置基础字段 - now := gtime.Now().Time - data.CreatedAt = &now // 取地址赋值给指针类型 - data.UpdatedAt = &now // 取地址赋值给指针类型 - data.IsDeleted = false - // 注意:Creator、Updater、TenantId 保持零值,不设置 - - if err = dao.Data.Insert(ctx, data); err != nil { - return - } - res = &dto.AddDataRes{Id: data.Id.Hex()} - return -} - -// Update 更新数据 -// 参数: ctx - 上下文,req - 更新数据请求 -// 返回: err - 错误信息 -// 功能: 更新数据记录内容 -func (s *data) Update(ctx context.Context, req *dto.UpdateDataReq) (err error) { - return dao.Data.Update(ctx, req) -} - -// // Delete 删除数据 -// func (s *data) Delete(ctx context.Context, req *dto.DeleteDataReq) (err error) { -// return dao.Data.Delete(ctx, req) -// } - -// List 获取数据列表 -// 参数: ctx - 上下文,req - 列表查询请求 -// 返回: res - 数据列表及分页信息,err - 错误信息 -// 功能: 分页查询数据记录 -func (s *data) List(ctx context.Context, req *dto.ListDataReq) (res *dto.ListDataRes, err error) { - list, total, err := dao.Data.List(ctx, req) - if err != nil { - return - } - res = &dto.ListDataRes{ - List: list, - Total: int(total), - } - return -} diff --git a/service/data_statistics_service.go b/service/data_statistics_service.go deleted file mode 100644 index b72e519..0000000 --- a/service/data_statistics_service.go +++ /dev/null @@ -1,168 +0,0 @@ -// Package service - 数据统计服务 -// 功能:对话数据统计分析、报表生成 -package service - -import ( - "archive/zip" - "bytes" - "context" - "customer-server/dao" - "customer-server/model/dto" - "customer-server/model/entity" - "regexp" - "strings" - - "gitea.com/red-future/common/utils" - "github.com/gogf/gf/v2/errors/gerror" - "github.com/gogf/gf/v2/os/gtime" - "github.com/gogf/gf/v2/util/gconv" -) - -var DataStatistics = new(dataStatistics) - -type dataStatistics struct{} - -// Add 添加数据统计 -// 参数: ctx - 上下文,req - 添加数据统计请求 -// 返回: res - 添加成功后的统计ID,err - 错误信息 -// 功能: 创建新的数据统计记录 -func (s *dataStatistics) Add(ctx context.Context, req *dto.AddDataStatisticsReq) (res *dto.AddDataStatisticsRes, err error) { - data := &entity.DataStatistics{} - if err = utils.Struct(req, data); err != nil { - return - } - // 使用 gtime 转换日期 - if dateTime := gtime.NewFromStr(req.Date); dateTime != nil { - date := dateTime.Time - data.Date = &date // 取地址赋值给指针类型 - } else { - return nil, gerror.New("日期格式错误") - } - // 设置基础字段 - now := gtime.Now().Time - data.CreatedAt = &now // 取地址赋值给指针类型 - data.UpdatedAt = &now // 取地址赋值给指针类型 - data.IsDeleted = false - // 注意:Creator、Updater、TenantId 保持零值,不设置 - - if err = dao.DataStatistics.Insert(ctx, data); err != nil { - return - } - res = &dto.AddDataStatisticsRes{Id: data.Id.Hex()} - return -} - -// Update 更新数据统计 -// 参数: ctx - 上下文,req - 更新数据统计请求 -// 返回: err - 错误信息 -// 功能: 更新数据统计记录内容 -func (s *dataStatistics) Update(ctx context.Context, req *dto.UpdateDataStatisticsReq) (err error) { - return dao.DataStatistics.Update(ctx, req) -} - -// List 获取数据统计列表 -// 参数: ctx - 上下文,req - 列表查询请求 -// 返回: res - 数据统计列表及分页信息,err - 错误信息 -// 功能: 分页查询数据统计记录 -func (s *dataStatistics) List(ctx context.Context, req *dto.ListDataStatisticsReq) (res *dto.ListDataStatisticsRes, err error) { - list, total, err := dao.DataStatistics.List(ctx, req) - if err != nil { - return - } - res = &dto.ListDataStatisticsRes{ - List: list, - Total: int(total), - } - return -} - -// Export 导出数据统计为ZIP文件 -// 参数: ctx - 上下文,req - 导出请求 -// 返回: zipData - ZIP文件字节数组,filename - 文件名,err - 错误信息 -// 功能: 将数据统计导出为ZIP文件,包含Excel文件 -func (s *dataStatistics) Export(ctx context.Context, req *dto.ExportDataStatisticsReq) (zipData []byte, filename string, err error) { - // 1. 查询所有符合条件的数据统计 - statistics, err := dao.DataStatistics.FindAllForExport(ctx, req) - if err != nil { - return nil, "", err - } - - if len(statistics) == 0 { - return nil, "", gerror.New("没有可导出的数据统计") - } - - // 2. 创建 ZIP 文件(内存中) - var buf bytes.Buffer - zipWriter := zip.NewWriter(&buf) - defer zipWriter.Close() - - // 3. 为每个数据统计生成 TXT 文件并添加到 ZIP - for _, stat := range statistics { - // 生成 TXT 内容 - txtContent := s.generateTxt(stat) - - // 生成文件名(清理并替换特殊字符) - dateStr := gtime.New(stat.Date).Format("Y-m-d") - cleanName := strings.ToValidUTF8(stat.CustomerServiceName, "未命名") - safeFilename := s.sanitizeFilename(cleanName) - if safeFilename == "" { - safeFilename = "statistics" - } - txtFilename := dateStr + "_" + safeFilename + "_" + stat.Id.Hex()[:8] + ".txt" - - // 添加文件到 ZIP - writer, err := zipWriter.Create(txtFilename) - if err != nil { - return nil, "", gerror.Newf("创建ZIP文件失败: %v", err) - } - - if _, err := writer.Write([]byte(txtContent)); err != nil { - return nil, "", gerror.Newf("写入ZIP文件失败: %v", err) - } - } - - // 5. 生成下载文件名 - timestamp := gtime.Now().Format("Ymd_His") - filename = "data_statistics_export_" + timestamp + ".zip" - - return buf.Bytes(), filename, nil -} - -// generateTxt 生成数据统计的 TXT 内容 -func (s *dataStatistics) generateTxt(stat *entity.DataStatistics) string { - var builder strings.Builder - - builder.WriteString("日期: " + gtime.New(stat.Date).Format("Y-m-d") + "\n") - builder.WriteString("客服ID: " + stat.AccountName + "\n") - builder.WriteString("客服名称: " + stat.CustomerServiceName + "\n") - builder.WriteString("客服平台: " + stat.CustomerServicePlatform + "\n") - builder.WriteString("\n=== 统计数据 ===\n") - builder.WriteString("进线数: " + gconv.String(stat.InboundCount) + "\n") - builder.WriteString("开口数: " + gconv.String(stat.ActiveCount) + "\n") - builder.WriteString("接待数: " + gconv.String(stat.ServedCount) + "\n") - builder.WriteString("发名片数: " + gconv.String(stat.ContactCardSentCount) + "\n") - builder.WriteString("发留资卡数: " + gconv.String(stat.NameCardSentCount) + "\n") - builder.WriteString("留资数: " + gconv.String(stat.LeftContactInfoCount) + "\n") - builder.WriteString("\n=== 响应率 ===\n") - builder.WriteString("30s响应率: " + gconv.String(stat.ResponseRate30s) + "%\n") - builder.WriteString("60s响应率: " + gconv.String(stat.ResponseRate60s) + "%\n") - builder.WriteString("360s响应率: " + gconv.String(stat.ResponseRate360s) + "%\n") - builder.WriteString("\n---\n") - builder.WriteString("记录ID: " + stat.Id.Hex() + "\n") - - return builder.String() -} - -// sanitizeFilename 清理文件名,移除或替换不安全的字符 -func (s *dataStatistics) sanitizeFilename(filename string) string { - // 移除或替换特殊字符 - reg := regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`) - safe := reg.ReplaceAllString(filename, "_") - - // 限制文件名长度 - if len(safe) > 50 { - safe = safe[:50] - } - - return strings.TrimSpace(safe) -} diff --git a/service/dataset_service.go b/service/dataset_service.go deleted file mode 100644 index 9df8f92..0000000 --- a/service/dataset_service.go +++ /dev/null @@ -1,197 +0,0 @@ -package service - -import ( - "context" - "customer-server/dao" - "customer-server/model/entity" - "fmt" - - "gitea.com/red-future/common/ragflow" - "github.com/gogf/gf/v2/errors/gerror" - "github.com/gogf/gf/v2/frame/g" -) - -// EnsureTenantDataset 确保租户知识库存在 -// 逻辑: -// 1. 用租户ID查找MongoDB中的知识库ID -// 2. 测试知识库ID是否在RAGFlow中可用 -// 3. 如果不可用,就直接根据名字在RAGFlow中查找 -// 4. 找到了就把知识库ID存到MongoDB -// 5. 如果名字也找不到,就创建新的 -// 6. 把新的ID和名字存到MongoDB -func EnsureTenantDataset(ctx context.Context, tenantId string) (datasetId string, err error) { - ragflowClient := ragflow.GetGlobalClient() - datasetName := fmt.Sprintf("dataset_tenant_%s", tenantId) - - // 1. 用租户ID查找MongoDB中的知识库ID - datasetId, err = dao.RAGFlowConfig.FindDatasetIdByTenant(ctx, tenantId) - if err != nil && err.Error() != "mongo: no documents in result" { - return "", gerror.Wrap(err, "查询租户知识库ID失败") - } - - // 2. 测试知识库ID是否在RAGFlow中可用 - if datasetId != "" { - g.Log().Infof(ctx, "租户%s已有知识库ID: %s,测试是否可用", tenantId, datasetId) - - listReq := &ragflow.ListDatasetsReq{Page: 1, PageSize: 100} - listRes, listErr := ragflowClient.ListDatasets(ctx, listReq) - - if listErr == nil && listRes != nil { - g.Log().Infof(ctx, "ListDatasets返回%d个知识库", len(listRes.Data)) - for i, ds := range listRes.Data { - g.Log().Infof(ctx, "知识库[%d]: id=%s, name=%s", i, ds.Id, ds.Name) - } - - // 测试ID是否可用 - for _, ds := range listRes.Data { - if ds.Id == datasetId { - g.Log().Infof(ctx, "知识库ID可用: %s", datasetId) - return datasetId, nil - } - } - - // 3. ID不可用,直接根据名字查找 - g.Log().Warningf(ctx, "知识库ID不可用,根据名字查找: %s", datasetName) - for _, ds := range listRes.Data { - if ds.Name == datasetName { - datasetId = ds.Id - g.Log().Infof(ctx, "找到同名知识库: name=%s, id=%s", datasetName, datasetId) - - // 4. 把知识库ID存到MongoDB - if updateErr := updateDatasetIdInMongo(ctx, tenantId, datasetId); updateErr != nil { - g.Log().Errorf(ctx, "更新MongoDB失败: %v", updateErr) - } - - return datasetId, nil - } - } - - g.Log().Warningf(ctx, "未找到同名知识库: %s", datasetName) - } else { - g.Log().Errorf(ctx, "ListDatasets失败: %v", listErr) - } - } - - // 5. 名字也找不到,创建新的 - g.Log().Infof(ctx, "创建新知识库: name=%s", datasetName) - - // 从config读取embedding模型,如果未配置则使用默认值 - embeddingModel := g.Cfg().MustGet(ctx, "ragflow.embedding_model", "text-embedding-v4").String() - - createReq := &ragflow.CreateDatasetReq{ - Name: datasetName, - EmbeddingModel: embeddingModel, - ChunkMethod: "naive", - } - - dataset, err := ragflowClient.CreateDataset(ctx, createReq) - - // 不依赖CreateDataset的返回值,因为RAGFlow可能返回code=0但data=null - // 立即用ListDatasets查找新创建的知识库来确认 - g.Log().Infof(ctx, "创建请求已发送,立即查找确认: name=%s", datasetName) - - listReq2 := &ragflow.ListDatasetsReq{Page: 1, PageSize: 100} - listRes2, listErr2 := ragflowClient.ListDatasets(ctx, listReq2) - - if listErr2 == nil && listRes2 != nil { - for _, ds := range listRes2.Data { - if ds.Name == datasetName { - datasetId = ds.Id - g.Log().Infof(ctx, "确认知识库已创建: id=%s, name=%s", datasetId, datasetName) - goto saveRecord - } - } - } - - // 如果ListDatasets也找不到,说明创建真的失败了 - if err != nil { - return "", gerror.Wrapf(err, "创建知识库失败且无法确认") - } - if dataset == nil || dataset.Id == "" { - return "", gerror.Newf("创建知识库失败,无法通过ListDatasets确认: name=%s", datasetName) - } - - // 使用CreateDataset返回的ID(兜底) - datasetId = dataset.Id - g.Log().Infof(ctx, "知识库创建成功: id=%s, name=%s", datasetId, datasetName) - -saveRecord: - - // 6. 把新的ID和名字存到MongoDB - if err := saveSystemDatasetRecord(ctx, tenantId, datasetId, datasetName); err != nil { - g.Log().Errorf(ctx, "保存到MongoDB失败: %v", err) - return "", gerror.Wrap(err, "保存到MongoDB失败") - } - - return datasetId, nil -} - -// uploadPlaceholderDocument 上传占位文档 -func uploadPlaceholderDocument(ctx context.Context, datasetId, tenantId string) error { - ragflowClient := ragflow.GetGlobalClient() - placeholderContent := "欢迎使用智能客服系统。这是一个占位文档,实际产品和话术会在后续上传。" - placeholderFilename := fmt.Sprintf("placeholder_tenant_%s.txt", tenantId) - - documentId, err := ragflowClient.UploadDocumentFromText(ctx, datasetId, placeholderContent, placeholderFilename) - if err != nil { - return gerror.Wrap(err, "上传占位文档失败") - } - - g.Log().Infof(ctx, "占位文档上传成功: document_id=%s", documentId) - - if parseErr := ragflowClient.ParseDocuments(ctx, datasetId, []string{documentId}); parseErr != nil { - g.Log().Warningf(ctx, "解析占位文档失败: %v", parseErr) - return parseErr - } - - g.Log().Infof(ctx, "占位文档解析已启动(后台异步进行)") - return nil -} - -// saveSystemDatasetRecord 保存系统占位记录到MongoDB -func saveSystemDatasetRecord(ctx context.Context, tenantId, datasetId, datasetName string) error { - // 先检查是否已有记录 - existing, err := dao.RAGFlowConfig.FindDatasetIdByTenant(ctx, tenantId) - if err == nil && existing != "" { - g.Log().Infof(ctx, "租户%s已有知识库记录,无需创建系统占位记录", tenantId) - return nil - } - - // 创建系统占位记录 - systemAccountName := fmt.Sprintf("_system_tenant_%s", tenantId) - config := &entity.RAGFlowConfig{ - AccountName: systemAccountName, - Platform: "system", - DatasetId: datasetId, - DatasetIds: []string{datasetId}, - DatasetName: datasetName, - ChatId: "", // 系统记录不需要Chat - Prompt: "", - DocumentIds: []string{}, - SimilarityThreshold: 0.2, - KeywordsSimilarityWeight: 0.7, - TopN: 8, - EmptyResponse: "抱歉,我暂时无法回答这个问题。", - SyncStatus: "synced", - } - // 统一使用string类型存储tenantId到MongoDB - config.TenantId = tenantId - config.IsDeleted = false - - if err := dao.RAGFlowConfig.Insert(ctx, config); err != nil { - return gerror.Wrap(err, "插入系统占位记录失败") - } - - g.Log().Infof(ctx, "系统占位记录已保存: tenant_id=%s, dataset_id=%s", tenantId, datasetId) - return nil -} - -// updateDatasetIdInMongo 更新MongoDB中租户的所有datasetId记录 -func updateDatasetIdInMongo(ctx context.Context, tenantId, newDatasetId string) error { - // 更新该租户所有ragflow_config记录的datasetId - if err := dao.RAGFlowConfig.UpdateDatasetIdByTenant(ctx, tenantId, newDatasetId); err != nil { - return gerror.Wrap(err, "更新租户datasetId失败") - } - g.Log().Infof(ctx, "已更新租户%s的所有记录的datasetId: %s", tenantId, newDatasetId) - return nil -} diff --git a/service/jaegertesttemp.go b/service/jaegertesttemp.go deleted file mode 100644 index 664b57b..0000000 --- a/service/jaegertesttemp.go +++ /dev/null @@ -1,17 +0,0 @@ -package service - -import ( - "context" - - "gitea.com/red-future/common/jaeger" - "github.com/gogf/gf/v2/errors/gerror" -) - -// JaegerTestTemp 测试 Jaeger 错误记录(临时,测试后删除) -func JaegerTestTemp(ctx context.Context, msg string) { - ctx, span := jaeger.NewSpan(ctx, "test.jaeger.error") - defer span.End() - - err := gerror.New(msg) - jaeger.RecordError(ctx, err, "测试Jaeger错误记录") -} diff --git a/service/product_service.go b/service/product_service.go deleted file mode 100644 index d22b6fc..0000000 --- a/service/product_service.go +++ /dev/null @@ -1,891 +0,0 @@ -// Package service - 产品服务 -// 功能:产品的增删改查、ZIP导入/导出、绑定/解绑客服账号、同步到RAGFlow、重试消费者 -package service - -import ( - "archive/zip" - "bytes" - "context" - "customer-server/dao" - "customer-server/model/dto" - "customer-server/model/entity" - "customer-server/util" - "encoding/json" - "fmt" - "io" - "mime/multipart" - "strings" - "unicode/utf8" - - "gitea.com/red-future/common/jaeger" - "gitea.com/red-future/common/rabbitmq" - "gitea.com/red-future/common/ragflow" - "gitea.com/red-future/common/utils" - "github.com/gogf/gf/v2/errors/gerror" - "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/os/glog" - "github.com/gogf/gf/v2/os/grpool" - "github.com/gogf/gf/v2/os/gtime" - "github.com/gogf/gf/v2/util/gconv" - "go.mongodb.org/mongo-driver/v2/bson" -) - -var ( - Product = new(product) - productGrpool = grpool.New(50) // 文档解析协程池,最大50并发 -) - -type product struct{} - -// Add 添加产品 -// 参数: ctx - 上下文,req - 添加产品请求(包含产品名称、描述、价格等) -// 返回: res - 添加成功后的产品ID和RAGFlow文档ID,err - 错误信息 -// 功能: 创建产品记录并自动上传到RAGFlow产品知识库(独立于话术知识库) -func (s *product) Add(ctx context.Context, req *dto.AddProductReq) (res *dto.AddProductRes, err error) { - // 校验产品名称长度 - if utf8.RuneCountInString(req.Name) > 64 { - return nil, gerror.New("产品名称必须在64字以内") - } - - // 校验产品详情长度 - if utf8.RuneCountInString(req.Description) > 8192 { - return nil, gerror.New("产品详情必须在8192字以内") - } - - // 去重检查:同一租户下名称唯一 - existing, err := dao.Product.FindByName(ctx, req.Name) - if err != nil { - return nil, gerror.Wrap(err, "检查产品重复失败") - } - if existing != nil { - return nil, gerror.Newf("产品名称已存在:name=%s, id=%s", req.Name, existing.Id.Hex()) - } - - data := &entity.Product{} - if err = utils.Struct(req, data); err != nil { - return - } - // 先从token获取租户信息(在Insert之前) - user, err := util.GetTenantInfo(ctx) - if err != nil { - return nil, gerror.Wrap(err, "获取租户信息失败") - } - tenantId := gconv.String(user.TenantId) - if tenantId == "" { - return nil, gerror.New("租户ID为空") - } - - // 确保产品知识库存在(检查话术知识库是否存在,作为租户是否初始化的标志) - speechcraftDatasetId, err := dao.RAGFlowConfig.FindDatasetIdByTenant(ctx, tenantId) - if err != nil || speechcraftDatasetId == "" { - return nil, gerror.Newf("租户知识库不存在,请先创建客服账号: tenant_id=%s", tenantId) - } - - // 设置基础字段 - now := gtime.Now().Time - data.CreatedAt = &now // 取地址赋值给指针类型 - data.UpdatedAt = &now // 取地址赋值给指针类型 - data.IsDeleted = false - // 统一使用string类型存储tenantId到MongoDB - data.TenantId = tenantId - - // 插入产品到MongoDB - _, err = dao.Product.Insert(ctx, data) - if err != nil { - return nil, err - } - - // 确保租户知识库存在(产品和话术共享租户知识库) - // 使用dataset_service提供的统一方法,自动处理创建、查找、保存等逻辑 - datasetId, err := EnsureTenantDataset(ctx, tenantId) - if err != nil { - g.Log().Errorf(ctx, "确保租户知识库失败: %v", err) - return nil, gerror.Wrap(err, "获取租户知识库失败") - } - g.Log().Infof(ctx, "租户%s的知识库ID: %s", tenantId, datasetId) - - // 同步上传到RAGFlow - ragflowClient := ragflow.GetGlobalClient() - g.Log().Infof(ctx, "准备上传产品到RAGFlow: product_id=%s, dataset_id=%s, name=%s", data.Id.Hex(), datasetId, data.Name) - filename := fmt.Sprintf("%s.txt", data.Name) - documentId, err := ragflowClient.UploadDocumentFromText(ctx, datasetId, data.Description, filename) - if err != nil { - // 回滚:删除刚插入的产品 - dao.MongoDAO.Delete(ctx, bson.M{"_id": data.Id}, entity.ProductCollection) - g.Log().Errorf(ctx, "产品上传RAGFlow失败: product_id=%s, dataset_id=%s, error=%v", data.Id.Hex(), datasetId, err) - jaeger.RecordError(ctx, err, "产品上传RAGFlow失败") - return nil, gerror.Wrap(err, "文档上传到知识库失败") - } - - // 异步触发解析(grpool自动管理goroutine生命周期,WithoutCancel保留追踪避免取消) - productGrpool.Add(ctx, func(ctx context.Context) { - parseCtx := context.WithoutCancel(ctx) - if err := ragflowClient.ParseDocuments(parseCtx, datasetId, []string{documentId}); err != nil { - g.Log().Errorf(parseCtx, "文档解析失败: document_id=%s, error=%v", documentId, err) - } else { - g.Log().Infof(parseCtx, "文档解析成功: document_id=%s", documentId) - } - }) - - // 更新MongoDB的RagSyncRecords数组(使用空accountName表示租户级文档) - syncTime := gtime.Now().Format("Y-m-d H:i:s") - record := entity.RagSyncRecord{ - AccountName: "", // 空表示租户级文档 - RagDocumentId: documentId, - RagSyncStatus: "synced", - SyncTime: syncTime, - RetryCount: 0, - } - filter := bson.M{"_id": data.Id} - update := bson.M{ - "$set": bson.M{ - "ragSyncRecords": []entity.RagSyncRecord{record}, - "ragLastSyncTime": syncTime, - "updatedAt": gtime.Now().Time, - }, - } - if _, _, err = dao.MongoDAO.UpdateOne(ctx, filter, update, entity.ProductCollection); err != nil { - g.Log().Errorf(ctx, "更新产品RagSyncRecords失败: %v", err) - // 不回滚,文档已上传成功 - } - - g.Log().Infof(ctx, "产品添加成功并上传到知识库: product_id=%s, document_id=%s", data.Id.Hex(), documentId) - res = &dto.AddProductRes{Id: data.Id.Hex()} - return -} - -// Update 更新产品 -// 参数: ctx - 上下文,req - 更新产品请求(包含产品ID和待更新字段) -// 返回: err - 错误信息 -// 功能: 更新产品信息并同步到RAGFlow,支持文档删除重建 -func (s *product) Update(ctx context.Context, req *dto.UpdateProductReq) (err error) { - // 如果更新了产品名称,校验长度 - if req.Name != "" && utf8.RuneCountInString(req.Name) > 64 { - return gerror.New("产品名称必须在64字以内") - } - - // 如果更新了产品详情,校验长度 - if req.Description != "" && utf8.RuneCountInString(req.Description) > 8192 { - return gerror.New("产品详情必须在8192字以内") - } - - // 去重检查:如果修改名称,检查是否与其他产品重复 - if req.Name != "" { - existing, err := dao.Product.FindByName(ctx, req.Name) - if err != nil { - return gerror.Wrap(err, "检查产品重复失败") - } - if existing != nil && existing.Id.Hex() != req.Id { - return gerror.Newf("产品名称已存在:name=%s, id=%s", req.Name, existing.Id.Hex()) - } - } - - return dao.Product.Update(ctx, req) -} - -// Delete 删除产品 -// 参数: ctx - 上下文,req - 删除产品请求(包含产品ID) -// 返回: err - 错误信息 -// 功能: 逻辑删除产品记录并从RAGFlow移除对应文档 -func (s *product) Delete(ctx context.Context, req *dto.DeleteProductReq) (err error) { - g.Log().Infof(ctx, "[Delete] 开始删除产品 - productId: %s", req.Id) - - // 1. 查询产品,获取RAGFlow同步记录(使用原生查询,避免租户过滤) - objectId, err := bson.ObjectIDFromHex(req.Id) - if err != nil { - return gerror.Wrap(err, "无效的产品ID") - } - - var product entity.Product - filter := bson.M{"_id": objectId, "isDeleted": false} - err = dao.MongoDAO.FindOne(ctx, filter, &product, entity.ProductCollection) - if err != nil { - if err.Error() == "mongo: no documents in result" { - return gerror.New("产品不存在") - } - return gerror.Wrap(err, "查询产品失败") - } - - g.Log().Infof(ctx, "[Delete] 查询到产品 - name: %s, ragSyncRecords数量: %d", product.Name, len(product.RagSyncRecords)) - - // 2. 删除RAGFlow中的文档 - if len(product.RagSyncRecords) > 0 { - ragflowClient := ragflow.GetGlobalClient() - if ragflowClient != nil { - tenantId := gconv.String(product.TenantId) - - // 查询租户的dataset_id - datasetId, err := dao.RAGFlowConfig.FindDatasetIdByTenant(ctx, tenantId) - if err != nil { - g.Log().Warningf(ctx, "查询租户知识库ID失败: %v", err) - } else if datasetId != "" { - // 收集所有需要删除的document_id - var documentIds []string - for _, record := range product.RagSyncRecords { - if record.RagDocumentId != "" { - documentIds = append(documentIds, record.RagDocumentId) - } - } - - // 批量删除RAGFlow文档 - if len(documentIds) > 0 { - if err := ragflowClient.DeleteDocument(ctx, datasetId, documentIds); err != nil { - g.Log().Errorf(ctx, "删除RAGFlow文档失败: %v, document_ids: %v", err, documentIds) - // 不阻断删除流程,记录错误后继续 - } else { - g.Log().Infof(ctx, "成功删除RAGFlow文档: count=%d", len(documentIds)) - } - } - } - } - } - - // 3. 软删除MongoDB记录 - return dao.Product.Delete(ctx, req) -} - -// List 获取产品列表 -// 参数: ctx - 上下文,req - 列表查询请求(支持分页、关键词搜索) -// 返回: res - 产品列表及分页信息,err - 错误信息 -// 功能: 分页查询产品记录,支持按名称、描述模糊搜索 -func (s *product) List(ctx context.Context, req *dto.ListProductReq) (res *dto.ListProductRes, err error) { - list, total, err := dao.Product.List(ctx, req) - if err != nil { - return - } - res = &dto.ListProductRes{ - List: list, - Total: int(total), - } - return -} - -// Export 导出产品为ZIP文件 -// 参数: ctx - 上下文,req - 导出请求(包含筛选条件) -// 返回: zipData - ZIP文件字节数组,filename - 文件名,err - 错误信息 -// 功能: 将产品数据导出为ZIP文件,包含JSON格式的产品列表 -func (s *product) Export(ctx context.Context, req *dto.ExportProductReq) (zipData []byte, filename string, err error) { - // 清理输入参数,防止非法 UTF-8 字符 - cleanName := strings.ToValidUTF8(req.Name, "") - - // 1. 查询所有符合条件的产品 - products, err := dao.Product.FindAllForExport(ctx, cleanName) - if err != nil { - return nil, "", err - } - - if len(products) == 0 { - return nil, "", gerror.New("没有可导出的产品") - } - - // 清理所有产品数据,确保 UTF-8 有效(防止数据库中的脏数据) - for i := range products { - products[i].Name = strings.ToValidUTF8(products[i].Name, "") - products[i].Description = strings.ToValidUTF8(products[i].Description, "") - // RagDocumentId字段在RagSyncRecords中,不在Product主体 - } - - // 2. 创建 ZIP 文件(内存中) - var buf bytes.Buffer - zipWriter := zip.NewWriter(&buf) - defer zipWriter.Close() - - // 3. 为每个产品生成 TXT 文件并添加到 ZIP - for _, product := range products { - // 生成 TXT 内容(产品详情) - txtContent := s.generateTxt(product) - - // 文件名就是产品名称(清理特殊字符) - cleanName := strings.ToValidUTF8(product.Name, "未命名") - safeFilename := s.sanitizeFilename(cleanName) - if safeFilename == "" { - safeFilename = "product" - } - txtFilename := safeFilename + ".txt" - - // 添加文件到 ZIP - writer, err := zipWriter.Create(txtFilename) - if err != nil { - return nil, "", gerror.Newf("创建ZIP文件失败: %v", err) - } - - if _, err := writer.Write([]byte(txtContent)); err != nil { - return nil, "", gerror.Newf("写入ZIP文件失败: %v", err) - } - } - - // 5. 生成下载文件名 - timestamp := gtime.Now().Format("Ymd_His") - filename = "products_export_" + timestamp + ".zip" - - return buf.Bytes(), filename, nil -} - -// generateTxt 将产品转换为 TXT 格式 -// 新格式:文件名=产品名称,内容=产品详情 -func (s *product) generateTxt(product *entity.Product) string { - // 清理产品详情,确保 UTF-8 有效 - cleanDescription := strings.ToValidUTF8(product.Description, "") - - // 直接返回产品详情 - if cleanDescription != "" { - return cleanDescription - } - - // 如果没有详情,返回空字符串 - return "" -} - -// sanitizeFilename 清理文件名中的特殊字符 -func (s *product) sanitizeFilename(name string) string { - // 替换不安全的文件名字符 - replacer := map[rune]rune{ - '/': '_', - '\\': '_', - ':': '_', - '*': '_', - '?': '_', - '"': '_', - '<': '_', - '>': '_', - '|': '_', - } - - // 预分配容量,避免循环中动态扩容 - result := make([]rune, 0, len(name)) - for _, char := range name { - if newChar, exists := replacer[char]; exists { - result = append(result, newChar) - } else { - result = append(result, char) - } - } - - filename := string(result) - // 限制文件名长度 - if utf8.RuneCountInString(filename) > 50 { - runes := []rune(filename) - filename = string(runes[:50]) - } - - return filename -} - -// Import 从ZIP文件导入产品 -// 参数: ctx - 上下文,file - 上传的ZIP文件 -// 返回: res - 导入结果(成功和失败数量),err - 错误信息 -// 功能: 从ZIP文件批量导入产品数据并同步到RAGFlow,失败记录加入重试队列 -func (s *product) Import(ctx context.Context, file *multipart.FileHeader) (res *dto.ImportProductRes, err error) { - res = &dto.ImportProductRes{ - SuccessCount: 0, - FailCount: 0, - FailReasons: []string{}, - } - - // 1. 获取租户信息 - user, err := util.GetTenantInfo(ctx) - if err != nil { - return nil, gerror.Wrap(err, "获取租户信息失败") - } - tenantId := gconv.String(user.TenantId) - if tenantId == "" { - return nil, gerror.New("租户ID为空") - } - - // 2. 打开上传的文件 - uploadedFile, err := file.Open() - if err != nil { - return nil, gerror.Newf("无法打开上传的文件: %v", err) - } - defer uploadedFile.Close() - - // 3. 读取文件内容到内存 - fileData, err := io.ReadAll(uploadedFile) - if err != nil { - return nil, gerror.Newf("读取文件失败: %v", err) - } - - // 4. 解析 ZIP 文件 - zipReader, err := zip.NewReader(bytes.NewReader(fileData), int64(len(fileData))) - if err != nil { - return nil, gerror.Newf("无法解析ZIP文件: %v", err) - } - - // 4. 遍历 ZIP 中的所有文件 - for _, zipFile := range zipReader.File { - // 只处理 .txt 文件 - if !strings.HasSuffix(strings.ToLower(zipFile.Name), ".txt") { - continue - } - - // 读取 TXT 文件内容(产品详情) - txtContent, err := s.readZipFile(zipFile) - if err != nil { - res.FailCount++ - res.FailReasons = append(res.FailReasons, "文件 "+zipFile.Name+" 读取失败: "+err.Error()) - continue - } - - // 从文件名提取产品名称(移除 .txt 后缀) - productName := strings.TrimSuffix(zipFile.Name, ".txt") - productName = strings.TrimSpace(productName) - - // 创建产品数据 - productData, err := s.parseSimpleTxt(productName, txtContent) - if err != nil { - res.FailCount++ - res.FailReasons = append(res.FailReasons, "文件 "+zipFile.Name+" 解析失败: "+err.Error()) - continue - } - - // 校验产品名称和详情长度 - if utf8.RuneCountInString(productData.Name) > 64 { - res.FailCount++ - res.FailReasons = append(res.FailReasons, "文件 "+zipFile.Name+": 产品名称超过64字") - continue - } - if utf8.RuneCountInString(productData.Description) > 8192 { - res.FailCount++ - res.FailReasons = append(res.FailReasons, "文件 "+zipFile.Name+": 产品详情超过8192字") - continue - } - - // 设置基础字段 - now := gtime.Now().Time - productData.CreatedAt = &now // 取地址赋值给指针类型 - productData.UpdatedAt = &now // 取地址赋值给指针类型 - productData.IsDeleted = false - // 统一使用string类型存储tenantId到MongoDB - productData.TenantId = tenantId - - // 插入数据库 - _, err = dao.Product.Insert(ctx, productData) - if err != nil { - res.FailCount++ - res.FailReasons = append(res.FailReasons, "文件 "+zipFile.Name+" 数据库插入失败: "+err.Error()) - continue - } - - // 同步上传到RAGFlow产品知识库(使用外层已声明的tenantId变量) - if tenantId != "" { - datasetId := fmt.Sprintf("dataset_product_tenant_%s", tenantId) - ragflowClient := ragflow.GetGlobalClient() - if ragflowClient != nil { - filename := fmt.Sprintf("%s.txt", productData.Name) - documentId, uploadErr := ragflowClient.UploadDocumentFromText(ctx, datasetId, productData.Description, filename) - if uploadErr != nil { - // 上传失败:回滚删除MongoDB记录 - dao.MongoDAO.Delete(ctx, bson.M{"_id": productData.Id}, entity.ProductCollection) - res.FailCount++ - res.FailReasons = append(res.FailReasons, "文件 "+zipFile.Name+" 上传知识库失败: "+uploadErr.Error()) - continue - } - - // 更新ragDocumentId - filter := bson.M{"_id": productData.Id} - update := bson.M{"$set": bson.M{"ragDocumentId": documentId}} - dao.MongoDAO.UpdateOne(ctx, filter, update, entity.ProductCollection) - - g.Log().Infof(ctx, "ZIP产品上传成功: name=%s, document_id=%s", productData.Name, documentId) - } - } - - res.SuccessCount++ - } - - return res, nil -} - -// readZipFile 读取 ZIP 文件中的单个文件内容 -func (s *product) readZipFile(file *zip.File) (string, error) { - reader, err := file.Open() - if err != nil { - return "", err - } - defer reader.Close() - - content, err := io.ReadAll(reader) - if err != nil { - return "", err - } - - return string(content), nil -} - -// parseSimpleTxt 解析简化格式的 TXT 文件 -// 文件名=产品名称,内容=产品详情 -func (s *product) parseSimpleTxt(productName string, description string) (*entity.Product, error) { - product := &entity.Product{} - - // 清理并验证产品名称 - product.Name = strings.TrimSpace(strings.ToValidUTF8(productName, "")) - if product.Name == "" { - return nil, gerror.New("产品名称不能为空") - } - - // 清理产品详情 - product.Description = strings.TrimSpace(strings.ToValidUTF8(description, "")) - - return product, nil -} - -// parseTxt 解析 TXT 文件内容为产品实体(旧格式兼容) -func (s *product) parseTxt(content string) (*entity.Product, error) { - product := &entity.Product{} - lines := strings.Split(content, "\n") - - var inDescription bool - var descriptionLines []string - - for _, line := range lines { - line = strings.TrimSpace(line) - - // 跳过空行 - if line == "" { - continue - } - - // 解析标题 - if strings.HasPrefix(line, "=== ") && strings.HasSuffix(line, " ===") { - product.Name = strings.TrimSpace(line[4 : len(line)-4]) - continue - } - - // 跳过基本信息标记 - if line == "【基本信息】" { - inDescription = false - continue - } - - // 产品详情开始 - if line == "【产品详情】" { - inDescription = true - continue - } - - // 解析基本信息字段 - if !inDescription { - // 跳过系统生成的字段(产品ID、创建时间等) - if strings.HasPrefix(line, "产品ID:") || - strings.HasPrefix(line, "创建时间:") || - strings.HasPrefix(line, "更新时间:") || - strings.HasPrefix(line, "导出时间:") { - continue - } - - // 解析 RAGFlow 文档 ID - if strings.HasPrefix(line, "RAGFlow文档ID:") { - // RAGFlow文档ID存储在RagSyncRecords中 - continue - } - } - - // 收集产品详情 - if inDescription { - // 跳过分隔线和导出时间 - if strings.Contains(line, "==================") || strings.HasPrefix(line, "导出时间:") { - continue - } - if line != "暂无产品详情" { - descriptionLines = append(descriptionLines, line) - } - } - } - - // 拼接产品详情 - product.Description = strings.Join(descriptionLines, "\n") - - // 校验必填字段 - if product.Name == "" { - return nil, gerror.New("产品名称不能为空") - } - - return product, nil -} - -// BindToCustomerServices 绑定产品到多个客服账号 -func (p *product) BindToCustomerServices(ctx context.Context, req *dto.BindProductReq) (res *dto.BindProductRes, err error) { - res = &dto.BindProductRes{} - - // 1. 查询产品 - product, err := dao.Product.GetById(ctx, req.ProductId) - if err != nil { - return nil, gerror.Wrapf(err, "查询产品失败") - } - if product == nil { - return nil, gerror.New("产品不存在") - } - - // 2. 构建已存在的绑定map(去重) - existingMap := make(map[string]bool) - for _, csId := range product.AccountNames { - existingMap[csId] = true - } - - // 3. 过滤并添加新绑定 - var newBindings []string - var failedIds []string - - for _, csId := range req.AccountNames { - // 检查去重:customer_service_id是否已存在 - if existingMap[csId] { - failedIds = append(failedIds, csId) - g.Log().Warningf(ctx, "客服账号 %s 已绑定该产品,跳过", csId) - continue - } - - // 验证客服账号是否存在 - csAccount, err := dao.CustomerServiceAccount.FindByAccountName(ctx, csId) - if err != nil || csAccount == nil { - failedIds = append(failedIds, csId) - g.Log().Warningf(ctx, "客服账号 %s 不存在或已删除,跳过", csId) - continue - } - - newBindings = append(newBindings, csId) - } - - // 4. 如果没有新的绑定,直接返回 - if len(newBindings) == 0 { - res.SuccessCount = 0 - res.FailedIds = failedIds - res.Message = "所有客服账号均已绑定或不存在" - return res, nil - } - - // 5. 更新产品绑定 - product.AccountNames = append(product.AccountNames, newBindings...) - if err = dao.Product.UpdateEntity(ctx, product); err != nil { - return nil, gerror.Wrapf(err, "更新产品绑定失败") - } - - // 6. 同步到RAGFlow(自动创建知识库) - for _, csId := range newBindings { - // 获取客服账号信息以获取tenant_id - csAccount, err := dao.CustomerServiceAccount.FindByAccountName(ctx, csId) - if err != nil || csAccount == nil { - g.Log().Errorf(ctx, "获取客服账号信息失败: %s", csId) - continue - } - - // 同步到RAGFlow(会自动创建知识库) - tenantId := gconv.String(csAccount.TenantId) - _, err = p.SyncToRAGFlow(ctx, req.ProductId, csId, tenantId) - if err != nil { - g.Log().Errorf(ctx, "同步到RAGFlow失败: product_id=%s, cs_id=%s, error=%v", req.ProductId, csId, err) - // 不阻断绑定流程,失败会进入重试队列 - } - } - - res.SuccessCount = len(newBindings) - res.FailedIds = failedIds - res.Message = "绑定成功" - return -} - -// UnbindFromCustomerService 从客服账号解绑产品 -func (p *product) UnbindFromCustomerService(ctx context.Context, req *dto.UnbindProductReq) (res *dto.UnbindProductRes, err error) { - res = &dto.UnbindProductRes{} - - product, err := dao.Product.GetById(ctx, req.ProductId) - if err != nil { - return nil, gerror.Wrapf(err, "查询产品失败") - } - if product == nil { - return nil, gerror.New("产品不存在") - } - - // 查找并移除绑定 - var newBindings []string - found := false - - for _, csId := range product.AccountNames { - if csId == req.AccountName { - found = true - continue - } - newBindings = append(newBindings, csId) - } - - if !found { - res.Success = false - res.Message = "未找到该绑定关系" - return res, nil - } - - product.AccountNames = newBindings - if err = dao.Product.UpdateEntity(ctx, product); err != nil { - return nil, gerror.Wrapf(err, "解绑失败") - } - - res.Success = true - res.Message = "解绑成功" - return -} - -// SyncToRAGFlow 同步产品到RAGFlow(租户级知识库) -func (p *product) SyncToRAGFlow(ctx context.Context, productId, accountName, tenantId string) (documentId string, err error) { - // 1. 查询产品 - product, err := dao.Product.GetById(ctx, productId) - if err != nil { - return "", gerror.Wrapf(err, "查询产品失败") - } - if product == nil { - return "", gerror.New("产品不存在") - } - - // 2. 获取租户的产品知识库ID - datasetId := fmt.Sprintf("dataset_product_tenant_%s", tenantId) - - // 2.1 确保知识库存在,不存在则自动创建 - if err := p.ensureDatasetExists(ctx, datasetId, tenantId, "产品"); err != nil { - return "", gerror.Wrapf(err, "确保知识库存在失败") - } - - // 3. 调用RAGFlow上传文档 - ragflowClient := ragflow.GetGlobalClient() - filename := fmt.Sprintf("%s_%s.txt", product.Name, accountName) - documentId, err = ragflowClient.UploadDocumentFromText(ctx, datasetId, product.Description, filename) - if err != nil { - jaeger.RecordError(ctx, err, "产品上传RAGFlow失败") - p.sendToRetryQueue(ctx, productId, accountName, tenantId, 0) - return "", err - } - - // 4. 更新MongoDB的RagSyncRecord - now := gtime.Now().Format("Y-m-d H:i:s") - updated := false - for i := range product.RagSyncRecords { - record := &product.RagSyncRecords[i] - if record.AccountName == accountName { - record.RagDocumentId = documentId - record.RagSyncStatus = "synced" - record.SyncTime = now - record.RetryCount = 0 - updated = true - break - } - } - - if !updated { - product.RagSyncRecords = append(product.RagSyncRecords, entity.RagSyncRecord{ - AccountName: accountName, - RagDocumentId: documentId, - RagSyncStatus: "synced", - SyncTime: now, - RetryCount: 0, - }) - } - - product.RagLastSyncTime = now - if err = dao.Product.UpdateEntity(ctx, product); err != nil { - return "", gerror.Wrapf(err, "更新产品同步状态失败") - } - - glog.Infof(ctx, "产品同步成功: product_id=%s, account_name=%s, document_id=%s", productId, accountName, documentId) - return documentId, nil -} - -// ensureDatasetExists 已废弃,改用公共方法 EnsureTenantDataset -// 保留此方法仅为兼容性,直接调用公共方法 -func (p *product) ensureDatasetExists(ctx context.Context, datasetId, tenantId, datasetType string) error { - _, err := EnsureTenantDataset(ctx, tenantId) - return err -} - -// sendToRetryQueue 发送到重试队列 -func (p *product) sendToRetryQueue(ctx context.Context, productId, accountName, tenantId string, retryCount int) { - msg := dto.RAGFlowSyncRetryMsg{ - Type: "product", - Id: productId, - AccountName: accountName, - TenantId: tenantId, - RetryCount: retryCount, - } - - var delay int - switch retryCount { - case 0: - delay = 5 * 60 - case 1: - delay = 15 * 60 - case 2: - delay = 60 * 60 - default: - glog.Warningf(ctx, "产品同步重试次数超限,标记为失败: %s", productId) - p.markSyncFailed(ctx, productId, accountName) - return - } - - if err := rabbitmq.PublishWithDelay(ctx, "ragflow.sync.retry.product", msg, delay); err != nil { - jaeger.RecordError(ctx, err, "发送RAGFlow重试消息失败") - } -} - -// markSyncFailed 标记同步失败 -func (p *product) markSyncFailed(ctx context.Context, productId, accountName string) { - product, err := dao.Product.GetById(ctx, productId) - if err != nil { - return - } - - for i := range product.RagSyncRecords { - record := &product.RagSyncRecords[i] - if record.AccountName == accountName { - record.RagSyncStatus = "failed" - record.SyncTime = gtime.Now().Format("Y-m-d H:i:s") - break - } - } - - dao.Product.UpdateEntity(ctx, product) -} - -// HandleRAGFlowSyncRetry RAGFlow同步重试消费者 -func (p *product) HandleRAGFlowSyncRetry(ctx context.Context, msg dto.RAGFlowSyncRetryMsg) error { - glog.Infof(ctx, "处理RAGFlow同步重试: type=%s, id=%s, retry=%d", msg.Type, msg.Id, msg.RetryCount) - - if msg.Type != "product" { - return nil - } - - _, err := p.SyncToRAGFlow(ctx, msg.Id, msg.AccountName, msg.TenantId) - if err != nil { - p.sendToRetryQueue(ctx, msg.Id, msg.AccountName, msg.TenantId, msg.RetryCount+1) - return err - } - - return nil -} - -// ProductRetryConsumer 产品RAGFlow重试消费者 -type ProductRetryConsumer struct { - queueName string - consumer *rabbitmq.Consumer -} - -// NewProductRetryConsumer 创建产品RAGFlow重试消费者 -func NewProductRetryConsumer(ctx context.Context) *ProductRetryConsumer { - return &ProductRetryConsumer{ - queueName: "ragflow.sync.retry.product", - } -} - -// Start 启动消费者 -func (c *ProductRetryConsumer) Start(ctx context.Context) error { - c.consumer = rabbitmq.NewConsumer(c.queueName, func(ctx context.Context, body []byte) error { - var msg dto.RAGFlowSyncRetryMsg - if err := json.Unmarshal(body, &msg); err != nil { - return err - } - return Product.HandleRAGFlowSyncRetry(ctx, msg) - }) - return c.consumer.Start(ctx) -} - -// Stop 停止消费者 -func (c *ProductRetryConsumer) Stop(ctx context.Context) { - if c.consumer != nil { - c.consumer.Stop(ctx) - } -} diff --git a/service/ragflow_config_service.go b/service/ragflow_config_service.go deleted file mode 100644 index c823e79..0000000 --- a/service/ragflow_config_service.go +++ /dev/null @@ -1,211 +0,0 @@ -// Package service - RAGFlow配置服务 -// 功能:管理ragflow_config表,对话配置(chatId、提示词)、知识库映射(datasetId) -package service - -import ( - "context" - "customer-server/dao" - "customer-server/model/dto" - - "gitea.com/red-future/common/ragflow" - "github.com/gogf/gf/v2/errors/gerror" - "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/os/gtime" - "github.com/gogf/gf/v2/text/gstr" -) - -var RAGFlowConfig = new(ragflowConfig) - -type ragflowConfig struct{} - -// Get 获取RAGFlow配置(配置不存在时自动重建) -// 参数: ctx - 上下文,req - 查询请求(包含客服账号名) -// 返回: res - RAGFlow配置信息(提示词、参数等),err - 错误信息 -// 功能: 查询客服账号的RAGFlow对话配置,不存在时自动重建 -func (s *ragflowConfig) Get(ctx context.Context, req *dto.GetRAGFlowConfigReq) (res *dto.GetRAGFlowConfigRes, err error) { - config, err := dao.RAGFlowConfig.FindByAccountName(ctx, req.AccountName) - if err != nil { - return nil, gerror.Wrap(err, "查询RAGFlow配置失败") - } - - // 配置不存在,尝试自动重建 - if config == nil { - g.Log().Warningf(ctx, "客服账号 %s 的RAGFlow配置不存在,尝试自动重建...", req.AccountName) - - // 查询客服账号获取platform - account, findErr := dao.CustomerServiceAccount.FindByAccountName(ctx, req.AccountName) - if findErr != nil { - return nil, gerror.Wrapf(findErr, "查询客服账号失败") - } - if account == nil { - return nil, gerror.Newf("客服账号 %s 不存在,无法自动创建RAGFlow配置", req.AccountName) - } - - // 调用重建逻辑 - recreateReq := &dto.RecreateRAGFlowConfigReq{ - AccountName: req.AccountName, - Platform: account.Platform, - } - if recreateErr := CustomerServiceAccount.RecreateRAGFlowConfig(ctx, recreateReq); recreateErr != nil { - return nil, gerror.Wrapf(recreateErr, "自动重建RAGFlow配置失败") - } - - // 重新查询配置 - config, err = dao.RAGFlowConfig.FindByAccountName(ctx, req.AccountName) - if err != nil { - return nil, gerror.Wrap(err, "重建后查询配置失败") - } - if config == nil { - return nil, gerror.New("重建后仍无法获取配置") - } - - g.Log().Infof(ctx, "成功自动重建客服账号 %s 的RAGFlow配置", req.AccountName) - } - - // 返回给前端时去掉自动追加的知识库部分,只显示用户的业务话术 - userPrompt := config.Prompt - knowledgePart := "\n\n以下是知识库:\n{knowledge}\n以上是知识库。" - if gstr.Contains(userPrompt, knowledgePart) { - userPrompt = gstr.Replace(userPrompt, knowledgePart, "") - } - - res = &dto.GetRAGFlowConfigRes{ - AccountName: config.AccountName, - Platform: config.Platform, - DatasetId: config.DatasetId, - DatasetName: config.DatasetName, - ChatId: config.ChatId, - Prompt: userPrompt, // 只返回用户部分 - DocumentIds: config.DocumentIds, - SimilarityThreshold: config.SimilarityThreshold, - KeywordsSimilarityWeight: config.KeywordsSimilarityWeight, - TopN: config.TopN, - EmptyResponse: config.EmptyResponse, - } - return -} - -// UpdatePrompt 更新提示词(配置不存在时自动重建) -// 参数: ctx - 上下文,req - 更新提示词请求(包含客服账号名和新提示词) -// 返回: res - 更新结果信息,err - 错误信息 -// 功能: 更新RAGFlow对话的系统提示词并同步到RAGFlow,影响AI回复风格 -func (s *ragflowConfig) UpdatePrompt(ctx context.Context, req *dto.UpdatePromptReq) (res *dto.UpdatePromptRes, err error) { - // 1. 查询当前配置 - config, err := dao.RAGFlowConfig.FindByAccountName(ctx, req.AccountName) - if err != nil { - return nil, gerror.Wrap(err, "查询RAGFlow配置失败") - } - - // 配置不存在,尝试自动重建 - if config == nil { - g.Log().Warningf(ctx, "客服账号 %s 的RAGFlow配置不存在,尝试自动重建...", req.AccountName) - - // 查询客服账号获取platform - account, findErr := dao.CustomerServiceAccount.FindByAccountName(ctx, req.AccountName) - if findErr != nil { - return nil, gerror.Wrapf(findErr, "查询客服账号失败") - } - if account == nil { - return nil, gerror.Newf("客服账号 %s 不存在,无法自动创建RAGFlow配置", req.AccountName) - } - - // 调用重建逻辑 - recreateReq := &dto.RecreateRAGFlowConfigReq{ - AccountName: req.AccountName, - Platform: account.Platform, - } - if recreateErr := CustomerServiceAccount.RecreateRAGFlowConfig(ctx, recreateReq); recreateErr != nil { - return nil, gerror.Wrapf(recreateErr, "自动重建RAGFlow配置失败") - } - - // 重新查询配置 - config, err = dao.RAGFlowConfig.FindByAccountName(ctx, req.AccountName) - if err != nil { - return nil, gerror.Wrap(err, "重建后查询配置失败") - } - if config == nil { - return nil, gerror.New("重建后仍无法获取配置") - } - - g.Log().Infof(ctx, "成功自动重建客服账号 %s 的RAGFlow配置", req.AccountName) - } - - // 2. 更新字段并自动追加知识库引用 - // 用户只需输入业务话术,后端自动在末尾追加标准的知识库引用格式 - userPrompt := req.Prompt - - // 先移除所有可能的知识库引用部分(使用查找并截断方式,与List方法保持一致) - knowledgePatterns := []string{ - "\n\n以下是知识库:", - "\n\n以下是知识库", - "\n以下是知识库:", - "\n以下是知识库", - } - - // 找到最早出现的知识库引用位置并截断 - for _, pattern := range knowledgePatterns { - if idx := gstr.Pos(userPrompt, pattern); idx >= 0 { - userPrompt = gstr.SubStr(userPrompt, 0, idx) - break - } - } - - // 去除末尾多余的空行 - userPrompt = gstr.TrimRight(userPrompt) - - // 统一追加标准的知识库引用格式 - userPrompt = userPrompt + "\n\n以下是知识库:\n{knowledge}\n以上是知识库。" - config.Prompt = userPrompt - - if req.SimilarityThreshold != nil { - config.SimilarityThreshold = *req.SimilarityThreshold - } - if req.KeywordsSimilarityWeight != nil { - config.KeywordsSimilarityWeight = *req.KeywordsSimilarityWeight - } - if req.TopN != nil { - config.TopN = *req.TopN - } - if req.EmptyResponse != nil { - config.EmptyResponse = *req.EmptyResponse - } - now := gtime.Now().Time - config.UpdatedAt = &now // 取地址赋值给指针类型 - - // 3. 调用RAGFlow API更新Chat配置 - ragflowClient := ragflow.GetGlobalClient() - updateReq := &ragflow.UpdateChatReq{ - Prompt: &ragflow.PromptConfig{ - Prompt: config.Prompt, - SimilarityThreshold: config.SimilarityThreshold, - KeywordsSimilarityWeight: config.KeywordsSimilarityWeight, - TopN: config.TopN, - EmptyResponse: config.EmptyResponse, - Variables: []map[string]interface{}{ - { - "key": "knowledge", - "optional": true, - }, - }, - }, - DatasetIds: []string{config.DatasetId}, - } - - if err := ragflowClient.UpdateChat(ctx, config.ChatId, updateReq); err != nil { - return nil, gerror.Wrapf(err, "调用RAGFlow API更新Chat失败") - } - - // 4. 更新MongoDB - if err := dao.RAGFlowConfig.UpdateEntity(ctx, config); err != nil { - return nil, gerror.Wrap(err, "更新MongoDB配置失败") - } - - g.Log().Infof(ctx, "RAGFlow配置更新成功: account_name=%s, chat_id=%s", - config.AccountName, config.ChatId) - - res = &dto.UpdatePromptRes{ - Success: true, - Message: "提示词更新成功", - } - return -} diff --git a/service/scripted_speech_service.go b/service/scripted_speech_service.go index 72477a3..41be143 100644 --- a/service/scripted_speech_service.go +++ b/service/scripted_speech_service.go @@ -6,32 +6,33 @@ import ( "context" "customer-server/dao" "customer-server/model/dto" + "fmt" - "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/util/gconv" ) var ( - ScriptedSpeech = new(scriptedSpeech) + ScriptedSpeechService = new(scriptedSpeechService) ) -type scriptedSpeech struct{} +type scriptedSpeechService struct{} // Add 添加预制话术 // 参数: ctx - 上下文,req - 添加预制话术请求 // 返回: res - 添加成功后的预制话术ID,err - 错误信息 // 功能: 创建新的预制话术记录 -func (s *scriptedSpeech) Add(ctx context.Context, req *dto.AddScriptedSpeechReq) (res *dto.AddScriptedSpeechRes, err error) { - // 检查账号是否存在 - account, err := dao.Account.GetById(ctx, &dto.GetAccountReq{Id: req.AccountId}) +func (s *scriptedSpeechService) Add(ctx context.Context, req *dto.AddScriptedSpeechReq) (res *dto.AddScriptedSpeechRes, err error) { + count, err := dao.ScriptedSpeech.Count(ctx, &dto.ListScriptedSpeechReq{ + DatasetId: req.DatasetId, + SceneType: req.SceneType, + }) if err != nil { return } - if account == nil { - err = gerror.New("客服账号不存在") + if count > 0 { + err = fmt.Errorf("话术场景已存在") return } - // 插入数据库 id, err := dao.ScriptedSpeech.Insert(ctx, req) if err != nil { @@ -45,7 +46,7 @@ func (s *scriptedSpeech) Add(ctx context.Context, req *dto.AddScriptedSpeechReq) // 参数: ctx - 上下文,req - 更新预制话术请求 // 返回: err - 错误信息 // 功能: 更新预制话术信息 -func (s *scriptedSpeech) Update(ctx context.Context, req *dto.UpdateScriptedSpeechReq) (err error) { +func (s *scriptedSpeechService) Update(ctx context.Context, req *dto.UpdateScriptedSpeechReq) (err error) { _, err = dao.ScriptedSpeech.Update(ctx, req) return } @@ -54,7 +55,7 @@ func (s *scriptedSpeech) Update(ctx context.Context, req *dto.UpdateScriptedSpee // 参数: ctx - 上下文,req - 删除预制话术请求 // 返回: err - 错误信息 // 功能: 逻辑删除预制话术记录 -func (s *scriptedSpeech) Delete(ctx context.Context, req *dto.DeleteScriptedSpeechReq) (err error) { +func (s *scriptedSpeechService) Delete(ctx context.Context, req *dto.DeleteScriptedSpeechReq) (err error) { _, err = dao.ScriptedSpeech.Delete(ctx, req) return } @@ -63,7 +64,7 @@ func (s *scriptedSpeech) Delete(ctx context.Context, req *dto.DeleteScriptedSpee // 参数: ctx - 上下文,req - 获取预制话术请求 // 返回: res - 预制话术信息,err - 错误信息 // 功能: 根据ID获取单个预制话术详情 -func (s *scriptedSpeech) Get(ctx context.Context, req *dto.GetScriptedSpeechReq) (res *dto.ScriptedSpeechVO, err error) { +func (s *scriptedSpeechService) Get(ctx context.Context, req *dto.GetScriptedSpeechReq) (res *dto.ScriptedSpeechVO, err error) { r, err := dao.ScriptedSpeech.GetById(ctx, req) if err != nil { return @@ -76,7 +77,7 @@ func (s *scriptedSpeech) Get(ctx context.Context, req *dto.GetScriptedSpeechReq) // 参数: ctx - 上下文,req - 列表查询请求 // 返回: res - 预制话术列表及分页信息,err - 错误信息 // 功能: 分页查询预制话术记录 -func (s *scriptedSpeech) List(ctx context.Context, req *dto.ListScriptedSpeechReq) (res *dto.ListScriptedSpeechRes, err error) { +func (s *scriptedSpeechService) List(ctx context.Context, req *dto.ListScriptedSpeechReq) (res *dto.ListScriptedSpeechRes, err error) { list, total, err := dao.ScriptedSpeech.List(ctx, req) if err != nil { return diff --git a/service/session_service.go b/service/session_service.go deleted file mode 100644 index 7861d17..0000000 --- a/service/session_service.go +++ /dev/null @@ -1,85 +0,0 @@ -// Package service - 会话服务 -// 功能:用户会话管理、状态维护 -package service - -import ( - "context" - "customer-server/dao" - - "gitea.com/red-future/common/jaeger" - "gitea.com/red-future/common/rabbitmq" - "gitea.com/red-future/common/redis" - "github.com/gogf/gf/v2/encoding/gjson" - "github.com/gogf/gf/v2/os/glog" -) - -// sessionService 会话服务(操作 session 表) -type sessionService struct{} - -// SessionService 会话服务单例 -var SessionService = new(sessionService) - -// ============== RabbitMQ 消费者(归档延时消息)============== - -// SessionArchiveConsumer 会话归档消费者 -type SessionArchiveConsumer struct { - consumer *rabbitmq.Consumer -} - -// NewSessionArchiveConsumer 创建会话归档消费者 -func NewSessionArchiveConsumer(ctx context.Context) *SessionArchiveConsumer { - queueName := GetConfigString(ctx, "archive.queue") - return &SessionArchiveConsumer{ - consumer: rabbitmq.NewConsumer(queueName, handleSessionArchive), - } -} - -// Start 启动消费者 -func (c *SessionArchiveConsumer) Start(ctx context.Context) (err error) { - glog.Info(ctx, "会话归档消费者启动...") - return c.consumer.Start(ctx) -} - -// Stop 停止消费者 -func (c *SessionArchiveConsumer) Stop(ctx context.Context) { - c.consumer.Stop(ctx) -} - -// handleSessionArchive 处理会话归档消息 -func handleSessionArchive(ctx context.Context, body []byte) (err error) { - ctx, span := jaeger.NewSpan(ctx, "consumer.session.archive") - defer span.End() - - var msg redis.ArchiveMessage - if err = gjson.DecodeTo(body, &msg); err != nil { - jaeger.RecordError(ctx, err, "解析归档消息失败") - return - } - - glog.Infof(ctx, "收到归档消息 - 用户: %s, Session: %s", msg.UserId, msg.SessionId) - - // 检查用户是否在归档发送后有活跃(60分钟内) - isActive, err := redis.IsUserActive(ctx, msg.UserId, int64(redis.GetArchiveDelay())) - if err != nil { - jaeger.RecordError(ctx, err, "检查用户活跃状态失败") - return - } - - if isActive { - glog.Infof(ctx, "用户 %s 在归档期间有活跃,跳过归档", msg.UserId) - return - } - - // 执行归档 - if err = dao.Session.Archive(ctx, msg.UserId, msg.SessionId); err != nil { - jaeger.RecordError(ctx, err, "归档会话失败") - return - } - - // 清除 Session 缓存(需要tenantId) - // TODO: ArchiveMessage需要添加TenantId字段 - redis.DelSessionCache(ctx, msg.TenantId, msg.UserId) - - glog.Infof(ctx, "会话已归档 - 用户: %s, Session: %s", msg.UserId, msg.SessionId) - return -} diff --git a/service/session_tool_service.go b/service/session_tool_service.go new file mode 100644 index 0000000..5a25774 --- /dev/null +++ b/service/session_tool_service.go @@ -0,0 +1,399 @@ +package service + +import ( + "context" + "customer-server/consts/account" + "customer-server/consts/public" + "customer-server/consts/scriptedSpeech" + "customer-server/dao" + "customer-server/model/dto" + "customer-server/model/entity" + "encoding/json" + "fmt" + "slices" + "strings" + "time" + + "gitea.com/red-future/common/beans" + "gitea.com/red-future/common/http" + "gitea.com/red-future/common/jaeger" + "gitea.com/red-future/common/utils" + gmq "github.com/bjang03/gmq/core/gmq" + "github.com/bjang03/gmq/mq" + "github.com/bjang03/gmq/types" + "github.com/gogf/gf/v2/encoding/gjson" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/util/gconv" +) + +var SessionToolService = new(sessionToolService) + +type sessionToolService struct{} + +func (s *sessionToolService) PushOpeningRemark(ctx context.Context, userId string, accountInfo *dto.AccountVO, headers map[string]string) (content string, err error) { + content = "" + var sceneType = scriptedSpeech.SceneTypeOpeningRemark + var key = fmt.Sprintf("account:%s:%s:%s", accountInfo.AccountCode, account.GetDescByCode(accountInfo.Platform), userId) + get, err := g.Redis().Get(ctx, key) + if err != nil { + return + } + if g.IsEmpty(get) { + // 构建开场白内容 + if len(accountInfo.DatasetIds) > 1 { + var datasetInfo *dto.RagListDatasetRes + datasetInfo, err = SessionToolService.GetDatasetInfo(ctx, accountInfo.DatasetIds, headers) + if err != nil { + return + } + if g.IsEmpty(datasetInfo) { + err = fmt.Errorf("数据集不存在") + return + } + var datasetDescriptions []string + for _, dataset := range datasetInfo.List { + datasetDescriptions = append(datasetDescriptions, dataset.Name) + } + content = SessionToolService.BuildMenuContent(accountInfo.Greeting, datasetDescriptions, len(accountInfo.DatasetIds)) + } else { + content = SessionToolService.BuildMenuContent(accountInfo.Greeting, accountInfo.KeywordOption, len(accountInfo.DatasetIds)) + } + err = s.pushDelayMsg(ctx, key, sceneType.Code(), sceneType.Desc(), accountInfo.DatasetIds) + if err != nil { + return + } + } + return +} + +func (s *sessionToolService) PushDialog(ctx context.Context, userId string, questionContent string, accountInfo *dto.AccountVO, headers map[string]string) (content string, err error) { + sceneType := scriptedSpeech.SceneTypeDialog + // 删除延迟消息 + //err = s.DeleteDelayMsg(ctx) + //if err != nil { + // return nil, err + //} + content = "" + var key = fmt.Sprintf("account:%s:%s:%s", accountInfo.AccountCode, account.GetDescByCode(accountInfo.Platform), userId) + get, err := g.Redis().Get(ctx, key) + if err != nil { + return + } + if !g.IsEmpty(get) { + // 获取用户对话上下文 + var history []*dto.Message + history, err = SessionToolService.GetUserHistory(ctx, userId) + if err != nil { + err = fmt.Errorf("获取用户对话上下文失败: %w", err) + return + } + + // 获取用户对话记录 + var accountUserDialog *entity.AccountUserDialog + accountUserDialog, err = dao.AccountUserDialog.Get(ctx, &dto.GetAccountUserDialogReq{ + AccountId: accountInfo.Id, + UserId: userId, + }) + if err != nil { + err = fmt.Errorf("获取用户对话记录失败: %w", err) + return + } + if g.IsEmpty(accountUserDialog.Id) { + // 保存用户对话记录 + if _, err = dao.AccountUserDialog.Insert(ctx, &dto.AddAccountUserDialogReq{ + AccountId: accountInfo.Id, + UserId: userId, + DialogCount: 1, + }); err != nil { + err = fmt.Errorf("保存用户对话记录失败: %w", err) + return + } + } else { + if accountUserDialog.DialogCount >= g.Cfg().MustGet(ctx, "card.triggerCount").Int64() { + // TODO 替换为实际卡片发送逻辑 + content = "请加一下卡片的联系方式,进行更专业的咨询" + sceneType = scriptedSpeech.SceneTypeCardSend + if _, err = SessionToolService.ClearUserHistory(ctx, userId); err != nil { + err = fmt.Errorf("清除用户对话上下文失败: %w", err) + return + } + } else { + // 更新用户对话记录 + if _, err = dao.AccountUserDialog.Update(ctx, &dto.UpdateAccountUserDialogReq{ + Id: accountUserDialog.Id, + DialogCount: 1, + }); err != nil { + return + } + } + } + if sceneType.Code() != scriptedSpeech.SceneTypeCardSend.Code() { + // 通过HTTP调用rag服务的RAG查询接口 + var ragQuery *dto.RagQueryRes + ragQuery, err = SessionToolService.GetRagQuery(ctx, questionContent, accountInfo.DatasetIds, history, headers) + if err != nil { + err = fmt.Errorf("调用rag服务的RAG查询接口失败: %w", err) + return + } + content = ragQuery.Answer + + // 保存用户对话上下文 + err = SessionToolService.SaveUserHistory(ctx, userId, []*dto.Message{ + {Role: "user", Content: questionContent}, + {Role: "assistant", Content: content}, + }) + if err != nil { + err = fmt.Errorf("保存用户对话上下文失败: %w", err) + return + } + } + + err = s.pushDelayMsg(ctx, key, sceneType.Code(), sceneType.Desc(), accountInfo.DatasetIds) + if err != nil { + return + } + } + return +} + +func (s *sessionToolService) pushDelayMsg(ctx context.Context, key string, sceneTypeCode scriptedSpeech.SceneType, sceneTypeDesc string, datasetIds []int64) (err error) { + err = g.Redis().SetEX(ctx, key, sceneTypeDesc, gconv.Int64(10*time.Second)) + if err != nil { + return err + } + // 获取追问话术内容 + var msg string + if len(datasetIds) == 1 { + scriptedSpeechInfo, err := SessionToolService.GetScriptedSpeechContent(ctx, datasetIds[0], sceneTypeCode) + if err != nil { + return fmt.Errorf("获取追问话术内容失败: %w", err) + } + if g.IsEmpty(scriptedSpeechInfo) { + if sceneTypeCode == scriptedSpeech.SceneTypeOpeningRemark.Code() { + msg = "宝子,刚才给您发的信息您有看到吗?有任何问题都能直接问我,加微信也能更方便沟通~" + } else if sceneTypeCode == scriptedSpeech.SceneTypeDialog.Code() { + msg = "看您暂时没回复,是不是还有什么疑问?加微信我详细给您说明~" + } else if sceneTypeCode == scriptedSpeech.SceneTypeCardSend.Code() { + msg = "宝子,加上没~要及时加哦,不然卡片容易失效哒✨" + } + } + msg = scriptedSpeechInfo.QuestionContent + } else { + msg = "宝子,刚才给您发的信息您有看到吗?有任何问题都能直接问我,加微信也能更方便沟通~" + } + var msgMap = map[string]string{ + "key": key, + "data": msg, + } + err = gmq.GetGmq(public.GmqMsgPluginsName).GmqPublishDelay(ctx, &mq.NatsPubDelayMessage{ + PubDelayMessage: types.PubDelayMessage{ + PubMessage: types.PubMessage{ + Topic: public.AccountFollowupTopic, + Data: msgMap, + }, + DelaySeconds: 60, + }, + }) + return +} + +// GetAccountInfo 获取客服账号信息 +func (s *sessionToolService) GetAccountInfo(ctx context.Context, accountCode string) (res *dto.AccountVO, err error) { + r, err := dao.Account.GetByAccountCode(ctx, &dto.GetByAccountCodeReq{ + AccountCode: accountCode, + }) + if err != nil { + return nil, fmt.Errorf("获取客服账号信息失败: %w", err) + } + err = gconv.Struct(r, &res) + return +} + +// SetUserInfo 设置用户信息 +func (s *sessionToolService) SetUserInfo(ctx context.Context, creator string, tenantId uint64) (headers map[string]string, err error) { + // 创建完整的用户信息 + userInfo := &beans.User{ + UserName: creator, + TenantId: tenantId, + } + ctx = context.WithValue(ctx, "user", *userInfo) + // 提取并保存请求头(在连接升级前) + headers = make(map[string]string) + // 提取其他headers + if r := g.RequestFromCtx(ctx); r != nil { + for k, v := range r.Request.Header { + if len(v) > 0 { + headers[k] = v[0] + } + } + } + // 将完整用户信息序列化为JSON,放到X-User-Info请求头 + userInfoJson, err := gjson.Encode(userInfo) + if err != nil { + return nil, fmt.Errorf("用户信息序列化失败: %w", err) + } + headers["X-User-Info"] = string(userInfoJson) + return +} + +// GetDatasetInfo 获取数据集信息 +func (s *sessionToolService) GetDatasetInfo(ctx context.Context, datasetIds []int64, headers map[string]string) (res *dto.RagListDatasetRes, err error) { + // 通过HTTP调用rag服务的关键词查询接口 + res = &dto.RagListDatasetRes{} + if err = http.Get(ctx, "rag/dataset/list", headers, &res, &dto.RagListDatasetReq{ + Ids: datasetIds, + }); err != nil { + return nil, fmt.Errorf("获取数据集信息失败: %w", err) + } + return +} + +// BuildMenuContent 生成菜单话术内容 +func (s *sessionToolService) BuildMenuContent(greeting string, options []string, datasetCount int) string { + var sb strings.Builder + // 问候语 + if datasetCount > 1 { + greeting = "您好,很高兴为您服务!请问咨询什么方面问题?" + } else { + if greeting == "" { + greeting = "您好,很高兴为您服务!请问有什么可以帮您?" + } + } + + sb.WriteString(greeting) + sb.WriteByte('\n') + // 拼接选项 1、xx 2、xx... + for i, opt := range options { + sb.WriteString(fmt.Sprintf("%d、%s\n", i+1, opt)) + if i == len(options)-1 { + sb.WriteString(fmt.Sprintf("%s\n", "💗回复数字就好~")) + } + } + // 固定结尾 + sb.WriteString("🌟也可直接点击下方咨询专业老师~") + + return sb.String() +} + +// GetScriptedSpeechContent 获取话术内容 +func (s *sessionToolService) GetScriptedSpeechContent(ctx context.Context, datasetId int64, sceneType scriptedSpeech.SceneType) (res *dto.ScriptedSpeechVO, err error) { + r, err := dao.ScriptedSpeech.GetByDatasetIdAndSceneType(ctx, &dto.ListScriptedSpeechReq{ + DatasetId: datasetId, + SceneType: sceneType, + }) + if err != nil { + return + } + err = gconv.Struct(r, &res) + return +} + +// GetRagQuery 获取rag查询结果 +func (s *sessionToolService) GetRagQuery(ctx context.Context, questionContent string, datasetIds []int64, history []*dto.Message, headers map[string]string) (res *dto.RagQueryRes, err error) { + resp := new(dto.RagQueryRes) + if err = http.Post(ctx, "rag/document/vector/ragQuery", headers, &resp, &dto.RagQueryReq{ + Content: questionContent, + DatasetIds: datasetIds, + History: history, + TopK: 5, + }); err != nil { + return + } + return resp, nil +} + +// SaveUserHistory 保存用户对话历史到Redis +func (s *sessionToolService) SaveUserHistory(ctx context.Context, userKey string, newMessages []*dto.Message) (err error) { + key := fmt.Sprintf(public.AccountDialogKeyUserId, userKey) + + // 1. 先读旧历史 + var oldMessages []*dto.Message + oldMessages, err = s.GetUserHistory(ctx, key) + if err != nil { + return err + } + + // 2. 合并 + allMessages := append(oldMessages, newMessages...) + + // 3. 限制长度(保留最新 N 轮) + maxMsgCount := 2 * g.Cfg().MustGet(ctx, "history.contextLimit", 5).Int() + if len(allMessages) > maxMsgCount { + allMessages = allMessages[len(allMessages)-maxMsgCount:] + } + + // 4. 存回Redis + data, err := json.Marshal(allMessages) + if err != nil { + return err + } + return g.Redis().SetEX(ctx, key, data, gconv.Int64(15*time.Second)) +} + +// GetUserHistory 从Redis获取用户历史 +func (s *sessionToolService) GetUserHistory(ctx context.Context, key string) ([]*dto.Message, error) { + data, err := g.Redis().Get(ctx, key) + if err != nil || data.IsEmpty() { + return []*dto.Message{}, nil + } + + var messages []*dto.Message + if err = json.Unmarshal(data.Bytes(), &messages); err != nil { + return []*dto.Message{}, err + } + return messages, nil +} + +// ClearUserHistory 清空历史(可选) +func (s *sessionToolService) ClearUserHistory(ctx context.Context, userKey string) (int64, error) { + key := fmt.Sprintf(public.AccountDialogKeyUserId, userKey) + return g.Redis().Del(ctx, key) +} + +// getDatasetIdsByKeywords 通过关键词查询数据集ID +func (s *sessionToolService) getDatasetIdsByKeywords(ctx context.Context, content string, headers map[string]string) (res []int64, err error) { + // 1. 提取关键词 + keywords := s.extractKeywords(content) + g.Log().Infof(ctx, "提取关键词: %v", keywords) + + // 通过HTTP调用rag服务的关键词查询接口 + respKeyword := &dto.RAGListKeywordRes{} + if err = http.Get(ctx, "rag/keyword/listKeyword", headers, &respKeyword, &dto.RAGListKeywordReq{ + Words: keywords, + }); err != nil { + jaeger.RecordError(ctx, err, "RAG查询关键词失败") + g.Log().Errorf(ctx, "RAG查询关键词失败: %v", err) + return + } + var datasetIds []int64 + for _, v := range respKeyword.List { + if !slices.Contains(datasetIds, v.DatasetId) { + datasetIds = append(datasetIds, v.DatasetId) + } + } + return datasetIds, nil +} + +// extractKeywords 提取关键词 +func (s *sessionToolService) extractKeywords(text string) []string { + if text == "" { + return []string{} + } + + // 使用gse分词工具提取关键词 + keywords := utils.GseTool.Extract(text, 5) + + words := make([]string, 0, len(keywords)) + for _, kw := range keywords { + if kw.Word != "" { + words = append(words, kw.Word) + } + } + + // 如果没有提取到关键词,使用分词结果 + if len(words) == 0 { + words = utils.GseTool.Cut(text) + } + + return words +} diff --git a/service/speechcraft_service.go b/service/speechcraft_service.go deleted file mode 100644 index 362af62..0000000 --- a/service/speechcraft_service.go +++ /dev/null @@ -1,653 +0,0 @@ -// Package service - 话术服务 -// 功能:话术的增删改查、绑定/解绑客服账号、同步到RAGFlow、重试消费者 -package service - -import ( - "context" - "customer-server/dao" - "customer-server/model/dto" - "customer-server/model/entity" - "customer-server/util" - "fmt" - - "gitea.com/red-future/common/db/mongo" - "gitea.com/red-future/common/jaeger" - "gitea.com/red-future/common/rabbitmq" - "gitea.com/red-future/common/ragflow" - "gitea.com/red-future/common/redis" - "gitea.com/red-future/common/utils" - "github.com/gogf/gf/v2/errors/gerror" - "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/os/glog" - "github.com/gogf/gf/v2/os/grpool" - "github.com/gogf/gf/v2/os/gtime" - "github.com/gogf/gf/v2/text/gstr" - "github.com/gogf/gf/v2/util/gconv" - "go.mongodb.org/mongo-driver/v2/bson" -) - -var ( - Speechcraft = new(speechcraft) - speechcraftGrpool = grpool.New(50) // 文档解析协程池,最大50并发 -) - -type speechcraft struct{} - -// Add 添加话术 -// 参数: ctx - 上下文,req - 添加话术请求(包含标题、内容、方向、标签等) -// 返回: res - 添加成功后的话术ID和RAGFlow文档ID,err - 错误信息 -// 功能: 创建话术记录并自动上传到RAGFlow租户知识库,支持去重检查 -func (s *speechcraft) Add(ctx context.Context, req *dto.AddSpeechcraftReq) (res *dto.AddSpeechcraftRes, err error) { - // 去重检查:同一租户下tag唯一 - if req.Tag != "" { - existing, err := dao.Speechcraft.FindByTag(ctx, req.Tag) - if err != nil { - return nil, gerror.Wrap(err, "检查话术重复失败") - } - if existing != nil { - return nil, gerror.Newf("话术tag已存在:tag=%s, id=%s", req.Tag, existing.Id.Hex()) - } - } - - // 先从token或请求获取租户信息(在Insert之前) - var tenantId string - if req.TenantId != nil { - tenantId = gconv.String(req.TenantId) - g.Log().Debugf(ctx, "使用请求中的TenantId: %v", req.TenantId) - } else { - user, err := util.GetTenantInfo(ctx) - if err != nil { - return nil, gerror.Wrap(err, "获取租户信息失败") - } - tenantId = gconv.String(user.TenantId) - if tenantId == "" { - return nil, gerror.New("租户ID为空") - } - } - - // 查询租户知识库ID - datasetId, err := dao.RAGFlowConfig.FindDatasetIdByTenant(ctx, tenantId) - if err != nil || datasetId == "" { - return nil, gerror.Newf("租户知识库不存在,请先创建客服账号: tenant_id=%s", tenantId) - } - - data := &entity.Speechcraft{} - if err = utils.Struct(req, data); err != nil { - return - } - // 设置基础字段 - now := gtime.Now().Time - data.CreatedAt = &now // 取地址赋值给指针类型 - data.UpdatedAt = &now // 取地址赋值给指针类型 - data.IsDeleted = false - // 统一使用string类型存储tenantId到MongoDB - data.TenantId = tenantId - - // 使用DAO封装的Insert方法 - id, err := dao.Speechcraft.Insert(ctx, data) - if err != nil { - return nil, gerror.Wrap(err, "插入话术失败") - } - data.Id = &id // 取地址赋值给指针类型 - - // 同步上传到RAGFlow - ragflowClient := ragflow.GetGlobalClient() - if ragflowClient == nil { - // 回滚:删除刚插入的话术 - dao.MongoDAO.Delete(ctx, bson.M{"_id": data.Id}, entity.SpeechcraftCollection) - return nil, gerror.New("RAGFlow客户端未初始化,请检查配置") - } - - g.Log().Infof(ctx, "准备上传话术到RAGFlow: speechcraft_id=%s, dataset_id=%s, direction=%s, tag=%s", - data.Id.Hex(), datasetId, data.Direction, data.Tag) - filename := fmt.Sprintf("%s_%s.txt", data.Direction, data.Tag) - documentId, err := ragflowClient.UploadDocumentFromText(ctx, datasetId, data.Content, filename) - if err != nil { - // 回滚:删除刚插入的话术 - dao.MongoDAO.Delete(ctx, bson.M{"_id": data.Id}, entity.SpeechcraftCollection) - g.Log().Errorf(ctx, "话术上传RAGFlow失败: speechcraft_id=%s, dataset_id=%s, error=%v", data.Id.Hex(), datasetId, err) - jaeger.RecordError(ctx, err, "话术上传RAGFlow失败") - return nil, gerror.Wrap(err, "文档上传到知识库失败") - } - - // 异步触发解析(grpool自动管理goroutine生命周期,WithoutCancel保留追踪避免取消) - speechcraftGrpool.Add(ctx, func(ctx context.Context) { - parseCtx := context.WithoutCancel(ctx) - if err := ragflowClient.ParseDocuments(parseCtx, datasetId, []string{documentId}); err != nil { - g.Log().Errorf(parseCtx, "文档解析失败: document_id=%s, error=%v", documentId, err) - } else { - g.Log().Infof(parseCtx, "文档解析成功: document_id=%s", documentId) - } - }) - - // 更新MongoDB的RagSyncRecords数组(使用空accountName表示租户级文档) - syncTime := gtime.Now().Format("Y-m-d H:i:s") - record := entity.RagSyncRecord{ - AccountName: "", // 空表示租户级文档 - RagDocumentId: documentId, - RagSyncStatus: "synced", - SyncTime: syncTime, - RetryCount: 0, - } - filter := bson.M{"_id": data.Id} - update := bson.M{ - "$set": bson.M{ - "ragSyncRecords": []entity.RagSyncRecord{record}, - "ragLastSyncTime": syncTime, - "updatedAt": gtime.Now().Time, - }, - } - if _, _, err = dao.MongoDAO.UpdateOne(ctx, filter, update, entity.SpeechcraftCollection); err != nil { - g.Log().Errorf(ctx, "更新话术RagSyncRecords失败: %v", err) - // 不回滚,文档已上传成功 - } - - g.Log().Infof(ctx, "话术添加成功并上传到知识库: speechcraft_id=%s, document_id=%s", data.Id.Hex(), documentId) - res = &dto.AddSpeechcraftRes{Id: data.Id.Hex()} - return -} - -// Update 更新话术 -// 参数: ctx - 上下文,req - 更新话术请求(包含话术ID和待更新字段) -// 返回: err - 错误信息 -// 功能: 更新话术内容并同步到RAGFlow,支持文档删除重建 -func (s *speechcraft) Update(ctx context.Context, req *dto.UpdateSpeechcraftReq) (err error) { - return dao.Speechcraft.Update(ctx, req) -} - -// Delete 删除话术 -// 参数: ctx - 上下文,req - 删除话术请求(包含话术ID) -// 返回: err - 错误信息 -// 功能: 逻辑删除话术记录并从RAGFlow移除对应文档 -func (s *speechcraft) Delete(ctx context.Context, req *dto.DeleteSpeechcraftReq) (err error) { - g.Log().Infof(ctx, "[Delete] 开始删除话术 - speechcraftId: %s", req.Id) - - // 1. 查询话术,获取RAGFlow同步记录(使用原生查询,避免租户过滤) - objectId, err := bson.ObjectIDFromHex(req.Id) - if err != nil { - return gerror.Wrap(err, "无效的话术ID") - } - - var speechcraft entity.Speechcraft - filter := bson.M{"_id": objectId, "isDeleted": false} - err = dao.MongoDAO.FindOne(ctx, filter, &speechcraft, entity.SpeechcraftCollection) - if err != nil { - if err.Error() == "mongo: no documents in result" { - return gerror.New("话术不存在") - } - return gerror.Wrap(err, "查询话术失败") - } - - g.Log().Infof(ctx, "[Delete] 查询到话术 - tag: %s, ragSyncRecords数量: %d", speechcraft.Tag, len(speechcraft.RagSyncRecords)) - - // 2. 删除RAGFlow中的文档 - if len(speechcraft.RagSyncRecords) > 0 { - ragflowClient := ragflow.GetGlobalClient() - if ragflowClient != nil { - tenantId := gconv.String(speechcraft.TenantId) - - // 查询租户的dataset_id - datasetId, err := dao.RAGFlowConfig.FindDatasetIdByTenant(ctx, tenantId) - if err != nil { - g.Log().Warningf(ctx, "查询租户知识库ID失败: %v", err) - } else if datasetId != "" { - // 收集所有需要删除的document_id - var documentIds []string - for _, record := range speechcraft.RagSyncRecords { - if record.RagDocumentId != "" { - documentIds = append(documentIds, record.RagDocumentId) - } - } - - // 批量删除RAGFlow文档 - if len(documentIds) > 0 { - if err := ragflowClient.DeleteDocument(ctx, datasetId, documentIds); err != nil { - g.Log().Errorf(ctx, "删除RAGFlow文档失败: %v, document_ids: %v", err, documentIds) - // 不阻断删除流程,记录错误后继续 - } else { - g.Log().Infof(ctx, "成功删除RAGFlow文档: count=%d", len(documentIds)) - } - } - } - } - } - - // 3. 软删除MongoDB记录 - return dao.Speechcraft.Delete(ctx, req) -} - -// List 获取话术列表 -// 参数: ctx - 上下文,req - 列表查询请求(支持分页、关键词搜索、平台筛选) -// 返回: res - 话术列表及分页信息,err - 错误信息 -// 功能: 分页查询话术记录,支持按标题、内容模糊搜索和平台筛选 -func (s *speechcraft) List(ctx context.Context, req *dto.ListSpeechcraftReq) (res *dto.ListSpeechcraftRes, err error) { - list, total, err := dao.Speechcraft.List(ctx, req) - if err != nil { - return - } - res = &dto.ListSpeechcraftRes{ - List: list, - Total: int(total), - } - return -} - -// Match 话术匹配(核心方法) -// 根据用户当前阶段、行为、输入内容匹配话术 -// -// func (s *speechcraft) Match(ctx context.Context, userId, platform, content, status string) (answer string, nextStage int, err error) { -// // 1. 获取用户当前阶段 -// state, err := dao.UserStage.GetOrCreate(ctx, userId, platform) -func (s *speechcraft) Match(ctx context.Context, userId, platform, tenantId, content, status string) (answer string, nextStage int, err error) { - // 1. 获取用户当前状态(Redis,5分钟过期) - userState, err := redis.GetUserState(ctx, userId, platform) - if err != nil { - jaeger.RecordError(ctx, err, "获取用户状态失败") - return - } - - glog.Infof(ctx, "话术匹配 - 用户: %s, 当前阶段: %d, 行为: %s, 内容: %s", userId, userState.Stage, status, content) - - // 2. 状态3(发卡片状态):持续提示用户添加联系方式 - if userState.Stage == 3 { - answer = "请加一下卡片的联系方式,进行更专业的咨询" // TODO: 替换为实际卡片内容 - nextStage = 3 // 保持状态3 - glog.Infof(ctx, "用户处于发卡片状态 - 用户: %s", userId) - return - } - - // 4. 检测用户是否想要联系方式(5次内立即发卡) - if s.isRequestingContact(content) { - glog.Infof(ctx, "检测到联系方式请求关键词 - 用户: %s, 内容: %s", userId, content) - return s.handleCardRequest(ctx, userId, platform) - } - - // 5. 所有其他消息直接走RAGFlow(话术已上传到知识库) - // 后端只负责:开场白(WebSocket连接时发送)+ 发卡片(上面的逻辑) - glog.Infof(ctx, "无话术匹配,转发到RAGFlow - 用户: %s, 内容: %s", userId, content) - nextStage = 0 - if updateErr := redis.SetUserStage(ctx, userId, platform, 0); updateErr != nil { - jaeger.RecordError(ctx, updateErr, "更新用户阶段为0失败") - } - // answer为空,调用方会走RAGFlow - return -} - -// ProcessAndPublish 处理用户消息并推送到Redis Stream -// 参数: ctx - 上下文,userId - 用户ID,platform - 平台,tenantId - 租户ID,content - 消息内容,status - 用户行为状态,accountName - 客服账号名 -// 返回: isPushed - 是否成功推送话术匹配结果,err - 错误信息 -// 功能: 尝试匹配话术,匹配成功则直接推送话术内容,失败则转发到Redis Stream由RAGFlow处理 -func (s *speechcraft) ProcessAndPublish(ctx context.Context, userId, platform, tenantId, content, status, accountName string) (isPushed bool, err error) { - // 1. 话术匹配 - answer, _, err := s.Match(ctx, userId, platform, tenantId, content, status) - if err != nil { - return - } - - // 2. 未匹配到话术,直接推送到Redis Stream - if answer == "" { - glog.Infof(ctx, "话术未匹配,转发到 AI 模型 - 用户: %s, 客服账号: %s", userId, accountName) - - // 获取当前实例的动态响应队列名(自动生成,支持多实例部署) - baseQueue := GetConfigString(ctx, "rabbitmq.responseQueue") - replyQueue := rabbitmq.GetInstanceQueueName(baseQueue) - messageId := userId + "_" + gconv.String(gtime.Now().TimestampNano()) - - // 构造Stream消息 - msg := &redis.SendStreamMessage{ - UserId: userId, - Platform: platform, - TenantId: tenantId, - AccountName: accountName, - Content: content, - Timestamp: gtime.Now().Timestamp(), - MessageId: messageId, - ReplyQueue: replyQueue, - } - - // 检查是否有session缓存,无缓存说明已归档,需要读取历史 - if sessionId, _ := redis.GetSessionCache(ctx, tenantId, userId); sessionId == "" { - if history, histErr := dao.Conversation.GetRecentHistory(ctx, userId, redis.GetHistoryContextLimit()); histErr == nil && len(history) > 0 { - msg.History = history - glog.Infof(ctx, "用户已归档,读取 %d 轮历史对话 - 用户: %s", len(history), userId) - } - } - - // 写入Redis Stream - var streamMsgId string - streamMsgId, err = redis.AddToStream(ctx, redis.RAGFlowRequestStreamKey, msg) - if err != nil { - jaeger.RecordError(ctx, err, "写入Stream失败") - return - } - - glog.Infof(ctx, "消息已写入Stream - StreamID: %s, 用户: %s", streamMsgId, userId) - isPushed = false // 未匹配话术,消息转发到AI处理 - return - } - - // 3. 匹配到话术,直接推送 WebSocket(无需经过 RabbitMQ) - if err = WebSocket.PushRAGFlowResponse(ctx, tenantId, userId, platform, answer); err != nil { - jaeger.RecordError(ctx, err, "推送话术响应失败") - return - } - - glog.Infof(ctx, "话术响应已推送 - 用户: %s, 话术长度: %d", userId, len(answer)) - isPushed = true // 已直接推送响应 - return -} - -// ResetUserStage 重置用户阶段 -func (s *speechcraft) ResetUserStage(ctx context.Context, userId, platform string) (err error) { - return dao.UserStage.Reset(ctx, userId, platform) -} - -// isRequestingContact 检测用户是否想要联系方式(触发立即发卡) -func (s *speechcraft) isRequestingContact(content string) bool { - // 联系方式相关关键词 - contactKeywords := []string{ - "联系方式", "联系你", "联系", - "微信", "VX", "vx", "wx", "WX", - "电话", "手机号", "电话号码", - "怎么找你", "如何联系", "加你", - "私信", "私聊", - } - - // 清理内容(去除空格、标点) - cleanContent := gstr.Trim(content) - cleanContent = gstr.ToLower(cleanContent) - - for _, keyword := range contactKeywords { - if gstr.Contains(cleanContent, gstr.ToLower(keyword)) { - return true - } - } - - return false -} - -// handleCardRequest 处理用户请求联系方式(立即发卡片) -func (s *speechcraft) handleCardRequest(ctx context.Context, userId, platform string) (answer string, nextStage int, err error) { - // 更新用户状态为3(发卡片状态) - if err = redis.SetUserStage(ctx, userId, platform, 3); err != nil { - jaeger.RecordError(ctx, err, "更新用户状态为3失败") - return - } - - // 返回卡片话术 - answer = "请加一下卡片的联系方式,进行更专业的咨询" // TODO: 替换为实际卡片内容 - nextStage = 3 - - glog.Infof(ctx, "用户请求联系方式,立即发送卡片 - 用户: %s", userId) - return -} - -// BindToCustomerServices 绑定话术到客服账号 -// 参数: ctx - 上下文,req - 绑定请求(包含话术ID和客服账号名列表) -// 返回: res - 绑定结果(成功和失败的账号列表),err - 错误信息 -// 功能: 将话术同步到指定客服账号的RAGFlow知识库,批量处理并返回每个账号的结果 -func (s *speechcraft) BindToCustomerServices(ctx context.Context, req *dto.BindSpeechcraftReq) (res *dto.BindSpeechcraftRes, err error) { - res = &dto.BindSpeechcraftRes{} - - // 0. 参数验证 - if len(req.AccountNames) == 0 { - return nil, gerror.New("客服账号ID列表不能为空") - } - - // 1. 查询话术(验证存在性和获取租户信息) - r := g.RequestFromCtx(ctx) - if r != nil { - r.SetParam("accountName", req.AccountNames[0]) - } - - speechcraft, err := dao.Speechcraft.GetById(ctx, req.SpeechcraftId) - if err != nil { - return nil, gerror.Wrapf(err, "查询话术失败") - } - if speechcraft == nil { - return nil, gerror.New("话术不存在") - } - - speechcraftTenantId := gconv.String(speechcraft.TenantId) - - // 2. 遍历客服账号,更新每个账号的speechcraft_ids - var newBindings []string - var alreadyBound []string - var notFound []string - - for _, csId := range req.AccountNames { - // 查询客服账号 - csAccount, err := dao.CustomerServiceAccount.FindByAccountName(ctx, csId) - if err != nil || csAccount == nil { - notFound = append(notFound, csId) - g.Log().Warningf(ctx, "客服账号 %s 不存在或已删除,跳过", csId) - continue - } - - // 租户隔离校验 - accountTenantId := gconv.String(csAccount.TenantId) - if speechcraftTenantId != accountTenantId { - g.Log().Warningf(ctx, "话术和客服账号不属于同一租户,跳过: speechcraft_tenant=%s, account_tenant=%s", - speechcraftTenantId, accountTenantId) - notFound = append(notFound, csId) - continue - } - - // 检查是否已绑定 - alreadyExists := false - for _, existingId := range csAccount.SpeechcraftIds { - if existingId == req.SpeechcraftId { - alreadyExists = true - break - } - } - - if alreadyExists { - alreadyBound = append(alreadyBound, csId) - g.Log().Warningf(ctx, "客服账号 %s 已绑定该话术,跳过", csId) - continue - } - - // 添加到speechcraft_ids列表 - csAccount.SpeechcraftIds = append(csAccount.SpeechcraftIds, req.SpeechcraftId) - - // 更新数据库 - filter := bson.M{"_id": csAccount.Id, "isDeleted": false} - update := bson.M{"$set": bson.M{"speechcraftIds": csAccount.SpeechcraftIds}} - if _, err := mongo.DB().Update(ctx, filter, update, entity.CustomerServiceAccountCollection); err != nil { - g.Log().Errorf(ctx, "更新客服账号绑定失败: %s, error=%v", csId, err) - notFound = append(notFound, csId) - continue - } - - newBindings = append(newBindings, csId) - } - - // 3. 如果没有新的绑定,直接返回 - if len(newBindings) == 0 { - res.SuccessCount = 0 - res.AlreadyBound = alreadyBound - res.NotFound = notFound - - if len(alreadyBound) > 0 && len(notFound) > 0 { - res.Message = "部分客服账号已绑定,部分不存在" - } else if len(alreadyBound) > 0 { - res.Message = "所有客服账号已绑定,无需重复绑定" - } else if len(notFound) > 0 { - res.Message = "所有客服账号都不存在或租户不匹配" - } - return res, nil - } - - // 6. 同步到RAGFlow(自动创建知识库) - for _, csId := range newBindings { - // 获取客服账号信息以获取tenant_id - csAccount, err := dao.CustomerServiceAccount.FindByAccountName(ctx, csId) - if err != nil || csAccount == nil { - g.Log().Errorf(ctx, "获取客服账号信息失败: %s", csId) - continue - } - - // 同步到RAGFlow(会自动创建知识库) - tenantId := gconv.String(csAccount.TenantId) - g.Log().Infof(ctx, "客服账号租户信息: cs_id=%s, tenant_id=%v, tenant_id_type=%T", csId, csAccount.TenantId, csAccount.TenantId) - _, err = s.SyncToRAGFlow(ctx, req.SpeechcraftId, csId, tenantId) - if err != nil { - g.Log().Errorf(ctx, "同步到RAGFlow失败: speechcraft_id=%s, cs_id=%s, error=%v", req.SpeechcraftId, csId, err) - // 不阻断绑定流程,失败会进入重试队列 - } - } - - res.SuccessCount = len(newBindings) - res.AlreadyBound = alreadyBound - res.NotFound = notFound - - // 生成详细的响应消息 - if len(alreadyBound) > 0 || len(notFound) > 0 { - res.Message = fmt.Sprintf("成功绑定%d个", len(newBindings)) - if len(alreadyBound) > 0 { - res.Message += fmt.Sprintf(",%d个已绑定", len(alreadyBound)) - } - if len(notFound) > 0 { - res.Message += fmt.Sprintf(",%d个不存在", len(notFound)) - } - } else { - res.Message = fmt.Sprintf("成功绑定%d个客服账号", len(newBindings)) - } - return -} - -// UnbindFromCustomerService 从客服账号解绑话术 -// 参数: ctx - 上下文,req - 解绑请求(包含话术ID和客服账号名) -// 返回: res - 解绑结果信息,err - 错误信息 -// 功能: 从客服账号的RAGFlow知识库中删除话术文档 -func (s *speechcraft) UnbindFromCustomerService(ctx context.Context, req *dto.UnbindSpeechcraftReq) (res *dto.UnbindSpeechcraftRes, err error) { - res = &dto.UnbindSpeechcraftRes{} - - // 1. 验证话术存在 - speechcraft, err := dao.Speechcraft.GetById(ctx, req.SpeechcraftId) - if err != nil { - return nil, gerror.Wrapf(err, "查询话术失败") - } - if speechcraft == nil { - return nil, gerror.New("话术不存在") - } - - // 2. 查询客服账号 - csAccount, err := dao.CustomerServiceAccount.FindByAccountName(ctx, req.AccountName) - if err != nil || csAccount == nil { - res.Success = false - res.Message = "客服账号不存在" - return res, nil - } - - // 3. 从 speechcraft_ids 中移除话术ID - var newSpeechcraftIds []string - found := false - for _, scId := range csAccount.SpeechcraftIds { - if scId == req.SpeechcraftId { - found = true - continue - } - newSpeechcraftIds = append(newSpeechcraftIds, scId) - } - - if !found { - res.Success = false - res.Message = "未找到该绑定关系" - return res, nil - } - - // 4. 更新数据库 - filter := bson.M{"_id": csAccount.Id, "isDeleted": false} - update := bson.M{"$set": bson.M{"speechcraftIds": newSpeechcraftIds}} - if _, err := mongo.DB().Update(ctx, filter, update, entity.CustomerServiceAccountCollection); err != nil { - return nil, gerror.Wrapf(err, "解绑失败") - } - - res.Success = true - res.Message = "解绑成功" - return -} - -// SyncToRAGFlow 同步话术到RAGFlow -// 参数: ctx - 上下文,speechcraftId - 话术ID,accountName - 客服账号名,tenantId - 租户ID -// 返回: documentId - RAGFlow文档ID,err - 错误信息 -// 功能: 将话术上传到指定客服账号的RAGFlow知识库,失败时自动加入重试队列 -func (s *speechcraft) SyncToRAGFlow(ctx context.Context, speechcraftId, accountName, tenantId string) (documentId string, err error) { - // 1. 查询话术 - speechcraft, err := dao.Speechcraft.GetById(ctx, speechcraftId) - if err != nil { - return "", gerror.Wrapf(err, "查询话术失败") - } - if speechcraft == nil { - return "", gerror.New("话术不存在") - } - - // 2. 确保知识库存在,获取真实的datasetId - datasetId, err := s.ensureDatasetExists(ctx, tenantId, "话术") - if err != nil { - return "", gerror.Wrapf(err, "确保知识库存在失败") - } - - // 3. 调用RAGFlow上传文档 - ragflowClient := ragflow.GetGlobalClient() - filename := fmt.Sprintf("%s_%s_%s.txt", speechcraft.Direction, speechcraft.Tag, accountName) - documentId, err = ragflowClient.UploadDocumentFromText(ctx, datasetId, speechcraft.Content, filename) - if err != nil { - jaeger.RecordError(ctx, err, "话术上传RAGFlow失败") - return "", gerror.Wrap(err, "话术上传RAGFlow失败") - } - - // 3.1 上传成功后立即调用解析接口 - g.Log().Infof(ctx, "文档上传成功,开始解析: document_id=%s", documentId) - if err = ragflowClient.ParseDocuments(ctx, datasetId, []string{documentId}); err != nil { - // 解析失败只记录日志,不影响绑定流程(文档已上传,可以手动重试解析) - g.Log().Errorf(ctx, "文档解析失败: document_id=%s, error=%v", documentId, err) - jaeger.RecordError(ctx, err, "文档解析失败") - } else { - g.Log().Infof(ctx, "文档解析请求已发送: document_id=%s", documentId) - } - - // 4. 更新MongoDB的RagSyncRecord - now := gtime.Now().Format("Y-m-d H:i:s") - updated := false - for i := range speechcraft.RagSyncRecords { - record := &speechcraft.RagSyncRecords[i] - if record.AccountName == accountName { - record.RagDocumentId = documentId - record.RagSyncStatus = "synced" - record.SyncTime = now - record.RetryCount = 0 - updated = true - break - } - } - - // 如果没有找到记录,新增 - if !updated { - speechcraft.RagSyncRecords = append(speechcraft.RagSyncRecords, entity.RagSyncRecord{ - AccountName: accountName, - RagDocumentId: documentId, - RagSyncStatus: "synced", - SyncTime: now, - RetryCount: 0, - }) - } - - if err = dao.Speechcraft.UpdateEntity(ctx, speechcraft); err != nil { - return "", gerror.Wrapf(err, "更新话术同步状态失败") - } - - // 注意:不再更新Chat的datasetIds,因为创建Chat时已经绑定了知识库 - // 话术文档上传到知识库后,Chat会自动使用该知识库的内容 - - glog.Infof(ctx, "话术同步成功: speechcraft_id=%s, account_name=%s, document_id=%s", speechcraftId, accountName, documentId) - return documentId, nil -} - -// ensureDatasetExists 已废弃,改用公共方法 EnsureTenantDataset -// 保留此方法仅为兼容性,直接调用公共方法 -func (s *speechcraft) ensureDatasetExists(ctx context.Context, tenantId, datasetType string) (datasetId string, err error) { - return EnsureTenantDataset(ctx, tenantId) -} diff --git a/service/webhook_service.go b/service/webhook_service.go deleted file mode 100644 index ed6736d..0000000 --- a/service/webhook_service.go +++ /dev/null @@ -1,101 +0,0 @@ -// Package service - Webhook服务 -// 功能:接收并处理平台(小红书、抖音)的webhook消息 -package service - -import ( - "context" - "customer-server/dao" - "customer-server/model/dto" - - "gitea.com/red-future/common/jaeger" - "gitea.com/red-future/common/rabbitmq" - "gitea.com/red-future/common/redis" - "gitea.com/red-future/common/utils" - "github.com/gogf/gf/v2/os/glog" - "github.com/gogf/gf/v2/os/gtime" - "github.com/gogf/gf/v2/util/gconv" -) - -var Webhook = new(webhookService) - -type webhookService struct{} - -// Receive 接收平台消息并写入队列 -func (s *webhookService) Receive(ctx context.Context, req *dto.WebhookReceiveReq) (res *dto.WebhookReceiveRes, err error) { - glog.Infof(ctx, "收到 Webhook 消息 - 平台: %s, 用户: %s, 内容: %s", req.Platform, req.UserId, req.Content) - - // 生成消息ID - now := gtime.Now() - messageId := req.Platform + "_" + req.UserId + "_" + gconv.String(now.TimestampNano()) - if req.MsgId != "" { - messageId = req.MsgId // 使用平台消息ID(便于去重) - } - - // 从 token 获取租户ID - var tenantId string - if user, userErr := utils.GetUserInfo(ctx); userErr == nil { - tenantId = gconv.String(user.TenantId) - } - - // 构造消息 - userId := req.Platform + "_" + req.UserId // 添加平台前缀 - // 获取当前实例的动态响应队列名(自动生成,支持多实例部署) - baseQueue := GetConfigString(ctx, "rabbitmq.responseQueue") - replyQueue := rabbitmq.GetInstanceQueueName(baseQueue) - msg := &redis.SendStreamMessage{ - UserId: userId, - TenantId: tenantId, - Content: req.Content, - Timestamp: now.Timestamp(), - MessageId: messageId, - Platform: req.Platform, - AccountId: req.AccountId, - ReplyQueue: replyQueue, - } - - // 检查是否有 session 缓存,无缓存说明已归档,需要读取历史 - if sessionId, _ := redis.GetSessionCache(ctx, tenantId, userId); sessionId == "" { - if history, histErr := dao.Conversation.GetRecentHistory(ctx, userId, redis.GetHistoryContextLimit()); histErr == nil && len(history) > 0 { - msg.History = history - glog.Infof(ctx, "用户已归档,读取 %d 轮历史对话 - 用户: %s", len(history), userId) - } - } - - // 写入 Redis Stream - streamMsgId, err := redis.AddToStream(ctx, redis.RAGFlowRequestStreamKey, msg) - if err != nil { - jaeger.RecordError(ctx, err, "写入 Stream 失败") - return - } - - glog.Infof(ctx, "消息已写入 Stream - MessageID: %s", streamMsgId) - - res = &dto.WebhookReceiveRes{ - Success: true, - MsgId: streamMsgId, - } - return -} - -// GetHistory 查询用户对话记录 -func (s *webhookService) GetHistory(ctx context.Context, req *dto.ConversationListReq) (res *dto.ConversationListRes, err error) { - list, err := dao.Conversation.FindByUserId(ctx, req.UserId, req.Limit) - if err != nil { - jaeger.RecordError(ctx, err, "查询对话记录失败") - return - } - - res = &dto.ConversationListRes{ - List: make([]*dto.ConversationItem, 0, len(list)), - } - - for _, item := range list { - res.List = append(res.List, &dto.ConversationItem{ - Question: item.Question, - Answer: item.Answer, - MsgTime: gtime.New(item.MsgTime).Format("Y-m-d H:i:s"), - SessionId: item.SessionId, - }) - } - return -} diff --git a/service/websocket_service.go b/service/websocket_service.go deleted file mode 100644 index a0ff73c..0000000 --- a/service/websocket_service.go +++ /dev/null @@ -1,506 +0,0 @@ -// Package service - WebSocket服务 -// 功能:WebSocket连接管理、消息推送、心跳维护 -package service - -import ( - "context" - "customer-server/dao" - "customer-server/model/dto" - "customer-server/model/entity" - "customer-server/util" - "errors" - "net/http" - - commonMongo "gitea.com/red-future/common/db/mongo" - "gitea.com/red-future/common/jaeger" - "gitea.com/red-future/common/rabbitmq" - "gitea.com/red-future/common/redis" - "gitea.com/red-future/common/utils" - "github.com/gogf/gf/v2/container/gmap" - "github.com/gogf/gf/v2/encoding/gjson" - "github.com/gogf/gf/v2/errors/gerror" - "github.com/gogf/gf/v2/net/ghttp" - "github.com/gogf/gf/v2/os/glog" - "github.com/gogf/gf/v2/os/gtime" - "github.com/gogf/gf/v2/util/gconv" - "github.com/gorilla/websocket" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" -) - -// WebSocket 全局单例 -var WebSocket = &websocketService{ - connections: gmap.NewStrAnyMap(true), - upgrader: websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { - return true // 允许跨域 - }, - }, -} - -// GoFrame 并发安全 Map -type websocketService struct { - connections *gmap.StrAnyMap - upgrader websocket.Upgrader -} - -// key: userId_platform -// wsConnection WebSocket 连接信息 -type wsConnection struct { - UserId string - Platform string - TenantId string - AccountName string // 客服账号ID - Conn *websocket.Conn - CreatedAt int64 -} - -// 租户ID缓存 Key 前缀和过期时间 -const ( - tenantCacheKeyPrefix = "tenant:custsvc:" - tenantCacheExpire = 300 // 5分钟 -) - -// resolveTenantId 获取租户ID(兼容仅有accountName的场景) -// 参数: ctx - 上下文,r - HTTP请求对象 -// 返回: tenantId - 租户ID,err - 错误信息 -// 功能: 优先从token获取,其次从客服账号查询,支持缓存 -func (s *websocketService) resolveTenantId(ctx context.Context, r *ghttp.Request) (tenantId string, err error) { - // 1. 优先从 token 获取 - if user, userErr := util.GetTenantInfo(ctx); userErr == nil { - if id := gconv.String(user.TenantId); id != "" { - return id, nil - } - } - - if r == nil { - return "", gerror.New("无法获取租户信息:缺少请求上下文") - } - - // 2. 从请求参数获取 accountName - custId := r.Get("accountName").String() - if custId == "" { - custId = r.Get("account_name").String() - } - if custId == "" { - return "", gerror.New("缺少 accountName 参数") - } - - // 2. 从 Redis 缓存查询 - cacheKey := tenantCacheKeyPrefix + custId - if cached, _ := redis.RedisClient().Get(ctx, cacheKey); !cached.IsEmpty() { - return cached.String(), nil - } - - // 3. Redis 未命中,查询 MongoDB - coll := commonMongo.GetDB().Collection(entity.CustomerServiceAccountCollection) - var doc struct { - TenantId interface{} `bson:"tenantId"` - } - - filters := []bson.M{ - {"accountName": custId, "isDeleted": false}, - {"accountName": custId}, // 兼容旧数据未设置 isDeleted - } - - if objectId, objErr := bson.ObjectIDFromHex(custId); objErr == nil { - filters = append(filters, - bson.M{"_id": objectId, "isDeleted": false}, - bson.M{"_id": objectId}, - ) - } - - for _, filter := range filters { - if err = coll.FindOne(ctx, filter).Decode(&doc); err == nil { - tenantId = gconv.String(doc.TenantId) - if tenantId == "" { - return "", gerror.Newf("客服账号 %s 未配置 tenantId", custId) - } - // 4. 写入 Redis 缓存 - redis.RedisClient().SetEX(ctx, cacheKey, tenantId, tenantCacheExpire) - return - } - if !errors.Is(err, mongo.ErrNoDocuments) { - return - } - } - - return "", gerror.Newf("客服账号 %s 不存在", custId) -} - -// Connect 建立 WebSocket 连接 -func (s *websocketService) Connect(ctx context.Context, r *ghttp.Request, userId, platform string) error { - // 使用原生upgrader升级WebSocket连接 - ws, err := s.upgrader.Upgrade(r.Response.Writer, r.Request, nil) - if err != nil { - jaeger.RecordError(ctx, err, "WebSocket 升级失败") - return err - } - defer ws.Close() - - tenantId, err := s.resolveTenantId(ctx, r) - if err != nil { - jaeger.RecordError(ctx, err, "获取租户ID失败") - return err - } - - // 读取accountName参数(客服账号名称) - accountName := r.Get("accountName").String() - if accountName == "" { - accountName = r.Get("account_name").String() - } - - glog.Infof(ctx, "WebSocket 连接建立 - 用户: %s, 平台: %s, 租户: %s, 客服账号: %s", userId, platform, tenantId, accountName) - - // key格式: tenantId:userId_platform (确保租户隔离) - key := tenantId + ":" + userId + "_" + platform - - // 关闭旧连接 - if old := s.connections.Get(key); old != nil { - old.(*wsConnection).Conn.Close() - } - - // 注册新连接(携带 TenantId 和 AccountName) - s.connections.Set(key, &wsConnection{ - UserId: userId, - Platform: platform, - TenantId: tenantId, - AccountName: accountName, - Conn: ws, - CreatedAt: gtime.Now().Timestamp(), - }) - - // 发送开场白(连接建立后立即推送) - if accountName != "" { - greeting := s.getGreeting(ctx, accountName, tenantId) - if greeting != "" { - s.writeJSON(ws, &dto.WebSocketPushMsg{ - Type: "message", - Message: greeting, - }) - glog.Infof(ctx, "已发送开场白 - 用户: %s, 客服账号: %s, 长度: %d", userId, accountName, len(greeting)) - } else { - glog.Warningf(ctx, "客服账号未配置开场白 - accountName: %s, tenantId: %s", accountName, tenantId) - } - } - - // 处理消息(阻塞) - s.handleConnection(ctx, key, ws) - return nil -} - -// handleConnection 处理 WebSocket 连接 -func (s *websocketService) handleConnection(ctx context.Context, key string, conn *websocket.Conn) { - defer func() { - s.connections.Remove(key) - conn.Close() - glog.Infof(ctx, "WebSocket 连接断开 - %s", key) - }() - - for { - msgType, message, err := conn.ReadMessage() - if err != nil { - // 排除正常关闭情况:正常关闭、离开页面、无状态码关闭 - if websocket.IsUnexpectedCloseError(err, - websocket.CloseNormalClosure, - websocket.CloseGoingAway, - websocket.CloseNoStatusReceived, - ) { - jaeger.RecordError(ctx, err, "WebSocket 读取错误") - } - break - } - - if msgType != websocket.TextMessage { - continue - } - - content := gconv.String(message) - glog.Infof(ctx, "收到 WebSocket 消息 - %s: %s", key, content) - - // 解析 userId - connInfo := s.connections.Get(key) - if connInfo == nil { - break - } - wsConn := connInfo.(*wsConnection) - - // 先检查对话轮数,>5 则只发卡片,跳过话术 - // checkCardBeforeProcess 已推送卡片消息,无需ack - if handled, err := checkCardBeforeProcess(ctx, wsConn.TenantId, wsConn.UserId, wsConn.Platform); err != nil { - jaeger.RecordError(ctx, err, "卡片检查失败") - } else if handled { - continue - } - - // 话术匹配并发布响应 - // status 暂时为空,表示任意行为匹配 - // isPushed=true表示已直接推送响应(话术匹配),无需ack - // isPushed=false表示转发到RAGFlow,需要ack告知用户正在处理 - - // 创建带有accountName的context,供GetTenantInfo使用 - newCtx := ctx - if wsConn.AccountName != "" { - newCtx = context.WithValue(ctx, "accountName", wsConn.AccountName) - } - - isPushed, err := Speechcraft.ProcessAndPublish(newCtx, wsConn.UserId, wsConn.Platform, wsConn.TenantId, content, "", wsConn.AccountName) - if err != nil { - jaeger.RecordError(ctx, err, "话术处理失败") - s.writeJSON(conn, &dto.WebSocketPushMsg{Type: "error", Message: "消息处理失败"}) - continue - } - - // 只在转发到RAGFlow时发送ack(Go直接返回的不需要ack) - if !isPushed { - s.writeJSON(conn, &dto.WebSocketPushMsg{Type: "ack", Message: "消息已接收,正在处理..."}) - } - } -} - -// writeJSON 发送 JSON 消息 -func (s *websocketService) writeJSON(conn *websocket.Conn, data interface{}) { - jsonBytes, _ := gjson.Encode(data) - conn.WriteMessage(websocket.TextMessage, jsonBytes) -} - -// getGreeting 获取客服账号的开场白 -func (s *websocketService) getGreeting(ctx context.Context, accountName, tenantId string) string { - glog.Infof(ctx, "查询开场白 - accountName: %s, tenantId: %s", accountName, tenantId) - - // 复用dao层方法,保持查询逻辑一致 - account, err := dao.CustomerServiceAccount.FindByAccountName(ctx, accountName) - if err != nil { - jaeger.RecordError(ctx, err, "查询客服账号开场白失败") - glog.Errorf(ctx, "查询开场白失败: %v", err) - return "" - } - - if account == nil { - glog.Warningf(ctx, "客服账号不存在: accountName=%s", accountName) - return "" - } - - // 详细输出查询结果 - glog.Infof(ctx, "查询到客服账号: Id=%s, AccountName=%s, TenantId=%v, Greeting长度=%d, Platform=%s", - account.Id.Hex(), account.AccountName, account.TenantId, len(account.Greeting), account.Platform) - - return account.Greeting -} - -// Send 发送消息到 Redis Stream(HTTP 接口) -func (s *websocketService) Send(ctx context.Context, req *dto.WebSocketSendReq) (*dto.WebSocketSendRes, error) { - // 从 token 获取租户ID - var tenantId string - if user, err := utils.GetUserInfo(ctx); err == nil { - tenantId = gconv.String(user.TenantId) - } - - messageId, err := s.sendToStream(ctx, req.UserId, tenantId, req.Content) - if err != nil { - return nil, err - } - return &dto.WebSocketSendRes{MessageId: messageId}, nil -} - -// sendToStream 发送消息到 Redis Stream -// 如果用户无 session 缓存(已归档),则从 MongoDB 读取历史对话一起发送 -func (s *websocketService) sendToStream(ctx context.Context, userId, tenantId, content string) (string, error) { - now := gtime.Now() - platform := "xiaohongshu" // 默认平台 - - // 获取当前实例的动态响应队列名(自动生成,支持多实例部署) - baseQueue := GetConfigString(ctx, "rabbitmq.responseQueue") - replyQueue := rabbitmq.GetInstanceQueueName(baseQueue) - - // 获取accountName(优先使用用户选择的方向映射) - var accountName string - var chatId string - - // 1. 尝试从用户状态获取(用户选择方向后的映射) - if userState, stateErr := redis.GetUserState(ctx, userId, platform); stateErr == nil && userState.AccountName != "" { - accountName = userState.AccountName - glog.Infof(ctx, "使用用户选择方向的客服账号: %s", accountName) - } else { - // 2. 从连接信息获取(默认) - key := tenantId + ":" + userId + "_" + platform - connInfo := s.connections.Get(key) - if connInfo != nil { - wsConn := connInfo.(*wsConnection) - accountName = wsConn.AccountName - glog.Infof(ctx, "使用连接默认客服账号: %s", accountName) - } - } - - // 根据accountName查询ragflow_config获取chat_id - if accountName != "" { - config, err := dao.RAGFlowConfig.FindByAccountName(ctx, accountName) - if err == nil && config != nil { - chatId = config.ChatId - glog.Infof(ctx, "查询到chatId: accountName=%s, chatId=%s", accountName, chatId) - } - } - - // 如果未找到chatId,报错(应该从ragflowconfig表重建session) - if chatId == "" { - return "", gerror.New("chatId未找到,需要重建RAGFlow session") - } - - msg := &redis.SendStreamMessage{ - UserId: userId, - TenantId: tenantId, - AccountName: accountName, - ChatId: chatId, - Content: content, - Timestamp: now.Timestamp(), - MessageId: userId + "_" + gconv.String(now.TimestampNano()), - ReplyQueue: replyQueue, - } - - // 检查是否有 session 缓存,无缓存说明已归档,需要读取历史 - if sessionId, _ := redis.GetSessionCache(ctx, tenantId, userId); sessionId == "" { - // 从 MongoDB 读取历史对话 - if history, err := dao.Conversation.GetRecentHistory(ctx, userId, redis.GetHistoryContextLimit()); err == nil && len(history) > 0 { - msg.History = history - glog.Infof(ctx, "用户已归档,读取 %d 轮历史对话 - 用户: %s", len(history), userId) - } - } - - messageId, err := redis.AddToStream(ctx, redis.RAGFlowRequestStreamKey, msg) - if err != nil { - return "", err - } - - glog.Infof(ctx, "消息已发送到 Stream - MessageID: %s, 用户: %s", messageId, userId) - return messageId, nil -} - -// SendToUser 发送消息给指定用户 -func (s *websocketService) SendToUser(ctx context.Context, tenantId, userId, platform string, data interface{}) error { - // key格式: tenantId:userId_platform - key := tenantId + ":" + userId + "_" + platform - connInfo := s.connections.Get(key) - if connInfo == nil { - glog.Warningf(ctx, "用户不在线 - %s", key) - return nil - } - - s.writeJSON(connInfo.(*wsConnection).Conn, data) - return nil -} - -// Broadcast 广播消息给所有连接 -func (s *websocketService) Broadcast(ctx context.Context, content string) { - msg := &dto.WebSocketPushMsg{Type: "broadcast", Content: content} - msgBytes := gjson.MustEncode(msg) - - s.connections.Iterator(func(key string, value interface{}) bool { - conn := value.(*wsConnection) - if err := conn.Conn.WriteMessage(websocket.TextMessage, msgBytes); err != nil { - jaeger.RecordError(ctx, err, "广播消息失败 - "+key) - } - return true - }) -} - -// GetOnlineUsers 获取在线用户列表 -func (s *websocketService) GetOnlineUsers() *dto.WebSocketOnlineRes { - users := make([]dto.WebSocketOnlineUserRes, 0, s.connections.Size()) - - s.connections.Iterator(func(_ string, value interface{}) bool { - conn := value.(*wsConnection) - users = append(users, dto.WebSocketOnlineUserRes{ - UserId: conn.UserId, - Platform: conn.Platform, - CreatedAt: conn.CreatedAt, - }) - return true - }) - - return &dto.WebSocketOnlineRes{ - Count: len(users), - Users: users, - } -} - -// PushRAGFlowResponse 推送 RAGFlow 响应给用户 -func (s *websocketService) PushRAGFlowResponse(ctx context.Context, tenantId, userId, platform, content string) error { - return s.SendToUser(ctx, tenantId, userId, platform, &dto.WebSocketPushMsg{Type: "answer", Content: content}) -} - -// ============== RabbitMQ 消费者(追问延时消息)============== - -// FollowUpConsumer 追问消费者 -type FollowUpConsumer struct { - consumer *rabbitmq.Consumer -} - -// NewFollowUpConsumer 创建追问消费者 -func NewFollowUpConsumer(ctx context.Context) *FollowUpConsumer { - queueName := GetConfigString(ctx, "followUp.queue") - return &FollowUpConsumer{ - consumer: rabbitmq.NewConsumer(queueName, handleFollowUp), - } -} - -// Start 启动消费者 -func (c *FollowUpConsumer) Start(ctx context.Context) (err error) { - glog.Info(ctx, "追问消费者启动...") - return c.consumer.Start(ctx) -} - -// Stop 停止消费者 -func (c *FollowUpConsumer) Stop(ctx context.Context) { - c.consumer.Stop(ctx) -} - -// handleFollowUp 处理追问消息 -func handleFollowUp(ctx context.Context, body []byte) (err error) { - ctx, span := jaeger.NewSpan(ctx, "consumer.followup") - defer span.End() - - var msg redis.FollowUpMessage - if err = gjson.DecodeTo(body, &msg); err != nil { - jaeger.RecordError(ctx, err, "解析追问消息失败") - return - } - - glog.Infof(ctx, "收到追问消息 - 用户: %s, 类型: %d", msg.UserId, msg.FollowUpType) - - // 检查用户状态,如果在状态5(方向选择)或状态3(发卡片),跳过追问 - userState, err := redis.GetUserState(ctx, msg.UserId, msg.Platform) - if err != nil { - jaeger.RecordError(ctx, err, "获取用户状态失败") - return - } - - if userState.Stage == 5 { - glog.Infof(ctx, "用户 %s 在方向选择状态,跳过追问", msg.UserId) - return - } - - if userState.Stage == 3 { - glog.Infof(ctx, "用户 %s 在发卡片状态,跳过追问", msg.UserId) - return - } - - // 检查用户是否在追问发送后有活跃 - isActive, err := redis.IsUserActive(ctx, msg.UserId, int64(redis.GetFollowUpDelay(msg.FollowUpType))) - if err != nil { - jaeger.RecordError(ctx, err, "检查用户活跃状态失败") - return - } - - if isActive { - glog.Infof(ctx, "用户 %s 在追问期间有活跃,跳过追问", msg.UserId) - return - } - - // 发送追问消息给用户 - if err = WebSocket.PushRAGFlowResponse(ctx, msg.TenantId, msg.UserId, msg.Platform, msg.Content); err != nil { - jaeger.RecordError(ctx, err, "推送追问消息失败") - } - - glog.Infof(ctx, "追问消息已发送 - 租户: %s, 用户: %s, 内容: %s", msg.TenantId, msg.UserId, msg.Content) - return -} diff --git a/service/xiaohongshu_service.go b/service/xiaohongshu_service.go deleted file mode 100644 index aadc0f9..0000000 --- a/service/xiaohongshu_service.go +++ /dev/null @@ -1,459 +0,0 @@ -package service - -import ( - "context" - "crypto/aes" - "crypto/cipher" - "crypto/rand" - "crypto/sha256" - "customer-server/dao" - "customer-server/model/dto" - "customer-server/model/entity" - "encoding/base64" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "strings" - - commonMongo "gitea.com/red-future/common/db/mongo" - "gitea.com/red-future/common/jaeger" - "gitea.com/red-future/common/rabbitmq" - "gitea.com/red-future/common/redis" - "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/os/gtime" - "github.com/gogf/gf/v2/util/gconv" - "go.mongodb.org/mongo-driver/v2/bson" -) - -var Xiaohongshu = new(xiaohongshu) - -type xiaohongshu struct{} - -const ( - XhsApiBaseUrl = "https://adapi.xiaohongshu.com" - XhsPlatformName = "xiaohongshu" - XhsEncryptSplit = "~split~" -) - -// ==================== 加解密工具 ==================== - -// Encrypt AES加密 -// 参数: ctx - 上下文,content - 待加密内容,secretKey - 密钥(Base64编码) -// 返回: res - 加密后的字符串(Base64编码),err - 错误信息 -// 功能: 使用AES-CBC模式加密内容,用于小红书API签名 -func (s *xiaohongshu) Encrypt(ctx context.Context, content, secretKey string) (res string, err error) { - keyBytes, err := base64.StdEncoding.DecodeString(secretKey) - if err != nil { - jaeger.RecordError(ctx, err) - return - } - - block, err := aes.NewCipher(keyBytes) - if err != nil { - jaeger.RecordError(ctx, err) - return - } - - iv := make([]byte, aes.BlockSize) - if _, err = io.ReadFull(rand.Reader, iv); err != nil { - jaeger.RecordError(ctx, err) - return - } - - stream := cipher.NewCBCEncrypter(block, iv) - contentBytes := []byte(content) - paddedContent := pkcs5Padding(contentBytes, aes.BlockSize) - cipherText := make([]byte, len(paddedContent)) - stream.CryptBlocks(cipherText, paddedContent) - - ivBase64 := base64.StdEncoding.EncodeToString(iv) - cipherBase64 := base64.StdEncoding.EncodeToString(cipherText) - res = fmt.Sprintf("%s%s%s", ivBase64, XhsEncryptSplit, cipherBase64) - return -} - -func (s *xiaohongshu) Decrypt(ctx context.Context, cipherText, secretKey string) (res string, err error) { - keyBytes, err := base64.StdEncoding.DecodeString(secretKey) - if err != nil { - jaeger.RecordError(ctx, err) - return - } - - parts := strings.Split(cipherText, XhsEncryptSplit) - if len(parts) != 2 { - err = errors.New("invalid cipher text format") - jaeger.RecordError(ctx, err) - return - } - - iv, err := base64.StdEncoding.DecodeString(parts[0]) - if err != nil { - jaeger.RecordError(ctx, err) - return - } - - encrypted, err := base64.StdEncoding.DecodeString(parts[1]) - if err != nil { - jaeger.RecordError(ctx, err) - return - } - - block, err := aes.NewCipher(keyBytes) - if err != nil { - jaeger.RecordError(ctx, err) - return - } - - if len(encrypted)%aes.BlockSize != 0 { - err = errors.New("cipher text is not a multiple of block size") - jaeger.RecordError(ctx, err) - return - } - - stream := cipher.NewCBCDecrypter(block, iv) - decrypted := make([]byte, len(encrypted)) - stream.CryptBlocks(decrypted, encrypted) - - decrypted, err = pkcs5Unpadding(decrypted) - if err != nil { - jaeger.RecordError(ctx, err) - return - } - - res = string(decrypted) - return -} - -func pkcs5Padding(data []byte, blockSize int) []byte { - padding := blockSize - len(data)%blockSize - padText := make([]byte, padding) - for i := range padText { - padText[i] = byte(padding) - } - return append(data, padText...) -} - -func pkcs5Unpadding(data []byte) (res []byte, err error) { - length := len(data) - if length == 0 { - err = errors.New("invalid padding size") - return - } - padding := int(data[length-1]) - if padding > length { - err = errors.New("invalid padding size") - return - } - res = data[:length-padding] - return -} - -// ==================== 账号绑定管理 ==================== - -func (s *xiaohongshu) HandleBindAccount(ctx context.Context, req *dto.XhsBindAccountReq) (err error) { - var account entity.CustomerServiceAccount - filter := bson.M{"platform": XhsPlatformName, "isDeleted": false} - if err = dao.MongoDAO.FindOne(ctx, filter, &account, entity.CustomerServiceAccountCollection); err != nil { - jaeger.RecordError(ctx, err) - return - } - - decrypted, err := s.Decrypt(ctx, req.Content, account.SecretKey) - if err != nil { - jaeger.RecordError(ctx, err) - return - } - - var bindData dto.XhsBindAccountDecrypted - if err = json.Unmarshal([]byte(decrypted), &bindData); err != nil { - jaeger.RecordError(ctx, err) - return - } - - update := bson.M{ - "$set": bson.M{ - "accessToken": bindData.Token, - "appId": bindData.AppId, - "xhsUserId": bindData.UserId, - "updatedAt": gtime.Now().Time, - }, - } - filter = bson.M{"_id": account.Id} - _, err = commonMongo.GetDB().Collection(entity.CustomerServiceAccountCollection).UpdateOne(ctx, filter, update) - if err != nil { - jaeger.RecordError(ctx, err) - return - } - - g.Log().Infof(ctx, "[小红书] 绑定账户成功: userId=%s, nickName=%s", bindData.UserId, bindData.NickName) - return -} - -func (s *xiaohongshu) HandleUnbindAccount(ctx context.Context, req *dto.XhsUnbindAccountReq) (err error) { - var account entity.CustomerServiceAccount - filter := bson.M{"platform": XhsPlatformName, "isDeleted": false} - if err = dao.MongoDAO.FindOne(ctx, filter, &account, entity.CustomerServiceAccountCollection); err != nil { - jaeger.RecordError(ctx, err) - return - } - - decrypted, err := s.Decrypt(ctx, req.Content, account.SecretKey) - if err != nil { - jaeger.RecordError(ctx, err) - return - } - - var unbindData dto.XhsUnbindAccountDecrypted - if err = json.Unmarshal([]byte(decrypted), &unbindData); err != nil { - jaeger.RecordError(ctx, err) - return - } - - update := bson.M{ - "$set": bson.M{ - "accessToken": "", - "xhsUserId": "", - "updatedAt": gtime.Now().Time, - }, - } - filter = bson.M{"_id": account.Id} - _, err = commonMongo.GetDB().Collection(entity.CustomerServiceAccountCollection).UpdateOne(ctx, filter, update) - if err != nil { - jaeger.RecordError(ctx, err) - return - } - - g.Log().Infof(ctx, "[小红书] 解绑账户成功: userId=%s", unbindData.UserId) - return -} - -// ==================== 消息收发 ==================== - -func (s *xiaohongshu) HandleReceiveMessage(ctx context.Context, req *dto.XhsReceiveMessageReq) (err error) { - accountId, err := s.getAccountIdByPlatform(ctx) - if err != nil { - return - } - - var account entity.CustomerServiceAccount - filter := bson.M{"_id": accountId, "isDeleted": false} - if err = dao.MongoDAO.FindOne(ctx, filter, &account, entity.CustomerServiceAccountCollection); err != nil { - jaeger.RecordError(ctx, err) - return - } - - decrypted, err := s.Decrypt(ctx, req.Content, account.SecretKey) - if err != nil { - jaeger.RecordError(ctx, err) - return - } - - var conversation entity.Conversation - id := bson.NewObjectID() - conversation.Id = &id // 取地址赋值给指针类型 - conversation.SessionId = fmt.Sprintf("%s_%s", req.FromUserId, XhsPlatformName) - conversation.UserId = req.FromUserId - conversation.CustomerServiceId = accountId.Hex() - conversation.Role = "user" - conversation.Platform = XhsPlatformName - conversation.MessageId = req.MessageId - conversation.MessageType = req.MessageType - now := gtime.Now().Time - conversation.CreatedAt = &now // 取地址赋值给指针类型 - - switch req.MessageType { - case "TEXT": - var textContent dto.XhsTextContent - if err = json.Unmarshal([]byte(decrypted), &textContent); err != nil { - jaeger.RecordError(ctx, err) - return - } - conversation.Content = textContent.Text - case "IMAGE": - var imgContent dto.XhsImageContent - if err = json.Unmarshal([]byte(decrypted), &imgContent); err != nil { - jaeger.RecordError(ctx, err) - return - } - conversation.Content = fmt.Sprintf("[图片]%s", imgContent.Link) - case "VIDEO": - var videoContent dto.XhsVideoContent - if err = json.Unmarshal([]byte(decrypted), &videoContent); err != nil { - jaeger.RecordError(ctx, err) - return - } - conversation.Content = fmt.Sprintf("[视频]%s", videoContent.Link) - case "CARD": - var cardContent dto.XhsCardContent - if err = json.Unmarshal([]byte(decrypted), &cardContent); err != nil { - jaeger.RecordError(ctx, err) - return - } - conversation.Content = fmt.Sprintf("[卡片-%s]%s", cardContent.ContentType, cardContent.Id) - case "REVOKE": - var revokeContent dto.XhsRevokeContent - if err = json.Unmarshal([]byte(decrypted), &revokeContent); err != nil { - jaeger.RecordError(ctx, err) - return - } - conversation.Content = fmt.Sprintf("[撤回消息]%s", revokeContent.MessageId) - case "HINT": - conversation.Content = "[系统提示消息]" - case "SMILES": - conversation.Content = "[表情消息]" - default: - conversation.Content = fmt.Sprintf("[%s类型消息]", req.MessageType) - } - - _, err = commonMongo.GetDB().Collection(entity.ConversationCollection).InsertOne(ctx, conversation) - if err != nil { - jaeger.RecordError(ctx, err) - return - } - - g.Log().Infof(ctx, "[小红书] 接收消息成功: sessionId=%s, messageType=%s", conversation.SessionId, req.MessageType) - - if req.MessageType == "TEXT" { - asyncCtx := context.WithoutCancel(ctx) - go s.processUserMessage(asyncCtx, &account, &conversation) - } - return -} - -func (s *xiaohongshu) SendMessage(ctx context.Context, account *entity.CustomerServiceAccount, toUserId, content string) (err error) { - textContent := dto.XhsTextContent{Text: content} - contentJson, err := json.Marshal(textContent) - if err != nil { - jaeger.RecordError(ctx, err) - return - } - - encrypted, err := s.Encrypt(ctx, string(contentJson), account.SecretKey) - if err != nil { - jaeger.RecordError(ctx, err) - return - } - - now := gtime.Now() - sendReq := dto.XhsSendMessageReq{ - UserId: account.XhsUserId, - RequestId: fmt.Sprintf("%d", now.UnixNano()), - MessageType: "TEXT", - FromUserId: account.XhsUserId, - ToUserId: toUserId, - ThirdAccountId: account.Id.Hex(), - Timestamp: now.UnixMilli(), - Content: encrypted, - } - - url := fmt.Sprintf("%s/api/open/im/third/send", XhsApiBaseUrl) - client := g.Client() - client.SetHeader("Access-Token", account.AccessToken) - client.SetHeader("Content-Type", "application/json") - - resp, err := client.Post(ctx, url, sendReq) - if err != nil { - jaeger.RecordError(ctx, err) - return - } - defer resp.Close() - - var sendRes dto.XhsSendMessageRes - if err = json.Unmarshal(resp.ReadAll(), &sendRes); err != nil { - jaeger.RecordError(ctx, err) - return - } - - if sendRes.Code != 0 { - err = fmt.Errorf("发送消息失败: code=%d, msg=%s", sendRes.Code, sendRes.Msg) - jaeger.RecordError(ctx, err) - return - } - - var conversation entity.Conversation - id2 := bson.NewObjectID() - conversation.Id = &id2 // 取地址赋值给指针类型 - conversation.SessionId = fmt.Sprintf("%s_%s", toUserId, XhsPlatformName) - conversation.UserId = toUserId - conversation.CustomerServiceId = account.Id.Hex() - conversation.Role = "assistant" - conversation.Platform = XhsPlatformName - conversation.MessageId = sendRes.Data.MessageId - conversation.MessageType = "TEXT" - conversation.Content = content - now2 := gtime.Now().Time - conversation.CreatedAt = &now2 // 取地址赋值给指针类型 - - _, err = commonMongo.GetDB().Collection(entity.ConversationCollection).InsertOne(ctx, conversation) - if err != nil { - jaeger.RecordError(ctx, err) - return - } - - g.Log().Infof(ctx, "[小红书] 发送消息成功: toUserId=%s, messageId=%s", toUserId, sendRes.Data.MessageId) - return -} - -func (s *xiaohongshu) GenerateSignature(ctx context.Context, secretKey, requestBody string) (res string) { - h := sha256.New() - h.Write([]byte(secretKey + requestBody)) - res = hex.EncodeToString(h.Sum(nil)) - return -} - -// ==================== 私有方法 ==================== - -func (s *xiaohongshu) getAccountIdByPlatform(ctx context.Context) (res bson.ObjectID, err error) { - var account entity.CustomerServiceAccount - filter := bson.M{"platform": XhsPlatformName, "isDeleted": false} - if err = dao.MongoDAO.FindOne(ctx, filter, &account, entity.CustomerServiceAccountCollection); err != nil { - jaeger.RecordError(ctx, err) - return - } - res = *account.Id // 解引用指针类型 - return -} - -func (s *xiaohongshu) processUserMessage(ctx context.Context, account *entity.CustomerServiceAccount, conversation *entity.Conversation) { - if err := s.sendToRAGFlowStream(ctx, account, conversation); err != nil { - jaeger.RecordError(ctx, err) - g.Log().Errorf(ctx, "[小红书] 发送到RAGFlow Stream失败: %v", err) - return - } - g.Log().Infof(ctx, "[小红书] 消息已发送到RAGFlow Stream: userId=%s", conversation.UserId) -} - -func (s *xiaohongshu) sendToRAGFlowStream(ctx context.Context, account *entity.CustomerServiceAccount, conversation *entity.Conversation) (err error) { - baseQueue := GetConfigString(ctx, "rabbitmq.responseQueue") - replyQueue := rabbitmq.GetInstanceQueueName(baseQueue) - - msg := &redis.SendStreamMessage{ - UserId: fmt.Sprintf("%s_%s", XhsPlatformName, conversation.UserId), - TenantId: gconv.String(account.TenantId), - Content: conversation.Content, - Timestamp: gtime.New(conversation.CreatedAt).Timestamp(), - MessageId: conversation.MessageId, - Platform: XhsPlatformName, - AccountId: account.Id.Hex(), - AccountName: account.AccountName, - ReplyQueue: replyQueue, - } - - if sessionId, _ := redis.GetSessionCache(ctx, gconv.String(account.TenantId), msg.UserId); sessionId == "" { - if history, histErr := dao.Conversation.GetRecentHistory(ctx, msg.UserId, redis.GetHistoryContextLimit()); histErr == nil && len(history) > 0 { - msg.History = history - g.Log().Infof(ctx, "[小红书] 用户已归档,读取 %d 轮历史对话", len(history)) - } - } - - streamMsgId, err := redis.AddToStream(ctx, redis.RAGFlowRequestStreamKey, msg) - if err != nil { - jaeger.RecordError(ctx, err) - return - } - - g.Log().Infof(ctx, "[小红书] 消息已写入Stream: streamMsgId=%s, sessionId=%s", streamMsgId, conversation.SessionId) - return -} diff --git a/tools/delete_datasets_simple.go b/tools/delete_datasets_simple.go deleted file mode 100644 index 1d26a25..0000000 --- a/tools/delete_datasets_simple.go +++ /dev/null @@ -1,152 +0,0 @@ -package main - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "strings" - "time" - - "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/os/gcfg" - "github.com/gogf/gf/v2/os/gctx" -) - -type Dataset struct { - Id string `json:"id"` - Name string `json:"name"` -} - -type ListDatasetsRes struct { - Code int `json:"code"` - Data []Dataset `json:"data"` -} - -type DeleteReq struct { - Ids []string `json:"ids"` -} - -type CommonResponse struct { - Code int `json:"code"` - Message string `json:"message"` -} - -func main() { - ctx := gctx.New() - - fmt.Println("🚀 RAGFlow知识库清理工具(简化版)") - fmt.Println("=" + strings.Repeat("=", 50)) - - // 设置配置文件路径 - g.Cfg().GetAdapter().(*gcfg.AdapterFile).SetPath("../") - - // 读取配置 - baseURL := g.Cfg().MustGet(ctx, "ragflow.base_url").String() - apiKey := g.Cfg().MustGet(ctx, "ragflow.api_key").String() - - if baseURL == "" || apiKey == "" { - fmt.Println("❌ RAGFlow配置缺失,请检查../config.yaml") - fmt.Println(" 需要配置: ragflow.base_url 和 ragflow.api_key") - os.Exit(1) - } - - baseURL = strings.TrimSuffix(baseURL, "/") - fmt.Printf("📡 连接到RAGFlow: %s\n", baseURL) - - // 创建HTTP客户端 - client := &http.Client{ - Timeout: 30 * time.Second, - } - - // 1. 列出所有知识库 - listURL := baseURL + "/api/v1/datasets?page=1&page_size=1000" - req, err := http.NewRequest("GET", listURL, nil) - if err != nil { - fmt.Printf("❌ 创建请求失败: %v\n", err) - os.Exit(1) - } - req.Header.Set("Authorization", "Bearer "+apiKey) - - resp, err := client.Do(req) - if err != nil { - fmt.Printf("❌ 查询知识库列表失败: %v\n", err) - os.Exit(1) - } - defer resp.Body.Close() - - body, _ := io.ReadAll(resp.Body) - var listRes ListDatasetsRes - if err := json.Unmarshal(body, &listRes); err != nil { - fmt.Printf("❌ 解析响应失败: %v\n", err) - fmt.Printf("响应内容: %s\n", string(body)) - os.Exit(1) - } - - if len(listRes.Data) == 0 { - fmt.Println("✅ 没有找到任何知识库,无需清理") - os.Exit(0) - } - - // 显示知识库列表 - fmt.Printf("\n📚 发现 %d 个知识库:\n", len(listRes.Data)) - for i, dataset := range listRes.Data { - fmt.Printf(" %d. ID: %s, Name: %s\n", i+1, dataset.Id, dataset.Name) - } - - // 二次确认 - fmt.Printf("\n⚠️ 警告:即将删除所有 %d 个知识库!\n", len(listRes.Data)) - fmt.Print("请输入 'YES' 确认删除: ") - - var confirm string - fmt.Scanln(&confirm) - - if confirm != "YES" { - fmt.Println("❌ 取消删除操作") - os.Exit(0) - } - - // 2. 批量删除 - var datasetIds []string - for _, dataset := range listRes.Data { - datasetIds = append(datasetIds, dataset.Id) - } - - deleteReq := DeleteReq{Ids: datasetIds} - reqBody, _ := json.Marshal(deleteReq) - - deleteURL := baseURL + "/api/v1/datasets" - delReq, err := http.NewRequest("DELETE", deleteURL, bytes.NewBuffer(reqBody)) - if err != nil { - fmt.Printf("❌ 创建删除请求失败: %v\n", err) - os.Exit(1) - } - delReq.Header.Set("Authorization", "Bearer "+apiKey) - delReq.Header.Set("Content-Type", "application/json") - - fmt.Println("\n🗑️ 开始删除知识库...") - delResp, err := client.Do(delReq) - if err != nil { - fmt.Printf("❌ 删除失败: %v\n", err) - os.Exit(1) - } - defer delResp.Body.Close() - - delBody, _ := io.ReadAll(delResp.Body) - var commonRes CommonResponse - if err := json.Unmarshal(delBody, &commonRes); err != nil { - fmt.Printf("❌ 解析删除响应失败: %v\n", err) - fmt.Printf("响应内容: %s\n", string(delBody)) - os.Exit(1) - } - - if commonRes.Code != 0 { - fmt.Printf("❌ 删除失败: %s\n", commonRes.Message) - os.Exit(1) - } - - fmt.Printf("✅ 成功删除 %d 个知识库!\n", len(datasetIds)) - fmt.Println("=" + strings.Repeat("=", 50)) -} diff --git a/update.sql b/update.sql index 6b2a4a8..2daf24e 100644 --- a/update.sql +++ b/update.sql @@ -13,11 +13,12 @@ CREATE TABLE IF NOT EXISTS customer_server_account ( deleted_at timestamp(6), -- 业务字段 + account_code VARCHAR(128) NOT NULL, -- 客服账号编码 account_name VARCHAR(128) NOT NULL, -- 客服账号名称 status SMALLINT NOT NULL DEFAULT 1, -- 状态:1启用/0停用 platform VARCHAR(32) NOT NULL, -- 客服平台 greeting TEXT DEFAULT '', -- 开场白 - prompt TEXT[] DEFAULT '{}', -- 提示词数组 + keyword_option TEXT[] DEFAULT '{}', -- 关键词选项数组 self_identity TEXT DEFAULT '', -- AI身份描述 dataset_ids BIGINT[] DEFAULT '{}', -- 绑定的数据集ID列表 document_ids BIGINT[] DEFAULT '{}', -- 绑定的文档ID列表 @@ -26,6 +27,7 @@ CREATE TABLE IF NOT EXISTS customer_server_account ( -- 索引(高频查询) CREATE INDEX idx_csa_tenant_id ON customer_server_account(tenant_id); +CREATE INDEX idx_csa_account_code ON customer_server_account(account_code); CREATE INDEX idx_csa_account_name ON customer_server_account(account_name); CREATE INDEX idx_csa_status ON customer_server_account(status); CREATE INDEX idx_csa_platform ON customer_server_account(platform); @@ -40,11 +42,12 @@ COMMENT ON COLUMN customer_server_account.created_at IS '创建时间'; COMMENT ON COLUMN customer_server_account.updater IS '更新人'; COMMENT ON COLUMN customer_server_account.updated_at IS '更新时间'; COMMENT ON COLUMN customer_server_account.deleted_at IS '删除时间(软删)'; +COMMENT ON COLUMN customer_server_account.account_code IS '客服账号编码'; COMMENT ON COLUMN customer_server_account.account_name IS '客服账号名称'; COMMENT ON COLUMN customer_server_account.status IS '客服账号状态'; COMMENT ON COLUMN customer_server_account.platform IS '客服平台'; COMMENT ON COLUMN customer_server_account.greeting IS '开场白'; -COMMENT ON COLUMN customer_server_account.prompt IS '提示词数组'; +COMMENT ON COLUMN customer_server_account.keyword_option IS '关键词选项数组'; COMMENT ON COLUMN customer_server_account.self_identity IS 'AI身份描述'; COMMENT ON COLUMN customer_server_account.dataset_ids IS '绑定的数据集ID列表'; COMMENT ON COLUMN customer_server_account.document_ids IS '绑定的文档ID列表'; @@ -53,7 +56,7 @@ COMMENT ON COLUMN customer_server_account.expand_data IS '扩展数据(JSONB)'; --------------------pgsql创建customer_server_account表语句--------------------------- --------------------pgsql创建customer_server_scripted_speech表语句--------------------------- --- 客服话术表(自定义问答话术) +-- 客服话术表(自定义话术) CREATE TABLE IF NOT EXISTS customer_server_scripted_speech ( -- 基础字段(完全对齐项目规范) id BIGINT PRIMARY KEY, -- 主键ID(非自增) @@ -65,20 +68,18 @@ CREATE TABLE IF NOT EXISTS customer_server_scripted_speech ( deleted_at timestamp(6), -- 业务字段 - account_id BIGINT NOT NULL, -- 账号ID dataset_id BIGINT NOT NULL, -- 数据集ID - question_content TEXT NOT NULL, -- 问题内容 - answer_content TEXT NOT NULL -- 回答内容 -); + scene_type SMALLINT NOT NULL, -- 场景类型 + question_content TEXT NOT NULL -- 问题内容 + ); -- 索引(高频查询) CREATE INDEX idx_csss_tenant_id ON customer_server_scripted_speech(tenant_id); -CREATE INDEX idx_csss_account_id ON customer_server_scripted_speech(account_id); CREATE INDEX idx_csss_dataset_id ON customer_server_scripted_speech(dataset_id); CREATE INDEX idx_csss_deleted_at ON customer_server_scripted_speech(deleted_at); -- 表和字段注释 -COMMENT ON TABLE customer_server_scripted_speech IS '客服话术表(自定义问答话术)'; +COMMENT ON TABLE customer_server_scripted_speech IS '客服话术表(自定义话术)'; COMMENT ON COLUMN customer_server_scripted_speech.id IS '主键ID(非自增)'; COMMENT ON COLUMN customer_server_scripted_speech.tenant_id IS '租户ID'; COMMENT ON COLUMN customer_server_scripted_speech.creator IS '创建人'; @@ -86,9 +87,52 @@ COMMENT ON COLUMN customer_server_scripted_speech.created_at IS '创建时间'; COMMENT ON COLUMN customer_server_scripted_speech.updater IS '更新人'; COMMENT ON COLUMN customer_server_scripted_speech.updated_at IS '更新时间'; COMMENT ON COLUMN customer_server_scripted_speech.deleted_at IS '删除时间(软删)'; -COMMENT ON COLUMN customer_server_scripted_speech.account_id IS '账号ID'; COMMENT ON COLUMN customer_server_scripted_speech.dataset_id IS '数据集ID'; +COMMENT ON COLUMN customer_server_scripted_speech.scene_type IS '场景类型'; COMMENT ON COLUMN customer_server_scripted_speech.question_content IS '问题内容'; -COMMENT ON COLUMN customer_server_scripted_speech.answer_content IS '回答内容'; ---------------------pgsql创建customer_server_scripted_speech表语句--------------------------- \ No newline at end of file +--------------------pgsql创建customer_server_scripted_speech表语句--------------------------- + +--------------------pgsql创建customer_server_account_user_dialog表语句--------------------------- +-- 客服账号用户对话统计表 +CREATE TABLE IF NOT EXISTS customer_server_account_user_dialog ( + -- 基础字段(完全对齐项目规范) + id BIGINT PRIMARY KEY, -- 主键ID(非自增) + tenant_id BIGINT NOT NULL DEFAULT 0, -- 租户ID int8 + creator VARCHAR(64) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at timestamp(6), + + -- 业务字段 + account_id BIGINT NOT NULL, -- 客服ID + user_id VARCHAR(255) NOT NULL, -- 用户ID + dialog_count BIGINT NOT NULL DEFAULT 0 -- 对话次数 + ); + +-- 唯一索引:租户 + 客服ID + 用户ID 唯一 +CREATE UNIQUE INDEX uk_csaud_tenant_account_user + ON customer_server_account_user_dialog(tenant_id, account_id, user_id) + WHERE deleted_at IS NULL; + +-- 索引(高频查询) +CREATE INDEX idx_csaud_tenant_id ON customer_server_account_user_dialog(tenant_id); +CREATE INDEX idx_csaud_account_id ON customer_server_account_user_dialog(account_id); +CREATE INDEX idx_csaud_user_id ON customer_server_account_user_dialog(user_id); +CREATE INDEX idx_csaud_deleted_at ON customer_server_account_user_dialog(deleted_at); + +-- 表和字段注释 +COMMENT ON TABLE customer_server_account_user_dialog IS '客服账号用户对话统计表'; +COMMENT ON COLUMN customer_server_account_user_dialog.id IS '主键ID(非自增)'; +COMMENT ON COLUMN customer_server_account_user_dialog.tenant_id IS '租户ID'; +COMMENT ON COLUMN customer_server_account_user_dialog.creator IS '创建人'; +COMMENT ON COLUMN customer_server_account_user_dialog.created_at IS '创建时间'; +COMMENT ON COLUMN customer_server_account_user_dialog.updater IS '更新人'; +COMMENT ON COLUMN customer_server_account_user_dialog.updated_at IS '更新时间'; +COMMENT ON COLUMN customer_server_account_user_dialog.deleted_at IS '删除时间(软删)'; +COMMENT ON COLUMN customer_server_account_user_dialog.account_id IS '客服ID'; +COMMENT ON COLUMN customer_server_account_user_dialog.user_id IS '用户ID'; +COMMENT ON COLUMN customer_server_account_user_dialog.dialog_count IS '对话次数'; + +--------------------pgsql创建customer_server_account_user_dialog表语句--------------------------- \ No newline at end of file diff --git a/util/util.go b/util/util.go deleted file mode 100644 index 09a20fa..0000000 --- a/util/util.go +++ /dev/null @@ -1,75 +0,0 @@ -package util - -import ( - "context" - "fmt" - - "gitea.com/red-future/common/beans" - "gitea.com/red-future/common/db/mongo" - "gitea.com/red-future/common/redis" - "gitea.com/red-future/common/utils" - "github.com/gogf/gf/v2/errors/gerror" - "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/util/gconv" - "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 = gconv.Uint64(cached.Interface()) - user.UserName = accountName - return user, nil - } - - // 4. 缓存未命中,查询 customer_service_account 表 - filter := bson.M{"accountName": accountName, "isDeleted": false} - var account struct { - TenantId uint64 `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 -}