diff --git a/internal/biz/advice_advicer.go b/internal/biz/advice_advicer.go index 60ae933..bfc97c6 100644 --- a/internal/biz/advice_advicer.go +++ b/internal/biz/advice_advicer.go @@ -22,11 +22,11 @@ import ( ) type AdviceAdvicerBiz struct { - advicerImpl *impl.AdviceAdvicerImpl - advicerVersionMongo *mongo_model.AdvicerVersionMongo + advicerImpl *impl.AdviceAdvicerImpl + advicerVersionMongo *mongo_model.AdvicerVersionMongo aiAdviceModelSupImpl *impl.AiAdviceModelSupImpl - adviceProjectImpl *impl.AdviceProjectImpl - mongo *pkg.Mongo + adviceProjectImpl *impl.AdviceProjectImpl + mongo *pkg.Mongo } func NewAdviceAdvicerBiz( @@ -37,11 +37,11 @@ func NewAdviceAdvicerBiz( mongo *pkg.Mongo, ) *AdviceAdvicerBiz { return &AdviceAdvicerBiz{ - advicerImpl: advicerImpl, - advicerVersionMongo: advicerVersionMongo, + advicerImpl: advicerImpl, + advicerVersionMongo: advicerVersionMongo, aiAdviceModelSupImpl: aiAdviceModelSupImpl, - adviceProjectImpl: adviceProjectImpl, - mongo: mongo, + adviceProjectImpl: adviceProjectImpl, + mongo: mongo, } } @@ -114,6 +114,35 @@ func (a *AdviceAdvicerBiz) HostingUpdate(ctx context.Context, req *entitys.Advic return a.advicerImpl.UpdateByCond(&cond, updates) } +// FindByWxDeviceId 通过 wx_device_id 查销售记录 +func (a *AdviceAdvicerBiz) FindByWxDeviceId(ctx context.Context, wxDeviceId string) (model.AiAdviceAdvicer, error) { + return a.advicerImpl.FindByWxDeviceId(ctx, wxDeviceId) +} + +// GetLatestVersion 获取某销售的最新版本(按 lastUpdateTime 倒序取第一条) +func (a *AdviceAdvicerBiz) GetLatestVersion(ctx context.Context, advicerId int32) (versionId string, version mongo_model.AdvicerVersionMongo, err error) { + list, err := a.VersionList(ctx, &entitys.AdvicerVersionListReq{AdvicerId: advicerId}) + if err != nil { + return "", mongo_model.AdvicerVersionMongo{}, err + } + if len(list) == 0 { + return "", mongo_model.AdvicerVersionMongo{}, fmt.Errorf("未找到该销售的版本") + } + // 按 LastUpdateTime 倒序找最新 + latest := list[0] + for _, item := range list[1:] { + if item.LastUpdateTime.After(latest.LastUpdateTime) { + latest = item + } + } + return latest.Id.Hex(), mongo_model.AdvicerVersionMongo{ + AdvicerId: latest.AdvicerId, + VersionDesc: latest.VersionDesc, + Data: latest.Data, + LastUpdateTime: latest.LastUpdateTime, + }, nil +} + func (a *AdviceAdvicerBiz) VersionAdd(ctx context.Context, param *entitys.AdvicerVersionAddReq) (id interface{}, err error) { cond := builder.NewCond() cond = cond.And(builder.Eq{"advicer_id": param.AdvicerID}) diff --git a/internal/biz/advice_chat.go b/internal/biz/advice_chat.go index b460f8e..e0ccf18 100644 --- a/internal/biz/advice_chat.go +++ b/internal/biz/advice_chat.go @@ -84,6 +84,7 @@ func (a *AdviceChatBiz) Regis(ctx context.Context, chatData *entitys.ChatData, r ProjectID: projectInfo.Base.ProjectID, SupID: projectInfo.Base.ModelSupID, AdvicerVersionID: req.AdvicerVersionId, + AdvicerID: req.AdvicerId, ClientID: req.ClientId, TalkSkillID: req.TalkSkillId, Mission: req.Mission, @@ -166,6 +167,62 @@ func (a *AdviceChatBiz) Chat(ctx context.Context, chat *entitys.AdvicerChatReq) return } +// ChatWithSession 与 Chat 逻辑相同,但直接接收已查到的 session 和 modelInfo,跳过重复查询。 +// 用于托管流程中 Regis 刚创建完 session 后立即 Chat 的场景。 +func (a *AdviceChatBiz) ChatWithSession(ctx context.Context, chat *entitys.AdvicerChatReq, session *dbmodel.AiAdviceSession, modelInfo *dbmodel.AiAdviceModelSup) (assistant mongo_model.Assistant, err error) { + if session == nil || session.SessionID == "" { + return assistant, errors.New("会话信息不能为空") + } + if modelInfo == nil || modelInfo.SupID == 0 { + return assistant, errors.New("模型信息不能为空") + } + if len(chat.Content) == 0 { + return assistant, nil + } + chatHis, err := a.getChatHis(ctx, session.SessionID, 6) + if err != nil { + return assistant, err + } + prompt, err := a.buildChatPromptResponse(ctx, chat, session, chatHis) + if err != nil { + return assistant, err + } + resContent, err := a.callLlmResponse(ctx, prompt, modelInfo.Key, modelInfo.URL, modelInfo.ChatModel, session.ContextCache) + if err != nil { + return assistant, err + } + + result := resContent.GetOutputText() + if err = json.Unmarshal([]byte(result), &assistant); err != nil { + return assistant, err + } + var inToken, outToken int64 + if resContent.Usage != nil { + inToken = int64(resContent.Usage.InputTokens) + outToken = int64(resContent.Usage.OutputTokens) + } + chatCtx, cancel := context.WithCancel(context.Background()) + go func(s dbmodel.AiAdviceSession) { + defer cancel() + _, _ = a.mongo.Co(a.advicerChatHisMongo).InsertOne(chatCtx, &mongo_model.AdvicerChatHisMongo{ + SessionId: chat.SessionId, + User: chat.Content, + Assistant: assistant, + InToken: inToken, + OutToken: outToken, + CreatAt: time.Now(), + }) + if assistant.MissionStatus == "fail" || assistant.MissionStatus == "completed" { + cond := builder.NewCond() + cond = cond.And(builder.Eq{"session_id": chat.SessionId}) + s.MissionStatus = assistant.MissionStatus + s.MissionCompleteDesc = assistant.MissionCompleteDesc + _ = a.aiAdviceSessionImpl.UpdateByCond(&cond, s) + } + }(*session) + return +} + func (a *AdviceChatBiz) buildChatPromptResponse(ctx context.Context, chat *entitys.AdvicerChatReq, session *dbmodel.AiAdviceSession, chatList []mongo_model.AdvicerChatHisMongoEntity) ([]openai.ResponseInputMessage, error) { message := []openai.ResponseInputMessage{ { diff --git a/internal/biz/advice_customer_new.go b/internal/biz/advice_customer_new.go index ec43fcd..e2917ce 100644 --- a/internal/biz/advice_customer_new.go +++ b/internal/biz/advice_customer_new.go @@ -106,6 +106,11 @@ func (a *AdviceCustomerBiz) BatchUpdate(ctx context.Context, req *entitys.Advice return a.impl.BatchUpdateByWxid(ctx, records) } +// UpdateLabels 仅更新好友的标签列表 +func (a *AdviceCustomerBiz) UpdateLabels(ctx context.Context, req *entitys.AdvicerCustomerUpdateLabelsReq) error { + return a.impl.UpdateLabelList(ctx, req.SelfWxid, req.UserName, req.LabelList) +} + func (a *AdviceCustomerBiz) toModel(req *entitys.AdvicerCustomerAddReq) model.AiAdviceCustomer { return model.AiAdviceCustomer{ SelfWxid: req.SelfWxid, @@ -131,3 +136,13 @@ func (a *AdviceCustomerBiz) toModel(req *entitys.AdvicerCustomerAddReq) model.Ai PhoneNumList: req.PhoneNumList, } } + +// FindBySelfWxidAndUserName 按登录wxid+客户wxid查单条客户记录 +func (a *AdviceCustomerBiz) FindBySelfWxidAndUserName(ctx context.Context, selfWxid, userName string) (model.AiAdviceCustomer, error) { + return a.impl.FindBySelfWxidAndUserName(ctx, selfWxid, userName) +} + +// UpdateSessionId 更新客户的当前活跃会话ID +func (a *AdviceCustomerBiz) UpdateSessionId(ctx context.Context, customerId int32, sessionId string) error { + return a.impl.UpdateSessionId(ctx, customerId, sessionId) +} diff --git a/internal/biz/advice_label.go b/internal/biz/advice_label.go index 6656f6a..1f2b8ef 100644 --- a/internal/biz/advice_label.go +++ b/internal/biz/advice_label.go @@ -82,3 +82,13 @@ func (a *AdviceLabelBiz) Sync(ctx context.Context, req *entitys.AdvicerLabelSync return nil } + +// ToggleHosting 切换标签托管状态 +func (a *AdviceLabelBiz) ToggleHosting(ctx context.Context, req *entitys.AdvicerLabelHostingReq) error { + return a.impl.UpdateHosting(ctx, req.SelfWxid, req.LabelId, req.Hosting) +} + +// GetHostingLabelIds 返回某销售下 hosting=1 的标签 ID 列表 +func (a *AdviceLabelBiz) GetHostingLabelIds(ctx context.Context, selfWxid string) ([]int, error) { + return a.impl.GetHostingLabelIds(ctx, selfWxid) +} diff --git a/internal/biz/advice_wx.go b/internal/biz/advice_wx.go index 1e774bc..fdce12b 100644 --- a/internal/biz/advice_wx.go +++ b/internal/biz/advice_wx.go @@ -2,6 +2,7 @@ package biz import ( "ai_scheduler/internal/config" + dbmodel "ai_scheduler/internal/data/model" "ai_scheduler/internal/data/mongo_model" "ai_scheduler/internal/entitys" "ai_scheduler/internal/pkg" @@ -11,6 +12,7 @@ import ( "encoding/json" "errors" "fmt" + "math/rand" "strings" "time" @@ -18,6 +20,7 @@ import ( "go.mongodb.org/mongo-driver/mongo/options" "github.com/gofiber/fiber/v2/log" + "github.com/google/uuid" ) // WsBroadcaster WebSocket 广播接口(由 services/advice.WxWsHub 实现) @@ -29,12 +32,19 @@ type WsBroadcaster interface { // 上游(WTAPI)消息通过 login/setCallback 配置的回调地址推送到本服务, // 本模块负责容错解析回调报文、记录消息流水、更新客户互动时间。 type AdviceWxBiz struct { - cfg *config.Config - mongo *pkg.Mongo - wxMsgMongo *mongo_model.AdvicerWxMsgMongo - clientBiz *AdviceClientBiz - strategyBiz *AdviceStrategyBiz - wsBroadcaster WsBroadcaster + cfg *config.Config + mongo *pkg.Mongo + wxMsgMongo *mongo_model.AdvicerWxMsgMongo + clientBiz *AdviceClientBiz + strategyBiz *AdviceStrategyBiz + wsBroadcaster WsBroadcaster + adviceCustomerBiz *AdviceCustomerBiz + adviceLabelBiz *AdviceLabelBiz + adviceAdvicerBiz *AdviceAdvicerBiz + adviceChatBiz *AdviceChatBiz + adviceProjectBiz *AdviceProjectBiz + adviceSkillBiz *AdviceSkillBiz + wxSendBiz *AdviceWxSendBiz } func NewAdviceWxBiz( @@ -44,14 +54,28 @@ func NewAdviceWxBiz( clientBiz *AdviceClientBiz, strategyBiz *AdviceStrategyBiz, wsBroadcaster WsBroadcaster, + adviceCustomerBiz *AdviceCustomerBiz, + adviceLabelBiz *AdviceLabelBiz, + adviceAdvicerBiz *AdviceAdvicerBiz, + adviceChatBiz *AdviceChatBiz, + adviceProjectBiz *AdviceProjectBiz, + adviceSkillBiz *AdviceSkillBiz, + wxSendBiz *AdviceWxSendBiz, ) *AdviceWxBiz { return &AdviceWxBiz{ - cfg: cfg, - mongo: mongo, - wxMsgMongo: wxMsgMongo, - clientBiz: clientBiz, - strategyBiz: strategyBiz, - wsBroadcaster: wsBroadcaster, + cfg: cfg, + mongo: mongo, + wxMsgMongo: wxMsgMongo, + clientBiz: clientBiz, + strategyBiz: strategyBiz, + wsBroadcaster: wsBroadcaster, + adviceCustomerBiz: adviceCustomerBiz, + adviceLabelBiz: adviceLabelBiz, + adviceAdvicerBiz: adviceAdvicerBiz, + adviceChatBiz: adviceChatBiz, + adviceProjectBiz: adviceProjectBiz, + adviceSkillBiz: adviceSkillBiz, + wxSendBiz: wxSendBiz, } } @@ -226,9 +250,303 @@ func (a *AdviceWxBiz) handleOneEvent(ctx context.Context, body []byte) error { a.strategyBiz.OnCustomerMessage(sctx, wxid, msgContent) }() } + + // 托管自动注册 + AI 回复:双向触发(不限 direction),文本私聊消息检查 + if wx.IsTextMsg(msgType) && !event.IsGroupMsg() { + appId, selfWxid, msgContent := event.AppId, event.Wxid, content + go a.tryHostingAutoRegis(context.Background(), appId, selfWxid, peerWxid, msgContent, direction) + } return nil } +// ==================== 托管自动注册 ==================== + +// tryHostingAutoRegis 托管自动注册 + AI 回复:检查消息是否触发托管会话, +// 若客户无 session 则自动注册,然后用触发消息调用 Chat 生成 AI 回复并发送。 +func (a *AdviceWxBiz) tryHostingAutoRegis(ctx context.Context, appId, selfWxid, peerWxid, content string, direction string) { + if appId == "" || selfWxid == "" || peerWxid == "" { + return + } + + // 1. 通过 appId(即 wx_device_id)查 advicer + advicer, err := a.adviceAdvicerBiz.FindByWxDeviceId(ctx, appId) + if err != nil { + log.Infof("[托管] 查 advicer 失败 appId=%s err=%v", appId, err) + return + } + if advicer.AdvicerID == 0 || advicer.HostingEnabled != 1 { + return + } + + // 2. 查 customer(selfWxid + peerWxid) + customer, err := a.adviceCustomerBiz.FindBySelfWxidAndUserName(ctx, selfWxid, peerWxid) + if err != nil { + log.Infof("[托管] 查 customer 失败 selfWxid=%s peerWxid=%s err=%v", selfWxid, peerWxid, err) + return + } + if customer.CustomerID == 0 { + return + } + + sessionId := customer.SessionId + var cachedSession *dbmodel.AiAdviceSession + var cachedModelInfo *dbmodel.AiAdviceModelSup + + // 3. 无会话 → 检查标签交集 → 自动注册(projectInfo 延迟到需要时才查) + if sessionId == "" { + hostingLabelIds, _ := a.adviceLabelBiz.GetHostingLabelIds(ctx, selfWxid) + if len(hostingLabelIds) == 0 { + return + } + customerLabelSet := make(map[int]bool) + if customer.LabelList != "" { + for _, s := range strings.Split(customer.LabelList, ",") { + var id int + if _, e := fmt.Sscanf(strings.TrimSpace(s), "%d", &id); e == nil { + customerLabelSet[id] = true + } + } + } + hasIntersection := false + for _, id := range hostingLabelIds { + if customerLabelSet[id] { + hasIntersection = true + break + } + } + if !hasIntersection { + return + } + // 标签有交集,才查项目信息(用于注册 + 后续发送) + projectInfo, err := a.adviceProjectBiz.Info(ctx, &entitys.AdvicerProjectInfoReq{ProjectId: advicer.ProjectID}) + if err != nil { + log.Infof("[托管] 查项目信息失败 projectId=%d err=%v", advicer.ProjectID, err) + return + } + sessionId, cachedSession, cachedModelInfo, err = a.autoRegisSession(ctx, advicer, projectInfo, customer.CustomerID, customer.NickName) + if err != nil { + log.Infof("[托管] 自动注册会话失败 err=%v", err) + return + } + } + + // 4. 有会话 + 客户发的消息 → 调用 Chat 生成 AI 回复并发送 + if sessionId != "" && direction == mongo_model.WxMsgDirectionCustomer && content != "" { + // 延迟获取 wxToken(已有会话路径才需要查项目信息) + wxToken := "" + if p, err := a.adviceProjectBiz.Info(ctx, &entitys.AdvicerProjectInfoReq{ProjectId: advicer.ProjectID}); err == nil { + wxToken = p.Base.WxToken + } else { + log.Infof("[托管] 查项目信息失败 projectId=%d err=%v", advicer.ProjectID, err) + return + } + a.hostingChatAndSend(ctx, sessionId, content, appId, peerWxid, wxToken, cachedSession, cachedModelInfo) + } +} + +// autoRegisSession 自动注册托管会话,返回 sessionId + session对象 + modelInfo(供后续 Chat 复用)。 +// advicer 和 projectInfo 由调用方传入,避免重复查询。 +func (a *AdviceWxBiz) autoRegisSession(ctx context.Context, + advicer dbmodel.AiAdviceAdvicer, + projectInfo *entitys.AdvicerProjectInfoRes, + customerId int32, customerNickName string, +) (string, *dbmodel.AiAdviceSession, *dbmodel.AiAdviceModelSup, error) { + // 解析版本 + versionId := advicer.HostingVersionId + var err error + if versionId == "" { + versionId, _, err = a.adviceAdvicerBiz.GetLatestVersion(ctx, advicer.AdvicerID) + if err != nil { + return "", nil, nil, fmt.Errorf("取最新版本版本失败: %w", err) + } + } + + if len(projectInfo.ModelInfo.Key) == 0 || len(projectInfo.ModelInfo.ChatModel) == 0 { + return "", nil, nil, fmt.Errorf("项目未配置模型 projectId=%d", advicer.ProjectID) + } + + // 查模型信息(用于后续 Chat,通过 projectInfo 中的 ModelSupID 获取) + modelInfo, err := a.adviceProjectBiz.ModelInfo(projectInfo.Base.ModelSupID) + if err != nil { + return "", nil, nil, fmt.Errorf("查模型信息失败: %w", err) + } + + // 查版本数据 + versionInfo, err := a.adviceAdvicerBiz.VersionInfo(ctx, &entitys.AdvicerVersionInfoReq{Id: versionId}) + if err != nil { + return "", nil, nil, fmt.Errorf("查版本数据失败: %w", err) + } + + // 构建 ChatData(直接使用传入的 advicer,不再重复查询) + chatData := &entitys.ChatData{ + AdvicerInfo: advicer.Entity(), + AdvicerVersion: versionInfo.Data, + ProjectInfo: projectInfo.ConfigInfo.Entity(), + RuleDimension: projectInfo.Base.RuleDimension, + } + + // 构建注册请求(托管场景额外强调短消息格式) + mission := fmt.Sprintf("与%s的自动托管会话。注意:每条回复必须简短(10~30字),像真人微信聊天一样拆成多条短消息,用\\n分隔。", customerNickName) + if customerNickName == "" { + mission = "自动托管会话。注意:每条回复必须简短(10~30字),像真人微信聊天一样拆成多条短消息,用\\n分隔。" + } + regisReq := &entitys.AdvicerChatRegistReq{ + AdvicerVersionId: versionId, + TalkSkillId: advicer.HostingSkillId, + Mission: mission, + AdvicerId: advicer.AdvicerID, + } + + // 构造 session 对象(Regis 内部会写入相同数据,这里同步保存供后续 Chat 复用) + sessionId := uuid.New().String() + session := &dbmodel.AiAdviceSession{ + SessionID: sessionId, + ProjectID: projectInfo.Base.ProjectID, + SupID: projectInfo.Base.ModelSupID, + AdvicerVersionID: versionId, + AdvicerID: advicer.AdvicerID, + TalkSkillID: advicer.HostingSkillId, + Mission: mission, + } + + // 注册会话 + regisSessionId, err := a.adviceChatBiz.Regis(ctx, chatData, regisReq, projectInfo) + if err != nil { + return "", nil, nil, fmt.Errorf("注册会话失败: %w", err) + } + sessionId = regisSessionId + session.SessionID = sessionId + + // 回写 customer.session_id + if err = a.adviceCustomerBiz.UpdateSessionId(ctx, customerId, sessionId); err != nil { + log.Infof("[托管] 回写 session_id 失败 customerId=%d sessionId=%s err=%v", customerId, sessionId, err) + } + log.Infof("[托管] 自动注册会话成功 customerId=%d sessionId=%s advicerId=%d", customerId, sessionId, advicer.AdvicerID) + return sessionId, session, &modelInfo, nil +} + +// hostingChatAndSend 调用 Chat 生成 AI 回复,并通过微信发送。 +// 当 session 和 modelInfo 非空时(新注册路径),使用 ChatWithSession 跳过重复查询。 +func (a *AdviceWxBiz) hostingChatAndSend(ctx context.Context, sessionId, content, appId, peerWxid, wxToken string, + session *dbmodel.AiAdviceSession, modelInfo *dbmodel.AiAdviceModelSup, +) { + var assistant mongo_model.Assistant + var err error + + chatReq := &entitys.AdvicerChatReq{SessionId: sessionId, Content: content} + if session != nil && session.SessionID != "" && modelInfo != nil && modelInfo.SupID != 0 { + // 新注册路径:session/model 已在 autoRegisSession 中获取,跳过重复查询 + assistant, err = a.adviceChatBiz.ChatWithSession(ctx, chatReq, session, modelInfo) + } else { + // 已有会话路径:走原 Chat 查询 + assistant, err = a.adviceChatBiz.Chat(ctx, chatReq) + } + if err != nil { + log.Infof("[托管] Chat 失败 sessionId=%s err=%v", sessionId, err) + return + } + reply := strings.TrimSpace(assistant.Result) + if reply == "" { + log.Infof("[托管] Chat 返回空回复 sessionId=%s", sessionId) + return + } + // 拆分回复为多段消息,模拟真人分段发送 + chunks := splitReplyToChunks(reply) + // 模拟阅读客户消息 + 思考的延迟(3~8秒) + readDelay := time.Duration(3000+rand.Intn(5001)) * time.Millisecond + time.Sleep(readDelay) + // 通过微信分段发送 AI 回复(使用项目级 wx_token) + if a.wxSendBiz != nil { + if _, err = a.wxSendBiz.SendMultiWithToken(ctx, wxToken, appId, peerWxid, chunks, mongo_model.WxMsgSourceCallback); err != nil { + log.Infof("[托管] 发送回复失败 appId=%s peerWxid=%s err=%v", appId, peerWxid, err) + } else { + log.Infof("[托管] 发送回复成功 sessionId=%s chunks=%d reply=%s", sessionId, len(chunks), reply) + } + } +} + +// splitReplyToChunks 将 AI 回复拆分为多条短消息,模拟真人分段发送。 +// 拆分策略:先按换行分段,过长段落按句末标点拆分,过短段落合并。 +func splitReplyToChunks(reply string) []string { + const maxChunkLen = 80 // 每条消息最大字数(接近真人微信习惯) + const minChunkLen = 15 // 低于此字数考虑合并到上一条 + + // 1. 按双换行或单换行拆分为段落 + rawParagraphs := strings.Split(reply, "\n\n") + var paragraphs []string + for _, p := range rawParagraphs { + p = strings.TrimSpace(p) + if p == "" { + continue + } + // 段落内如果还有单换行,也拆开 + for _, line := range strings.Split(p, "\n") { + line = strings.TrimSpace(line) + if line != "" { + paragraphs = append(paragraphs, line) + } + } + } + if len(paragraphs) == 0 { + return []string{reply} + } + + // 2. 过长段落按句末标点拆分 + sentenceEnds := []rune{'。', '!', '?', ';', '.', '!', '?', ';'} + var segments []string + for _, p := range paragraphs { + if len([]rune(p)) <= maxChunkLen { + segments = append(segments, p) + continue + } + // 按句末标点拆分 + runes := []rune(p) + start := 0 + for i, r := range runes { + for _, sep := range sentenceEnds { + if r == sep && i-start >= 10 { + seg := string(runes[start : i+1]) + if len([]rune(seg)) <= maxChunkLen { + segments = append(segments, seg) + start = i + 1 + } + break + } + } + } + // 剩余部分 + if start < len(runes) { + remaining := strings.TrimSpace(string(runes[start:])) + if remaining != "" { + segments = append(segments, remaining) + } + } + } + + // 3. 合并过短的段落(模拟真人一口气发完短句) + var chunks []string + buf := "" + for _, s := range segments { + if buf == "" { + buf = s + } else if len([]rune(buf))+len([]rune(s))+1 <= maxChunkLen && len([]rune(s)) < minChunkLen { + // 当前段很短,合并到上一条 + buf = buf + "\n" + s + } else { + chunks = append(chunks, buf) + buf = s + } + } + if buf != "" { + chunks = append(chunks, buf) + } + + if len(chunks) == 0 { + return []string{reply} + } + return chunks +} + // callbackCreateTime 将回调时间戳转为 time.Time(兼容秒/毫秒,缺省取当前时间) func callbackCreateTime(ts int64) time.Time { if ts <= 0 { diff --git a/internal/biz/advice_wx_send.go b/internal/biz/advice_wx_send.go index c91b51f..43b5793 100644 --- a/internal/biz/advice_wx_send.go +++ b/internal/biz/advice_wx_send.go @@ -8,6 +8,7 @@ import ( "context" "errors" "fmt" + "math/rand" "strings" "time" ) @@ -43,11 +44,17 @@ func (a *AdviceWxSendBiz) Available() bool { // SendText 向客户发送一条文本消息(appId 为空时自动按 wxid 取绑定客户的 appId)。 // 发送成功后登记消息流水并更新客户互动时间(lastReplyAt)。 +// 使用全局配置 cfg.Advicer.WxToken 作为协议 Token。 func (a *AdviceWxSendBiz) SendText(ctx context.Context, appId, wxid, content, source string) (err error) { token := a.cfg.Advicer.WxToken if len(token) == 0 { return errors.New("未配置 advicer.wx_token,无法发送微信消息") } + return a.SendTextWithToken(ctx, token, appId, wxid, content, source) +} + +// SendTextWithToken 使用指定的 token 发送文本消息(托管场景使用项目级 wx_token)。 +func (a *AdviceWxSendBiz) SendTextWithToken(ctx context.Context, token, appId, wxid, content, source string) (err error) { content = strings.TrimSpace(content) if len(wxid) == 0 { return errors.New("wxid 不能为空") @@ -106,3 +113,45 @@ func (a *AdviceWxSendBiz) SendMulti(ctx context.Context, appId, wxid string, con } return sent, nil } + +// SendMultiWithToken 使用项目级 token 按顺序发送多条短消息,每条之间按打字速度计算延迟。 +func (a *AdviceWxSendBiz) SendMultiWithToken(ctx context.Context, token, appId, wxid string, contents []string, source string) (sent []string, err error) { + for i, content := range contents { + content = strings.TrimSpace(content) + if len(content) == 0 { + continue + } + if e := a.SendTextWithToken(ctx, token, appId, wxid, content, source); e != nil { + return sent, e + } + sent = append(sent, content) + // 最后一条之后不需要等待 + if i < len(contents)-1 { + delay := typingDelay(len(content)) + time.Sleep(delay) + } + } + return sent, nil +} + +// typingDelay 根据消息长度计算模拟真人打字延迟。 +// 模型:阅读上一条消息 + 思考 + 打字,每字约 300~500ms,加思考时间和随机抖动。 +func typingDelay(charCount int) time.Duration { + if charCount <= 0 { + return 3 * time.Second + } + // 思考时间:2~5秒(每条消息前都要想一想) + thinkMs := 2000 + rand.Intn(3001) + // 打字时间:每字 300~500ms(模拟手机端打字速度) + typeMsPerChar := 300 + rand.Intn(201) + typeMs := charCount * typeMsPerChar + totalMs := thinkMs + typeMs + // 下限 3秒,上限 20秒 + if totalMs < 3000 { + totalMs = 3000 + } + if totalMs > 20000 { + totalMs = 20000 + } + return time.Duration(totalMs) * time.Millisecond +} diff --git a/internal/data/constants/prompt.go b/internal/data/constants/prompt.go index 1bbf165..1e7b3a6 100644 --- a/internal/data/constants/prompt.go +++ b/internal/data/constants/prompt.go @@ -5,7 +5,7 @@ const BasePrompt = `**[场景]**现在你是专业的顾问,我们在聊天软 **[要求]**提供的json内容包含顾问,项目,对话技巧,客户信息,请根据json内容进行模仿真人进行对话,** 绝对不能让对方看出来你是ai!** **[规则]** 1.必须模仿顾问的聊天方式,不能有markdown格式,聊天内容必须贴近聊天软件的正常聊天,非客户要求不要自顾自的介绍项目!开场白不要太长! -2.像真人发微信一样说话:一条消息只表达一个意思,每条尽量简短(一般不超过40个字);要说的内容较多时,拆成2~3条短消息,在result里用\n分隔,不要把一大段话塞进同一条消息! +2.像真人发微信一样说话:一条消息只表达一个意思,每条尽量控制在10~30个字;要说的内容较多时,必须拆成2~4条短消息,在result里用\n分隔。禁止把超过40个字的内容塞进同一条消息!记住:真人微信聊天不会发长段落,每条消息就像一口气打出来的那么短。 3.结合[任务内容]信息,若任务状态为"in_progress",则在对话中尽量完成任务;如果任务状态为"completed"或"fail",则主动结束对话,且该轮对话中不再更新任务状态,mission_complete_desc记录最终结果且不再更改。不要每一句对话都包含任务内容,不要目的性太强! 4.不能有任何画外音的文字格式出现!比如(等待对方回复) **[任务内容]**` @@ -27,7 +27,7 @@ const BasePrompt2 = "**[输出格式]**\n" + "1. **result** (字符串)\n" + " - 对应变量:`{{chat_content}}`\n" + " - 内容:顾问的实际对话内容\n" + - " - 要求:自然语言回复,面向用户;口语化、简短,多条消息必须用\\n转义符分隔(每条一般不超过40个字)\n\n" + + " - 要求:自然语言回复,面向用户;口语化、简短,多条消息必须用\\n转义符分隔(每条控制在10~30字,禁止超过40字)\n\n" + "2. **mission** (字符串)\n" + " - 对应变量:`{{mission}}`\n" + " - 取值:任务内容\n" + diff --git a/internal/data/impl/advice_advicer_impl.go b/internal/data/impl/advice_advicer_impl.go index 500fc00..c2698f4 100644 --- a/internal/data/impl/advice_advicer_impl.go +++ b/internal/data/impl/advice_advicer_impl.go @@ -4,6 +4,7 @@ import ( "ai_scheduler/internal/data/model" "ai_scheduler/tmpl/dataTemp" "ai_scheduler/utils" + "context" ) type AdviceAdvicerImpl struct { @@ -15,3 +16,12 @@ func NewAdviceAdvicerImpl(db *utils.Db) *AdviceAdvicerImpl { DataTemp: *dataTemp.NewDataTemp(db, new(model.AiAdviceAdvicer)), } } + +// FindByWxDeviceId 通过微信设备 ID 查询销售记录 +func (a *AdviceAdvicerImpl) FindByWxDeviceId(ctx context.Context, wxDeviceId string) (model.AiAdviceAdvicer, error) { + var record model.AiAdviceAdvicer + err := a.Db.WithContext(ctx). + Where("wx_device_id = ?", wxDeviceId). + First(&record).Error + return record, err +} diff --git a/internal/data/impl/advice_customer_impl.go b/internal/data/impl/advice_customer_impl.go index a19e3e6..c85171d 100644 --- a/internal/data/impl/advice_customer_impl.go +++ b/internal/data/impl/advice_customer_impl.go @@ -53,3 +53,28 @@ func (a *AdviceCustomerImpl) BatchUpdateByWxid(ctx context.Context, records []mo } return nil } + +// UpdateLabelList 仅更新好友的标签列表 +func (a *AdviceCustomerImpl) UpdateLabelList(ctx context.Context, selfWxid, userName, labelList string) error { + return a.Db.WithContext(ctx). + Model(&model.AiAdviceCustomer{}). + Where("self_wxid = ? AND user_name = ?", selfWxid, userName). + Update("label_list", labelList).Error +} + +// FindBySelfWxidAndUserName 查询单个客户记录 +func (a *AdviceCustomerImpl) FindBySelfWxidAndUserName(ctx context.Context, selfWxid, userName string) (model.AiAdviceCustomer, error) { + var record model.AiAdviceCustomer + err := a.Db.WithContext(ctx). + Where("self_wxid = ? AND user_name = ?", selfWxid, userName). + First(&record).Error + return record, err +} + +// UpdateSessionId 更新客户的会话 ID +func (a *AdviceCustomerImpl) UpdateSessionId(ctx context.Context, customerId int32, sessionId string) error { + return a.Db.WithContext(ctx). + Model(&model.AiAdviceCustomer{}). + Where("customer_id = ?", customerId). + Update("session_id", sessionId).Error +} diff --git a/internal/data/impl/advice_label_impl.go b/internal/data/impl/advice_label_impl.go index d42639e..ab55be8 100644 --- a/internal/data/impl/advice_label_impl.go +++ b/internal/data/impl/advice_label_impl.go @@ -52,3 +52,21 @@ func (a *AdviceLabelImpl) DeleteByLabelIds(ctx context.Context, selfWxid string, Where("self_wxid = ? AND label_id IN ?", selfWxid, labelIds). Delete(&model.AiAdviceLabel{}).Error } + +// UpdateHosting 更新标签托管状态 +func (a *AdviceLabelImpl) UpdateHosting(ctx context.Context, selfWxid string, labelId int, hosting int8) error { + return a.Db.WithContext(ctx). + Model(&model.AiAdviceLabel{}). + Where("self_wxid = ? AND label_id = ?", selfWxid, labelId). + Update("hosting", hosting).Error +} + +// GetHostingLabelIds 查询某销售下所有已托管的标签 ID +func (a *AdviceLabelImpl) GetHostingLabelIds(ctx context.Context, selfWxid string) ([]int, error) { + var ids []int + err := a.Db.WithContext(ctx). + Model(&model.AiAdviceLabel{}). + Where("self_wxid = ? AND hosting = 1", selfWxid). + Pluck("label_id", &ids).Error + return ids, err +} diff --git a/internal/data/model/ai_advice_advicer.gen.go b/internal/data/model/ai_advice_advicer.gen.go index 7227f43..7fe7534 100644 --- a/internal/data/model/ai_advice_advicer.gen.go +++ b/internal/data/model/ai_advice_advicer.gen.go @@ -12,15 +12,18 @@ const TableNameAiAdviceAdvicer = "ai_advice_advicer" // AiAdviceAdvicer mapped from table type AiAdviceAdvicer struct { - AdvicerID int32 `gorm:"column:advicer_id;primaryKey;autoIncrement:true" json:"advicer_id"` - ProjectID int32 `gorm:"column:project_id;not null" json:"project_id"` - Name string `gorm:"column:name;not null;comment:姓名" json:"name"` // 姓名 - Birth time.Time `gorm:"column:birth;not null;comment:用户名称" json:"birth"` // 用户名称 - Gender int32 `gorm:"column:gender;not null;comment:1:男,2:女" json:"gender"` // 1:男,2:女 - WorkingYears int32 `gorm:"column:working_years;not null;default:1;comment:工作年限" json:"working_years"` // 工作年限 - JoinDate time.Time `gorm:"column:join_date;comment:入职时间" json:"join_date"` // 入职时间 - WxDeviceId string `gorm:"column:wx_device_id;comment:微信设备ID" json:"wx_device_id"` // 微信设备ID - CreateAt time.Time `gorm:"column:create_at;default:CURRENT_TIMESTAMP" json:"create_at"` + AdvicerID int32 `gorm:"column:advicer_id;primaryKey;autoIncrement:true" json:"advicer_id"` + ProjectID int32 `gorm:"column:project_id;not null" json:"project_id"` + Name string `gorm:"column:name;not null;comment:姓名" json:"name"` // 姓名 + Birth time.Time `gorm:"column:birth;not null;comment:用户名称" json:"birth"` // 用户名称 + Gender int32 `gorm:"column:gender;not null;comment:1:男,2:女" json:"gender"` // 1:男,2:女 + WorkingYears int32 `gorm:"column:working_years;not null;default:1;comment:工作年限" json:"working_years"` // 工作年限 + JoinDate time.Time `gorm:"column:join_date;comment:入职时间" json:"join_date"` // 入职时间 + WxDeviceId string `gorm:"column:wx_device_id;comment:微信设备ID" json:"wx_device_id"` // 微信设备ID + HostingEnabled int32 `gorm:"column:hosting_enabled;not null;default:0;comment:托管开关 0关 1开" json:"hosting_enabled"` + HostingVersionId string `gorm:"column:hosting_version_id;not null;default:'';comment:指定版本ID,空=最新" json:"hosting_version_id"` + HostingSkillId string `gorm:"column:hosting_skill_id;not null;default:'';comment:指定技巧ID,空=最新" json:"hosting_skill_id"` + CreateAt time.Time `gorm:"column:create_at;default:CURRENT_TIMESTAMP" json:"create_at"` } // TableName AiAdviceAdvicer's table name diff --git a/internal/data/model/ai_advice_customer.gen.go b/internal/data/model/ai_advice_customer.gen.go index 01a9a8d..6fa87d4 100644 --- a/internal/data/model/ai_advice_customer.gen.go +++ b/internal/data/model/ai_advice_customer.gen.go @@ -25,6 +25,7 @@ type AiAdviceCustomer struct { Description string `gorm:"column:description;not null;comment:描述" json:"description"` CardImgUrl string `gorm:"column:card_img_url;not null;comment:名片图 URL" json:"card_img_url"` LabelList string `gorm:"column:label_list;not null;comment:标签列表" json:"label_list"` + SessionId string `gorm:"column:session_id;not null;default:'';comment:当前活跃会话ID" json:"session_id"` Province string `gorm:"column:province;not null;comment:省份" json:"province"` City string `gorm:"column:city;not null;comment:城市" json:"city"` PhoneNumList string `gorm:"column:phone_num_list;not null;comment:手机号列表" json:"phone_num_list"` diff --git a/internal/data/model/ai_advice_label.gen.go b/internal/data/model/ai_advice_label.gen.go index 187af06..7d3a1ab 100644 --- a/internal/data/model/ai_advice_label.gen.go +++ b/internal/data/model/ai_advice_label.gen.go @@ -6,12 +6,13 @@ const TableNameAiAdviceLabel = "ai_advice_label" // AiAdviceLabel mapped from table type AiAdviceLabel struct { - ID int32 `gorm:"column:id;primaryKey;autoIncrement:true" json:"id"` - SelfWxid string `gorm:"column:self_wxid;not null;comment:登录微信的 wxid" json:"self_wxid"` - LabelId int `gorm:"column:label_id;not null;comment:微信标签 ID" json:"label_id"` - LabelName string `gorm:"column:label_name;not null;comment:标签名称" json:"label_name"` - CreateAt time.Time `gorm:"column:create_at;default:CURRENT_TIMESTAMP" json:"create_at"` - UpdateAt time.Time `gorm:"column:update_at;default:CURRENT_TIMESTAMP" json:"update_at"` + ID int32 `gorm:"column:id;primaryKey;autoIncrement:true" json:"id"` + SelfWxid string `gorm:"column:self_wxid;not null;comment:登录微信的 wxid" json:"self_wxid"` + LabelId int `gorm:"column:label_id;not null;comment:微信标签 ID" json:"label_id"` + LabelName string `gorm:"column:label_name;not null;comment:标签名称" json:"label_name"` + Hosting int8 `gorm:"column:hosting;not null;default:0;comment:是否托管中 0否1是" json:"hosting"` + CreateAt time.Time `gorm:"column:create_at;default:CURRENT_TIMESTAMP" json:"create_at"` + UpdateAt time.Time `gorm:"column:update_at;default:CURRENT_TIMESTAMP" json:"update_at"` } // TableName AiAdviceLabel's table name diff --git a/internal/data/model/ai_advice_session.gen.go b/internal/data/model/ai_advice_session.gen.go index 4ee8688..2efc2e5 100644 --- a/internal/data/model/ai_advice_session.gen.go +++ b/internal/data/model/ai_advice_session.gen.go @@ -18,6 +18,7 @@ type AiAdviceSession struct { SupID int32 `gorm:"column:sup_id;not null" json:"sup_id"` AdvicerVersionID string `gorm:"column:advicer_version_id;not null;comment:顾问版本" json:"advicer_version_id"` // 顾问版本 ClientID string `gorm:"column:client_id;not null;comment:客户信息id" json:"client_id"` // 客户信息id + AdvicerID int32 `gorm:"column:advicer_id;not null;default:0;comment:销售人员ID" json:"advicer_id"` // 销售人员ID TalkSkillID string `gorm:"column:talk_skill_id;not null;comment:聊天话术id" json:"talk_skill_id"` // 聊天话术id ContextCache string `gorm:"column:context_cache;not null;comment:上下文缓存信息(火山引擎)" json:"context_cache"` // 上下文缓存信息(火山引擎) Mission string `gorm:"column:mission;not null;comment:任务" json:"mission"` // 任务 diff --git a/internal/entitys/advicer_data.go b/internal/entitys/advicer_data.go index 0b6f8fb..cc27038 100644 --- a/internal/entitys/advicer_data.go +++ b/internal/entitys/advicer_data.go @@ -83,9 +83,9 @@ type AdvicerVersionStyleGenerateReq struct { } type AdvicerTalkSkillAddReq struct { - ProjectId int32 `json:"projectId" bson:"projectId"` - Desc string `json:"desc" bson:"desc"` - Data map[string]interface{} `json:"data"` // 可视化编辑器数据(扁平 KV) + ProjectId int32 `json:"projectId" bson:"projectId"` + Desc string `json:"desc" bson:"desc"` + Data map[string]interface{} `json:"data"` // 可视化编辑器数据(扁平 KV) NeedsMining mongo_model.NeedsMining `json:"needsMining" bson:"needsMining"` PainPointResponse mongo_model.PainPointResponse `json:"painPointResponse" bson:"painPointResponse"` ValueBuilding mongo_model.ValueBuilding `json:"valueBuilding" bson:"valueBuilding"` @@ -94,10 +94,10 @@ type AdvicerTalkSkillAddReq struct { } type AdvicerTalkSkillUpdateReq struct { - Id string `json:"id"` - ProjectId int32 `json:"projectId" bson:"projectId"` - Desc string `json:"desc" bson:"desc"` - Data map[string]interface{} `json:"data"` // 可视化编辑器数据(扁平 KV) + Id string `json:"id"` + ProjectId int32 `json:"projectId" bson:"projectId"` + Desc string `json:"desc" bson:"desc"` + Data map[string]interface{} `json:"data"` // 可视化编辑器数据(扁平 KV) NeedsMining mongo_model.NeedsMining `json:"needsMining" bson:"needsMining"` PainPointResponse mongo_model.PainPointResponse `json:"painPointResponse" bson:"painPointResponse"` ValueBuilding mongo_model.ValueBuilding `json:"valueBuilding" bson:"valueBuilding"` @@ -254,6 +254,7 @@ type AdvicerChatRegistReq struct { ClientId string `json:"clientId"` TalkSkillId string `json:"talkSkillId"` Mission string `json:"mission"` + AdvicerId int32 `json:"-"` // 内部填充,不由前端传入 } type AdvicerChatRegistRes struct { @@ -524,6 +525,15 @@ type AdvicerCustomerBatchAddReq struct { List []AdvicerCustomerAddReq `json:"list"` } +// AdvicerCustomerUpdateLabelsReq 更新好友标签列表(wx API + DB 一次完成) +type AdvicerCustomerUpdateLabelsReq struct { + ProjectId int32 `json:"projectId" validate:"required" zh:"项目ID"` + AppId string `json:"appId" zh:"设备ID"` + SelfWxid string `json:"selfWxid" validate:"required" zh:"销售wxid"` + UserName string `json:"userName" validate:"required" zh:"好友userName"` + LabelList string `json:"labelList"` +} + // ==================== 标签同步 ==================== // AdvicerLabelSyncReq 标签同步请求(前端从微信获取标签后提交) @@ -542,3 +552,10 @@ type AdvicerLabelItemReq struct { type AdvicerLabelListReq struct { SelfWxid string `json:"selfWxid"` } + +// AdvicerLabelHostingReq 切换标签托管状态 +type AdvicerLabelHostingReq struct { + SelfWxid string `json:"selfWxid" validate:"required" zh:"销售wxid"` + LabelId int `json:"labelId" validate:"required" zh:"标签ID"` + Hosting int8 `json:"hosting"` // 0=取消托管 1=开启托管 +} diff --git a/internal/pkg/wx/qs_contacts.go b/internal/pkg/wx/qs_contacts.go index 65cd157..6f34ccb 100644 --- a/internal/pkg/wx/qs_contacts.go +++ b/internal/pkg/wx/qs_contacts.go @@ -33,26 +33,26 @@ type GetBriefInfoReq struct { // GetBriefInfoResData data为数组,本结构体为数组元素类型 type GetBriefInfoResData struct { - UserName string `json:"userName"` - NickName string `json:"nickName"` - PyInitial string `json:"pyInitial"` - QuanPin string `json:"quanPin"` - Sex int `json:"sex"` - Remark string `json:"remark"` - RemarkPyInitial string `json:"remarkPyInitial"` - RemarkQuanPin string `json:"remarkQuanPin"` - Signature string `json:"signature"` - Alias string `json:"alias"` - SnsBgImg string `json:"snsBgImg"` - Country string `json:"country"` - BigHeadImgUrl string `json:"bigHeadImgUrl"` - SmallHeadImgUrl string `json:"smallHeadImgUrl"` - Description string `json:"description"` - CardImgUrl string `json:"cardImgUrl"` - LabelList string `json:"labelList"` - Province string `json:"province"` - City string `json:"city"` - PhoneNumList string `json:"phoneNumList"` + UserName string `json:"userName"` + NickName string `json:"nickName"` + PyInitial string `json:"pyInitial"` + QuanPin string `json:"quanPin"` + Sex int `json:"sex"` + Remark string `json:"remark"` + RemarkPyInitial string `json:"remarkPyInitial"` + RemarkQuanPin string `json:"remarkQuanPin"` + Signature string `json:"signature"` + Alias string `json:"alias"` + SnsBgImg string `json:"snsBgImg"` + Country string `json:"country"` + BigHeadImgUrl string `json:"bigHeadImgUrl"` + SmallHeadImgUrl string `json:"smallHeadImgUrl"` + Description string `json:"description"` + CardImgUrl string `json:"cardImgUrl"` + LabelList string `json:"labelList"` + Province string `json:"province"` + City string `json:"city"` + PhoneNumList interface{} `json:"phoneNumList"` } type GetDetailInfoReq struct { @@ -102,8 +102,8 @@ type GetDetailInfoResData struct { Province string `json:"province"` // 城市 City string `json:"city"` - // 好友的手机号码 - PhoneNumList string `json:"phoneNumList"` + // 好友的手机号码(上游有时返回数组有时返回字符串) + PhoneNumList interface{} `json:"phoneNumList"` } type ContactsSearchReq struct { diff --git a/internal/server/router/advicer.go b/internal/server/router/advicer.go index 4c7b397..50b6b40 100644 --- a/internal/server/router/advicer.go +++ b/internal/server/router/advicer.go @@ -121,6 +121,7 @@ func AdvicerRouterRegist( advicer.Post("modelsup/del", modelSup.Del) // 微信 API 代理(包装转发,标准响应格式) + advicer.Post("wx/batch_chatroom_info", wxProxy.BatchChatroomInfo) advicer.All("wx/*", wxProxy.Proxy) // 微信好友(ai_advice_customer) @@ -130,8 +131,10 @@ func AdvicerRouterRegist( advicer.Post("advicer/customer/diff", adviceCustomer.Diff) advicer.Post("advicer/customer/batch_add", adviceCustomer.BatchAdd) advicer.Post("advicer/customer/batch_update", adviceCustomer.BatchUpdate) + advicer.Post("advicer/customer/update_labels", adviceCustomer.UpdateLabels) // 微信标签(ai_advice_label) advicer.Post("advicer/label/list", adviceLabel.List) advicer.Post("advicer/label/sync", adviceLabel.Sync) + advicer.Post("advicer/label/hosting", adviceLabel.ToggleHosting) } diff --git a/internal/services/advice/chat.go b/internal/services/advice/chat.go index cdbc890..54d9863 100644 --- a/internal/services/advice/chat.go +++ b/internal/services/advice/chat.go @@ -115,6 +115,7 @@ func (a *ChatService) Regis(c *fiber.Ctx) error { AdvicerVersion: versionInfo.Data, RuleDimension: projectInfo.Base.RuleDimension, } + req.AdvicerId = advicerInfo.AdvicerID sessionId, err := a.adviceChatBiz.Regis(c.UserContext(), &chat, req, projectInfo) log.Info(sessionId) diff --git a/internal/services/advice/customer_new.go b/internal/services/advice/customer_new.go index 2c1a1f8..a275e47 100644 --- a/internal/services/advice/customer_new.go +++ b/internal/services/advice/customer_new.go @@ -4,6 +4,9 @@ import ( "ai_scheduler/internal/biz" "ai_scheduler/internal/entitys" "ai_scheduler/internal/pkg" + "ai_scheduler/internal/pkg/wx" + "fmt" + "strings" "github.com/gofiber/fiber/v2" ) @@ -12,10 +15,11 @@ import ( type CustomerService struct { customerBiz *biz.AdviceCustomerBiz labelBiz *biz.AdviceLabelBiz + projectBiz *biz.AdviceProjectBiz } -func NewCustomerService(customerBiz *biz.AdviceCustomerBiz, labelBiz *biz.AdviceLabelBiz) *CustomerService { - return &CustomerService{customerBiz: customerBiz, labelBiz: labelBiz} +func NewCustomerService(customerBiz *biz.AdviceCustomerBiz, labelBiz *biz.AdviceLabelBiz, projectBiz *biz.AdviceProjectBiz) *CustomerService { + return &CustomerService{customerBiz: customerBiz, labelBiz: labelBiz, projectBiz: projectBiz} } // Add 新增单条好友 @@ -87,3 +91,46 @@ func (s *CustomerService) BatchUpdate(c *fiber.Ctx) error { err := s.customerBiz.BatchUpdate(c.UserContext(), req) return pkg.HandleResponse(c, nil, err) } + +// UpdateLabels 更新好友标签列表(wx API + DB 一次完成) +func (s *CustomerService) UpdateLabels(c *fiber.Ctx) error { + req := &entitys.AdvicerCustomerUpdateLabelsReq{} + if err := c.BodyParser(req); err != nil { + return err + } + + // 1. 查询项目的 wx_token + project, err := s.projectBiz.BaseInfo(req.ProjectId) + if err != nil { + return pkg.HandleResponse(c, nil, err) + } + if project.WxToken == "" { + return pkg.HandleResponse(c, nil, fiber.NewError(fiber.StatusBadRequest, "项目未配置微信接入 Token")) + } + + // 2. 调用微信 API 修改好友标签(全量替换) + var wxIds []string + if req.UserName != "" { + wxIds = strings.Split(req.UserName, ",") + } + wxReq := wx.ModifyMemberListReq{ + AppId: req.AppId, + LabelIds: req.LabelList, + WxIds: wxIds, + } + var wxRes wx.ModifyMemberListResData + if err := wx.Request(c.UserContext(), project.WxToken, wx.ModifyMemberList, wxReq, &wxRes); err != nil { + return pkg.HandleResponse(c, nil, fmt.Errorf("微信标签修改失败: %w", err)) + } + + // 3. 更新 DB + if err := s.customerBiz.UpdateLabels(c.UserContext(), &entitys.AdvicerCustomerUpdateLabelsReq{ + SelfWxid: req.SelfWxid, + UserName: req.UserName, + LabelList: req.LabelList, + }); err != nil { + return pkg.HandleResponse(c, nil, fmt.Errorf("数据库更新失败: %w", err)) + } + + return pkg.HandleResponse(c, nil, nil) +} diff --git a/internal/services/advice/label.go b/internal/services/advice/label.go index 323a344..ac26bc3 100644 --- a/internal/services/advice/label.go +++ b/internal/services/advice/label.go @@ -36,3 +36,13 @@ func (s *LabelService) Sync(c *fiber.Ctx) error { err := s.labelBiz.Sync(c.UserContext(), req) return pkg.HandleResponse(c, nil, err) } + +// ToggleHosting 切换标签托管状态 +func (s *LabelService) ToggleHosting(c *fiber.Ctx) error { + req := &entitys.AdvicerLabelHostingReq{} + if err := c.BodyParser(req); err != nil { + return err + } + err := s.labelBiz.ToggleHosting(c.UserContext(), req) + return pkg.HandleResponse(c, nil, err) +} diff --git a/internal/services/advice/wx_proxy.go b/internal/services/advice/wx_proxy.go index d3c5ba7..5c06258 100644 --- a/internal/services/advice/wx_proxy.go +++ b/internal/services/advice/wx_proxy.go @@ -7,6 +7,7 @@ import ( "context" "encoding/json" "fmt" + "log" "github.com/gofiber/fiber/v2" ) @@ -205,6 +206,13 @@ func (s *WxProxyService) Proxy(c *fiber.Ctx) error { return pkg.HandleResponse(c, nil, fiber.NewError(fiber.StatusBadGateway, "微信接口调用失败: "+err.Error())) } + // TODO: 临时调试日志,排查完成后删除 + if sub == "contacts/getDetailInfo" { + if raw, jerr := json.Marshal(data); jerr == nil { + log.Printf("[DEBUG] contacts/getDetailInfo raw response: %s", string(raw)) + } + } + // 标记跳过 registerCommon 的二次包装,避免响应被双层嵌套 // Proxy 已自行返回标准 {code, data, message} 格式,无需中间件再包一层 c.Locals("skip_response_wrap", true) @@ -214,3 +222,52 @@ func (s *WxProxyService) Proxy(c *fiber.Ctx) error { "message": "ok", }) } + +// BatchChatroomInfoReq 批量获取群信息请求 +type BatchChatroomInfoReq struct { + ProjectId int32 `json:"projectId"` + AppId string `json:"appId"` + ChatroomIds []string `json:"chatroomIds"` +} + +// BatchChatroomInfo 批量获取多个群的信息(内部循环调用 wx API,一次返回) +func (s *WxProxyService) BatchChatroomInfo(c *fiber.Ctx) error { + var req BatchChatroomInfoReq + if err := c.BodyParser(&req); err != nil { + return pkg.HandleResponse(c, nil, err) + } + if len(req.ChatroomIds) == 0 { + return pkg.HandleResponse(c, nil, nil) + } + + // 查询项目的 wx_token + project, err := s.projectBiz.BaseInfo(req.ProjectId) + if err != nil { + return pkg.HandleResponse(c, nil, err) + } + if project.WxToken == "" { + return pkg.HandleResponse(c, nil, fiber.NewError(fiber.StatusBadRequest, "项目未配置微信接入 Token")) + } + + // 循环调用 wx API,收集所有结果 + results := make([]wx.GetChatroomInfoResData, 0, len(req.ChatroomIds)) + for _, chatroomId := range req.ChatroomIds { + wxReq := wx.GetChatroomInfoReq{ + AppId: req.AppId, + ChatroomId: chatroomId, + } + var wxRes wx.GetChatroomInfoResData + if err := wx.Request(c.UserContext(), project.WxToken, wx.GetChatroomInfo, wxReq, &wxRes); err != nil { + log.Printf("[WARN] batch_chatroom_info: getChatroomInfo(%s) failed: %v", chatroomId, err) + continue // 单个失败不影响其他 + } + results = append(results, wxRes) + } + + c.Locals("skip_response_wrap", true) + return c.JSON(fiber.Map{ + "code": 0, + "data": results, + "message": "ok", + }) +} diff --git a/server.exe b/server.exe index 5369bf5..50712ae 100755 Binary files a/server.exe and b/server.exe differ diff --git a/sql/06_ai_advice_label_hosting.sql b/sql/06_ai_advice_label_hosting.sql new file mode 100644 index 0000000..e294b9e --- /dev/null +++ b/sql/06_ai_advice_label_hosting.sql @@ -0,0 +1,3 @@ +-- 标签托管标记:ai_advice_label 新增 hosting 字段 +ALTER TABLE `ai_advice_label` + ADD COLUMN `hosting` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否托管中(0=否 1=是)' AFTER `label_name`; diff --git a/sql/07_customer_session.sql b/sql/07_customer_session.sql new file mode 100644 index 0000000..a1c3628 --- /dev/null +++ b/sql/07_customer_session.sql @@ -0,0 +1,7 @@ +-- 客户表新增会话字段(托管自动注册 AI 会话) +ALTER TABLE ai_advice_customer + ADD COLUMN session_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '当前活跃会话ID' AFTER label_list; + +-- 会话表新增销售ID(用于动态取最新版本) +ALTER TABLE ai_advice_session + ADD COLUMN advicer_id INT NOT NULL DEFAULT 0 COMMENT '销售人员ID(托管自动注册时用于动态取最新版本)' AFTER client_id; diff --git a/web/assets/css/theme.css b/web/assets/css/theme.css index 827b5a9..24063a2 100644 --- a/web/assets/css/theme.css +++ b/web/assets/css/theme.css @@ -653,6 +653,7 @@ table.table td.ops .btn:last-child { margin-right: 0; } padding: 9px 13px; cursor: pointer; border-bottom: 1px solid rgba(105, 135, 255, 0.05); transition: background 0.13s ease; + position: relative; } .contact-item:hover { background: var(--bg-hover); } .contact-item.active { background: var(--grad-soft); border-right: 2px solid var(--primary); } @@ -732,6 +733,82 @@ table.table td.ops .btn:last-child { margin-right: 0; } } .label-row:hover { background: var(--bg-hover); } .label-arrow { font-size: 11px; color: var(--text-2); width: 14px; text-align: center; flex-shrink: 0; } +.label-row .spacer { flex: 1; } +.label-hosting-btn { + font-size: 11px; padding: 2px 10px; border-radius: 10px; + border: 1px solid var(--border-strong); background: transparent; + color: var(--text-2); cursor: pointer; transition: all 0.16s ease; + white-space: nowrap; flex-shrink: 0; +} +.label-hosting-btn:hover { background: var(--bg-hover); color: var(--text-0); } +.label-hosting-btn.hosting { + background: linear-gradient(135deg, rgba(45, 224, 255, 0.18), rgba(79, 124, 255, 0.18)); + border-color: rgba(45, 224, 255, 0.5); color: var(--cyan); +} +.label-hosting-btn:disabled { opacity: 0.5; cursor: not-allowed; } + +/* 已托管标签小标签(青绿色调区分) */ +.ci-label-hosting { + background: linear-gradient(135deg, rgba(45, 224, 255, 0.15), rgba(47, 208, 140, 0.15)); + border-color: rgba(45, 224, 255, 0.4); + color: var(--cyan); +} + +/* 编辑标签按钮(好友/会话列表项右下角) */ +.ci-edit-label-btn { + position: absolute; right: 6px; bottom: 4px; + font-size: 10px; padding: 1px 7px; border-radius: 8px; + border: 1px solid var(--border); background: rgba(14, 21, 40, 0.7); + color: var(--text-2); cursor: pointer; transition: all 0.15s ease; + opacity: 0; z-index: 2; +} +.contact-item:hover .ci-edit-label-btn { opacity: 1; } +.ci-edit-label-btn:hover { + background: var(--bg-hover); color: var(--text-0); + border-color: var(--border-strong); +} + +/* 标签编辑弹窗 */ +.wx-label-editor-popup { + width: 260px; max-height: 380px; + background: rgba(14, 22, 44, 0.97); + border: 1px solid var(--border-strong); + border-radius: 12px; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(105, 135, 255, 0.12); + backdrop-filter: blur(20px); + animation: fadeIn 0.15s ease; + display: flex; flex-direction: column; + overflow: hidden; +} +.wx-le-head { + padding: 10px 14px; font-size: 13px; font-weight: 700; + border-bottom: 1px solid var(--border); color: var(--text-0); +} +.wx-le-body { + flex: 1; overflow-y: auto; padding: 6px 0; + max-height: 260px; +} +.wx-le-item { + display: flex; align-items: center; gap: 8px; + padding: 7px 14px; cursor: pointer; transition: background 0.12s ease; + font-size: 12.5px; color: var(--text-1); +} +.wx-le-item:hover { background: var(--bg-hover); } +.wx-le-item.wx-le-checked { color: var(--text-0); background: rgba(79, 124, 255, 0.08); } +.wx-le-item input[type="checkbox"] { + accent-color: var(--primary); width: 14px; height: 14px; flex-shrink: 0; +} +.wx-le-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.wx-le-hosting-tag { + font-size: 9px; padding: 1px 5px; border-radius: 6px; + background: rgba(45, 224, 255, 0.15); color: var(--cyan); + border: 1px solid rgba(45, 224, 255, 0.3); + flex-shrink: 0; +} +.wx-le-foot { + display: flex; justify-content: flex-end; gap: 8px; + padding: 8px 14px; border-top: 1px solid var(--border); +} .label-friend { padding-left: 36px; } .label-friend .wx-avatar { width: 32px !important; height: 32px !important; } .label-loading { text-align: center; padding: 10px 16px; } diff --git a/web/assets/js/wx.js b/web/assets/js/wx.js index 88b7423..4b9a4fa 100644 --- a/web/assets/js/wx.js +++ b/web/assets/js/wx.js @@ -37,6 +37,18 @@ function loadBriefCache() { return {}; } function saveBriefCache() {} function clearBriefCache() {} + + /** 将上游可能返回数组或字符串的字段统一转为字符串 */ + function normalizeStr(val) { + if (val == null) return ""; + if (Array.isArray(val)) { + return val.map(function (item) { + // 去掉控制字符(\n \u000b 等)和空白,只保留数字、+、-、空格 + return String(item).replace(/[\x00-\x1F\x7F]/g, "").trim(); + }).filter(Boolean).join(","); + } + return String(val).replace(/[\x00-\x1F\x7F]/g, "").trim(); + } function freshState(container) { // 不使用缓存的 wxApp,每次 mount 都从 API 重新获取设备信息 @@ -145,7 +157,7 @@ if (!state) return; if (state.loginTimer) clearInterval(state.loginTimer); disconnectWs(); - // 清理联系人详情缓存 + closeLabelEditor(); WxN.clearDetailCache(); WxN.hideContactDetail(); state = null; @@ -253,7 +265,7 @@ return Core.api("advicer/label/list", { selfWxid: selfWxid }) .then(function (list) { state.labels = (list || []).map(function (l) { - return { labelId: l.label_id, labelName: l.label_name }; + return { labelId: l.label_id, labelName: l.label_name, hosting: l.hosting || 0 }; }); // 构建 labelId→labelName 映射 state.labelMap = {}; @@ -362,7 +374,7 @@ // 从 diff 响应中直接加载标签(进入工作台时一并获取) if (res.labels) { state.labels = res.labels.map(function (l) { - return { labelId: l.label_id, labelName: l.label_name }; + return { labelId: l.label_id, labelName: l.label_name, hosting: l.hosting || 0 }; }); state.labelMap = {}; state.labels.forEach(function (l) { state.labelMap[String(l.labelId)] = l.labelName; }); @@ -423,7 +435,7 @@ smallHeadImgUrl: info.smallHeadImgUrl || "", description: info.description || "", cardImgUrl: info.cardImgUrl || "", labelList: info.labelList || "", province: info.province || "", city: info.city || "", - phoneNumList: info.phoneNumList || "" + phoneNumList: normalizeStr(info.phoneNumList) }; }); Core.api("advicer/customer/batch_add", { list: records }) @@ -481,7 +493,7 @@ smallHeadImgUrl: info.smallHeadImgUrl || "", description: info.description || "", cardImgUrl: info.cardImgUrl || "", labelList: info.labelList || "", province: info.province || "", city: info.city || "", - phoneNumList: info.phoneNumList || "" + phoneNumList: normalizeStr(info.phoneNumList) }); } }); @@ -510,7 +522,7 @@ return { name: name, avatar: info.bigHeadImgUrl || info.smallHeadImgUrl || "" }; } - /** 获取好友的标签 HTML(小标签展示) */ + /** 获取好友的标签 HTML(小标签展示,全部标签) */ function contactLabelsHtml(wxid) { var info = state.brief[wxid]; if (!info || !info.labelList) return ""; @@ -527,6 +539,45 @@ }).join("") + ''; } + /** 获取好友的已托管标签 HTML(仅显示 hosting=1 的标签) */ + function hostingLabelsHtml(wxid) { + var info = state.brief[wxid]; + if (!info || !info.labelList) return ""; + var ids = String(info.labelList).split(",").filter(Boolean); + if (!ids.length) return ""; + var tags = []; + ids.forEach(function (id) { + // 查找该标签是否为托管标签 + for (var i = 0; i < state.labels.length; i++) { + if (String(state.labels[i].labelId) === id && state.labels[i].hosting) { + tags.push(state.labels[i].labelName); + break; + } + } + }); + if (!tags.length) return ""; + return '
' + tags.map(function (t) { + return '' + Core.esc(t) + ''; + }).join("") + '
'; + } + + /** 获取好友所属的托管标签 ID 列表 */ + function getHostingLabelIds(wxid) { + var info = state.brief[wxid]; + if (!info || !info.labelList) return []; + var ids = String(info.labelList).split(",").filter(Boolean); + var hostingIds = []; + ids.forEach(function (id) { + for (var i = 0; i < state.labels.length; i++) { + if (String(state.labels[i].labelId) === id && state.labels[i].hosting) { + hostingIds.push(id); + break; + } + } + }); + return hostingIds; + } + function renderContacts() { if (WxN.hideContactDetail) WxN.hideContactDetail(); var box = $("#wxContacts", state.container); @@ -570,9 +621,11 @@ unreadBadge + '' + '
' + Core.esc(d.name) + '' + (c.isGroup ? '群' : '') + + hostingLabelsHtml(c.wxid) + '' + Core.esc(timeStr) + "
" + - contactLabelsHtml(c.wxid) + - '
' + Core.esc(dirIcon + " " + (c.lastMsg || "")) + "
"; + '
' + Core.esc(dirIcon + " " + (c.lastMsg || "")) + '
' + + (!c.isGroup ? '' : '') + + ''; }).join(""); } } else if (state.tab === "labels") { @@ -595,10 +648,14 @@ var countSpan = (count != null) ? ' (' + count + ')' : ' …'; + var hostingCls = l.hosting ? ' hosting' : ''; + var hostingBtn = ''; var html = '
' + '' + arrow + '' + '' + Core.esc(l.labelName) + '' + - countSpan + '
'; + countSpan + + '' + + hostingBtn + ''; if (expanded) { var isLoading = !!state.loadingLabels[lid]; if (isLoading) { @@ -643,9 +700,11 @@ (d.avatar ? '' : '
' + Core.esc(Core.initial(d.name)) + "
") + - '
' + Core.esc(d.name) + "
" + - contactLabelsHtml(w) + - '
' + Core.esc(w) + "
"; + '
' + Core.esc(d.name) + '' + + hostingLabelsHtml(w) + '
' + + '
' + Core.esc(w) + "
" + + '' + + ''; }).join(""); } } @@ -668,7 +727,9 @@ }); // 标签行点击展开/收起 $$(".label-row", box).forEach(function (row) { - row.onclick = function () { + row.onclick = function (e) { + // 点击托管按钮不触发展开/收起 + if (e.target.classList.contains('label-hosting-btn')) return; var lid = row.dataset.labelId; if (state.expandedLabels[lid]) { // 收起 @@ -683,6 +744,30 @@ } }; }); + // 托管按钮点击 + $$(".label-hosting-btn", box).forEach(function (btn) { + btn.onclick = function (e) { + e.stopPropagation(); + var lid = Number(btn.dataset.labelId); + var newHosting = Number(btn.dataset.hosting); + var selfWxid = state.app && state.app.wxid; + if (!selfWxid) return; + btn.disabled = true; + Core.api("advicer/label/hosting", { selfWxid: selfWxid, labelId: lid, hosting: newHosting }) + .then(function () { + // 更新 state.labels 中对应标签的 hosting 状态 + state.labels.forEach(function (l) { + if (l.labelId === lid) l.hosting = newHosting; + }); + Core.toast(newHosting ? "已开启托管" : "已取消托管", "ok"); + renderContacts(); + }) + .catch(function (e) { + Core.toast("操作失败:" + e.message, "err"); + btn.disabled = false; + }); + }; + }); $$(".contact-item", box).forEach(function (c) { c.onclick = function () { var wxid = c.dataset.wxid; @@ -698,6 +783,136 @@ c.onmouseleave = function () { WxN.hideContactDetail(); }; } }); + // 编辑标签按钮点击 + $$(".ci-edit-label-btn", box).forEach(function (btn) { + btn.onclick = function (e) { + e.stopPropagation(); + openLabelEditor(btn.dataset.wxid, btn); + }; + }); + } + + /* ==================== 标签编辑弹窗 ==================== */ + var _labelEditorPopup = null; + + function openLabelEditor(wxid, anchorEl) { + closeLabelEditor(); + var s = state; + var info = s.brief[wxid] || {}; + var currentLabels = (info.labelList || "").split(",").filter(Boolean); + var allLabels = s.labels; + if (!allLabels.length) { Core.toast("暂无标签,请先在标签 tab 同步", "warn"); return; } + + var popup = document.createElement('div'); + popup.className = 'wx-label-editor-popup'; + popup.style.position = 'fixed'; + popup.style.zIndex = '9999'; + + // 视口边界检测 + var popupWidth = 260; + var rect = anchorEl.getBoundingClientRect(); + var leftPos = rect.right + 6; + if (leftPos + popupWidth > window.innerWidth - 10) { + leftPos = rect.left - popupWidth - 6; + } + if (leftPos < 10) leftPos = 10; + var topPos = rect.top; + if (topPos + 350 > window.innerHeight) { + topPos = Math.max(10, window.innerHeight - 360); + } + popup.style.left = leftPos + 'px'; + popup.style.top = topPos + 'px'; + + // 渲染标签列表 + var html = '
编辑标签
'; + html += '
'; + allLabels.forEach(function (l) { + var lid = String(l.labelId); + var checked = currentLabels.indexOf(lid) >= 0 ? ' checked' : ''; + var checkedCls = checked ? ' wx-le-checked' : ''; + html += ''; + }); + html += '
'; + html += '
' + + '' + + '' + + '
'; + popup.innerHTML = html; + document.body.appendChild(popup); + _labelEditorPopup = popup; + + // checkbox 变化时更新样式 + $$(".wx-le-item input", popup).forEach(function (cb) { + cb.onchange = function () { + var label = cb.closest('.wx-le-item'); + if (cb.checked) label.classList.add('wx-le-checked'); + else label.classList.remove('wx-le-checked'); + }; + }); + + // 取消 + $(".wx-le-cancel", popup).onclick = function () { closeLabelEditor(); }; + + // 保存 + $(".wx-le-save", popup).onclick = function () { + var newLabelIds = []; + $$(".wx-le-item input:checked", popup).forEach(function (cb) { + newLabelIds.push(cb.value); + }); + var selfWxid = s.app && s.app.wxid; + if (!selfWxid) return; + + var proj = Core.store(Core.KEYS.project); + if (!proj || !proj.projectId) { Core.toast("请先选择项目", "err"); return; } + + var saveBtn = $(".wx-le-save", popup); + saveBtn.disabled = true; + saveBtn.textContent = "保存中…"; + + // 一次调用后端接口:内部完成 wx API 调用 + DB 更新 + Core.api("advicer/customer/update_labels", { + projectId: proj.projectId, + appId: s.app.appId, + selfWxid: selfWxid, + userName: wxid, + labelList: newLabelIds.join(",") + }).then(function () { + // 更新本地 brief 中的 labelList + if (s.brief[wxid]) { + s.brief[wxid].labelList = newLabelIds.join(","); + } + Core.toast("标签已更新", "ok"); + closeLabelEditor(); + renderContacts(); + }).catch(function (e) { + Core.toast("保存失败:" + e.message, "err"); + saveBtn.disabled = false; + saveBtn.textContent = "保存"; + }); + }; + + // 点击外部关闭 + setTimeout(function () { + document.addEventListener('mousedown', _labelEditorOutsideClick, true); + }, 0); + } + + function _labelEditorOutsideClick(e) { + if (_labelEditorPopup && !_labelEditorPopup.contains(e.target) && !e.target.classList.contains('ci-edit-label-btn')) { + closeLabelEditor(); + } + } + + function closeLabelEditor() { + document.removeEventListener('mousedown', _labelEditorOutsideClick, true); + if (_labelEditorPopup && _labelEditorPopup.parentNode) { + _labelEditorPopup.parentNode.removeChild(_labelEditorPopup); + } + _labelEditorPopup = null; } /* ==================== WebSocket 实时通信 ==================== */ @@ -985,6 +1200,7 @@ WxN.connectWs = connectWs; WxN.disconnectWs = disconnectWs; WxN.openAiConfig = openAiConfig; + WxN.closeLabelEditor = closeLabelEditor; global.Wx = { mount: mount, unmount: unmount }; })(window); diff --git a/web/assets/js/wx_chat.js b/web/assets/js/wx_chat.js index 8c2228a..ddfc322 100644 --- a/web/assets/js/wx_chat.js +++ b/web/assets/js/wx_chat.js @@ -82,28 +82,36 @@ function fixMissingChatroomAvatars() { var s = state(); + var missingIds = []; s.conversations.forEach(function (c) { if (!c.isGroup) return; var info = s.brief[c.wxid] || {}; if (info.bigHeadImgUrl || info.smallHeadImgUrl) return; - Core.wxApi("group/getChatroomInfo", { appId: s.app.appId, chatroomId: c.wxid }) - .then(function (data) { + missingIds.push(c.wxid); + }); + if (!missingIds.length) return; + + // 一次批量调用后端,后端内部循环调 wx API 并返回所有群信息 + Core.wxApi("batch_chatroom_info", { appId: s.app.appId, chatroomIds: missingIds }) + .then(function (list) { + (list || []).forEach(function (data) { if (!data || !data.chatroomId) return; - var existing = s.brief[c.wxid] || {}; + var existing = s.brief[data.chatroomId] || {}; existing.smallHeadImgUrl = existing.smallHeadImgUrl || data.smallHeadImgUrl || ""; existing.nickName = existing.nickName || data.nickName || ""; if (data.remark) existing.remark = existing.remark || data.remark; - s.brief[c.wxid] = existing; - saveBriefCache(); - if (s.active && s.active.wxid === c.wxid) { - s.active.avatar = existing.bigHeadImgUrl || existing.smallHeadImgUrl || ""; - s.active.name = existing.remark || existing.nickName || s.active.name; - WxN.renderChatHead(); - } - if (s.tab === "conversations") WxN.renderContacts(); - }) - .catch(function () {}); - }); + s.brief[data.chatroomId] = existing; + }); + saveBriefCache(); + if (s.active && s.brief[s.active.wxid]) { + var ai = s.brief[s.active.wxid]; + s.active.avatar = ai.bigHeadImgUrl || ai.smallHeadImgUrl || s.active.avatar; + s.active.name = ai.remark || ai.nickName || s.active.name; + WxN.renderChatHead(); + } + if (s.tab === "conversations") WxN.renderContacts(); + }) + .catch(function () {}); } function openConversationChat(wxid) { diff --git a/web/project.html b/web/project.html index b6e21de..eeebffa 100644 --- a/web/project.html +++ b/web/project.html @@ -4,7 +4,7 @@ 项目后台 · AI 销售助手 - + @@ -92,9 +92,9 @@ - + - +