856 lines
32 KiB
Go
856 lines
32 KiB
Go
// 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
|
||
}
|