feat: 新增账号编码和HTTP连接功能
This commit is contained in:
68
service/account_http_service.go
Normal file
68
service/account_http_service.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/consts/public"
|
||||
"customer-server/model/dto"
|
||||
"fmt"
|
||||
|
||||
gmq "github.com/bjang03/gmq/core/gmq"
|
||||
"github.com/bjang03/gmq/mq"
|
||||
"github.com/bjang03/gmq/types"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
var (
|
||||
AccountHttpService = new(accountHttpService)
|
||||
)
|
||||
|
||||
type accountHttpService struct{}
|
||||
|
||||
func (s *accountHttpService) DeleteDelayMsg(ctx context.Context) (err error) {
|
||||
return gmq.GetGmq(public.GmqMsgPluginsName).GmqDeleteDelay(ctx, &mq.NatsDelMessage{
|
||||
DelMessage: types.DelMessage{
|
||||
Topic: public.AccountFollowupTopic,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *accountHttpService) Connect(ctx context.Context, req *dto.AccountHttpConnectReq) (res *dto.AccountHttpConnectRes, err error) {
|
||||
// 获取客服账号信息
|
||||
accountInfo, err := SessionToolService.GetAccountInfo(ctx, req.AccountCode)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if g.IsEmpty(accountInfo) {
|
||||
return nil, fmt.Errorf("客服账号不存在")
|
||||
}
|
||||
|
||||
// 设置用户信息
|
||||
headers, err := SessionToolService.SetUserInfo(ctx, accountInfo.Creator, accountInfo.TenantId)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
content, err := SessionToolService.PushOpeningRemark(ctx, req.UserId, accountInfo, headers)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if !g.IsEmpty(content) {
|
||||
res = &dto.AccountHttpConnectRes{
|
||||
Content: content,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
dialogContent, err := SessionToolService.PushDialog(ctx, req.UserId, req.QuestionContent, accountInfo, headers)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if !g.IsEmpty(dialogContent) {
|
||||
res = &dto.AccountHttpConnectRes{
|
||||
Content: dialogContent,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
@@ -24,7 +24,7 @@ type accountService struct{}
|
||||
func (s *accountService) Add(ctx context.Context, req *dto.AddAccountReq) (res *dto.AddAccountRes, err error) {
|
||||
// 检查账号名称是否已存在
|
||||
count, err := dao.Account.Count(ctx, &dto.ListAccountReq{
|
||||
AccountName: req.AccountName,
|
||||
AccountCode: req.AccountCode,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
|
||||
@@ -3,19 +3,18 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"customer-server/consts/account"
|
||||
"customer-server/dao"
|
||||
"customer-server/model/dto"
|
||||
"customer-server/model/entity"
|
||||
"errors"
|
||||
"net/http"
|
||||
"fmt"
|
||||
netHttp "net/http"
|
||||
|
||||
"gitea.com/red-future/common/beans"
|
||||
"gitea.com/red-future/common/jaeger"
|
||||
"github.com/gogf/gf/v2/container/gmap"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
"github.com/gogf/gf/v2/os/glog"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/os/grpool"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
@@ -24,73 +23,92 @@ import (
|
||||
var AccountWebSocket = &accountWebsocketService{
|
||||
connections: gmap.NewStrAnyMap(true),
|
||||
upgrader: websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
CheckOrigin: func(r *netHttp.Request) bool {
|
||||
return true // 允许跨域
|
||||
},
|
||||
},
|
||||
workerPool: grpool.New(50), // 限制最大并发数为50
|
||||
}
|
||||
|
||||
type accountWebsocketService struct {
|
||||
connections *gmap.StrAnyMap
|
||||
upgrader websocket.Upgrader
|
||||
workerPool *grpool.Pool // 工作池限制goroutine数量
|
||||
}
|
||||
|
||||
// key: userId_platform
|
||||
// accountWsConnection WebSocket 连接信息
|
||||
type accountWsConnection struct {
|
||||
AccountInfo *dto.AccountVO
|
||||
UserId string
|
||||
Platform account.Platform
|
||||
TenantId uint64
|
||||
AccountName string // 客服账号ID
|
||||
Conn *websocket.Conn
|
||||
CreatedAt int64
|
||||
Headers map[string]string // 保存原始请求头
|
||||
}
|
||||
|
||||
// Connect 建立 WebSocket 连接
|
||||
func (s *accountWebsocketService) Connect(ctx context.Context, r *ghttp.Request, accountName string, platform account.Platform) error {
|
||||
func (s *accountWebsocketService) Connect(ctx context.Context, r *ghttp.Request, req *dto.AccountWebSocketConnectReq) error {
|
||||
// 使用原生upgrader升级WebSocket连接
|
||||
ws, err := s.upgrader.Upgrade(r.Response.Writer, r.Request, nil)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err, "WebSocket 升级失败")
|
||||
return err
|
||||
}
|
||||
defer ws.Close()
|
||||
if g.IsEmpty(accountName) {
|
||||
return errors.New("accountName is empty")
|
||||
}
|
||||
res, err := s.getGreeting(ctx, accountName)
|
||||
|
||||
// 获取客服账号信息
|
||||
accountInfo, err := SessionToolService.GetAccountInfo(ctx, req.AccountCode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if g.IsEmpty(&res) {
|
||||
return errors.New("account is empty")
|
||||
}
|
||||
if !g.IsEmpty(res.Greeting) {
|
||||
s.writeJSON(ws, &dto.WebSocketPushMsg{
|
||||
Type: "message",
|
||||
Message: res.Greeting,
|
||||
})
|
||||
glog.Infof(ctx, "已发送开场白 - 用户: %v, 客服账号: %s, 长度: %d", res.Id, accountName, len(res.Greeting))
|
||||
} else {
|
||||
glog.Warningf(ctx, "客服账号未配置开场白 - accountName: %s, tenantId: %v", accountName, res.TenantId)
|
||||
if g.IsEmpty(accountInfo) {
|
||||
return fmt.Errorf("客服账号不存在")
|
||||
}
|
||||
|
||||
// key格式: tenantId:userId_platform (确保租户隔离)
|
||||
key := gconv.String(res.TenantId) + ":" + gconv.String(res.Creator) + ":" + gconv.String(platform)
|
||||
// 创建完整的用户信息
|
||||
userInfo := &beans.User{
|
||||
UserName: accountInfo.Creator,
|
||||
TenantId: accountInfo.TenantId,
|
||||
}
|
||||
ctx = context.WithValue(ctx, "user", *userInfo)
|
||||
// 提取并保存请求头(在连接升级前)
|
||||
headers := make(map[string]string)
|
||||
// 提取其他headers
|
||||
for k, v := range r.Request.Header {
|
||||
if len(v) > 0 {
|
||||
headers[k] = v[0]
|
||||
}
|
||||
}
|
||||
// 将完整用户信息序列化为JSON,放到X-User-Info请求头
|
||||
userInfoJson, err := gjson.Encode(userInfo)
|
||||
if err != nil {
|
||||
glog.Errorf(ctx, "用户信息序列化失败: %v", err)
|
||||
} else {
|
||||
headers["X-User-Info"] = string(userInfoJson)
|
||||
glog.Debugf(ctx, "已添加用户信息到请求头: %s", string(userInfoJson))
|
||||
}
|
||||
|
||||
var key = fmt.Sprintf("account:%s:%s:%s", req.AccountCode, account.GetDescByCode(req.Platform), req.UserId)
|
||||
content, err := SessionToolService.PushOpeningRemark(ctx, req.UserId, accountInfo, headers)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !g.IsEmpty(content) {
|
||||
s.writeJSON(ws, &dto.WebSocketPushMsg{
|
||||
Type: "message",
|
||||
Message: content,
|
||||
})
|
||||
}
|
||||
|
||||
// 关闭旧连接
|
||||
if old := s.connections.Get(key); old != nil {
|
||||
old.(*accountWsConnection).Conn.Close()
|
||||
}
|
||||
|
||||
// 注册新连接(携带 TenantId 和 AccountName)
|
||||
// 注册新连接(携带完整用户信息)
|
||||
s.connections.Set(key, &accountWsConnection{
|
||||
UserId: res.Creator,
|
||||
Platform: platform,
|
||||
TenantId: res.TenantId,
|
||||
AccountName: accountName,
|
||||
AccountInfo: accountInfo,
|
||||
UserId: req.UserId,
|
||||
Conn: ws,
|
||||
CreatedAt: gtime.Now().Timestamp(),
|
||||
Headers: headers, // 保存请求头
|
||||
})
|
||||
|
||||
// 处理消息(阻塞)
|
||||
@@ -124,64 +142,93 @@ func (s *accountWebsocketService) handleConnection(ctx context.Context, key stri
|
||||
continue
|
||||
}
|
||||
|
||||
content := gconv.String(message)
|
||||
glog.Infof(ctx, "收到 WebSocket 消息 - %s: %s", key, content)
|
||||
questionContent := gconv.String(message)
|
||||
glog.Infof(ctx, "收到 WebSocket 消息 - %s: %s", key, questionContent)
|
||||
|
||||
// 解析 userId
|
||||
//connInfo := s.connections.Get(key)
|
||||
//if connInfo == nil {
|
||||
// break
|
||||
//}
|
||||
//wsConn := connInfo.(*accountWsConnection)
|
||||
// 解析连接信息
|
||||
connInfo := s.connections.Get(key)
|
||||
if connInfo == nil {
|
||||
glog.Warningf(ctx, "WebSocket连接信息不存在 - %s", key)
|
||||
break
|
||||
}
|
||||
wsConn := connInfo.(*accountWsConnection)
|
||||
|
||||
// 先检查对话轮数,>5 则只发卡片,跳过话术
|
||||
//checkCardBeforeProcess 已推送卡片消息,无需ack
|
||||
//if handled, err := checkCardBeforeProcess(ctx, wsConn.TenantId, wsConn.UserId, wsConn.Platform); err != nil {
|
||||
// jaeger.RecordError(ctx, err, "卡片检查失败")
|
||||
//} else if handled {
|
||||
// continue
|
||||
//}
|
||||
// 发送ack告知用户正在处理
|
||||
s.writeJSON(conn, &dto.WebSocketPushMsg{Type: "ack", Message: "消息已接收,正在处理..."})
|
||||
|
||||
// 话术匹配并发布响应
|
||||
// status 暂时为空,表示任意行为匹配
|
||||
// isPushed=true表示已直接推送响应(话术匹配),无需ack
|
||||
// isPushed=false表示转发到RAGFlow,需要ack告知用户正在处理
|
||||
// 异步处理消息,避免阻塞WebSocket连接,使用工作池限制并发
|
||||
s.workerPool.Add(ctx, func(poolCtx context.Context) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
glog.Errorf(ctx, "WebSocket处理消息失败: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
// 创建带有accountName的context,供GetTenantInfo使用
|
||||
//newCtx := ctx
|
||||
//if wsConn.AccountName != "" {
|
||||
// newCtx = context.WithValue(ctx, "accountName", wsConn.AccountName)
|
||||
//}
|
||||
|
||||
//isPushed, err := Speechcraft.ProcessAndPublish(newCtx, wsConn.UserId, wsConn.Platform, wsConn.TenantId, content, "", wsConn.AccountName)
|
||||
//if err != nil {
|
||||
// jaeger.RecordError(ctx, err, "话术处理失败")
|
||||
// s.writeJSON(conn, &dto.WebSocketPushMsg{Type: "error", Message: "消息处理失败"})
|
||||
// continue
|
||||
//}
|
||||
|
||||
// 只在转发到RAGFlow时发送ack(Go直接返回的不需要ack)
|
||||
//if !isPushed {
|
||||
// s.writeJSON(conn, &dto.WebSocketPushMsg{Type: "ack", Message: "消息已接收,正在处理..."})
|
||||
//}
|
||||
var content string
|
||||
content, err = SessionToolService.PushDialog(ctx, wsConn.UserId, questionContent, wsConn.AccountInfo, wsConn.Headers)
|
||||
if err != nil {
|
||||
s.writeJSON(conn, &dto.WebSocketPushMsg{
|
||||
Type: "error",
|
||||
Content: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
// 发送答案给前端
|
||||
s.writeJSON(conn, &dto.WebSocketPushMsg{
|
||||
Type: "answer",
|
||||
Content: content,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// writeJSON 发送 JSON 消息
|
||||
// writeJSON 发送 JSON 消息(带错误处理)
|
||||
func (s *accountWebsocketService) writeJSON(conn *websocket.Conn, data interface{}) {
|
||||
jsonBytes, _ := gjson.Encode(data)
|
||||
conn.WriteMessage(websocket.TextMessage, jsonBytes)
|
||||
}
|
||||
|
||||
// getGreeting 获取客服账号的开场白
|
||||
func (s *accountWebsocketService) getGreeting(ctx context.Context, accountName string) (res *entity.Account, err error) {
|
||||
res, err = dao.Account.GetByAccountName(ctx, &dto.GetByAccountNameReq{
|
||||
AccountName: accountName,
|
||||
})
|
||||
jsonBytes, err := gjson.Encode(data)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err, "查询客服账号开场白失败")
|
||||
glog.Errorf(ctx, "查询开场白失败: %v", err)
|
||||
glog.Errorf(context.Background(), "JSON编码失败: %v", err)
|
||||
return
|
||||
}
|
||||
if err := conn.WriteMessage(websocket.TextMessage, jsonBytes); err != nil {
|
||||
glog.Errorf(context.Background(), "WebSocket写入失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *accountWebsocketService) AccountMsg(ctx context.Context, msg any) (err error) {
|
||||
msgStr := gconv.Map(msg)
|
||||
if g.IsEmpty(msgStr) {
|
||||
g.Log().Error(ctx, "DocsChunkMsg err:", "msg is empty")
|
||||
return
|
||||
}
|
||||
// 直接通过 key 获取连接
|
||||
connAny := s.connections.Get(gconv.String(msgStr["key"]))
|
||||
if connAny != nil {
|
||||
wsConn := connAny.(*accountWsConnection)
|
||||
s.writeJSON(wsConn.Conn, &dto.WebSocketPushMsg{
|
||||
Type: "delay_msg",
|
||||
Content: gconv.String(msgStr["data"]),
|
||||
})
|
||||
}
|
||||
g.Log().Info(ctx, "DocsChunkMsg:", msgStr)
|
||||
return
|
||||
}
|
||||
|
||||
// Close 释放所有资源
|
||||
func (s *accountWebsocketService) Close() {
|
||||
if s.workerPool != nil {
|
||||
s.workerPool.Close()
|
||||
glog.Info(context.Background(), "WebSocket工作池已关闭")
|
||||
}
|
||||
|
||||
// 关闭所有WebSocket连接
|
||||
s.connections.LockFunc(func(m map[string]interface{}) {
|
||||
for key, conn := range m {
|
||||
if wsConn, ok := conn.(*accountWsConnection); ok && wsConn.Conn != nil {
|
||||
wsConn.Conn.Close()
|
||||
glog.Infof(context.Background(), "强制关闭WebSocket连接 - %s", key)
|
||||
}
|
||||
}
|
||||
})
|
||||
s.connections.Clear()
|
||||
glog.Info(context.Background(), "WebSocket连接池已清空")
|
||||
}
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
// Package service - 归档服务
|
||||
// 功能:对话记录从MongoDB归档到Elasticsearch,定时任务+手动触发
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/dao"
|
||||
"customer-server/model/entity"
|
||||
|
||||
"gitea.com/red-future/common/elasticsearch"
|
||||
"gitea.com/red-future/common/jaeger"
|
||||
"gitea.com/red-future/common/redis"
|
||||
"github.com/gogf/gf/v2/os/gcron"
|
||||
"github.com/gogf/gf/v2/os/glog"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
// archiveService 归档服务
|
||||
type archiveService struct{}
|
||||
|
||||
// ArchiveService 归档服务单例
|
||||
var ArchiveService = new(archiveService)
|
||||
|
||||
// 归档锁的键名和过期时间
|
||||
const (
|
||||
archiveLockKey = "archive:monthly:lock"
|
||||
archiveLockExpire = 3600 // 1 小时
|
||||
)
|
||||
|
||||
// MonthlyArchive 月度归档主流程
|
||||
// 参数: ctx - 上下文
|
||||
// 返回: err - 错误信息
|
||||
// 功能: 将MongoDB对话记录归档到Elasticsearch,流程:1.复制到临时表 2.删除原表 3.写入ES 4.删临时表
|
||||
// 注意: 使用分布式锁确保只有一个节点执行
|
||||
func (s *archiveService) MonthlyArchive(ctx context.Context) (err error) {
|
||||
// 获取分布式锁,确保只有一个节点执行归档
|
||||
if !redis.TryLock(ctx, archiveLockKey, archiveLockExpire) {
|
||||
glog.Info(ctx, "其他节点正在执行归档,本节点跳过")
|
||||
return
|
||||
}
|
||||
defer redis.Unlock(ctx, archiveLockKey)
|
||||
|
||||
beginTime := gtime.Now()
|
||||
glog.Info(ctx, "========== 开始月度归档 ==========")
|
||||
|
||||
// 计算归档时间范围
|
||||
now := gtime.Now()
|
||||
var archiveStart, archiveEnd *gtime.Time
|
||||
|
||||
testMode := GetConfigBool(ctx, "archive.testMode")
|
||||
if testMode {
|
||||
// 测试模式:归档最近 7 天
|
||||
archiveStart = now.AddDate(0, 0, -7)
|
||||
archiveEnd = now
|
||||
glog.Infof(ctx, "[测试模式] 归档时间范围: %s 至 %s(最近 7 天)",
|
||||
archiveStart.Format("Y-m-d H:i:s"),
|
||||
archiveEnd.Format("Y-m-d H:i:s"))
|
||||
} else {
|
||||
// 生产模式:归档上个月整月数据
|
||||
// 本月第一天 00:00:00
|
||||
archiveEnd = gtime.NewFromStr(now.Format("Y-m") + "-01 00:00:00")
|
||||
// 上个月第一天 00:00:00
|
||||
archiveStart = archiveEnd.AddDate(0, -1, 0)
|
||||
// 计算上个月天数
|
||||
daysInLastMonth := archiveEnd.AddDate(0, 0, -1).Day()
|
||||
glog.Infof(ctx, "归档时间范围: %s 至 %s(共 %d 天)",
|
||||
archiveStart.Format("Y-m-d H:i:s"),
|
||||
archiveEnd.AddDate(0, 0, -1).Format("Y-m-d 23:59:59"),
|
||||
daysInLastMonth)
|
||||
}
|
||||
|
||||
// Step 1: 复制数据到临时表
|
||||
glog.Info(ctx, "[Step 1/4] 复制数据到临时表...")
|
||||
copyCount, err := dao.Archive.CopyToTempByRange(ctx, archiveStart.Time, archiveEnd.Time)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err, "复制数据到临时表失败")
|
||||
return
|
||||
}
|
||||
if copyCount == 0 {
|
||||
glog.Info(ctx, "没有需要归档的数据,跳过")
|
||||
return
|
||||
}
|
||||
glog.Infof(ctx, "复制完成,共 %d 条记录", copyCount)
|
||||
|
||||
// Step 2: 删除原表数据
|
||||
glog.Info(ctx, "[Step 2/4] 删除原表数据...")
|
||||
deleteCount, err := dao.Archive.DeleteByTempIds(ctx)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err, "删除原表数据失败")
|
||||
// 不返回错误,继续尝试后续步骤
|
||||
}
|
||||
glog.Infof(ctx, "删除完成,共 %d 条记录", deleteCount)
|
||||
|
||||
// Step 3: 写入 ES
|
||||
glog.Info(ctx, "[Step 3/4] 写入 ES...")
|
||||
if err = s.writeToES(ctx); err != nil {
|
||||
jaeger.RecordError(ctx, err, "写入 ES 失败")
|
||||
// 不返回错误,临时表数据保留,下次可以重试
|
||||
return
|
||||
}
|
||||
|
||||
// Step 4: 删除临时表
|
||||
glog.Info(ctx, "[Step 4/4] 删除临时表...")
|
||||
if err = dao.Archive.DropTempCollection(ctx); err != nil {
|
||||
jaeger.RecordError(ctx, err, "删除临时表失败")
|
||||
return
|
||||
}
|
||||
|
||||
elapsed := gtime.Now().Sub(beginTime)
|
||||
glog.Infof(ctx, "========== 月度归档完成,耗时: %s ==========", elapsed)
|
||||
return
|
||||
}
|
||||
|
||||
// writeToES 将临时表数据写入 ES
|
||||
func (s *archiveService) writeToES(ctx context.Context) (err error) {
|
||||
// 确保索引存在
|
||||
if err = elasticsearch.CreateIndexIfNotExists(ctx, entity.ConversationESIndex, entity.ConversationESMapping); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 获取临时表数据
|
||||
tempData, err := dao.Archive.GetTempData(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if len(tempData) == 0 {
|
||||
glog.Info(ctx, "临时表无数据")
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为 ES 文档格式
|
||||
now := gtime.Now().Time
|
||||
docs := make([]interface{}, 0, len(tempData))
|
||||
for _, temp := range tempData {
|
||||
doc := entity.ConversationES{
|
||||
Id: temp.OriginalId,
|
||||
UserId: temp.UserId,
|
||||
Platform: temp.Platform,
|
||||
SessionId: temp.SessionId,
|
||||
Question: temp.Question,
|
||||
Answer: temp.Answer,
|
||||
MessageId: temp.MessageId,
|
||||
MsgTime: *temp.MsgTime, // 解引用指针类型
|
||||
TenantId: gconv.String(temp.TenantId),
|
||||
CreatedAt: *temp.CreatedAt, // 解引用指针类型
|
||||
UpdatedAt: *temp.UpdatedAt, // 解引用指针类型
|
||||
ArchivedAt: now,
|
||||
}
|
||||
docs = append(docs, doc)
|
||||
}
|
||||
|
||||
// 批量写入 ES
|
||||
batchSize := GetConfigInt(ctx, "archive.esBatchSize")
|
||||
for i := 0; i < len(docs); i += batchSize {
|
||||
end := i + batchSize
|
||||
if end > len(docs) {
|
||||
end = len(docs)
|
||||
}
|
||||
if err = elasticsearch.BulkIndex(ctx, entity.ConversationESIndex, docs[i:end]); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
glog.Infof(ctx, "ES 写入完成,共 %d 条记录", len(docs))
|
||||
return
|
||||
}
|
||||
|
||||
// ============== 定时任务(月度归档)==============
|
||||
|
||||
// StartArchiveCron 启动归档定时任务
|
||||
// 默认每月 1 号凌晨 3 点执行
|
||||
func (s *archiveService) StartCron(ctx context.Context) {
|
||||
cronExpr := GetConfigString(ctx, "archive.cron")
|
||||
enabled := GetConfigBool(ctx, "archive.enabled")
|
||||
|
||||
if !enabled {
|
||||
glog.Info(ctx, "月度归档定时任务已禁用")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, span := jaeger.NewSpan(ctx, "cron.archive.register")
|
||||
defer span.End()
|
||||
|
||||
_, err := gcron.Add(ctx, cronExpr, func(ctx context.Context) {
|
||||
ctx, span := jaeger.NewSpan(ctx, "cron.archive.monthly")
|
||||
defer span.End()
|
||||
|
||||
glog.Info(ctx, "月度归档定时任务开始执行")
|
||||
if err := s.MonthlyArchive(ctx); err != nil {
|
||||
jaeger.RecordError(ctx, err, "月度归档执行失败")
|
||||
}
|
||||
}, "monthly-archive")
|
||||
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err, "注册月度归档定时任务失败")
|
||||
return
|
||||
}
|
||||
|
||||
glog.Infof(ctx, "月度归档定时任务已启动 - Cron: %s", cronExpr)
|
||||
}
|
||||
|
||||
// RunNow 立即执行归档(用于手动触发或测试)
|
||||
func (s *archiveService) RunNow(ctx context.Context) error {
|
||||
return s.MonthlyArchive(ctx)
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/glog"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
"github.com/hashicorp/consul/api"
|
||||
)
|
||||
|
||||
var (
|
||||
configCache = make(map[string]interface{}) // 动态配置缓存
|
||||
configMu sync.RWMutex // 读写锁
|
||||
consulClient *api.Client // Consul客户端
|
||||
startOnce sync.Once // 确保只启动一次监听
|
||||
)
|
||||
|
||||
// InitConsulWatcher 初始化Consul配置监听
|
||||
func InitConsulWatcher(ctx context.Context) error {
|
||||
consulAddr := g.Cfg().MustGet(ctx, "consul.address").String()
|
||||
if consulAddr == "" {
|
||||
glog.Warning(ctx, "Consul未配置,使用本地config.yml")
|
||||
return nil
|
||||
}
|
||||
|
||||
config := api.DefaultConfig()
|
||||
config.Address = consulAddr
|
||||
client, err := api.NewClient(config)
|
||||
if err != nil {
|
||||
glog.Errorf(ctx, "Consul客户端初始化失败: %v", err)
|
||||
return err
|
||||
}
|
||||
consulClient = client
|
||||
|
||||
startOnce.Do(func() {
|
||||
go watchAllConfigsV2(ctx)
|
||||
glog.Info(ctx, "Consul配置监听已启动")
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// watchAllConfigsV2 监听Consul前缀下所有配置
|
||||
func watchAllConfigsV2(ctx context.Context) {
|
||||
const prefix = "customerService/"
|
||||
kv := consulClient.KV()
|
||||
var lastIndex uint64
|
||||
|
||||
for {
|
||||
pairs, meta, err := kv.List(prefix, &api.QueryOptions{
|
||||
WaitIndex: lastIndex,
|
||||
WaitTime: 5 * time.Minute,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
glog.Errorf(ctx, "Consul查询失败: %v", err)
|
||||
time.Sleep(5 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
if meta.LastIndex != lastIndex {
|
||||
lastIndex = meta.LastIndex
|
||||
updateConfigCacheDiff(ctx, pairs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// updateConfigCacheDiff 增量更新配置缓存
|
||||
func updateConfigCacheDiff(ctx context.Context, pairs api.KVPairs) {
|
||||
configMu.Lock()
|
||||
defer configMu.Unlock()
|
||||
|
||||
for _, pair := range pairs {
|
||||
key := strings.TrimPrefix(pair.Key, "customerService/")
|
||||
key = strings.ReplaceAll(key, "/", ".")
|
||||
|
||||
newVal := parseValue(pair.Value)
|
||||
oldVal, exists := configCache[key]
|
||||
|
||||
if !exists || oldVal != newVal {
|
||||
logConfigChange(ctx, key, gconv.String(oldVal), gconv.String(newVal))
|
||||
configCache[key] = newVal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseValue 自动推断配置值类型
|
||||
func parseValue(value []byte) interface{} {
|
||||
str := string(value)
|
||||
|
||||
if i, err := strconv.Atoi(str); err == nil {
|
||||
return i
|
||||
}
|
||||
|
||||
if b, err := strconv.ParseBool(str); err == nil {
|
||||
return b
|
||||
}
|
||||
|
||||
var arr []interface{}
|
||||
if err := json.Unmarshal(value, &arr); err == nil {
|
||||
return arr
|
||||
}
|
||||
|
||||
return str
|
||||
}
|
||||
|
||||
// GetConfigInt 读取int配置
|
||||
func GetConfigInt(ctx context.Context, key string) int {
|
||||
configMu.RLock()
|
||||
val, ok := configCache[key]
|
||||
configMu.RUnlock()
|
||||
|
||||
if ok {
|
||||
return gconv.Int(val)
|
||||
}
|
||||
|
||||
return g.Cfg().MustGet(ctx, key).Int()
|
||||
}
|
||||
|
||||
// GetConfigString 读取string配置
|
||||
func GetConfigString(ctx context.Context, key string) string {
|
||||
configMu.RLock()
|
||||
val, ok := configCache[key]
|
||||
configMu.RUnlock()
|
||||
|
||||
if ok {
|
||||
return gconv.String(val)
|
||||
}
|
||||
|
||||
return g.Cfg().MustGet(ctx, key).String()
|
||||
}
|
||||
|
||||
// GetConfigBool 读取bool配置
|
||||
func GetConfigBool(ctx context.Context, key string) bool {
|
||||
configMu.RLock()
|
||||
val, ok := configCache[key]
|
||||
configMu.RUnlock()
|
||||
|
||||
if ok {
|
||||
return gconv.Bool(val)
|
||||
}
|
||||
|
||||
return g.Cfg().MustGet(ctx, key).Bool()
|
||||
}
|
||||
|
||||
// GetConfigStringSlice 读取字符串数组配置
|
||||
func GetConfigStringSlice(ctx context.Context, key string) []string {
|
||||
configMu.RLock()
|
||||
val, ok := configCache[key]
|
||||
configMu.RUnlock()
|
||||
|
||||
if ok {
|
||||
if arr, ok := val.([]interface{}); ok {
|
||||
result := make([]string, len(arr))
|
||||
for i, v := range arr {
|
||||
result[i] = gconv.String(v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
return g.Cfg().MustGet(ctx, key).Strings()
|
||||
}
|
||||
|
||||
// GetInstanceConfigStringSlice 读取实例级字符串数组配置(支持实例级负载隔离)
|
||||
// 优先级:实例专用配置 > 全局Consul配置 > config.yml
|
||||
func GetInstanceConfigStringSlice(ctx context.Context, key string) []string {
|
||||
// 获取实例ID(环境变量优先,hostname备用)
|
||||
instanceID := os.Getenv("INSTANCE_ID")
|
||||
if instanceID == "" {
|
||||
hostname, err := os.Hostname()
|
||||
if err == nil {
|
||||
instanceID = hostname
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有实例ID,先查找实例专用配置
|
||||
if instanceID != "" {
|
||||
instanceKey := fmt.Sprintf("instance.%s.%s", instanceID, key)
|
||||
configMu.RLock()
|
||||
val, ok := configCache[instanceKey]
|
||||
configMu.RUnlock()
|
||||
|
||||
if ok {
|
||||
if arr, ok := val.([]interface{}); ok {
|
||||
result := make([]string, len(arr))
|
||||
for i, v := range arr {
|
||||
result[i] = gconv.String(v)
|
||||
}
|
||||
glog.Debugf(ctx, "🎯 使用实例专用配置: %s = %v", instanceKey, result)
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 未找到实例配置,fallback到全局配置
|
||||
return GetConfigStringSlice(ctx, key)
|
||||
}
|
||||
|
||||
// logConfigChange 记录配置变更
|
||||
func logConfigChange(ctx context.Context, key, oldVal, newVal string) {
|
||||
glog.Infof(ctx, "📝 配置变更: %s = %s → %s", key, oldVal, newVal)
|
||||
}
|
||||
@@ -1,353 +0,0 @@
|
||||
// Package service - 对话服务
|
||||
// 功能:处理RAGFlow响应、批量落库、卡片触发逻辑
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/dao"
|
||||
"customer-server/model/entity"
|
||||
|
||||
"gitea.com/red-future/common/jaeger"
|
||||
"gitea.com/red-future/common/rabbitmq"
|
||||
"gitea.com/red-future/common/redis"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/glog"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
// conversationService 对话服务(操作 conversation 表)
|
||||
type conversationService struct{}
|
||||
|
||||
// ConversationService 对话服务单例
|
||||
var ConversationService = new(conversationService)
|
||||
|
||||
// ============== RabbitMQ 消费者(RAGFlow 响应消息)==============
|
||||
|
||||
// ResponseConsumer RabbitMQ 响应消费者
|
||||
type ResponseConsumer struct {
|
||||
queueName string
|
||||
consumer *rabbitmq.Consumer
|
||||
}
|
||||
|
||||
// NewResponseConsumer 创建响应消费者
|
||||
// 参数: ctx - 上下文
|
||||
// 返回: ResponseConsumer - 响应消费者实例
|
||||
// 功能: 创建当前实例的唯一响应队列,支持多实例部署
|
||||
func NewResponseConsumer(ctx context.Context) *ResponseConsumer {
|
||||
// 从配置读取基础队列名(用于生成唯一实例队列名)
|
||||
baseQueue := GetConfigString(ctx, "rabbitmq.responseQueue")
|
||||
|
||||
// 生成当前实例的唯一队列名:ragflow.response.queue.{hostname}.{uuid8}
|
||||
// 支持多实例部署,每个实例有独立响应队列
|
||||
queueName := rabbitmq.GetInstanceQueueName(baseQueue)
|
||||
glog.Infof(ctx, "响应队列动态生成 - 实例队列: %s", queueName)
|
||||
|
||||
return &ResponseConsumer{
|
||||
queueName: queueName,
|
||||
}
|
||||
}
|
||||
|
||||
// Start 启动消费者
|
||||
// 参数: ctx - 上下文
|
||||
// 返回: err - 错误信息
|
||||
// 功能: 声明并绑定当前实例的响应队列,开始消费RAGFlow响应消息
|
||||
func (c *ResponseConsumer) Start(ctx context.Context) (err error) {
|
||||
glog.Infof(ctx, "RabbitMQ 响应消费者启动 - Queue: %s", c.queueName)
|
||||
|
||||
// 声明当前实例的动态响应队列
|
||||
if err = rabbitmq.DeclareQueue(ctx, &rabbitmq.QueueConfig{
|
||||
Name: c.queueName,
|
||||
Durable: true,
|
||||
}); err != nil {
|
||||
glog.Errorf(ctx, "声明动态响应队列失败: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// 绑定队列到 Exchange(使用队列名作为routing key,实现精确路由)
|
||||
// message发送消息时会使用队列名作为routing key
|
||||
if err = rabbitmq.BindQueue(ctx, &rabbitmq.BindingConfig{
|
||||
Queue: c.queueName,
|
||||
Exchange: "ragflow.response",
|
||||
RoutingKey: c.queueName, // 使用队列名,只接收发给自己的消息
|
||||
}); err != nil {
|
||||
glog.Errorf(ctx, "绑定动态响应队列失败: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
glog.Infof(ctx, "动态响应队列已绑定: %s -> ragflow.response (routingKey=#)", c.queueName)
|
||||
|
||||
c.consumer = rabbitmq.NewConsumer(c.queueName, handleResponse)
|
||||
return c.consumer.Start(ctx)
|
||||
}
|
||||
|
||||
// Stop 停止消费者
|
||||
// 参数: ctx - 上下文
|
||||
// 功能: 停止消费RAGFlow响应消息
|
||||
func (c *ResponseConsumer) Stop(ctx context.Context) {
|
||||
if c.consumer != nil {
|
||||
c.consumer.Stop(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// ============== 卡片触发配置(待接入小红书卡片接口后修改)==============
|
||||
//
|
||||
// 【用户状态存储】
|
||||
// 使用 Redis Hash 存储用户会话状态(阶段+对话计数),统一5分钟TTL
|
||||
// Key: ragflow:user:state:{userId}_{platform}
|
||||
// Fields: stage(阶段)、count(对话计数)
|
||||
//
|
||||
// 【状态定义】
|
||||
// 状态0:走AI模型 | 状态1:打招呼 | 状态2:业务咨询 | 状态3:发卡片
|
||||
//
|
||||
// 【卡片触发逻辑】
|
||||
// 对话轮数>=配置值时,更新状态为3,并发送卡片消息
|
||||
// 配置项:config.yml中的card.triggerCount(默认5轮)
|
||||
//
|
||||
// 【待接入小红书卡片 API 后修改位置】
|
||||
// checkAndSendCard() 函数中的 cardMessage 变量
|
||||
|
||||
const (
|
||||
// ConversationFlushDelaySeconds 对话缓存延时落库时间(秒)
|
||||
ConversationFlushDelaySeconds = 600 // 10分钟
|
||||
)
|
||||
|
||||
// ============== 延时落库消费者 ==============
|
||||
|
||||
// DelayedFlushMessage 延时落库消息(按sessionId)
|
||||
type DelayedFlushMessage struct {
|
||||
SessionId string `json:"sessionId"`
|
||||
}
|
||||
|
||||
// DelayedFlushConsumer 延时落库消费者
|
||||
type DelayedFlushConsumer struct {
|
||||
queueName string
|
||||
consumer *rabbitmq.Consumer
|
||||
}
|
||||
|
||||
// NewDelayedFlushConsumer 创建延时落库消费者
|
||||
func NewDelayedFlushConsumer(ctx context.Context) *DelayedFlushConsumer {
|
||||
return &DelayedFlushConsumer{
|
||||
queueName: "conversation.flush.queue",
|
||||
}
|
||||
}
|
||||
|
||||
// Start 启动消费者
|
||||
func (c *DelayedFlushConsumer) Start(ctx context.Context) (err error) {
|
||||
glog.Infof(ctx, "延时落库消费者启动 - Queue: %s", c.queueName)
|
||||
c.consumer = rabbitmq.NewConsumer(c.queueName, handleDelayedFlush)
|
||||
return c.consumer.Start(ctx)
|
||||
}
|
||||
|
||||
// Stop 停止消费者
|
||||
func (c *DelayedFlushConsumer) Stop(ctx context.Context) {
|
||||
if c.consumer != nil {
|
||||
c.consumer.Stop(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// handleDelayedFlush 处理延时落库消息
|
||||
func handleDelayedFlush(ctx context.Context, body []byte) error {
|
||||
var msg DelayedFlushMessage
|
||||
if err := gjson.DecodeTo(body, &msg); err != nil {
|
||||
glog.Errorf(ctx, "解析延时落库消息失败: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
glog.Infof(ctx, "收到延时落库消息 - SessionId: %s", msg.SessionId)
|
||||
|
||||
// 检查是否有未落库的缓存
|
||||
count, err := redis.GetCachedConversationCount(ctx, msg.SessionId)
|
||||
if err != nil {
|
||||
glog.Errorf(ctx, "获取缓存数量失败: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
glog.Debugf(ctx, "无需落库(缓存为空或已落库)- SessionId: %s", msg.SessionId)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 执行落库
|
||||
if err = flushConversationCache(ctx, msg.SessionId); err != nil {
|
||||
glog.Errorf(ctx, "延时落库失败: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
glog.Infof(ctx, "延时落库完成 - SessionId: %s", msg.SessionId)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 延时落库发布器(单例)
|
||||
var delayedFlushPublisher *rabbitmq.Publisher
|
||||
|
||||
// getDelayedFlushPublisher 获取延时落库发布器
|
||||
func getDelayedFlushPublisher() *rabbitmq.Publisher {
|
||||
if delayedFlushPublisher == nil {
|
||||
delayedFlushPublisher = rabbitmq.NewPublisher("conversation.flush.delayed", "flush")
|
||||
}
|
||||
return delayedFlushPublisher
|
||||
}
|
||||
|
||||
// sendDelayedFlushMessage 发送延时落库消息
|
||||
func sendDelayedFlushMessage(ctx context.Context, sessionId string) error {
|
||||
msg := &DelayedFlushMessage{
|
||||
SessionId: sessionId,
|
||||
}
|
||||
return getDelayedFlushPublisher().PublishDelayed(ctx, msg, ConversationFlushDelaySeconds)
|
||||
}
|
||||
|
||||
// handleResponse 处理 RabbitMQ 消息(幂等)
|
||||
// 落库逻辑:前5句缓存到Redis,第5句时批量落库MongoDB,超过5句不落库
|
||||
func handleResponse(ctx context.Context, body []byte) error {
|
||||
ctx, span := jaeger.NewSpan(ctx, "consumer.response")
|
||||
defer span.End()
|
||||
|
||||
glog.Infof(ctx, ">>> handleResponse 被调用,消息长度: %d", len(body))
|
||||
|
||||
// 解析消息到结构体
|
||||
var msg redis.ResponseStreamMessage
|
||||
if err := gjson.DecodeTo(body, &msg); err != nil {
|
||||
jaeger.RecordError(ctx, err, "解析响应消息失败")
|
||||
return err
|
||||
}
|
||||
|
||||
glog.Infof(ctx, "收到 RAGFlow 响应 - 用户: %s, MessageId: %s", msg.UserId, msg.MessageId)
|
||||
|
||||
// 1. 获取当前对话轮数
|
||||
state, err := redis.GetUserState(ctx, msg.UserId, msg.Platform)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err, "获取用户状态失败")
|
||||
}
|
||||
count := state.Count
|
||||
|
||||
// 2. 根据轮数决定落库策略
|
||||
cardTriggerCount := g.Cfg().MustGet(ctx, "card.triggerCount", 5).Int64()
|
||||
if count <= cardTriggerCount {
|
||||
// 前N句:缓存到Redis(按sessionId)
|
||||
msgTime := gtime.NewFromTimeStamp(msg.Timestamp).Time
|
||||
conversation := &entity.Conversation{
|
||||
UserId: msg.UserId,
|
||||
Platform: msg.Platform,
|
||||
SessionId: msg.SessionId,
|
||||
Question: msg.Question,
|
||||
Answer: msg.Content,
|
||||
MessageId: msg.MessageId,
|
||||
MsgTime: &msgTime, // 取地址赋值给指针类型
|
||||
}
|
||||
conversation.TenantId = msg.TenantId
|
||||
|
||||
// 序列化后缓存(使用sessionId作为key)
|
||||
data, _ := gjson.Encode(conversation)
|
||||
if cacheErr := redis.CacheConversation(ctx, msg.SessionId, data); cacheErr != nil {
|
||||
jaeger.RecordError(ctx, cacheErr, "缓存对话记录失败")
|
||||
} else {
|
||||
glog.Debugf(ctx, "对话已缓存到 Redis - SessionId: %s, 第 %d 轮", msg.SessionId, count)
|
||||
}
|
||||
|
||||
// 第1句时:发送10分钟延时落库消息(兜底)
|
||||
if count == 1 {
|
||||
if delayErr := sendDelayedFlushMessage(ctx, msg.SessionId); delayErr != nil {
|
||||
glog.Warningf(ctx, "发送延时落库消息失败: %v", delayErr)
|
||||
}
|
||||
}
|
||||
|
||||
// 第N句时:立即批量落库
|
||||
if count == cardTriggerCount {
|
||||
if flushErr := flushConversationCache(ctx, msg.SessionId); flushErr != nil {
|
||||
jaeger.RecordError(ctx, flushErr, "批量落库失败")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 超过N句:不落库(已发卡片)
|
||||
glog.Debugf(ctx, "第 %d 轮(>%d),跳过落库 - 用户: %s", count, cardTriggerCount, msg.UserId)
|
||||
}
|
||||
|
||||
// 3. 推送给 WebSocket 用户(无论是否落库都推送)
|
||||
glog.Infof(ctx, "准备推送 WebSocket - 用户: %s_%s, 内容长度: %d", msg.UserId, msg.Platform, len(msg.Content))
|
||||
if err = WebSocket.PushRAGFlowResponse(ctx, msg.TenantId, msg.UserId, msg.Platform, msg.Content); err != nil {
|
||||
jaeger.RecordError(ctx, err, "推送 WebSocket 失败")
|
||||
} else {
|
||||
glog.Infof(ctx, "WebSocket 推送成功 - 用户: %s_%s", msg.UserId, msg.Platform)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// flushConversationCache 将Redis缓存的对话批量落库到MongoDB
|
||||
func flushConversationCache(ctx context.Context, sessionId string) error {
|
||||
// 获取缓存的对话列表
|
||||
cached, err := redis.GetCachedConversations(ctx, sessionId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(cached) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 反序列化
|
||||
list := make([]*entity.Conversation, 0, len(cached))
|
||||
for _, data := range cached {
|
||||
var conv entity.Conversation
|
||||
if decErr := gjson.DecodeTo([]byte(data), &conv); decErr != nil {
|
||||
glog.Warningf(ctx, "反序列化对话失败: %v", decErr)
|
||||
continue
|
||||
}
|
||||
list = append(list, &conv)
|
||||
}
|
||||
|
||||
// 批量插入MongoDB
|
||||
if len(list) > 0 {
|
||||
if insertErr := dao.Conversation.BatchInsert(ctx, list); insertErr != nil {
|
||||
return insertErr
|
||||
}
|
||||
glog.Infof(ctx, "批量落库成功 - SessionId: %s, 共 %d 条", sessionId, len(list))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkCardBeforeProcess 检查对话轮数,达到阈值时发卡片
|
||||
// 返回 handled=true 表示已处理(发送卡片),调用方应跳过后续话术处理
|
||||
func checkCardBeforeProcess(ctx context.Context, tenantId, userId, platform string) (handled bool, err error) {
|
||||
// 获取用户当前状态
|
||||
state, err := redis.GetUserState(ctx, userId, platform)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 状态5(未选择方向)时不计数,等用户选择方向后再开始计数
|
||||
if state.Stage == 5 {
|
||||
glog.Debugf(ctx, "用户 %s_%s 处于状态5(未选择方向),跳过计数", userId, platform)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// 增加对话计数
|
||||
count, err := redis.IncrUserCount(ctx, userId, platform)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
glog.Infof(ctx, "用户 %s_%s 当前对话轮数: %d", userId, platform, count)
|
||||
|
||||
// 对话>=配置轮数,发卡片并跳过话术(从配置值开始就发卡片,不再调用AI)
|
||||
cardTriggerCount := g.Cfg().MustGet(ctx, "card.triggerCount", 5).Int64()
|
||||
if count >= cardTriggerCount {
|
||||
glog.Infof(ctx, "用户 %s_%s 对话第 %d 轮(>=%d),触发发送卡片", userId, platform, count, cardTriggerCount)
|
||||
|
||||
// 更新用户状态为3(发卡片状态)
|
||||
if updateErr := redis.SetUserStage(ctx, userId, platform, 3); updateErr != nil {
|
||||
jaeger.RecordError(ctx, updateErr, "更新用户状态为3失败")
|
||||
}
|
||||
|
||||
cardMessage := "请加一下卡片的联系方式,进行更专业的咨询" // TODO: 替换为实际卡片发送逻辑
|
||||
if pushErr := WebSocket.PushRAGFlowResponse(ctx, tenantId, userId, platform, cardMessage); pushErr != nil {
|
||||
jaeger.RecordError(ctx, pushErr, "推送卡片消息失败")
|
||||
glog.Errorf(ctx, "推送卡片失败 - 用户: %s_%s, 错误: %v", userId, platform, pushErr)
|
||||
err = pushErr
|
||||
return
|
||||
}
|
||||
glog.Infof(ctx, "卡片消息已推送 - 用户: %s_%s", userId, platform)
|
||||
handled = true
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1,855 +0,0 @@
|
||||
// Package service - 客服账号服务
|
||||
// 功能:客服账号的增删改查业务逻辑
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/dao"
|
||||
"customer-server/model/dto"
|
||||
"customer-server/model/entity"
|
||||
"customer-server/util"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.com/red-future/common/beans"
|
||||
commonMongo "gitea.com/red-future/common/db/mongo"
|
||||
"gitea.com/red-future/common/ragflow"
|
||||
"gitea.com/red-future/common/utils"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/text/gstr"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
var CustomerServiceAccount = new(customerServiceAccount)
|
||||
|
||||
type customerServiceAccount struct{}
|
||||
|
||||
// Add 添加客服账号
|
||||
func (s *customerServiceAccount) Add(ctx context.Context, req *dto.AddCustomerServiceAccountReq) (res *dto.AddCustomerServiceAccountRes, err error) {
|
||||
// 1. 检查客服ID是否已存在
|
||||
coll := commonMongo.GetDB().Collection(entity.CustomerServiceAccountCollection)
|
||||
filter := bson.M{"accountName": req.AccountName, "isDeleted": false}
|
||||
count, err := coll.CountDocuments(ctx, filter)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "检查客服ID是否存在失败")
|
||||
}
|
||||
if count > 0 {
|
||||
return nil, gerror.Newf("客服账号名称 '%s' 已存在,请使用其他名称", req.AccountName)
|
||||
}
|
||||
|
||||
// 2. 准备数据(但暂不写入MongoDB)
|
||||
data := &entity.CustomerServiceAccount{}
|
||||
if err = utils.Struct(req, data); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 调试日志:输出请求和转换后的数据
|
||||
g.Log().Infof(ctx, "[Add] 请求数据 - AccountName: %s, Platform: %s, Prompt: %v, Greeting: %v",
|
||||
req.AccountName, req.Platform, req.Prompt, req.Greeting)
|
||||
g.Log().Infof(ctx, "[Add] 转换后Entity - AccountName: %s, Platform: %s, Greeting: %s",
|
||||
data.AccountName, data.Platform, data.Greeting)
|
||||
|
||||
now := gtime.Now().Time
|
||||
data.CreatedAt = &now // 取地址赋值给指针类型
|
||||
data.UpdatedAt = &now // 取地址赋值给指针类型
|
||||
data.IsDeleted = false
|
||||
data.IsDisabled = false
|
||||
|
||||
// 获取或设置tenantId(统一转换为string存储)
|
||||
var tenantId string
|
||||
if req.TenantId != nil {
|
||||
tenantId = gconv.String(req.TenantId)
|
||||
g.Log().Infof(ctx, "使用手动指定的tenant_id: %v", req.TenantId)
|
||||
} else {
|
||||
// 从session获取tenantId
|
||||
tenantInfo, err := util.GetTenantInfo(ctx)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "无法获取租户信息")
|
||||
}
|
||||
tenantId = gconv.String(tenantInfo.TenantId)
|
||||
if tenantId == "" {
|
||||
return nil, gerror.New("无法获取租户ID")
|
||||
}
|
||||
}
|
||||
// 统一使用string类型存储到MongoDB
|
||||
data.TenantId = tenantId
|
||||
|
||||
// 3. 确保租户知识库存在(调用公共方法)
|
||||
datasetId, err := EnsureTenantDataset(ctx, tenantId)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "确保租户知识库存在失败: %v", err)
|
||||
return nil, gerror.Wrap(err, "确保租户知识库存在失败")
|
||||
}
|
||||
|
||||
// 4. 先插入客服账号(必须在创建Chat之前,因为createChatAndSaveConfig内部会通过accountName查询租户ID)
|
||||
err = dao.CustomerServiceAccount.Insert(ctx, data)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "插入客服账号失败")
|
||||
}
|
||||
|
||||
// 5. 处理话术绑定
|
||||
if len(req.SpeechcraftIds) > 0 {
|
||||
g.Log().Infof(ctx, "开始同步话术到RAGFlow: count=%d", len(req.SpeechcraftIds))
|
||||
for _, speechcraftId := range req.SpeechcraftIds {
|
||||
_, err := Speechcraft.SyncToRAGFlow(ctx, speechcraftId, req.AccountName, tenantId)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "同步话术到RAGFlow失败: speechcraft_id=%s, error=%v", speechcraftId, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 创建Chat并保存RAGFlow配置(内部会轮询等待文档解析完成)
|
||||
var assistantDesc string
|
||||
if req.SelfIdentity != nil {
|
||||
assistantDesc = *req.SelfIdentity
|
||||
}
|
||||
if err = s.createChatAndSaveConfig(ctx, req.AccountName, req.Platform, tenantId, datasetId, assistantDesc); err != nil {
|
||||
g.Log().Errorf(ctx, "创建Chat配置失败: %v", err)
|
||||
return nil, gerror.Wrap(err, "创建Chat配置失败")
|
||||
}
|
||||
|
||||
// 7. 如果提供了自定义提示词,调用UpdatePrompt更新
|
||||
if req.Prompt != nil && *req.Prompt != "" {
|
||||
g.Log().Infof(ctx, "创建完成,开始更新自定义提示词")
|
||||
updateReq := &dto.UpdatePromptReq{
|
||||
AccountName: req.AccountName,
|
||||
Prompt: *req.Prompt,
|
||||
}
|
||||
if _, err := RAGFlowConfig.UpdatePrompt(ctx, updateReq); err != nil {
|
||||
g.Log().Errorf(ctx, "更新自定义提示词失败: %v", err)
|
||||
// 不阻断创建流程,提示词可以后续修改
|
||||
} else {
|
||||
g.Log().Infof(ctx, "自定义提示词更新成功")
|
||||
}
|
||||
}
|
||||
|
||||
res = &dto.AddCustomerServiceAccountRes{Id: data.Id.Hex()}
|
||||
return
|
||||
}
|
||||
|
||||
// createChatAndSaveConfig 创建Chat并保存RAGFlow配置(会轮询等待文档解析完成)
|
||||
func (s *customerServiceAccount) createChatAndSaveConfig(ctx context.Context, accountName, platform, tenantId, datasetId, assistantDescription string) error {
|
||||
ragflowClient := ragflow.GetGlobalClient()
|
||||
|
||||
// 1. 轮询检查知识库中是否有解析完成的文档(最多60秒)
|
||||
g.Log().Infof(ctx, "等待知识库文档解析完成: dataset_id=%s", datasetId)
|
||||
maxWaitSeconds := 60
|
||||
|
||||
for i := 0; i < maxWaitSeconds; i++ {
|
||||
listReq := &ragflow.ListDocumentsReq{
|
||||
Page: 1,
|
||||
PageSize: 1,
|
||||
}
|
||||
listRes, err := ragflowClient.ListDocuments(ctx, datasetId, listReq)
|
||||
if err == nil && listRes != nil && len(listRes.Data.Docs) > 0 {
|
||||
doc := listRes.Data.Docs[0]
|
||||
|
||||
// 调试:输出所有状态字段
|
||||
if i == 0 || i%5 == 0 {
|
||||
g.Log().Infof(ctx, "文档状态详情: dataset_id=%s, doc_id=%s, RunStatus=%s, Status=%s, ChunkCount=%d, Progress=%.2f, ProgressMsg=%s",
|
||||
datasetId, doc.Id, doc.RunStatus, doc.Status, doc.ChunkCount, doc.Progress, doc.ProgressMsg)
|
||||
}
|
||||
|
||||
// 检查解析失败状态:Progress < 0 表示解析出错(如embedding模型无法访问)
|
||||
if doc.Progress < 0 {
|
||||
return gerror.Newf("RAGFlow文档解析失败(embedding服务不可用): dataset_id=%s, doc_id=%s, error=%s",
|
||||
datasetId, doc.Id, doc.ProgressMsg)
|
||||
}
|
||||
|
||||
// 检查解析完成的条件:ChunkCount > 0 表示已解析出分块
|
||||
if doc.ChunkCount > 0 {
|
||||
g.Log().Infof(ctx, "知识库文档解析完成: dataset_id=%s, doc_id=%s, chunk_count=%d, 耗时=%d秒",
|
||||
datasetId, doc.Id, doc.ChunkCount, i)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 超时检查
|
||||
if i >= maxWaitSeconds-1 {
|
||||
return gerror.Newf("等待文档解析超时: dataset_id=%s, 已等待%d秒", datasetId, maxWaitSeconds)
|
||||
}
|
||||
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
|
||||
// 2. 生成默认提示词、助理描述并创建Chat
|
||||
promptText := s.generateDefaultPrompt()
|
||||
modelName := s.getDefaultModelName(ctx)
|
||||
|
||||
// 使用传入的assistantDescription,如果为空则使用默认生成
|
||||
var assistantDesc string
|
||||
if assistantDescription != "" {
|
||||
assistantDesc = assistantDescription
|
||||
} else {
|
||||
assistantDesc = s.generateAssistantDescription(accountName, platform)
|
||||
}
|
||||
|
||||
// Chat名称:账号名_平台_租户ID
|
||||
chatName := fmt.Sprintf("%s_%s_%s", accountName, platform, tenantId)
|
||||
|
||||
chatReq := &ragflow.CreateChatReq{
|
||||
Name: chatName,
|
||||
Description: assistantDesc,
|
||||
DatasetIds: []string{datasetId},
|
||||
Prompt: &ragflow.PromptConfig{
|
||||
Prompt: promptText,
|
||||
SimilarityThreshold: 0.2,
|
||||
KeywordsSimilarityWeight: 0.7,
|
||||
TopN: 8,
|
||||
EmptyResponse: "",
|
||||
Variables: []map[string]interface{}{
|
||||
{
|
||||
"key": "knowledge",
|
||||
"optional": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
Llm: &ragflow.Llm{
|
||||
ModelName: modelName,
|
||||
},
|
||||
}
|
||||
|
||||
chat, err := ragflowClient.CreateChat(ctx, chatReq)
|
||||
if err != nil {
|
||||
// 如果Chat名称已存在,尝试查询现有Chat
|
||||
if strings.Contains(err.Error(), "Duplicated chat name") {
|
||||
g.Log().Warningf(ctx, "Chat名称已存在,尝试查询现有Chat: %s", chatName)
|
||||
|
||||
// 查询Chat列表,查找同名的Chat
|
||||
listReq := &ragflow.ListChatsReq{
|
||||
Page: 1,
|
||||
PageSize: 100,
|
||||
}
|
||||
listRes, listErr := ragflowClient.ListChats(ctx, listReq)
|
||||
if listErr != nil {
|
||||
return gerror.Wrapf(listErr, "查询Chat列表失败")
|
||||
}
|
||||
// 检查listRes和Data是否为nil:防止遍历nil切片导致空指针异常
|
||||
if listRes == nil || listRes.Data == nil {
|
||||
return gerror.New("查询Chat列表返回空对象")
|
||||
}
|
||||
|
||||
// 查找同名Chat
|
||||
for _, existingChat := range listRes.Data {
|
||||
if existingChat.Name == chatName {
|
||||
g.Log().Infof(ctx, "找到现有Chat: name=%s, id=%s,复用该Chat", chatName, existingChat.Id)
|
||||
chat = existingChat
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if chat == nil {
|
||||
return gerror.Newf("找不到同名Chat: %s", chatName)
|
||||
}
|
||||
} else {
|
||||
return gerror.Wrapf(err, "创建RAGFlow Chat失败")
|
||||
}
|
||||
}
|
||||
|
||||
// 最终检查chat是否为nil:防止CreateChat返回(nil, nil)或Duplicated情况下未找到同名Chat
|
||||
if chat == nil {
|
||||
return gerror.New("创建RAGFlow Chat返回空对象")
|
||||
}
|
||||
|
||||
// 先查询现有配置,保留用户的自定义设置
|
||||
coll := commonMongo.GetDB().Collection(entity.RAGFlowConfigCollection)
|
||||
filter := bson.M{"accountName": accountName, "isDeleted": false}
|
||||
var existingConfig entity.RAGFlowConfig
|
||||
findErr := coll.FindOne(ctx, filter).Decode(&existingConfig)
|
||||
|
||||
now := gtime.Now().Time
|
||||
|
||||
// 如果已有配置,保留用户自定义的prompt和参数
|
||||
if findErr == nil && !existingConfig.Id.IsZero() {
|
||||
g.Log().Infof(ctx, "保留现有配置的自定义设置,仅更新chat_id和dataset_id")
|
||||
|
||||
// 只更新必要字段,保留用户自定义内容
|
||||
update := bson.M{
|
||||
"$set": bson.M{
|
||||
"chatId": chat.Id,
|
||||
"datasetId": datasetId,
|
||||
"datasetIds": []string{datasetId},
|
||||
"syncStatus": "synced",
|
||||
"updatedAt": now,
|
||||
},
|
||||
}
|
||||
if _, err := coll.UpdateOne(ctx, filter, update); err != nil {
|
||||
return gerror.Wrap(err, "更新RAGFlowConfig失败")
|
||||
}
|
||||
} else {
|
||||
// 首次创建配置,使用默认值
|
||||
g.Log().Infof(ctx, "首次创建RAGFlow配置,使用默认提示词")
|
||||
|
||||
config := &entity.RAGFlowConfig{
|
||||
AccountName: accountName,
|
||||
Platform: platform,
|
||||
DatasetId: datasetId,
|
||||
DatasetIds: []string{datasetId},
|
||||
DatasetName: "租户话术知识库",
|
||||
ChatId: chat.Id,
|
||||
Prompt: promptText,
|
||||
DocumentIds: []string{},
|
||||
SimilarityThreshold: 0.2,
|
||||
KeywordsSimilarityWeight: 0.7,
|
||||
TopN: 8,
|
||||
EmptyResponse: "",
|
||||
SyncStatus: "synced",
|
||||
}
|
||||
// 统一使用string类型存储tenantId到MongoDB
|
||||
config.TenantId = tenantId
|
||||
config.CreatedAt = &now // 取地址赋值给指针类型
|
||||
config.UpdatedAt = &now // 取地址赋值给指针类型
|
||||
config.IsDeleted = false
|
||||
|
||||
update := bson.M{"$set": config}
|
||||
opts := options.UpdateOne().SetUpsert(true)
|
||||
if _, err := coll.UpdateOne(ctx, filter, update, opts); err != nil {
|
||||
return gerror.Wrap(err, "更新RAGFlowConfig失败")
|
||||
}
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "RAGFlowConfig创建/更新成功: account_name=%s, chat_id=%s, dataset_id=%s",
|
||||
accountName, chat.Id, datasetId)
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateDefaultPrompt 生成默认提示词(包含知识库引用)
|
||||
func (s *customerServiceAccount) generateDefaultPrompt() string {
|
||||
// 默认提示词已包含完整的知识库引用格式
|
||||
return `你是一个智能助手,请总结知识库的内容来回答问题,请列举知识库中的数据详细回答。当所有知识库内容都与问题无关时,你的回答必须包括"知识库中未找到您要的答案!"这句话。回答需要考虑聊天历史。
|
||||
|
||||
以下是知识库:
|
||||
{knowledge}
|
||||
以上是知识库。`
|
||||
}
|
||||
|
||||
// generateAssistantDescription 根据客服账号生成助理描述
|
||||
func (s *customerServiceAccount) generateAssistantDescription(accountName, platform string) string {
|
||||
// 根据账号名称判断业务方向
|
||||
lowerName := gstr.ToLower(accountName)
|
||||
|
||||
// 气血方向
|
||||
if gstr.Contains(lowerName, "qixue") || gstr.Contains(lowerName, "气血") {
|
||||
return "专业的女性健康顾问,专注于月经调理、气血养护等健康问题。以温暖关怀的态度为客户提供专业建议,帮助改善身体状况。"
|
||||
}
|
||||
|
||||
// 减肥方向
|
||||
if gstr.Contains(lowerName, "jianfei") || gstr.Contains(lowerName, "减肥") || gstr.Contains(lowerName, "shoushen") || gstr.Contains(lowerName, "瘦身") {
|
||||
return "专业的减肥瘦身顾问,提供科学健康的减肥方案和营养建议。以积极正面的态度鼓励客户坚持健康的生活方式,达成理想体型。"
|
||||
}
|
||||
|
||||
// 护肤方向
|
||||
if gstr.Contains(lowerName, "hufu") || gstr.Contains(lowerName, "护肤") || gstr.Contains(lowerName, "meirong") || gstr.Contains(lowerName, "美容") {
|
||||
return "专业的护肤美容顾问,为客户提供个性化的护肤方案和产品建议。帮助客户解决各类肌肤问题,重现健康光彩。"
|
||||
}
|
||||
|
||||
// 养生方向
|
||||
if gstr.Contains(lowerName, "yangsheng") || gstr.Contains(lowerName, "养生") || gstr.Contains(lowerName, "health") {
|
||||
return "专业的养生健康顾问,提供中医养生、日常保健等全方位健康建议。倡导健康生活方式,帮助客户提升整体身心健康。"
|
||||
}
|
||||
|
||||
// 默认通用描述
|
||||
return fmt.Sprintf("智能客服助理,为您提供专业的咨询服务。基于%s平台,随时解答您的疑问,提供贴心的服务体验。", platform)
|
||||
}
|
||||
|
||||
// getDefaultModelName 获取默认模型名称(Consul优先,config.yml兜底)
|
||||
func (s *customerServiceAccount) getDefaultModelName(ctx context.Context) string {
|
||||
// 使用统一配置读取方法(自动支持Consul动态配置)
|
||||
model := GetConfigString(ctx, "ragflow.default_model")
|
||||
if model != "" {
|
||||
return model
|
||||
}
|
||||
|
||||
// 硬编码兜底(理论上不会走到这里)
|
||||
return "qwen3-235b-a22b-instruct-2507"
|
||||
}
|
||||
|
||||
// Update 更新客服账号
|
||||
func (s *customerServiceAccount) Update(ctx context.Context, req *dto.UpdateCustomerServiceAccountReq) (err error) {
|
||||
// 调试日志:输出请求数据
|
||||
g.Log().Infof(ctx, "[Update] 请求数据 - Id: %s, AccountName: %s, Platform: %s, Prompt: %v, Greeting: %v",
|
||||
req.Id, req.AccountName, req.Platform, req.Prompt, req.Greeting)
|
||||
|
||||
// 1. 检查账号名称是否重复
|
||||
if req.AccountName != "" {
|
||||
existingAccount, err := dao.CustomerServiceAccount.FindByAccountName(ctx, req.AccountName)
|
||||
if err != nil && err != mongo.ErrNoDocuments {
|
||||
return err
|
||||
}
|
||||
if existingAccount != nil && existingAccount.Id.Hex() != req.Id {
|
||||
return gerror.Newf("客服账号名称 '%s' 已被其他账号使用,请使用其他名称", req.AccountName)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 更新基本信息(accountName, platform)
|
||||
if err = dao.CustomerServiceAccount.Update(ctx, req); err != nil {
|
||||
g.Log().Errorf(ctx, "[Update] 更新基本信息失败: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// 3. 查询客服账号获取accountName(用于后续更新)
|
||||
objectId, err := bson.ObjectIDFromHex(req.Id)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "无效的账号ID")
|
||||
}
|
||||
var account entity.CustomerServiceAccount
|
||||
filter := bson.M{"_id": objectId, "isDeleted": false}
|
||||
if err = dao.MongoDAO.FindOne(ctx, filter, &account, entity.CustomerServiceAccountCollection); err != nil {
|
||||
return gerror.Wrap(err, "查询客服账号失败")
|
||||
}
|
||||
|
||||
// 4. 如果提供了开场白,更新greeting字段到customer_service_account表
|
||||
if req.Greeting != nil {
|
||||
g.Log().Infof(ctx, "[Update] 更新开场白 - accountName: %s, greeting: %s", account.AccountName, *req.Greeting)
|
||||
updateReq := &dto.UpdateGreetingReq{
|
||||
AccountName: account.AccountName,
|
||||
Greeting: *req.Greeting,
|
||||
}
|
||||
if err = s.UpdateGreeting(ctx, updateReq); err != nil {
|
||||
g.Log().Errorf(ctx, "[Update] 更新开场白失败: %v", err)
|
||||
return gerror.Wrap(err, "更新开场白失败")
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 如果提供了提示词,更新RAGFlow配置
|
||||
if req.Prompt != nil {
|
||||
g.Log().Infof(ctx, "[Update] 更新提示词 - accountName: %s", account.AccountName)
|
||||
updatePromptReq := &dto.UpdatePromptReq{
|
||||
AccountName: account.AccountName,
|
||||
Prompt: *req.Prompt,
|
||||
}
|
||||
if _, err = RAGFlowConfig.UpdatePrompt(ctx, updatePromptReq); err != nil {
|
||||
g.Log().Errorf(ctx, "[Update] 更新提示词失败: %v", err)
|
||||
return gerror.Wrap(err, "更新提示词失败")
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 如果accountName、platform或selfIdentity有变化,同步更新RAGFlow Chat的name和description
|
||||
if req.AccountName != "" || req.Platform != "" || req.SelfIdentity != nil {
|
||||
if err = s.updateRAGFlowChatInfo(ctx, &account, req); err != nil {
|
||||
g.Log().Errorf(ctx, "[Update] 同步更新RAGFlow Chat失败: %v", err)
|
||||
return gerror.Wrap(err, "同步更新RAGFlow Chat失败")
|
||||
}
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "[Update] 客服账号更新成功 - accountName: %s", account.AccountName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateGreeting 更新开场白
|
||||
func (s *customerServiceAccount) UpdateGreeting(ctx context.Context, req *dto.UpdateGreetingReq) (err error) {
|
||||
// 获取租户ID(租户隔离)
|
||||
user, err := util.GetTenantInfo(ctx)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "获取租户信息失败")
|
||||
}
|
||||
|
||||
filter := bson.M{
|
||||
"accountName": req.AccountName,
|
||||
"tenantId": user.TenantId,
|
||||
"isDeleted": false,
|
||||
}
|
||||
update := bson.M{
|
||||
"$set": bson.M{
|
||||
"greeting": req.Greeting,
|
||||
"updatedAt": gtime.Now().Time,
|
||||
"updater": user.UserName,
|
||||
},
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "[UpdateGreeting] 开始更新开场白 - accountName: %s, tenantId: %v, greeting: %s",
|
||||
req.AccountName, user.TenantId, req.Greeting)
|
||||
g.Log().Infof(ctx, "[UpdateGreeting] 查询条件 - filter: %+v", filter)
|
||||
g.Log().Infof(ctx, "[UpdateGreeting] 更新内容 - update: %+v", update)
|
||||
|
||||
// 使用MongoDAO.UpdateOne(不需要token验证,避免双重验证冲突)
|
||||
matchedCount, modifiedCount, err := dao.MongoDAO.UpdateOne(ctx, filter, update, entity.CustomerServiceAccountCollection)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "[UpdateGreeting] 数据库更新失败 - err: %v", err)
|
||||
return gerror.Wrapf(err, "更新开场白失败")
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "[UpdateGreeting] 数据库更新结果 - matchedCount: %d, modifiedCount: %d", matchedCount, modifiedCount)
|
||||
|
||||
if modifiedCount == 0 {
|
||||
if matchedCount == 0 {
|
||||
g.Log().Errorf(ctx, "[UpdateGreeting] 未找到匹配记录 - accountName: %s, tenantId: %v", req.AccountName, user.TenantId)
|
||||
return gerror.Newf("客服账号不存在或不属于当前租户: accountName=%s", req.AccountName)
|
||||
}
|
||||
g.Log().Warningf(ctx, "[UpdateGreeting] 记录已存在但未修改(内容相同) - accountName: %s", req.AccountName)
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "[UpdateGreeting] ✅ 开场白更新成功 - accountName: %s, 更新记录数: %d", req.AccountName, modifiedCount)
|
||||
return
|
||||
}
|
||||
|
||||
// ToggleStatus 切换客服账号状态(启用/禁用)
|
||||
// 参数: ctx - 上下文,req - 状态切换请求(包含账号ID)
|
||||
// 返回: err - 错误信息
|
||||
// 功能: 在启用和禁用状态之间切换,禁用后不再接收用户消息
|
||||
func (s *customerServiceAccount) ToggleStatus(ctx context.Context, req *dto.ToggleCustomerServiceAccountStatusReq) (err error) {
|
||||
return dao.CustomerServiceAccount.ToggleStatus(ctx, req)
|
||||
}
|
||||
|
||||
// List 获取客服账号列表(关联查询prompt字段)
|
||||
// 参数: ctx - 上下文,req - 列表查询请求(支持分页、平台筛选、状态筛选)
|
||||
// 返回: res - 客服账号列表及分页信息(包含prompt字段),err - 错误信息
|
||||
// 功能: 分页查询客服账号,并从ragflow_config表关联查询每个账号的prompt(去除知识库引用部分)
|
||||
func (s *customerServiceAccount) List(ctx context.Context, req *dto.ListCustomerServiceAccountReq) (res *dto.ListCustomerServiceAccountRes, err error) {
|
||||
list, total, err := dao.CustomerServiceAccount.List(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 关联查询每个账号的prompt(从ragflow_config表)
|
||||
for i := range list {
|
||||
account := list[i]
|
||||
if config, configErr := dao.RAGFlowConfig.FindByAccountName(ctx, account.AccountName); configErr == nil && config != nil {
|
||||
// 去除知识库引用部分,只返回用户输入的业务提示词
|
||||
userPrompt := config.Prompt
|
||||
|
||||
// 查找知识库引用的起始位置(支持多种格式)
|
||||
knowledgePatterns := []string{
|
||||
"\n\n以下是知识库:",
|
||||
"\n\n以下是知识库",
|
||||
"\n以下是知识库:",
|
||||
"\n以下是知识库",
|
||||
}
|
||||
|
||||
// 找到最早出现的知识库引用位置并截断
|
||||
for _, pattern := range knowledgePatterns {
|
||||
if idx := gstr.Pos(userPrompt, pattern); idx >= 0 {
|
||||
userPrompt = gstr.SubStr(userPrompt, 0, idx)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 去除末尾空白
|
||||
userPrompt = gstr.TrimRight(userPrompt)
|
||||
|
||||
// 将处理后的prompt赋值给account
|
||||
list[i].Prompt = userPrompt
|
||||
g.Log().Debugf(ctx, "账号 %s 的prompt已关联(原始长度: %d, 处理后长度: %d)",
|
||||
account.AccountName, len(config.Prompt), len(userPrompt))
|
||||
}
|
||||
}
|
||||
|
||||
res = &dto.ListCustomerServiceAccountRes{
|
||||
List: list,
|
||||
Total: int(total),
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Delete 删除客服账号(软删除 + 删除RAGFlow Chat)
|
||||
// 参数: ctx - 上下文,req - 删除客服账号请求(包含账号ID)
|
||||
// 返回: res - 删除结果信息,err - 错误信息
|
||||
// 功能: 逻辑删除客服账号,同时删除RAGFlow中的Chat配置和ragflow_config记录
|
||||
func (s *customerServiceAccount) Delete(ctx context.Context, req *dto.DeleteCustomerServiceAccountReq) (res *dto.DeleteCustomerServiceAccountRes, err error) {
|
||||
res = &dto.DeleteCustomerServiceAccountRes{}
|
||||
|
||||
// 1. 查询客服账号信息(使用MongoDAO直接查询,避免租户过滤)
|
||||
objectId, err := bson.ObjectIDFromHex(req.Id)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "无效的账号ID")
|
||||
}
|
||||
|
||||
var account entity.CustomerServiceAccount
|
||||
filter := bson.M{"_id": objectId, "isDeleted": false}
|
||||
err = dao.MongoDAO.FindOne(ctx, filter, &account, entity.CustomerServiceAccountCollection)
|
||||
if err != nil {
|
||||
if err.Error() == "mongo: no documents in result" {
|
||||
return nil, gerror.New("客服账号不存在")
|
||||
}
|
||||
return nil, gerror.Wrap(err, "查询客服账号失败")
|
||||
}
|
||||
|
||||
// 2. 查询关联的RAGFlowConfig,获取chatId
|
||||
config, err := dao.RAGFlowConfig.FindByAccountName(ctx, account.AccountName)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "查询RAGFlowConfig失败: %v", err)
|
||||
}
|
||||
|
||||
// 3. 如果存在chatId,调用RAGFlow API删除Chat
|
||||
if config != nil && config.ChatId != "" {
|
||||
ragflowClient := ragflow.GetGlobalClient()
|
||||
if err := ragflowClient.DeleteChats(ctx, []string{config.ChatId}); err != nil {
|
||||
g.Log().Errorf(ctx, "删除RAGFlow Chat失败: chat_id=%s, error=%v", config.ChatId, err)
|
||||
// 不阻断删除流程,记录错误日志
|
||||
} else {
|
||||
g.Log().Infof(ctx, "RAGFlow Chat删除成功: chat_id=%s", config.ChatId)
|
||||
}
|
||||
|
||||
// 4. 软删除RAGFlowConfig记录
|
||||
filter := bson.M{"_id": config.Id, "isDeleted": false}
|
||||
update := bson.M{"$set": bson.M{"isDeleted": true}}
|
||||
if _, err := commonMongo.DB().Update(ctx, filter, update, entity.RAGFlowConfigCollection); err != nil {
|
||||
g.Log().Warningf(ctx, "软删除RAGFlowConfig失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 软删除客服账号
|
||||
if err := dao.CustomerServiceAccount.Delete(ctx, account.Id.Hex()); err != nil {
|
||||
return nil, gerror.Wrap(err, "删除客服账号失败")
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "客服账号删除成功: account_name=%s, chat_id=%s",
|
||||
account.AccountName, config.ChatId)
|
||||
|
||||
res.Success = true
|
||||
res.Message = "删除成功"
|
||||
return
|
||||
}
|
||||
|
||||
// getAccessibleSpeechcraftsInternal 内部方法:获取客服账号可访问的话术(动态权限)
|
||||
// 参数: ctx - 上下文,accountName - 客服账号名
|
||||
// 返回: speechcrafts - 话术列表,err - 错误信息
|
||||
// 功能: 根据客服账号的speechcraft_ids字段判断权限范围
|
||||
//
|
||||
// 空数组 = 返回该租户下所有话术(动态权限)
|
||||
// 非空 = 只返回指定ID的话术(指定权限)
|
||||
func (s *customerServiceAccount) getAccessibleSpeechcraftsInternal(ctx context.Context, accountName string) (speechcrafts []*entity.Speechcraft, err error) {
|
||||
// 1. 查询客服账号
|
||||
account, err := dao.CustomerServiceAccount.FindByAccountName(ctx, accountName)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrapf(err, "查询客服账号失败")
|
||||
}
|
||||
if account == nil {
|
||||
return nil, gerror.New("客服账号不存在")
|
||||
}
|
||||
|
||||
// 2. 根据speechcraft_ids判断权限范围
|
||||
if len(account.SpeechcraftIds) == 0 {
|
||||
// 空数组 = 动态权限,返回该租户下所有话术
|
||||
filter := bson.M{"isDeleted": false}
|
||||
page := &beans.Page{PageNum: 1, PageSize: 10000} // 查询所有话术
|
||||
orderBy := []beans.OrderBy{}
|
||||
_, err = commonMongo.DB().Find(ctx, filter, &speechcrafts, entity.SpeechcraftCollection, page, orderBy)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrapf(err, "查询所有话术失败")
|
||||
}
|
||||
g.Log().Infof(ctx, "客服账号使用动态权限:account_name=%s, speechcraft_count=%d", accountName, len(speechcrafts))
|
||||
} else {
|
||||
// 非空 = 指定权限,只返回指定ID的话术
|
||||
var objectIds []bson.ObjectID
|
||||
for _, idStr := range account.SpeechcraftIds {
|
||||
if oid, err := bson.ObjectIDFromHex(idStr); err == nil {
|
||||
objectIds = append(objectIds, oid)
|
||||
}
|
||||
}
|
||||
|
||||
if len(objectIds) > 0 {
|
||||
filter := bson.M{
|
||||
"_id": bson.M{"$in": objectIds},
|
||||
"isDeleted": false,
|
||||
}
|
||||
page := &beans.Page{PageNum: 1, PageSize: 10000} // 查询指定话术
|
||||
orderBy := []beans.OrderBy{}
|
||||
_, err = commonMongo.DB().Find(ctx, filter, &speechcrafts, entity.SpeechcraftCollection, page, orderBy)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrapf(err, "查询指定话术失败")
|
||||
}
|
||||
}
|
||||
g.Log().Infof(ctx, "客服账号使用指定权限:account_name=%s, allowed_count=%d, found_count=%d",
|
||||
accountName, len(account.SpeechcraftIds), len(speechcrafts))
|
||||
}
|
||||
|
||||
return speechcrafts, nil
|
||||
}
|
||||
|
||||
// GetAccessibleSpeechcrafts 获取客服账号可访问的话术列表(API接口)
|
||||
// 参数: ctx - 上下文,req - 查询请求(包含账号名)
|
||||
// 返回: res - 话术列表及权限类型(dynamic/specified),err - 错误信息
|
||||
// 功能: 查询客服账号可访问的话术,返回格式化的DTO列表和权限标识
|
||||
func (s *customerServiceAccount) GetAccessibleSpeechcrafts(ctx context.Context, req *dto.GetAccessibleSpeechcraftsReq) (res *dto.GetAccessibleSpeechcraftsRes, err error) {
|
||||
res = &dto.GetAccessibleSpeechcraftsRes{}
|
||||
|
||||
// 1. 查询客服账号
|
||||
account, err := dao.CustomerServiceAccount.FindByAccountName(ctx, req.AccountName)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrapf(err, "查询客服账号失败")
|
||||
}
|
||||
if account == nil {
|
||||
return nil, gerror.New("客服账号不存在")
|
||||
}
|
||||
|
||||
// 2. 获取话术列表
|
||||
speechcrafts, err := s.getAccessibleSpeechcraftsInternal(ctx, req.AccountName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 3. 转换为DTO格式
|
||||
for _, sc := range speechcrafts {
|
||||
item := dto.SpeechcraftItem{
|
||||
Id: sc.Id.Hex(),
|
||||
Tag: sc.Tag,
|
||||
Content: sc.Content,
|
||||
Direction: sc.Direction,
|
||||
Platform: sc.Platform,
|
||||
Keywords: sc.Keywords,
|
||||
}
|
||||
res.Speechcrafts = append(res.Speechcrafts, item)
|
||||
}
|
||||
|
||||
res.TotalCount = len(res.Speechcrafts)
|
||||
|
||||
// 4. 标记权限类型
|
||||
if len(account.SpeechcraftIds) == 0 {
|
||||
res.Permission = "dynamic" // 动态权限:所有话术
|
||||
} else {
|
||||
res.Permission = "specified" // 指定权限:限定话术
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// RecreateRAGFlowConfig 重新创建RAGFlow配置
|
||||
// 参数: ctx - 上下文,req - 重建请求(包含账号名和平台)
|
||||
// 返回: err - 错误信息
|
||||
// 功能: 删除旧配置并重新创建Dataset和Chat,用于修复创建客服账号时RAGFlow配置创建失败的情况,或chat被删除后自动重建
|
||||
func (s *customerServiceAccount) RecreateRAGFlowConfig(ctx context.Context, req *dto.RecreateRAGFlowConfigReq) (err error) {
|
||||
// 1. 查询客服账号
|
||||
account, err := dao.CustomerServiceAccount.FindByAccountName(ctx, req.AccountName)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "查询客服账号失败")
|
||||
}
|
||||
if account == nil {
|
||||
return gerror.Newf("客服账号 %s 不存在", req.AccountName)
|
||||
}
|
||||
|
||||
// 2. 获取租户ID
|
||||
tenantId := gconv.String(account.TenantId)
|
||||
if tenantId == "" {
|
||||
return gerror.New("客服账号的租户ID为空")
|
||||
}
|
||||
accountName := req.AccountName
|
||||
|
||||
// 3. 查询现有RAGFlow配置(带租户隔离)
|
||||
coll := commonMongo.GetDB().Collection(entity.RAGFlowConfigCollection)
|
||||
filter := bson.M{"accountName": accountName, "tenantId": account.TenantId, "isDeleted": false}
|
||||
var existingConfig entity.RAGFlowConfig
|
||||
err = coll.FindOne(ctx, filter).Decode(&existingConfig)
|
||||
|
||||
// 4. 如果已有配置且chat_id不为空,验证chat是否真实存在于RAGFlow
|
||||
needRecreate := false
|
||||
if err == nil && !existingConfig.Id.IsZero() && existingConfig.ChatId != "" {
|
||||
// 调用RAGFlow API验证chat是否存在(通过UpdateChat验证)
|
||||
ragflowClient := ragflow.GetGlobalClient()
|
||||
if ragflowClient != nil {
|
||||
// 尝试更新chat(即使不改任何参数,如果chat不存在会返回错误)
|
||||
updateReq := &ragflow.UpdateChatReq{
|
||||
DatasetIds: existingConfig.DatasetIds,
|
||||
}
|
||||
updateErr := ragflowClient.UpdateChat(ctx, existingConfig.ChatId, updateReq)
|
||||
if updateErr != nil {
|
||||
// chat不存在或更新失败,需要重建
|
||||
g.Log().Warningf(ctx, "RAGFlow中的Chat已被删除或不可用,需要重建 - accountName: %s, old_chat_id: %s, error: %v",
|
||||
accountName, existingConfig.ChatId, updateErr)
|
||||
needRecreate = true
|
||||
} else {
|
||||
// chat存在且有效,无需重建
|
||||
g.Log().Infof(ctx, "发现现有有效的RAGFlow配置,无需重建 - accountName: %s, chat_id: %s", accountName, existingConfig.ChatId)
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
// ragflowClient为空,无法验证,默认需要重建
|
||||
needRecreate = true
|
||||
}
|
||||
} else {
|
||||
// 配置不存在或chat_id为空,需要创建
|
||||
needRecreate = true
|
||||
}
|
||||
|
||||
// 5. 需要重建chat
|
||||
if !needRecreate {
|
||||
return nil
|
||||
}
|
||||
g.Log().Infof(ctx, "开始创建新Chat - accountName: %s", accountName)
|
||||
|
||||
// 6. 确保租户知识库存在(调用公共方法)
|
||||
datasetId, err := EnsureTenantDataset(ctx, tenantId)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "确保租户知识库存在失败: %v", err)
|
||||
return gerror.Wrap(err, "确保租户知识库存在失败")
|
||||
}
|
||||
|
||||
// 7. 创建Chat并保存RAGFlow配置(重建时使用账号的selfIdentity)
|
||||
if err = s.createChatAndSaveConfig(ctx, req.AccountName, req.Platform, tenantId, datasetId, account.SelfIdentity); err != nil {
|
||||
g.Log().Errorf(ctx, "创建Chat配置失败: %v", err)
|
||||
return gerror.Wrap(err, "创建Chat配置失败")
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "成功为客服账号 %s 创建RAGFlow配置", req.AccountName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateRAGFlowChatInfo 同步更新RAGFlow Chat的name和description
|
||||
func (s *customerServiceAccount) updateRAGFlowChatInfo(ctx context.Context, account *entity.CustomerServiceAccount, req *dto.UpdateCustomerServiceAccountReq) (err error) {
|
||||
// 1. 查询RAGFlow配置获取chatId
|
||||
config, err := dao.RAGFlowConfig.FindByAccountName(ctx, account.AccountName)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "查询RAGFlow配置失败,跳过同步: %v", err)
|
||||
return nil
|
||||
}
|
||||
if config == nil || config.ChatId == "" {
|
||||
g.Log().Warningf(ctx, "RAGFlow配置不存在或chatId为空,跳过同步")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 2. 构建新的chat名称和描述
|
||||
tenantId := gconv.String(account.TenantId)
|
||||
|
||||
// 使用更新后的accountName和platform,如果没有提供则使用原值
|
||||
accountName := account.AccountName
|
||||
platform := account.Platform
|
||||
if req.AccountName != "" {
|
||||
accountName = req.AccountName
|
||||
}
|
||||
if req.Platform != "" {
|
||||
platform = req.Platform
|
||||
}
|
||||
|
||||
newChatName := fmt.Sprintf("%s_%s_%s", accountName, platform, tenantId)
|
||||
|
||||
// 使用更新后的selfIdentity,如果没有提供则使用原值
|
||||
var newDescription string
|
||||
if req.SelfIdentity != nil {
|
||||
newDescription = *req.SelfIdentity
|
||||
} else {
|
||||
newDescription = account.SelfIdentity
|
||||
}
|
||||
|
||||
// 如果selfIdentity为空,使用默认生成方式
|
||||
if newDescription == "" {
|
||||
newDescription = s.generateAssistantDescription(accountName, platform)
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "[updateRAGFlowChatInfo] 同步更新RAGFlow Chat - chatId: %s, newName: %s, newDescription: %s",
|
||||
config.ChatId, newChatName, newDescription)
|
||||
|
||||
// 3. 调用RAGFlow API更新Chat
|
||||
ragflowClient := ragflow.GetGlobalClient()
|
||||
if ragflowClient == nil {
|
||||
return gerror.New("RAGFlow客户端未初始化")
|
||||
}
|
||||
|
||||
updateReq := &ragflow.UpdateChatReq{
|
||||
Name: newChatName,
|
||||
Description: newDescription,
|
||||
}
|
||||
|
||||
if err = ragflowClient.UpdateChat(ctx, config.ChatId, updateReq); err != nil {
|
||||
g.Log().Errorf(ctx, "[updateRAGFlowChatInfo] 更新RAGFlow Chat失败: %v", err)
|
||||
return gerror.Wrap(err, "更新RAGFlow Chat失败")
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "[updateRAGFlowChatInfo] ✅ 成功同步更新RAGFlow Chat")
|
||||
return nil
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
// Package service - 对话数据服务
|
||||
// 功能:对话记录查询、导出Excel
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/dao"
|
||||
"customer-server/model/dto"
|
||||
"customer-server/model/entity"
|
||||
|
||||
"gitea.com/red-future/common/utils"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
var Data = new(data)
|
||||
|
||||
type data struct{}
|
||||
|
||||
// Add 添加数据
|
||||
// 参数: ctx - 上下文,req - 添加数据请求
|
||||
// 返回: res - 添加成功后的数据ID,err - 错误信息
|
||||
// 功能: 创建新的数据记录
|
||||
func (s *data) Add(ctx context.Context, req *dto.AddDataReq) (res *dto.AddDataRes, err error) {
|
||||
data := &entity.Data{}
|
||||
if err = utils.Struct(req, data); err != nil {
|
||||
return
|
||||
}
|
||||
// 设置基础字段
|
||||
now := gtime.Now().Time
|
||||
data.CreatedAt = &now // 取地址赋值给指针类型
|
||||
data.UpdatedAt = &now // 取地址赋值给指针类型
|
||||
data.IsDeleted = false
|
||||
// 注意:Creator、Updater、TenantId 保持零值,不设置
|
||||
|
||||
if err = dao.Data.Insert(ctx, data); err != nil {
|
||||
return
|
||||
}
|
||||
res = &dto.AddDataRes{Id: data.Id.Hex()}
|
||||
return
|
||||
}
|
||||
|
||||
// Update 更新数据
|
||||
// 参数: ctx - 上下文,req - 更新数据请求
|
||||
// 返回: err - 错误信息
|
||||
// 功能: 更新数据记录内容
|
||||
func (s *data) Update(ctx context.Context, req *dto.UpdateDataReq) (err error) {
|
||||
return dao.Data.Update(ctx, req)
|
||||
}
|
||||
|
||||
// // Delete 删除数据
|
||||
// func (s *data) Delete(ctx context.Context, req *dto.DeleteDataReq) (err error) {
|
||||
// return dao.Data.Delete(ctx, req)
|
||||
// }
|
||||
|
||||
// List 获取数据列表
|
||||
// 参数: ctx - 上下文,req - 列表查询请求
|
||||
// 返回: res - 数据列表及分页信息,err - 错误信息
|
||||
// 功能: 分页查询数据记录
|
||||
func (s *data) List(ctx context.Context, req *dto.ListDataReq) (res *dto.ListDataRes, err error) {
|
||||
list, total, err := dao.Data.List(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = &dto.ListDataRes{
|
||||
List: list,
|
||||
Total: int(total),
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
// Package service - 数据统计服务
|
||||
// 功能:对话数据统计分析、报表生成
|
||||
package service
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"customer-server/dao"
|
||||
"customer-server/model/dto"
|
||||
"customer-server/model/entity"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gitea.com/red-future/common/utils"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
var DataStatistics = new(dataStatistics)
|
||||
|
||||
type dataStatistics struct{}
|
||||
|
||||
// Add 添加数据统计
|
||||
// 参数: ctx - 上下文,req - 添加数据统计请求
|
||||
// 返回: res - 添加成功后的统计ID,err - 错误信息
|
||||
// 功能: 创建新的数据统计记录
|
||||
func (s *dataStatistics) Add(ctx context.Context, req *dto.AddDataStatisticsReq) (res *dto.AddDataStatisticsRes, err error) {
|
||||
data := &entity.DataStatistics{}
|
||||
if err = utils.Struct(req, data); err != nil {
|
||||
return
|
||||
}
|
||||
// 使用 gtime 转换日期
|
||||
if dateTime := gtime.NewFromStr(req.Date); dateTime != nil {
|
||||
date := dateTime.Time
|
||||
data.Date = &date // 取地址赋值给指针类型
|
||||
} else {
|
||||
return nil, gerror.New("日期格式错误")
|
||||
}
|
||||
// 设置基础字段
|
||||
now := gtime.Now().Time
|
||||
data.CreatedAt = &now // 取地址赋值给指针类型
|
||||
data.UpdatedAt = &now // 取地址赋值给指针类型
|
||||
data.IsDeleted = false
|
||||
// 注意:Creator、Updater、TenantId 保持零值,不设置
|
||||
|
||||
if err = dao.DataStatistics.Insert(ctx, data); err != nil {
|
||||
return
|
||||
}
|
||||
res = &dto.AddDataStatisticsRes{Id: data.Id.Hex()}
|
||||
return
|
||||
}
|
||||
|
||||
// Update 更新数据统计
|
||||
// 参数: ctx - 上下文,req - 更新数据统计请求
|
||||
// 返回: err - 错误信息
|
||||
// 功能: 更新数据统计记录内容
|
||||
func (s *dataStatistics) Update(ctx context.Context, req *dto.UpdateDataStatisticsReq) (err error) {
|
||||
return dao.DataStatistics.Update(ctx, req)
|
||||
}
|
||||
|
||||
// List 获取数据统计列表
|
||||
// 参数: ctx - 上下文,req - 列表查询请求
|
||||
// 返回: res - 数据统计列表及分页信息,err - 错误信息
|
||||
// 功能: 分页查询数据统计记录
|
||||
func (s *dataStatistics) List(ctx context.Context, req *dto.ListDataStatisticsReq) (res *dto.ListDataStatisticsRes, err error) {
|
||||
list, total, err := dao.DataStatistics.List(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = &dto.ListDataStatisticsRes{
|
||||
List: list,
|
||||
Total: int(total),
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Export 导出数据统计为ZIP文件
|
||||
// 参数: ctx - 上下文,req - 导出请求
|
||||
// 返回: zipData - ZIP文件字节数组,filename - 文件名,err - 错误信息
|
||||
// 功能: 将数据统计导出为ZIP文件,包含Excel文件
|
||||
func (s *dataStatistics) Export(ctx context.Context, req *dto.ExportDataStatisticsReq) (zipData []byte, filename string, err error) {
|
||||
// 1. 查询所有符合条件的数据统计
|
||||
statistics, err := dao.DataStatistics.FindAllForExport(ctx, req)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
if len(statistics) == 0 {
|
||||
return nil, "", gerror.New("没有可导出的数据统计")
|
||||
}
|
||||
|
||||
// 2. 创建 ZIP 文件(内存中)
|
||||
var buf bytes.Buffer
|
||||
zipWriter := zip.NewWriter(&buf)
|
||||
defer zipWriter.Close()
|
||||
|
||||
// 3. 为每个数据统计生成 TXT 文件并添加到 ZIP
|
||||
for _, stat := range statistics {
|
||||
// 生成 TXT 内容
|
||||
txtContent := s.generateTxt(stat)
|
||||
|
||||
// 生成文件名(清理并替换特殊字符)
|
||||
dateStr := gtime.New(stat.Date).Format("Y-m-d")
|
||||
cleanName := strings.ToValidUTF8(stat.CustomerServiceName, "未命名")
|
||||
safeFilename := s.sanitizeFilename(cleanName)
|
||||
if safeFilename == "" {
|
||||
safeFilename = "statistics"
|
||||
}
|
||||
txtFilename := dateStr + "_" + safeFilename + "_" + stat.Id.Hex()[:8] + ".txt"
|
||||
|
||||
// 添加文件到 ZIP
|
||||
writer, err := zipWriter.Create(txtFilename)
|
||||
if err != nil {
|
||||
return nil, "", gerror.Newf("创建ZIP文件失败: %v", err)
|
||||
}
|
||||
|
||||
if _, err := writer.Write([]byte(txtContent)); err != nil {
|
||||
return nil, "", gerror.Newf("写入ZIP文件失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 生成下载文件名
|
||||
timestamp := gtime.Now().Format("Ymd_His")
|
||||
filename = "data_statistics_export_" + timestamp + ".zip"
|
||||
|
||||
return buf.Bytes(), filename, nil
|
||||
}
|
||||
|
||||
// generateTxt 生成数据统计的 TXT 内容
|
||||
func (s *dataStatistics) generateTxt(stat *entity.DataStatistics) string {
|
||||
var builder strings.Builder
|
||||
|
||||
builder.WriteString("日期: " + gtime.New(stat.Date).Format("Y-m-d") + "\n")
|
||||
builder.WriteString("客服ID: " + stat.AccountName + "\n")
|
||||
builder.WriteString("客服名称: " + stat.CustomerServiceName + "\n")
|
||||
builder.WriteString("客服平台: " + stat.CustomerServicePlatform + "\n")
|
||||
builder.WriteString("\n=== 统计数据 ===\n")
|
||||
builder.WriteString("进线数: " + gconv.String(stat.InboundCount) + "\n")
|
||||
builder.WriteString("开口数: " + gconv.String(stat.ActiveCount) + "\n")
|
||||
builder.WriteString("接待数: " + gconv.String(stat.ServedCount) + "\n")
|
||||
builder.WriteString("发名片数: " + gconv.String(stat.ContactCardSentCount) + "\n")
|
||||
builder.WriteString("发留资卡数: " + gconv.String(stat.NameCardSentCount) + "\n")
|
||||
builder.WriteString("留资数: " + gconv.String(stat.LeftContactInfoCount) + "\n")
|
||||
builder.WriteString("\n=== 响应率 ===\n")
|
||||
builder.WriteString("30s响应率: " + gconv.String(stat.ResponseRate30s) + "%\n")
|
||||
builder.WriteString("60s响应率: " + gconv.String(stat.ResponseRate60s) + "%\n")
|
||||
builder.WriteString("360s响应率: " + gconv.String(stat.ResponseRate360s) + "%\n")
|
||||
builder.WriteString("\n---\n")
|
||||
builder.WriteString("记录ID: " + stat.Id.Hex() + "\n")
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
// sanitizeFilename 清理文件名,移除或替换不安全的字符
|
||||
func (s *dataStatistics) sanitizeFilename(filename string) string {
|
||||
// 移除或替换特殊字符
|
||||
reg := regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
|
||||
safe := reg.ReplaceAllString(filename, "_")
|
||||
|
||||
// 限制文件名长度
|
||||
if len(safe) > 50 {
|
||||
safe = safe[:50]
|
||||
}
|
||||
|
||||
return strings.TrimSpace(safe)
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/dao"
|
||||
"customer-server/model/entity"
|
||||
"fmt"
|
||||
|
||||
"gitea.com/red-future/common/ragflow"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// EnsureTenantDataset 确保租户知识库存在
|
||||
// 逻辑:
|
||||
// 1. 用租户ID查找MongoDB中的知识库ID
|
||||
// 2. 测试知识库ID是否在RAGFlow中可用
|
||||
// 3. 如果不可用,就直接根据名字在RAGFlow中查找
|
||||
// 4. 找到了就把知识库ID存到MongoDB
|
||||
// 5. 如果名字也找不到,就创建新的
|
||||
// 6. 把新的ID和名字存到MongoDB
|
||||
func EnsureTenantDataset(ctx context.Context, tenantId string) (datasetId string, err error) {
|
||||
ragflowClient := ragflow.GetGlobalClient()
|
||||
datasetName := fmt.Sprintf("dataset_tenant_%s", tenantId)
|
||||
|
||||
// 1. 用租户ID查找MongoDB中的知识库ID
|
||||
datasetId, err = dao.RAGFlowConfig.FindDatasetIdByTenant(ctx, tenantId)
|
||||
if err != nil && err.Error() != "mongo: no documents in result" {
|
||||
return "", gerror.Wrap(err, "查询租户知识库ID失败")
|
||||
}
|
||||
|
||||
// 2. 测试知识库ID是否在RAGFlow中可用
|
||||
if datasetId != "" {
|
||||
g.Log().Infof(ctx, "租户%s已有知识库ID: %s,测试是否可用", tenantId, datasetId)
|
||||
|
||||
listReq := &ragflow.ListDatasetsReq{Page: 1, PageSize: 100}
|
||||
listRes, listErr := ragflowClient.ListDatasets(ctx, listReq)
|
||||
|
||||
if listErr == nil && listRes != nil {
|
||||
g.Log().Infof(ctx, "ListDatasets返回%d个知识库", len(listRes.Data))
|
||||
for i, ds := range listRes.Data {
|
||||
g.Log().Infof(ctx, "知识库[%d]: id=%s, name=%s", i, ds.Id, ds.Name)
|
||||
}
|
||||
|
||||
// 测试ID是否可用
|
||||
for _, ds := range listRes.Data {
|
||||
if ds.Id == datasetId {
|
||||
g.Log().Infof(ctx, "知识库ID可用: %s", datasetId)
|
||||
return datasetId, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 3. ID不可用,直接根据名字查找
|
||||
g.Log().Warningf(ctx, "知识库ID不可用,根据名字查找: %s", datasetName)
|
||||
for _, ds := range listRes.Data {
|
||||
if ds.Name == datasetName {
|
||||
datasetId = ds.Id
|
||||
g.Log().Infof(ctx, "找到同名知识库: name=%s, id=%s", datasetName, datasetId)
|
||||
|
||||
// 4. 把知识库ID存到MongoDB
|
||||
if updateErr := updateDatasetIdInMongo(ctx, tenantId, datasetId); updateErr != nil {
|
||||
g.Log().Errorf(ctx, "更新MongoDB失败: %v", updateErr)
|
||||
}
|
||||
|
||||
return datasetId, nil
|
||||
}
|
||||
}
|
||||
|
||||
g.Log().Warningf(ctx, "未找到同名知识库: %s", datasetName)
|
||||
} else {
|
||||
g.Log().Errorf(ctx, "ListDatasets失败: %v", listErr)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 名字也找不到,创建新的
|
||||
g.Log().Infof(ctx, "创建新知识库: name=%s", datasetName)
|
||||
|
||||
// 从config读取embedding模型,如果未配置则使用默认值
|
||||
embeddingModel := g.Cfg().MustGet(ctx, "ragflow.embedding_model", "text-embedding-v4").String()
|
||||
|
||||
createReq := &ragflow.CreateDatasetReq{
|
||||
Name: datasetName,
|
||||
EmbeddingModel: embeddingModel,
|
||||
ChunkMethod: "naive",
|
||||
}
|
||||
|
||||
dataset, err := ragflowClient.CreateDataset(ctx, createReq)
|
||||
|
||||
// 不依赖CreateDataset的返回值,因为RAGFlow可能返回code=0但data=null
|
||||
// 立即用ListDatasets查找新创建的知识库来确认
|
||||
g.Log().Infof(ctx, "创建请求已发送,立即查找确认: name=%s", datasetName)
|
||||
|
||||
listReq2 := &ragflow.ListDatasetsReq{Page: 1, PageSize: 100}
|
||||
listRes2, listErr2 := ragflowClient.ListDatasets(ctx, listReq2)
|
||||
|
||||
if listErr2 == nil && listRes2 != nil {
|
||||
for _, ds := range listRes2.Data {
|
||||
if ds.Name == datasetName {
|
||||
datasetId = ds.Id
|
||||
g.Log().Infof(ctx, "确认知识库已创建: id=%s, name=%s", datasetId, datasetName)
|
||||
goto saveRecord
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果ListDatasets也找不到,说明创建真的失败了
|
||||
if err != nil {
|
||||
return "", gerror.Wrapf(err, "创建知识库失败且无法确认")
|
||||
}
|
||||
if dataset == nil || dataset.Id == "" {
|
||||
return "", gerror.Newf("创建知识库失败,无法通过ListDatasets确认: name=%s", datasetName)
|
||||
}
|
||||
|
||||
// 使用CreateDataset返回的ID(兜底)
|
||||
datasetId = dataset.Id
|
||||
g.Log().Infof(ctx, "知识库创建成功: id=%s, name=%s", datasetId, datasetName)
|
||||
|
||||
saveRecord:
|
||||
|
||||
// 6. 把新的ID和名字存到MongoDB
|
||||
if err := saveSystemDatasetRecord(ctx, tenantId, datasetId, datasetName); err != nil {
|
||||
g.Log().Errorf(ctx, "保存到MongoDB失败: %v", err)
|
||||
return "", gerror.Wrap(err, "保存到MongoDB失败")
|
||||
}
|
||||
|
||||
return datasetId, nil
|
||||
}
|
||||
|
||||
// uploadPlaceholderDocument 上传占位文档
|
||||
func uploadPlaceholderDocument(ctx context.Context, datasetId, tenantId string) error {
|
||||
ragflowClient := ragflow.GetGlobalClient()
|
||||
placeholderContent := "欢迎使用智能客服系统。这是一个占位文档,实际产品和话术会在后续上传。"
|
||||
placeholderFilename := fmt.Sprintf("placeholder_tenant_%s.txt", tenantId)
|
||||
|
||||
documentId, err := ragflowClient.UploadDocumentFromText(ctx, datasetId, placeholderContent, placeholderFilename)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "上传占位文档失败")
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "占位文档上传成功: document_id=%s", documentId)
|
||||
|
||||
if parseErr := ragflowClient.ParseDocuments(ctx, datasetId, []string{documentId}); parseErr != nil {
|
||||
g.Log().Warningf(ctx, "解析占位文档失败: %v", parseErr)
|
||||
return parseErr
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "占位文档解析已启动(后台异步进行)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveSystemDatasetRecord 保存系统占位记录到MongoDB
|
||||
func saveSystemDatasetRecord(ctx context.Context, tenantId, datasetId, datasetName string) error {
|
||||
// 先检查是否已有记录
|
||||
existing, err := dao.RAGFlowConfig.FindDatasetIdByTenant(ctx, tenantId)
|
||||
if err == nil && existing != "" {
|
||||
g.Log().Infof(ctx, "租户%s已有知识库记录,无需创建系统占位记录", tenantId)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 创建系统占位记录
|
||||
systemAccountName := fmt.Sprintf("_system_tenant_%s", tenantId)
|
||||
config := &entity.RAGFlowConfig{
|
||||
AccountName: systemAccountName,
|
||||
Platform: "system",
|
||||
DatasetId: datasetId,
|
||||
DatasetIds: []string{datasetId},
|
||||
DatasetName: datasetName,
|
||||
ChatId: "", // 系统记录不需要Chat
|
||||
Prompt: "",
|
||||
DocumentIds: []string{},
|
||||
SimilarityThreshold: 0.2,
|
||||
KeywordsSimilarityWeight: 0.7,
|
||||
TopN: 8,
|
||||
EmptyResponse: "抱歉,我暂时无法回答这个问题。",
|
||||
SyncStatus: "synced",
|
||||
}
|
||||
// 统一使用string类型存储tenantId到MongoDB
|
||||
config.TenantId = tenantId
|
||||
config.IsDeleted = false
|
||||
|
||||
if err := dao.RAGFlowConfig.Insert(ctx, config); err != nil {
|
||||
return gerror.Wrap(err, "插入系统占位记录失败")
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "系统占位记录已保存: tenant_id=%s, dataset_id=%s", tenantId, datasetId)
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateDatasetIdInMongo 更新MongoDB中租户的所有datasetId记录
|
||||
func updateDatasetIdInMongo(ctx context.Context, tenantId, newDatasetId string) error {
|
||||
// 更新该租户所有ragflow_config记录的datasetId
|
||||
if err := dao.RAGFlowConfig.UpdateDatasetIdByTenant(ctx, tenantId, newDatasetId); err != nil {
|
||||
return gerror.Wrap(err, "更新租户datasetId失败")
|
||||
}
|
||||
g.Log().Infof(ctx, "已更新租户%s的所有记录的datasetId: %s", tenantId, newDatasetId)
|
||||
return nil
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.com/red-future/common/jaeger"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
)
|
||||
|
||||
// JaegerTestTemp 测试 Jaeger 错误记录(临时,测试后删除)
|
||||
func JaegerTestTemp(ctx context.Context, msg string) {
|
||||
ctx, span := jaeger.NewSpan(ctx, "test.jaeger.error")
|
||||
defer span.End()
|
||||
|
||||
err := gerror.New(msg)
|
||||
jaeger.RecordError(ctx, err, "测试Jaeger错误记录")
|
||||
}
|
||||
@@ -1,891 +0,0 @@
|
||||
// Package service - 产品服务
|
||||
// 功能:产品的增删改查、ZIP导入/导出、绑定/解绑客服账号、同步到RAGFlow、重试消费者
|
||||
package service
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"customer-server/dao"
|
||||
"customer-server/model/dto"
|
||||
"customer-server/model/entity"
|
||||
"customer-server/util"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"gitea.com/red-future/common/jaeger"
|
||||
"gitea.com/red-future/common/rabbitmq"
|
||||
"gitea.com/red-future/common/ragflow"
|
||||
"gitea.com/red-future/common/utils"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/glog"
|
||||
"github.com/gogf/gf/v2/os/grpool"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
var (
|
||||
Product = new(product)
|
||||
productGrpool = grpool.New(50) // 文档解析协程池,最大50并发
|
||||
)
|
||||
|
||||
type product struct{}
|
||||
|
||||
// Add 添加产品
|
||||
// 参数: ctx - 上下文,req - 添加产品请求(包含产品名称、描述、价格等)
|
||||
// 返回: res - 添加成功后的产品ID和RAGFlow文档ID,err - 错误信息
|
||||
// 功能: 创建产品记录并自动上传到RAGFlow产品知识库(独立于话术知识库)
|
||||
func (s *product) Add(ctx context.Context, req *dto.AddProductReq) (res *dto.AddProductRes, err error) {
|
||||
// 校验产品名称长度
|
||||
if utf8.RuneCountInString(req.Name) > 64 {
|
||||
return nil, gerror.New("产品名称必须在64字以内")
|
||||
}
|
||||
|
||||
// 校验产品详情长度
|
||||
if utf8.RuneCountInString(req.Description) > 8192 {
|
||||
return nil, gerror.New("产品详情必须在8192字以内")
|
||||
}
|
||||
|
||||
// 去重检查:同一租户下名称唯一
|
||||
existing, err := dao.Product.FindByName(ctx, req.Name)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "检查产品重复失败")
|
||||
}
|
||||
if existing != nil {
|
||||
return nil, gerror.Newf("产品名称已存在:name=%s, id=%s", req.Name, existing.Id.Hex())
|
||||
}
|
||||
|
||||
data := &entity.Product{}
|
||||
if err = utils.Struct(req, data); err != nil {
|
||||
return
|
||||
}
|
||||
// 先从token获取租户信息(在Insert之前)
|
||||
user, err := util.GetTenantInfo(ctx)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "获取租户信息失败")
|
||||
}
|
||||
tenantId := gconv.String(user.TenantId)
|
||||
if tenantId == "" {
|
||||
return nil, gerror.New("租户ID为空")
|
||||
}
|
||||
|
||||
// 确保产品知识库存在(检查话术知识库是否存在,作为租户是否初始化的标志)
|
||||
speechcraftDatasetId, err := dao.RAGFlowConfig.FindDatasetIdByTenant(ctx, tenantId)
|
||||
if err != nil || speechcraftDatasetId == "" {
|
||||
return nil, gerror.Newf("租户知识库不存在,请先创建客服账号: tenant_id=%s", tenantId)
|
||||
}
|
||||
|
||||
// 设置基础字段
|
||||
now := gtime.Now().Time
|
||||
data.CreatedAt = &now // 取地址赋值给指针类型
|
||||
data.UpdatedAt = &now // 取地址赋值给指针类型
|
||||
data.IsDeleted = false
|
||||
// 统一使用string类型存储tenantId到MongoDB
|
||||
data.TenantId = tenantId
|
||||
|
||||
// 插入产品到MongoDB
|
||||
_, err = dao.Product.Insert(ctx, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 确保租户知识库存在(产品和话术共享租户知识库)
|
||||
// 使用dataset_service提供的统一方法,自动处理创建、查找、保存等逻辑
|
||||
datasetId, err := EnsureTenantDataset(ctx, tenantId)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "确保租户知识库失败: %v", err)
|
||||
return nil, gerror.Wrap(err, "获取租户知识库失败")
|
||||
}
|
||||
g.Log().Infof(ctx, "租户%s的知识库ID: %s", tenantId, datasetId)
|
||||
|
||||
// 同步上传到RAGFlow
|
||||
ragflowClient := ragflow.GetGlobalClient()
|
||||
g.Log().Infof(ctx, "准备上传产品到RAGFlow: product_id=%s, dataset_id=%s, name=%s", data.Id.Hex(), datasetId, data.Name)
|
||||
filename := fmt.Sprintf("%s.txt", data.Name)
|
||||
documentId, err := ragflowClient.UploadDocumentFromText(ctx, datasetId, data.Description, filename)
|
||||
if err != nil {
|
||||
// 回滚:删除刚插入的产品
|
||||
dao.MongoDAO.Delete(ctx, bson.M{"_id": data.Id}, entity.ProductCollection)
|
||||
g.Log().Errorf(ctx, "产品上传RAGFlow失败: product_id=%s, dataset_id=%s, error=%v", data.Id.Hex(), datasetId, err)
|
||||
jaeger.RecordError(ctx, err, "产品上传RAGFlow失败")
|
||||
return nil, gerror.Wrap(err, "文档上传到知识库失败")
|
||||
}
|
||||
|
||||
// 异步触发解析(grpool自动管理goroutine生命周期,WithoutCancel保留追踪避免取消)
|
||||
productGrpool.Add(ctx, func(ctx context.Context) {
|
||||
parseCtx := context.WithoutCancel(ctx)
|
||||
if err := ragflowClient.ParseDocuments(parseCtx, datasetId, []string{documentId}); err != nil {
|
||||
g.Log().Errorf(parseCtx, "文档解析失败: document_id=%s, error=%v", documentId, err)
|
||||
} else {
|
||||
g.Log().Infof(parseCtx, "文档解析成功: document_id=%s", documentId)
|
||||
}
|
||||
})
|
||||
|
||||
// 更新MongoDB的RagSyncRecords数组(使用空accountName表示租户级文档)
|
||||
syncTime := gtime.Now().Format("Y-m-d H:i:s")
|
||||
record := entity.RagSyncRecord{
|
||||
AccountName: "", // 空表示租户级文档
|
||||
RagDocumentId: documentId,
|
||||
RagSyncStatus: "synced",
|
||||
SyncTime: syncTime,
|
||||
RetryCount: 0,
|
||||
}
|
||||
filter := bson.M{"_id": data.Id}
|
||||
update := bson.M{
|
||||
"$set": bson.M{
|
||||
"ragSyncRecords": []entity.RagSyncRecord{record},
|
||||
"ragLastSyncTime": syncTime,
|
||||
"updatedAt": gtime.Now().Time,
|
||||
},
|
||||
}
|
||||
if _, _, err = dao.MongoDAO.UpdateOne(ctx, filter, update, entity.ProductCollection); err != nil {
|
||||
g.Log().Errorf(ctx, "更新产品RagSyncRecords失败: %v", err)
|
||||
// 不回滚,文档已上传成功
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "产品添加成功并上传到知识库: product_id=%s, document_id=%s", data.Id.Hex(), documentId)
|
||||
res = &dto.AddProductRes{Id: data.Id.Hex()}
|
||||
return
|
||||
}
|
||||
|
||||
// Update 更新产品
|
||||
// 参数: ctx - 上下文,req - 更新产品请求(包含产品ID和待更新字段)
|
||||
// 返回: err - 错误信息
|
||||
// 功能: 更新产品信息并同步到RAGFlow,支持文档删除重建
|
||||
func (s *product) Update(ctx context.Context, req *dto.UpdateProductReq) (err error) {
|
||||
// 如果更新了产品名称,校验长度
|
||||
if req.Name != "" && utf8.RuneCountInString(req.Name) > 64 {
|
||||
return gerror.New("产品名称必须在64字以内")
|
||||
}
|
||||
|
||||
// 如果更新了产品详情,校验长度
|
||||
if req.Description != "" && utf8.RuneCountInString(req.Description) > 8192 {
|
||||
return gerror.New("产品详情必须在8192字以内")
|
||||
}
|
||||
|
||||
// 去重检查:如果修改名称,检查是否与其他产品重复
|
||||
if req.Name != "" {
|
||||
existing, err := dao.Product.FindByName(ctx, req.Name)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "检查产品重复失败")
|
||||
}
|
||||
if existing != nil && existing.Id.Hex() != req.Id {
|
||||
return gerror.Newf("产品名称已存在:name=%s, id=%s", req.Name, existing.Id.Hex())
|
||||
}
|
||||
}
|
||||
|
||||
return dao.Product.Update(ctx, req)
|
||||
}
|
||||
|
||||
// Delete 删除产品
|
||||
// 参数: ctx - 上下文,req - 删除产品请求(包含产品ID)
|
||||
// 返回: err - 错误信息
|
||||
// 功能: 逻辑删除产品记录并从RAGFlow移除对应文档
|
||||
func (s *product) Delete(ctx context.Context, req *dto.DeleteProductReq) (err error) {
|
||||
g.Log().Infof(ctx, "[Delete] 开始删除产品 - productId: %s", req.Id)
|
||||
|
||||
// 1. 查询产品,获取RAGFlow同步记录(使用原生查询,避免租户过滤)
|
||||
objectId, err := bson.ObjectIDFromHex(req.Id)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "无效的产品ID")
|
||||
}
|
||||
|
||||
var product entity.Product
|
||||
filter := bson.M{"_id": objectId, "isDeleted": false}
|
||||
err = dao.MongoDAO.FindOne(ctx, filter, &product, entity.ProductCollection)
|
||||
if err != nil {
|
||||
if err.Error() == "mongo: no documents in result" {
|
||||
return gerror.New("产品不存在")
|
||||
}
|
||||
return gerror.Wrap(err, "查询产品失败")
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "[Delete] 查询到产品 - name: %s, ragSyncRecords数量: %d", product.Name, len(product.RagSyncRecords))
|
||||
|
||||
// 2. 删除RAGFlow中的文档
|
||||
if len(product.RagSyncRecords) > 0 {
|
||||
ragflowClient := ragflow.GetGlobalClient()
|
||||
if ragflowClient != nil {
|
||||
tenantId := gconv.String(product.TenantId)
|
||||
|
||||
// 查询租户的dataset_id
|
||||
datasetId, err := dao.RAGFlowConfig.FindDatasetIdByTenant(ctx, tenantId)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "查询租户知识库ID失败: %v", err)
|
||||
} else if datasetId != "" {
|
||||
// 收集所有需要删除的document_id
|
||||
var documentIds []string
|
||||
for _, record := range product.RagSyncRecords {
|
||||
if record.RagDocumentId != "" {
|
||||
documentIds = append(documentIds, record.RagDocumentId)
|
||||
}
|
||||
}
|
||||
|
||||
// 批量删除RAGFlow文档
|
||||
if len(documentIds) > 0 {
|
||||
if err := ragflowClient.DeleteDocument(ctx, datasetId, documentIds); err != nil {
|
||||
g.Log().Errorf(ctx, "删除RAGFlow文档失败: %v, document_ids: %v", err, documentIds)
|
||||
// 不阻断删除流程,记录错误后继续
|
||||
} else {
|
||||
g.Log().Infof(ctx, "成功删除RAGFlow文档: count=%d", len(documentIds))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 软删除MongoDB记录
|
||||
return dao.Product.Delete(ctx, req)
|
||||
}
|
||||
|
||||
// List 获取产品列表
|
||||
// 参数: ctx - 上下文,req - 列表查询请求(支持分页、关键词搜索)
|
||||
// 返回: res - 产品列表及分页信息,err - 错误信息
|
||||
// 功能: 分页查询产品记录,支持按名称、描述模糊搜索
|
||||
func (s *product) List(ctx context.Context, req *dto.ListProductReq) (res *dto.ListProductRes, err error) {
|
||||
list, total, err := dao.Product.List(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = &dto.ListProductRes{
|
||||
List: list,
|
||||
Total: int(total),
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Export 导出产品为ZIP文件
|
||||
// 参数: ctx - 上下文,req - 导出请求(包含筛选条件)
|
||||
// 返回: zipData - ZIP文件字节数组,filename - 文件名,err - 错误信息
|
||||
// 功能: 将产品数据导出为ZIP文件,包含JSON格式的产品列表
|
||||
func (s *product) Export(ctx context.Context, req *dto.ExportProductReq) (zipData []byte, filename string, err error) {
|
||||
// 清理输入参数,防止非法 UTF-8 字符
|
||||
cleanName := strings.ToValidUTF8(req.Name, "")
|
||||
|
||||
// 1. 查询所有符合条件的产品
|
||||
products, err := dao.Product.FindAllForExport(ctx, cleanName)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
if len(products) == 0 {
|
||||
return nil, "", gerror.New("没有可导出的产品")
|
||||
}
|
||||
|
||||
// 清理所有产品数据,确保 UTF-8 有效(防止数据库中的脏数据)
|
||||
for i := range products {
|
||||
products[i].Name = strings.ToValidUTF8(products[i].Name, "")
|
||||
products[i].Description = strings.ToValidUTF8(products[i].Description, "")
|
||||
// RagDocumentId字段在RagSyncRecords中,不在Product主体
|
||||
}
|
||||
|
||||
// 2. 创建 ZIP 文件(内存中)
|
||||
var buf bytes.Buffer
|
||||
zipWriter := zip.NewWriter(&buf)
|
||||
defer zipWriter.Close()
|
||||
|
||||
// 3. 为每个产品生成 TXT 文件并添加到 ZIP
|
||||
for _, product := range products {
|
||||
// 生成 TXT 内容(产品详情)
|
||||
txtContent := s.generateTxt(product)
|
||||
|
||||
// 文件名就是产品名称(清理特殊字符)
|
||||
cleanName := strings.ToValidUTF8(product.Name, "未命名")
|
||||
safeFilename := s.sanitizeFilename(cleanName)
|
||||
if safeFilename == "" {
|
||||
safeFilename = "product"
|
||||
}
|
||||
txtFilename := safeFilename + ".txt"
|
||||
|
||||
// 添加文件到 ZIP
|
||||
writer, err := zipWriter.Create(txtFilename)
|
||||
if err != nil {
|
||||
return nil, "", gerror.Newf("创建ZIP文件失败: %v", err)
|
||||
}
|
||||
|
||||
if _, err := writer.Write([]byte(txtContent)); err != nil {
|
||||
return nil, "", gerror.Newf("写入ZIP文件失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 生成下载文件名
|
||||
timestamp := gtime.Now().Format("Ymd_His")
|
||||
filename = "products_export_" + timestamp + ".zip"
|
||||
|
||||
return buf.Bytes(), filename, nil
|
||||
}
|
||||
|
||||
// generateTxt 将产品转换为 TXT 格式
|
||||
// 新格式:文件名=产品名称,内容=产品详情
|
||||
func (s *product) generateTxt(product *entity.Product) string {
|
||||
// 清理产品详情,确保 UTF-8 有效
|
||||
cleanDescription := strings.ToValidUTF8(product.Description, "")
|
||||
|
||||
// 直接返回产品详情
|
||||
if cleanDescription != "" {
|
||||
return cleanDescription
|
||||
}
|
||||
|
||||
// 如果没有详情,返回空字符串
|
||||
return ""
|
||||
}
|
||||
|
||||
// sanitizeFilename 清理文件名中的特殊字符
|
||||
func (s *product) sanitizeFilename(name string) string {
|
||||
// 替换不安全的文件名字符
|
||||
replacer := map[rune]rune{
|
||||
'/': '_',
|
||||
'\\': '_',
|
||||
':': '_',
|
||||
'*': '_',
|
||||
'?': '_',
|
||||
'"': '_',
|
||||
'<': '_',
|
||||
'>': '_',
|
||||
'|': '_',
|
||||
}
|
||||
|
||||
// 预分配容量,避免循环中动态扩容
|
||||
result := make([]rune, 0, len(name))
|
||||
for _, char := range name {
|
||||
if newChar, exists := replacer[char]; exists {
|
||||
result = append(result, newChar)
|
||||
} else {
|
||||
result = append(result, char)
|
||||
}
|
||||
}
|
||||
|
||||
filename := string(result)
|
||||
// 限制文件名长度
|
||||
if utf8.RuneCountInString(filename) > 50 {
|
||||
runes := []rune(filename)
|
||||
filename = string(runes[:50])
|
||||
}
|
||||
|
||||
return filename
|
||||
}
|
||||
|
||||
// Import 从ZIP文件导入产品
|
||||
// 参数: ctx - 上下文,file - 上传的ZIP文件
|
||||
// 返回: res - 导入结果(成功和失败数量),err - 错误信息
|
||||
// 功能: 从ZIP文件批量导入产品数据并同步到RAGFlow,失败记录加入重试队列
|
||||
func (s *product) Import(ctx context.Context, file *multipart.FileHeader) (res *dto.ImportProductRes, err error) {
|
||||
res = &dto.ImportProductRes{
|
||||
SuccessCount: 0,
|
||||
FailCount: 0,
|
||||
FailReasons: []string{},
|
||||
}
|
||||
|
||||
// 1. 获取租户信息
|
||||
user, err := util.GetTenantInfo(ctx)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "获取租户信息失败")
|
||||
}
|
||||
tenantId := gconv.String(user.TenantId)
|
||||
if tenantId == "" {
|
||||
return nil, gerror.New("租户ID为空")
|
||||
}
|
||||
|
||||
// 2. 打开上传的文件
|
||||
uploadedFile, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, gerror.Newf("无法打开上传的文件: %v", err)
|
||||
}
|
||||
defer uploadedFile.Close()
|
||||
|
||||
// 3. 读取文件内容到内存
|
||||
fileData, err := io.ReadAll(uploadedFile)
|
||||
if err != nil {
|
||||
return nil, gerror.Newf("读取文件失败: %v", err)
|
||||
}
|
||||
|
||||
// 4. 解析 ZIP 文件
|
||||
zipReader, err := zip.NewReader(bytes.NewReader(fileData), int64(len(fileData)))
|
||||
if err != nil {
|
||||
return nil, gerror.Newf("无法解析ZIP文件: %v", err)
|
||||
}
|
||||
|
||||
// 4. 遍历 ZIP 中的所有文件
|
||||
for _, zipFile := range zipReader.File {
|
||||
// 只处理 .txt 文件
|
||||
if !strings.HasSuffix(strings.ToLower(zipFile.Name), ".txt") {
|
||||
continue
|
||||
}
|
||||
|
||||
// 读取 TXT 文件内容(产品详情)
|
||||
txtContent, err := s.readZipFile(zipFile)
|
||||
if err != nil {
|
||||
res.FailCount++
|
||||
res.FailReasons = append(res.FailReasons, "文件 "+zipFile.Name+" 读取失败: "+err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
// 从文件名提取产品名称(移除 .txt 后缀)
|
||||
productName := strings.TrimSuffix(zipFile.Name, ".txt")
|
||||
productName = strings.TrimSpace(productName)
|
||||
|
||||
// 创建产品数据
|
||||
productData, err := s.parseSimpleTxt(productName, txtContent)
|
||||
if err != nil {
|
||||
res.FailCount++
|
||||
res.FailReasons = append(res.FailReasons, "文件 "+zipFile.Name+" 解析失败: "+err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
// 校验产品名称和详情长度
|
||||
if utf8.RuneCountInString(productData.Name) > 64 {
|
||||
res.FailCount++
|
||||
res.FailReasons = append(res.FailReasons, "文件 "+zipFile.Name+": 产品名称超过64字")
|
||||
continue
|
||||
}
|
||||
if utf8.RuneCountInString(productData.Description) > 8192 {
|
||||
res.FailCount++
|
||||
res.FailReasons = append(res.FailReasons, "文件 "+zipFile.Name+": 产品详情超过8192字")
|
||||
continue
|
||||
}
|
||||
|
||||
// 设置基础字段
|
||||
now := gtime.Now().Time
|
||||
productData.CreatedAt = &now // 取地址赋值给指针类型
|
||||
productData.UpdatedAt = &now // 取地址赋值给指针类型
|
||||
productData.IsDeleted = false
|
||||
// 统一使用string类型存储tenantId到MongoDB
|
||||
productData.TenantId = tenantId
|
||||
|
||||
// 插入数据库
|
||||
_, err = dao.Product.Insert(ctx, productData)
|
||||
if err != nil {
|
||||
res.FailCount++
|
||||
res.FailReasons = append(res.FailReasons, "文件 "+zipFile.Name+" 数据库插入失败: "+err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
// 同步上传到RAGFlow产品知识库(使用外层已声明的tenantId变量)
|
||||
if tenantId != "" {
|
||||
datasetId := fmt.Sprintf("dataset_product_tenant_%s", tenantId)
|
||||
ragflowClient := ragflow.GetGlobalClient()
|
||||
if ragflowClient != nil {
|
||||
filename := fmt.Sprintf("%s.txt", productData.Name)
|
||||
documentId, uploadErr := ragflowClient.UploadDocumentFromText(ctx, datasetId, productData.Description, filename)
|
||||
if uploadErr != nil {
|
||||
// 上传失败:回滚删除MongoDB记录
|
||||
dao.MongoDAO.Delete(ctx, bson.M{"_id": productData.Id}, entity.ProductCollection)
|
||||
res.FailCount++
|
||||
res.FailReasons = append(res.FailReasons, "文件 "+zipFile.Name+" 上传知识库失败: "+uploadErr.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
// 更新ragDocumentId
|
||||
filter := bson.M{"_id": productData.Id}
|
||||
update := bson.M{"$set": bson.M{"ragDocumentId": documentId}}
|
||||
dao.MongoDAO.UpdateOne(ctx, filter, update, entity.ProductCollection)
|
||||
|
||||
g.Log().Infof(ctx, "ZIP产品上传成功: name=%s, document_id=%s", productData.Name, documentId)
|
||||
}
|
||||
}
|
||||
|
||||
res.SuccessCount++
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// readZipFile 读取 ZIP 文件中的单个文件内容
|
||||
func (s *product) readZipFile(file *zip.File) (string, error) {
|
||||
reader, err := file.Open()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
content, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(content), nil
|
||||
}
|
||||
|
||||
// parseSimpleTxt 解析简化格式的 TXT 文件
|
||||
// 文件名=产品名称,内容=产品详情
|
||||
func (s *product) parseSimpleTxt(productName string, description string) (*entity.Product, error) {
|
||||
product := &entity.Product{}
|
||||
|
||||
// 清理并验证产品名称
|
||||
product.Name = strings.TrimSpace(strings.ToValidUTF8(productName, ""))
|
||||
if product.Name == "" {
|
||||
return nil, gerror.New("产品名称不能为空")
|
||||
}
|
||||
|
||||
// 清理产品详情
|
||||
product.Description = strings.TrimSpace(strings.ToValidUTF8(description, ""))
|
||||
|
||||
return product, nil
|
||||
}
|
||||
|
||||
// parseTxt 解析 TXT 文件内容为产品实体(旧格式兼容)
|
||||
func (s *product) parseTxt(content string) (*entity.Product, error) {
|
||||
product := &entity.Product{}
|
||||
lines := strings.Split(content, "\n")
|
||||
|
||||
var inDescription bool
|
||||
var descriptionLines []string
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
|
||||
// 跳过空行
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 解析标题
|
||||
if strings.HasPrefix(line, "=== ") && strings.HasSuffix(line, " ===") {
|
||||
product.Name = strings.TrimSpace(line[4 : len(line)-4])
|
||||
continue
|
||||
}
|
||||
|
||||
// 跳过基本信息标记
|
||||
if line == "【基本信息】" {
|
||||
inDescription = false
|
||||
continue
|
||||
}
|
||||
|
||||
// 产品详情开始
|
||||
if line == "【产品详情】" {
|
||||
inDescription = true
|
||||
continue
|
||||
}
|
||||
|
||||
// 解析基本信息字段
|
||||
if !inDescription {
|
||||
// 跳过系统生成的字段(产品ID、创建时间等)
|
||||
if strings.HasPrefix(line, "产品ID:") ||
|
||||
strings.HasPrefix(line, "创建时间:") ||
|
||||
strings.HasPrefix(line, "更新时间:") ||
|
||||
strings.HasPrefix(line, "导出时间:") {
|
||||
continue
|
||||
}
|
||||
|
||||
// 解析 RAGFlow 文档 ID
|
||||
if strings.HasPrefix(line, "RAGFlow文档ID:") {
|
||||
// RAGFlow文档ID存储在RagSyncRecords中
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 收集产品详情
|
||||
if inDescription {
|
||||
// 跳过分隔线和导出时间
|
||||
if strings.Contains(line, "==================") || strings.HasPrefix(line, "导出时间:") {
|
||||
continue
|
||||
}
|
||||
if line != "暂无产品详情" {
|
||||
descriptionLines = append(descriptionLines, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 拼接产品详情
|
||||
product.Description = strings.Join(descriptionLines, "\n")
|
||||
|
||||
// 校验必填字段
|
||||
if product.Name == "" {
|
||||
return nil, gerror.New("产品名称不能为空")
|
||||
}
|
||||
|
||||
return product, nil
|
||||
}
|
||||
|
||||
// BindToCustomerServices 绑定产品到多个客服账号
|
||||
func (p *product) BindToCustomerServices(ctx context.Context, req *dto.BindProductReq) (res *dto.BindProductRes, err error) {
|
||||
res = &dto.BindProductRes{}
|
||||
|
||||
// 1. 查询产品
|
||||
product, err := dao.Product.GetById(ctx, req.ProductId)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrapf(err, "查询产品失败")
|
||||
}
|
||||
if product == nil {
|
||||
return nil, gerror.New("产品不存在")
|
||||
}
|
||||
|
||||
// 2. 构建已存在的绑定map(去重)
|
||||
existingMap := make(map[string]bool)
|
||||
for _, csId := range product.AccountNames {
|
||||
existingMap[csId] = true
|
||||
}
|
||||
|
||||
// 3. 过滤并添加新绑定
|
||||
var newBindings []string
|
||||
var failedIds []string
|
||||
|
||||
for _, csId := range req.AccountNames {
|
||||
// 检查去重:customer_service_id是否已存在
|
||||
if existingMap[csId] {
|
||||
failedIds = append(failedIds, csId)
|
||||
g.Log().Warningf(ctx, "客服账号 %s 已绑定该产品,跳过", csId)
|
||||
continue
|
||||
}
|
||||
|
||||
// 验证客服账号是否存在
|
||||
csAccount, err := dao.CustomerServiceAccount.FindByAccountName(ctx, csId)
|
||||
if err != nil || csAccount == nil {
|
||||
failedIds = append(failedIds, csId)
|
||||
g.Log().Warningf(ctx, "客服账号 %s 不存在或已删除,跳过", csId)
|
||||
continue
|
||||
}
|
||||
|
||||
newBindings = append(newBindings, csId)
|
||||
}
|
||||
|
||||
// 4. 如果没有新的绑定,直接返回
|
||||
if len(newBindings) == 0 {
|
||||
res.SuccessCount = 0
|
||||
res.FailedIds = failedIds
|
||||
res.Message = "所有客服账号均已绑定或不存在"
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// 5. 更新产品绑定
|
||||
product.AccountNames = append(product.AccountNames, newBindings...)
|
||||
if err = dao.Product.UpdateEntity(ctx, product); err != nil {
|
||||
return nil, gerror.Wrapf(err, "更新产品绑定失败")
|
||||
}
|
||||
|
||||
// 6. 同步到RAGFlow(自动创建知识库)
|
||||
for _, csId := range newBindings {
|
||||
// 获取客服账号信息以获取tenant_id
|
||||
csAccount, err := dao.CustomerServiceAccount.FindByAccountName(ctx, csId)
|
||||
if err != nil || csAccount == nil {
|
||||
g.Log().Errorf(ctx, "获取客服账号信息失败: %s", csId)
|
||||
continue
|
||||
}
|
||||
|
||||
// 同步到RAGFlow(会自动创建知识库)
|
||||
tenantId := gconv.String(csAccount.TenantId)
|
||||
_, err = p.SyncToRAGFlow(ctx, req.ProductId, csId, tenantId)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "同步到RAGFlow失败: product_id=%s, cs_id=%s, error=%v", req.ProductId, csId, err)
|
||||
// 不阻断绑定流程,失败会进入重试队列
|
||||
}
|
||||
}
|
||||
|
||||
res.SuccessCount = len(newBindings)
|
||||
res.FailedIds = failedIds
|
||||
res.Message = "绑定成功"
|
||||
return
|
||||
}
|
||||
|
||||
// UnbindFromCustomerService 从客服账号解绑产品
|
||||
func (p *product) UnbindFromCustomerService(ctx context.Context, req *dto.UnbindProductReq) (res *dto.UnbindProductRes, err error) {
|
||||
res = &dto.UnbindProductRes{}
|
||||
|
||||
product, err := dao.Product.GetById(ctx, req.ProductId)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrapf(err, "查询产品失败")
|
||||
}
|
||||
if product == nil {
|
||||
return nil, gerror.New("产品不存在")
|
||||
}
|
||||
|
||||
// 查找并移除绑定
|
||||
var newBindings []string
|
||||
found := false
|
||||
|
||||
for _, csId := range product.AccountNames {
|
||||
if csId == req.AccountName {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
newBindings = append(newBindings, csId)
|
||||
}
|
||||
|
||||
if !found {
|
||||
res.Success = false
|
||||
res.Message = "未找到该绑定关系"
|
||||
return res, nil
|
||||
}
|
||||
|
||||
product.AccountNames = newBindings
|
||||
if err = dao.Product.UpdateEntity(ctx, product); err != nil {
|
||||
return nil, gerror.Wrapf(err, "解绑失败")
|
||||
}
|
||||
|
||||
res.Success = true
|
||||
res.Message = "解绑成功"
|
||||
return
|
||||
}
|
||||
|
||||
// SyncToRAGFlow 同步产品到RAGFlow(租户级知识库)
|
||||
func (p *product) SyncToRAGFlow(ctx context.Context, productId, accountName, tenantId string) (documentId string, err error) {
|
||||
// 1. 查询产品
|
||||
product, err := dao.Product.GetById(ctx, productId)
|
||||
if err != nil {
|
||||
return "", gerror.Wrapf(err, "查询产品失败")
|
||||
}
|
||||
if product == nil {
|
||||
return "", gerror.New("产品不存在")
|
||||
}
|
||||
|
||||
// 2. 获取租户的产品知识库ID
|
||||
datasetId := fmt.Sprintf("dataset_product_tenant_%s", tenantId)
|
||||
|
||||
// 2.1 确保知识库存在,不存在则自动创建
|
||||
if err := p.ensureDatasetExists(ctx, datasetId, tenantId, "产品"); err != nil {
|
||||
return "", gerror.Wrapf(err, "确保知识库存在失败")
|
||||
}
|
||||
|
||||
// 3. 调用RAGFlow上传文档
|
||||
ragflowClient := ragflow.GetGlobalClient()
|
||||
filename := fmt.Sprintf("%s_%s.txt", product.Name, accountName)
|
||||
documentId, err = ragflowClient.UploadDocumentFromText(ctx, datasetId, product.Description, filename)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err, "产品上传RAGFlow失败")
|
||||
p.sendToRetryQueue(ctx, productId, accountName, tenantId, 0)
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 4. 更新MongoDB的RagSyncRecord
|
||||
now := gtime.Now().Format("Y-m-d H:i:s")
|
||||
updated := false
|
||||
for i := range product.RagSyncRecords {
|
||||
record := &product.RagSyncRecords[i]
|
||||
if record.AccountName == accountName {
|
||||
record.RagDocumentId = documentId
|
||||
record.RagSyncStatus = "synced"
|
||||
record.SyncTime = now
|
||||
record.RetryCount = 0
|
||||
updated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !updated {
|
||||
product.RagSyncRecords = append(product.RagSyncRecords, entity.RagSyncRecord{
|
||||
AccountName: accountName,
|
||||
RagDocumentId: documentId,
|
||||
RagSyncStatus: "synced",
|
||||
SyncTime: now,
|
||||
RetryCount: 0,
|
||||
})
|
||||
}
|
||||
|
||||
product.RagLastSyncTime = now
|
||||
if err = dao.Product.UpdateEntity(ctx, product); err != nil {
|
||||
return "", gerror.Wrapf(err, "更新产品同步状态失败")
|
||||
}
|
||||
|
||||
glog.Infof(ctx, "产品同步成功: product_id=%s, account_name=%s, document_id=%s", productId, accountName, documentId)
|
||||
return documentId, nil
|
||||
}
|
||||
|
||||
// ensureDatasetExists 已废弃,改用公共方法 EnsureTenantDataset
|
||||
// 保留此方法仅为兼容性,直接调用公共方法
|
||||
func (p *product) ensureDatasetExists(ctx context.Context, datasetId, tenantId, datasetType string) error {
|
||||
_, err := EnsureTenantDataset(ctx, tenantId)
|
||||
return err
|
||||
}
|
||||
|
||||
// sendToRetryQueue 发送到重试队列
|
||||
func (p *product) sendToRetryQueue(ctx context.Context, productId, accountName, tenantId string, retryCount int) {
|
||||
msg := dto.RAGFlowSyncRetryMsg{
|
||||
Type: "product",
|
||||
Id: productId,
|
||||
AccountName: accountName,
|
||||
TenantId: tenantId,
|
||||
RetryCount: retryCount,
|
||||
}
|
||||
|
||||
var delay int
|
||||
switch retryCount {
|
||||
case 0:
|
||||
delay = 5 * 60
|
||||
case 1:
|
||||
delay = 15 * 60
|
||||
case 2:
|
||||
delay = 60 * 60
|
||||
default:
|
||||
glog.Warningf(ctx, "产品同步重试次数超限,标记为失败: %s", productId)
|
||||
p.markSyncFailed(ctx, productId, accountName)
|
||||
return
|
||||
}
|
||||
|
||||
if err := rabbitmq.PublishWithDelay(ctx, "ragflow.sync.retry.product", msg, delay); err != nil {
|
||||
jaeger.RecordError(ctx, err, "发送RAGFlow重试消息失败")
|
||||
}
|
||||
}
|
||||
|
||||
// markSyncFailed 标记同步失败
|
||||
func (p *product) markSyncFailed(ctx context.Context, productId, accountName string) {
|
||||
product, err := dao.Product.GetById(ctx, productId)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for i := range product.RagSyncRecords {
|
||||
record := &product.RagSyncRecords[i]
|
||||
if record.AccountName == accountName {
|
||||
record.RagSyncStatus = "failed"
|
||||
record.SyncTime = gtime.Now().Format("Y-m-d H:i:s")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
dao.Product.UpdateEntity(ctx, product)
|
||||
}
|
||||
|
||||
// HandleRAGFlowSyncRetry RAGFlow同步重试消费者
|
||||
func (p *product) HandleRAGFlowSyncRetry(ctx context.Context, msg dto.RAGFlowSyncRetryMsg) error {
|
||||
glog.Infof(ctx, "处理RAGFlow同步重试: type=%s, id=%s, retry=%d", msg.Type, msg.Id, msg.RetryCount)
|
||||
|
||||
if msg.Type != "product" {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := p.SyncToRAGFlow(ctx, msg.Id, msg.AccountName, msg.TenantId)
|
||||
if err != nil {
|
||||
p.sendToRetryQueue(ctx, msg.Id, msg.AccountName, msg.TenantId, msg.RetryCount+1)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProductRetryConsumer 产品RAGFlow重试消费者
|
||||
type ProductRetryConsumer struct {
|
||||
queueName string
|
||||
consumer *rabbitmq.Consumer
|
||||
}
|
||||
|
||||
// NewProductRetryConsumer 创建产品RAGFlow重试消费者
|
||||
func NewProductRetryConsumer(ctx context.Context) *ProductRetryConsumer {
|
||||
return &ProductRetryConsumer{
|
||||
queueName: "ragflow.sync.retry.product",
|
||||
}
|
||||
}
|
||||
|
||||
// Start 启动消费者
|
||||
func (c *ProductRetryConsumer) Start(ctx context.Context) error {
|
||||
c.consumer = rabbitmq.NewConsumer(c.queueName, func(ctx context.Context, body []byte) error {
|
||||
var msg dto.RAGFlowSyncRetryMsg
|
||||
if err := json.Unmarshal(body, &msg); err != nil {
|
||||
return err
|
||||
}
|
||||
return Product.HandleRAGFlowSyncRetry(ctx, msg)
|
||||
})
|
||||
return c.consumer.Start(ctx)
|
||||
}
|
||||
|
||||
// Stop 停止消费者
|
||||
func (c *ProductRetryConsumer) Stop(ctx context.Context) {
|
||||
if c.consumer != nil {
|
||||
c.consumer.Stop(ctx)
|
||||
}
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
// Package service - RAGFlow配置服务
|
||||
// 功能:管理ragflow_config表,对话配置(chatId、提示词)、知识库映射(datasetId)
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/dao"
|
||||
"customer-server/model/dto"
|
||||
|
||||
"gitea.com/red-future/common/ragflow"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/text/gstr"
|
||||
)
|
||||
|
||||
var RAGFlowConfig = new(ragflowConfig)
|
||||
|
||||
type ragflowConfig struct{}
|
||||
|
||||
// Get 获取RAGFlow配置(配置不存在时自动重建)
|
||||
// 参数: ctx - 上下文,req - 查询请求(包含客服账号名)
|
||||
// 返回: res - RAGFlow配置信息(提示词、参数等),err - 错误信息
|
||||
// 功能: 查询客服账号的RAGFlow对话配置,不存在时自动重建
|
||||
func (s *ragflowConfig) Get(ctx context.Context, req *dto.GetRAGFlowConfigReq) (res *dto.GetRAGFlowConfigRes, err error) {
|
||||
config, err := dao.RAGFlowConfig.FindByAccountName(ctx, req.AccountName)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "查询RAGFlow配置失败")
|
||||
}
|
||||
|
||||
// 配置不存在,尝试自动重建
|
||||
if config == nil {
|
||||
g.Log().Warningf(ctx, "客服账号 %s 的RAGFlow配置不存在,尝试自动重建...", req.AccountName)
|
||||
|
||||
// 查询客服账号获取platform
|
||||
account, findErr := dao.CustomerServiceAccount.FindByAccountName(ctx, req.AccountName)
|
||||
if findErr != nil {
|
||||
return nil, gerror.Wrapf(findErr, "查询客服账号失败")
|
||||
}
|
||||
if account == nil {
|
||||
return nil, gerror.Newf("客服账号 %s 不存在,无法自动创建RAGFlow配置", req.AccountName)
|
||||
}
|
||||
|
||||
// 调用重建逻辑
|
||||
recreateReq := &dto.RecreateRAGFlowConfigReq{
|
||||
AccountName: req.AccountName,
|
||||
Platform: account.Platform,
|
||||
}
|
||||
if recreateErr := CustomerServiceAccount.RecreateRAGFlowConfig(ctx, recreateReq); recreateErr != nil {
|
||||
return nil, gerror.Wrapf(recreateErr, "自动重建RAGFlow配置失败")
|
||||
}
|
||||
|
||||
// 重新查询配置
|
||||
config, err = dao.RAGFlowConfig.FindByAccountName(ctx, req.AccountName)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "重建后查询配置失败")
|
||||
}
|
||||
if config == nil {
|
||||
return nil, gerror.New("重建后仍无法获取配置")
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "成功自动重建客服账号 %s 的RAGFlow配置", req.AccountName)
|
||||
}
|
||||
|
||||
// 返回给前端时去掉自动追加的知识库部分,只显示用户的业务话术
|
||||
userPrompt := config.Prompt
|
||||
knowledgePart := "\n\n以下是知识库:\n{knowledge}\n以上是知识库。"
|
||||
if gstr.Contains(userPrompt, knowledgePart) {
|
||||
userPrompt = gstr.Replace(userPrompt, knowledgePart, "")
|
||||
}
|
||||
|
||||
res = &dto.GetRAGFlowConfigRes{
|
||||
AccountName: config.AccountName,
|
||||
Platform: config.Platform,
|
||||
DatasetId: config.DatasetId,
|
||||
DatasetName: config.DatasetName,
|
||||
ChatId: config.ChatId,
|
||||
Prompt: userPrompt, // 只返回用户部分
|
||||
DocumentIds: config.DocumentIds,
|
||||
SimilarityThreshold: config.SimilarityThreshold,
|
||||
KeywordsSimilarityWeight: config.KeywordsSimilarityWeight,
|
||||
TopN: config.TopN,
|
||||
EmptyResponse: config.EmptyResponse,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// UpdatePrompt 更新提示词(配置不存在时自动重建)
|
||||
// 参数: ctx - 上下文,req - 更新提示词请求(包含客服账号名和新提示词)
|
||||
// 返回: res - 更新结果信息,err - 错误信息
|
||||
// 功能: 更新RAGFlow对话的系统提示词并同步到RAGFlow,影响AI回复风格
|
||||
func (s *ragflowConfig) UpdatePrompt(ctx context.Context, req *dto.UpdatePromptReq) (res *dto.UpdatePromptRes, err error) {
|
||||
// 1. 查询当前配置
|
||||
config, err := dao.RAGFlowConfig.FindByAccountName(ctx, req.AccountName)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "查询RAGFlow配置失败")
|
||||
}
|
||||
|
||||
// 配置不存在,尝试自动重建
|
||||
if config == nil {
|
||||
g.Log().Warningf(ctx, "客服账号 %s 的RAGFlow配置不存在,尝试自动重建...", req.AccountName)
|
||||
|
||||
// 查询客服账号获取platform
|
||||
account, findErr := dao.CustomerServiceAccount.FindByAccountName(ctx, req.AccountName)
|
||||
if findErr != nil {
|
||||
return nil, gerror.Wrapf(findErr, "查询客服账号失败")
|
||||
}
|
||||
if account == nil {
|
||||
return nil, gerror.Newf("客服账号 %s 不存在,无法自动创建RAGFlow配置", req.AccountName)
|
||||
}
|
||||
|
||||
// 调用重建逻辑
|
||||
recreateReq := &dto.RecreateRAGFlowConfigReq{
|
||||
AccountName: req.AccountName,
|
||||
Platform: account.Platform,
|
||||
}
|
||||
if recreateErr := CustomerServiceAccount.RecreateRAGFlowConfig(ctx, recreateReq); recreateErr != nil {
|
||||
return nil, gerror.Wrapf(recreateErr, "自动重建RAGFlow配置失败")
|
||||
}
|
||||
|
||||
// 重新查询配置
|
||||
config, err = dao.RAGFlowConfig.FindByAccountName(ctx, req.AccountName)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "重建后查询配置失败")
|
||||
}
|
||||
if config == nil {
|
||||
return nil, gerror.New("重建后仍无法获取配置")
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "成功自动重建客服账号 %s 的RAGFlow配置", req.AccountName)
|
||||
}
|
||||
|
||||
// 2. 更新字段并自动追加知识库引用
|
||||
// 用户只需输入业务话术,后端自动在末尾追加标准的知识库引用格式
|
||||
userPrompt := req.Prompt
|
||||
|
||||
// 先移除所有可能的知识库引用部分(使用查找并截断方式,与List方法保持一致)
|
||||
knowledgePatterns := []string{
|
||||
"\n\n以下是知识库:",
|
||||
"\n\n以下是知识库",
|
||||
"\n以下是知识库:",
|
||||
"\n以下是知识库",
|
||||
}
|
||||
|
||||
// 找到最早出现的知识库引用位置并截断
|
||||
for _, pattern := range knowledgePatterns {
|
||||
if idx := gstr.Pos(userPrompt, pattern); idx >= 0 {
|
||||
userPrompt = gstr.SubStr(userPrompt, 0, idx)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 去除末尾多余的空行
|
||||
userPrompt = gstr.TrimRight(userPrompt)
|
||||
|
||||
// 统一追加标准的知识库引用格式
|
||||
userPrompt = userPrompt + "\n\n以下是知识库:\n{knowledge}\n以上是知识库。"
|
||||
config.Prompt = userPrompt
|
||||
|
||||
if req.SimilarityThreshold != nil {
|
||||
config.SimilarityThreshold = *req.SimilarityThreshold
|
||||
}
|
||||
if req.KeywordsSimilarityWeight != nil {
|
||||
config.KeywordsSimilarityWeight = *req.KeywordsSimilarityWeight
|
||||
}
|
||||
if req.TopN != nil {
|
||||
config.TopN = *req.TopN
|
||||
}
|
||||
if req.EmptyResponse != nil {
|
||||
config.EmptyResponse = *req.EmptyResponse
|
||||
}
|
||||
now := gtime.Now().Time
|
||||
config.UpdatedAt = &now // 取地址赋值给指针类型
|
||||
|
||||
// 3. 调用RAGFlow API更新Chat配置
|
||||
ragflowClient := ragflow.GetGlobalClient()
|
||||
updateReq := &ragflow.UpdateChatReq{
|
||||
Prompt: &ragflow.PromptConfig{
|
||||
Prompt: config.Prompt,
|
||||
SimilarityThreshold: config.SimilarityThreshold,
|
||||
KeywordsSimilarityWeight: config.KeywordsSimilarityWeight,
|
||||
TopN: config.TopN,
|
||||
EmptyResponse: config.EmptyResponse,
|
||||
Variables: []map[string]interface{}{
|
||||
{
|
||||
"key": "knowledge",
|
||||
"optional": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
DatasetIds: []string{config.DatasetId},
|
||||
}
|
||||
|
||||
if err := ragflowClient.UpdateChat(ctx, config.ChatId, updateReq); err != nil {
|
||||
return nil, gerror.Wrapf(err, "调用RAGFlow API更新Chat失败")
|
||||
}
|
||||
|
||||
// 4. 更新MongoDB
|
||||
if err := dao.RAGFlowConfig.UpdateEntity(ctx, config); err != nil {
|
||||
return nil, gerror.Wrap(err, "更新MongoDB配置失败")
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "RAGFlow配置更新成功: account_name=%s, chat_id=%s",
|
||||
config.AccountName, config.ChatId)
|
||||
|
||||
res = &dto.UpdatePromptRes{
|
||||
Success: true,
|
||||
Message: "提示词更新成功",
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -6,32 +6,33 @@ import (
|
||||
"context"
|
||||
"customer-server/dao"
|
||||
"customer-server/model/dto"
|
||||
"fmt"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
var (
|
||||
ScriptedSpeech = new(scriptedSpeech)
|
||||
ScriptedSpeechService = new(scriptedSpeechService)
|
||||
)
|
||||
|
||||
type scriptedSpeech struct{}
|
||||
type scriptedSpeechService struct{}
|
||||
|
||||
// Add 添加预制话术
|
||||
// 参数: ctx - 上下文,req - 添加预制话术请求
|
||||
// 返回: res - 添加成功后的预制话术ID,err - 错误信息
|
||||
// 功能: 创建新的预制话术记录
|
||||
func (s *scriptedSpeech) Add(ctx context.Context, req *dto.AddScriptedSpeechReq) (res *dto.AddScriptedSpeechRes, err error) {
|
||||
// 检查账号是否存在
|
||||
account, err := dao.Account.GetById(ctx, &dto.GetAccountReq{Id: req.AccountId})
|
||||
func (s *scriptedSpeechService) Add(ctx context.Context, req *dto.AddScriptedSpeechReq) (res *dto.AddScriptedSpeechRes, err error) {
|
||||
count, err := dao.ScriptedSpeech.Count(ctx, &dto.ListScriptedSpeechReq{
|
||||
DatasetId: req.DatasetId,
|
||||
SceneType: req.SceneType,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if account == nil {
|
||||
err = gerror.New("客服账号不存在")
|
||||
if count > 0 {
|
||||
err = fmt.Errorf("话术场景已存在")
|
||||
return
|
||||
}
|
||||
|
||||
// 插入数据库
|
||||
id, err := dao.ScriptedSpeech.Insert(ctx, req)
|
||||
if err != nil {
|
||||
@@ -45,7 +46,7 @@ func (s *scriptedSpeech) Add(ctx context.Context, req *dto.AddScriptedSpeechReq)
|
||||
// 参数: ctx - 上下文,req - 更新预制话术请求
|
||||
// 返回: err - 错误信息
|
||||
// 功能: 更新预制话术信息
|
||||
func (s *scriptedSpeech) Update(ctx context.Context, req *dto.UpdateScriptedSpeechReq) (err error) {
|
||||
func (s *scriptedSpeechService) Update(ctx context.Context, req *dto.UpdateScriptedSpeechReq) (err error) {
|
||||
_, err = dao.ScriptedSpeech.Update(ctx, req)
|
||||
return
|
||||
}
|
||||
@@ -54,7 +55,7 @@ func (s *scriptedSpeech) Update(ctx context.Context, req *dto.UpdateScriptedSpee
|
||||
// 参数: ctx - 上下文,req - 删除预制话术请求
|
||||
// 返回: err - 错误信息
|
||||
// 功能: 逻辑删除预制话术记录
|
||||
func (s *scriptedSpeech) Delete(ctx context.Context, req *dto.DeleteScriptedSpeechReq) (err error) {
|
||||
func (s *scriptedSpeechService) Delete(ctx context.Context, req *dto.DeleteScriptedSpeechReq) (err error) {
|
||||
_, err = dao.ScriptedSpeech.Delete(ctx, req)
|
||||
return
|
||||
}
|
||||
@@ -63,7 +64,7 @@ func (s *scriptedSpeech) Delete(ctx context.Context, req *dto.DeleteScriptedSpee
|
||||
// 参数: ctx - 上下文,req - 获取预制话术请求
|
||||
// 返回: res - 预制话术信息,err - 错误信息
|
||||
// 功能: 根据ID获取单个预制话术详情
|
||||
func (s *scriptedSpeech) Get(ctx context.Context, req *dto.GetScriptedSpeechReq) (res *dto.ScriptedSpeechVO, err error) {
|
||||
func (s *scriptedSpeechService) Get(ctx context.Context, req *dto.GetScriptedSpeechReq) (res *dto.ScriptedSpeechVO, err error) {
|
||||
r, err := dao.ScriptedSpeech.GetById(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
@@ -76,7 +77,7 @@ func (s *scriptedSpeech) Get(ctx context.Context, req *dto.GetScriptedSpeechReq)
|
||||
// 参数: ctx - 上下文,req - 列表查询请求
|
||||
// 返回: res - 预制话术列表及分页信息,err - 错误信息
|
||||
// 功能: 分页查询预制话术记录
|
||||
func (s *scriptedSpeech) List(ctx context.Context, req *dto.ListScriptedSpeechReq) (res *dto.ListScriptedSpeechRes, err error) {
|
||||
func (s *scriptedSpeechService) List(ctx context.Context, req *dto.ListScriptedSpeechReq) (res *dto.ListScriptedSpeechRes, err error) {
|
||||
list, total, err := dao.ScriptedSpeech.List(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
// Package service - 会话服务
|
||||
// 功能:用户会话管理、状态维护
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/dao"
|
||||
|
||||
"gitea.com/red-future/common/jaeger"
|
||||
"gitea.com/red-future/common/rabbitmq"
|
||||
"gitea.com/red-future/common/redis"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/os/glog"
|
||||
)
|
||||
|
||||
// sessionService 会话服务(操作 session 表)
|
||||
type sessionService struct{}
|
||||
|
||||
// SessionService 会话服务单例
|
||||
var SessionService = new(sessionService)
|
||||
|
||||
// ============== RabbitMQ 消费者(归档延时消息)==============
|
||||
|
||||
// SessionArchiveConsumer 会话归档消费者
|
||||
type SessionArchiveConsumer struct {
|
||||
consumer *rabbitmq.Consumer
|
||||
}
|
||||
|
||||
// NewSessionArchiveConsumer 创建会话归档消费者
|
||||
func NewSessionArchiveConsumer(ctx context.Context) *SessionArchiveConsumer {
|
||||
queueName := GetConfigString(ctx, "archive.queue")
|
||||
return &SessionArchiveConsumer{
|
||||
consumer: rabbitmq.NewConsumer(queueName, handleSessionArchive),
|
||||
}
|
||||
}
|
||||
|
||||
// Start 启动消费者
|
||||
func (c *SessionArchiveConsumer) Start(ctx context.Context) (err error) {
|
||||
glog.Info(ctx, "会话归档消费者启动...")
|
||||
return c.consumer.Start(ctx)
|
||||
}
|
||||
|
||||
// Stop 停止消费者
|
||||
func (c *SessionArchiveConsumer) Stop(ctx context.Context) {
|
||||
c.consumer.Stop(ctx)
|
||||
}
|
||||
|
||||
// handleSessionArchive 处理会话归档消息
|
||||
func handleSessionArchive(ctx context.Context, body []byte) (err error) {
|
||||
ctx, span := jaeger.NewSpan(ctx, "consumer.session.archive")
|
||||
defer span.End()
|
||||
|
||||
var msg redis.ArchiveMessage
|
||||
if err = gjson.DecodeTo(body, &msg); err != nil {
|
||||
jaeger.RecordError(ctx, err, "解析归档消息失败")
|
||||
return
|
||||
}
|
||||
|
||||
glog.Infof(ctx, "收到归档消息 - 用户: %s, Session: %s", msg.UserId, msg.SessionId)
|
||||
|
||||
// 检查用户是否在归档发送后有活跃(60分钟内)
|
||||
isActive, err := redis.IsUserActive(ctx, msg.UserId, int64(redis.GetArchiveDelay()))
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err, "检查用户活跃状态失败")
|
||||
return
|
||||
}
|
||||
|
||||
if isActive {
|
||||
glog.Infof(ctx, "用户 %s 在归档期间有活跃,跳过归档", msg.UserId)
|
||||
return
|
||||
}
|
||||
|
||||
// 执行归档
|
||||
if err = dao.Session.Archive(ctx, msg.UserId, msg.SessionId); err != nil {
|
||||
jaeger.RecordError(ctx, err, "归档会话失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 清除 Session 缓存(需要tenantId)
|
||||
// TODO: ArchiveMessage需要添加TenantId字段
|
||||
redis.DelSessionCache(ctx, msg.TenantId, msg.UserId)
|
||||
|
||||
glog.Infof(ctx, "会话已归档 - 用户: %s, Session: %s", msg.UserId, msg.SessionId)
|
||||
return
|
||||
}
|
||||
399
service/session_tool_service.go
Normal file
399
service/session_tool_service.go
Normal file
@@ -0,0 +1,399 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/consts/account"
|
||||
"customer-server/consts/public"
|
||||
"customer-server/consts/scriptedSpeech"
|
||||
"customer-server/dao"
|
||||
"customer-server/model/dto"
|
||||
"customer-server/model/entity"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.com/red-future/common/beans"
|
||||
"gitea.com/red-future/common/http"
|
||||
"gitea.com/red-future/common/jaeger"
|
||||
"gitea.com/red-future/common/utils"
|
||||
gmq "github.com/bjang03/gmq/core/gmq"
|
||||
"github.com/bjang03/gmq/mq"
|
||||
"github.com/bjang03/gmq/types"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
var SessionToolService = new(sessionToolService)
|
||||
|
||||
type sessionToolService struct{}
|
||||
|
||||
func (s *sessionToolService) PushOpeningRemark(ctx context.Context, userId string, accountInfo *dto.AccountVO, headers map[string]string) (content string, err error) {
|
||||
content = ""
|
||||
var sceneType = scriptedSpeech.SceneTypeOpeningRemark
|
||||
var key = fmt.Sprintf("account:%s:%s:%s", accountInfo.AccountCode, account.GetDescByCode(accountInfo.Platform), userId)
|
||||
get, err := g.Redis().Get(ctx, key)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if g.IsEmpty(get) {
|
||||
// 构建开场白内容
|
||||
if len(accountInfo.DatasetIds) > 1 {
|
||||
var datasetInfo *dto.RagListDatasetRes
|
||||
datasetInfo, err = SessionToolService.GetDatasetInfo(ctx, accountInfo.DatasetIds, headers)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if g.IsEmpty(datasetInfo) {
|
||||
err = fmt.Errorf("数据集不存在")
|
||||
return
|
||||
}
|
||||
var datasetDescriptions []string
|
||||
for _, dataset := range datasetInfo.List {
|
||||
datasetDescriptions = append(datasetDescriptions, dataset.Name)
|
||||
}
|
||||
content = SessionToolService.BuildMenuContent(accountInfo.Greeting, datasetDescriptions, len(accountInfo.DatasetIds))
|
||||
} else {
|
||||
content = SessionToolService.BuildMenuContent(accountInfo.Greeting, accountInfo.KeywordOption, len(accountInfo.DatasetIds))
|
||||
}
|
||||
err = s.pushDelayMsg(ctx, key, sceneType.Code(), sceneType.Desc(), accountInfo.DatasetIds)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *sessionToolService) PushDialog(ctx context.Context, userId string, questionContent string, accountInfo *dto.AccountVO, headers map[string]string) (content string, err error) {
|
||||
sceneType := scriptedSpeech.SceneTypeDialog
|
||||
// 删除延迟消息
|
||||
//err = s.DeleteDelayMsg(ctx)
|
||||
//if err != nil {
|
||||
// return nil, err
|
||||
//}
|
||||
content = ""
|
||||
var key = fmt.Sprintf("account:%s:%s:%s", accountInfo.AccountCode, account.GetDescByCode(accountInfo.Platform), userId)
|
||||
get, err := g.Redis().Get(ctx, key)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if !g.IsEmpty(get) {
|
||||
// 获取用户对话上下文
|
||||
var history []*dto.Message
|
||||
history, err = SessionToolService.GetUserHistory(ctx, userId)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("获取用户对话上下文失败: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取用户对话记录
|
||||
var accountUserDialog *entity.AccountUserDialog
|
||||
accountUserDialog, err = dao.AccountUserDialog.Get(ctx, &dto.GetAccountUserDialogReq{
|
||||
AccountId: accountInfo.Id,
|
||||
UserId: userId,
|
||||
})
|
||||
if err != nil {
|
||||
err = fmt.Errorf("获取用户对话记录失败: %w", err)
|
||||
return
|
||||
}
|
||||
if g.IsEmpty(accountUserDialog.Id) {
|
||||
// 保存用户对话记录
|
||||
if _, err = dao.AccountUserDialog.Insert(ctx, &dto.AddAccountUserDialogReq{
|
||||
AccountId: accountInfo.Id,
|
||||
UserId: userId,
|
||||
DialogCount: 1,
|
||||
}); err != nil {
|
||||
err = fmt.Errorf("保存用户对话记录失败: %w", err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if accountUserDialog.DialogCount >= g.Cfg().MustGet(ctx, "card.triggerCount").Int64() {
|
||||
// TODO 替换为实际卡片发送逻辑
|
||||
content = "请加一下卡片的联系方式,进行更专业的咨询"
|
||||
sceneType = scriptedSpeech.SceneTypeCardSend
|
||||
if _, err = SessionToolService.ClearUserHistory(ctx, userId); err != nil {
|
||||
err = fmt.Errorf("清除用户对话上下文失败: %w", err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 更新用户对话记录
|
||||
if _, err = dao.AccountUserDialog.Update(ctx, &dto.UpdateAccountUserDialogReq{
|
||||
Id: accountUserDialog.Id,
|
||||
DialogCount: 1,
|
||||
}); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if sceneType.Code() != scriptedSpeech.SceneTypeCardSend.Code() {
|
||||
// 通过HTTP调用rag服务的RAG查询接口
|
||||
var ragQuery *dto.RagQueryRes
|
||||
ragQuery, err = SessionToolService.GetRagQuery(ctx, questionContent, accountInfo.DatasetIds, history, headers)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("调用rag服务的RAG查询接口失败: %w", err)
|
||||
return
|
||||
}
|
||||
content = ragQuery.Answer
|
||||
|
||||
// 保存用户对话上下文
|
||||
err = SessionToolService.SaveUserHistory(ctx, userId, []*dto.Message{
|
||||
{Role: "user", Content: questionContent},
|
||||
{Role: "assistant", Content: content},
|
||||
})
|
||||
if err != nil {
|
||||
err = fmt.Errorf("保存用户对话上下文失败: %w", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
err = s.pushDelayMsg(ctx, key, sceneType.Code(), sceneType.Desc(), accountInfo.DatasetIds)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *sessionToolService) pushDelayMsg(ctx context.Context, key string, sceneTypeCode scriptedSpeech.SceneType, sceneTypeDesc string, datasetIds []int64) (err error) {
|
||||
err = g.Redis().SetEX(ctx, key, sceneTypeDesc, gconv.Int64(10*time.Second))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 获取追问话术内容
|
||||
var msg string
|
||||
if len(datasetIds) == 1 {
|
||||
scriptedSpeechInfo, err := SessionToolService.GetScriptedSpeechContent(ctx, datasetIds[0], sceneTypeCode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取追问话术内容失败: %w", err)
|
||||
}
|
||||
if g.IsEmpty(scriptedSpeechInfo) {
|
||||
if sceneTypeCode == scriptedSpeech.SceneTypeOpeningRemark.Code() {
|
||||
msg = "宝子,刚才给您发的信息您有看到吗?有任何问题都能直接问我,加微信也能更方便沟通~"
|
||||
} else if sceneTypeCode == scriptedSpeech.SceneTypeDialog.Code() {
|
||||
msg = "看您暂时没回复,是不是还有什么疑问?加微信我详细给您说明~"
|
||||
} else if sceneTypeCode == scriptedSpeech.SceneTypeCardSend.Code() {
|
||||
msg = "宝子,加上没~要及时加哦,不然卡片容易失效哒✨"
|
||||
}
|
||||
}
|
||||
msg = scriptedSpeechInfo.QuestionContent
|
||||
} else {
|
||||
msg = "宝子,刚才给您发的信息您有看到吗?有任何问题都能直接问我,加微信也能更方便沟通~"
|
||||
}
|
||||
var msgMap = map[string]string{
|
||||
"key": key,
|
||||
"data": msg,
|
||||
}
|
||||
err = gmq.GetGmq(public.GmqMsgPluginsName).GmqPublishDelay(ctx, &mq.NatsPubDelayMessage{
|
||||
PubDelayMessage: types.PubDelayMessage{
|
||||
PubMessage: types.PubMessage{
|
||||
Topic: public.AccountFollowupTopic,
|
||||
Data: msgMap,
|
||||
},
|
||||
DelaySeconds: 60,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// GetAccountInfo 获取客服账号信息
|
||||
func (s *sessionToolService) GetAccountInfo(ctx context.Context, accountCode string) (res *dto.AccountVO, err error) {
|
||||
r, err := dao.Account.GetByAccountCode(ctx, &dto.GetByAccountCodeReq{
|
||||
AccountCode: accountCode,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取客服账号信息失败: %w", err)
|
||||
}
|
||||
err = gconv.Struct(r, &res)
|
||||
return
|
||||
}
|
||||
|
||||
// SetUserInfo 设置用户信息
|
||||
func (s *sessionToolService) SetUserInfo(ctx context.Context, creator string, tenantId uint64) (headers map[string]string, err error) {
|
||||
// 创建完整的用户信息
|
||||
userInfo := &beans.User{
|
||||
UserName: creator,
|
||||
TenantId: tenantId,
|
||||
}
|
||||
ctx = context.WithValue(ctx, "user", *userInfo)
|
||||
// 提取并保存请求头(在连接升级前)
|
||||
headers = make(map[string]string)
|
||||
// 提取其他headers
|
||||
if r := g.RequestFromCtx(ctx); r != nil {
|
||||
for k, v := range r.Request.Header {
|
||||
if len(v) > 0 {
|
||||
headers[k] = v[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
// 将完整用户信息序列化为JSON,放到X-User-Info请求头
|
||||
userInfoJson, err := gjson.Encode(userInfo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("用户信息序列化失败: %w", err)
|
||||
}
|
||||
headers["X-User-Info"] = string(userInfoJson)
|
||||
return
|
||||
}
|
||||
|
||||
// GetDatasetInfo 获取数据集信息
|
||||
func (s *sessionToolService) GetDatasetInfo(ctx context.Context, datasetIds []int64, headers map[string]string) (res *dto.RagListDatasetRes, err error) {
|
||||
// 通过HTTP调用rag服务的关键词查询接口
|
||||
res = &dto.RagListDatasetRes{}
|
||||
if err = http.Get(ctx, "rag/dataset/list", headers, &res, &dto.RagListDatasetReq{
|
||||
Ids: datasetIds,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("获取数据集信息失败: %w", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// BuildMenuContent 生成菜单话术内容
|
||||
func (s *sessionToolService) BuildMenuContent(greeting string, options []string, datasetCount int) string {
|
||||
var sb strings.Builder
|
||||
// 问候语
|
||||
if datasetCount > 1 {
|
||||
greeting = "您好,很高兴为您服务!请问咨询什么方面问题?"
|
||||
} else {
|
||||
if greeting == "" {
|
||||
greeting = "您好,很高兴为您服务!请问有什么可以帮您?"
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString(greeting)
|
||||
sb.WriteByte('\n')
|
||||
// 拼接选项 1、xx 2、xx...
|
||||
for i, opt := range options {
|
||||
sb.WriteString(fmt.Sprintf("%d、%s\n", i+1, opt))
|
||||
if i == len(options)-1 {
|
||||
sb.WriteString(fmt.Sprintf("%s\n", "💗回复数字就好~"))
|
||||
}
|
||||
}
|
||||
// 固定结尾
|
||||
sb.WriteString("🌟也可直接点击下方咨询专业老师~")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// GetScriptedSpeechContent 获取话术内容
|
||||
func (s *sessionToolService) GetScriptedSpeechContent(ctx context.Context, datasetId int64, sceneType scriptedSpeech.SceneType) (res *dto.ScriptedSpeechVO, err error) {
|
||||
r, err := dao.ScriptedSpeech.GetByDatasetIdAndSceneType(ctx, &dto.ListScriptedSpeechReq{
|
||||
DatasetId: datasetId,
|
||||
SceneType: sceneType,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = gconv.Struct(r, &res)
|
||||
return
|
||||
}
|
||||
|
||||
// GetRagQuery 获取rag查询结果
|
||||
func (s *sessionToolService) GetRagQuery(ctx context.Context, questionContent string, datasetIds []int64, history []*dto.Message, headers map[string]string) (res *dto.RagQueryRes, err error) {
|
||||
resp := new(dto.RagQueryRes)
|
||||
if err = http.Post(ctx, "rag/document/vector/ragQuery", headers, &resp, &dto.RagQueryReq{
|
||||
Content: questionContent,
|
||||
DatasetIds: datasetIds,
|
||||
History: history,
|
||||
TopK: 5,
|
||||
}); err != nil {
|
||||
return
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// SaveUserHistory 保存用户对话历史到Redis
|
||||
func (s *sessionToolService) SaveUserHistory(ctx context.Context, userKey string, newMessages []*dto.Message) (err error) {
|
||||
key := fmt.Sprintf(public.AccountDialogKeyUserId, userKey)
|
||||
|
||||
// 1. 先读旧历史
|
||||
var oldMessages []*dto.Message
|
||||
oldMessages, err = s.GetUserHistory(ctx, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. 合并
|
||||
allMessages := append(oldMessages, newMessages...)
|
||||
|
||||
// 3. 限制长度(保留最新 N 轮)
|
||||
maxMsgCount := 2 * g.Cfg().MustGet(ctx, "history.contextLimit", 5).Int()
|
||||
if len(allMessages) > maxMsgCount {
|
||||
allMessages = allMessages[len(allMessages)-maxMsgCount:]
|
||||
}
|
||||
|
||||
// 4. 存回Redis
|
||||
data, err := json.Marshal(allMessages)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return g.Redis().SetEX(ctx, key, data, gconv.Int64(15*time.Second))
|
||||
}
|
||||
|
||||
// GetUserHistory 从Redis获取用户历史
|
||||
func (s *sessionToolService) GetUserHistory(ctx context.Context, key string) ([]*dto.Message, error) {
|
||||
data, err := g.Redis().Get(ctx, key)
|
||||
if err != nil || data.IsEmpty() {
|
||||
return []*dto.Message{}, nil
|
||||
}
|
||||
|
||||
var messages []*dto.Message
|
||||
if err = json.Unmarshal(data.Bytes(), &messages); err != nil {
|
||||
return []*dto.Message{}, err
|
||||
}
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// ClearUserHistory 清空历史(可选)
|
||||
func (s *sessionToolService) ClearUserHistory(ctx context.Context, userKey string) (int64, error) {
|
||||
key := fmt.Sprintf(public.AccountDialogKeyUserId, userKey)
|
||||
return g.Redis().Del(ctx, key)
|
||||
}
|
||||
|
||||
// getDatasetIdsByKeywords 通过关键词查询数据集ID
|
||||
func (s *sessionToolService) getDatasetIdsByKeywords(ctx context.Context, content string, headers map[string]string) (res []int64, err error) {
|
||||
// 1. 提取关键词
|
||||
keywords := s.extractKeywords(content)
|
||||
g.Log().Infof(ctx, "提取关键词: %v", keywords)
|
||||
|
||||
// 通过HTTP调用rag服务的关键词查询接口
|
||||
respKeyword := &dto.RAGListKeywordRes{}
|
||||
if err = http.Get(ctx, "rag/keyword/listKeyword", headers, &respKeyword, &dto.RAGListKeywordReq{
|
||||
Words: keywords,
|
||||
}); err != nil {
|
||||
jaeger.RecordError(ctx, err, "RAG查询关键词失败")
|
||||
g.Log().Errorf(ctx, "RAG查询关键词失败: %v", err)
|
||||
return
|
||||
}
|
||||
var datasetIds []int64
|
||||
for _, v := range respKeyword.List {
|
||||
if !slices.Contains(datasetIds, v.DatasetId) {
|
||||
datasetIds = append(datasetIds, v.DatasetId)
|
||||
}
|
||||
}
|
||||
return datasetIds, nil
|
||||
}
|
||||
|
||||
// extractKeywords 提取关键词
|
||||
func (s *sessionToolService) extractKeywords(text string) []string {
|
||||
if text == "" {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// 使用gse分词工具提取关键词
|
||||
keywords := utils.GseTool.Extract(text, 5)
|
||||
|
||||
words := make([]string, 0, len(keywords))
|
||||
for _, kw := range keywords {
|
||||
if kw.Word != "" {
|
||||
words = append(words, kw.Word)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有提取到关键词,使用分词结果
|
||||
if len(words) == 0 {
|
||||
words = utils.GseTool.Cut(text)
|
||||
}
|
||||
|
||||
return words
|
||||
}
|
||||
@@ -1,653 +0,0 @@
|
||||
// Package service - 话术服务
|
||||
// 功能:话术的增删改查、绑定/解绑客服账号、同步到RAGFlow、重试消费者
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/dao"
|
||||
"customer-server/model/dto"
|
||||
"customer-server/model/entity"
|
||||
"customer-server/util"
|
||||
"fmt"
|
||||
|
||||
"gitea.com/red-future/common/db/mongo"
|
||||
"gitea.com/red-future/common/jaeger"
|
||||
"gitea.com/red-future/common/rabbitmq"
|
||||
"gitea.com/red-future/common/ragflow"
|
||||
"gitea.com/red-future/common/redis"
|
||||
"gitea.com/red-future/common/utils"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/glog"
|
||||
"github.com/gogf/gf/v2/os/grpool"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/text/gstr"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
var (
|
||||
Speechcraft = new(speechcraft)
|
||||
speechcraftGrpool = grpool.New(50) // 文档解析协程池,最大50并发
|
||||
)
|
||||
|
||||
type speechcraft struct{}
|
||||
|
||||
// Add 添加话术
|
||||
// 参数: ctx - 上下文,req - 添加话术请求(包含标题、内容、方向、标签等)
|
||||
// 返回: res - 添加成功后的话术ID和RAGFlow文档ID,err - 错误信息
|
||||
// 功能: 创建话术记录并自动上传到RAGFlow租户知识库,支持去重检查
|
||||
func (s *speechcraft) Add(ctx context.Context, req *dto.AddSpeechcraftReq) (res *dto.AddSpeechcraftRes, err error) {
|
||||
// 去重检查:同一租户下tag唯一
|
||||
if req.Tag != "" {
|
||||
existing, err := dao.Speechcraft.FindByTag(ctx, req.Tag)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "检查话术重复失败")
|
||||
}
|
||||
if existing != nil {
|
||||
return nil, gerror.Newf("话术tag已存在:tag=%s, id=%s", req.Tag, existing.Id.Hex())
|
||||
}
|
||||
}
|
||||
|
||||
// 先从token或请求获取租户信息(在Insert之前)
|
||||
var tenantId string
|
||||
if req.TenantId != nil {
|
||||
tenantId = gconv.String(req.TenantId)
|
||||
g.Log().Debugf(ctx, "使用请求中的TenantId: %v", req.TenantId)
|
||||
} else {
|
||||
user, err := util.GetTenantInfo(ctx)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "获取租户信息失败")
|
||||
}
|
||||
tenantId = gconv.String(user.TenantId)
|
||||
if tenantId == "" {
|
||||
return nil, gerror.New("租户ID为空")
|
||||
}
|
||||
}
|
||||
|
||||
// 查询租户知识库ID
|
||||
datasetId, err := dao.RAGFlowConfig.FindDatasetIdByTenant(ctx, tenantId)
|
||||
if err != nil || datasetId == "" {
|
||||
return nil, gerror.Newf("租户知识库不存在,请先创建客服账号: tenant_id=%s", tenantId)
|
||||
}
|
||||
|
||||
data := &entity.Speechcraft{}
|
||||
if err = utils.Struct(req, data); err != nil {
|
||||
return
|
||||
}
|
||||
// 设置基础字段
|
||||
now := gtime.Now().Time
|
||||
data.CreatedAt = &now // 取地址赋值给指针类型
|
||||
data.UpdatedAt = &now // 取地址赋值给指针类型
|
||||
data.IsDeleted = false
|
||||
// 统一使用string类型存储tenantId到MongoDB
|
||||
data.TenantId = tenantId
|
||||
|
||||
// 使用DAO封装的Insert方法
|
||||
id, err := dao.Speechcraft.Insert(ctx, data)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "插入话术失败")
|
||||
}
|
||||
data.Id = &id // 取地址赋值给指针类型
|
||||
|
||||
// 同步上传到RAGFlow
|
||||
ragflowClient := ragflow.GetGlobalClient()
|
||||
if ragflowClient == nil {
|
||||
// 回滚:删除刚插入的话术
|
||||
dao.MongoDAO.Delete(ctx, bson.M{"_id": data.Id}, entity.SpeechcraftCollection)
|
||||
return nil, gerror.New("RAGFlow客户端未初始化,请检查配置")
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "准备上传话术到RAGFlow: speechcraft_id=%s, dataset_id=%s, direction=%s, tag=%s",
|
||||
data.Id.Hex(), datasetId, data.Direction, data.Tag)
|
||||
filename := fmt.Sprintf("%s_%s.txt", data.Direction, data.Tag)
|
||||
documentId, err := ragflowClient.UploadDocumentFromText(ctx, datasetId, data.Content, filename)
|
||||
if err != nil {
|
||||
// 回滚:删除刚插入的话术
|
||||
dao.MongoDAO.Delete(ctx, bson.M{"_id": data.Id}, entity.SpeechcraftCollection)
|
||||
g.Log().Errorf(ctx, "话术上传RAGFlow失败: speechcraft_id=%s, dataset_id=%s, error=%v", data.Id.Hex(), datasetId, err)
|
||||
jaeger.RecordError(ctx, err, "话术上传RAGFlow失败")
|
||||
return nil, gerror.Wrap(err, "文档上传到知识库失败")
|
||||
}
|
||||
|
||||
// 异步触发解析(grpool自动管理goroutine生命周期,WithoutCancel保留追踪避免取消)
|
||||
speechcraftGrpool.Add(ctx, func(ctx context.Context) {
|
||||
parseCtx := context.WithoutCancel(ctx)
|
||||
if err := ragflowClient.ParseDocuments(parseCtx, datasetId, []string{documentId}); err != nil {
|
||||
g.Log().Errorf(parseCtx, "文档解析失败: document_id=%s, error=%v", documentId, err)
|
||||
} else {
|
||||
g.Log().Infof(parseCtx, "文档解析成功: document_id=%s", documentId)
|
||||
}
|
||||
})
|
||||
|
||||
// 更新MongoDB的RagSyncRecords数组(使用空accountName表示租户级文档)
|
||||
syncTime := gtime.Now().Format("Y-m-d H:i:s")
|
||||
record := entity.RagSyncRecord{
|
||||
AccountName: "", // 空表示租户级文档
|
||||
RagDocumentId: documentId,
|
||||
RagSyncStatus: "synced",
|
||||
SyncTime: syncTime,
|
||||
RetryCount: 0,
|
||||
}
|
||||
filter := bson.M{"_id": data.Id}
|
||||
update := bson.M{
|
||||
"$set": bson.M{
|
||||
"ragSyncRecords": []entity.RagSyncRecord{record},
|
||||
"ragLastSyncTime": syncTime,
|
||||
"updatedAt": gtime.Now().Time,
|
||||
},
|
||||
}
|
||||
if _, _, err = dao.MongoDAO.UpdateOne(ctx, filter, update, entity.SpeechcraftCollection); err != nil {
|
||||
g.Log().Errorf(ctx, "更新话术RagSyncRecords失败: %v", err)
|
||||
// 不回滚,文档已上传成功
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "话术添加成功并上传到知识库: speechcraft_id=%s, document_id=%s", data.Id.Hex(), documentId)
|
||||
res = &dto.AddSpeechcraftRes{Id: data.Id.Hex()}
|
||||
return
|
||||
}
|
||||
|
||||
// Update 更新话术
|
||||
// 参数: ctx - 上下文,req - 更新话术请求(包含话术ID和待更新字段)
|
||||
// 返回: err - 错误信息
|
||||
// 功能: 更新话术内容并同步到RAGFlow,支持文档删除重建
|
||||
func (s *speechcraft) Update(ctx context.Context, req *dto.UpdateSpeechcraftReq) (err error) {
|
||||
return dao.Speechcraft.Update(ctx, req)
|
||||
}
|
||||
|
||||
// Delete 删除话术
|
||||
// 参数: ctx - 上下文,req - 删除话术请求(包含话术ID)
|
||||
// 返回: err - 错误信息
|
||||
// 功能: 逻辑删除话术记录并从RAGFlow移除对应文档
|
||||
func (s *speechcraft) Delete(ctx context.Context, req *dto.DeleteSpeechcraftReq) (err error) {
|
||||
g.Log().Infof(ctx, "[Delete] 开始删除话术 - speechcraftId: %s", req.Id)
|
||||
|
||||
// 1. 查询话术,获取RAGFlow同步记录(使用原生查询,避免租户过滤)
|
||||
objectId, err := bson.ObjectIDFromHex(req.Id)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "无效的话术ID")
|
||||
}
|
||||
|
||||
var speechcraft entity.Speechcraft
|
||||
filter := bson.M{"_id": objectId, "isDeleted": false}
|
||||
err = dao.MongoDAO.FindOne(ctx, filter, &speechcraft, entity.SpeechcraftCollection)
|
||||
if err != nil {
|
||||
if err.Error() == "mongo: no documents in result" {
|
||||
return gerror.New("话术不存在")
|
||||
}
|
||||
return gerror.Wrap(err, "查询话术失败")
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "[Delete] 查询到话术 - tag: %s, ragSyncRecords数量: %d", speechcraft.Tag, len(speechcraft.RagSyncRecords))
|
||||
|
||||
// 2. 删除RAGFlow中的文档
|
||||
if len(speechcraft.RagSyncRecords) > 0 {
|
||||
ragflowClient := ragflow.GetGlobalClient()
|
||||
if ragflowClient != nil {
|
||||
tenantId := gconv.String(speechcraft.TenantId)
|
||||
|
||||
// 查询租户的dataset_id
|
||||
datasetId, err := dao.RAGFlowConfig.FindDatasetIdByTenant(ctx, tenantId)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "查询租户知识库ID失败: %v", err)
|
||||
} else if datasetId != "" {
|
||||
// 收集所有需要删除的document_id
|
||||
var documentIds []string
|
||||
for _, record := range speechcraft.RagSyncRecords {
|
||||
if record.RagDocumentId != "" {
|
||||
documentIds = append(documentIds, record.RagDocumentId)
|
||||
}
|
||||
}
|
||||
|
||||
// 批量删除RAGFlow文档
|
||||
if len(documentIds) > 0 {
|
||||
if err := ragflowClient.DeleteDocument(ctx, datasetId, documentIds); err != nil {
|
||||
g.Log().Errorf(ctx, "删除RAGFlow文档失败: %v, document_ids: %v", err, documentIds)
|
||||
// 不阻断删除流程,记录错误后继续
|
||||
} else {
|
||||
g.Log().Infof(ctx, "成功删除RAGFlow文档: count=%d", len(documentIds))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 软删除MongoDB记录
|
||||
return dao.Speechcraft.Delete(ctx, req)
|
||||
}
|
||||
|
||||
// List 获取话术列表
|
||||
// 参数: ctx - 上下文,req - 列表查询请求(支持分页、关键词搜索、平台筛选)
|
||||
// 返回: res - 话术列表及分页信息,err - 错误信息
|
||||
// 功能: 分页查询话术记录,支持按标题、内容模糊搜索和平台筛选
|
||||
func (s *speechcraft) List(ctx context.Context, req *dto.ListSpeechcraftReq) (res *dto.ListSpeechcraftRes, err error) {
|
||||
list, total, err := dao.Speechcraft.List(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = &dto.ListSpeechcraftRes{
|
||||
List: list,
|
||||
Total: int(total),
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Match 话术匹配(核心方法)
|
||||
// 根据用户当前阶段、行为、输入内容匹配话术
|
||||
//
|
||||
// func (s *speechcraft) Match(ctx context.Context, userId, platform, content, status string) (answer string, nextStage int, err error) {
|
||||
// // 1. 获取用户当前阶段
|
||||
// state, err := dao.UserStage.GetOrCreate(ctx, userId, platform)
|
||||
func (s *speechcraft) Match(ctx context.Context, userId, platform, tenantId, content, status string) (answer string, nextStage int, err error) {
|
||||
// 1. 获取用户当前状态(Redis,5分钟过期)
|
||||
userState, err := redis.GetUserState(ctx, userId, platform)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err, "获取用户状态失败")
|
||||
return
|
||||
}
|
||||
|
||||
glog.Infof(ctx, "话术匹配 - 用户: %s, 当前阶段: %d, 行为: %s, 内容: %s", userId, userState.Stage, status, content)
|
||||
|
||||
// 2. 状态3(发卡片状态):持续提示用户添加联系方式
|
||||
if userState.Stage == 3 {
|
||||
answer = "请加一下卡片的联系方式,进行更专业的咨询" // TODO: 替换为实际卡片内容
|
||||
nextStage = 3 // 保持状态3
|
||||
glog.Infof(ctx, "用户处于发卡片状态 - 用户: %s", userId)
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 检测用户是否想要联系方式(5次内立即发卡)
|
||||
if s.isRequestingContact(content) {
|
||||
glog.Infof(ctx, "检测到联系方式请求关键词 - 用户: %s, 内容: %s", userId, content)
|
||||
return s.handleCardRequest(ctx, userId, platform)
|
||||
}
|
||||
|
||||
// 5. 所有其他消息直接走RAGFlow(话术已上传到知识库)
|
||||
// 后端只负责:开场白(WebSocket连接时发送)+ 发卡片(上面的逻辑)
|
||||
glog.Infof(ctx, "无话术匹配,转发到RAGFlow - 用户: %s, 内容: %s", userId, content)
|
||||
nextStage = 0
|
||||
if updateErr := redis.SetUserStage(ctx, userId, platform, 0); updateErr != nil {
|
||||
jaeger.RecordError(ctx, updateErr, "更新用户阶段为0失败")
|
||||
}
|
||||
// answer为空,调用方会走RAGFlow
|
||||
return
|
||||
}
|
||||
|
||||
// ProcessAndPublish 处理用户消息并推送到Redis Stream
|
||||
// 参数: ctx - 上下文,userId - 用户ID,platform - 平台,tenantId - 租户ID,content - 消息内容,status - 用户行为状态,accountName - 客服账号名
|
||||
// 返回: isPushed - 是否成功推送话术匹配结果,err - 错误信息
|
||||
// 功能: 尝试匹配话术,匹配成功则直接推送话术内容,失败则转发到Redis Stream由RAGFlow处理
|
||||
func (s *speechcraft) ProcessAndPublish(ctx context.Context, userId, platform, tenantId, content, status, accountName string) (isPushed bool, err error) {
|
||||
// 1. 话术匹配
|
||||
answer, _, err := s.Match(ctx, userId, platform, tenantId, content, status)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 未匹配到话术,直接推送到Redis Stream
|
||||
if answer == "" {
|
||||
glog.Infof(ctx, "话术未匹配,转发到 AI 模型 - 用户: %s, 客服账号: %s", userId, accountName)
|
||||
|
||||
// 获取当前实例的动态响应队列名(自动生成,支持多实例部署)
|
||||
baseQueue := GetConfigString(ctx, "rabbitmq.responseQueue")
|
||||
replyQueue := rabbitmq.GetInstanceQueueName(baseQueue)
|
||||
messageId := userId + "_" + gconv.String(gtime.Now().TimestampNano())
|
||||
|
||||
// 构造Stream消息
|
||||
msg := &redis.SendStreamMessage{
|
||||
UserId: userId,
|
||||
Platform: platform,
|
||||
TenantId: tenantId,
|
||||
AccountName: accountName,
|
||||
Content: content,
|
||||
Timestamp: gtime.Now().Timestamp(),
|
||||
MessageId: messageId,
|
||||
ReplyQueue: replyQueue,
|
||||
}
|
||||
|
||||
// 检查是否有session缓存,无缓存说明已归档,需要读取历史
|
||||
if sessionId, _ := redis.GetSessionCache(ctx, tenantId, userId); sessionId == "" {
|
||||
if history, histErr := dao.Conversation.GetRecentHistory(ctx, userId, redis.GetHistoryContextLimit()); histErr == nil && len(history) > 0 {
|
||||
msg.History = history
|
||||
glog.Infof(ctx, "用户已归档,读取 %d 轮历史对话 - 用户: %s", len(history), userId)
|
||||
}
|
||||
}
|
||||
|
||||
// 写入Redis Stream
|
||||
var streamMsgId string
|
||||
streamMsgId, err = redis.AddToStream(ctx, redis.RAGFlowRequestStreamKey, msg)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err, "写入Stream失败")
|
||||
return
|
||||
}
|
||||
|
||||
glog.Infof(ctx, "消息已写入Stream - StreamID: %s, 用户: %s", streamMsgId, userId)
|
||||
isPushed = false // 未匹配话术,消息转发到AI处理
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 匹配到话术,直接推送 WebSocket(无需经过 RabbitMQ)
|
||||
if err = WebSocket.PushRAGFlowResponse(ctx, tenantId, userId, platform, answer); err != nil {
|
||||
jaeger.RecordError(ctx, err, "推送话术响应失败")
|
||||
return
|
||||
}
|
||||
|
||||
glog.Infof(ctx, "话术响应已推送 - 用户: %s, 话术长度: %d", userId, len(answer))
|
||||
isPushed = true // 已直接推送响应
|
||||
return
|
||||
}
|
||||
|
||||
// ResetUserStage 重置用户阶段
|
||||
func (s *speechcraft) ResetUserStage(ctx context.Context, userId, platform string) (err error) {
|
||||
return dao.UserStage.Reset(ctx, userId, platform)
|
||||
}
|
||||
|
||||
// isRequestingContact 检测用户是否想要联系方式(触发立即发卡)
|
||||
func (s *speechcraft) isRequestingContact(content string) bool {
|
||||
// 联系方式相关关键词
|
||||
contactKeywords := []string{
|
||||
"联系方式", "联系你", "联系",
|
||||
"微信", "VX", "vx", "wx", "WX",
|
||||
"电话", "手机号", "电话号码",
|
||||
"怎么找你", "如何联系", "加你",
|
||||
"私信", "私聊",
|
||||
}
|
||||
|
||||
// 清理内容(去除空格、标点)
|
||||
cleanContent := gstr.Trim(content)
|
||||
cleanContent = gstr.ToLower(cleanContent)
|
||||
|
||||
for _, keyword := range contactKeywords {
|
||||
if gstr.Contains(cleanContent, gstr.ToLower(keyword)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// handleCardRequest 处理用户请求联系方式(立即发卡片)
|
||||
func (s *speechcraft) handleCardRequest(ctx context.Context, userId, platform string) (answer string, nextStage int, err error) {
|
||||
// 更新用户状态为3(发卡片状态)
|
||||
if err = redis.SetUserStage(ctx, userId, platform, 3); err != nil {
|
||||
jaeger.RecordError(ctx, err, "更新用户状态为3失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 返回卡片话术
|
||||
answer = "请加一下卡片的联系方式,进行更专业的咨询" // TODO: 替换为实际卡片内容
|
||||
nextStage = 3
|
||||
|
||||
glog.Infof(ctx, "用户请求联系方式,立即发送卡片 - 用户: %s", userId)
|
||||
return
|
||||
}
|
||||
|
||||
// BindToCustomerServices 绑定话术到客服账号
|
||||
// 参数: ctx - 上下文,req - 绑定请求(包含话术ID和客服账号名列表)
|
||||
// 返回: res - 绑定结果(成功和失败的账号列表),err - 错误信息
|
||||
// 功能: 将话术同步到指定客服账号的RAGFlow知识库,批量处理并返回每个账号的结果
|
||||
func (s *speechcraft) BindToCustomerServices(ctx context.Context, req *dto.BindSpeechcraftReq) (res *dto.BindSpeechcraftRes, err error) {
|
||||
res = &dto.BindSpeechcraftRes{}
|
||||
|
||||
// 0. 参数验证
|
||||
if len(req.AccountNames) == 0 {
|
||||
return nil, gerror.New("客服账号ID列表不能为空")
|
||||
}
|
||||
|
||||
// 1. 查询话术(验证存在性和获取租户信息)
|
||||
r := g.RequestFromCtx(ctx)
|
||||
if r != nil {
|
||||
r.SetParam("accountName", req.AccountNames[0])
|
||||
}
|
||||
|
||||
speechcraft, err := dao.Speechcraft.GetById(ctx, req.SpeechcraftId)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrapf(err, "查询话术失败")
|
||||
}
|
||||
if speechcraft == nil {
|
||||
return nil, gerror.New("话术不存在")
|
||||
}
|
||||
|
||||
speechcraftTenantId := gconv.String(speechcraft.TenantId)
|
||||
|
||||
// 2. 遍历客服账号,更新每个账号的speechcraft_ids
|
||||
var newBindings []string
|
||||
var alreadyBound []string
|
||||
var notFound []string
|
||||
|
||||
for _, csId := range req.AccountNames {
|
||||
// 查询客服账号
|
||||
csAccount, err := dao.CustomerServiceAccount.FindByAccountName(ctx, csId)
|
||||
if err != nil || csAccount == nil {
|
||||
notFound = append(notFound, csId)
|
||||
g.Log().Warningf(ctx, "客服账号 %s 不存在或已删除,跳过", csId)
|
||||
continue
|
||||
}
|
||||
|
||||
// 租户隔离校验
|
||||
accountTenantId := gconv.String(csAccount.TenantId)
|
||||
if speechcraftTenantId != accountTenantId {
|
||||
g.Log().Warningf(ctx, "话术和客服账号不属于同一租户,跳过: speechcraft_tenant=%s, account_tenant=%s",
|
||||
speechcraftTenantId, accountTenantId)
|
||||
notFound = append(notFound, csId)
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查是否已绑定
|
||||
alreadyExists := false
|
||||
for _, existingId := range csAccount.SpeechcraftIds {
|
||||
if existingId == req.SpeechcraftId {
|
||||
alreadyExists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if alreadyExists {
|
||||
alreadyBound = append(alreadyBound, csId)
|
||||
g.Log().Warningf(ctx, "客服账号 %s 已绑定该话术,跳过", csId)
|
||||
continue
|
||||
}
|
||||
|
||||
// 添加到speechcraft_ids列表
|
||||
csAccount.SpeechcraftIds = append(csAccount.SpeechcraftIds, req.SpeechcraftId)
|
||||
|
||||
// 更新数据库
|
||||
filter := bson.M{"_id": csAccount.Id, "isDeleted": false}
|
||||
update := bson.M{"$set": bson.M{"speechcraftIds": csAccount.SpeechcraftIds}}
|
||||
if _, err := mongo.DB().Update(ctx, filter, update, entity.CustomerServiceAccountCollection); err != nil {
|
||||
g.Log().Errorf(ctx, "更新客服账号绑定失败: %s, error=%v", csId, err)
|
||||
notFound = append(notFound, csId)
|
||||
continue
|
||||
}
|
||||
|
||||
newBindings = append(newBindings, csId)
|
||||
}
|
||||
|
||||
// 3. 如果没有新的绑定,直接返回
|
||||
if len(newBindings) == 0 {
|
||||
res.SuccessCount = 0
|
||||
res.AlreadyBound = alreadyBound
|
||||
res.NotFound = notFound
|
||||
|
||||
if len(alreadyBound) > 0 && len(notFound) > 0 {
|
||||
res.Message = "部分客服账号已绑定,部分不存在"
|
||||
} else if len(alreadyBound) > 0 {
|
||||
res.Message = "所有客服账号已绑定,无需重复绑定"
|
||||
} else if len(notFound) > 0 {
|
||||
res.Message = "所有客服账号都不存在或租户不匹配"
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// 6. 同步到RAGFlow(自动创建知识库)
|
||||
for _, csId := range newBindings {
|
||||
// 获取客服账号信息以获取tenant_id
|
||||
csAccount, err := dao.CustomerServiceAccount.FindByAccountName(ctx, csId)
|
||||
if err != nil || csAccount == nil {
|
||||
g.Log().Errorf(ctx, "获取客服账号信息失败: %s", csId)
|
||||
continue
|
||||
}
|
||||
|
||||
// 同步到RAGFlow(会自动创建知识库)
|
||||
tenantId := gconv.String(csAccount.TenantId)
|
||||
g.Log().Infof(ctx, "客服账号租户信息: cs_id=%s, tenant_id=%v, tenant_id_type=%T", csId, csAccount.TenantId, csAccount.TenantId)
|
||||
_, err = s.SyncToRAGFlow(ctx, req.SpeechcraftId, csId, tenantId)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "同步到RAGFlow失败: speechcraft_id=%s, cs_id=%s, error=%v", req.SpeechcraftId, csId, err)
|
||||
// 不阻断绑定流程,失败会进入重试队列
|
||||
}
|
||||
}
|
||||
|
||||
res.SuccessCount = len(newBindings)
|
||||
res.AlreadyBound = alreadyBound
|
||||
res.NotFound = notFound
|
||||
|
||||
// 生成详细的响应消息
|
||||
if len(alreadyBound) > 0 || len(notFound) > 0 {
|
||||
res.Message = fmt.Sprintf("成功绑定%d个", len(newBindings))
|
||||
if len(alreadyBound) > 0 {
|
||||
res.Message += fmt.Sprintf(",%d个已绑定", len(alreadyBound))
|
||||
}
|
||||
if len(notFound) > 0 {
|
||||
res.Message += fmt.Sprintf(",%d个不存在", len(notFound))
|
||||
}
|
||||
} else {
|
||||
res.Message = fmt.Sprintf("成功绑定%d个客服账号", len(newBindings))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// UnbindFromCustomerService 从客服账号解绑话术
|
||||
// 参数: ctx - 上下文,req - 解绑请求(包含话术ID和客服账号名)
|
||||
// 返回: res - 解绑结果信息,err - 错误信息
|
||||
// 功能: 从客服账号的RAGFlow知识库中删除话术文档
|
||||
func (s *speechcraft) UnbindFromCustomerService(ctx context.Context, req *dto.UnbindSpeechcraftReq) (res *dto.UnbindSpeechcraftRes, err error) {
|
||||
res = &dto.UnbindSpeechcraftRes{}
|
||||
|
||||
// 1. 验证话术存在
|
||||
speechcraft, err := dao.Speechcraft.GetById(ctx, req.SpeechcraftId)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrapf(err, "查询话术失败")
|
||||
}
|
||||
if speechcraft == nil {
|
||||
return nil, gerror.New("话术不存在")
|
||||
}
|
||||
|
||||
// 2. 查询客服账号
|
||||
csAccount, err := dao.CustomerServiceAccount.FindByAccountName(ctx, req.AccountName)
|
||||
if err != nil || csAccount == nil {
|
||||
res.Success = false
|
||||
res.Message = "客服账号不存在"
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// 3. 从 speechcraft_ids 中移除话术ID
|
||||
var newSpeechcraftIds []string
|
||||
found := false
|
||||
for _, scId := range csAccount.SpeechcraftIds {
|
||||
if scId == req.SpeechcraftId {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
newSpeechcraftIds = append(newSpeechcraftIds, scId)
|
||||
}
|
||||
|
||||
if !found {
|
||||
res.Success = false
|
||||
res.Message = "未找到该绑定关系"
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// 4. 更新数据库
|
||||
filter := bson.M{"_id": csAccount.Id, "isDeleted": false}
|
||||
update := bson.M{"$set": bson.M{"speechcraftIds": newSpeechcraftIds}}
|
||||
if _, err := mongo.DB().Update(ctx, filter, update, entity.CustomerServiceAccountCollection); err != nil {
|
||||
return nil, gerror.Wrapf(err, "解绑失败")
|
||||
}
|
||||
|
||||
res.Success = true
|
||||
res.Message = "解绑成功"
|
||||
return
|
||||
}
|
||||
|
||||
// SyncToRAGFlow 同步话术到RAGFlow
|
||||
// 参数: ctx - 上下文,speechcraftId - 话术ID,accountName - 客服账号名,tenantId - 租户ID
|
||||
// 返回: documentId - RAGFlow文档ID,err - 错误信息
|
||||
// 功能: 将话术上传到指定客服账号的RAGFlow知识库,失败时自动加入重试队列
|
||||
func (s *speechcraft) SyncToRAGFlow(ctx context.Context, speechcraftId, accountName, tenantId string) (documentId string, err error) {
|
||||
// 1. 查询话术
|
||||
speechcraft, err := dao.Speechcraft.GetById(ctx, speechcraftId)
|
||||
if err != nil {
|
||||
return "", gerror.Wrapf(err, "查询话术失败")
|
||||
}
|
||||
if speechcraft == nil {
|
||||
return "", gerror.New("话术不存在")
|
||||
}
|
||||
|
||||
// 2. 确保知识库存在,获取真实的datasetId
|
||||
datasetId, err := s.ensureDatasetExists(ctx, tenantId, "话术")
|
||||
if err != nil {
|
||||
return "", gerror.Wrapf(err, "确保知识库存在失败")
|
||||
}
|
||||
|
||||
// 3. 调用RAGFlow上传文档
|
||||
ragflowClient := ragflow.GetGlobalClient()
|
||||
filename := fmt.Sprintf("%s_%s_%s.txt", speechcraft.Direction, speechcraft.Tag, accountName)
|
||||
documentId, err = ragflowClient.UploadDocumentFromText(ctx, datasetId, speechcraft.Content, filename)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err, "话术上传RAGFlow失败")
|
||||
return "", gerror.Wrap(err, "话术上传RAGFlow失败")
|
||||
}
|
||||
|
||||
// 3.1 上传成功后立即调用解析接口
|
||||
g.Log().Infof(ctx, "文档上传成功,开始解析: document_id=%s", documentId)
|
||||
if err = ragflowClient.ParseDocuments(ctx, datasetId, []string{documentId}); err != nil {
|
||||
// 解析失败只记录日志,不影响绑定流程(文档已上传,可以手动重试解析)
|
||||
g.Log().Errorf(ctx, "文档解析失败: document_id=%s, error=%v", documentId, err)
|
||||
jaeger.RecordError(ctx, err, "文档解析失败")
|
||||
} else {
|
||||
g.Log().Infof(ctx, "文档解析请求已发送: document_id=%s", documentId)
|
||||
}
|
||||
|
||||
// 4. 更新MongoDB的RagSyncRecord
|
||||
now := gtime.Now().Format("Y-m-d H:i:s")
|
||||
updated := false
|
||||
for i := range speechcraft.RagSyncRecords {
|
||||
record := &speechcraft.RagSyncRecords[i]
|
||||
if record.AccountName == accountName {
|
||||
record.RagDocumentId = documentId
|
||||
record.RagSyncStatus = "synced"
|
||||
record.SyncTime = now
|
||||
record.RetryCount = 0
|
||||
updated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有找到记录,新增
|
||||
if !updated {
|
||||
speechcraft.RagSyncRecords = append(speechcraft.RagSyncRecords, entity.RagSyncRecord{
|
||||
AccountName: accountName,
|
||||
RagDocumentId: documentId,
|
||||
RagSyncStatus: "synced",
|
||||
SyncTime: now,
|
||||
RetryCount: 0,
|
||||
})
|
||||
}
|
||||
|
||||
if err = dao.Speechcraft.UpdateEntity(ctx, speechcraft); err != nil {
|
||||
return "", gerror.Wrapf(err, "更新话术同步状态失败")
|
||||
}
|
||||
|
||||
// 注意:不再更新Chat的datasetIds,因为创建Chat时已经绑定了知识库
|
||||
// 话术文档上传到知识库后,Chat会自动使用该知识库的内容
|
||||
|
||||
glog.Infof(ctx, "话术同步成功: speechcraft_id=%s, account_name=%s, document_id=%s", speechcraftId, accountName, documentId)
|
||||
return documentId, nil
|
||||
}
|
||||
|
||||
// ensureDatasetExists 已废弃,改用公共方法 EnsureTenantDataset
|
||||
// 保留此方法仅为兼容性,直接调用公共方法
|
||||
func (s *speechcraft) ensureDatasetExists(ctx context.Context, tenantId, datasetType string) (datasetId string, err error) {
|
||||
return EnsureTenantDataset(ctx, tenantId)
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
// Package service - Webhook服务
|
||||
// 功能:接收并处理平台(小红书、抖音)的webhook消息
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/dao"
|
||||
"customer-server/model/dto"
|
||||
|
||||
"gitea.com/red-future/common/jaeger"
|
||||
"gitea.com/red-future/common/rabbitmq"
|
||||
"gitea.com/red-future/common/redis"
|
||||
"gitea.com/red-future/common/utils"
|
||||
"github.com/gogf/gf/v2/os/glog"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
var Webhook = new(webhookService)
|
||||
|
||||
type webhookService struct{}
|
||||
|
||||
// Receive 接收平台消息并写入队列
|
||||
func (s *webhookService) Receive(ctx context.Context, req *dto.WebhookReceiveReq) (res *dto.WebhookReceiveRes, err error) {
|
||||
glog.Infof(ctx, "收到 Webhook 消息 - 平台: %s, 用户: %s, 内容: %s", req.Platform, req.UserId, req.Content)
|
||||
|
||||
// 生成消息ID
|
||||
now := gtime.Now()
|
||||
messageId := req.Platform + "_" + req.UserId + "_" + gconv.String(now.TimestampNano())
|
||||
if req.MsgId != "" {
|
||||
messageId = req.MsgId // 使用平台消息ID(便于去重)
|
||||
}
|
||||
|
||||
// 从 token 获取租户ID
|
||||
var tenantId string
|
||||
if user, userErr := utils.GetUserInfo(ctx); userErr == nil {
|
||||
tenantId = gconv.String(user.TenantId)
|
||||
}
|
||||
|
||||
// 构造消息
|
||||
userId := req.Platform + "_" + req.UserId // 添加平台前缀
|
||||
// 获取当前实例的动态响应队列名(自动生成,支持多实例部署)
|
||||
baseQueue := GetConfigString(ctx, "rabbitmq.responseQueue")
|
||||
replyQueue := rabbitmq.GetInstanceQueueName(baseQueue)
|
||||
msg := &redis.SendStreamMessage{
|
||||
UserId: userId,
|
||||
TenantId: tenantId,
|
||||
Content: req.Content,
|
||||
Timestamp: now.Timestamp(),
|
||||
MessageId: messageId,
|
||||
Platform: req.Platform,
|
||||
AccountId: req.AccountId,
|
||||
ReplyQueue: replyQueue,
|
||||
}
|
||||
|
||||
// 检查是否有 session 缓存,无缓存说明已归档,需要读取历史
|
||||
if sessionId, _ := redis.GetSessionCache(ctx, tenantId, userId); sessionId == "" {
|
||||
if history, histErr := dao.Conversation.GetRecentHistory(ctx, userId, redis.GetHistoryContextLimit()); histErr == nil && len(history) > 0 {
|
||||
msg.History = history
|
||||
glog.Infof(ctx, "用户已归档,读取 %d 轮历史对话 - 用户: %s", len(history), userId)
|
||||
}
|
||||
}
|
||||
|
||||
// 写入 Redis Stream
|
||||
streamMsgId, err := redis.AddToStream(ctx, redis.RAGFlowRequestStreamKey, msg)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err, "写入 Stream 失败")
|
||||
return
|
||||
}
|
||||
|
||||
glog.Infof(ctx, "消息已写入 Stream - MessageID: %s", streamMsgId)
|
||||
|
||||
res = &dto.WebhookReceiveRes{
|
||||
Success: true,
|
||||
MsgId: streamMsgId,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// GetHistory 查询用户对话记录
|
||||
func (s *webhookService) GetHistory(ctx context.Context, req *dto.ConversationListReq) (res *dto.ConversationListRes, err error) {
|
||||
list, err := dao.Conversation.FindByUserId(ctx, req.UserId, req.Limit)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err, "查询对话记录失败")
|
||||
return
|
||||
}
|
||||
|
||||
res = &dto.ConversationListRes{
|
||||
List: make([]*dto.ConversationItem, 0, len(list)),
|
||||
}
|
||||
|
||||
for _, item := range list {
|
||||
res.List = append(res.List, &dto.ConversationItem{
|
||||
Question: item.Question,
|
||||
Answer: item.Answer,
|
||||
MsgTime: gtime.New(item.MsgTime).Format("Y-m-d H:i:s"),
|
||||
SessionId: item.SessionId,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1,506 +0,0 @@
|
||||
// Package service - WebSocket服务
|
||||
// 功能:WebSocket连接管理、消息推送、心跳维护
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"customer-server/dao"
|
||||
"customer-server/model/dto"
|
||||
"customer-server/model/entity"
|
||||
"customer-server/util"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
commonMongo "gitea.com/red-future/common/db/mongo"
|
||||
"gitea.com/red-future/common/jaeger"
|
||||
"gitea.com/red-future/common/rabbitmq"
|
||||
"gitea.com/red-future/common/redis"
|
||||
"gitea.com/red-future/common/utils"
|
||||
"github.com/gogf/gf/v2/container/gmap"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
"github.com/gogf/gf/v2/os/glog"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
"github.com/gorilla/websocket"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
// WebSocket 全局单例
|
||||
var WebSocket = &websocketService{
|
||||
connections: gmap.NewStrAnyMap(true),
|
||||
upgrader: websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true // 允许跨域
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// GoFrame 并发安全 Map
|
||||
type websocketService struct {
|
||||
connections *gmap.StrAnyMap
|
||||
upgrader websocket.Upgrader
|
||||
}
|
||||
|
||||
// key: userId_platform
|
||||
// wsConnection WebSocket 连接信息
|
||||
type wsConnection struct {
|
||||
UserId string
|
||||
Platform string
|
||||
TenantId string
|
||||
AccountName string // 客服账号ID
|
||||
Conn *websocket.Conn
|
||||
CreatedAt int64
|
||||
}
|
||||
|
||||
// 租户ID缓存 Key 前缀和过期时间
|
||||
const (
|
||||
tenantCacheKeyPrefix = "tenant:custsvc:"
|
||||
tenantCacheExpire = 300 // 5分钟
|
||||
)
|
||||
|
||||
// resolveTenantId 获取租户ID(兼容仅有accountName的场景)
|
||||
// 参数: ctx - 上下文,r - HTTP请求对象
|
||||
// 返回: tenantId - 租户ID,err - 错误信息
|
||||
// 功能: 优先从token获取,其次从客服账号查询,支持缓存
|
||||
func (s *websocketService) resolveTenantId(ctx context.Context, r *ghttp.Request) (tenantId string, err error) {
|
||||
// 1. 优先从 token 获取
|
||||
if user, userErr := util.GetTenantInfo(ctx); userErr == nil {
|
||||
if id := gconv.String(user.TenantId); id != "" {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
|
||||
if r == nil {
|
||||
return "", gerror.New("无法获取租户信息:缺少请求上下文")
|
||||
}
|
||||
|
||||
// 2. 从请求参数获取 accountName
|
||||
custId := r.Get("accountName").String()
|
||||
if custId == "" {
|
||||
custId = r.Get("account_name").String()
|
||||
}
|
||||
if custId == "" {
|
||||
return "", gerror.New("缺少 accountName 参数")
|
||||
}
|
||||
|
||||
// 2. 从 Redis 缓存查询
|
||||
cacheKey := tenantCacheKeyPrefix + custId
|
||||
if cached, _ := redis.RedisClient().Get(ctx, cacheKey); !cached.IsEmpty() {
|
||||
return cached.String(), nil
|
||||
}
|
||||
|
||||
// 3. Redis 未命中,查询 MongoDB
|
||||
coll := commonMongo.GetDB().Collection(entity.CustomerServiceAccountCollection)
|
||||
var doc struct {
|
||||
TenantId interface{} `bson:"tenantId"`
|
||||
}
|
||||
|
||||
filters := []bson.M{
|
||||
{"accountName": custId, "isDeleted": false},
|
||||
{"accountName": custId}, // 兼容旧数据未设置 isDeleted
|
||||
}
|
||||
|
||||
if objectId, objErr := bson.ObjectIDFromHex(custId); objErr == nil {
|
||||
filters = append(filters,
|
||||
bson.M{"_id": objectId, "isDeleted": false},
|
||||
bson.M{"_id": objectId},
|
||||
)
|
||||
}
|
||||
|
||||
for _, filter := range filters {
|
||||
if err = coll.FindOne(ctx, filter).Decode(&doc); err == nil {
|
||||
tenantId = gconv.String(doc.TenantId)
|
||||
if tenantId == "" {
|
||||
return "", gerror.Newf("客服账号 %s 未配置 tenantId", custId)
|
||||
}
|
||||
// 4. 写入 Redis 缓存
|
||||
redis.RedisClient().SetEX(ctx, cacheKey, tenantId, tenantCacheExpire)
|
||||
return
|
||||
}
|
||||
if !errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return "", gerror.Newf("客服账号 %s 不存在", custId)
|
||||
}
|
||||
|
||||
// Connect 建立 WebSocket 连接
|
||||
func (s *websocketService) Connect(ctx context.Context, r *ghttp.Request, userId, platform string) error {
|
||||
// 使用原生upgrader升级WebSocket连接
|
||||
ws, err := s.upgrader.Upgrade(r.Response.Writer, r.Request, nil)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err, "WebSocket 升级失败")
|
||||
return err
|
||||
}
|
||||
defer ws.Close()
|
||||
|
||||
tenantId, err := s.resolveTenantId(ctx, r)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err, "获取租户ID失败")
|
||||
return err
|
||||
}
|
||||
|
||||
// 读取accountName参数(客服账号名称)
|
||||
accountName := r.Get("accountName").String()
|
||||
if accountName == "" {
|
||||
accountName = r.Get("account_name").String()
|
||||
}
|
||||
|
||||
glog.Infof(ctx, "WebSocket 连接建立 - 用户: %s, 平台: %s, 租户: %s, 客服账号: %s", userId, platform, tenantId, accountName)
|
||||
|
||||
// key格式: tenantId:userId_platform (确保租户隔离)
|
||||
key := tenantId + ":" + userId + "_" + platform
|
||||
|
||||
// 关闭旧连接
|
||||
if old := s.connections.Get(key); old != nil {
|
||||
old.(*wsConnection).Conn.Close()
|
||||
}
|
||||
|
||||
// 注册新连接(携带 TenantId 和 AccountName)
|
||||
s.connections.Set(key, &wsConnection{
|
||||
UserId: userId,
|
||||
Platform: platform,
|
||||
TenantId: tenantId,
|
||||
AccountName: accountName,
|
||||
Conn: ws,
|
||||
CreatedAt: gtime.Now().Timestamp(),
|
||||
})
|
||||
|
||||
// 发送开场白(连接建立后立即推送)
|
||||
if accountName != "" {
|
||||
greeting := s.getGreeting(ctx, accountName, tenantId)
|
||||
if greeting != "" {
|
||||
s.writeJSON(ws, &dto.WebSocketPushMsg{
|
||||
Type: "message",
|
||||
Message: greeting,
|
||||
})
|
||||
glog.Infof(ctx, "已发送开场白 - 用户: %s, 客服账号: %s, 长度: %d", userId, accountName, len(greeting))
|
||||
} else {
|
||||
glog.Warningf(ctx, "客服账号未配置开场白 - accountName: %s, tenantId: %s", accountName, tenantId)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理消息(阻塞)
|
||||
s.handleConnection(ctx, key, ws)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleConnection 处理 WebSocket 连接
|
||||
func (s *websocketService) handleConnection(ctx context.Context, key string, conn *websocket.Conn) {
|
||||
defer func() {
|
||||
s.connections.Remove(key)
|
||||
conn.Close()
|
||||
glog.Infof(ctx, "WebSocket 连接断开 - %s", key)
|
||||
}()
|
||||
|
||||
for {
|
||||
msgType, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
// 排除正常关闭情况:正常关闭、离开页面、无状态码关闭
|
||||
if websocket.IsUnexpectedCloseError(err,
|
||||
websocket.CloseNormalClosure,
|
||||
websocket.CloseGoingAway,
|
||||
websocket.CloseNoStatusReceived,
|
||||
) {
|
||||
jaeger.RecordError(ctx, err, "WebSocket 读取错误")
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if msgType != websocket.TextMessage {
|
||||
continue
|
||||
}
|
||||
|
||||
content := gconv.String(message)
|
||||
glog.Infof(ctx, "收到 WebSocket 消息 - %s: %s", key, content)
|
||||
|
||||
// 解析 userId
|
||||
connInfo := s.connections.Get(key)
|
||||
if connInfo == nil {
|
||||
break
|
||||
}
|
||||
wsConn := connInfo.(*wsConnection)
|
||||
|
||||
// 先检查对话轮数,>5 则只发卡片,跳过话术
|
||||
// checkCardBeforeProcess 已推送卡片消息,无需ack
|
||||
if handled, err := checkCardBeforeProcess(ctx, wsConn.TenantId, wsConn.UserId, wsConn.Platform); err != nil {
|
||||
jaeger.RecordError(ctx, err, "卡片检查失败")
|
||||
} else if handled {
|
||||
continue
|
||||
}
|
||||
|
||||
// 话术匹配并发布响应
|
||||
// status 暂时为空,表示任意行为匹配
|
||||
// isPushed=true表示已直接推送响应(话术匹配),无需ack
|
||||
// isPushed=false表示转发到RAGFlow,需要ack告知用户正在处理
|
||||
|
||||
// 创建带有accountName的context,供GetTenantInfo使用
|
||||
newCtx := ctx
|
||||
if wsConn.AccountName != "" {
|
||||
newCtx = context.WithValue(ctx, "accountName", wsConn.AccountName)
|
||||
}
|
||||
|
||||
isPushed, err := Speechcraft.ProcessAndPublish(newCtx, wsConn.UserId, wsConn.Platform, wsConn.TenantId, content, "", wsConn.AccountName)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err, "话术处理失败")
|
||||
s.writeJSON(conn, &dto.WebSocketPushMsg{Type: "error", Message: "消息处理失败"})
|
||||
continue
|
||||
}
|
||||
|
||||
// 只在转发到RAGFlow时发送ack(Go直接返回的不需要ack)
|
||||
if !isPushed {
|
||||
s.writeJSON(conn, &dto.WebSocketPushMsg{Type: "ack", Message: "消息已接收,正在处理..."})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writeJSON 发送 JSON 消息
|
||||
func (s *websocketService) writeJSON(conn *websocket.Conn, data interface{}) {
|
||||
jsonBytes, _ := gjson.Encode(data)
|
||||
conn.WriteMessage(websocket.TextMessage, jsonBytes)
|
||||
}
|
||||
|
||||
// getGreeting 获取客服账号的开场白
|
||||
func (s *websocketService) getGreeting(ctx context.Context, accountName, tenantId string) string {
|
||||
glog.Infof(ctx, "查询开场白 - accountName: %s, tenantId: %s", accountName, tenantId)
|
||||
|
||||
// 复用dao层方法,保持查询逻辑一致
|
||||
account, err := dao.CustomerServiceAccount.FindByAccountName(ctx, accountName)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err, "查询客服账号开场白失败")
|
||||
glog.Errorf(ctx, "查询开场白失败: %v", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
if account == nil {
|
||||
glog.Warningf(ctx, "客服账号不存在: accountName=%s", accountName)
|
||||
return ""
|
||||
}
|
||||
|
||||
// 详细输出查询结果
|
||||
glog.Infof(ctx, "查询到客服账号: Id=%s, AccountName=%s, TenantId=%v, Greeting长度=%d, Platform=%s",
|
||||
account.Id.Hex(), account.AccountName, account.TenantId, len(account.Greeting), account.Platform)
|
||||
|
||||
return account.Greeting
|
||||
}
|
||||
|
||||
// Send 发送消息到 Redis Stream(HTTP 接口)
|
||||
func (s *websocketService) Send(ctx context.Context, req *dto.WebSocketSendReq) (*dto.WebSocketSendRes, error) {
|
||||
// 从 token 获取租户ID
|
||||
var tenantId string
|
||||
if user, err := utils.GetUserInfo(ctx); err == nil {
|
||||
tenantId = gconv.String(user.TenantId)
|
||||
}
|
||||
|
||||
messageId, err := s.sendToStream(ctx, req.UserId, tenantId, req.Content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.WebSocketSendRes{MessageId: messageId}, nil
|
||||
}
|
||||
|
||||
// sendToStream 发送消息到 Redis Stream
|
||||
// 如果用户无 session 缓存(已归档),则从 MongoDB 读取历史对话一起发送
|
||||
func (s *websocketService) sendToStream(ctx context.Context, userId, tenantId, content string) (string, error) {
|
||||
now := gtime.Now()
|
||||
platform := "xiaohongshu" // 默认平台
|
||||
|
||||
// 获取当前实例的动态响应队列名(自动生成,支持多实例部署)
|
||||
baseQueue := GetConfigString(ctx, "rabbitmq.responseQueue")
|
||||
replyQueue := rabbitmq.GetInstanceQueueName(baseQueue)
|
||||
|
||||
// 获取accountName(优先使用用户选择的方向映射)
|
||||
var accountName string
|
||||
var chatId string
|
||||
|
||||
// 1. 尝试从用户状态获取(用户选择方向后的映射)
|
||||
if userState, stateErr := redis.GetUserState(ctx, userId, platform); stateErr == nil && userState.AccountName != "" {
|
||||
accountName = userState.AccountName
|
||||
glog.Infof(ctx, "使用用户选择方向的客服账号: %s", accountName)
|
||||
} else {
|
||||
// 2. 从连接信息获取(默认)
|
||||
key := tenantId + ":" + userId + "_" + platform
|
||||
connInfo := s.connections.Get(key)
|
||||
if connInfo != nil {
|
||||
wsConn := connInfo.(*wsConnection)
|
||||
accountName = wsConn.AccountName
|
||||
glog.Infof(ctx, "使用连接默认客服账号: %s", accountName)
|
||||
}
|
||||
}
|
||||
|
||||
// 根据accountName查询ragflow_config获取chat_id
|
||||
if accountName != "" {
|
||||
config, err := dao.RAGFlowConfig.FindByAccountName(ctx, accountName)
|
||||
if err == nil && config != nil {
|
||||
chatId = config.ChatId
|
||||
glog.Infof(ctx, "查询到chatId: accountName=%s, chatId=%s", accountName, chatId)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果未找到chatId,报错(应该从ragflowconfig表重建session)
|
||||
if chatId == "" {
|
||||
return "", gerror.New("chatId未找到,需要重建RAGFlow session")
|
||||
}
|
||||
|
||||
msg := &redis.SendStreamMessage{
|
||||
UserId: userId,
|
||||
TenantId: tenantId,
|
||||
AccountName: accountName,
|
||||
ChatId: chatId,
|
||||
Content: content,
|
||||
Timestamp: now.Timestamp(),
|
||||
MessageId: userId + "_" + gconv.String(now.TimestampNano()),
|
||||
ReplyQueue: replyQueue,
|
||||
}
|
||||
|
||||
// 检查是否有 session 缓存,无缓存说明已归档,需要读取历史
|
||||
if sessionId, _ := redis.GetSessionCache(ctx, tenantId, userId); sessionId == "" {
|
||||
// 从 MongoDB 读取历史对话
|
||||
if history, err := dao.Conversation.GetRecentHistory(ctx, userId, redis.GetHistoryContextLimit()); err == nil && len(history) > 0 {
|
||||
msg.History = history
|
||||
glog.Infof(ctx, "用户已归档,读取 %d 轮历史对话 - 用户: %s", len(history), userId)
|
||||
}
|
||||
}
|
||||
|
||||
messageId, err := redis.AddToStream(ctx, redis.RAGFlowRequestStreamKey, msg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
glog.Infof(ctx, "消息已发送到 Stream - MessageID: %s, 用户: %s", messageId, userId)
|
||||
return messageId, nil
|
||||
}
|
||||
|
||||
// SendToUser 发送消息给指定用户
|
||||
func (s *websocketService) SendToUser(ctx context.Context, tenantId, userId, platform string, data interface{}) error {
|
||||
// key格式: tenantId:userId_platform
|
||||
key := tenantId + ":" + userId + "_" + platform
|
||||
connInfo := s.connections.Get(key)
|
||||
if connInfo == nil {
|
||||
glog.Warningf(ctx, "用户不在线 - %s", key)
|
||||
return nil
|
||||
}
|
||||
|
||||
s.writeJSON(connInfo.(*wsConnection).Conn, data)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Broadcast 广播消息给所有连接
|
||||
func (s *websocketService) Broadcast(ctx context.Context, content string) {
|
||||
msg := &dto.WebSocketPushMsg{Type: "broadcast", Content: content}
|
||||
msgBytes := gjson.MustEncode(msg)
|
||||
|
||||
s.connections.Iterator(func(key string, value interface{}) bool {
|
||||
conn := value.(*wsConnection)
|
||||
if err := conn.Conn.WriteMessage(websocket.TextMessage, msgBytes); err != nil {
|
||||
jaeger.RecordError(ctx, err, "广播消息失败 - "+key)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// GetOnlineUsers 获取在线用户列表
|
||||
func (s *websocketService) GetOnlineUsers() *dto.WebSocketOnlineRes {
|
||||
users := make([]dto.WebSocketOnlineUserRes, 0, s.connections.Size())
|
||||
|
||||
s.connections.Iterator(func(_ string, value interface{}) bool {
|
||||
conn := value.(*wsConnection)
|
||||
users = append(users, dto.WebSocketOnlineUserRes{
|
||||
UserId: conn.UserId,
|
||||
Platform: conn.Platform,
|
||||
CreatedAt: conn.CreatedAt,
|
||||
})
|
||||
return true
|
||||
})
|
||||
|
||||
return &dto.WebSocketOnlineRes{
|
||||
Count: len(users),
|
||||
Users: users,
|
||||
}
|
||||
}
|
||||
|
||||
// PushRAGFlowResponse 推送 RAGFlow 响应给用户
|
||||
func (s *websocketService) PushRAGFlowResponse(ctx context.Context, tenantId, userId, platform, content string) error {
|
||||
return s.SendToUser(ctx, tenantId, userId, platform, &dto.WebSocketPushMsg{Type: "answer", Content: content})
|
||||
}
|
||||
|
||||
// ============== RabbitMQ 消费者(追问延时消息)==============
|
||||
|
||||
// FollowUpConsumer 追问消费者
|
||||
type FollowUpConsumer struct {
|
||||
consumer *rabbitmq.Consumer
|
||||
}
|
||||
|
||||
// NewFollowUpConsumer 创建追问消费者
|
||||
func NewFollowUpConsumer(ctx context.Context) *FollowUpConsumer {
|
||||
queueName := GetConfigString(ctx, "followUp.queue")
|
||||
return &FollowUpConsumer{
|
||||
consumer: rabbitmq.NewConsumer(queueName, handleFollowUp),
|
||||
}
|
||||
}
|
||||
|
||||
// Start 启动消费者
|
||||
func (c *FollowUpConsumer) Start(ctx context.Context) (err error) {
|
||||
glog.Info(ctx, "追问消费者启动...")
|
||||
return c.consumer.Start(ctx)
|
||||
}
|
||||
|
||||
// Stop 停止消费者
|
||||
func (c *FollowUpConsumer) Stop(ctx context.Context) {
|
||||
c.consumer.Stop(ctx)
|
||||
}
|
||||
|
||||
// handleFollowUp 处理追问消息
|
||||
func handleFollowUp(ctx context.Context, body []byte) (err error) {
|
||||
ctx, span := jaeger.NewSpan(ctx, "consumer.followup")
|
||||
defer span.End()
|
||||
|
||||
var msg redis.FollowUpMessage
|
||||
if err = gjson.DecodeTo(body, &msg); err != nil {
|
||||
jaeger.RecordError(ctx, err, "解析追问消息失败")
|
||||
return
|
||||
}
|
||||
|
||||
glog.Infof(ctx, "收到追问消息 - 用户: %s, 类型: %d", msg.UserId, msg.FollowUpType)
|
||||
|
||||
// 检查用户状态,如果在状态5(方向选择)或状态3(发卡片),跳过追问
|
||||
userState, err := redis.GetUserState(ctx, msg.UserId, msg.Platform)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err, "获取用户状态失败")
|
||||
return
|
||||
}
|
||||
|
||||
if userState.Stage == 5 {
|
||||
glog.Infof(ctx, "用户 %s 在方向选择状态,跳过追问", msg.UserId)
|
||||
return
|
||||
}
|
||||
|
||||
if userState.Stage == 3 {
|
||||
glog.Infof(ctx, "用户 %s 在发卡片状态,跳过追问", msg.UserId)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查用户是否在追问发送后有活跃
|
||||
isActive, err := redis.IsUserActive(ctx, msg.UserId, int64(redis.GetFollowUpDelay(msg.FollowUpType)))
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err, "检查用户活跃状态失败")
|
||||
return
|
||||
}
|
||||
|
||||
if isActive {
|
||||
glog.Infof(ctx, "用户 %s 在追问期间有活跃,跳过追问", msg.UserId)
|
||||
return
|
||||
}
|
||||
|
||||
// 发送追问消息给用户
|
||||
if err = WebSocket.PushRAGFlowResponse(ctx, msg.TenantId, msg.UserId, msg.Platform, msg.Content); err != nil {
|
||||
jaeger.RecordError(ctx, err, "推送追问消息失败")
|
||||
}
|
||||
|
||||
glog.Infof(ctx, "追问消息已发送 - 租户: %s, 用户: %s, 内容: %s", msg.TenantId, msg.UserId, msg.Content)
|
||||
return
|
||||
}
|
||||
@@ -1,459 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"customer-server/dao"
|
||||
"customer-server/model/dto"
|
||||
"customer-server/model/entity"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
commonMongo "gitea.com/red-future/common/db/mongo"
|
||||
"gitea.com/red-future/common/jaeger"
|
||||
"gitea.com/red-future/common/rabbitmq"
|
||||
"gitea.com/red-future/common/redis"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
var Xiaohongshu = new(xiaohongshu)
|
||||
|
||||
type xiaohongshu struct{}
|
||||
|
||||
const (
|
||||
XhsApiBaseUrl = "https://adapi.xiaohongshu.com"
|
||||
XhsPlatformName = "xiaohongshu"
|
||||
XhsEncryptSplit = "~split~"
|
||||
)
|
||||
|
||||
// ==================== 加解密工具 ====================
|
||||
|
||||
// Encrypt AES加密
|
||||
// 参数: ctx - 上下文,content - 待加密内容,secretKey - 密钥(Base64编码)
|
||||
// 返回: res - 加密后的字符串(Base64编码),err - 错误信息
|
||||
// 功能: 使用AES-CBC模式加密内容,用于小红书API签名
|
||||
func (s *xiaohongshu) Encrypt(ctx context.Context, content, secretKey string) (res string, err error) {
|
||||
keyBytes, err := base64.StdEncoding.DecodeString(secretKey)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(keyBytes)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
iv := make([]byte, aes.BlockSize)
|
||||
if _, err = io.ReadFull(rand.Reader, iv); err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
stream := cipher.NewCBCEncrypter(block, iv)
|
||||
contentBytes := []byte(content)
|
||||
paddedContent := pkcs5Padding(contentBytes, aes.BlockSize)
|
||||
cipherText := make([]byte, len(paddedContent))
|
||||
stream.CryptBlocks(cipherText, paddedContent)
|
||||
|
||||
ivBase64 := base64.StdEncoding.EncodeToString(iv)
|
||||
cipherBase64 := base64.StdEncoding.EncodeToString(cipherText)
|
||||
res = fmt.Sprintf("%s%s%s", ivBase64, XhsEncryptSplit, cipherBase64)
|
||||
return
|
||||
}
|
||||
|
||||
func (s *xiaohongshu) Decrypt(ctx context.Context, cipherText, secretKey string) (res string, err error) {
|
||||
keyBytes, err := base64.StdEncoding.DecodeString(secretKey)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.Split(cipherText, XhsEncryptSplit)
|
||||
if len(parts) != 2 {
|
||||
err = errors.New("invalid cipher text format")
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
iv, err := base64.StdEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
encrypted, err := base64.StdEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(keyBytes)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(encrypted)%aes.BlockSize != 0 {
|
||||
err = errors.New("cipher text is not a multiple of block size")
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
stream := cipher.NewCBCDecrypter(block, iv)
|
||||
decrypted := make([]byte, len(encrypted))
|
||||
stream.CryptBlocks(decrypted, encrypted)
|
||||
|
||||
decrypted, err = pkcs5Unpadding(decrypted)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
res = string(decrypted)
|
||||
return
|
||||
}
|
||||
|
||||
func pkcs5Padding(data []byte, blockSize int) []byte {
|
||||
padding := blockSize - len(data)%blockSize
|
||||
padText := make([]byte, padding)
|
||||
for i := range padText {
|
||||
padText[i] = byte(padding)
|
||||
}
|
||||
return append(data, padText...)
|
||||
}
|
||||
|
||||
func pkcs5Unpadding(data []byte) (res []byte, err error) {
|
||||
length := len(data)
|
||||
if length == 0 {
|
||||
err = errors.New("invalid padding size")
|
||||
return
|
||||
}
|
||||
padding := int(data[length-1])
|
||||
if padding > length {
|
||||
err = errors.New("invalid padding size")
|
||||
return
|
||||
}
|
||||
res = data[:length-padding]
|
||||
return
|
||||
}
|
||||
|
||||
// ==================== 账号绑定管理 ====================
|
||||
|
||||
func (s *xiaohongshu) HandleBindAccount(ctx context.Context, req *dto.XhsBindAccountReq) (err error) {
|
||||
var account entity.CustomerServiceAccount
|
||||
filter := bson.M{"platform": XhsPlatformName, "isDeleted": false}
|
||||
if err = dao.MongoDAO.FindOne(ctx, filter, &account, entity.CustomerServiceAccountCollection); err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
decrypted, err := s.Decrypt(ctx, req.Content, account.SecretKey)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
var bindData dto.XhsBindAccountDecrypted
|
||||
if err = json.Unmarshal([]byte(decrypted), &bindData); err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
update := bson.M{
|
||||
"$set": bson.M{
|
||||
"accessToken": bindData.Token,
|
||||
"appId": bindData.AppId,
|
||||
"xhsUserId": bindData.UserId,
|
||||
"updatedAt": gtime.Now().Time,
|
||||
},
|
||||
}
|
||||
filter = bson.M{"_id": account.Id}
|
||||
_, err = commonMongo.GetDB().Collection(entity.CustomerServiceAccountCollection).UpdateOne(ctx, filter, update)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "[小红书] 绑定账户成功: userId=%s, nickName=%s", bindData.UserId, bindData.NickName)
|
||||
return
|
||||
}
|
||||
|
||||
func (s *xiaohongshu) HandleUnbindAccount(ctx context.Context, req *dto.XhsUnbindAccountReq) (err error) {
|
||||
var account entity.CustomerServiceAccount
|
||||
filter := bson.M{"platform": XhsPlatformName, "isDeleted": false}
|
||||
if err = dao.MongoDAO.FindOne(ctx, filter, &account, entity.CustomerServiceAccountCollection); err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
decrypted, err := s.Decrypt(ctx, req.Content, account.SecretKey)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
var unbindData dto.XhsUnbindAccountDecrypted
|
||||
if err = json.Unmarshal([]byte(decrypted), &unbindData); err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
update := bson.M{
|
||||
"$set": bson.M{
|
||||
"accessToken": "",
|
||||
"xhsUserId": "",
|
||||
"updatedAt": gtime.Now().Time,
|
||||
},
|
||||
}
|
||||
filter = bson.M{"_id": account.Id}
|
||||
_, err = commonMongo.GetDB().Collection(entity.CustomerServiceAccountCollection).UpdateOne(ctx, filter, update)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "[小红书] 解绑账户成功: userId=%s", unbindData.UserId)
|
||||
return
|
||||
}
|
||||
|
||||
// ==================== 消息收发 ====================
|
||||
|
||||
func (s *xiaohongshu) HandleReceiveMessage(ctx context.Context, req *dto.XhsReceiveMessageReq) (err error) {
|
||||
accountId, err := s.getAccountIdByPlatform(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var account entity.CustomerServiceAccount
|
||||
filter := bson.M{"_id": accountId, "isDeleted": false}
|
||||
if err = dao.MongoDAO.FindOne(ctx, filter, &account, entity.CustomerServiceAccountCollection); err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
decrypted, err := s.Decrypt(ctx, req.Content, account.SecretKey)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
var conversation entity.Conversation
|
||||
id := bson.NewObjectID()
|
||||
conversation.Id = &id // 取地址赋值给指针类型
|
||||
conversation.SessionId = fmt.Sprintf("%s_%s", req.FromUserId, XhsPlatformName)
|
||||
conversation.UserId = req.FromUserId
|
||||
conversation.CustomerServiceId = accountId.Hex()
|
||||
conversation.Role = "user"
|
||||
conversation.Platform = XhsPlatformName
|
||||
conversation.MessageId = req.MessageId
|
||||
conversation.MessageType = req.MessageType
|
||||
now := gtime.Now().Time
|
||||
conversation.CreatedAt = &now // 取地址赋值给指针类型
|
||||
|
||||
switch req.MessageType {
|
||||
case "TEXT":
|
||||
var textContent dto.XhsTextContent
|
||||
if err = json.Unmarshal([]byte(decrypted), &textContent); err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
conversation.Content = textContent.Text
|
||||
case "IMAGE":
|
||||
var imgContent dto.XhsImageContent
|
||||
if err = json.Unmarshal([]byte(decrypted), &imgContent); err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
conversation.Content = fmt.Sprintf("[图片]%s", imgContent.Link)
|
||||
case "VIDEO":
|
||||
var videoContent dto.XhsVideoContent
|
||||
if err = json.Unmarshal([]byte(decrypted), &videoContent); err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
conversation.Content = fmt.Sprintf("[视频]%s", videoContent.Link)
|
||||
case "CARD":
|
||||
var cardContent dto.XhsCardContent
|
||||
if err = json.Unmarshal([]byte(decrypted), &cardContent); err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
conversation.Content = fmt.Sprintf("[卡片-%s]%s", cardContent.ContentType, cardContent.Id)
|
||||
case "REVOKE":
|
||||
var revokeContent dto.XhsRevokeContent
|
||||
if err = json.Unmarshal([]byte(decrypted), &revokeContent); err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
conversation.Content = fmt.Sprintf("[撤回消息]%s", revokeContent.MessageId)
|
||||
case "HINT":
|
||||
conversation.Content = "[系统提示消息]"
|
||||
case "SMILES":
|
||||
conversation.Content = "[表情消息]"
|
||||
default:
|
||||
conversation.Content = fmt.Sprintf("[%s类型消息]", req.MessageType)
|
||||
}
|
||||
|
||||
_, err = commonMongo.GetDB().Collection(entity.ConversationCollection).InsertOne(ctx, conversation)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "[小红书] 接收消息成功: sessionId=%s, messageType=%s", conversation.SessionId, req.MessageType)
|
||||
|
||||
if req.MessageType == "TEXT" {
|
||||
asyncCtx := context.WithoutCancel(ctx)
|
||||
go s.processUserMessage(asyncCtx, &account, &conversation)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *xiaohongshu) SendMessage(ctx context.Context, account *entity.CustomerServiceAccount, toUserId, content string) (err error) {
|
||||
textContent := dto.XhsTextContent{Text: content}
|
||||
contentJson, err := json.Marshal(textContent)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
encrypted, err := s.Encrypt(ctx, string(contentJson), account.SecretKey)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
now := gtime.Now()
|
||||
sendReq := dto.XhsSendMessageReq{
|
||||
UserId: account.XhsUserId,
|
||||
RequestId: fmt.Sprintf("%d", now.UnixNano()),
|
||||
MessageType: "TEXT",
|
||||
FromUserId: account.XhsUserId,
|
||||
ToUserId: toUserId,
|
||||
ThirdAccountId: account.Id.Hex(),
|
||||
Timestamp: now.UnixMilli(),
|
||||
Content: encrypted,
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/api/open/im/third/send", XhsApiBaseUrl)
|
||||
client := g.Client()
|
||||
client.SetHeader("Access-Token", account.AccessToken)
|
||||
client.SetHeader("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Post(ctx, url, sendReq)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
defer resp.Close()
|
||||
|
||||
var sendRes dto.XhsSendMessageRes
|
||||
if err = json.Unmarshal(resp.ReadAll(), &sendRes); err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
if sendRes.Code != 0 {
|
||||
err = fmt.Errorf("发送消息失败: code=%d, msg=%s", sendRes.Code, sendRes.Msg)
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
var conversation entity.Conversation
|
||||
id2 := bson.NewObjectID()
|
||||
conversation.Id = &id2 // 取地址赋值给指针类型
|
||||
conversation.SessionId = fmt.Sprintf("%s_%s", toUserId, XhsPlatformName)
|
||||
conversation.UserId = toUserId
|
||||
conversation.CustomerServiceId = account.Id.Hex()
|
||||
conversation.Role = "assistant"
|
||||
conversation.Platform = XhsPlatformName
|
||||
conversation.MessageId = sendRes.Data.MessageId
|
||||
conversation.MessageType = "TEXT"
|
||||
conversation.Content = content
|
||||
now2 := gtime.Now().Time
|
||||
conversation.CreatedAt = &now2 // 取地址赋值给指针类型
|
||||
|
||||
_, err = commonMongo.GetDB().Collection(entity.ConversationCollection).InsertOne(ctx, conversation)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "[小红书] 发送消息成功: toUserId=%s, messageId=%s", toUserId, sendRes.Data.MessageId)
|
||||
return
|
||||
}
|
||||
|
||||
func (s *xiaohongshu) GenerateSignature(ctx context.Context, secretKey, requestBody string) (res string) {
|
||||
h := sha256.New()
|
||||
h.Write([]byte(secretKey + requestBody))
|
||||
res = hex.EncodeToString(h.Sum(nil))
|
||||
return
|
||||
}
|
||||
|
||||
// ==================== 私有方法 ====================
|
||||
|
||||
func (s *xiaohongshu) getAccountIdByPlatform(ctx context.Context) (res bson.ObjectID, err error) {
|
||||
var account entity.CustomerServiceAccount
|
||||
filter := bson.M{"platform": XhsPlatformName, "isDeleted": false}
|
||||
if err = dao.MongoDAO.FindOne(ctx, filter, &account, entity.CustomerServiceAccountCollection); err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
res = *account.Id // 解引用指针类型
|
||||
return
|
||||
}
|
||||
|
||||
func (s *xiaohongshu) processUserMessage(ctx context.Context, account *entity.CustomerServiceAccount, conversation *entity.Conversation) {
|
||||
if err := s.sendToRAGFlowStream(ctx, account, conversation); err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
g.Log().Errorf(ctx, "[小红书] 发送到RAGFlow Stream失败: %v", err)
|
||||
return
|
||||
}
|
||||
g.Log().Infof(ctx, "[小红书] 消息已发送到RAGFlow Stream: userId=%s", conversation.UserId)
|
||||
}
|
||||
|
||||
func (s *xiaohongshu) sendToRAGFlowStream(ctx context.Context, account *entity.CustomerServiceAccount, conversation *entity.Conversation) (err error) {
|
||||
baseQueue := GetConfigString(ctx, "rabbitmq.responseQueue")
|
||||
replyQueue := rabbitmq.GetInstanceQueueName(baseQueue)
|
||||
|
||||
msg := &redis.SendStreamMessage{
|
||||
UserId: fmt.Sprintf("%s_%s", XhsPlatformName, conversation.UserId),
|
||||
TenantId: gconv.String(account.TenantId),
|
||||
Content: conversation.Content,
|
||||
Timestamp: gtime.New(conversation.CreatedAt).Timestamp(),
|
||||
MessageId: conversation.MessageId,
|
||||
Platform: XhsPlatformName,
|
||||
AccountId: account.Id.Hex(),
|
||||
AccountName: account.AccountName,
|
||||
ReplyQueue: replyQueue,
|
||||
}
|
||||
|
||||
if sessionId, _ := redis.GetSessionCache(ctx, gconv.String(account.TenantId), msg.UserId); sessionId == "" {
|
||||
if history, histErr := dao.Conversation.GetRecentHistory(ctx, msg.UserId, redis.GetHistoryContextLimit()); histErr == nil && len(history) > 0 {
|
||||
msg.History = history
|
||||
g.Log().Infof(ctx, "[小红书] 用户已归档,读取 %d 轮历史对话", len(history))
|
||||
}
|
||||
}
|
||||
|
||||
streamMsgId, err := redis.AddToStream(ctx, redis.RAGFlowRequestStreamKey, msg)
|
||||
if err != nil {
|
||||
jaeger.RecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "[小红书] 消息已写入Stream: streamMsgId=%s, sessionId=%s", streamMsgId, conversation.SessionId)
|
||||
return
|
||||
}
|
||||
Reference in New Issue
Block a user