This commit is contained in:
2026-03-14 10:02:49 +08:00
parent 03b50ef904
commit 830f75a334
75 changed files with 10677 additions and 2 deletions

207
service/archive_service.go Normal file
View File

@@ -0,0 +1,207 @@
// Package service - 归档服务
// 功能对话记录从MongoDB归档到Elasticsearch定时任务+手动触发
package service
import (
"context"
"customer-server/dao"
"customer-server/model/entity"
"gitea.com/red-future/common/elasticsearch"
"gitea.com/red-future/common/jaeger"
"gitea.com/red-future/common/redis"
"github.com/gogf/gf/v2/os/gcron"
"github.com/gogf/gf/v2/os/glog"
"github.com/gogf/gf/v2/os/gtime"
"github.com/gogf/gf/v2/util/gconv"
)
// archiveService 归档服务
type archiveService struct{}
// ArchiveService 归档服务单例
var ArchiveService = new(archiveService)
// 归档锁的键名和过期时间
const (
archiveLockKey = "archive:monthly:lock"
archiveLockExpire = 3600 // 1 小时
)
// MonthlyArchive 月度归档主流程
// 参数: ctx - 上下文
// 返回: err - 错误信息
// 功能: 将MongoDB对话记录归档到Elasticsearch流程1.复制到临时表 2.删除原表 3.写入ES 4.删临时表
// 注意: 使用分布式锁确保只有一个节点执行
func (s *archiveService) MonthlyArchive(ctx context.Context) (err error) {
// 获取分布式锁,确保只有一个节点执行归档
if !redis.TryLock(ctx, archiveLockKey, archiveLockExpire) {
glog.Info(ctx, "其他节点正在执行归档,本节点跳过")
return
}
defer redis.Unlock(ctx, archiveLockKey)
beginTime := gtime.Now()
glog.Info(ctx, "========== 开始月度归档 ==========")
// 计算归档时间范围
now := gtime.Now()
var archiveStart, archiveEnd *gtime.Time
testMode := GetConfigBool(ctx, "archive.testMode")
if testMode {
// 测试模式:归档最近 7 天
archiveStart = now.AddDate(0, 0, -7)
archiveEnd = now
glog.Infof(ctx, "[测试模式] 归档时间范围: %s 至 %s最近 7 天)",
archiveStart.Format("Y-m-d H:i:s"),
archiveEnd.Format("Y-m-d H:i:s"))
} else {
// 生产模式:归档上个月整月数据
// 本月第一天 00:00:00
archiveEnd = gtime.NewFromStr(now.Format("Y-m") + "-01 00:00:00")
// 上个月第一天 00:00:00
archiveStart = archiveEnd.AddDate(0, -1, 0)
// 计算上个月天数
daysInLastMonth := archiveEnd.AddDate(0, 0, -1).Day()
glog.Infof(ctx, "归档时间范围: %s 至 %s共 %d 天)",
archiveStart.Format("Y-m-d H:i:s"),
archiveEnd.AddDate(0, 0, -1).Format("Y-m-d 23:59:59"),
daysInLastMonth)
}
// Step 1: 复制数据到临时表
glog.Info(ctx, "[Step 1/4] 复制数据到临时表...")
copyCount, err := dao.Archive.CopyToTempByRange(ctx, archiveStart.Time, archiveEnd.Time)
if err != nil {
jaeger.RecordError(ctx, err, "复制数据到临时表失败")
return
}
if copyCount == 0 {
glog.Info(ctx, "没有需要归档的数据,跳过")
return
}
glog.Infof(ctx, "复制完成,共 %d 条记录", copyCount)
// Step 2: 删除原表数据
glog.Info(ctx, "[Step 2/4] 删除原表数据...")
deleteCount, err := dao.Archive.DeleteByTempIds(ctx)
if err != nil {
jaeger.RecordError(ctx, err, "删除原表数据失败")
// 不返回错误,继续尝试后续步骤
}
glog.Infof(ctx, "删除完成,共 %d 条记录", deleteCount)
// Step 3: 写入 ES
glog.Info(ctx, "[Step 3/4] 写入 ES...")
if err = s.writeToES(ctx); err != nil {
jaeger.RecordError(ctx, err, "写入 ES 失败")
// 不返回错误,临时表数据保留,下次可以重试
return
}
// Step 4: 删除临时表
glog.Info(ctx, "[Step 4/4] 删除临时表...")
if err = dao.Archive.DropTempCollection(ctx); err != nil {
jaeger.RecordError(ctx, err, "删除临时表失败")
return
}
elapsed := gtime.Now().Sub(beginTime)
glog.Infof(ctx, "========== 月度归档完成,耗时: %s ==========", elapsed)
return
}
// writeToES 将临时表数据写入 ES
func (s *archiveService) writeToES(ctx context.Context) (err error) {
// 确保索引存在
if err = elasticsearch.CreateIndexIfNotExists(ctx, entity.ConversationESIndex, entity.ConversationESMapping); err != nil {
return
}
// 获取临时表数据
tempData, err := dao.Archive.GetTempData(ctx)
if err != nil {
return
}
if len(tempData) == 0 {
glog.Info(ctx, "临时表无数据")
return
}
// 转换为 ES 文档格式
now := gtime.Now().Time
docs := make([]interface{}, 0, len(tempData))
for _, temp := range tempData {
doc := entity.ConversationES{
Id: temp.OriginalId,
UserId: temp.UserId,
Platform: temp.Platform,
SessionId: temp.SessionId,
Question: temp.Question,
Answer: temp.Answer,
MessageId: temp.MessageId,
MsgTime: *temp.MsgTime, // 解引用指针类型
TenantId: gconv.String(temp.TenantId),
CreatedAt: *temp.CreatedAt, // 解引用指针类型
UpdatedAt: *temp.UpdatedAt, // 解引用指针类型
ArchivedAt: now,
}
docs = append(docs, doc)
}
// 批量写入 ES
batchSize := GetConfigInt(ctx, "archive.esBatchSize")
for i := 0; i < len(docs); i += batchSize {
end := i + batchSize
if end > len(docs) {
end = len(docs)
}
if err = elasticsearch.BulkIndex(ctx, entity.ConversationESIndex, docs[i:end]); err != nil {
return
}
}
glog.Infof(ctx, "ES 写入完成,共 %d 条记录", len(docs))
return
}
// ============== 定时任务(月度归档)==============
// StartArchiveCron 启动归档定时任务
// 默认每月 1 号凌晨 3 点执行
func (s *archiveService) StartCron(ctx context.Context) {
cronExpr := GetConfigString(ctx, "archive.cron")
enabled := GetConfigBool(ctx, "archive.enabled")
if !enabled {
glog.Info(ctx, "月度归档定时任务已禁用")
return
}
ctx, span := jaeger.NewSpan(ctx, "cron.archive.register")
defer span.End()
_, err := gcron.Add(ctx, cronExpr, func(ctx context.Context) {
ctx, span := jaeger.NewSpan(ctx, "cron.archive.monthly")
defer span.End()
glog.Info(ctx, "月度归档定时任务开始执行")
if err := s.MonthlyArchive(ctx); err != nil {
jaeger.RecordError(ctx, err, "月度归档执行失败")
}
}, "monthly-archive")
if err != nil {
jaeger.RecordError(ctx, err, "注册月度归档定时任务失败")
return
}
glog.Infof(ctx, "月度归档定时任务已启动 - Cron: %s", cronExpr)
}
// RunNow 立即执行归档(用于手动触发或测试)
func (s *archiveService) RunNow(ctx context.Context) error {
return s.MonthlyArchive(ctx)
}

211
service/config_service.go Normal file
View File

@@ -0,0 +1,211 @@
package service
import (
"context"
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/glog"
"github.com/gogf/gf/v2/util/gconv"
"github.com/hashicorp/consul/api"
)
var (
configCache = make(map[string]interface{}) // 动态配置缓存
configMu sync.RWMutex // 读写锁
consulClient *api.Client // Consul客户端
startOnce sync.Once // 确保只启动一次监听
)
// InitConsulWatcher 初始化Consul配置监听
func InitConsulWatcher(ctx context.Context) error {
consulAddr := g.Cfg().MustGet(ctx, "consul.address").String()
if consulAddr == "" {
glog.Warning(ctx, "Consul未配置使用本地config.yml")
return nil
}
config := api.DefaultConfig()
config.Address = consulAddr
client, err := api.NewClient(config)
if err != nil {
glog.Errorf(ctx, "Consul客户端初始化失败: %v", err)
return err
}
consulClient = client
startOnce.Do(func() {
go watchAllConfigsV2(ctx)
glog.Info(ctx, "Consul配置监听已启动")
})
return nil
}
// watchAllConfigsV2 监听Consul前缀下所有配置
func watchAllConfigsV2(ctx context.Context) {
const prefix = "customerService/"
kv := consulClient.KV()
var lastIndex uint64
for {
pairs, meta, err := kv.List(prefix, &api.QueryOptions{
WaitIndex: lastIndex,
WaitTime: 5 * time.Minute,
})
if err != nil {
glog.Errorf(ctx, "Consul查询失败: %v", err)
time.Sleep(5 * time.Second)
continue
}
if meta.LastIndex != lastIndex {
lastIndex = meta.LastIndex
updateConfigCacheDiff(ctx, pairs)
}
}
}
// updateConfigCacheDiff 增量更新配置缓存
func updateConfigCacheDiff(ctx context.Context, pairs api.KVPairs) {
configMu.Lock()
defer configMu.Unlock()
for _, pair := range pairs {
key := strings.TrimPrefix(pair.Key, "customerService/")
key = strings.ReplaceAll(key, "/", ".")
newVal := parseValue(pair.Value)
oldVal, exists := configCache[key]
if !exists || oldVal != newVal {
logConfigChange(ctx, key, gconv.String(oldVal), gconv.String(newVal))
configCache[key] = newVal
}
}
}
// parseValue 自动推断配置值类型
func parseValue(value []byte) interface{} {
str := string(value)
if i, err := strconv.Atoi(str); err == nil {
return i
}
if b, err := strconv.ParseBool(str); err == nil {
return b
}
var arr []interface{}
if err := json.Unmarshal(value, &arr); err == nil {
return arr
}
return str
}
// GetConfigInt 读取int配置
func GetConfigInt(ctx context.Context, key string) int {
configMu.RLock()
val, ok := configCache[key]
configMu.RUnlock()
if ok {
return gconv.Int(val)
}
return g.Cfg().MustGet(ctx, key).Int()
}
// GetConfigString 读取string配置
func GetConfigString(ctx context.Context, key string) string {
configMu.RLock()
val, ok := configCache[key]
configMu.RUnlock()
if ok {
return gconv.String(val)
}
return g.Cfg().MustGet(ctx, key).String()
}
// GetConfigBool 读取bool配置
func GetConfigBool(ctx context.Context, key string) bool {
configMu.RLock()
val, ok := configCache[key]
configMu.RUnlock()
if ok {
return gconv.Bool(val)
}
return g.Cfg().MustGet(ctx, key).Bool()
}
// GetConfigStringSlice 读取字符串数组配置
func GetConfigStringSlice(ctx context.Context, key string) []string {
configMu.RLock()
val, ok := configCache[key]
configMu.RUnlock()
if ok {
if arr, ok := val.([]interface{}); ok {
result := make([]string, len(arr))
for i, v := range arr {
result[i] = gconv.String(v)
}
return result
}
}
return g.Cfg().MustGet(ctx, key).Strings()
}
// GetInstanceConfigStringSlice 读取实例级字符串数组配置(支持实例级负载隔离)
// 优先级:实例专用配置 > 全局Consul配置 > config.yml
func GetInstanceConfigStringSlice(ctx context.Context, key string) []string {
// 获取实例ID环境变量优先hostname备用
instanceID := os.Getenv("INSTANCE_ID")
if instanceID == "" {
hostname, err := os.Hostname()
if err == nil {
instanceID = hostname
}
}
// 如果有实例ID先查找实例专用配置
if instanceID != "" {
instanceKey := fmt.Sprintf("instance.%s.%s", instanceID, key)
configMu.RLock()
val, ok := configCache[instanceKey]
configMu.RUnlock()
if ok {
if arr, ok := val.([]interface{}); ok {
result := make([]string, len(arr))
for i, v := range arr {
result[i] = gconv.String(v)
}
glog.Debugf(ctx, "🎯 使用实例专用配置: %s = %v", instanceKey, result)
return result
}
}
}
// 未找到实例配置fallback到全局配置
return GetConfigStringSlice(ctx, key)
}
// logConfigChange 记录配置变更
func logConfigChange(ctx context.Context, key, oldVal, newVal string) {
glog.Infof(ctx, "📝 配置变更: %s = %s → %s", key, oldVal, newVal)
}

View File

@@ -0,0 +1,353 @@
// Package service - 对话服务
// 功能处理RAGFlow响应、批量落库、卡片触发逻辑
package service
import (
"context"
"customer-server/dao"
"customer-server/model/entity"
"gitea.com/red-future/common/jaeger"
"gitea.com/red-future/common/rabbitmq"
"gitea.com/red-future/common/redis"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/glog"
"github.com/gogf/gf/v2/os/gtime"
)
// conversationService 对话服务(操作 conversation 表)
type conversationService struct{}
// ConversationService 对话服务单例
var ConversationService = new(conversationService)
// ============== RabbitMQ 消费者RAGFlow 响应消息)==============
// ResponseConsumer RabbitMQ 响应消费者
type ResponseConsumer struct {
queueName string
consumer *rabbitmq.Consumer
}
// NewResponseConsumer 创建响应消费者
// 参数: ctx - 上下文
// 返回: ResponseConsumer - 响应消费者实例
// 功能: 创建当前实例的唯一响应队列,支持多实例部署
func NewResponseConsumer(ctx context.Context) *ResponseConsumer {
// 从配置读取基础队列名(用于生成唯一实例队列名)
baseQueue := GetConfigString(ctx, "rabbitmq.responseQueue")
// 生成当前实例的唯一队列名ragflow.response.queue.{hostname}.{uuid8}
// 支持多实例部署,每个实例有独立响应队列
queueName := rabbitmq.GetInstanceQueueName(baseQueue)
glog.Infof(ctx, "响应队列动态生成 - 实例队列: %s", queueName)
return &ResponseConsumer{
queueName: queueName,
}
}
// Start 启动消费者
// 参数: ctx - 上下文
// 返回: err - 错误信息
// 功能: 声明并绑定当前实例的响应队列开始消费RAGFlow响应消息
func (c *ResponseConsumer) Start(ctx context.Context) (err error) {
glog.Infof(ctx, "RabbitMQ 响应消费者启动 - Queue: %s", c.queueName)
// 声明当前实例的动态响应队列
if err = rabbitmq.DeclareQueue(ctx, &rabbitmq.QueueConfig{
Name: c.queueName,
Durable: true,
}); err != nil {
glog.Errorf(ctx, "声明动态响应队列失败: %v", err)
return err
}
// 绑定队列到 Exchange使用队列名作为routing key实现精确路由
// message发送消息时会使用队列名作为routing key
if err = rabbitmq.BindQueue(ctx, &rabbitmq.BindingConfig{
Queue: c.queueName,
Exchange: "ragflow.response",
RoutingKey: c.queueName, // 使用队列名,只接收发给自己的消息
}); err != nil {
glog.Errorf(ctx, "绑定动态响应队列失败: %v", err)
return err
}
glog.Infof(ctx, "动态响应队列已绑定: %s -> ragflow.response (routingKey=#)", c.queueName)
c.consumer = rabbitmq.NewConsumer(c.queueName, handleResponse)
return c.consumer.Start(ctx)
}
// Stop 停止消费者
// 参数: ctx - 上下文
// 功能: 停止消费RAGFlow响应消息
func (c *ResponseConsumer) Stop(ctx context.Context) {
if c.consumer != nil {
c.consumer.Stop(ctx)
}
}
// ============== 卡片触发配置(待接入小红书卡片接口后修改)==============
//
// 【用户状态存储】
// 使用 Redis Hash 存储用户会话状态(阶段+对话计数统一5分钟TTL
// Key: ragflow:user:state:{userId}_{platform}
// Fields: stage阶段、count对话计数
//
// 【状态定义】
// 状态0走AI模型 | 状态1打招呼 | 状态2业务咨询 | 状态3发卡片
//
// 【卡片触发逻辑】
// 对话轮数>=配置值时更新状态为3并发送卡片消息
// 配置项config.yml中的card.triggerCount默认5轮
//
// 【待接入小红书卡片 API 后修改位置】
// checkAndSendCard() 函数中的 cardMessage 变量
const (
// ConversationFlushDelaySeconds 对话缓存延时落库时间(秒)
ConversationFlushDelaySeconds = 600 // 10分钟
)
// ============== 延时落库消费者 ==============
// DelayedFlushMessage 延时落库消息按sessionId
type DelayedFlushMessage struct {
SessionId string `json:"sessionId"`
}
// DelayedFlushConsumer 延时落库消费者
type DelayedFlushConsumer struct {
queueName string
consumer *rabbitmq.Consumer
}
// NewDelayedFlushConsumer 创建延时落库消费者
func NewDelayedFlushConsumer(ctx context.Context) *DelayedFlushConsumer {
return &DelayedFlushConsumer{
queueName: "conversation.flush.queue",
}
}
// Start 启动消费者
func (c *DelayedFlushConsumer) Start(ctx context.Context) (err error) {
glog.Infof(ctx, "延时落库消费者启动 - Queue: %s", c.queueName)
c.consumer = rabbitmq.NewConsumer(c.queueName, handleDelayedFlush)
return c.consumer.Start(ctx)
}
// Stop 停止消费者
func (c *DelayedFlushConsumer) Stop(ctx context.Context) {
if c.consumer != nil {
c.consumer.Stop(ctx)
}
}
// handleDelayedFlush 处理延时落库消息
func handleDelayedFlush(ctx context.Context, body []byte) error {
var msg DelayedFlushMessage
if err := gjson.DecodeTo(body, &msg); err != nil {
glog.Errorf(ctx, "解析延时落库消息失败: %v", err)
return err
}
glog.Infof(ctx, "收到延时落库消息 - SessionId: %s", msg.SessionId)
// 检查是否有未落库的缓存
count, err := redis.GetCachedConversationCount(ctx, msg.SessionId)
if err != nil {
glog.Errorf(ctx, "获取缓存数量失败: %v", err)
return err
}
if count == 0 {
glog.Debugf(ctx, "无需落库(缓存为空或已落库)- SessionId: %s", msg.SessionId)
return nil
}
// 执行落库
if err = flushConversationCache(ctx, msg.SessionId); err != nil {
glog.Errorf(ctx, "延时落库失败: %v", err)
return err
}
glog.Infof(ctx, "延时落库完成 - SessionId: %s", msg.SessionId)
return nil
}
// 延时落库发布器(单例)
var delayedFlushPublisher *rabbitmq.Publisher
// getDelayedFlushPublisher 获取延时落库发布器
func getDelayedFlushPublisher() *rabbitmq.Publisher {
if delayedFlushPublisher == nil {
delayedFlushPublisher = rabbitmq.NewPublisher("conversation.flush.delayed", "flush")
}
return delayedFlushPublisher
}
// sendDelayedFlushMessage 发送延时落库消息
func sendDelayedFlushMessage(ctx context.Context, sessionId string) error {
msg := &DelayedFlushMessage{
SessionId: sessionId,
}
return getDelayedFlushPublisher().PublishDelayed(ctx, msg, ConversationFlushDelaySeconds)
}
// handleResponse 处理 RabbitMQ 消息(幂等)
// 落库逻辑前5句缓存到Redis第5句时批量落库MongoDB超过5句不落库
func handleResponse(ctx context.Context, body []byte) error {
ctx, span := jaeger.NewSpan(ctx, "consumer.response")
defer span.End()
glog.Infof(ctx, ">>> handleResponse 被调用,消息长度: %d", len(body))
// 解析消息到结构体
var msg redis.ResponseStreamMessage
if err := gjson.DecodeTo(body, &msg); err != nil {
jaeger.RecordError(ctx, err, "解析响应消息失败")
return err
}
glog.Infof(ctx, "收到 RAGFlow 响应 - 用户: %s, MessageId: %s", msg.UserId, msg.MessageId)
// 1. 获取当前对话轮数
state, err := redis.GetUserState(ctx, msg.UserId, msg.Platform)
if err != nil {
jaeger.RecordError(ctx, err, "获取用户状态失败")
}
count := state.Count
// 2. 根据轮数决定落库策略
cardTriggerCount := g.Cfg().MustGet(ctx, "card.triggerCount", 5).Int64()
if count <= cardTriggerCount {
// 前N句缓存到Redis按sessionId
msgTime := gtime.NewFromTimeStamp(msg.Timestamp).Time
conversation := &entity.Conversation{
UserId: msg.UserId,
Platform: msg.Platform,
SessionId: msg.SessionId,
Question: msg.Question,
Answer: msg.Content,
MessageId: msg.MessageId,
MsgTime: &msgTime, // 取地址赋值给指针类型
}
conversation.TenantId = msg.TenantId
// 序列化后缓存使用sessionId作为key
data, _ := gjson.Encode(conversation)
if cacheErr := redis.CacheConversation(ctx, msg.SessionId, data); cacheErr != nil {
jaeger.RecordError(ctx, cacheErr, "缓存对话记录失败")
} else {
glog.Debugf(ctx, "对话已缓存到 Redis - SessionId: %s, 第 %d 轮", msg.SessionId, count)
}
// 第1句时发送10分钟延时落库消息兜底
if count == 1 {
if delayErr := sendDelayedFlushMessage(ctx, msg.SessionId); delayErr != nil {
glog.Warningf(ctx, "发送延时落库消息失败: %v", delayErr)
}
}
// 第N句时立即批量落库
if count == cardTriggerCount {
if flushErr := flushConversationCache(ctx, msg.SessionId); flushErr != nil {
jaeger.RecordError(ctx, flushErr, "批量落库失败")
}
}
} else {
// 超过N句不落库已发卡片
glog.Debugf(ctx, "第 %d 轮(>%d跳过落库 - 用户: %s", count, cardTriggerCount, msg.UserId)
}
// 3. 推送给 WebSocket 用户(无论是否落库都推送)
glog.Infof(ctx, "准备推送 WebSocket - 用户: %s_%s, 内容长度: %d", msg.UserId, msg.Platform, len(msg.Content))
if err = WebSocket.PushRAGFlowResponse(ctx, msg.TenantId, msg.UserId, msg.Platform, msg.Content); err != nil {
jaeger.RecordError(ctx, err, "推送 WebSocket 失败")
} else {
glog.Infof(ctx, "WebSocket 推送成功 - 用户: %s_%s", msg.UserId, msg.Platform)
}
return nil
}
// flushConversationCache 将Redis缓存的对话批量落库到MongoDB
func flushConversationCache(ctx context.Context, sessionId string) error {
// 获取缓存的对话列表
cached, err := redis.GetCachedConversations(ctx, sessionId)
if err != nil {
return err
}
if len(cached) == 0 {
return nil
}
// 反序列化
list := make([]*entity.Conversation, 0, len(cached))
for _, data := range cached {
var conv entity.Conversation
if decErr := gjson.DecodeTo([]byte(data), &conv); decErr != nil {
glog.Warningf(ctx, "反序列化对话失败: %v", decErr)
continue
}
list = append(list, &conv)
}
// 批量插入MongoDB
if len(list) > 0 {
if insertErr := dao.Conversation.BatchInsert(ctx, list); insertErr != nil {
return insertErr
}
glog.Infof(ctx, "批量落库成功 - SessionId: %s, 共 %d 条", sessionId, len(list))
}
return nil
}
// checkCardBeforeProcess 检查对话轮数,达到阈值时发卡片
// 返回 handled=true 表示已处理(发送卡片),调用方应跳过后续话术处理
func checkCardBeforeProcess(ctx context.Context, tenantId, userId, platform string) (handled bool, err error) {
// 获取用户当前状态
state, err := redis.GetUserState(ctx, userId, platform)
if err != nil {
return
}
// 状态5未选择方向时不计数等用户选择方向后再开始计数
if state.Stage == 5 {
glog.Debugf(ctx, "用户 %s_%s 处于状态5未选择方向跳过计数", userId, platform)
return false, nil
}
// 增加对话计数
count, err := redis.IncrUserCount(ctx, userId, platform)
if err != nil {
return
}
glog.Infof(ctx, "用户 %s_%s 当前对话轮数: %d", userId, platform, count)
// 对话>=配置轮数发卡片并跳过话术从配置值开始就发卡片不再调用AI
cardTriggerCount := g.Cfg().MustGet(ctx, "card.triggerCount", 5).Int64()
if count >= cardTriggerCount {
glog.Infof(ctx, "用户 %s_%s 对话第 %d 轮(>=%d触发发送卡片", userId, platform, count, cardTriggerCount)
// 更新用户状态为3发卡片状态
if updateErr := redis.SetUserStage(ctx, userId, platform, 3); updateErr != nil {
jaeger.RecordError(ctx, updateErr, "更新用户状态为3失败")
}
cardMessage := "请加一下卡片的联系方式,进行更专业的咨询" // TODO: 替换为实际卡片发送逻辑
if pushErr := WebSocket.PushRAGFlowResponse(ctx, tenantId, userId, platform, cardMessage); pushErr != nil {
jaeger.RecordError(ctx, pushErr, "推送卡片消息失败")
glog.Errorf(ctx, "推送卡片失败 - 用户: %s_%s, 错误: %v", userId, platform, pushErr)
err = pushErr
return
}
glog.Infof(ctx, "卡片消息已推送 - 用户: %s_%s", userId, platform)
handled = true
}
return
}

View File

@@ -0,0 +1,855 @@
// Package service - 客服账号服务
// 功能:客服账号的增删改查业务逻辑
package service
import (
"context"
"customer-server/dao"
"customer-server/model/dto"
"customer-server/model/entity"
"customer-server/util"
"fmt"
"strings"
"time"
"gitea.com/red-future/common/beans"
commonMongo "gitea.com/red-future/common/db/mongo"
"gitea.com/red-future/common/ragflow"
"gitea.com/red-future/common/utils"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"github.com/gogf/gf/v2/text/gstr"
"github.com/gogf/gf/v2/util/gconv"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
var CustomerServiceAccount = new(customerServiceAccount)
type customerServiceAccount struct{}
// Add 添加客服账号
func (s *customerServiceAccount) Add(ctx context.Context, req *dto.AddCustomerServiceAccountReq) (res *dto.AddCustomerServiceAccountRes, err error) {
// 1. 检查客服ID是否已存在
coll := commonMongo.GetDB().Collection(entity.CustomerServiceAccountCollection)
filter := bson.M{"accountName": req.AccountName, "isDeleted": false}
count, err := coll.CountDocuments(ctx, filter)
if err != nil {
return nil, gerror.Wrap(err, "检查客服ID是否存在失败")
}
if count > 0 {
return nil, gerror.Newf("客服账号名称 '%s' 已存在,请使用其他名称", req.AccountName)
}
// 2. 准备数据但暂不写入MongoDB
data := &entity.CustomerServiceAccount{}
if err = utils.Struct(req, data); err != nil {
return
}
// 调试日志:输出请求和转换后的数据
g.Log().Infof(ctx, "[Add] 请求数据 - AccountName: %s, Platform: %s, Prompt: %v, Greeting: %v",
req.AccountName, req.Platform, req.Prompt, req.Greeting)
g.Log().Infof(ctx, "[Add] 转换后Entity - AccountName: %s, Platform: %s, Greeting: %s",
data.AccountName, data.Platform, data.Greeting)
now := gtime.Now().Time
data.CreatedAt = &now // 取地址赋值给指针类型
data.UpdatedAt = &now // 取地址赋值给指针类型
data.IsDeleted = false
data.IsDisabled = false
// 获取或设置tenantId统一转换为string存储
var tenantId string
if req.TenantId != nil {
tenantId = gconv.String(req.TenantId)
g.Log().Infof(ctx, "使用手动指定的tenant_id: %v", req.TenantId)
} else {
// 从session获取tenantId
tenantInfo, err := util.GetTenantInfo(ctx)
if err != nil {
return nil, gerror.Wrap(err, "无法获取租户信息")
}
tenantId = gconv.String(tenantInfo.TenantId)
if tenantId == "" {
return nil, gerror.New("无法获取租户ID")
}
}
// 统一使用string类型存储到MongoDB
data.TenantId = tenantId
// 3. 确保租户知识库存在(调用公共方法)
datasetId, err := EnsureTenantDataset(ctx, tenantId)
if err != nil {
g.Log().Errorf(ctx, "确保租户知识库存在失败: %v", err)
return nil, gerror.Wrap(err, "确保租户知识库存在失败")
}
// 4. 先插入客服账号必须在创建Chat之前因为createChatAndSaveConfig内部会通过accountName查询租户ID
err = dao.CustomerServiceAccount.Insert(ctx, data)
if err != nil {
return nil, gerror.Wrap(err, "插入客服账号失败")
}
// 5. 处理话术绑定
if len(req.SpeechcraftIds) > 0 {
g.Log().Infof(ctx, "开始同步话术到RAGFlow: count=%d", len(req.SpeechcraftIds))
for _, speechcraftId := range req.SpeechcraftIds {
_, err := Speechcraft.SyncToRAGFlow(ctx, speechcraftId, req.AccountName, tenantId)
if err != nil {
g.Log().Errorf(ctx, "同步话术到RAGFlow失败: speechcraft_id=%s, error=%v", speechcraftId, err)
}
}
}
// 6. 创建Chat并保存RAGFlow配置内部会轮询等待文档解析完成
var assistantDesc string
if req.SelfIdentity != nil {
assistantDesc = *req.SelfIdentity
}
if err = s.createChatAndSaveConfig(ctx, req.AccountName, req.Platform, tenantId, datasetId, assistantDesc); err != nil {
g.Log().Errorf(ctx, "创建Chat配置失败: %v", err)
return nil, gerror.Wrap(err, "创建Chat配置失败")
}
// 7. 如果提供了自定义提示词调用UpdatePrompt更新
if req.Prompt != nil && *req.Prompt != "" {
g.Log().Infof(ctx, "创建完成,开始更新自定义提示词")
updateReq := &dto.UpdatePromptReq{
AccountName: req.AccountName,
Prompt: *req.Prompt,
}
if _, err := RAGFlowConfig.UpdatePrompt(ctx, updateReq); err != nil {
g.Log().Errorf(ctx, "更新自定义提示词失败: %v", err)
// 不阻断创建流程,提示词可以后续修改
} else {
g.Log().Infof(ctx, "自定义提示词更新成功")
}
}
res = &dto.AddCustomerServiceAccountRes{Id: data.Id.Hex()}
return
}
// createChatAndSaveConfig 创建Chat并保存RAGFlow配置会轮询等待文档解析完成
func (s *customerServiceAccount) createChatAndSaveConfig(ctx context.Context, accountName, platform, tenantId, datasetId, assistantDescription string) error {
ragflowClient := ragflow.GetGlobalClient()
// 1. 轮询检查知识库中是否有解析完成的文档最多60秒
g.Log().Infof(ctx, "等待知识库文档解析完成: dataset_id=%s", datasetId)
maxWaitSeconds := 60
for i := 0; i < maxWaitSeconds; i++ {
listReq := &ragflow.ListDocumentsReq{
Page: 1,
PageSize: 1,
}
listRes, err := ragflowClient.ListDocuments(ctx, datasetId, listReq)
if err == nil && listRes != nil && len(listRes.Data.Docs) > 0 {
doc := listRes.Data.Docs[0]
// 调试:输出所有状态字段
if i == 0 || i%5 == 0 {
g.Log().Infof(ctx, "文档状态详情: dataset_id=%s, doc_id=%s, RunStatus=%s, Status=%s, ChunkCount=%d, Progress=%.2f, ProgressMsg=%s",
datasetId, doc.Id, doc.RunStatus, doc.Status, doc.ChunkCount, doc.Progress, doc.ProgressMsg)
}
// 检查解析失败状态Progress < 0 表示解析出错如embedding模型无法访问
if doc.Progress < 0 {
return gerror.Newf("RAGFlow文档解析失败embedding服务不可用: dataset_id=%s, doc_id=%s, error=%s",
datasetId, doc.Id, doc.ProgressMsg)
}
// 检查解析完成的条件ChunkCount > 0 表示已解析出分块
if doc.ChunkCount > 0 {
g.Log().Infof(ctx, "知识库文档解析完成: dataset_id=%s, doc_id=%s, chunk_count=%d, 耗时=%d秒",
datasetId, doc.Id, doc.ChunkCount, i)
break
}
}
// 超时检查
if i >= maxWaitSeconds-1 {
return gerror.Newf("等待文档解析超时: dataset_id=%s, 已等待%d秒", datasetId, maxWaitSeconds)
}
time.Sleep(time.Second)
}
// 2. 生成默认提示词、助理描述并创建Chat
promptText := s.generateDefaultPrompt()
modelName := s.getDefaultModelName(ctx)
// 使用传入的assistantDescription如果为空则使用默认生成
var assistantDesc string
if assistantDescription != "" {
assistantDesc = assistantDescription
} else {
assistantDesc = s.generateAssistantDescription(accountName, platform)
}
// Chat名称账号名_平台_租户ID
chatName := fmt.Sprintf("%s_%s_%s", accountName, platform, tenantId)
chatReq := &ragflow.CreateChatReq{
Name: chatName,
Description: assistantDesc,
DatasetIds: []string{datasetId},
Prompt: &ragflow.PromptConfig{
Prompt: promptText,
SimilarityThreshold: 0.2,
KeywordsSimilarityWeight: 0.7,
TopN: 8,
EmptyResponse: "",
Variables: []map[string]interface{}{
{
"key": "knowledge",
"optional": true,
},
},
},
Llm: &ragflow.Llm{
ModelName: modelName,
},
}
chat, err := ragflowClient.CreateChat(ctx, chatReq)
if err != nil {
// 如果Chat名称已存在尝试查询现有Chat
if strings.Contains(err.Error(), "Duplicated chat name") {
g.Log().Warningf(ctx, "Chat名称已存在尝试查询现有Chat: %s", chatName)
// 查询Chat列表查找同名的Chat
listReq := &ragflow.ListChatsReq{
Page: 1,
PageSize: 100,
}
listRes, listErr := ragflowClient.ListChats(ctx, listReq)
if listErr != nil {
return gerror.Wrapf(listErr, "查询Chat列表失败")
}
// 检查listRes和Data是否为nil防止遍历nil切片导致空指针异常
if listRes == nil || listRes.Data == nil {
return gerror.New("查询Chat列表返回空对象")
}
// 查找同名Chat
for _, existingChat := range listRes.Data {
if existingChat.Name == chatName {
g.Log().Infof(ctx, "找到现有Chat: name=%s, id=%s复用该Chat", chatName, existingChat.Id)
chat = existingChat
break
}
}
if chat == nil {
return gerror.Newf("找不到同名Chat: %s", chatName)
}
} else {
return gerror.Wrapf(err, "创建RAGFlow Chat失败")
}
}
// 最终检查chat是否为nil防止CreateChat返回(nil, nil)或Duplicated情况下未找到同名Chat
if chat == nil {
return gerror.New("创建RAGFlow Chat返回空对象")
}
// 先查询现有配置,保留用户的自定义设置
coll := commonMongo.GetDB().Collection(entity.RAGFlowConfigCollection)
filter := bson.M{"accountName": accountName, "isDeleted": false}
var existingConfig entity.RAGFlowConfig
findErr := coll.FindOne(ctx, filter).Decode(&existingConfig)
now := gtime.Now().Time
// 如果已有配置保留用户自定义的prompt和参数
if findErr == nil && !existingConfig.Id.IsZero() {
g.Log().Infof(ctx, "保留现有配置的自定义设置仅更新chat_id和dataset_id")
// 只更新必要字段,保留用户自定义内容
update := bson.M{
"$set": bson.M{
"chatId": chat.Id,
"datasetId": datasetId,
"datasetIds": []string{datasetId},
"syncStatus": "synced",
"updatedAt": now,
},
}
if _, err := coll.UpdateOne(ctx, filter, update); err != nil {
return gerror.Wrap(err, "更新RAGFlowConfig失败")
}
} else {
// 首次创建配置,使用默认值
g.Log().Infof(ctx, "首次创建RAGFlow配置使用默认提示词")
config := &entity.RAGFlowConfig{
AccountName: accountName,
Platform: platform,
DatasetId: datasetId,
DatasetIds: []string{datasetId},
DatasetName: "租户话术知识库",
ChatId: chat.Id,
Prompt: promptText,
DocumentIds: []string{},
SimilarityThreshold: 0.2,
KeywordsSimilarityWeight: 0.7,
TopN: 8,
EmptyResponse: "",
SyncStatus: "synced",
}
// 统一使用string类型存储tenantId到MongoDB
config.TenantId = tenantId
config.CreatedAt = &now // 取地址赋值给指针类型
config.UpdatedAt = &now // 取地址赋值给指针类型
config.IsDeleted = false
update := bson.M{"$set": config}
opts := options.UpdateOne().SetUpsert(true)
if _, err := coll.UpdateOne(ctx, filter, update, opts); err != nil {
return gerror.Wrap(err, "更新RAGFlowConfig失败")
}
}
g.Log().Infof(ctx, "RAGFlowConfig创建/更新成功: account_name=%s, chat_id=%s, dataset_id=%s",
accountName, chat.Id, datasetId)
return nil
}
// generateDefaultPrompt 生成默认提示词(包含知识库引用)
func (s *customerServiceAccount) generateDefaultPrompt() string {
// 默认提示词已包含完整的知识库引用格式
return `你是一个智能助手,请总结知识库的内容来回答问题,请列举知识库中的数据详细回答。当所有知识库内容都与问题无关时,你的回答必须包括"知识库中未找到您要的答案!"这句话。回答需要考虑聊天历史。
以下是知识库:
{knowledge}
以上是知识库。`
}
// generateAssistantDescription 根据客服账号生成助理描述
func (s *customerServiceAccount) generateAssistantDescription(accountName, platform string) string {
// 根据账号名称判断业务方向
lowerName := gstr.ToLower(accountName)
// 气血方向
if gstr.Contains(lowerName, "qixue") || gstr.Contains(lowerName, "气血") {
return "专业的女性健康顾问,专注于月经调理、气血养护等健康问题。以温暖关怀的态度为客户提供专业建议,帮助改善身体状况。"
}
// 减肥方向
if gstr.Contains(lowerName, "jianfei") || gstr.Contains(lowerName, "减肥") || gstr.Contains(lowerName, "shoushen") || gstr.Contains(lowerName, "瘦身") {
return "专业的减肥瘦身顾问,提供科学健康的减肥方案和营养建议。以积极正面的态度鼓励客户坚持健康的生活方式,达成理想体型。"
}
// 护肤方向
if gstr.Contains(lowerName, "hufu") || gstr.Contains(lowerName, "护肤") || gstr.Contains(lowerName, "meirong") || gstr.Contains(lowerName, "美容") {
return "专业的护肤美容顾问,为客户提供个性化的护肤方案和产品建议。帮助客户解决各类肌肤问题,重现健康光彩。"
}
// 养生方向
if gstr.Contains(lowerName, "yangsheng") || gstr.Contains(lowerName, "养生") || gstr.Contains(lowerName, "health") {
return "专业的养生健康顾问,提供中医养生、日常保健等全方位健康建议。倡导健康生活方式,帮助客户提升整体身心健康。"
}
// 默认通用描述
return fmt.Sprintf("智能客服助理,为您提供专业的咨询服务。基于%s平台随时解答您的疑问提供贴心的服务体验。", platform)
}
// getDefaultModelName 获取默认模型名称Consul优先config.yml兜底
func (s *customerServiceAccount) getDefaultModelName(ctx context.Context) string {
// 使用统一配置读取方法自动支持Consul动态配置
model := GetConfigString(ctx, "ragflow.default_model")
if model != "" {
return model
}
// 硬编码兜底(理论上不会走到这里)
return "qwen3-235b-a22b-instruct-2507"
}
// Update 更新客服账号
func (s *customerServiceAccount) Update(ctx context.Context, req *dto.UpdateCustomerServiceAccountReq) (err error) {
// 调试日志:输出请求数据
g.Log().Infof(ctx, "[Update] 请求数据 - Id: %s, AccountName: %s, Platform: %s, Prompt: %v, Greeting: %v",
req.Id, req.AccountName, req.Platform, req.Prompt, req.Greeting)
// 1. 检查账号名称是否重复
if req.AccountName != "" {
existingAccount, err := dao.CustomerServiceAccount.FindByAccountName(ctx, req.AccountName)
if err != nil && err != mongo.ErrNoDocuments {
return err
}
if existingAccount != nil && existingAccount.Id.Hex() != req.Id {
return gerror.Newf("客服账号名称 '%s' 已被其他账号使用,请使用其他名称", req.AccountName)
}
}
// 2. 更新基本信息accountName, platform
if err = dao.CustomerServiceAccount.Update(ctx, req); err != nil {
g.Log().Errorf(ctx, "[Update] 更新基本信息失败: %v", err)
return err
}
// 3. 查询客服账号获取accountName用于后续更新
objectId, err := bson.ObjectIDFromHex(req.Id)
if err != nil {
return gerror.Wrap(err, "无效的账号ID")
}
var account entity.CustomerServiceAccount
filter := bson.M{"_id": objectId, "isDeleted": false}
if err = dao.MongoDAO.FindOne(ctx, filter, &account, entity.CustomerServiceAccountCollection); err != nil {
return gerror.Wrap(err, "查询客服账号失败")
}
// 4. 如果提供了开场白更新greeting字段到customer_service_account表
if req.Greeting != nil {
g.Log().Infof(ctx, "[Update] 更新开场白 - accountName: %s, greeting: %s", account.AccountName, *req.Greeting)
updateReq := &dto.UpdateGreetingReq{
AccountName: account.AccountName,
Greeting: *req.Greeting,
}
if err = s.UpdateGreeting(ctx, updateReq); err != nil {
g.Log().Errorf(ctx, "[Update] 更新开场白失败: %v", err)
return gerror.Wrap(err, "更新开场白失败")
}
}
// 5. 如果提供了提示词更新RAGFlow配置
if req.Prompt != nil {
g.Log().Infof(ctx, "[Update] 更新提示词 - accountName: %s", account.AccountName)
updatePromptReq := &dto.UpdatePromptReq{
AccountName: account.AccountName,
Prompt: *req.Prompt,
}
if _, err = RAGFlowConfig.UpdatePrompt(ctx, updatePromptReq); err != nil {
g.Log().Errorf(ctx, "[Update] 更新提示词失败: %v", err)
return gerror.Wrap(err, "更新提示词失败")
}
}
// 6. 如果accountName、platform或selfIdentity有变化同步更新RAGFlow Chat的name和description
if req.AccountName != "" || req.Platform != "" || req.SelfIdentity != nil {
if err = s.updateRAGFlowChatInfo(ctx, &account, req); err != nil {
g.Log().Errorf(ctx, "[Update] 同步更新RAGFlow Chat失败: %v", err)
return gerror.Wrap(err, "同步更新RAGFlow Chat失败")
}
}
g.Log().Infof(ctx, "[Update] 客服账号更新成功 - accountName: %s", account.AccountName)
return nil
}
// UpdateGreeting 更新开场白
func (s *customerServiceAccount) UpdateGreeting(ctx context.Context, req *dto.UpdateGreetingReq) (err error) {
// 获取租户ID租户隔离
user, err := util.GetTenantInfo(ctx)
if err != nil {
return gerror.Wrap(err, "获取租户信息失败")
}
filter := bson.M{
"accountName": req.AccountName,
"tenantId": user.TenantId,
"isDeleted": false,
}
update := bson.M{
"$set": bson.M{
"greeting": req.Greeting,
"updatedAt": gtime.Now().Time,
"updater": user.UserName,
},
}
g.Log().Infof(ctx, "[UpdateGreeting] 开始更新开场白 - accountName: %s, tenantId: %v, greeting: %s",
req.AccountName, user.TenantId, req.Greeting)
g.Log().Infof(ctx, "[UpdateGreeting] 查询条件 - filter: %+v", filter)
g.Log().Infof(ctx, "[UpdateGreeting] 更新内容 - update: %+v", update)
// 使用MongoDAO.UpdateOne不需要token验证避免双重验证冲突
matchedCount, modifiedCount, err := dao.MongoDAO.UpdateOne(ctx, filter, update, entity.CustomerServiceAccountCollection)
if err != nil {
g.Log().Errorf(ctx, "[UpdateGreeting] 数据库更新失败 - err: %v", err)
return gerror.Wrapf(err, "更新开场白失败")
}
g.Log().Infof(ctx, "[UpdateGreeting] 数据库更新结果 - matchedCount: %d, modifiedCount: %d", matchedCount, modifiedCount)
if modifiedCount == 0 {
if matchedCount == 0 {
g.Log().Errorf(ctx, "[UpdateGreeting] 未找到匹配记录 - accountName: %s, tenantId: %v", req.AccountName, user.TenantId)
return gerror.Newf("客服账号不存在或不属于当前租户: accountName=%s", req.AccountName)
}
g.Log().Warningf(ctx, "[UpdateGreeting] 记录已存在但未修改(内容相同) - accountName: %s", req.AccountName)
}
g.Log().Infof(ctx, "[UpdateGreeting] ✅ 开场白更新成功 - accountName: %s, 更新记录数: %d", req.AccountName, modifiedCount)
return
}
// ToggleStatus 切换客服账号状态(启用/禁用)
// 参数: ctx - 上下文req - 状态切换请求包含账号ID
// 返回: err - 错误信息
// 功能: 在启用和禁用状态之间切换,禁用后不再接收用户消息
func (s *customerServiceAccount) ToggleStatus(ctx context.Context, req *dto.ToggleCustomerServiceAccountStatusReq) (err error) {
return dao.CustomerServiceAccount.ToggleStatus(ctx, req)
}
// List 获取客服账号列表关联查询prompt字段
// 参数: ctx - 上下文req - 列表查询请求(支持分页、平台筛选、状态筛选)
// 返回: res - 客服账号列表及分页信息包含prompt字段err - 错误信息
// 功能: 分页查询客服账号并从ragflow_config表关联查询每个账号的prompt去除知识库引用部分
func (s *customerServiceAccount) List(ctx context.Context, req *dto.ListCustomerServiceAccountReq) (res *dto.ListCustomerServiceAccountRes, err error) {
list, total, err := dao.CustomerServiceAccount.List(ctx, req)
if err != nil {
return
}
// 关联查询每个账号的prompt从ragflow_config表
for i := range list {
account := list[i]
if config, configErr := dao.RAGFlowConfig.FindByAccountName(ctx, account.AccountName); configErr == nil && config != nil {
// 去除知识库引用部分,只返回用户输入的业务提示词
userPrompt := config.Prompt
// 查找知识库引用的起始位置(支持多种格式)
knowledgePatterns := []string{
"\n\n以下是知识库",
"\n\n以下是知识库",
"\n以下是知识库",
"\n以下是知识库",
}
// 找到最早出现的知识库引用位置并截断
for _, pattern := range knowledgePatterns {
if idx := gstr.Pos(userPrompt, pattern); idx >= 0 {
userPrompt = gstr.SubStr(userPrompt, 0, idx)
break
}
}
// 去除末尾空白
userPrompt = gstr.TrimRight(userPrompt)
// 将处理后的prompt赋值给account
list[i].Prompt = userPrompt
g.Log().Debugf(ctx, "账号 %s 的prompt已关联原始长度: %d, 处理后长度: %d",
account.AccountName, len(config.Prompt), len(userPrompt))
}
}
res = &dto.ListCustomerServiceAccountRes{
List: list,
Total: int(total),
}
return
}
// Delete 删除客服账号(软删除 + 删除RAGFlow Chat
// 参数: ctx - 上下文req - 删除客服账号请求包含账号ID
// 返回: res - 删除结果信息err - 错误信息
// 功能: 逻辑删除客服账号同时删除RAGFlow中的Chat配置和ragflow_config记录
func (s *customerServiceAccount) Delete(ctx context.Context, req *dto.DeleteCustomerServiceAccountReq) (res *dto.DeleteCustomerServiceAccountRes, err error) {
res = &dto.DeleteCustomerServiceAccountRes{}
// 1. 查询客服账号信息使用MongoDAO直接查询避免租户过滤
objectId, err := bson.ObjectIDFromHex(req.Id)
if err != nil {
return nil, gerror.Wrap(err, "无效的账号ID")
}
var account entity.CustomerServiceAccount
filter := bson.M{"_id": objectId, "isDeleted": false}
err = dao.MongoDAO.FindOne(ctx, filter, &account, entity.CustomerServiceAccountCollection)
if err != nil {
if err.Error() == "mongo: no documents in result" {
return nil, gerror.New("客服账号不存在")
}
return nil, gerror.Wrap(err, "查询客服账号失败")
}
// 2. 查询关联的RAGFlowConfig获取chatId
config, err := dao.RAGFlowConfig.FindByAccountName(ctx, account.AccountName)
if err != nil {
g.Log().Warningf(ctx, "查询RAGFlowConfig失败: %v", err)
}
// 3. 如果存在chatId调用RAGFlow API删除Chat
if config != nil && config.ChatId != "" {
ragflowClient := ragflow.GetGlobalClient()
if err := ragflowClient.DeleteChats(ctx, []string{config.ChatId}); err != nil {
g.Log().Errorf(ctx, "删除RAGFlow Chat失败: chat_id=%s, error=%v", config.ChatId, err)
// 不阻断删除流程,记录错误日志
} else {
g.Log().Infof(ctx, "RAGFlow Chat删除成功: chat_id=%s", config.ChatId)
}
// 4. 软删除RAGFlowConfig记录
filter := bson.M{"_id": config.Id, "isDeleted": false}
update := bson.M{"$set": bson.M{"isDeleted": true}}
if _, err := commonMongo.DB().Update(ctx, filter, update, entity.RAGFlowConfigCollection); err != nil {
g.Log().Warningf(ctx, "软删除RAGFlowConfig失败: %v", err)
}
}
// 5. 软删除客服账号
if err := dao.CustomerServiceAccount.Delete(ctx, account.Id.Hex()); err != nil {
return nil, gerror.Wrap(err, "删除客服账号失败")
}
g.Log().Infof(ctx, "客服账号删除成功: account_name=%s, chat_id=%s",
account.AccountName, config.ChatId)
res.Success = true
res.Message = "删除成功"
return
}
// getAccessibleSpeechcraftsInternal 内部方法:获取客服账号可访问的话术(动态权限)
// 参数: ctx - 上下文accountName - 客服账号名
// 返回: speechcrafts - 话术列表err - 错误信息
// 功能: 根据客服账号的speechcraft_ids字段判断权限范围
//
// 空数组 = 返回该租户下所有话术(动态权限)
// 非空 = 只返回指定ID的话术指定权限
func (s *customerServiceAccount) getAccessibleSpeechcraftsInternal(ctx context.Context, accountName string) (speechcrafts []*entity.Speechcraft, err error) {
// 1. 查询客服账号
account, err := dao.CustomerServiceAccount.FindByAccountName(ctx, accountName)
if err != nil {
return nil, gerror.Wrapf(err, "查询客服账号失败")
}
if account == nil {
return nil, gerror.New("客服账号不存在")
}
// 2. 根据speechcraft_ids判断权限范围
if len(account.SpeechcraftIds) == 0 {
// 空数组 = 动态权限,返回该租户下所有话术
filter := bson.M{"isDeleted": false}
page := &beans.Page{PageNum: 1, PageSize: 10000} // 查询所有话术
orderBy := []beans.OrderBy{}
_, err = commonMongo.DB().Find(ctx, filter, &speechcrafts, entity.SpeechcraftCollection, page, orderBy)
if err != nil {
return nil, gerror.Wrapf(err, "查询所有话术失败")
}
g.Log().Infof(ctx, "客服账号使用动态权限account_name=%s, speechcraft_count=%d", accountName, len(speechcrafts))
} else {
// 非空 = 指定权限只返回指定ID的话术
var objectIds []bson.ObjectID
for _, idStr := range account.SpeechcraftIds {
if oid, err := bson.ObjectIDFromHex(idStr); err == nil {
objectIds = append(objectIds, oid)
}
}
if len(objectIds) > 0 {
filter := bson.M{
"_id": bson.M{"$in": objectIds},
"isDeleted": false,
}
page := &beans.Page{PageNum: 1, PageSize: 10000} // 查询指定话术
orderBy := []beans.OrderBy{}
_, err = commonMongo.DB().Find(ctx, filter, &speechcrafts, entity.SpeechcraftCollection, page, orderBy)
if err != nil {
return nil, gerror.Wrapf(err, "查询指定话术失败")
}
}
g.Log().Infof(ctx, "客服账号使用指定权限account_name=%s, allowed_count=%d, found_count=%d",
accountName, len(account.SpeechcraftIds), len(speechcrafts))
}
return speechcrafts, nil
}
// GetAccessibleSpeechcrafts 获取客服账号可访问的话术列表API接口
// 参数: ctx - 上下文req - 查询请求(包含账号名)
// 返回: res - 话术列表及权限类型dynamic/specifiederr - 错误信息
// 功能: 查询客服账号可访问的话术返回格式化的DTO列表和权限标识
func (s *customerServiceAccount) GetAccessibleSpeechcrafts(ctx context.Context, req *dto.GetAccessibleSpeechcraftsReq) (res *dto.GetAccessibleSpeechcraftsRes, err error) {
res = &dto.GetAccessibleSpeechcraftsRes{}
// 1. 查询客服账号
account, err := dao.CustomerServiceAccount.FindByAccountName(ctx, req.AccountName)
if err != nil {
return nil, gerror.Wrapf(err, "查询客服账号失败")
}
if account == nil {
return nil, gerror.New("客服账号不存在")
}
// 2. 获取话术列表
speechcrafts, err := s.getAccessibleSpeechcraftsInternal(ctx, req.AccountName)
if err != nil {
return nil, err
}
// 3. 转换为DTO格式
for _, sc := range speechcrafts {
item := dto.SpeechcraftItem{
Id: sc.Id.Hex(),
Tag: sc.Tag,
Content: sc.Content,
Direction: sc.Direction,
Platform: sc.Platform,
Keywords: sc.Keywords,
}
res.Speechcrafts = append(res.Speechcrafts, item)
}
res.TotalCount = len(res.Speechcrafts)
// 4. 标记权限类型
if len(account.SpeechcraftIds) == 0 {
res.Permission = "dynamic" // 动态权限:所有话术
} else {
res.Permission = "specified" // 指定权限:限定话术
}
return res, nil
}
// RecreateRAGFlowConfig 重新创建RAGFlow配置
// 参数: ctx - 上下文req - 重建请求(包含账号名和平台)
// 返回: err - 错误信息
// 功能: 删除旧配置并重新创建Dataset和Chat用于修复创建客服账号时RAGFlow配置创建失败的情况或chat被删除后自动重建
func (s *customerServiceAccount) RecreateRAGFlowConfig(ctx context.Context, req *dto.RecreateRAGFlowConfigReq) (err error) {
// 1. 查询客服账号
account, err := dao.CustomerServiceAccount.FindByAccountName(ctx, req.AccountName)
if err != nil {
return gerror.Wrap(err, "查询客服账号失败")
}
if account == nil {
return gerror.Newf("客服账号 %s 不存在", req.AccountName)
}
// 2. 获取租户ID
tenantId := gconv.String(account.TenantId)
if tenantId == "" {
return gerror.New("客服账号的租户ID为空")
}
accountName := req.AccountName
// 3. 查询现有RAGFlow配置带租户隔离
coll := commonMongo.GetDB().Collection(entity.RAGFlowConfigCollection)
filter := bson.M{"accountName": accountName, "tenantId": account.TenantId, "isDeleted": false}
var existingConfig entity.RAGFlowConfig
err = coll.FindOne(ctx, filter).Decode(&existingConfig)
// 4. 如果已有配置且chat_id不为空验证chat是否真实存在于RAGFlow
needRecreate := false
if err == nil && !existingConfig.Id.IsZero() && existingConfig.ChatId != "" {
// 调用RAGFlow API验证chat是否存在通过UpdateChat验证
ragflowClient := ragflow.GetGlobalClient()
if ragflowClient != nil {
// 尝试更新chat即使不改任何参数如果chat不存在会返回错误
updateReq := &ragflow.UpdateChatReq{
DatasetIds: existingConfig.DatasetIds,
}
updateErr := ragflowClient.UpdateChat(ctx, existingConfig.ChatId, updateReq)
if updateErr != nil {
// chat不存在或更新失败需要重建
g.Log().Warningf(ctx, "RAGFlow中的Chat已被删除或不可用需要重建 - accountName: %s, old_chat_id: %s, error: %v",
accountName, existingConfig.ChatId, updateErr)
needRecreate = true
} else {
// chat存在且有效无需重建
g.Log().Infof(ctx, "发现现有有效的RAGFlow配置无需重建 - accountName: %s, chat_id: %s", accountName, existingConfig.ChatId)
return nil
}
} else {
// ragflowClient为空无法验证默认需要重建
needRecreate = true
}
} else {
// 配置不存在或chat_id为空需要创建
needRecreate = true
}
// 5. 需要重建chat
if !needRecreate {
return nil
}
g.Log().Infof(ctx, "开始创建新Chat - accountName: %s", accountName)
// 6. 确保租户知识库存在(调用公共方法)
datasetId, err := EnsureTenantDataset(ctx, tenantId)
if err != nil {
g.Log().Errorf(ctx, "确保租户知识库存在失败: %v", err)
return gerror.Wrap(err, "确保租户知识库存在失败")
}
// 7. 创建Chat并保存RAGFlow配置重建时使用账号的selfIdentity
if err = s.createChatAndSaveConfig(ctx, req.AccountName, req.Platform, tenantId, datasetId, account.SelfIdentity); err != nil {
g.Log().Errorf(ctx, "创建Chat配置失败: %v", err)
return gerror.Wrap(err, "创建Chat配置失败")
}
g.Log().Infof(ctx, "成功为客服账号 %s 创建RAGFlow配置", req.AccountName)
return nil
}
// updateRAGFlowChatInfo 同步更新RAGFlow Chat的name和description
func (s *customerServiceAccount) updateRAGFlowChatInfo(ctx context.Context, account *entity.CustomerServiceAccount, req *dto.UpdateCustomerServiceAccountReq) (err error) {
// 1. 查询RAGFlow配置获取chatId
config, err := dao.RAGFlowConfig.FindByAccountName(ctx, account.AccountName)
if err != nil {
g.Log().Warningf(ctx, "查询RAGFlow配置失败跳过同步: %v", err)
return nil
}
if config == nil || config.ChatId == "" {
g.Log().Warningf(ctx, "RAGFlow配置不存在或chatId为空跳过同步")
return nil
}
// 2. 构建新的chat名称和描述
tenantId := gconv.String(account.TenantId)
// 使用更新后的accountName和platform如果没有提供则使用原值
accountName := account.AccountName
platform := account.Platform
if req.AccountName != "" {
accountName = req.AccountName
}
if req.Platform != "" {
platform = req.Platform
}
newChatName := fmt.Sprintf("%s_%s_%s", accountName, platform, tenantId)
// 使用更新后的selfIdentity如果没有提供则使用原值
var newDescription string
if req.SelfIdentity != nil {
newDescription = *req.SelfIdentity
} else {
newDescription = account.SelfIdentity
}
// 如果selfIdentity为空使用默认生成方式
if newDescription == "" {
newDescription = s.generateAssistantDescription(accountName, platform)
}
g.Log().Infof(ctx, "[updateRAGFlowChatInfo] 同步更新RAGFlow Chat - chatId: %s, newName: %s, newDescription: %s",
config.ChatId, newChatName, newDescription)
// 3. 调用RAGFlow API更新Chat
ragflowClient := ragflow.GetGlobalClient()
if ragflowClient == nil {
return gerror.New("RAGFlow客户端未初始化")
}
updateReq := &ragflow.UpdateChatReq{
Name: newChatName,
Description: newDescription,
}
if err = ragflowClient.UpdateChat(ctx, config.ChatId, updateReq); err != nil {
g.Log().Errorf(ctx, "[updateRAGFlowChatInfo] 更新RAGFlow Chat失败: %v", err)
return gerror.Wrap(err, "更新RAGFlow Chat失败")
}
g.Log().Infof(ctx, "[updateRAGFlowChatInfo] ✅ 成功同步更新RAGFlow Chat")
return nil
}

69
service/data_service.go Normal file
View File

@@ -0,0 +1,69 @@
// Package service - 对话数据服务
// 功能对话记录查询、导出Excel
package service
import (
"context"
"customer-server/dao"
"customer-server/model/dto"
"customer-server/model/entity"
"gitea.com/red-future/common/utils"
"github.com/gogf/gf/v2/os/gtime"
)
var Data = new(data)
type data struct{}
// Add 添加数据
// 参数: ctx - 上下文req - 添加数据请求
// 返回: res - 添加成功后的数据IDerr - 错误信息
// 功能: 创建新的数据记录
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
}

View File

@@ -0,0 +1,168 @@
// Package service - 数据统计服务
// 功能:对话数据统计分析、报表生成
package service
import (
"archive/zip"
"bytes"
"context"
"customer-server/dao"
"customer-server/model/dto"
"customer-server/model/entity"
"regexp"
"strings"
"gitea.com/red-future/common/utils"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/os/gtime"
"github.com/gogf/gf/v2/util/gconv"
)
var DataStatistics = new(dataStatistics)
type dataStatistics struct{}
// Add 添加数据统计
// 参数: ctx - 上下文req - 添加数据统计请求
// 返回: res - 添加成功后的统计IDerr - 错误信息
// 功能: 创建新的数据统计记录
func (s *dataStatistics) Add(ctx context.Context, req *dto.AddDataStatisticsReq) (res *dto.AddDataStatisticsRes, err error) {
data := &entity.DataStatistics{}
if err = utils.Struct(req, data); err != nil {
return
}
// 使用 gtime 转换日期
if dateTime := gtime.NewFromStr(req.Date); dateTime != nil {
date := dateTime.Time
data.Date = &date // 取地址赋值给指针类型
} else {
return nil, gerror.New("日期格式错误")
}
// 设置基础字段
now := gtime.Now().Time
data.CreatedAt = &now // 取地址赋值给指针类型
data.UpdatedAt = &now // 取地址赋值给指针类型
data.IsDeleted = false
// 注意Creator、Updater、TenantId 保持零值,不设置
if err = dao.DataStatistics.Insert(ctx, data); err != nil {
return
}
res = &dto.AddDataStatisticsRes{Id: data.Id.Hex()}
return
}
// Update 更新数据统计
// 参数: ctx - 上下文req - 更新数据统计请求
// 返回: err - 错误信息
// 功能: 更新数据统计记录内容
func (s *dataStatistics) Update(ctx context.Context, req *dto.UpdateDataStatisticsReq) (err error) {
return dao.DataStatistics.Update(ctx, req)
}
// List 获取数据统计列表
// 参数: ctx - 上下文req - 列表查询请求
// 返回: res - 数据统计列表及分页信息err - 错误信息
// 功能: 分页查询数据统计记录
func (s *dataStatistics) List(ctx context.Context, req *dto.ListDataStatisticsReq) (res *dto.ListDataStatisticsRes, err error) {
list, total, err := dao.DataStatistics.List(ctx, req)
if err != nil {
return
}
res = &dto.ListDataStatisticsRes{
List: list,
Total: int(total),
}
return
}
// Export 导出数据统计为ZIP文件
// 参数: ctx - 上下文req - 导出请求
// 返回: zipData - ZIP文件字节数组filename - 文件名err - 错误信息
// 功能: 将数据统计导出为ZIP文件包含Excel文件
func (s *dataStatistics) Export(ctx context.Context, req *dto.ExportDataStatisticsReq) (zipData []byte, filename string, err error) {
// 1. 查询所有符合条件的数据统计
statistics, err := dao.DataStatistics.FindAllForExport(ctx, req)
if err != nil {
return nil, "", err
}
if len(statistics) == 0 {
return nil, "", gerror.New("没有可导出的数据统计")
}
// 2. 创建 ZIP 文件(内存中)
var buf bytes.Buffer
zipWriter := zip.NewWriter(&buf)
defer zipWriter.Close()
// 3. 为每个数据统计生成 TXT 文件并添加到 ZIP
for _, stat := range statistics {
// 生成 TXT 内容
txtContent := s.generateTxt(stat)
// 生成文件名(清理并替换特殊字符)
dateStr := gtime.New(stat.Date).Format("Y-m-d")
cleanName := strings.ToValidUTF8(stat.CustomerServiceName, "未命名")
safeFilename := s.sanitizeFilename(cleanName)
if safeFilename == "" {
safeFilename = "statistics"
}
txtFilename := dateStr + "_" + safeFilename + "_" + stat.Id.Hex()[:8] + ".txt"
// 添加文件到 ZIP
writer, err := zipWriter.Create(txtFilename)
if err != nil {
return nil, "", gerror.Newf("创建ZIP文件失败: %v", err)
}
if _, err := writer.Write([]byte(txtContent)); err != nil {
return nil, "", gerror.Newf("写入ZIP文件失败: %v", err)
}
}
// 5. 生成下载文件名
timestamp := gtime.Now().Format("Ymd_His")
filename = "data_statistics_export_" + timestamp + ".zip"
return buf.Bytes(), filename, nil
}
// generateTxt 生成数据统计的 TXT 内容
func (s *dataStatistics) generateTxt(stat *entity.DataStatistics) string {
var builder strings.Builder
builder.WriteString("日期: " + gtime.New(stat.Date).Format("Y-m-d") + "\n")
builder.WriteString("客服ID: " + stat.AccountName + "\n")
builder.WriteString("客服名称: " + stat.CustomerServiceName + "\n")
builder.WriteString("客服平台: " + stat.CustomerServicePlatform + "\n")
builder.WriteString("\n=== 统计数据 ===\n")
builder.WriteString("进线数: " + gconv.String(stat.InboundCount) + "\n")
builder.WriteString("开口数: " + gconv.String(stat.ActiveCount) + "\n")
builder.WriteString("接待数: " + gconv.String(stat.ServedCount) + "\n")
builder.WriteString("发名片数: " + gconv.String(stat.ContactCardSentCount) + "\n")
builder.WriteString("发留资卡数: " + gconv.String(stat.NameCardSentCount) + "\n")
builder.WriteString("留资数: " + gconv.String(stat.LeftContactInfoCount) + "\n")
builder.WriteString("\n=== 响应率 ===\n")
builder.WriteString("30s响应率: " + gconv.String(stat.ResponseRate30s) + "%\n")
builder.WriteString("60s响应率: " + gconv.String(stat.ResponseRate60s) + "%\n")
builder.WriteString("360s响应率: " + gconv.String(stat.ResponseRate360s) + "%\n")
builder.WriteString("\n---\n")
builder.WriteString("记录ID: " + stat.Id.Hex() + "\n")
return builder.String()
}
// sanitizeFilename 清理文件名,移除或替换不安全的字符
func (s *dataStatistics) sanitizeFilename(filename string) string {
// 移除或替换特殊字符
reg := regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
safe := reg.ReplaceAllString(filename, "_")
// 限制文件名长度
if len(safe) > 50 {
safe = safe[:50]
}
return strings.TrimSpace(safe)
}

197
service/dataset_service.go Normal file
View File

@@ -0,0 +1,197 @@
package service
import (
"context"
"customer-server/dao"
"customer-server/model/entity"
"fmt"
"gitea.com/red-future/common/ragflow"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
)
// EnsureTenantDataset 确保租户知识库存在
// 逻辑:
// 1. 用租户ID查找MongoDB中的知识库ID
// 2. 测试知识库ID是否在RAGFlow中可用
// 3. 如果不可用就直接根据名字在RAGFlow中查找
// 4. 找到了就把知识库ID存到MongoDB
// 5. 如果名字也找不到,就创建新的
// 6. 把新的ID和名字存到MongoDB
func EnsureTenantDataset(ctx context.Context, tenantId string) (datasetId string, err error) {
ragflowClient := ragflow.GetGlobalClient()
datasetName := fmt.Sprintf("dataset_tenant_%s", tenantId)
// 1. 用租户ID查找MongoDB中的知识库ID
datasetId, err = dao.RAGFlowConfig.FindDatasetIdByTenant(ctx, tenantId)
if err != nil && err.Error() != "mongo: no documents in result" {
return "", gerror.Wrap(err, "查询租户知识库ID失败")
}
// 2. 测试知识库ID是否在RAGFlow中可用
if datasetId != "" {
g.Log().Infof(ctx, "租户%s已有知识库ID: %s测试是否可用", tenantId, datasetId)
listReq := &ragflow.ListDatasetsReq{Page: 1, PageSize: 100}
listRes, listErr := ragflowClient.ListDatasets(ctx, listReq)
if listErr == nil && listRes != nil {
g.Log().Infof(ctx, "ListDatasets返回%d个知识库", len(listRes.Data))
for i, ds := range listRes.Data {
g.Log().Infof(ctx, "知识库[%d]: id=%s, name=%s", i, ds.Id, ds.Name)
}
// 测试ID是否可用
for _, ds := range listRes.Data {
if ds.Id == datasetId {
g.Log().Infof(ctx, "知识库ID可用: %s", datasetId)
return datasetId, nil
}
}
// 3. ID不可用直接根据名字查找
g.Log().Warningf(ctx, "知识库ID不可用根据名字查找: %s", datasetName)
for _, ds := range listRes.Data {
if ds.Name == datasetName {
datasetId = ds.Id
g.Log().Infof(ctx, "找到同名知识库: name=%s, id=%s", datasetName, datasetId)
// 4. 把知识库ID存到MongoDB
if updateErr := updateDatasetIdInMongo(ctx, tenantId, datasetId); updateErr != nil {
g.Log().Errorf(ctx, "更新MongoDB失败: %v", updateErr)
}
return datasetId, nil
}
}
g.Log().Warningf(ctx, "未找到同名知识库: %s", datasetName)
} else {
g.Log().Errorf(ctx, "ListDatasets失败: %v", listErr)
}
}
// 5. 名字也找不到,创建新的
g.Log().Infof(ctx, "创建新知识库: name=%s", datasetName)
// 从config读取embedding模型如果未配置则使用默认值
embeddingModel := g.Cfg().MustGet(ctx, "ragflow.embedding_model", "text-embedding-v4").String()
createReq := &ragflow.CreateDatasetReq{
Name: datasetName,
EmbeddingModel: embeddingModel,
ChunkMethod: "naive",
}
dataset, err := ragflowClient.CreateDataset(ctx, createReq)
// 不依赖CreateDataset的返回值因为RAGFlow可能返回code=0但data=null
// 立即用ListDatasets查找新创建的知识库来确认
g.Log().Infof(ctx, "创建请求已发送,立即查找确认: name=%s", datasetName)
listReq2 := &ragflow.ListDatasetsReq{Page: 1, PageSize: 100}
listRes2, listErr2 := ragflowClient.ListDatasets(ctx, listReq2)
if listErr2 == nil && listRes2 != nil {
for _, ds := range listRes2.Data {
if ds.Name == datasetName {
datasetId = ds.Id
g.Log().Infof(ctx, "确认知识库已创建: id=%s, name=%s", datasetId, datasetName)
goto saveRecord
}
}
}
// 如果ListDatasets也找不到说明创建真的失败了
if err != nil {
return "", gerror.Wrapf(err, "创建知识库失败且无法确认")
}
if dataset == nil || dataset.Id == "" {
return "", gerror.Newf("创建知识库失败无法通过ListDatasets确认: name=%s", datasetName)
}
// 使用CreateDataset返回的ID兜底
datasetId = dataset.Id
g.Log().Infof(ctx, "知识库创建成功: id=%s, name=%s", datasetId, datasetName)
saveRecord:
// 6. 把新的ID和名字存到MongoDB
if err := saveSystemDatasetRecord(ctx, tenantId, datasetId, datasetName); err != nil {
g.Log().Errorf(ctx, "保存到MongoDB失败: %v", err)
return "", gerror.Wrap(err, "保存到MongoDB失败")
}
return datasetId, nil
}
// uploadPlaceholderDocument 上传占位文档
func uploadPlaceholderDocument(ctx context.Context, datasetId, tenantId string) error {
ragflowClient := ragflow.GetGlobalClient()
placeholderContent := "欢迎使用智能客服系统。这是一个占位文档,实际产品和话术会在后续上传。"
placeholderFilename := fmt.Sprintf("placeholder_tenant_%s.txt", tenantId)
documentId, err := ragflowClient.UploadDocumentFromText(ctx, datasetId, placeholderContent, placeholderFilename)
if err != nil {
return gerror.Wrap(err, "上传占位文档失败")
}
g.Log().Infof(ctx, "占位文档上传成功: document_id=%s", documentId)
if parseErr := ragflowClient.ParseDocuments(ctx, datasetId, []string{documentId}); parseErr != nil {
g.Log().Warningf(ctx, "解析占位文档失败: %v", parseErr)
return parseErr
}
g.Log().Infof(ctx, "占位文档解析已启动(后台异步进行)")
return nil
}
// saveSystemDatasetRecord 保存系统占位记录到MongoDB
func saveSystemDatasetRecord(ctx context.Context, tenantId, datasetId, datasetName string) error {
// 先检查是否已有记录
existing, err := dao.RAGFlowConfig.FindDatasetIdByTenant(ctx, tenantId)
if err == nil && existing != "" {
g.Log().Infof(ctx, "租户%s已有知识库记录无需创建系统占位记录", tenantId)
return nil
}
// 创建系统占位记录
systemAccountName := fmt.Sprintf("_system_tenant_%s", tenantId)
config := &entity.RAGFlowConfig{
AccountName: systemAccountName,
Platform: "system",
DatasetId: datasetId,
DatasetIds: []string{datasetId},
DatasetName: datasetName,
ChatId: "", // 系统记录不需要Chat
Prompt: "",
DocumentIds: []string{},
SimilarityThreshold: 0.2,
KeywordsSimilarityWeight: 0.7,
TopN: 8,
EmptyResponse: "抱歉,我暂时无法回答这个问题。",
SyncStatus: "synced",
}
// 统一使用string类型存储tenantId到MongoDB
config.TenantId = tenantId
config.IsDeleted = false
if err := dao.RAGFlowConfig.Insert(ctx, config); err != nil {
return gerror.Wrap(err, "插入系统占位记录失败")
}
g.Log().Infof(ctx, "系统占位记录已保存: tenant_id=%s, dataset_id=%s", tenantId, datasetId)
return nil
}
// updateDatasetIdInMongo 更新MongoDB中租户的所有datasetId记录
func updateDatasetIdInMongo(ctx context.Context, tenantId, newDatasetId string) error {
// 更新该租户所有ragflow_config记录的datasetId
if err := dao.RAGFlowConfig.UpdateDatasetIdByTenant(ctx, tenantId, newDatasetId); err != nil {
return gerror.Wrap(err, "更新租户datasetId失败")
}
g.Log().Infof(ctx, "已更新租户%s的所有记录的datasetId: %s", tenantId, newDatasetId)
return nil
}

17
service/jaegertesttemp.go Normal file
View File

@@ -0,0 +1,17 @@
package service
import (
"context"
"gitea.com/red-future/common/jaeger"
"github.com/gogf/gf/v2/errors/gerror"
)
// JaegerTestTemp 测试 Jaeger 错误记录(临时,测试后删除)
func JaegerTestTemp(ctx context.Context, msg string) {
ctx, span := jaeger.NewSpan(ctx, "test.jaeger.error")
defer span.End()
err := gerror.New(msg)
jaeger.RecordError(ctx, err, "测试Jaeger错误记录")
}

891
service/product_service.go Normal file
View File

@@ -0,0 +1,891 @@
// Package service - 产品服务
// 功能产品的增删改查、ZIP导入/导出、绑定/解绑客服账号、同步到RAGFlow、重试消费者
package service
import (
"archive/zip"
"bytes"
"context"
"customer-server/dao"
"customer-server/model/dto"
"customer-server/model/entity"
"customer-server/util"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"strings"
"unicode/utf8"
"gitea.com/red-future/common/jaeger"
"gitea.com/red-future/common/rabbitmq"
"gitea.com/red-future/common/ragflow"
"gitea.com/red-future/common/utils"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/glog"
"github.com/gogf/gf/v2/os/grpool"
"github.com/gogf/gf/v2/os/gtime"
"github.com/gogf/gf/v2/util/gconv"
"go.mongodb.org/mongo-driver/v2/bson"
)
var (
Product = new(product)
productGrpool = grpool.New(50) // 文档解析协程池最大50并发
)
type product struct{}
// Add 添加产品
// 参数: ctx - 上下文req - 添加产品请求(包含产品名称、描述、价格等)
// 返回: res - 添加成功后的产品ID和RAGFlow文档IDerr - 错误信息
// 功能: 创建产品记录并自动上传到RAGFlow产品知识库独立于话术知识库
func (s *product) Add(ctx context.Context, req *dto.AddProductReq) (res *dto.AddProductRes, err error) {
// 校验产品名称长度
if utf8.RuneCountInString(req.Name) > 64 {
return nil, gerror.New("产品名称必须在64字以内")
}
// 校验产品详情长度
if utf8.RuneCountInString(req.Description) > 8192 {
return nil, gerror.New("产品详情必须在8192字以内")
}
// 去重检查:同一租户下名称唯一
existing, err := dao.Product.FindByName(ctx, req.Name)
if err != nil {
return nil, gerror.Wrap(err, "检查产品重复失败")
}
if existing != nil {
return nil, gerror.Newf("产品名称已存在name=%s, id=%s", req.Name, existing.Id.Hex())
}
data := &entity.Product{}
if err = utils.Struct(req, data); err != nil {
return
}
// 先从token获取租户信息在Insert之前
user, err := util.GetTenantInfo(ctx)
if err != nil {
return nil, gerror.Wrap(err, "获取租户信息失败")
}
tenantId := gconv.String(user.TenantId)
if tenantId == "" {
return nil, gerror.New("租户ID为空")
}
// 确保产品知识库存在(检查话术知识库是否存在,作为租户是否初始化的标志)
speechcraftDatasetId, err := dao.RAGFlowConfig.FindDatasetIdByTenant(ctx, tenantId)
if err != nil || speechcraftDatasetId == "" {
return nil, gerror.Newf("租户知识库不存在,请先创建客服账号: tenant_id=%s", tenantId)
}
// 设置基础字段
now := gtime.Now().Time
data.CreatedAt = &now // 取地址赋值给指针类型
data.UpdatedAt = &now // 取地址赋值给指针类型
data.IsDeleted = false
// 统一使用string类型存储tenantId到MongoDB
data.TenantId = tenantId
// 插入产品到MongoDB
_, err = dao.Product.Insert(ctx, data)
if err != nil {
return nil, err
}
// 确保租户知识库存在(产品和话术共享租户知识库)
// 使用dataset_service提供的统一方法自动处理创建、查找、保存等逻辑
datasetId, err := EnsureTenantDataset(ctx, tenantId)
if err != nil {
g.Log().Errorf(ctx, "确保租户知识库失败: %v", err)
return nil, gerror.Wrap(err, "获取租户知识库失败")
}
g.Log().Infof(ctx, "租户%s的知识库ID: %s", tenantId, datasetId)
// 同步上传到RAGFlow
ragflowClient := ragflow.GetGlobalClient()
g.Log().Infof(ctx, "准备上传产品到RAGFlow: product_id=%s, dataset_id=%s, name=%s", data.Id.Hex(), datasetId, data.Name)
filename := fmt.Sprintf("%s.txt", data.Name)
documentId, err := ragflowClient.UploadDocumentFromText(ctx, datasetId, data.Description, filename)
if err != nil {
// 回滚:删除刚插入的产品
dao.MongoDAO.Delete(ctx, bson.M{"_id": data.Id}, entity.ProductCollection)
g.Log().Errorf(ctx, "产品上传RAGFlow失败: product_id=%s, dataset_id=%s, error=%v", data.Id.Hex(), datasetId, err)
jaeger.RecordError(ctx, err, "产品上传RAGFlow失败")
return nil, gerror.Wrap(err, "文档上传到知识库失败")
}
// 异步触发解析grpool自动管理goroutine生命周期WithoutCancel保留追踪避免取消
productGrpool.Add(ctx, func(ctx context.Context) {
parseCtx := context.WithoutCancel(ctx)
if err := ragflowClient.ParseDocuments(parseCtx, datasetId, []string{documentId}); err != nil {
g.Log().Errorf(parseCtx, "文档解析失败: document_id=%s, error=%v", documentId, err)
} else {
g.Log().Infof(parseCtx, "文档解析成功: document_id=%s", documentId)
}
})
// 更新MongoDB的RagSyncRecords数组使用空accountName表示租户级文档
syncTime := gtime.Now().Format("Y-m-d H:i:s")
record := entity.RagSyncRecord{
AccountName: "", // 空表示租户级文档
RagDocumentId: documentId,
RagSyncStatus: "synced",
SyncTime: syncTime,
RetryCount: 0,
}
filter := bson.M{"_id": data.Id}
update := bson.M{
"$set": bson.M{
"ragSyncRecords": []entity.RagSyncRecord{record},
"ragLastSyncTime": syncTime,
"updatedAt": gtime.Now().Time,
},
}
if _, _, err = dao.MongoDAO.UpdateOne(ctx, filter, update, entity.ProductCollection); err != nil {
g.Log().Errorf(ctx, "更新产品RagSyncRecords失败: %v", err)
// 不回滚,文档已上传成功
}
g.Log().Infof(ctx, "产品添加成功并上传到知识库: product_id=%s, document_id=%s", data.Id.Hex(), documentId)
res = &dto.AddProductRes{Id: data.Id.Hex()}
return
}
// Update 更新产品
// 参数: ctx - 上下文req - 更新产品请求包含产品ID和待更新字段
// 返回: err - 错误信息
// 功能: 更新产品信息并同步到RAGFlow支持文档删除重建
func (s *product) Update(ctx context.Context, req *dto.UpdateProductReq) (err error) {
// 如果更新了产品名称,校验长度
if req.Name != "" && utf8.RuneCountInString(req.Name) > 64 {
return gerror.New("产品名称必须在64字以内")
}
// 如果更新了产品详情,校验长度
if req.Description != "" && utf8.RuneCountInString(req.Description) > 8192 {
return gerror.New("产品详情必须在8192字以内")
}
// 去重检查:如果修改名称,检查是否与其他产品重复
if req.Name != "" {
existing, err := dao.Product.FindByName(ctx, req.Name)
if err != nil {
return gerror.Wrap(err, "检查产品重复失败")
}
if existing != nil && existing.Id.Hex() != req.Id {
return gerror.Newf("产品名称已存在name=%s, id=%s", req.Name, existing.Id.Hex())
}
}
return dao.Product.Update(ctx, req)
}
// Delete 删除产品
// 参数: ctx - 上下文req - 删除产品请求包含产品ID
// 返回: err - 错误信息
// 功能: 逻辑删除产品记录并从RAGFlow移除对应文档
func (s *product) Delete(ctx context.Context, req *dto.DeleteProductReq) (err error) {
g.Log().Infof(ctx, "[Delete] 开始删除产品 - productId: %s", req.Id)
// 1. 查询产品获取RAGFlow同步记录使用原生查询避免租户过滤
objectId, err := bson.ObjectIDFromHex(req.Id)
if err != nil {
return gerror.Wrap(err, "无效的产品ID")
}
var product entity.Product
filter := bson.M{"_id": objectId, "isDeleted": false}
err = dao.MongoDAO.FindOne(ctx, filter, &product, entity.ProductCollection)
if err != nil {
if err.Error() == "mongo: no documents in result" {
return gerror.New("产品不存在")
}
return gerror.Wrap(err, "查询产品失败")
}
g.Log().Infof(ctx, "[Delete] 查询到产品 - name: %s, ragSyncRecords数量: %d", product.Name, len(product.RagSyncRecords))
// 2. 删除RAGFlow中的文档
if len(product.RagSyncRecords) > 0 {
ragflowClient := ragflow.GetGlobalClient()
if ragflowClient != nil {
tenantId := gconv.String(product.TenantId)
// 查询租户的dataset_id
datasetId, err := dao.RAGFlowConfig.FindDatasetIdByTenant(ctx, tenantId)
if err != nil {
g.Log().Warningf(ctx, "查询租户知识库ID失败: %v", err)
} else if datasetId != "" {
// 收集所有需要删除的document_id
var documentIds []string
for _, record := range product.RagSyncRecords {
if record.RagDocumentId != "" {
documentIds = append(documentIds, record.RagDocumentId)
}
}
// 批量删除RAGFlow文档
if len(documentIds) > 0 {
if err := ragflowClient.DeleteDocument(ctx, datasetId, documentIds); err != nil {
g.Log().Errorf(ctx, "删除RAGFlow文档失败: %v, document_ids: %v", err, documentIds)
// 不阻断删除流程,记录错误后继续
} else {
g.Log().Infof(ctx, "成功删除RAGFlow文档: count=%d", len(documentIds))
}
}
}
}
}
// 3. 软删除MongoDB记录
return dao.Product.Delete(ctx, req)
}
// List 获取产品列表
// 参数: ctx - 上下文req - 列表查询请求(支持分页、关键词搜索)
// 返回: res - 产品列表及分页信息err - 错误信息
// 功能: 分页查询产品记录,支持按名称、描述模糊搜索
func (s *product) List(ctx context.Context, req *dto.ListProductReq) (res *dto.ListProductRes, err error) {
list, total, err := dao.Product.List(ctx, req)
if err != nil {
return
}
res = &dto.ListProductRes{
List: list,
Total: int(total),
}
return
}
// Export 导出产品为ZIP文件
// 参数: ctx - 上下文req - 导出请求(包含筛选条件)
// 返回: zipData - ZIP文件字节数组filename - 文件名err - 错误信息
// 功能: 将产品数据导出为ZIP文件包含JSON格式的产品列表
func (s *product) Export(ctx context.Context, req *dto.ExportProductReq) (zipData []byte, filename string, err error) {
// 清理输入参数,防止非法 UTF-8 字符
cleanName := strings.ToValidUTF8(req.Name, "")
// 1. 查询所有符合条件的产品
products, err := dao.Product.FindAllForExport(ctx, cleanName)
if err != nil {
return nil, "", err
}
if len(products) == 0 {
return nil, "", gerror.New("没有可导出的产品")
}
// 清理所有产品数据,确保 UTF-8 有效(防止数据库中的脏数据)
for i := range products {
products[i].Name = strings.ToValidUTF8(products[i].Name, "")
products[i].Description = strings.ToValidUTF8(products[i].Description, "")
// RagDocumentId字段在RagSyncRecords中不在Product主体
}
// 2. 创建 ZIP 文件(内存中)
var buf bytes.Buffer
zipWriter := zip.NewWriter(&buf)
defer zipWriter.Close()
// 3. 为每个产品生成 TXT 文件并添加到 ZIP
for _, product := range products {
// 生成 TXT 内容(产品详情)
txtContent := s.generateTxt(product)
// 文件名就是产品名称(清理特殊字符)
cleanName := strings.ToValidUTF8(product.Name, "未命名")
safeFilename := s.sanitizeFilename(cleanName)
if safeFilename == "" {
safeFilename = "product"
}
txtFilename := safeFilename + ".txt"
// 添加文件到 ZIP
writer, err := zipWriter.Create(txtFilename)
if err != nil {
return nil, "", gerror.Newf("创建ZIP文件失败: %v", err)
}
if _, err := writer.Write([]byte(txtContent)); err != nil {
return nil, "", gerror.Newf("写入ZIP文件失败: %v", err)
}
}
// 5. 生成下载文件名
timestamp := gtime.Now().Format("Ymd_His")
filename = "products_export_" + timestamp + ".zip"
return buf.Bytes(), filename, nil
}
// generateTxt 将产品转换为 TXT 格式
// 新格式:文件名=产品名称,内容=产品详情
func (s *product) generateTxt(product *entity.Product) string {
// 清理产品详情,确保 UTF-8 有效
cleanDescription := strings.ToValidUTF8(product.Description, "")
// 直接返回产品详情
if cleanDescription != "" {
return cleanDescription
}
// 如果没有详情,返回空字符串
return ""
}
// sanitizeFilename 清理文件名中的特殊字符
func (s *product) sanitizeFilename(name string) string {
// 替换不安全的文件名字符
replacer := map[rune]rune{
'/': '_',
'\\': '_',
':': '_',
'*': '_',
'?': '_',
'"': '_',
'<': '_',
'>': '_',
'|': '_',
}
// 预分配容量,避免循环中动态扩容
result := make([]rune, 0, len(name))
for _, char := range name {
if newChar, exists := replacer[char]; exists {
result = append(result, newChar)
} else {
result = append(result, char)
}
}
filename := string(result)
// 限制文件名长度
if utf8.RuneCountInString(filename) > 50 {
runes := []rune(filename)
filename = string(runes[:50])
}
return filename
}
// Import 从ZIP文件导入产品
// 参数: ctx - 上下文file - 上传的ZIP文件
// 返回: res - 导入结果成功和失败数量err - 错误信息
// 功能: 从ZIP文件批量导入产品数据并同步到RAGFlow失败记录加入重试队列
func (s *product) Import(ctx context.Context, file *multipart.FileHeader) (res *dto.ImportProductRes, err error) {
res = &dto.ImportProductRes{
SuccessCount: 0,
FailCount: 0,
FailReasons: []string{},
}
// 1. 获取租户信息
user, err := util.GetTenantInfo(ctx)
if err != nil {
return nil, gerror.Wrap(err, "获取租户信息失败")
}
tenantId := gconv.String(user.TenantId)
if tenantId == "" {
return nil, gerror.New("租户ID为空")
}
// 2. 打开上传的文件
uploadedFile, err := file.Open()
if err != nil {
return nil, gerror.Newf("无法打开上传的文件: %v", err)
}
defer uploadedFile.Close()
// 3. 读取文件内容到内存
fileData, err := io.ReadAll(uploadedFile)
if err != nil {
return nil, gerror.Newf("读取文件失败: %v", err)
}
// 4. 解析 ZIP 文件
zipReader, err := zip.NewReader(bytes.NewReader(fileData), int64(len(fileData)))
if err != nil {
return nil, gerror.Newf("无法解析ZIP文件: %v", err)
}
// 4. 遍历 ZIP 中的所有文件
for _, zipFile := range zipReader.File {
// 只处理 .txt 文件
if !strings.HasSuffix(strings.ToLower(zipFile.Name), ".txt") {
continue
}
// 读取 TXT 文件内容(产品详情)
txtContent, err := s.readZipFile(zipFile)
if err != nil {
res.FailCount++
res.FailReasons = append(res.FailReasons, "文件 "+zipFile.Name+" 读取失败: "+err.Error())
continue
}
// 从文件名提取产品名称(移除 .txt 后缀)
productName := strings.TrimSuffix(zipFile.Name, ".txt")
productName = strings.TrimSpace(productName)
// 创建产品数据
productData, err := s.parseSimpleTxt(productName, txtContent)
if err != nil {
res.FailCount++
res.FailReasons = append(res.FailReasons, "文件 "+zipFile.Name+" 解析失败: "+err.Error())
continue
}
// 校验产品名称和详情长度
if utf8.RuneCountInString(productData.Name) > 64 {
res.FailCount++
res.FailReasons = append(res.FailReasons, "文件 "+zipFile.Name+": 产品名称超过64字")
continue
}
if utf8.RuneCountInString(productData.Description) > 8192 {
res.FailCount++
res.FailReasons = append(res.FailReasons, "文件 "+zipFile.Name+": 产品详情超过8192字")
continue
}
// 设置基础字段
now := gtime.Now().Time
productData.CreatedAt = &now // 取地址赋值给指针类型
productData.UpdatedAt = &now // 取地址赋值给指针类型
productData.IsDeleted = false
// 统一使用string类型存储tenantId到MongoDB
productData.TenantId = tenantId
// 插入数据库
_, err = dao.Product.Insert(ctx, productData)
if err != nil {
res.FailCount++
res.FailReasons = append(res.FailReasons, "文件 "+zipFile.Name+" 数据库插入失败: "+err.Error())
continue
}
// 同步上传到RAGFlow产品知识库使用外层已声明的tenantId变量
if tenantId != "" {
datasetId := fmt.Sprintf("dataset_product_tenant_%s", tenantId)
ragflowClient := ragflow.GetGlobalClient()
if ragflowClient != nil {
filename := fmt.Sprintf("%s.txt", productData.Name)
documentId, uploadErr := ragflowClient.UploadDocumentFromText(ctx, datasetId, productData.Description, filename)
if uploadErr != nil {
// 上传失败回滚删除MongoDB记录
dao.MongoDAO.Delete(ctx, bson.M{"_id": productData.Id}, entity.ProductCollection)
res.FailCount++
res.FailReasons = append(res.FailReasons, "文件 "+zipFile.Name+" 上传知识库失败: "+uploadErr.Error())
continue
}
// 更新ragDocumentId
filter := bson.M{"_id": productData.Id}
update := bson.M{"$set": bson.M{"ragDocumentId": documentId}}
dao.MongoDAO.UpdateOne(ctx, filter, update, entity.ProductCollection)
g.Log().Infof(ctx, "ZIP产品上传成功: name=%s, document_id=%s", productData.Name, documentId)
}
}
res.SuccessCount++
}
return res, nil
}
// readZipFile 读取 ZIP 文件中的单个文件内容
func (s *product) readZipFile(file *zip.File) (string, error) {
reader, err := file.Open()
if err != nil {
return "", err
}
defer reader.Close()
content, err := io.ReadAll(reader)
if err != nil {
return "", err
}
return string(content), nil
}
// parseSimpleTxt 解析简化格式的 TXT 文件
// 文件名=产品名称,内容=产品详情
func (s *product) parseSimpleTxt(productName string, description string) (*entity.Product, error) {
product := &entity.Product{}
// 清理并验证产品名称
product.Name = strings.TrimSpace(strings.ToValidUTF8(productName, ""))
if product.Name == "" {
return nil, gerror.New("产品名称不能为空")
}
// 清理产品详情
product.Description = strings.TrimSpace(strings.ToValidUTF8(description, ""))
return product, nil
}
// parseTxt 解析 TXT 文件内容为产品实体(旧格式兼容)
func (s *product) parseTxt(content string) (*entity.Product, error) {
product := &entity.Product{}
lines := strings.Split(content, "\n")
var inDescription bool
var descriptionLines []string
for _, line := range lines {
line = strings.TrimSpace(line)
// 跳过空行
if line == "" {
continue
}
// 解析标题
if strings.HasPrefix(line, "=== ") && strings.HasSuffix(line, " ===") {
product.Name = strings.TrimSpace(line[4 : len(line)-4])
continue
}
// 跳过基本信息标记
if line == "【基本信息】" {
inDescription = false
continue
}
// 产品详情开始
if line == "【产品详情】" {
inDescription = true
continue
}
// 解析基本信息字段
if !inDescription {
// 跳过系统生成的字段产品ID、创建时间等
if strings.HasPrefix(line, "产品ID:") ||
strings.HasPrefix(line, "创建时间:") ||
strings.HasPrefix(line, "更新时间:") ||
strings.HasPrefix(line, "导出时间:") {
continue
}
// 解析 RAGFlow 文档 ID
if strings.HasPrefix(line, "RAGFlow文档ID:") {
// RAGFlow文档ID存储在RagSyncRecords中
continue
}
}
// 收集产品详情
if inDescription {
// 跳过分隔线和导出时间
if strings.Contains(line, "==================") || strings.HasPrefix(line, "导出时间:") {
continue
}
if line != "暂无产品详情" {
descriptionLines = append(descriptionLines, line)
}
}
}
// 拼接产品详情
product.Description = strings.Join(descriptionLines, "\n")
// 校验必填字段
if product.Name == "" {
return nil, gerror.New("产品名称不能为空")
}
return product, nil
}
// BindToCustomerServices 绑定产品到多个客服账号
func (p *product) BindToCustomerServices(ctx context.Context, req *dto.BindProductReq) (res *dto.BindProductRes, err error) {
res = &dto.BindProductRes{}
// 1. 查询产品
product, err := dao.Product.GetById(ctx, req.ProductId)
if err != nil {
return nil, gerror.Wrapf(err, "查询产品失败")
}
if product == nil {
return nil, gerror.New("产品不存在")
}
// 2. 构建已存在的绑定map去重
existingMap := make(map[string]bool)
for _, csId := range product.AccountNames {
existingMap[csId] = true
}
// 3. 过滤并添加新绑定
var newBindings []string
var failedIds []string
for _, csId := range req.AccountNames {
// 检查去重customer_service_id是否已存在
if existingMap[csId] {
failedIds = append(failedIds, csId)
g.Log().Warningf(ctx, "客服账号 %s 已绑定该产品,跳过", csId)
continue
}
// 验证客服账号是否存在
csAccount, err := dao.CustomerServiceAccount.FindByAccountName(ctx, csId)
if err != nil || csAccount == nil {
failedIds = append(failedIds, csId)
g.Log().Warningf(ctx, "客服账号 %s 不存在或已删除,跳过", csId)
continue
}
newBindings = append(newBindings, csId)
}
// 4. 如果没有新的绑定,直接返回
if len(newBindings) == 0 {
res.SuccessCount = 0
res.FailedIds = failedIds
res.Message = "所有客服账号均已绑定或不存在"
return res, nil
}
// 5. 更新产品绑定
product.AccountNames = append(product.AccountNames, newBindings...)
if err = dao.Product.UpdateEntity(ctx, product); err != nil {
return nil, gerror.Wrapf(err, "更新产品绑定失败")
}
// 6. 同步到RAGFlow自动创建知识库
for _, csId := range newBindings {
// 获取客服账号信息以获取tenant_id
csAccount, err := dao.CustomerServiceAccount.FindByAccountName(ctx, csId)
if err != nil || csAccount == nil {
g.Log().Errorf(ctx, "获取客服账号信息失败: %s", csId)
continue
}
// 同步到RAGFlow会自动创建知识库
tenantId := gconv.String(csAccount.TenantId)
_, err = p.SyncToRAGFlow(ctx, req.ProductId, csId, tenantId)
if err != nil {
g.Log().Errorf(ctx, "同步到RAGFlow失败: product_id=%s, cs_id=%s, error=%v", req.ProductId, csId, err)
// 不阻断绑定流程,失败会进入重试队列
}
}
res.SuccessCount = len(newBindings)
res.FailedIds = failedIds
res.Message = "绑定成功"
return
}
// UnbindFromCustomerService 从客服账号解绑产品
func (p *product) UnbindFromCustomerService(ctx context.Context, req *dto.UnbindProductReq) (res *dto.UnbindProductRes, err error) {
res = &dto.UnbindProductRes{}
product, err := dao.Product.GetById(ctx, req.ProductId)
if err != nil {
return nil, gerror.Wrapf(err, "查询产品失败")
}
if product == nil {
return nil, gerror.New("产品不存在")
}
// 查找并移除绑定
var newBindings []string
found := false
for _, csId := range product.AccountNames {
if csId == req.AccountName {
found = true
continue
}
newBindings = append(newBindings, csId)
}
if !found {
res.Success = false
res.Message = "未找到该绑定关系"
return res, nil
}
product.AccountNames = newBindings
if err = dao.Product.UpdateEntity(ctx, product); err != nil {
return nil, gerror.Wrapf(err, "解绑失败")
}
res.Success = true
res.Message = "解绑成功"
return
}
// SyncToRAGFlow 同步产品到RAGFlow租户级知识库
func (p *product) SyncToRAGFlow(ctx context.Context, productId, accountName, tenantId string) (documentId string, err error) {
// 1. 查询产品
product, err := dao.Product.GetById(ctx, productId)
if err != nil {
return "", gerror.Wrapf(err, "查询产品失败")
}
if product == nil {
return "", gerror.New("产品不存在")
}
// 2. 获取租户的产品知识库ID
datasetId := fmt.Sprintf("dataset_product_tenant_%s", tenantId)
// 2.1 确保知识库存在,不存在则自动创建
if err := p.ensureDatasetExists(ctx, datasetId, tenantId, "产品"); err != nil {
return "", gerror.Wrapf(err, "确保知识库存在失败")
}
// 3. 调用RAGFlow上传文档
ragflowClient := ragflow.GetGlobalClient()
filename := fmt.Sprintf("%s_%s.txt", product.Name, accountName)
documentId, err = ragflowClient.UploadDocumentFromText(ctx, datasetId, product.Description, filename)
if err != nil {
jaeger.RecordError(ctx, err, "产品上传RAGFlow失败")
p.sendToRetryQueue(ctx, productId, accountName, tenantId, 0)
return "", err
}
// 4. 更新MongoDB的RagSyncRecord
now := gtime.Now().Format("Y-m-d H:i:s")
updated := false
for i := range product.RagSyncRecords {
record := &product.RagSyncRecords[i]
if record.AccountName == accountName {
record.RagDocumentId = documentId
record.RagSyncStatus = "synced"
record.SyncTime = now
record.RetryCount = 0
updated = true
break
}
}
if !updated {
product.RagSyncRecords = append(product.RagSyncRecords, entity.RagSyncRecord{
AccountName: accountName,
RagDocumentId: documentId,
RagSyncStatus: "synced",
SyncTime: now,
RetryCount: 0,
})
}
product.RagLastSyncTime = now
if err = dao.Product.UpdateEntity(ctx, product); err != nil {
return "", gerror.Wrapf(err, "更新产品同步状态失败")
}
glog.Infof(ctx, "产品同步成功: product_id=%s, account_name=%s, document_id=%s", productId, accountName, documentId)
return documentId, nil
}
// ensureDatasetExists 已废弃,改用公共方法 EnsureTenantDataset
// 保留此方法仅为兼容性,直接调用公共方法
func (p *product) ensureDatasetExists(ctx context.Context, datasetId, tenantId, datasetType string) error {
_, err := EnsureTenantDataset(ctx, tenantId)
return err
}
// sendToRetryQueue 发送到重试队列
func (p *product) sendToRetryQueue(ctx context.Context, productId, accountName, tenantId string, retryCount int) {
msg := dto.RAGFlowSyncRetryMsg{
Type: "product",
Id: productId,
AccountName: accountName,
TenantId: tenantId,
RetryCount: retryCount,
}
var delay int
switch retryCount {
case 0:
delay = 5 * 60
case 1:
delay = 15 * 60
case 2:
delay = 60 * 60
default:
glog.Warningf(ctx, "产品同步重试次数超限,标记为失败: %s", productId)
p.markSyncFailed(ctx, productId, accountName)
return
}
if err := rabbitmq.PublishWithDelay(ctx, "ragflow.sync.retry.product", msg, delay); err != nil {
jaeger.RecordError(ctx, err, "发送RAGFlow重试消息失败")
}
}
// markSyncFailed 标记同步失败
func (p *product) markSyncFailed(ctx context.Context, productId, accountName string) {
product, err := dao.Product.GetById(ctx, productId)
if err != nil {
return
}
for i := range product.RagSyncRecords {
record := &product.RagSyncRecords[i]
if record.AccountName == accountName {
record.RagSyncStatus = "failed"
record.SyncTime = gtime.Now().Format("Y-m-d H:i:s")
break
}
}
dao.Product.UpdateEntity(ctx, product)
}
// HandleRAGFlowSyncRetry RAGFlow同步重试消费者
func (p *product) HandleRAGFlowSyncRetry(ctx context.Context, msg dto.RAGFlowSyncRetryMsg) error {
glog.Infof(ctx, "处理RAGFlow同步重试: type=%s, id=%s, retry=%d", msg.Type, msg.Id, msg.RetryCount)
if msg.Type != "product" {
return nil
}
_, err := p.SyncToRAGFlow(ctx, msg.Id, msg.AccountName, msg.TenantId)
if err != nil {
p.sendToRetryQueue(ctx, msg.Id, msg.AccountName, msg.TenantId, msg.RetryCount+1)
return err
}
return nil
}
// ProductRetryConsumer 产品RAGFlow重试消费者
type ProductRetryConsumer struct {
queueName string
consumer *rabbitmq.Consumer
}
// NewProductRetryConsumer 创建产品RAGFlow重试消费者
func NewProductRetryConsumer(ctx context.Context) *ProductRetryConsumer {
return &ProductRetryConsumer{
queueName: "ragflow.sync.retry.product",
}
}
// Start 启动消费者
func (c *ProductRetryConsumer) Start(ctx context.Context) error {
c.consumer = rabbitmq.NewConsumer(c.queueName, func(ctx context.Context, body []byte) error {
var msg dto.RAGFlowSyncRetryMsg
if err := json.Unmarshal(body, &msg); err != nil {
return err
}
return Product.HandleRAGFlowSyncRetry(ctx, msg)
})
return c.consumer.Start(ctx)
}
// Stop 停止消费者
func (c *ProductRetryConsumer) Stop(ctx context.Context) {
if c.consumer != nil {
c.consumer.Stop(ctx)
}
}

View File

@@ -0,0 +1,211 @@
// Package service - RAGFlow配置服务
// 功能管理ragflow_config表对话配置chatId、提示词、知识库映射datasetId
package service
import (
"context"
"customer-server/dao"
"customer-server/model/dto"
"gitea.com/red-future/common/ragflow"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"github.com/gogf/gf/v2/text/gstr"
)
var RAGFlowConfig = new(ragflowConfig)
type ragflowConfig struct{}
// Get 获取RAGFlow配置配置不存在时自动重建
// 参数: ctx - 上下文req - 查询请求(包含客服账号名)
// 返回: res - RAGFlow配置信息提示词、参数等err - 错误信息
// 功能: 查询客服账号的RAGFlow对话配置不存在时自动重建
func (s *ragflowConfig) Get(ctx context.Context, req *dto.GetRAGFlowConfigReq) (res *dto.GetRAGFlowConfigRes, err error) {
config, err := dao.RAGFlowConfig.FindByAccountName(ctx, req.AccountName)
if err != nil {
return nil, gerror.Wrap(err, "查询RAGFlow配置失败")
}
// 配置不存在,尝试自动重建
if config == nil {
g.Log().Warningf(ctx, "客服账号 %s 的RAGFlow配置不存在尝试自动重建...", req.AccountName)
// 查询客服账号获取platform
account, findErr := dao.CustomerServiceAccount.FindByAccountName(ctx, req.AccountName)
if findErr != nil {
return nil, gerror.Wrapf(findErr, "查询客服账号失败")
}
if account == nil {
return nil, gerror.Newf("客服账号 %s 不存在无法自动创建RAGFlow配置", req.AccountName)
}
// 调用重建逻辑
recreateReq := &dto.RecreateRAGFlowConfigReq{
AccountName: req.AccountName,
Platform: account.Platform,
}
if recreateErr := CustomerServiceAccount.RecreateRAGFlowConfig(ctx, recreateReq); recreateErr != nil {
return nil, gerror.Wrapf(recreateErr, "自动重建RAGFlow配置失败")
}
// 重新查询配置
config, err = dao.RAGFlowConfig.FindByAccountName(ctx, req.AccountName)
if err != nil {
return nil, gerror.Wrap(err, "重建后查询配置失败")
}
if config == nil {
return nil, gerror.New("重建后仍无法获取配置")
}
g.Log().Infof(ctx, "成功自动重建客服账号 %s 的RAGFlow配置", req.AccountName)
}
// 返回给前端时去掉自动追加的知识库部分,只显示用户的业务话术
userPrompt := config.Prompt
knowledgePart := "\n\n以下是知识库\n{knowledge}\n以上是知识库。"
if gstr.Contains(userPrompt, knowledgePart) {
userPrompt = gstr.Replace(userPrompt, knowledgePart, "")
}
res = &dto.GetRAGFlowConfigRes{
AccountName: config.AccountName,
Platform: config.Platform,
DatasetId: config.DatasetId,
DatasetName: config.DatasetName,
ChatId: config.ChatId,
Prompt: userPrompt, // 只返回用户部分
DocumentIds: config.DocumentIds,
SimilarityThreshold: config.SimilarityThreshold,
KeywordsSimilarityWeight: config.KeywordsSimilarityWeight,
TopN: config.TopN,
EmptyResponse: config.EmptyResponse,
}
return
}
// UpdatePrompt 更新提示词(配置不存在时自动重建)
// 参数: ctx - 上下文req - 更新提示词请求(包含客服账号名和新提示词)
// 返回: res - 更新结果信息err - 错误信息
// 功能: 更新RAGFlow对话的系统提示词并同步到RAGFlow影响AI回复风格
func (s *ragflowConfig) UpdatePrompt(ctx context.Context, req *dto.UpdatePromptReq) (res *dto.UpdatePromptRes, err error) {
// 1. 查询当前配置
config, err := dao.RAGFlowConfig.FindByAccountName(ctx, req.AccountName)
if err != nil {
return nil, gerror.Wrap(err, "查询RAGFlow配置失败")
}
// 配置不存在,尝试自动重建
if config == nil {
g.Log().Warningf(ctx, "客服账号 %s 的RAGFlow配置不存在尝试自动重建...", req.AccountName)
// 查询客服账号获取platform
account, findErr := dao.CustomerServiceAccount.FindByAccountName(ctx, req.AccountName)
if findErr != nil {
return nil, gerror.Wrapf(findErr, "查询客服账号失败")
}
if account == nil {
return nil, gerror.Newf("客服账号 %s 不存在无法自动创建RAGFlow配置", req.AccountName)
}
// 调用重建逻辑
recreateReq := &dto.RecreateRAGFlowConfigReq{
AccountName: req.AccountName,
Platform: account.Platform,
}
if recreateErr := CustomerServiceAccount.RecreateRAGFlowConfig(ctx, recreateReq); recreateErr != nil {
return nil, gerror.Wrapf(recreateErr, "自动重建RAGFlow配置失败")
}
// 重新查询配置
config, err = dao.RAGFlowConfig.FindByAccountName(ctx, req.AccountName)
if err != nil {
return nil, gerror.Wrap(err, "重建后查询配置失败")
}
if config == nil {
return nil, gerror.New("重建后仍无法获取配置")
}
g.Log().Infof(ctx, "成功自动重建客服账号 %s 的RAGFlow配置", req.AccountName)
}
// 2. 更新字段并自动追加知识库引用
// 用户只需输入业务话术,后端自动在末尾追加标准的知识库引用格式
userPrompt := req.Prompt
// 先移除所有可能的知识库引用部分使用查找并截断方式与List方法保持一致
knowledgePatterns := []string{
"\n\n以下是知识库",
"\n\n以下是知识库",
"\n以下是知识库",
"\n以下是知识库",
}
// 找到最早出现的知识库引用位置并截断
for _, pattern := range knowledgePatterns {
if idx := gstr.Pos(userPrompt, pattern); idx >= 0 {
userPrompt = gstr.SubStr(userPrompt, 0, idx)
break
}
}
// 去除末尾多余的空行
userPrompt = gstr.TrimRight(userPrompt)
// 统一追加标准的知识库引用格式
userPrompt = userPrompt + "\n\n以下是知识库\n{knowledge}\n以上是知识库。"
config.Prompt = userPrompt
if req.SimilarityThreshold != nil {
config.SimilarityThreshold = *req.SimilarityThreshold
}
if req.KeywordsSimilarityWeight != nil {
config.KeywordsSimilarityWeight = *req.KeywordsSimilarityWeight
}
if req.TopN != nil {
config.TopN = *req.TopN
}
if req.EmptyResponse != nil {
config.EmptyResponse = *req.EmptyResponse
}
now := gtime.Now().Time
config.UpdatedAt = &now // 取地址赋值给指针类型
// 3. 调用RAGFlow API更新Chat配置
ragflowClient := ragflow.GetGlobalClient()
updateReq := &ragflow.UpdateChatReq{
Prompt: &ragflow.PromptConfig{
Prompt: config.Prompt,
SimilarityThreshold: config.SimilarityThreshold,
KeywordsSimilarityWeight: config.KeywordsSimilarityWeight,
TopN: config.TopN,
EmptyResponse: config.EmptyResponse,
Variables: []map[string]interface{}{
{
"key": "knowledge",
"optional": true,
},
},
},
DatasetIds: []string{config.DatasetId},
}
if err := ragflowClient.UpdateChat(ctx, config.ChatId, updateReq); err != nil {
return nil, gerror.Wrapf(err, "调用RAGFlow API更新Chat失败")
}
// 4. 更新MongoDB
if err := dao.RAGFlowConfig.UpdateEntity(ctx, config); err != nil {
return nil, gerror.Wrap(err, "更新MongoDB配置失败")
}
g.Log().Infof(ctx, "RAGFlow配置更新成功: account_name=%s, chat_id=%s",
config.AccountName, config.ChatId)
res = &dto.UpdatePromptRes{
Success: true,
Message: "提示词更新成功",
}
return
}

View File

@@ -0,0 +1,85 @@
// Package service - 会话服务
// 功能:用户会话管理、状态维护
package service
import (
"context"
"customer-server/dao"
"gitea.com/red-future/common/jaeger"
"gitea.com/red-future/common/rabbitmq"
"gitea.com/red-future/common/redis"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/os/glog"
)
// sessionService 会话服务(操作 session 表)
type sessionService struct{}
// SessionService 会话服务单例
var SessionService = new(sessionService)
// ============== RabbitMQ 消费者(归档延时消息)==============
// SessionArchiveConsumer 会话归档消费者
type SessionArchiveConsumer struct {
consumer *rabbitmq.Consumer
}
// NewSessionArchiveConsumer 创建会话归档消费者
func NewSessionArchiveConsumer(ctx context.Context) *SessionArchiveConsumer {
queueName := GetConfigString(ctx, "archive.queue")
return &SessionArchiveConsumer{
consumer: rabbitmq.NewConsumer(queueName, handleSessionArchive),
}
}
// Start 启动消费者
func (c *SessionArchiveConsumer) Start(ctx context.Context) (err error) {
glog.Info(ctx, "会话归档消费者启动...")
return c.consumer.Start(ctx)
}
// Stop 停止消费者
func (c *SessionArchiveConsumer) Stop(ctx context.Context) {
c.consumer.Stop(ctx)
}
// handleSessionArchive 处理会话归档消息
func handleSessionArchive(ctx context.Context, body []byte) (err error) {
ctx, span := jaeger.NewSpan(ctx, "consumer.session.archive")
defer span.End()
var msg redis.ArchiveMessage
if err = gjson.DecodeTo(body, &msg); err != nil {
jaeger.RecordError(ctx, err, "解析归档消息失败")
return
}
glog.Infof(ctx, "收到归档消息 - 用户: %s, Session: %s", msg.UserId, msg.SessionId)
// 检查用户是否在归档发送后有活跃60分钟内
isActive, err := redis.IsUserActive(ctx, msg.UserId, int64(redis.GetArchiveDelay()))
if err != nil {
jaeger.RecordError(ctx, err, "检查用户活跃状态失败")
return
}
if isActive {
glog.Infof(ctx, "用户 %s 在归档期间有活跃,跳过归档", msg.UserId)
return
}
// 执行归档
if err = dao.Session.Archive(ctx, msg.UserId, msg.SessionId); err != nil {
jaeger.RecordError(ctx, err, "归档会话失败")
return
}
// 清除 Session 缓存需要tenantId
// TODO: ArchiveMessage需要添加TenantId字段
redis.DelSessionCache(ctx, msg.TenantId, msg.UserId)
glog.Infof(ctx, "会话已归档 - 用户: %s, Session: %s", msg.UserId, msg.SessionId)
return
}

View File

@@ -0,0 +1,653 @@
// Package service - 话术服务
// 功能:话术的增删改查、绑定/解绑客服账号、同步到RAGFlow、重试消费者
package service
import (
"context"
"customer-server/dao"
"customer-server/model/dto"
"customer-server/model/entity"
"customer-server/util"
"fmt"
"gitea.com/red-future/common/db/mongo"
"gitea.com/red-future/common/jaeger"
"gitea.com/red-future/common/rabbitmq"
"gitea.com/red-future/common/ragflow"
"gitea.com/red-future/common/redis"
"gitea.com/red-future/common/utils"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/glog"
"github.com/gogf/gf/v2/os/grpool"
"github.com/gogf/gf/v2/os/gtime"
"github.com/gogf/gf/v2/text/gstr"
"github.com/gogf/gf/v2/util/gconv"
"go.mongodb.org/mongo-driver/v2/bson"
)
var (
Speechcraft = new(speechcraft)
speechcraftGrpool = grpool.New(50) // 文档解析协程池最大50并发
)
type speechcraft struct{}
// Add 添加话术
// 参数: ctx - 上下文req - 添加话术请求(包含标题、内容、方向、标签等)
// 返回: res - 添加成功后的话术ID和RAGFlow文档IDerr - 错误信息
// 功能: 创建话术记录并自动上传到RAGFlow租户知识库支持去重检查
func (s *speechcraft) Add(ctx context.Context, req *dto.AddSpeechcraftReq) (res *dto.AddSpeechcraftRes, err error) {
// 去重检查同一租户下tag唯一
if req.Tag != "" {
existing, err := dao.Speechcraft.FindByTag(ctx, req.Tag)
if err != nil {
return nil, gerror.Wrap(err, "检查话术重复失败")
}
if existing != nil {
return nil, gerror.Newf("话术tag已存在tag=%s, id=%s", req.Tag, existing.Id.Hex())
}
}
// 先从token或请求获取租户信息在Insert之前
var tenantId string
if req.TenantId != nil {
tenantId = gconv.String(req.TenantId)
g.Log().Debugf(ctx, "使用请求中的TenantId: %v", req.TenantId)
} else {
user, err := util.GetTenantInfo(ctx)
if err != nil {
return nil, gerror.Wrap(err, "获取租户信息失败")
}
tenantId = gconv.String(user.TenantId)
if tenantId == "" {
return nil, gerror.New("租户ID为空")
}
}
// 查询租户知识库ID
datasetId, err := dao.RAGFlowConfig.FindDatasetIdByTenant(ctx, tenantId)
if err != nil || datasetId == "" {
return nil, gerror.Newf("租户知识库不存在,请先创建客服账号: tenant_id=%s", tenantId)
}
data := &entity.Speechcraft{}
if err = utils.Struct(req, data); err != nil {
return
}
// 设置基础字段
now := gtime.Now().Time
data.CreatedAt = &now // 取地址赋值给指针类型
data.UpdatedAt = &now // 取地址赋值给指针类型
data.IsDeleted = false
// 统一使用string类型存储tenantId到MongoDB
data.TenantId = tenantId
// 使用DAO封装的Insert方法
id, err := dao.Speechcraft.Insert(ctx, data)
if err != nil {
return nil, gerror.Wrap(err, "插入话术失败")
}
data.Id = &id // 取地址赋值给指针类型
// 同步上传到RAGFlow
ragflowClient := ragflow.GetGlobalClient()
if ragflowClient == nil {
// 回滚:删除刚插入的话术
dao.MongoDAO.Delete(ctx, bson.M{"_id": data.Id}, entity.SpeechcraftCollection)
return nil, gerror.New("RAGFlow客户端未初始化请检查配置")
}
g.Log().Infof(ctx, "准备上传话术到RAGFlow: speechcraft_id=%s, dataset_id=%s, direction=%s, tag=%s",
data.Id.Hex(), datasetId, data.Direction, data.Tag)
filename := fmt.Sprintf("%s_%s.txt", data.Direction, data.Tag)
documentId, err := ragflowClient.UploadDocumentFromText(ctx, datasetId, data.Content, filename)
if err != nil {
// 回滚:删除刚插入的话术
dao.MongoDAO.Delete(ctx, bson.M{"_id": data.Id}, entity.SpeechcraftCollection)
g.Log().Errorf(ctx, "话术上传RAGFlow失败: speechcraft_id=%s, dataset_id=%s, error=%v", data.Id.Hex(), datasetId, err)
jaeger.RecordError(ctx, err, "话术上传RAGFlow失败")
return nil, gerror.Wrap(err, "文档上传到知识库失败")
}
// 异步触发解析grpool自动管理goroutine生命周期WithoutCancel保留追踪避免取消
speechcraftGrpool.Add(ctx, func(ctx context.Context) {
parseCtx := context.WithoutCancel(ctx)
if err := ragflowClient.ParseDocuments(parseCtx, datasetId, []string{documentId}); err != nil {
g.Log().Errorf(parseCtx, "文档解析失败: document_id=%s, error=%v", documentId, err)
} else {
g.Log().Infof(parseCtx, "文档解析成功: document_id=%s", documentId)
}
})
// 更新MongoDB的RagSyncRecords数组使用空accountName表示租户级文档
syncTime := gtime.Now().Format("Y-m-d H:i:s")
record := entity.RagSyncRecord{
AccountName: "", // 空表示租户级文档
RagDocumentId: documentId,
RagSyncStatus: "synced",
SyncTime: syncTime,
RetryCount: 0,
}
filter := bson.M{"_id": data.Id}
update := bson.M{
"$set": bson.M{
"ragSyncRecords": []entity.RagSyncRecord{record},
"ragLastSyncTime": syncTime,
"updatedAt": gtime.Now().Time,
},
}
if _, _, err = dao.MongoDAO.UpdateOne(ctx, filter, update, entity.SpeechcraftCollection); err != nil {
g.Log().Errorf(ctx, "更新话术RagSyncRecords失败: %v", err)
// 不回滚,文档已上传成功
}
g.Log().Infof(ctx, "话术添加成功并上传到知识库: speechcraft_id=%s, document_id=%s", data.Id.Hex(), documentId)
res = &dto.AddSpeechcraftRes{Id: data.Id.Hex()}
return
}
// Update 更新话术
// 参数: ctx - 上下文req - 更新话术请求包含话术ID和待更新字段
// 返回: err - 错误信息
// 功能: 更新话术内容并同步到RAGFlow支持文档删除重建
func (s *speechcraft) Update(ctx context.Context, req *dto.UpdateSpeechcraftReq) (err error) {
return dao.Speechcraft.Update(ctx, req)
}
// Delete 删除话术
// 参数: ctx - 上下文req - 删除话术请求包含话术ID
// 返回: err - 错误信息
// 功能: 逻辑删除话术记录并从RAGFlow移除对应文档
func (s *speechcraft) Delete(ctx context.Context, req *dto.DeleteSpeechcraftReq) (err error) {
g.Log().Infof(ctx, "[Delete] 开始删除话术 - speechcraftId: %s", req.Id)
// 1. 查询话术获取RAGFlow同步记录使用原生查询避免租户过滤
objectId, err := bson.ObjectIDFromHex(req.Id)
if err != nil {
return gerror.Wrap(err, "无效的话术ID")
}
var speechcraft entity.Speechcraft
filter := bson.M{"_id": objectId, "isDeleted": false}
err = dao.MongoDAO.FindOne(ctx, filter, &speechcraft, entity.SpeechcraftCollection)
if err != nil {
if err.Error() == "mongo: no documents in result" {
return gerror.New("话术不存在")
}
return gerror.Wrap(err, "查询话术失败")
}
g.Log().Infof(ctx, "[Delete] 查询到话术 - tag: %s, ragSyncRecords数量: %d", speechcraft.Tag, len(speechcraft.RagSyncRecords))
// 2. 删除RAGFlow中的文档
if len(speechcraft.RagSyncRecords) > 0 {
ragflowClient := ragflow.GetGlobalClient()
if ragflowClient != nil {
tenantId := gconv.String(speechcraft.TenantId)
// 查询租户的dataset_id
datasetId, err := dao.RAGFlowConfig.FindDatasetIdByTenant(ctx, tenantId)
if err != nil {
g.Log().Warningf(ctx, "查询租户知识库ID失败: %v", err)
} else if datasetId != "" {
// 收集所有需要删除的document_id
var documentIds []string
for _, record := range speechcraft.RagSyncRecords {
if record.RagDocumentId != "" {
documentIds = append(documentIds, record.RagDocumentId)
}
}
// 批量删除RAGFlow文档
if len(documentIds) > 0 {
if err := ragflowClient.DeleteDocument(ctx, datasetId, documentIds); err != nil {
g.Log().Errorf(ctx, "删除RAGFlow文档失败: %v, document_ids: %v", err, documentIds)
// 不阻断删除流程,记录错误后继续
} else {
g.Log().Infof(ctx, "成功删除RAGFlow文档: count=%d", len(documentIds))
}
}
}
}
}
// 3. 软删除MongoDB记录
return dao.Speechcraft.Delete(ctx, req)
}
// List 获取话术列表
// 参数: ctx - 上下文req - 列表查询请求(支持分页、关键词搜索、平台筛选)
// 返回: res - 话术列表及分页信息err - 错误信息
// 功能: 分页查询话术记录,支持按标题、内容模糊搜索和平台筛选
func (s *speechcraft) List(ctx context.Context, req *dto.ListSpeechcraftReq) (res *dto.ListSpeechcraftRes, err error) {
list, total, err := dao.Speechcraft.List(ctx, req)
if err != nil {
return
}
res = &dto.ListSpeechcraftRes{
List: list,
Total: int(total),
}
return
}
// Match 话术匹配(核心方法)
// 根据用户当前阶段、行为、输入内容匹配话术
//
// func (s *speechcraft) Match(ctx context.Context, userId, platform, content, status string) (answer string, nextStage int, err error) {
// // 1. 获取用户当前阶段
// state, err := dao.UserStage.GetOrCreate(ctx, userId, platform)
func (s *speechcraft) Match(ctx context.Context, userId, platform, tenantId, content, status string) (answer string, nextStage int, err error) {
// 1. 获取用户当前状态Redis5分钟过期
userState, err := redis.GetUserState(ctx, userId, platform)
if err != nil {
jaeger.RecordError(ctx, err, "获取用户状态失败")
return
}
glog.Infof(ctx, "话术匹配 - 用户: %s, 当前阶段: %d, 行为: %s, 内容: %s", userId, userState.Stage, status, content)
// 2. 状态3发卡片状态持续提示用户添加联系方式
if userState.Stage == 3 {
answer = "请加一下卡片的联系方式,进行更专业的咨询" // TODO: 替换为实际卡片内容
nextStage = 3 // 保持状态3
glog.Infof(ctx, "用户处于发卡片状态 - 用户: %s", userId)
return
}
// 4. 检测用户是否想要联系方式5次内立即发卡
if s.isRequestingContact(content) {
glog.Infof(ctx, "检测到联系方式请求关键词 - 用户: %s, 内容: %s", userId, content)
return s.handleCardRequest(ctx, userId, platform)
}
// 5. 所有其他消息直接走RAGFlow话术已上传到知识库
// 后端只负责开场白WebSocket连接时发送+ 发卡片(上面的逻辑)
glog.Infof(ctx, "无话术匹配转发到RAGFlow - 用户: %s, 内容: %s", userId, content)
nextStage = 0
if updateErr := redis.SetUserStage(ctx, userId, platform, 0); updateErr != nil {
jaeger.RecordError(ctx, updateErr, "更新用户阶段为0失败")
}
// answer为空调用方会走RAGFlow
return
}
// ProcessAndPublish 处理用户消息并推送到Redis Stream
// 参数: ctx - 上下文userId - 用户IDplatform - 平台tenantId - 租户IDcontent - 消息内容status - 用户行为状态accountName - 客服账号名
// 返回: isPushed - 是否成功推送话术匹配结果err - 错误信息
// 功能: 尝试匹配话术匹配成功则直接推送话术内容失败则转发到Redis Stream由RAGFlow处理
func (s *speechcraft) ProcessAndPublish(ctx context.Context, userId, platform, tenantId, content, status, accountName string) (isPushed bool, err error) {
// 1. 话术匹配
answer, _, err := s.Match(ctx, userId, platform, tenantId, content, status)
if err != nil {
return
}
// 2. 未匹配到话术直接推送到Redis Stream
if answer == "" {
glog.Infof(ctx, "话术未匹配,转发到 AI 模型 - 用户: %s, 客服账号: %s", userId, accountName)
// 获取当前实例的动态响应队列名(自动生成,支持多实例部署)
baseQueue := GetConfigString(ctx, "rabbitmq.responseQueue")
replyQueue := rabbitmq.GetInstanceQueueName(baseQueue)
messageId := userId + "_" + gconv.String(gtime.Now().TimestampNano())
// 构造Stream消息
msg := &redis.SendStreamMessage{
UserId: userId,
Platform: platform,
TenantId: tenantId,
AccountName: accountName,
Content: content,
Timestamp: gtime.Now().Timestamp(),
MessageId: messageId,
ReplyQueue: replyQueue,
}
// 检查是否有session缓存无缓存说明已归档需要读取历史
if sessionId, _ := redis.GetSessionCache(ctx, tenantId, userId); sessionId == "" {
if history, histErr := dao.Conversation.GetRecentHistory(ctx, userId, redis.GetHistoryContextLimit()); histErr == nil && len(history) > 0 {
msg.History = history
glog.Infof(ctx, "用户已归档,读取 %d 轮历史对话 - 用户: %s", len(history), userId)
}
}
// 写入Redis Stream
var streamMsgId string
streamMsgId, err = redis.AddToStream(ctx, redis.RAGFlowRequestStreamKey, msg)
if err != nil {
jaeger.RecordError(ctx, err, "写入Stream失败")
return
}
glog.Infof(ctx, "消息已写入Stream - StreamID: %s, 用户: %s", streamMsgId, userId)
isPushed = false // 未匹配话术消息转发到AI处理
return
}
// 3. 匹配到话术,直接推送 WebSocket无需经过 RabbitMQ
if err = WebSocket.PushRAGFlowResponse(ctx, tenantId, userId, platform, answer); err != nil {
jaeger.RecordError(ctx, err, "推送话术响应失败")
return
}
glog.Infof(ctx, "话术响应已推送 - 用户: %s, 话术长度: %d", userId, len(answer))
isPushed = true // 已直接推送响应
return
}
// ResetUserStage 重置用户阶段
func (s *speechcraft) ResetUserStage(ctx context.Context, userId, platform string) (err error) {
return dao.UserStage.Reset(ctx, userId, platform)
}
// isRequestingContact 检测用户是否想要联系方式(触发立即发卡)
func (s *speechcraft) isRequestingContact(content string) bool {
// 联系方式相关关键词
contactKeywords := []string{
"联系方式", "联系你", "联系",
"微信", "VX", "vx", "wx", "WX",
"电话", "手机号", "电话号码",
"怎么找你", "如何联系", "加你",
"私信", "私聊",
}
// 清理内容(去除空格、标点)
cleanContent := gstr.Trim(content)
cleanContent = gstr.ToLower(cleanContent)
for _, keyword := range contactKeywords {
if gstr.Contains(cleanContent, gstr.ToLower(keyword)) {
return true
}
}
return false
}
// handleCardRequest 处理用户请求联系方式(立即发卡片)
func (s *speechcraft) handleCardRequest(ctx context.Context, userId, platform string) (answer string, nextStage int, err error) {
// 更新用户状态为3发卡片状态
if err = redis.SetUserStage(ctx, userId, platform, 3); err != nil {
jaeger.RecordError(ctx, err, "更新用户状态为3失败")
return
}
// 返回卡片话术
answer = "请加一下卡片的联系方式,进行更专业的咨询" // TODO: 替换为实际卡片内容
nextStage = 3
glog.Infof(ctx, "用户请求联系方式,立即发送卡片 - 用户: %s", userId)
return
}
// BindToCustomerServices 绑定话术到客服账号
// 参数: ctx - 上下文req - 绑定请求包含话术ID和客服账号名列表
// 返回: res - 绑定结果成功和失败的账号列表err - 错误信息
// 功能: 将话术同步到指定客服账号的RAGFlow知识库批量处理并返回每个账号的结果
func (s *speechcraft) BindToCustomerServices(ctx context.Context, req *dto.BindSpeechcraftReq) (res *dto.BindSpeechcraftRes, err error) {
res = &dto.BindSpeechcraftRes{}
// 0. 参数验证
if len(req.AccountNames) == 0 {
return nil, gerror.New("客服账号ID列表不能为空")
}
// 1. 查询话术(验证存在性和获取租户信息)
r := g.RequestFromCtx(ctx)
if r != nil {
r.SetParam("accountName", req.AccountNames[0])
}
speechcraft, err := dao.Speechcraft.GetById(ctx, req.SpeechcraftId)
if err != nil {
return nil, gerror.Wrapf(err, "查询话术失败")
}
if speechcraft == nil {
return nil, gerror.New("话术不存在")
}
speechcraftTenantId := gconv.String(speechcraft.TenantId)
// 2. 遍历客服账号更新每个账号的speechcraft_ids
var newBindings []string
var alreadyBound []string
var notFound []string
for _, csId := range req.AccountNames {
// 查询客服账号
csAccount, err := dao.CustomerServiceAccount.FindByAccountName(ctx, csId)
if err != nil || csAccount == nil {
notFound = append(notFound, csId)
g.Log().Warningf(ctx, "客服账号 %s 不存在或已删除,跳过", csId)
continue
}
// 租户隔离校验
accountTenantId := gconv.String(csAccount.TenantId)
if speechcraftTenantId != accountTenantId {
g.Log().Warningf(ctx, "话术和客服账号不属于同一租户,跳过: speechcraft_tenant=%s, account_tenant=%s",
speechcraftTenantId, accountTenantId)
notFound = append(notFound, csId)
continue
}
// 检查是否已绑定
alreadyExists := false
for _, existingId := range csAccount.SpeechcraftIds {
if existingId == req.SpeechcraftId {
alreadyExists = true
break
}
}
if alreadyExists {
alreadyBound = append(alreadyBound, csId)
g.Log().Warningf(ctx, "客服账号 %s 已绑定该话术,跳过", csId)
continue
}
// 添加到speechcraft_ids列表
csAccount.SpeechcraftIds = append(csAccount.SpeechcraftIds, req.SpeechcraftId)
// 更新数据库
filter := bson.M{"_id": csAccount.Id, "isDeleted": false}
update := bson.M{"$set": bson.M{"speechcraftIds": csAccount.SpeechcraftIds}}
if _, err := mongo.DB().Update(ctx, filter, update, entity.CustomerServiceAccountCollection); err != nil {
g.Log().Errorf(ctx, "更新客服账号绑定失败: %s, error=%v", csId, err)
notFound = append(notFound, csId)
continue
}
newBindings = append(newBindings, csId)
}
// 3. 如果没有新的绑定,直接返回
if len(newBindings) == 0 {
res.SuccessCount = 0
res.AlreadyBound = alreadyBound
res.NotFound = notFound
if len(alreadyBound) > 0 && len(notFound) > 0 {
res.Message = "部分客服账号已绑定,部分不存在"
} else if len(alreadyBound) > 0 {
res.Message = "所有客服账号已绑定,无需重复绑定"
} else if len(notFound) > 0 {
res.Message = "所有客服账号都不存在或租户不匹配"
}
return res, nil
}
// 6. 同步到RAGFlow自动创建知识库
for _, csId := range newBindings {
// 获取客服账号信息以获取tenant_id
csAccount, err := dao.CustomerServiceAccount.FindByAccountName(ctx, csId)
if err != nil || csAccount == nil {
g.Log().Errorf(ctx, "获取客服账号信息失败: %s", csId)
continue
}
// 同步到RAGFlow会自动创建知识库
tenantId := gconv.String(csAccount.TenantId)
g.Log().Infof(ctx, "客服账号租户信息: cs_id=%s, tenant_id=%v, tenant_id_type=%T", csId, csAccount.TenantId, csAccount.TenantId)
_, err = s.SyncToRAGFlow(ctx, req.SpeechcraftId, csId, tenantId)
if err != nil {
g.Log().Errorf(ctx, "同步到RAGFlow失败: speechcraft_id=%s, cs_id=%s, error=%v", req.SpeechcraftId, csId, err)
// 不阻断绑定流程,失败会进入重试队列
}
}
res.SuccessCount = len(newBindings)
res.AlreadyBound = alreadyBound
res.NotFound = notFound
// 生成详细的响应消息
if len(alreadyBound) > 0 || len(notFound) > 0 {
res.Message = fmt.Sprintf("成功绑定%d个", len(newBindings))
if len(alreadyBound) > 0 {
res.Message += fmt.Sprintf("%d个已绑定", len(alreadyBound))
}
if len(notFound) > 0 {
res.Message += fmt.Sprintf("%d个不存在", len(notFound))
}
} else {
res.Message = fmt.Sprintf("成功绑定%d个客服账号", len(newBindings))
}
return
}
// UnbindFromCustomerService 从客服账号解绑话术
// 参数: ctx - 上下文req - 解绑请求包含话术ID和客服账号名
// 返回: res - 解绑结果信息err - 错误信息
// 功能: 从客服账号的RAGFlow知识库中删除话术文档
func (s *speechcraft) UnbindFromCustomerService(ctx context.Context, req *dto.UnbindSpeechcraftReq) (res *dto.UnbindSpeechcraftRes, err error) {
res = &dto.UnbindSpeechcraftRes{}
// 1. 验证话术存在
speechcraft, err := dao.Speechcraft.GetById(ctx, req.SpeechcraftId)
if err != nil {
return nil, gerror.Wrapf(err, "查询话术失败")
}
if speechcraft == nil {
return nil, gerror.New("话术不存在")
}
// 2. 查询客服账号
csAccount, err := dao.CustomerServiceAccount.FindByAccountName(ctx, req.AccountName)
if err != nil || csAccount == nil {
res.Success = false
res.Message = "客服账号不存在"
return res, nil
}
// 3. 从 speechcraft_ids 中移除话术ID
var newSpeechcraftIds []string
found := false
for _, scId := range csAccount.SpeechcraftIds {
if scId == req.SpeechcraftId {
found = true
continue
}
newSpeechcraftIds = append(newSpeechcraftIds, scId)
}
if !found {
res.Success = false
res.Message = "未找到该绑定关系"
return res, nil
}
// 4. 更新数据库
filter := bson.M{"_id": csAccount.Id, "isDeleted": false}
update := bson.M{"$set": bson.M{"speechcraftIds": newSpeechcraftIds}}
if _, err := mongo.DB().Update(ctx, filter, update, entity.CustomerServiceAccountCollection); err != nil {
return nil, gerror.Wrapf(err, "解绑失败")
}
res.Success = true
res.Message = "解绑成功"
return
}
// SyncToRAGFlow 同步话术到RAGFlow
// 参数: ctx - 上下文speechcraftId - 话术IDaccountName - 客服账号名tenantId - 租户ID
// 返回: documentId - RAGFlow文档IDerr - 错误信息
// 功能: 将话术上传到指定客服账号的RAGFlow知识库失败时自动加入重试队列
func (s *speechcraft) SyncToRAGFlow(ctx context.Context, speechcraftId, accountName, tenantId string) (documentId string, err error) {
// 1. 查询话术
speechcraft, err := dao.Speechcraft.GetById(ctx, speechcraftId)
if err != nil {
return "", gerror.Wrapf(err, "查询话术失败")
}
if speechcraft == nil {
return "", gerror.New("话术不存在")
}
// 2. 确保知识库存在获取真实的datasetId
datasetId, err := s.ensureDatasetExists(ctx, tenantId, "话术")
if err != nil {
return "", gerror.Wrapf(err, "确保知识库存在失败")
}
// 3. 调用RAGFlow上传文档
ragflowClient := ragflow.GetGlobalClient()
filename := fmt.Sprintf("%s_%s_%s.txt", speechcraft.Direction, speechcraft.Tag, accountName)
documentId, err = ragflowClient.UploadDocumentFromText(ctx, datasetId, speechcraft.Content, filename)
if err != nil {
jaeger.RecordError(ctx, err, "话术上传RAGFlow失败")
return "", gerror.Wrap(err, "话术上传RAGFlow失败")
}
// 3.1 上传成功后立即调用解析接口
g.Log().Infof(ctx, "文档上传成功,开始解析: document_id=%s", documentId)
if err = ragflowClient.ParseDocuments(ctx, datasetId, []string{documentId}); err != nil {
// 解析失败只记录日志,不影响绑定流程(文档已上传,可以手动重试解析)
g.Log().Errorf(ctx, "文档解析失败: document_id=%s, error=%v", documentId, err)
jaeger.RecordError(ctx, err, "文档解析失败")
} else {
g.Log().Infof(ctx, "文档解析请求已发送: document_id=%s", documentId)
}
// 4. 更新MongoDB的RagSyncRecord
now := gtime.Now().Format("Y-m-d H:i:s")
updated := false
for i := range speechcraft.RagSyncRecords {
record := &speechcraft.RagSyncRecords[i]
if record.AccountName == accountName {
record.RagDocumentId = documentId
record.RagSyncStatus = "synced"
record.SyncTime = now
record.RetryCount = 0
updated = true
break
}
}
// 如果没有找到记录,新增
if !updated {
speechcraft.RagSyncRecords = append(speechcraft.RagSyncRecords, entity.RagSyncRecord{
AccountName: accountName,
RagDocumentId: documentId,
RagSyncStatus: "synced",
SyncTime: now,
RetryCount: 0,
})
}
if err = dao.Speechcraft.UpdateEntity(ctx, speechcraft); err != nil {
return "", gerror.Wrapf(err, "更新话术同步状态失败")
}
// 注意不再更新Chat的datasetIds因为创建Chat时已经绑定了知识库
// 话术文档上传到知识库后Chat会自动使用该知识库的内容
glog.Infof(ctx, "话术同步成功: speechcraft_id=%s, account_name=%s, document_id=%s", speechcraftId, accountName, documentId)
return documentId, nil
}
// ensureDatasetExists 已废弃,改用公共方法 EnsureTenantDataset
// 保留此方法仅为兼容性,直接调用公共方法
func (s *speechcraft) ensureDatasetExists(ctx context.Context, tenantId, datasetType string) (datasetId string, err error) {
return EnsureTenantDataset(ctx, tenantId)
}

101
service/webhook_service.go Normal file
View File

@@ -0,0 +1,101 @@
// Package service - Webhook服务
// 功能接收并处理平台小红书、抖音的webhook消息
package service
import (
"context"
"customer-server/dao"
"customer-server/model/dto"
"gitea.com/red-future/common/jaeger"
"gitea.com/red-future/common/rabbitmq"
"gitea.com/red-future/common/redis"
"gitea.com/red-future/common/utils"
"github.com/gogf/gf/v2/os/glog"
"github.com/gogf/gf/v2/os/gtime"
"github.com/gogf/gf/v2/util/gconv"
)
var Webhook = new(webhookService)
type webhookService struct{}
// Receive 接收平台消息并写入队列
func (s *webhookService) Receive(ctx context.Context, req *dto.WebhookReceiveReq) (res *dto.WebhookReceiveRes, err error) {
glog.Infof(ctx, "收到 Webhook 消息 - 平台: %s, 用户: %s, 内容: %s", req.Platform, req.UserId, req.Content)
// 生成消息ID
now := gtime.Now()
messageId := req.Platform + "_" + req.UserId + "_" + gconv.String(now.TimestampNano())
if req.MsgId != "" {
messageId = req.MsgId // 使用平台消息ID便于去重
}
// 从 token 获取租户ID
var tenantId string
if user, userErr := utils.GetUserInfo(ctx); userErr == nil {
tenantId = gconv.String(user.TenantId)
}
// 构造消息
userId := req.Platform + "_" + req.UserId // 添加平台前缀
// 获取当前实例的动态响应队列名(自动生成,支持多实例部署)
baseQueue := GetConfigString(ctx, "rabbitmq.responseQueue")
replyQueue := rabbitmq.GetInstanceQueueName(baseQueue)
msg := &redis.SendStreamMessage{
UserId: userId,
TenantId: tenantId,
Content: req.Content,
Timestamp: now.Timestamp(),
MessageId: messageId,
Platform: req.Platform,
AccountId: req.AccountId,
ReplyQueue: replyQueue,
}
// 检查是否有 session 缓存,无缓存说明已归档,需要读取历史
if sessionId, _ := redis.GetSessionCache(ctx, tenantId, userId); sessionId == "" {
if history, histErr := dao.Conversation.GetRecentHistory(ctx, userId, redis.GetHistoryContextLimit()); histErr == nil && len(history) > 0 {
msg.History = history
glog.Infof(ctx, "用户已归档,读取 %d 轮历史对话 - 用户: %s", len(history), userId)
}
}
// 写入 Redis Stream
streamMsgId, err := redis.AddToStream(ctx, redis.RAGFlowRequestStreamKey, msg)
if err != nil {
jaeger.RecordError(ctx, err, "写入 Stream 失败")
return
}
glog.Infof(ctx, "消息已写入 Stream - MessageID: %s", streamMsgId)
res = &dto.WebhookReceiveRes{
Success: true,
MsgId: streamMsgId,
}
return
}
// GetHistory 查询用户对话记录
func (s *webhookService) GetHistory(ctx context.Context, req *dto.ConversationListReq) (res *dto.ConversationListRes, err error) {
list, err := dao.Conversation.FindByUserId(ctx, req.UserId, req.Limit)
if err != nil {
jaeger.RecordError(ctx, err, "查询对话记录失败")
return
}
res = &dto.ConversationListRes{
List: make([]*dto.ConversationItem, 0, len(list)),
}
for _, item := range list {
res.List = append(res.List, &dto.ConversationItem{
Question: item.Question,
Answer: item.Answer,
MsgTime: gtime.New(item.MsgTime).Format("Y-m-d H:i:s"),
SessionId: item.SessionId,
})
}
return
}

View File

@@ -0,0 +1,506 @@
// Package service - WebSocket服务
// 功能WebSocket连接管理、消息推送、心跳维护
package service
import (
"context"
"customer-server/dao"
"customer-server/model/dto"
"customer-server/model/entity"
"customer-server/util"
"errors"
"net/http"
commonMongo "gitea.com/red-future/common/db/mongo"
"gitea.com/red-future/common/jaeger"
"gitea.com/red-future/common/rabbitmq"
"gitea.com/red-future/common/redis"
"gitea.com/red-future/common/utils"
"github.com/gogf/gf/v2/container/gmap"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/os/glog"
"github.com/gogf/gf/v2/os/gtime"
"github.com/gogf/gf/v2/util/gconv"
"github.com/gorilla/websocket"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
// WebSocket 全局单例
var WebSocket = &websocketService{
connections: gmap.NewStrAnyMap(true),
upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true // 允许跨域
},
},
}
// GoFrame 并发安全 Map
type websocketService struct {
connections *gmap.StrAnyMap
upgrader websocket.Upgrader
}
// key: userId_platform
// wsConnection WebSocket 连接信息
type wsConnection struct {
UserId string
Platform string
TenantId string
AccountName string // 客服账号ID
Conn *websocket.Conn
CreatedAt int64
}
// 租户ID缓存 Key 前缀和过期时间
const (
tenantCacheKeyPrefix = "tenant:custsvc:"
tenantCacheExpire = 300 // 5分钟
)
// resolveTenantId 获取租户ID兼容仅有accountName的场景
// 参数: ctx - 上下文r - HTTP请求对象
// 返回: tenantId - 租户IDerr - 错误信息
// 功能: 优先从token获取其次从客服账号查询支持缓存
func (s *websocketService) resolveTenantId(ctx context.Context, r *ghttp.Request) (tenantId string, err error) {
// 1. 优先从 token 获取
if user, userErr := util.GetTenantInfo(ctx); userErr == nil {
if id := gconv.String(user.TenantId); id != "" {
return id, nil
}
}
if r == nil {
return "", gerror.New("无法获取租户信息:缺少请求上下文")
}
// 2. 从请求参数获取 accountName
custId := r.Get("accountName").String()
if custId == "" {
custId = r.Get("account_name").String()
}
if custId == "" {
return "", gerror.New("缺少 accountName 参数")
}
// 2. 从 Redis 缓存查询
cacheKey := tenantCacheKeyPrefix + custId
if cached, _ := redis.RedisClient().Get(ctx, cacheKey); !cached.IsEmpty() {
return cached.String(), nil
}
// 3. Redis 未命中,查询 MongoDB
coll := commonMongo.GetDB().Collection(entity.CustomerServiceAccountCollection)
var doc struct {
TenantId interface{} `bson:"tenantId"`
}
filters := []bson.M{
{"accountName": custId, "isDeleted": false},
{"accountName": custId}, // 兼容旧数据未设置 isDeleted
}
if objectId, objErr := bson.ObjectIDFromHex(custId); objErr == nil {
filters = append(filters,
bson.M{"_id": objectId, "isDeleted": false},
bson.M{"_id": objectId},
)
}
for _, filter := range filters {
if err = coll.FindOne(ctx, filter).Decode(&doc); err == nil {
tenantId = gconv.String(doc.TenantId)
if tenantId == "" {
return "", gerror.Newf("客服账号 %s 未配置 tenantId", custId)
}
// 4. 写入 Redis 缓存
redis.RedisClient().SetEX(ctx, cacheKey, tenantId, tenantCacheExpire)
return
}
if !errors.Is(err, mongo.ErrNoDocuments) {
return
}
}
return "", gerror.Newf("客服账号 %s 不存在", custId)
}
// Connect 建立 WebSocket 连接
func (s *websocketService) Connect(ctx context.Context, r *ghttp.Request, userId, platform string) error {
// 使用原生upgrader升级WebSocket连接
ws, err := s.upgrader.Upgrade(r.Response.Writer, r.Request, nil)
if err != nil {
jaeger.RecordError(ctx, err, "WebSocket 升级失败")
return err
}
defer ws.Close()
tenantId, err := s.resolveTenantId(ctx, r)
if err != nil {
jaeger.RecordError(ctx, err, "获取租户ID失败")
return err
}
// 读取accountName参数客服账号名称
accountName := r.Get("accountName").String()
if accountName == "" {
accountName = r.Get("account_name").String()
}
glog.Infof(ctx, "WebSocket 连接建立 - 用户: %s, 平台: %s, 租户: %s, 客服账号: %s", userId, platform, tenantId, accountName)
// key格式: tenantId:userId_platform (确保租户隔离)
key := tenantId + ":" + userId + "_" + platform
// 关闭旧连接
if old := s.connections.Get(key); old != nil {
old.(*wsConnection).Conn.Close()
}
// 注册新连接(携带 TenantId 和 AccountName
s.connections.Set(key, &wsConnection{
UserId: userId,
Platform: platform,
TenantId: tenantId,
AccountName: accountName,
Conn: ws,
CreatedAt: gtime.Now().Timestamp(),
})
// 发送开场白(连接建立后立即推送)
if accountName != "" {
greeting := s.getGreeting(ctx, accountName, tenantId)
if greeting != "" {
s.writeJSON(ws, &dto.WebSocketPushMsg{
Type: "message",
Message: greeting,
})
glog.Infof(ctx, "已发送开场白 - 用户: %s, 客服账号: %s, 长度: %d", userId, accountName, len(greeting))
} else {
glog.Warningf(ctx, "客服账号未配置开场白 - accountName: %s, tenantId: %s", accountName, tenantId)
}
}
// 处理消息(阻塞)
s.handleConnection(ctx, key, ws)
return nil
}
// handleConnection 处理 WebSocket 连接
func (s *websocketService) handleConnection(ctx context.Context, key string, conn *websocket.Conn) {
defer func() {
s.connections.Remove(key)
conn.Close()
glog.Infof(ctx, "WebSocket 连接断开 - %s", key)
}()
for {
msgType, message, err := conn.ReadMessage()
if err != nil {
// 排除正常关闭情况:正常关闭、离开页面、无状态码关闭
if websocket.IsUnexpectedCloseError(err,
websocket.CloseNormalClosure,
websocket.CloseGoingAway,
websocket.CloseNoStatusReceived,
) {
jaeger.RecordError(ctx, err, "WebSocket 读取错误")
}
break
}
if msgType != websocket.TextMessage {
continue
}
content := gconv.String(message)
glog.Infof(ctx, "收到 WebSocket 消息 - %s: %s", key, content)
// 解析 userId
connInfo := s.connections.Get(key)
if connInfo == nil {
break
}
wsConn := connInfo.(*wsConnection)
// 先检查对话轮数,>5 则只发卡片,跳过话术
// checkCardBeforeProcess 已推送卡片消息无需ack
if handled, err := checkCardBeforeProcess(ctx, wsConn.TenantId, wsConn.UserId, wsConn.Platform); err != nil {
jaeger.RecordError(ctx, err, "卡片检查失败")
} else if handled {
continue
}
// 话术匹配并发布响应
// status 暂时为空,表示任意行为匹配
// isPushed=true表示已直接推送响应话术匹配无需ack
// isPushed=false表示转发到RAGFlow需要ack告知用户正在处理
// 创建带有accountName的context供GetTenantInfo使用
newCtx := ctx
if wsConn.AccountName != "" {
newCtx = context.WithValue(ctx, "accountName", wsConn.AccountName)
}
isPushed, err := Speechcraft.ProcessAndPublish(newCtx, wsConn.UserId, wsConn.Platform, wsConn.TenantId, content, "", wsConn.AccountName)
if err != nil {
jaeger.RecordError(ctx, err, "话术处理失败")
s.writeJSON(conn, &dto.WebSocketPushMsg{Type: "error", Message: "消息处理失败"})
continue
}
// 只在转发到RAGFlow时发送ackGo直接返回的不需要ack
if !isPushed {
s.writeJSON(conn, &dto.WebSocketPushMsg{Type: "ack", Message: "消息已接收,正在处理..."})
}
}
}
// writeJSON 发送 JSON 消息
func (s *websocketService) writeJSON(conn *websocket.Conn, data interface{}) {
jsonBytes, _ := gjson.Encode(data)
conn.WriteMessage(websocket.TextMessage, jsonBytes)
}
// getGreeting 获取客服账号的开场白
func (s *websocketService) getGreeting(ctx context.Context, accountName, tenantId string) string {
glog.Infof(ctx, "查询开场白 - accountName: %s, tenantId: %s", accountName, tenantId)
// 复用dao层方法保持查询逻辑一致
account, err := dao.CustomerServiceAccount.FindByAccountName(ctx, accountName)
if err != nil {
jaeger.RecordError(ctx, err, "查询客服账号开场白失败")
glog.Errorf(ctx, "查询开场白失败: %v", err)
return ""
}
if account == nil {
glog.Warningf(ctx, "客服账号不存在: accountName=%s", accountName)
return ""
}
// 详细输出查询结果
glog.Infof(ctx, "查询到客服账号: Id=%s, AccountName=%s, TenantId=%v, Greeting长度=%d, Platform=%s",
account.Id.Hex(), account.AccountName, account.TenantId, len(account.Greeting), account.Platform)
return account.Greeting
}
// Send 发送消息到 Redis StreamHTTP 接口)
func (s *websocketService) Send(ctx context.Context, req *dto.WebSocketSendReq) (*dto.WebSocketSendRes, error) {
// 从 token 获取租户ID
var tenantId string
if user, err := utils.GetUserInfo(ctx); err == nil {
tenantId = gconv.String(user.TenantId)
}
messageId, err := s.sendToStream(ctx, req.UserId, tenantId, req.Content)
if err != nil {
return nil, err
}
return &dto.WebSocketSendRes{MessageId: messageId}, nil
}
// sendToStream 发送消息到 Redis Stream
// 如果用户无 session 缓存(已归档),则从 MongoDB 读取历史对话一起发送
func (s *websocketService) sendToStream(ctx context.Context, userId, tenantId, content string) (string, error) {
now := gtime.Now()
platform := "xiaohongshu" // 默认平台
// 获取当前实例的动态响应队列名(自动生成,支持多实例部署)
baseQueue := GetConfigString(ctx, "rabbitmq.responseQueue")
replyQueue := rabbitmq.GetInstanceQueueName(baseQueue)
// 获取accountName优先使用用户选择的方向映射
var accountName string
var chatId string
// 1. 尝试从用户状态获取(用户选择方向后的映射)
if userState, stateErr := redis.GetUserState(ctx, userId, platform); stateErr == nil && userState.AccountName != "" {
accountName = userState.AccountName
glog.Infof(ctx, "使用用户选择方向的客服账号: %s", accountName)
} else {
// 2. 从连接信息获取(默认)
key := tenantId + ":" + userId + "_" + platform
connInfo := s.connections.Get(key)
if connInfo != nil {
wsConn := connInfo.(*wsConnection)
accountName = wsConn.AccountName
glog.Infof(ctx, "使用连接默认客服账号: %s", accountName)
}
}
// 根据accountName查询ragflow_config获取chat_id
if accountName != "" {
config, err := dao.RAGFlowConfig.FindByAccountName(ctx, accountName)
if err == nil && config != nil {
chatId = config.ChatId
glog.Infof(ctx, "查询到chatId: accountName=%s, chatId=%s", accountName, chatId)
}
}
// 如果未找到chatId报错应该从ragflowconfig表重建session
if chatId == "" {
return "", gerror.New("chatId未找到需要重建RAGFlow session")
}
msg := &redis.SendStreamMessage{
UserId: userId,
TenantId: tenantId,
AccountName: accountName,
ChatId: chatId,
Content: content,
Timestamp: now.Timestamp(),
MessageId: userId + "_" + gconv.String(now.TimestampNano()),
ReplyQueue: replyQueue,
}
// 检查是否有 session 缓存,无缓存说明已归档,需要读取历史
if sessionId, _ := redis.GetSessionCache(ctx, tenantId, userId); sessionId == "" {
// 从 MongoDB 读取历史对话
if history, err := dao.Conversation.GetRecentHistory(ctx, userId, redis.GetHistoryContextLimit()); err == nil && len(history) > 0 {
msg.History = history
glog.Infof(ctx, "用户已归档,读取 %d 轮历史对话 - 用户: %s", len(history), userId)
}
}
messageId, err := redis.AddToStream(ctx, redis.RAGFlowRequestStreamKey, msg)
if err != nil {
return "", err
}
glog.Infof(ctx, "消息已发送到 Stream - MessageID: %s, 用户: %s", messageId, userId)
return messageId, nil
}
// SendToUser 发送消息给指定用户
func (s *websocketService) SendToUser(ctx context.Context, tenantId, userId, platform string, data interface{}) error {
// key格式: tenantId:userId_platform
key := tenantId + ":" + userId + "_" + platform
connInfo := s.connections.Get(key)
if connInfo == nil {
glog.Warningf(ctx, "用户不在线 - %s", key)
return nil
}
s.writeJSON(connInfo.(*wsConnection).Conn, data)
return nil
}
// Broadcast 广播消息给所有连接
func (s *websocketService) Broadcast(ctx context.Context, content string) {
msg := &dto.WebSocketPushMsg{Type: "broadcast", Content: content}
msgBytes := gjson.MustEncode(msg)
s.connections.Iterator(func(key string, value interface{}) bool {
conn := value.(*wsConnection)
if err := conn.Conn.WriteMessage(websocket.TextMessage, msgBytes); err != nil {
jaeger.RecordError(ctx, err, "广播消息失败 - "+key)
}
return true
})
}
// GetOnlineUsers 获取在线用户列表
func (s *websocketService) GetOnlineUsers() *dto.WebSocketOnlineRes {
users := make([]dto.WebSocketOnlineUserRes, 0, s.connections.Size())
s.connections.Iterator(func(_ string, value interface{}) bool {
conn := value.(*wsConnection)
users = append(users, dto.WebSocketOnlineUserRes{
UserId: conn.UserId,
Platform: conn.Platform,
CreatedAt: conn.CreatedAt,
})
return true
})
return &dto.WebSocketOnlineRes{
Count: len(users),
Users: users,
}
}
// PushRAGFlowResponse 推送 RAGFlow 响应给用户
func (s *websocketService) PushRAGFlowResponse(ctx context.Context, tenantId, userId, platform, content string) error {
return s.SendToUser(ctx, tenantId, userId, platform, &dto.WebSocketPushMsg{Type: "answer", Content: content})
}
// ============== RabbitMQ 消费者(追问延时消息)==============
// FollowUpConsumer 追问消费者
type FollowUpConsumer struct {
consumer *rabbitmq.Consumer
}
// NewFollowUpConsumer 创建追问消费者
func NewFollowUpConsumer(ctx context.Context) *FollowUpConsumer {
queueName := GetConfigString(ctx, "followUp.queue")
return &FollowUpConsumer{
consumer: rabbitmq.NewConsumer(queueName, handleFollowUp),
}
}
// Start 启动消费者
func (c *FollowUpConsumer) Start(ctx context.Context) (err error) {
glog.Info(ctx, "追问消费者启动...")
return c.consumer.Start(ctx)
}
// Stop 停止消费者
func (c *FollowUpConsumer) Stop(ctx context.Context) {
c.consumer.Stop(ctx)
}
// handleFollowUp 处理追问消息
func handleFollowUp(ctx context.Context, body []byte) (err error) {
ctx, span := jaeger.NewSpan(ctx, "consumer.followup")
defer span.End()
var msg redis.FollowUpMessage
if err = gjson.DecodeTo(body, &msg); err != nil {
jaeger.RecordError(ctx, err, "解析追问消息失败")
return
}
glog.Infof(ctx, "收到追问消息 - 用户: %s, 类型: %d", msg.UserId, msg.FollowUpType)
// 检查用户状态如果在状态5方向选择或状态3发卡片跳过追问
userState, err := redis.GetUserState(ctx, msg.UserId, msg.Platform)
if err != nil {
jaeger.RecordError(ctx, err, "获取用户状态失败")
return
}
if userState.Stage == 5 {
glog.Infof(ctx, "用户 %s 在方向选择状态,跳过追问", msg.UserId)
return
}
if userState.Stage == 3 {
glog.Infof(ctx, "用户 %s 在发卡片状态,跳过追问", msg.UserId)
return
}
// 检查用户是否在追问发送后有活跃
isActive, err := redis.IsUserActive(ctx, msg.UserId, int64(redis.GetFollowUpDelay(msg.FollowUpType)))
if err != nil {
jaeger.RecordError(ctx, err, "检查用户活跃状态失败")
return
}
if isActive {
glog.Infof(ctx, "用户 %s 在追问期间有活跃,跳过追问", msg.UserId)
return
}
// 发送追问消息给用户
if err = WebSocket.PushRAGFlowResponse(ctx, msg.TenantId, msg.UserId, msg.Platform, msg.Content); err != nil {
jaeger.RecordError(ctx, err, "推送追问消息失败")
}
glog.Infof(ctx, "追问消息已发送 - 租户: %s, 用户: %s, 内容: %s", msg.TenantId, msg.UserId, msg.Content)
return
}

View File

@@ -0,0 +1,459 @@
package service
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"customer-server/dao"
"customer-server/model/dto"
"customer-server/model/entity"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
commonMongo "gitea.com/red-future/common/db/mongo"
"gitea.com/red-future/common/jaeger"
"gitea.com/red-future/common/rabbitmq"
"gitea.com/red-future/common/redis"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"github.com/gogf/gf/v2/util/gconv"
"go.mongodb.org/mongo-driver/v2/bson"
)
var Xiaohongshu = new(xiaohongshu)
type xiaohongshu struct{}
const (
XhsApiBaseUrl = "https://adapi.xiaohongshu.com"
XhsPlatformName = "xiaohongshu"
XhsEncryptSplit = "~split~"
)
// ==================== 加解密工具 ====================
// Encrypt AES加密
// 参数: ctx - 上下文content - 待加密内容secretKey - 密钥Base64编码
// 返回: res - 加密后的字符串Base64编码err - 错误信息
// 功能: 使用AES-CBC模式加密内容用于小红书API签名
func (s *xiaohongshu) Encrypt(ctx context.Context, content, secretKey string) (res string, err error) {
keyBytes, err := base64.StdEncoding.DecodeString(secretKey)
if err != nil {
jaeger.RecordError(ctx, err)
return
}
block, err := aes.NewCipher(keyBytes)
if err != nil {
jaeger.RecordError(ctx, err)
return
}
iv := make([]byte, aes.BlockSize)
if _, err = io.ReadFull(rand.Reader, iv); err != nil {
jaeger.RecordError(ctx, err)
return
}
stream := cipher.NewCBCEncrypter(block, iv)
contentBytes := []byte(content)
paddedContent := pkcs5Padding(contentBytes, aes.BlockSize)
cipherText := make([]byte, len(paddedContent))
stream.CryptBlocks(cipherText, paddedContent)
ivBase64 := base64.StdEncoding.EncodeToString(iv)
cipherBase64 := base64.StdEncoding.EncodeToString(cipherText)
res = fmt.Sprintf("%s%s%s", ivBase64, XhsEncryptSplit, cipherBase64)
return
}
func (s *xiaohongshu) Decrypt(ctx context.Context, cipherText, secretKey string) (res string, err error) {
keyBytes, err := base64.StdEncoding.DecodeString(secretKey)
if err != nil {
jaeger.RecordError(ctx, err)
return
}
parts := strings.Split(cipherText, XhsEncryptSplit)
if len(parts) != 2 {
err = errors.New("invalid cipher text format")
jaeger.RecordError(ctx, err)
return
}
iv, err := base64.StdEncoding.DecodeString(parts[0])
if err != nil {
jaeger.RecordError(ctx, err)
return
}
encrypted, err := base64.StdEncoding.DecodeString(parts[1])
if err != nil {
jaeger.RecordError(ctx, err)
return
}
block, err := aes.NewCipher(keyBytes)
if err != nil {
jaeger.RecordError(ctx, err)
return
}
if len(encrypted)%aes.BlockSize != 0 {
err = errors.New("cipher text is not a multiple of block size")
jaeger.RecordError(ctx, err)
return
}
stream := cipher.NewCBCDecrypter(block, iv)
decrypted := make([]byte, len(encrypted))
stream.CryptBlocks(decrypted, encrypted)
decrypted, err = pkcs5Unpadding(decrypted)
if err != nil {
jaeger.RecordError(ctx, err)
return
}
res = string(decrypted)
return
}
func pkcs5Padding(data []byte, blockSize int) []byte {
padding := blockSize - len(data)%blockSize
padText := make([]byte, padding)
for i := range padText {
padText[i] = byte(padding)
}
return append(data, padText...)
}
func pkcs5Unpadding(data []byte) (res []byte, err error) {
length := len(data)
if length == 0 {
err = errors.New("invalid padding size")
return
}
padding := int(data[length-1])
if padding > length {
err = errors.New("invalid padding size")
return
}
res = data[:length-padding]
return
}
// ==================== 账号绑定管理 ====================
func (s *xiaohongshu) HandleBindAccount(ctx context.Context, req *dto.XhsBindAccountReq) (err error) {
var account entity.CustomerServiceAccount
filter := bson.M{"platform": XhsPlatformName, "isDeleted": false}
if err = dao.MongoDAO.FindOne(ctx, filter, &account, entity.CustomerServiceAccountCollection); err != nil {
jaeger.RecordError(ctx, err)
return
}
decrypted, err := s.Decrypt(ctx, req.Content, account.SecretKey)
if err != nil {
jaeger.RecordError(ctx, err)
return
}
var bindData dto.XhsBindAccountDecrypted
if err = json.Unmarshal([]byte(decrypted), &bindData); err != nil {
jaeger.RecordError(ctx, err)
return
}
update := bson.M{
"$set": bson.M{
"accessToken": bindData.Token,
"appId": bindData.AppId,
"xhsUserId": bindData.UserId,
"updatedAt": gtime.Now().Time,
},
}
filter = bson.M{"_id": account.Id}
_, err = commonMongo.GetDB().Collection(entity.CustomerServiceAccountCollection).UpdateOne(ctx, filter, update)
if err != nil {
jaeger.RecordError(ctx, err)
return
}
g.Log().Infof(ctx, "[小红书] 绑定账户成功: userId=%s, nickName=%s", bindData.UserId, bindData.NickName)
return
}
func (s *xiaohongshu) HandleUnbindAccount(ctx context.Context, req *dto.XhsUnbindAccountReq) (err error) {
var account entity.CustomerServiceAccount
filter := bson.M{"platform": XhsPlatformName, "isDeleted": false}
if err = dao.MongoDAO.FindOne(ctx, filter, &account, entity.CustomerServiceAccountCollection); err != nil {
jaeger.RecordError(ctx, err)
return
}
decrypted, err := s.Decrypt(ctx, req.Content, account.SecretKey)
if err != nil {
jaeger.RecordError(ctx, err)
return
}
var unbindData dto.XhsUnbindAccountDecrypted
if err = json.Unmarshal([]byte(decrypted), &unbindData); err != nil {
jaeger.RecordError(ctx, err)
return
}
update := bson.M{
"$set": bson.M{
"accessToken": "",
"xhsUserId": "",
"updatedAt": gtime.Now().Time,
},
}
filter = bson.M{"_id": account.Id}
_, err = commonMongo.GetDB().Collection(entity.CustomerServiceAccountCollection).UpdateOne(ctx, filter, update)
if err != nil {
jaeger.RecordError(ctx, err)
return
}
g.Log().Infof(ctx, "[小红书] 解绑账户成功: userId=%s", unbindData.UserId)
return
}
// ==================== 消息收发 ====================
func (s *xiaohongshu) HandleReceiveMessage(ctx context.Context, req *dto.XhsReceiveMessageReq) (err error) {
accountId, err := s.getAccountIdByPlatform(ctx)
if err != nil {
return
}
var account entity.CustomerServiceAccount
filter := bson.M{"_id": accountId, "isDeleted": false}
if err = dao.MongoDAO.FindOne(ctx, filter, &account, entity.CustomerServiceAccountCollection); err != nil {
jaeger.RecordError(ctx, err)
return
}
decrypted, err := s.Decrypt(ctx, req.Content, account.SecretKey)
if err != nil {
jaeger.RecordError(ctx, err)
return
}
var conversation entity.Conversation
id := bson.NewObjectID()
conversation.Id = &id // 取地址赋值给指针类型
conversation.SessionId = fmt.Sprintf("%s_%s", req.FromUserId, XhsPlatformName)
conversation.UserId = req.FromUserId
conversation.CustomerServiceId = accountId.Hex()
conversation.Role = "user"
conversation.Platform = XhsPlatformName
conversation.MessageId = req.MessageId
conversation.MessageType = req.MessageType
now := gtime.Now().Time
conversation.CreatedAt = &now // 取地址赋值给指针类型
switch req.MessageType {
case "TEXT":
var textContent dto.XhsTextContent
if err = json.Unmarshal([]byte(decrypted), &textContent); err != nil {
jaeger.RecordError(ctx, err)
return
}
conversation.Content = textContent.Text
case "IMAGE":
var imgContent dto.XhsImageContent
if err = json.Unmarshal([]byte(decrypted), &imgContent); err != nil {
jaeger.RecordError(ctx, err)
return
}
conversation.Content = fmt.Sprintf("[图片]%s", imgContent.Link)
case "VIDEO":
var videoContent dto.XhsVideoContent
if err = json.Unmarshal([]byte(decrypted), &videoContent); err != nil {
jaeger.RecordError(ctx, err)
return
}
conversation.Content = fmt.Sprintf("[视频]%s", videoContent.Link)
case "CARD":
var cardContent dto.XhsCardContent
if err = json.Unmarshal([]byte(decrypted), &cardContent); err != nil {
jaeger.RecordError(ctx, err)
return
}
conversation.Content = fmt.Sprintf("[卡片-%s]%s", cardContent.ContentType, cardContent.Id)
case "REVOKE":
var revokeContent dto.XhsRevokeContent
if err = json.Unmarshal([]byte(decrypted), &revokeContent); err != nil {
jaeger.RecordError(ctx, err)
return
}
conversation.Content = fmt.Sprintf("[撤回消息]%s", revokeContent.MessageId)
case "HINT":
conversation.Content = "[系统提示消息]"
case "SMILES":
conversation.Content = "[表情消息]"
default:
conversation.Content = fmt.Sprintf("[%s类型消息]", req.MessageType)
}
_, err = commonMongo.GetDB().Collection(entity.ConversationCollection).InsertOne(ctx, conversation)
if err != nil {
jaeger.RecordError(ctx, err)
return
}
g.Log().Infof(ctx, "[小红书] 接收消息成功: sessionId=%s, messageType=%s", conversation.SessionId, req.MessageType)
if req.MessageType == "TEXT" {
asyncCtx := context.WithoutCancel(ctx)
go s.processUserMessage(asyncCtx, &account, &conversation)
}
return
}
func (s *xiaohongshu) SendMessage(ctx context.Context, account *entity.CustomerServiceAccount, toUserId, content string) (err error) {
textContent := dto.XhsTextContent{Text: content}
contentJson, err := json.Marshal(textContent)
if err != nil {
jaeger.RecordError(ctx, err)
return
}
encrypted, err := s.Encrypt(ctx, string(contentJson), account.SecretKey)
if err != nil {
jaeger.RecordError(ctx, err)
return
}
now := gtime.Now()
sendReq := dto.XhsSendMessageReq{
UserId: account.XhsUserId,
RequestId: fmt.Sprintf("%d", now.UnixNano()),
MessageType: "TEXT",
FromUserId: account.XhsUserId,
ToUserId: toUserId,
ThirdAccountId: account.Id.Hex(),
Timestamp: now.UnixMilli(),
Content: encrypted,
}
url := fmt.Sprintf("%s/api/open/im/third/send", XhsApiBaseUrl)
client := g.Client()
client.SetHeader("Access-Token", account.AccessToken)
client.SetHeader("Content-Type", "application/json")
resp, err := client.Post(ctx, url, sendReq)
if err != nil {
jaeger.RecordError(ctx, err)
return
}
defer resp.Close()
var sendRes dto.XhsSendMessageRes
if err = json.Unmarshal(resp.ReadAll(), &sendRes); err != nil {
jaeger.RecordError(ctx, err)
return
}
if sendRes.Code != 0 {
err = fmt.Errorf("发送消息失败: code=%d, msg=%s", sendRes.Code, sendRes.Msg)
jaeger.RecordError(ctx, err)
return
}
var conversation entity.Conversation
id2 := bson.NewObjectID()
conversation.Id = &id2 // 取地址赋值给指针类型
conversation.SessionId = fmt.Sprintf("%s_%s", toUserId, XhsPlatformName)
conversation.UserId = toUserId
conversation.CustomerServiceId = account.Id.Hex()
conversation.Role = "assistant"
conversation.Platform = XhsPlatformName
conversation.MessageId = sendRes.Data.MessageId
conversation.MessageType = "TEXT"
conversation.Content = content
now2 := gtime.Now().Time
conversation.CreatedAt = &now2 // 取地址赋值给指针类型
_, err = commonMongo.GetDB().Collection(entity.ConversationCollection).InsertOne(ctx, conversation)
if err != nil {
jaeger.RecordError(ctx, err)
return
}
g.Log().Infof(ctx, "[小红书] 发送消息成功: toUserId=%s, messageId=%s", toUserId, sendRes.Data.MessageId)
return
}
func (s *xiaohongshu) GenerateSignature(ctx context.Context, secretKey, requestBody string) (res string) {
h := sha256.New()
h.Write([]byte(secretKey + requestBody))
res = hex.EncodeToString(h.Sum(nil))
return
}
// ==================== 私有方法 ====================
func (s *xiaohongshu) getAccountIdByPlatform(ctx context.Context) (res bson.ObjectID, err error) {
var account entity.CustomerServiceAccount
filter := bson.M{"platform": XhsPlatformName, "isDeleted": false}
if err = dao.MongoDAO.FindOne(ctx, filter, &account, entity.CustomerServiceAccountCollection); err != nil {
jaeger.RecordError(ctx, err)
return
}
res = *account.Id // 解引用指针类型
return
}
func (s *xiaohongshu) processUserMessage(ctx context.Context, account *entity.CustomerServiceAccount, conversation *entity.Conversation) {
if err := s.sendToRAGFlowStream(ctx, account, conversation); err != nil {
jaeger.RecordError(ctx, err)
g.Log().Errorf(ctx, "[小红书] 发送到RAGFlow Stream失败: %v", err)
return
}
g.Log().Infof(ctx, "[小红书] 消息已发送到RAGFlow Stream: userId=%s", conversation.UserId)
}
func (s *xiaohongshu) sendToRAGFlowStream(ctx context.Context, account *entity.CustomerServiceAccount, conversation *entity.Conversation) (err error) {
baseQueue := GetConfigString(ctx, "rabbitmq.responseQueue")
replyQueue := rabbitmq.GetInstanceQueueName(baseQueue)
msg := &redis.SendStreamMessage{
UserId: fmt.Sprintf("%s_%s", XhsPlatformName, conversation.UserId),
TenantId: gconv.String(account.TenantId),
Content: conversation.Content,
Timestamp: gtime.New(conversation.CreatedAt).Timestamp(),
MessageId: conversation.MessageId,
Platform: XhsPlatformName,
AccountId: account.Id.Hex(),
AccountName: account.AccountName,
ReplyQueue: replyQueue,
}
if sessionId, _ := redis.GetSessionCache(ctx, gconv.String(account.TenantId), msg.UserId); sessionId == "" {
if history, histErr := dao.Conversation.GetRecentHistory(ctx, msg.UserId, redis.GetHistoryContextLimit()); histErr == nil && len(history) > 0 {
msg.History = history
g.Log().Infof(ctx, "[小红书] 用户已归档,读取 %d 轮历史对话", len(history))
}
}
streamMsgId, err := redis.AddToStream(ctx, redis.RAGFlowRequestStreamKey, msg)
if err != nil {
jaeger.RecordError(ctx, err)
return
}
g.Log().Infof(ctx, "[小红书] 消息已写入Stream: streamMsgId=%s, sessionId=%s", streamMsgId, conversation.SessionId)
return
}