diff --git a/internal/biz/advice_advicer.go b/internal/biz/advice_advicer.go index adc5da7..942f9d6 100644 --- a/internal/biz/advice_advicer.go +++ b/internal/biz/advice_advicer.go @@ -131,6 +131,11 @@ func (a *AdviceAdvicerBiz) FindByWxDeviceId(ctx context.Context, wxDeviceId stri return a.advicerImpl.FindByWxDeviceId(ctx, wxDeviceId) } +// FindByWxId 通过登录微信 wxid 查销售记录 +func (a *AdviceAdvicerBiz) FindByWxId(ctx context.Context, wxId string) (model.AiAdviceAdvicer, error) { + return a.advicerImpl.FindByWxId(ctx, wxId) +} + // 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}) diff --git a/internal/biz/advice_chat.go b/internal/biz/advice_chat.go index 148b82a..2d152b8 100644 --- a/internal/biz/advice_chat.go +++ b/internal/biz/advice_chat.go @@ -31,10 +31,18 @@ type AdviceChatBiz struct { rdb *utils.Rdb aiAdviceSessionImpl *impl.AiAdviceSessionImpl aiAdviceModelSupImpl *impl.AiAdviceModelSupImpl - advicerChatHisMongo *mongo_model.AdvicerChatHisMongo - mongo *pkg.Mongo + advicerChatHisMongo *mongo_model.AdvicerChatHisMongo + mongo *pkg.Mongo + // followImpl 用于在对话时查询并注入该客户未闭环的成交线索 + followImpl *impl.AdviceFollowImpl + // followBiz 用于对话回复后把 AI 内联返回的 remind/lead 落库并回写 mission; + // 因 wire 构造顺序(chatBiz 早于 followBiz)用 setter 注入,避免重排依赖链。 + followBiz *AdviceFollowBiz } +// BindFollowBiz 注入跟进持久化业务(由 wire_gen 在构造后调用) +func (a *AdviceChatBiz) BindFollowBiz(f *AdviceFollowBiz) { a.followBiz = f } + func NewAdviceChatBiz( openai *third_party.OpenAi, rdb *utils.Rdb, @@ -42,6 +50,7 @@ func NewAdviceChatBiz( aiAdviceModelSupImpl *impl.AiAdviceModelSupImpl, advicerChatHisMongo *mongo_model.AdvicerChatHisMongo, mongo *pkg.Mongo, + followImpl *impl.AdviceFollowImpl, ) *AdviceChatBiz { return &AdviceChatBiz{ openai: openai, @@ -50,22 +59,20 @@ func NewAdviceChatBiz( aiAdviceModelSupImpl: aiAdviceModelSupImpl, advicerChatHisMongo: advicerChatHisMongo, mongo: mongo, + followImpl: followImpl, } } func (a *AdviceChatBiz) contextCache(ctx context.Context, chatData *entitys.ChatData, req *entitys.AdvicerChatRegistReq, projectInfo *entitys.AdvicerProjectInfoRes) (promptJson string, contextCache string, err error) { switch constants.Mode(projectInfo.ModelInfo.Mode) { case constants.ModeResponse, constants.ModeContext: - //ModeContext依赖火山专有Context缓存API,统一降级为Responses存储+PreviousResponseID续写 + // 恒定无状态:注册阶段不再调用模型创建续写 response。 + // previous_response_id 跨供应商不可靠且聊天链路已不再引用,省去会话开头的多余一次模型调用。 prompt, err := a.buildBasePromptResponse(ctx, chatData, req) if err != nil { return "", "", err } - cache, err := a.openai.CreateResponseMessages(ctx, projectInfo.ModelInfo.Key, projectInfo.ModelInfo.URL, projectInfo.ModelInfo.ChatModel, prompt, "") - if err != nil { - return "", "", err - } - contextCache = cache.ID + contextCache = "" promptJson = pkg.JsonStringIgonErr(prompt) default: return "", "", fmt.Errorf("未知的mode类型:%d", projectInfo.ModelInfo.Mode) @@ -142,12 +149,17 @@ func (a *AdviceChatBiz) Chat(ctx context.Context, chat *entitys.AdvicerChatReq) if cdStr, e := a.rdb.Rdb.Get(ctx, "chatdata:"+session.SessionID).Result(); e == nil { _ = json.Unmarshal([]byte(cdStr), &chatData) } + // chatdata 快照(TTL 1h)过期后用请求携带的客户 wxid 兜底,避免 remind/lead 漏落库与成交线索漏注入 + if chatData.ClientWxid == "" { + chatData.ClientWxid = chat.ClientWxid + } prompt, err := a.buildChatPromptResponse(ctx, chat, &session, chatHis, &chatData) if err != nil { return assistant, err } log.Infof("[提示词] sessionId=%s prompt=%s", session.SessionID, pkg.JsonStringIgonErr(prompt)) - resContent, err := a.callLlmResponse(ctx, prompt, modelInfo.Key, modelInfo.URL, modelInfo.ChatModel, session.ContextCache) + // 恒定无状态:不依赖 previous_response_id,每轮靠 buildChatPromptResponse 全量注入上下文,跨供应商可移植且不会再出现 "Previous response not found" + resContent, err := a.callLlmResponse(ctx, prompt, modelInfo.Key, modelInfo.URL, modelInfo.ChatModel, "") if err != nil { return assistant, err } @@ -180,6 +192,12 @@ func (a *AdviceChatBiz) Chat(ctx context.Context, chat *entitys.AdvicerChatReq) session.MissionCompleteDesc = assistant.MissionCompleteDesc _ = a.aiAdviceSessionImpl.UpdateByCond(&cond, session) } + // 对话内联抽取:把 AI 返回的 remind/lead 去重落库,并回写简洁 mission 到客户画像 + if a.followBiz != nil { + if e := a.followBiz.PersistFromAssistant(chatCtx, session.ProjectID, session.AdvicerID, chatData.ClientWxid, assistant.Mission, &assistant); e != nil { + log.Errorf("[跟进持久化] sessionId=%s 失败: %v", chat.SessionId, e) + } + } }(session) return } @@ -204,12 +222,17 @@ func (a *AdviceChatBiz) ChatWithSession(ctx context.Context, chat *entitys.Advic if cdStr, e := a.rdb.Rdb.Get(ctx, "chatdata:"+session.SessionID).Result(); e == nil { _ = json.Unmarshal([]byte(cdStr), &chatData) } + // 同 Chat:chatdata 快照过期时用请求携带的客户 wxid 兜底 + if chatData.ClientWxid == "" { + chatData.ClientWxid = chat.ClientWxid + } prompt, err := a.buildChatPromptResponse(ctx, chat, session, chatHis, &chatData) if err != nil { return assistant, err } log.Infof("[提示词] sessionId=%s prompt=%s", session.SessionID, pkg.JsonStringIgonErr(prompt)) - resContent, err := a.callLlmResponse(ctx, prompt, modelInfo.Key, modelInfo.URL, modelInfo.ChatModel, session.ContextCache) + // 恒定无状态:不依赖 previous_response_id,每轮靠 buildChatPromptResponse 全量注入上下文,跨供应商可移植且不会再出现 "Previous response not found" + resContent, err := a.callLlmResponse(ctx, prompt, modelInfo.Key, modelInfo.URL, modelInfo.ChatModel, "") if err != nil { return assistant, err } @@ -242,6 +265,12 @@ func (a *AdviceChatBiz) ChatWithSession(ctx context.Context, chat *entitys.Advic s.MissionCompleteDesc = assistant.MissionCompleteDesc _ = a.aiAdviceSessionImpl.UpdateByCond(&cond, s) } + // 对话内联抽取:把 AI 返回的 remind/lead 去重落库,并回写简洁 mission 到客户画像 + if a.followBiz != nil { + if e := a.followBiz.PersistFromAssistant(chatCtx, s.ProjectID, s.AdvicerID, chatData.ClientWxid, assistant.Mission, &assistant); e != nil { + log.Errorf("[跟进持久化] sessionId=%s 失败: %v", chat.SessionId, e) + } + } }(*session) return } @@ -304,7 +333,7 @@ func compactJSON(v interface{}) string { } func (a *AdviceChatBiz) buildChatPromptResponse(ctx context.Context, chat *entitys.AdvicerChatReq, session *dbmodel.AiAdviceSession, chatList []mongo_model.AdvicerChatHisMongoEntity, chatData *entitys.ChatData) ([]openai.ResponseInputMessage, error) { - const outputFormatHint = "\n[输出格式]请用 json 格式返回你的回复,结构为 {\"result\":\"回复内容\",\"mission_status\":\"in_progress|completed|fail\",\"mission_complete_desc\":\"本轮进展小结\"}。" + outputFormatHint := fmt.Sprintf("\n[当前时间]%s\n[输出格式]请用 json 格式返回你的回复,结构为 {\"result\":\"回复内容\",\"mission\":\"当前任务一句话简述(10~20字)\",\"mission_status\":\"in_progress|completed|fail\",\"mission_complete_desc\":\"本轮进展小结\"};当且仅当本轮对话中客户与你明确了未来的约定(提醒/回访/到访/成交/付款等)时,才在同一个 json 里追加可选字段 remind(到点提醒类约定)或 lead(成交线索类约定),结构见上方说明;没有明确约定时严禁输出这两个字段,一切以 [当前时间] 为基准推算 due_at。", time.Now().Format("2006-01-02 15:04")) var sys strings.Builder // 基础规则 + 任务 + 风控红线 sys.WriteString(constants.BasePrompt) @@ -336,12 +365,28 @@ func (a *AdviceChatBiz) buildChatPromptResponse(ctx context.Context, chat *entit sys.WriteString("\n[销售人设风格]") sys.WriteString(compactJSON(chatData.AdvicerVersion)) } - if chatData.ClientInfo != nil { + if len(chatData.ClientInfo) > 0 { sys.WriteString("\n[客户信息]") sys.WriteString(compactJSON(chatData.ClientInfo)) } + // 未闭环的成交线索(到访/成交/付款约定),供后续对话自然衔接 + if a.followImpl != nil && chatData.ClientWxid != "" { + if leads, e := a.followImpl.FindActiveLeadsByWxid(ctx, chatData.ClientWxid); e == nil && len(leads) > 0 { + sys.WriteString("\n[成交线索](以下是该客户此前与你达成、尚未闭环的到访/成交/付款约定,请在对话中自然衔接、适时确认或推进;切勿生硬罗列、逐条盘问,也不要编造未约定的细节)\n") + for _, ld := range leads { + line := "- " + dbmodel.LeadTypeLabel(ld.LeadType) + ":" + ld.Title + if ld.Detail != "" { + line += "(" + ld.Detail + ")" + } + if !ld.ExpectAt.IsZero() { + line += ",约定时间 " + ld.ExpectAt.Format("2006-01-02 15:04") + } + sys.WriteString(line + "\n") + } + } + } if chatData.TalkSkill != nil { - sys.WriteString("\n[销售技巧]") + sys.WriteString("\n[销售技巧](以下是你的备用弹药,不是待办提问清单:仅当客户主动聊到相关话题时,才顺势借用其中的说法来回应;严禁照着这些条目逐条向客户提问,也不要主动抛出这些话题)") sys.WriteString(compactJSON(chatData.TalkSkill)) } } @@ -668,9 +713,5 @@ func asFloat64(v interface{}) (float64, bool) { } func (a *AdviceChatBiz) callLlmResponse(ctx context.Context, request []openai.ResponseInputMessage, key string, url string, modelName string, id string) (*openai.CreateResponseResponse, error) { - res, err := a.openai.CreateResponseMessages(ctx, key, url, modelName, request, id) - if err != nil { - return nil, err - } - return res, nil + return a.openai.CreateResponseMessages(ctx, key, url, modelName, request, id) } diff --git a/internal/biz/advice_client.go b/internal/biz/advice_client.go index 56080d2..9d564d5 100644 --- a/internal/biz/advice_client.go +++ b/internal/biz/advice_client.go @@ -41,12 +41,9 @@ func (a *AdviceClientBiz) Add(ctx context.Context, param *entitys.AdvicerClientA ProjectId: param.ProjectId, AdvicerId: param.AdvicerId, Wxid: param.Wxid, + AppId: param.AppId, AiEnabled: aiEnabled, - PersonalInfo: param.PersonalInfo, - PurchasePurpose: param.PurchasePurpose, - CoreDemands: param.CoreDemands, - Concerns: param.Concerns, - DecisionProfile: param.DecisionProfile, + Profile: param.Profile, LastUpdateTime: time.Now(), }) if err != nil { @@ -84,6 +81,7 @@ func (a *AdviceClientBiz) Update(ctx context.Context, param *entitys.AdvicerrCli Wxid: old.Wxid, AppId: old.AppId, ClientLevel: old.ClientLevel, + Mission: old.Mission, LevelReason: old.LevelReason, LevelUpdateAt: old.LevelUpdateAt, LastContactAt: old.LastContactAt, @@ -92,11 +90,7 @@ func (a *AdviceClientBiz) Update(ctx context.Context, param *entitys.AdvicerrCli MsgCount: old.MsgCount, AiEnabled: aiEnabled, LastIterateAt: old.LastIterateAt, - PersonalInfo: param.PersonalInfo, - PurchasePurpose: param.PurchasePurpose, - CoreDemands: param.CoreDemands, - Concerns: param.Concerns, - DecisionProfile: param.DecisionProfile, + Profile: param.Profile, LastUpdateTime: time.Now(), }, } @@ -220,6 +214,52 @@ func (a *AdviceClientBiz) FindByWxid(ctx context.Context, wxid string) (info mon return info, true, nil } +// EnsureByWxid 按客户 wxid 获取画像记录;不存在则用传入的项目/销售/appId 自动建档(仅元数据 + 空画像栏目)。 +// 供「画像更新」按钮与定时提醒/成交线索按 wxid 单选时复用;返回 Item 与是否新建。 +func (a *AdviceClientBiz) EnsureByWxid(ctx context.Context, wxid string, projectId, advicerId int32, appId string) (item *mongo_model.AdvicerClientItem, created bool, err error) { + if len(wxid) == 0 { + return nil, false, errors.New("wxid不能为空") + } + if found, ok, e := a.FindByWxid(ctx, wxid); e != nil { + return nil, false, e + } else if ok { + return &found, false, nil + } + aiEnabled := true + if _, e := a.Add(ctx, &entitys.AdvicerClientAddReq{ + ProjectId: projectId, + AdvicerId: advicerId, + Wxid: wxid, + AppId: appId, + AiEnabled: &aiEnabled, + Profile: map[string]interface{}{}, + }); e != nil { + return nil, false, e + } + found, ok, e := a.FindByWxid(ctx, wxid) + if e != nil { + return nil, false, e + } + if !ok { + return nil, false, errors.New("客户建档失败") + } + return &found, true, nil +} + +// UpdateMission 回写客户当前对话任务(mission)到画像,供客户列表展示;仅更新 mission 字段。 +func (a *AdviceClientBiz) UpdateMission(ctx context.Context, clientIdHex, mission string) error { + if len(clientIdHex) == 0 { + return nil + } + objectID, err := primitive.ObjectIDFromHex(clientIdHex) + if err != nil { + return fmt.Errorf("ID转换失败: %w", err) + } + _, err = a.mongo.Co(a.AdvicerClientMongo).UpdateOne(ctx, bson.M{"_id": objectID}, + bson.M{"$set": bson.M{"mission": mission}}) + return err +} + // TouchInteraction 更新客户互动时间与消息数(回调链针对已绑定客户调用) // direction: customer 客户发来(更新 lastClientMsgAt);self/ai 我方回复(更新 lastReplyAt) func (a *AdviceClientBiz) TouchInteraction(ctx context.Context, wxid string, direction string) (err error) { diff --git a/internal/biz/advice_customer_new.go b/internal/biz/advice_customer_new.go index 8e16f40..a972e8f 100644 --- a/internal/biz/advice_customer_new.go +++ b/internal/biz/advice_customer_new.go @@ -111,6 +111,21 @@ func (a *AdviceCustomerBiz) UpdateLabels(ctx context.Context, req *entitys.Advic return a.impl.UpdateLabelList(ctx, req.SelfWxid, req.UserName, req.LabelList) } +// FindByCustomerId 按主键查客户记录 +func (a *AdviceCustomerBiz) FindByCustomerId(ctx context.Context, customerId int32) (model.AiAdviceCustomer, error) { + return a.impl.FindByCustomerId(ctx, customerId) +} + +// UpdateNote 更新客户备注(description 列) +func (a *AdviceCustomerBiz) UpdateNote(ctx context.Context, req *entitys.AdvicerCustomerUpdateNoteReq) error { + return a.impl.UpdateNote(ctx, req.CustomerId, req.Description) +} + +// UpdateLabelListById 按主键更新 label_list +func (a *AdviceCustomerBiz) UpdateLabelListById(ctx context.Context, customerId int32, labelList string) error { + return a.impl.UpdateLabelListById(ctx, customerId, labelList) +} + func (a *AdviceCustomerBiz) toModel(req *entitys.AdvicerCustomerAddReq) model.AiAdviceCustomer { return model.AiAdviceCustomer{ SelfWxid: req.SelfWxid, @@ -152,6 +167,11 @@ func (a *AdviceCustomerBiz) FindBySelfWxids(ctx context.Context, selfWxids []str return a.impl.FindBySelfWxids(ctx, selfWxids) } +// FindBySelfWxidHostingLabels 在 SQL 层用 FIND_IN_SET 筛选出各 selfWxid 下命中其托管标签的客户记录 +func (a *AdviceCustomerBiz) FindBySelfWxidHostingLabels(ctx context.Context, hostingLabelsMap map[string][]int) ([]model.AiAdviceCustomer, error) { + return a.impl.FindBySelfWxidHostingLabels(ctx, hostingLabelsMap) +} + // UpdateFromWxDetail 根据微信好友详情更新 ai_advice_customer 记录 func (a *AdviceCustomerBiz) UpdateFromWxDetail(ctx context.Context, selfWxid, userName string, updates map[string]interface{}) error { return a.impl.UpdateFromWxDetail(ctx, selfWxid, userName, updates) diff --git a/internal/biz/advice_evaluate.go b/internal/biz/advice_evaluate.go index 845dac8..534623c 100644 --- a/internal/biz/advice_evaluate.go +++ b/internal/biz/advice_evaluate.go @@ -109,7 +109,7 @@ func (a *AdviceEvaluateBiz) EvaluateAll(ctx context.Context, param *entitys.Advi res.Evaluated++ res.Details = append(res.Details, entitys.AdvicerClientLevelDetail{ ClientId: item.Id.Hex(), - ClientName: item.PersonalInfo.Name, + ClientName: item.SectionString(mongo_model.SectionKeyClientName), Level: level, LevelDesc: mongo_model.ClientLevelDesc[level], Reason: reason, diff --git a/internal/biz/advice_follow.go b/internal/biz/advice_follow.go new file mode 100644 index 0000000..9bc538e --- /dev/null +++ b/internal/biz/advice_follow.go @@ -0,0 +1,426 @@ +package biz + +import ( + "ai_scheduler/internal/biz/llm_service/third_party" + "ai_scheduler/internal/config" + "ai_scheduler/internal/data/constants" + "ai_scheduler/internal/data/impl" + "ai_scheduler/internal/data/model" + "ai_scheduler/internal/data/mongo_model" + "ai_scheduler/internal/entitys" + "context" + "fmt" + "strings" + "time" + + "go.mongodb.org/mongo-driver/bson/primitive" + + "github.com/gofiber/fiber/v2/log" +) + +// AdviceFollowBiz 时间约定处理(两表分开存:定时提醒 ai_advice_remind_task + 成交线索 ai_advice_deal_lead)。 +// 1. PersistFromAssistant:对话回复后,从 AI 内联返回的 JSON(remind/lead)去重落库,并回写客户 mission; +// 2. RunDue:每分钟扫描到点的待执行提醒(remind),由 AI 主动发消息提醒客户; +// visit/deal/payment 属成交线索,不自动对客户发,供销售查看并注入后续对话。 +type AdviceFollowBiz struct { + cfg *config.Config + openai *third_party.OpenAi + projectImpl *impl.AdviceProjectImpl + modelSupImpl *impl.AiAdviceModelSupImpl + clientBiz *AdviceClientBiz + advicerBiz *AdviceAdvicerBiz + strategyBiz *AdviceStrategyBiz + sendBiz *AdviceWxSendBiz + projectBiz *AdviceProjectBiz + followImpl *impl.AdviceFollowImpl +} + +func NewAdviceFollowBiz( + cfg *config.Config, + openai *third_party.OpenAi, + projectImpl *impl.AdviceProjectImpl, + modelSupImpl *impl.AiAdviceModelSupImpl, + clientBiz *AdviceClientBiz, + advicerBiz *AdviceAdvicerBiz, + strategyBiz *AdviceStrategyBiz, + sendBiz *AdviceWxSendBiz, + projectBiz *AdviceProjectBiz, + followImpl *impl.AdviceFollowImpl, +) *AdviceFollowBiz { + return &AdviceFollowBiz{ + cfg: cfg, + openai: openai, + projectImpl: projectImpl, + modelSupImpl: modelSupImpl, + clientBiz: clientBiz, + advicerBiz: advicerBiz, + strategyBiz: strategyBiz, + sendBiz: sendBiz, + projectBiz: projectBiz, + followImpl: followImpl, + } +} + +// PersistFromAssistant 对话回复后,把 AI 内联返回的 remind/lead 去重落库,并将简洁 mission 回写客户画像。 +// 去重保证与手动补录(RemindCreate/LeadCreate)不重复;提醒已过点则不入库(避免立即触发)。 +func (a *AdviceFollowBiz) PersistFromAssistant(ctx context.Context, projectId, advicerId int32, clientWxid, mission string, as *mongo_model.Assistant) error { + if as == nil { + return nil + } + if len(clientWxid) == 0 { + if as.Remind != nil || as.Lead != nil { + log.Warnf("[跟进持久化] 客户 wxid 缺失,remind/lead 未落库: projectId=%d advicerId=%d", projectId, advicerId) + return nil + } + return nil + } + mission = strings.TrimSpace(mission) + if mission == "" && as.Remind == nil && as.Lead == nil { + return nil + } + appId, selfWxid := "", "" + if advicerId != 0 { + if adv, e := a.advicerBiz.AdvicerInfo(ctx, &entitys.AdvicerInfoReq{AdvicerID: advicerId}); e == nil { + appId = adv.WxDeviceId + selfWxid = adv.WxId + } + } + item, _, e := a.clientBiz.EnsureByWxid(ctx, clientWxid, projectId, advicerId, appId) + if e != nil { + return fmt.Errorf("解析客户画像失败: %w", e) + } + if mission != "" && mission != item.Mission { + _ = a.clientBiz.UpdateMission(ctx, item.Id.Hex(), mission) + } + now := time.Now() + var err error + if r := as.Remind; r != nil { + title := strings.TrimSpace(r.Title) + dueAt, ok := parseDueAt(r.DueAt) + // 缺标题、无法推算绝对时间或已过点的提醒不入库 + if title == "" || !ok || !dueAt.After(now) { + log.Warnf("[跟进持久化] 提醒被丢弃: wxid=%s title=%q due_at=%q(解析失败或已过点)", clientWxid, title, r.DueAt) + } else if exist, _ := a.followImpl.RemindExists(ctx, item.Id.Hex(), dueAt, title); exist { + log.Infof("[跟进持久化] 提醒已存在,跳过: wxid=%s title=%q due_at=%s", clientWxid, title, dueAt.Format("2006-01-02 15:04")) + } else { + _, e = a.followImpl.RemindAdd(ctx, &model.AiAdviceRemindTask{ + ProjectId: item.ProjectId, + AdvicerId: item.AdvicerId, + ClientId: item.Id.Hex(), + Wxid: item.Wxid, + SelfWxid: selfWxid, + AppId: item.AppId, + Title: title, + Detail: strings.TrimSpace(r.Detail), + DueAt: dueAt, + Status: model.FollowStatusPending, + Source: model.FollowSourceAi, + CreateAt: now, + UpdateAt: now, + }) + if e != nil { + err = fmt.Errorf("提醒入库失败: %w", e) + } else { + log.Infof("[跟进持久化] 新增提醒: wxid=%s title=%q due_at=%s", clientWxid, title, dueAt.Format("2006-01-02 15:04")) + } + } + } + if l := as.Lead; l != nil { + typ := strings.TrimSpace(l.Type) + title := strings.TrimSpace(l.Title) + dueAt, ok := parseDueAt(l.DueAt) + if !model.IsLeadType(typ) || title == "" || !ok { + log.Warnf("[跟进持久化] 成交线索被丢弃: wxid=%s type=%q title=%q due_at=%q", clientWxid, typ, title, l.DueAt) + } else if exist, _ := a.followImpl.LeadExists(ctx, item.Id.Hex(), typ, dueAt, title); exist { + log.Infof("[跟进持久化] 成交线索已存在,跳过: wxid=%s type=%s title=%q", clientWxid, typ, title) + } else { + _, e = a.followImpl.LeadAdd(ctx, &model.AiAdviceDealLead{ + ProjectId: item.ProjectId, + AdvicerId: item.AdvicerId, + ClientId: item.Id.Hex(), + Wxid: item.Wxid, + SelfWxid: selfWxid, + AppId: item.AppId, + LeadType: typ, + Title: title, + Detail: strings.TrimSpace(l.Detail), + ExpectAt: dueAt, + Status: model.FollowStatusPending, + Source: model.FollowSourceAi, + CreateAt: now, + UpdateAt: now, + }) + if e != nil { + err = fmt.Errorf("成交线索入库失败: %w", e) + } else { + log.Infof("[跟进持久化] 新增成交线索: wxid=%s type=%s title=%q", clientWxid, typ, title) + } + } + } + return err +} + +// RunDue 扫描到点的 pending 提醒并执行(每分钟由 cron 调用)。 +func (a *AdviceFollowBiz) RunDue(ctx context.Context) (done int, err error) { + tasks, err := a.followImpl.FindDueRemind(ctx, time.Now()) + if err != nil { + return 0, err + } + for i := range tasks { + if a.execRemind(ctx, &tasks[i]) { + done++ + } + } + return done, nil +} + +// execRemind 到点提醒客户:AI 生成口语化提醒并发送,成功置 done,失败置 failed。 +func (a *AdviceFollowBiz) execRemind(ctx context.Context, t *model.AiAdviceRemindTask) bool { + token := a.getProjectWxToken(ctx, t.ProjectId) + if len(token) == 0 { + a.markFailed(ctx, t.Id, "项目未配置 wx_token,无法发送提醒") + return false + } + client, e := a.loadClient(ctx, t.ClientId) + if e != nil { + a.markFailed(ctx, t.Id, "客户不存在: "+e.Error()) + return false + } + // 约定信息作为附加上下文,让提醒内容贴合原约定 + extra := "[约定提醒] " + t.Title + if t.Detail != "" { + extra += ":" + t.Detail + } + replies, err := a.strategyBiz.GenerateReply(ctx, client, constants.FollowRemindMission, extra, "") + if err != nil || len(replies) == 0 { + a.markFailed(ctx, t.Id, fmt.Sprintf("生成提醒内容失败: %v", err)) + return false + } + sent, e := a.sendBiz.SendMultiWithToken(ctx, token, t.AppId, t.Wxid, replies, mongo_model.WxMsgSourceWeb) + if e != nil { + a.markFailed(ctx, t.Id, "发送提醒失败: "+e.Error()) + return false + } + a.markDone(ctx, t.Id, strings.Join(sent, "\n"), "") + return true +} + +// markDone 提醒置为已发送(带 status=pending 条件保证幂等,避免并发重复执行)。 +func (a *AdviceFollowBiz) markDone(ctx context.Context, id int64, result, errMsg string) { + _, _ = a.followImpl.RemindUpdateStatusIfPending(ctx, id, model.FollowStatusDone, result, errMsg) +} + +// markFailed 提醒置为失败(带 status=pending 条件)。 +func (a *AdviceFollowBiz) markFailed(ctx context.Context, id int64, errMsg string) { + _, _ = a.followImpl.RemindUpdateStatusIfPending(ctx, id, model.FollowStatusFailed, "", errMsg) +} + +// loadClient 按客户记录 _id(Hex) 加载客户并包装为 Item(供 GenerateReply 使用)。 +func (a *AdviceFollowBiz) loadClient(ctx context.Context, clientIdHex string) (*mongo_model.AdvicerClientItem, error) { + info, err := a.clientBiz.Info(ctx, &entitys.AdvicerClientInfoReq{Id: clientIdHex}) + if err != nil { + return nil, err + } + objID, e := primitive.ObjectIDFromHex(clientIdHex) + if e != nil { + return nil, e + } + return &mongo_model.AdvicerClientItem{Id: objID, AdvicerClientMongo: info}, nil +} + +// getProjectWxToken 获取项目级 wx_token,优先项目配置,回退全局。 +func (a *AdviceFollowBiz) getProjectWxToken(ctx context.Context, projectId int32) string { + if a.projectBiz != nil && projectId != 0 { + if info, err := a.projectBiz.BaseInfo(projectId); err == nil && info.WxToken != "" { + return info.WxToken + } + } + return a.cfg.Advicer.WxToken +} + +// RemindList 定时提醒列表(按到点时间倒序,支持项目/客户/状态过滤 + 分页)。 +func (a *AdviceFollowBiz) RemindList(ctx context.Context, param *entitys.AdvicerRemindListReq) (list []model.AiAdviceRemindTask, err error) { + if param == nil { + param = &entitys.AdvicerRemindListReq{} + } + return a.followImpl.RemindList(ctx, param.ProjectId, param.ClientId, param.Wxid, param.Status, param.Page, param.PageSize) +} + +// LeadList 成交线索列表(按期望时间倒序,支持项目/客户/状态/线索类型过滤 + 分页)。 +func (a *AdviceFollowBiz) LeadList(ctx context.Context, param *entitys.AdvicerDealLeadListReq) (list []model.AiAdviceDealLead, err error) { + if param == nil { + param = &entitys.AdvicerDealLeadListReq{} + } + return a.followImpl.LeadList(ctx, param.ProjectId, param.ClientId, param.Wxid, param.Status, param.LeadType, param.Page, param.PageSize) +} + +// RemindCreate 手动补录一条定时提醒(pending)。 +func (a *AdviceFollowBiz) RemindCreate(ctx context.Context, param *entitys.AdvicerRemindCreateReq) (id int64, err error) { + if param == nil || (len(param.ClientId) == 0 && len(param.Wxid) == 0) { + return 0, fmt.Errorf("请选择客户(clientId 与 wxid 至少传一个)") + } + dueAt, ok := parseDueAt(param.DueAt) + if !ok { + return 0, fmt.Errorf("到点时间格式错误(应形如 2006-01-02 15:04)") + } + client, selfWxid, e := a.resolveClientRef(ctx, param.ClientId, param.Wxid, param.ProjectId, param.AppId) + if e != nil { + return 0, e + } + now := time.Now() + record := &model.AiAdviceRemindTask{ + ProjectId: client.ProjectId, + AdvicerId: client.AdvicerId, + ClientId: client.Id.Hex(), + Wxid: client.Wxid, + SelfWxid: selfWxid, + AppId: client.AppId, + Title: strings.TrimSpace(param.Title), + Detail: strings.TrimSpace(param.Detail), + DueAt: dueAt, + Status: model.FollowStatusPending, + Source: model.FollowSourceManual, + CreateAt: now, + UpdateAt: now, + } + return a.followImpl.RemindAdd(ctx, record) +} + +// LeadCreate 手动补录一条成交线索(pending)。 +func (a *AdviceFollowBiz) LeadCreate(ctx context.Context, param *entitys.AdvicerDealLeadCreateReq) (id int64, err error) { + if param == nil || (len(param.ClientId) == 0 && len(param.Wxid) == 0) { + return 0, fmt.Errorf("请选择客户(clientId 与 wxid 至少传一个)") + } + leadType := strings.TrimSpace(param.LeadType) + if !model.IsLeadType(leadType) { + return 0, fmt.Errorf("非法的线索类型(应为 visit/deal/payment): %s", leadType) + } + expectAt, ok := parseDueAt(param.ExpectAt) + if !ok { + return 0, fmt.Errorf("约定时间格式错误(应形如 2006-01-02 15:04)") + } + client, selfWxid, e := a.resolveClientRef(ctx, param.ClientId, param.Wxid, param.ProjectId, param.AppId) + if e != nil { + return 0, e + } + now := time.Now() + lead := &model.AiAdviceDealLead{ + ProjectId: client.ProjectId, + AdvicerId: client.AdvicerId, + ClientId: client.Id.Hex(), + Wxid: client.Wxid, + SelfWxid: selfWxid, + AppId: client.AppId, + LeadType: leadType, + Title: strings.TrimSpace(param.Title), + Detail: strings.TrimSpace(param.Detail), + ExpectAt: expectAt, + Status: model.FollowStatusPending, + Source: model.FollowSourceManual, + CreateAt: now, + UpdateAt: now, + } + return a.followImpl.LeadAdd(ctx, lead) +} + +// resolveClient 按客户 Hex 加载画像并反查 self_wxid。 +func (a *AdviceFollowBiz) resolveClient(ctx context.Context, clientIdHex string) (*mongo_model.AdvicerClientItem, string, error) { + client, e := a.loadClient(ctx, clientIdHex) + if e != nil { + return nil, "", fmt.Errorf("客户不存在: %w", e) + } + selfWxid := "" + if adv, e := a.advicerBiz.FindByWxDeviceId(ctx, client.AppId); e == nil { + selfWxid = adv.WxId + } + return client, selfWxid, nil +} + +// resolveClientRef 解析提醒/线索的客户引用:优先 clientId(Mongo hex), +// 否则按 wxid(托管客户单选)定位画像;尚无画像的微信好友自动建立仅含元数据的空画像, +// 以保证到点提醒 execRemind 的 loadClient 可用。返回客户 Item 与销售本人 self_wxid。 +func (a *AdviceFollowBiz) resolveClientRef(ctx context.Context, clientIdHex, wxid string, projectId int32, appId string) (*mongo_model.AdvicerClientItem, string, error) { + if len(clientIdHex) != 0 { + return a.resolveClient(ctx, clientIdHex) + } + if len(wxid) == 0 { + return nil, "", fmt.Errorf("请选择客户") + } + advicerId := int32(0) + if len(appId) != 0 { + if adv, e := a.advicerBiz.FindByWxDeviceId(ctx, appId); e == nil { + advicerId = adv.AdvicerID + } + } + item, _, e := a.clientBiz.EnsureByWxid(ctx, wxid, projectId, advicerId, appId) + if e != nil { + return nil, "", fmt.Errorf("客户解析失败: %w", e) + } + selfWxid := "" + if id := item.AppId; len(id) != 0 { + if adv, e := a.advicerBiz.FindByWxDeviceId(ctx, id); e == nil { + selfWxid = adv.WxId + } + } + return item, selfWxid, nil +} + +// RemindCancel 取消一条待执行提醒(仅 pending)。 +func (a *AdviceFollowBiz) RemindCancel(ctx context.Context, id int64) error { + if id == 0 { + return fmt.Errorf("提醒ID不能为空") + } + affected, err := a.followImpl.RemindUpdateStatusIfPending(ctx, id, model.FollowStatusCancelled, "", "") + if err != nil { + return err + } + if affected == 0 { + return fmt.Errorf("提醒不存在或已非待处理状态") + } + return nil +} + +// LeadCancel 取消一条成交线索(仅 pending)。 +func (a *AdviceFollowBiz) LeadCancel(ctx context.Context, id int64) error { + if id == 0 { + return fmt.Errorf("线索ID不能为空") + } + affected, err := a.followImpl.LeadUpdateStatusIfPending(ctx, id, model.FollowStatusCancelled, "") + if err != nil { + return err + } + if affected == 0 { + return fmt.Errorf("线索不存在或已非待处理状态") + } + return nil +} + +// LeadComplete 将一条成交线索标记为已完成(销售手动闭环,仅 pending)。 +func (a *AdviceFollowBiz) LeadComplete(ctx context.Context, id int64) error { + if id == 0 { + return fmt.Errorf("线索ID不能为空") + } + affected, err := a.followImpl.LeadUpdateStatusIfPending(ctx, id, model.FollowStatusDone, "") + if err != nil { + return err + } + if affected == 0 { + return fmt.Errorf("线索不存在或已非待处理状态") + } + return nil +} + +// parseDueAt 解析 "2006-01-02 15:04"(兼容带秒),按本地时区。 +func parseDueAt(s string) (time.Time, bool) { + s = strings.TrimSpace(s) + if s == "" { + return time.Time{}, false + } + layouts := []string{"2006-01-02 15:04", "2006-01-02 15:04:05", "2006-01-02T15:04", "2006-01-02T15:04:05"} + for _, l := range layouts { + if t, e := time.ParseInLocation(l, s, time.Local); e == nil { + return t, true + } + } + return time.Time{}, false +} diff --git a/internal/biz/advice_iterate.go b/internal/biz/advice_iterate.go index 4567579..ef8a84c 100644 --- a/internal/biz/advice_iterate.go +++ b/internal/biz/advice_iterate.go @@ -32,6 +32,9 @@ type AdviceIterateBiz struct { wxMsgMongo *mongo_model.AdvicerWxMsgMongo projectImpl *impl.AdviceProjectImpl modelSupImpl *impl.AiAdviceModelSupImpl + customerImpl *impl.AdviceCustomerImpl + advicerBiz *AdviceAdvicerBiz + followBiz *AdviceFollowBiz } func NewAdviceIterateBiz( @@ -42,6 +45,9 @@ func NewAdviceIterateBiz( wxMsgMongo *mongo_model.AdvicerWxMsgMongo, projectImpl *impl.AdviceProjectImpl, modelSupImpl *impl.AiAdviceModelSupImpl, + customerImpl *impl.AdviceCustomerImpl, + advicerBiz *AdviceAdvicerBiz, + followBiz *AdviceFollowBiz, ) *AdviceIterateBiz { return &AdviceIterateBiz{ cfg: cfg, @@ -51,6 +57,9 @@ func NewAdviceIterateBiz( wxMsgMongo: wxMsgMongo, projectImpl: projectImpl, modelSupImpl: modelSupImpl, + customerImpl: customerImpl, + advicerBiz: advicerBiz, + followBiz: followBiz, } } @@ -160,7 +169,8 @@ func (a *AdviceIterateBiz) iterateClient(ctx context.Context, c *mongo_model.Adv return "", false, errors.New("项目未配置可用模型(请检查 modelSupId)") } history := a.recentMsgs(ctx, c.Wxid, 30) - content := buildIterateUserContent(c, history, newMsgs) + manual, wxRemark := a.fetchRemarks(ctx, c) + content := buildIterateUserContent(c, history, newMsgs, manual, wxRemark) messages := []openai.ResponseInputMessage{ {Role: openai.ChatMessageRoleSystem, Content: constants.ClientIteratePrompt}, {Role: openai.ChatMessageRoleUser, Content: content}, @@ -174,83 +184,73 @@ func (a *AdviceIterateBiz) iterateClient(ctx context.Context, c *mongo_model.Adv return "", false, fmt.Errorf("解析迭代结果失败: %w", err) } - // 合并增量(仅覆盖模型明确输出的"新发现/修正"字段) - set := bson.M{} - if p := patch.PersonalInfo; p != nil { - pi := c.PersonalInfo - if v := strings.TrimSpace(p.Name); v != "" && v != pi.Name { - pi.Name, updated = v, true - } - if v := strings.TrimSpace(p.Gender); v != "" && v != pi.Gender { - pi.Gender, updated = v, true - } - if v := strings.TrimSpace(p.Location); v != "" && v != pi.Location { - pi.Location, updated = v, true - } - if v := strings.TrimSpace(p.FamilyOrganize); v != "" && v != pi.FamilyOrganize { - pi.FamilyOrganize, updated = v, true - } - if v := strings.TrimSpace(p.Birthday); v != "" && v != pi.Birthday { - pi.Birthday, updated = v, true - } - if p.IsFirstHome != nil && *p.IsFirstHome != pi.IsFirstHome { - pi.IsFirstHome, updated = *p.IsFirstHome, true - } - set["personalInfo"] = pi - } - if p := patch.PurchasePurpose; p != nil { - pp := c.PurchasePurpose - if v := strings.TrimSpace(p.PrimaryPurpose); v != "" && v != pp.PrimaryPurpose { - pp.PrimaryPurpose, updated = v, true - } - if v := strings.TrimSpace(p.SecondaryPurpose); v != "" && v != pp.SecondaryPurpose { - pp.SecondaryPurpose, updated = v, true - } - if v := strings.TrimSpace(p.DecisionMakers); v != "" && v != pp.DecisionMakers { - pp.DecisionMakers, updated = v, true - } - set["purchasePurpose"] = pp - } - if p := patch.CoreDemands; p != nil { - cd := c.CoreDemands - if v := strings.TrimSpace(p.TotalBudget); v != "" && v != cd.TotalBudget { - cd.TotalBudget, updated = v, true - } - if v := strings.TrimSpace(p.PreferredLayout); v != "" && v != cd.PreferredLayout { - cd.PreferredLayout, updated = v, true - } - if v := strings.TrimSpace(p.CoreAppeal); v != "" && v != cd.CoreAppeal { - cd.CoreAppeal, updated = v, true - } - set["coreDemands"] = cd - } - if len(patch.Concerns) > 0 { - merged := mergeUnique(c.Concerns, patch.Concerns) - if len(merged) != len(c.Concerns) { - updated = true - } - set["concerns"] = merged - } - if len(patch.DecisionProfile) > 0 { - merged := mergeUnique(c.DecisionProfile, patch.DecisionProfile) - if len(merged) != len(c.DecisionProfile) { - updated = true - } - set["decisionProfile"] = merged - } - - now := time.Now() - set["lastIterateAt"] = now - if updated { - set["lastUpdateTime"] = now - } - _, err = a.mongo.Co(a.clientBiz.AdvicerClientMongo).UpdateOne(ctx, bson.M{"_id": c.Id}, bson.M{"$set": set}) + // 合并动态栏目(增量:以现有画像为基底,LLM 输出逐个覆盖非空栏目) + updated, err = a.applyIterateSections(ctx, c, patch.Sections, false) if err != nil { return "", false, err } return strings.TrimSpace(patch.Summary), updated, nil } +// applyIterateSections 把 LLM 输出的栏目合并/替换进画像并落库。 +// replace=true 为全量替换(「画像更新」按钮),false 为增量合并(定时迭代)。 +func (a *AdviceIterateBiz) applyIterateSections(ctx context.Context, c *mongo_model.AdvicerClientItem, sections map[string]interface{}, replace bool) (updated bool, err error) { + merged := map[string]interface{}{} + if !replace { + for k, v := range c.Profile { + merged[k] = v + } + } + for k, v := range sections { + name := strings.TrimSpace(k) + if name == "" || isEmptySection(v) { + continue + } + merged[name] = v + } + now := time.Now() + set := bson.M{"lastIterateAt": now} + if pkg.JsonStringIgonErr(merged) != pkg.JsonStringIgonErr(c.Profile) { + set["profile"] = merged + set["lastUpdateTime"] = now + updated = true + } + _, err = a.mongo.Co(a.clientBiz.AdvicerClientMongo).UpdateOne(ctx, bson.M{"_id": c.Id}, bson.M{"$set": set}) + return +} + +// isEmptySection 判断栏目值是否为空(空串/空数组/空对象/nil)。 +func isEmptySection(v interface{}) bool { + switch x := v.(type) { + case nil: + return true + case string: + return strings.TrimSpace(x) == "" + case []interface{}: + return len(x) == 0 + case map[string]interface{}: + return len(x) == 0 + } + return false +} + +// fetchRemarks 取客户备注:人工备注(description) + 微信备注名(remark),均存于 MySQL ai_advice_customer。 +// 靠 appId 反查销售 self_wxid,再按 (self_wxid, 客户wxid) 定位客户记录;查不到返回空串不报错。 +func (a *AdviceIterateBiz) fetchRemarks(ctx context.Context, c *mongo_model.AdvicerClientItem) (manual, wxRemark string) { + if a.customerImpl == nil || a.advicerBiz == nil || c == nil || len(c.AppId) == 0 || len(c.Wxid) == 0 { + return "", "" + } + adv, e := a.advicerBiz.FindByWxDeviceId(ctx, c.AppId) + if e != nil || len(adv.WxId) == 0 { + return "", "" + } + cust, e := a.customerImpl.FindBySelfWxidAndUserName(ctx, adv.WxId, c.Wxid) + if e != nil { + return "", "" + } + return strings.TrimSpace(cust.Description), strings.TrimSpace(cust.Remark) +} + // idleMinutes 对话完成闲置时长(分钟,默认 30) func (a *AdviceIterateBiz) idleMinutes() int { d := a.cfg.Advicer.DialogIdleMinutes @@ -299,42 +299,130 @@ func (a *AdviceIterateBiz) recentMsgs(ctx context.Context, wxid string, limit in // ==================== 迭代辅助类型与函数 ==================== -// iteratePatch 画像迭代增量(指针结构区分"未提及"与"空值") +// iteratePatch 画像迭代/生成结果(sections = 栏目名→内容;summary = 本次说明) type iteratePatch struct { - PersonalInfo *iteratePersonal `json:"personalInfo"` - PurchasePurpose *iteratePurchase `json:"purchasePurpose"` - CoreDemands *iterateDemands `json:"coreDemands"` - Concerns []string `json:"concerns"` - DecisionProfile []string `json:"decisionProfile"` - Summary string `json:"summary"` + Sections map[string]interface{} `json:"sections"` + Summary string `json:"summary"` } -type iteratePersonal struct { - Name string `json:"name"` - Gender string `json:"gender"` - Location string `json:"location"` - IsFirstHome *bool `json:"isFirstHome"` - FamilyOrganize string `json:"familyOrganize"` - Birthday string `json:"birthday"` +// RegenerateProfile 画像更新:基于客户旧画像(可能没有)+ 聊天记录重新生成全量动态栏目。 +// clientId 优先;否则按 wxid(托管客户)定位/自动建档。忽略 analyzed 标记、不回写,与定时迭代互不干扰。 +func (a *AdviceIterateBiz) RegenerateProfile(ctx context.Context, param *entitys.AdvicerProfileRegenerateReq) (res *entitys.AdvicerIterateRunRes, err error) { + res = &entitys.AdvicerIterateRunRes{Details: []string{}} + if param == nil || (len(param.ClientId) == 0 && len(param.Wxid) == 0) { + return res, errors.New("请指定客户(clientId 或 wxid)") + } + + var c *mongo_model.AdvicerClientItem + if len(param.ClientId) != 0 { + info, e := a.clientBiz.Info(ctx, &entitys.AdvicerClientInfoReq{Id: param.ClientId}) + if e != nil { + return res, fmt.Errorf("客户不存在: %w", e) + } + objID, e := primitive.ObjectIDFromHex(param.ClientId) + if e != nil { + return res, e + } + c = &mongo_model.AdvicerClientItem{Id: objID, AdvicerClientMongo: info} + } else { + advicerId := int32(0) + if len(param.AppId) != 0 { + if adv, e := a.advicerBiz.FindByWxDeviceId(ctx, param.AppId); e == nil { + advicerId = adv.AdvicerID + } + } + item, _, e := a.clientBiz.EnsureByWxid(ctx, param.Wxid, param.ProjectId, advicerId, param.AppId) + if e != nil { + return res, fmt.Errorf("客户解析失败: %w", e) + } + c = item + } + if len(c.Wxid) == 0 { + return res, errors.New("该客户未绑定微信 wxid,无聊天记录可生成画像") + } + + msgs := a.recentMsgs(ctx, c.Wxid, 200) + if len(msgs) == 0 { + res.Details = append(res.Details, clientNameOf(c)+":暂无聊天记录,未生成画像") + return res, nil + } + res.Scanned = len(msgs) + + _, model := projectModelOf(ctx, a.projectImpl, a.modelSupImpl, c.ProjectId) + if model == nil { + return res, errors.New("项目未配置可用模型(请检查 modelSupId)") + } + manual, wxRemark := a.fetchRemarks(ctx, c) + content := buildRegenerateUserContent(c, msgs, manual, wxRemark) + messages := []openai.ResponseInputMessage{ + {Role: openai.ChatMessageRoleSystem, Content: constants.ClientIteratePrompt}, + {Role: openai.ChatMessageRoleUser, Content: content}, + } + resp, err := a.openai.CreateResponseMessages(ctx, model.Key, model.URL, model.ChatModel, messages, "") + if err != nil { + return res, fmt.Errorf("分析聊天记录失败: %w", err) + } + var patch iteratePatch + if err = json.Unmarshal([]byte(extractJsonObject(resp.GetOutputText())), &patch); err != nil { + return res, fmt.Errorf("解析画像结果失败: %w", err) + } + updated, err := a.applyIterateSections(ctx, c, patch.Sections, true) + if err != nil { + return res, err + } + if updated { + res.Updated++ + text := clientNameOf(c) + ":画像已更新" + if s := strings.TrimSpace(patch.Summary); s != "" { + text += "(" + s + ")" + } + res.Details = append(res.Details, text) + } else { + res.Details = append(res.Details, clientNameOf(c)+":未能从聊天记录中提取到画像信息") + } + return res, nil } -type iteratePurchase struct { - PrimaryPurpose string `json:"primaryPurpose"` - SecondaryPurpose string `json:"secondaryPurpose"` - DecisionMakers string `json:"decisionMakers"` +// clientNameOf 客户显示名(画像「姓名」栏目优先,其次截断 wxid),供迭代/生成结果描述使用。 +func clientNameOf(c *mongo_model.AdvicerClientItem) string { + if n := strings.TrimSpace(c.SectionString(mongo_model.SectionKeyClientName)); n != "" { + return n + } + if len(c.Wxid) > 10 { + return c.Wxid[:10] + "..." + } + return c.Wxid } -type iterateDemands struct { - TotalBudget string `json:"totalBudget"` - PreferredLayout string `json:"preferredLayout"` - CoreAppeal string `json:"coreAppeal"` -} - -// buildIterateUserContent 组装迭代输入(现有画像 + 本轮新增对话 + 更早上下文) -func buildIterateUserContent(c *mongo_model.AdvicerClientItem, history []mongo_model.AdvicerWxMsgMongo, newMsgs []mongo_model.AdvicerWxMsgItem) string { +// buildRegenerateUserContent 组装画像更新输入(现有画像 + 客户备注 + 全量聊天记录) +func buildRegenerateUserContent(c *mongo_model.AdvicerClientItem, msgs []mongo_model.AdvicerWxMsgMongo, manual, wxRemark string) string { var b strings.Builder b.WriteString("[客户现有画像]\n") b.WriteString(pkg.JsonStringIgonErr(c.Entity())) + if manual != "" { + b.WriteString("\n[人工备注]" + manual) + } + if wxRemark != "" { + b.WriteString("\n[微信备注名]" + wxRemark) + } + b.WriteString("\n\n[需要分析的聊天记录(按时间先后)]\n") + for i := range msgs { + b.WriteString(formatIterateMsg(&msgs[i])) + } + return b.String() +} + +// buildIterateUserContent 组装迭代输入(现有画像 + 客户备注 + 本轮新增对话 + 更早上下文) +func buildIterateUserContent(c *mongo_model.AdvicerClientItem, history []mongo_model.AdvicerWxMsgMongo, newMsgs []mongo_model.AdvicerWxMsgItem, manual, wxRemark string) string { + var b strings.Builder + b.WriteString("[客户现有画像]\n") + b.WriteString(pkg.JsonStringIgonErr(c.Entity())) + if manual != "" { + b.WriteString("\n[人工备注]" + manual) + } + if wxRemark != "" { + b.WriteString("\n[微信备注名]" + wxRemark) + } b.WriteString("\n\n[本轮需要分析的聊天记录(按时间先后)]\n") if len(newMsgs) == 0 { b.WriteString("(无)\n") diff --git a/internal/biz/advice_proactive.go b/internal/biz/advice_proactive.go index cd2d71c..da7ed48 100644 --- a/internal/biz/advice_proactive.go +++ b/internal/biz/advice_proactive.go @@ -120,7 +120,7 @@ func (a *AdviceProactiveBiz) Scan(ctx context.Context, param *entitys.AdvicerPro continue } // 1) 生日触达 - if normalizeBirthday(item.PersonalInfo.Birthday) == todayMD { + if normalizeBirthday(item.SectionString(mongo_model.SectionKeyBirthday)) == todayMD { stat, detail := a.sendBirthday(ctx, &item, param, a.getProjectWxToken(ctx, item.ProjectId)) if detail != "" { res.Details = append(res.Details, detail) @@ -527,9 +527,9 @@ func normalizeBirthday(b string) string { return m + "-" + d } -// clientName 客户显示名(姓名优先,其次截断的 wxid) +// clientName 客户显示名(画像「姓名」栏目优先,其次截断的 wxid) func clientName(c *mongo_model.AdvicerClientItem) string { - if n := strings.TrimSpace(c.PersonalInfo.Name); n != "" { + if n := strings.TrimSpace(c.SectionString(mongo_model.SectionKeyClientName)); n != "" { return n } if len(c.Wxid) > 10 { diff --git a/internal/biz/advice_wx.go b/internal/biz/advice_wx.go index 4cbb2aa..8f1e548 100644 --- a/internal/biz/advice_wx.go +++ b/internal/biz/advice_wx.go @@ -498,8 +498,8 @@ func (a *AdviceWxBiz) autoRegisSession(ctx context.Context, talkSkillEntity = list[0].AdvicerTalkSkillMongo.Entity() } } - // 加载客户信息(MongoDB advicer_client) - var clientEntity *mongo_model.AdvicerClientMongoEntity + // 加载客户信息(MongoDB advicer_client,动态画像栏目) + var clientEntity mongo_model.AdvicerClientMongoEntity if peerWxid != "" { var clientDoc mongo_model.AdvicerClientMongo filter := bson.M{"projectId": projectInfo.Base.ProjectID, "advicerId": advicer.AdvicerID, "wxid": peerWxid} @@ -528,6 +528,7 @@ func (a *AdviceWxBiz) autoRegisSession(ctx context.Context, RuleDimension: projectInfo.Base.RuleDimension, TalkSkill: talkSkillEntity, ClientInfo: clientEntity, + ClientWxid: peerWxid, } // 构建注册请求(托管场景额外强调短消息格式) @@ -598,7 +599,7 @@ func (a *AdviceWxBiz) hostingChatAndSend(ctx context.Context, sessionId, content var assistant mongo_model.Assistant var err error - chatReq := &entitys.AdvicerChatReq{SessionId: sessionId, Content: content} + chatReq := &entitys.AdvicerChatReq{SessionId: sessionId, Content: content, ClientWxid: peerWxid} if session != nil && session.SessionID != "" && modelInfo != nil && modelInfo.SupID != 0 { // 新注册路径:session/model 已在 autoRegisSession 中获取,跳过重复查询 assistant, err = a.adviceChatBiz.ChatWithSession(ctx, chatReq, session, modelInfo) diff --git a/internal/biz/advice_wx_send.go b/internal/biz/advice_wx_send.go index 43b5793..dff020d 100644 --- a/internal/biz/advice_wx_send.go +++ b/internal/biz/advice_wx_send.go @@ -21,6 +21,7 @@ type AdviceWxSendBiz struct { mongo *pkg.Mongo wxMsgMongo *mongo_model.AdvicerWxMsgMongo clientBiz *AdviceClientBiz + advicerBiz *AdviceAdvicerBiz } func NewAdviceWxSendBiz( @@ -28,12 +29,14 @@ func NewAdviceWxSendBiz( mongo *pkg.Mongo, wxMsgMongo *mongo_model.AdvicerWxMsgMongo, clientBiz *AdviceClientBiz, + advicerBiz *AdviceAdvicerBiz, ) *AdviceWxSendBiz { return &AdviceWxSendBiz{ cfg: cfg, mongo: mongo, wxMsgMongo: wxMsgMongo, clientBiz: clientBiz, + advicerBiz: advicerBiz, } } @@ -78,11 +81,20 @@ func (a *AdviceWxSendBiz) SendTextWithToken(ctx context.Context, token, appId, w return fmt.Errorf("发送微信消息失败: %w", err) } + // 反查销售本人 wxid(selfWxid),保证 AI 发出的消息在按 selfWxid 维度的会话查询中可见 + var selfWxid string + if len(appId) != 0 && a.advicerBiz != nil { + if adv, e := a.advicerBiz.FindByWxDeviceId(ctx, appId); e == nil { + selfWxid = adv.WxId + } + } + // 登记消息流水(analyzed=false,下一轮画像迭代会连同客户消息一起分析) now := time.Now() _, _ = a.mongo.Co(a.wxMsgMongo).InsertOne(ctx, &mongo_model.AdvicerWxMsgMongo{ AppId: appId, Wxid: wxid, + SelfWxid: selfWxid, Direction: mongo_model.WxMsgDirectionAi, MsgType: mongo_model.WxMsgTypeText, Content: content, diff --git a/internal/biz/llm_service/third_party/openai.go b/internal/biz/llm_service/third_party/openai.go index 21f122c..79c1114 100644 --- a/internal/biz/llm_service/third_party/openai.go +++ b/internal/biz/llm_service/third_party/openai.go @@ -106,6 +106,8 @@ func (o *OpenAi) CreateResponseMessages( Temperature: &temperature, TopP: &topP, MaxOutputTokens: 800, + Stream: false, + Reasoning: &openai.ResponseReasoning{Effort: "none"}, Text: &openai.ResponseTextConfig{ Format: &openai.ResponseTextFormat{ Type: "json_object", diff --git a/internal/biz/provider_set.go b/internal/biz/provider_set.go index e0ef785..037e9d4 100644 --- a/internal/biz/provider_set.go +++ b/internal/biz/provider_set.go @@ -24,6 +24,7 @@ var ProviderSetBiz = wire.NewSet( NewAdviceStrategyBiz, NewAdviceProactiveBiz, NewAdviceIterateBiz, + NewAdviceFollowBiz, NewAdviceCustomerBiz, NewAdviceLabelBiz, ) diff --git a/internal/data/constants/prompt.go b/internal/data/constants/prompt.go index 4434679..398c253 100644 --- a/internal/data/constants/prompt.go +++ b/internal/data/constants/prompt.go @@ -6,23 +6,26 @@ const BasePrompt = `**[场景]**现在你是专业的顾问,我们在聊天软 **[规则]** 1.必须模仿顾问的聊天方式,不能有markdown格式,聊天内容必须贴近聊天软件的正常聊天,非客户要求不要自顾自的介绍项目!开场白不要太长! 2.像真人发微信一样说话:一条消息只表达一个意思,每条尽量控制在10~30个字;要说的内容较多时,必须拆成2~4条短消息,在result里用\n分隔。禁止把超过40个字的内容塞进同一条消息!记住:真人微信聊天不会发长段落,每条消息就像一口气打出来的那么短。 -3.结合[任务内容]信息,若任务状态为"in_progress",则在对话中尽量完成任务;如果任务状态为"completed"或"fail",则主动结束对话,且该轮对话中不再更新任务状态,mission_complete_desc记录最终结果且不再更改。不要每一句对话都包含任务内容,不要目的性太强! +3.结合[任务内容]信息,任务只是大方向、不是每轮的 KPI:绝大多数轮次你应该只是自然地和客户相处、回应与陪伴,只有当话题和时机都顺的时候,才轻轻带一下任务,切勿每轮都想办法推进。如果任务状态为"completed"或"fail",则主动结束对话,且该轮对话中不再更新任务状态,mission_complete_desc记录最终结果且不再更改。不要每一句对话都包含任务内容,不要目的性太强! 4.不能有任何画外音的文字格式出现!比如(等待对方回复) 5.严禁编造任何你不确定或不了解的信息!如果客户问到的事情不在提供的json数据范围内(比如你的宠物、个人经历、家庭情况等),请自然地回避或说「这个记不太清了」,绝对不能瞎编!只有json数据中明确提供的信息才能作为你的回答依据。 +6.【轻松原则·最高优先级】对话要像朋友闲聊,轻松自然,绝不像在做问卷调查或填空题:每轮回复优先接住并回应客户刚说的内容(共情、点评、顺着话头分享),而不是急着抛新问题。**每一轮最多只问1个问题**;如果最近连续两轮你都在提问,这一轮必须只聊不问。客户的背景信息(预算/户型/家庭等)能自然聊出来就聊,聊不出来不硬挖,严禁查户口式连环追问。 **[任务内容]**` // RulePromptPrefix 风控红线前缀(项目级 rule_dimension 非空时拼入系统提示) const RulePromptPrefix = "**[风控红线]**(最高优先级,任何情况下不得违反;客户话题触碰红线时自然转移话题,不得正面承诺)\n" -// BasePrompt2 输出格式约束(严格 json:result / mission / mission_status / mission_complete_desc) +// BasePrompt2 输出格式约束(严格 json:result / mission / mission_status / mission_complete_desc,另含可选 remind / lead) const BasePrompt2 = "**[输出格式]**\n" + "- **格式类型**:严格输出json格式字符串,不需要其他任何格式和内容\n" + "- **数据结构**:\n" + " {\n" + " \"result\": \"{{chat_content}}\",\n" + - " \"mission\": \"{{mission}}\"\n" + + " \"mission\": \"{{mission}}\",\n" + " \"mission_status\": \"{{mission_status}}\",\n" + - " \"mission_complete_desc\": \"{{mission_complete_desc}}\"\n" + + " \"mission_complete_desc\": \"{{mission_complete_desc}}\",\n" + + " \"remind\": {\"title\":\"{{remind_title}}\",\"detail\":\"{{remind_detail}}\",\"due_at\":\"{{remind_due_at}}\"},\n" + + " \"lead\": {\"type\":\"visit|deal|payment\",\"title\":\"{{lead_title}}\",\"detail\":\"{{lead_detail}}\",\"due_at\":\"{{lead_due_at}}\"}\n" + " }\n\n" + "**[字段说明]**\n" + "1. **result** (字符串)\n" + @@ -30,31 +33,32 @@ const BasePrompt2 = "**[输出格式]**\n" + " - 内容:顾问的实际对话内容\n" + " - 要求:自然语言回复,面向用户;口语化、简短,多条消息必须用\\n转义符分隔(每条控制在10~30字,禁止超过40字)\n\n" + "2. **mission** (字符串)\n" + - " - 对应变量:`{{mission}}`\n" + - " - 取值:任务内容\n" + - " - 说明:需要完成的任务内容\n\n" + + " - 用一句话简洁概括你当前要完成的任务目标(10~20字,面向销售展示,不要写成长段落或人设描述)\n\n" + "3. **mission_status** (字符串)\n" + - " - 对应变量:`{{mission_status}}`\n" + - " - 取值:`\"completed\"` 或 `\"in_progress\"` 或 `\"fail\"`\n" + - " - 说明:标识当前任务完成状态\n" + - " - `\"completed\"`:任务已全部完成\n" + - " - `\"in_progress\"`:任务仍在进行中\n" + - " - `\"fail\"`:任务失败,客户已经明确拒绝或者对任务内容表达反对\n\n" + + " - 取值:`\"completed\"` 或 `\"in_progress\"` 或 `\"fail\"`,标识当前任务完成状态\n\n" + "4. **mission_complete_desc** (字符串)\n" + - " - 对应变量:`{{mission_complete_desc}}`\n" + - " - 内容:根据mission_status提供相应描述\n" + - " - 当`mission_status: \"completed\"`时:简要总结任务完成情况\n" + - " - 当`mission_status: \"in_progress\"`时:说明下一步需要做什么\n\n" + + " - `completed`:简要总结任务完成情况;`in_progress`:用一句话客观记录本轮聊到了什么、气氛如何\n\n" + + "5. **remind** (可选·对象) 定时提醒/回访\n" + + " - **仅当本轮对话中,客户与你明确约定了「到点提醒/回访」的具体时间+动作时才输出**(如“明天上午十点你给我打电话”“过两天再联系我”)\n" + + " - 字段:title(不超过20字简述)、detail(尽量摘录约定原文与关键信息)、due_at(约定时间,格式 `2006-01-02 15:04`)\n" + + " - 相对时间(明天/后天/下周三/周末等)以 **[当前时间]** 为基准推算为绝对时间;只能确定日期不确定时刻时时刻默认 09:00\n" + + " - 没有明确「时间+动作」的约定时,**绝不要编造,整个 remind 字段省略不输出**\n\n" + + "6. **lead** (可选·对象) 成交线索\n" + + " - **仅当本轮出现明确的「到访/成交/付款」约定时才输出**\n" + + " - type:`visit`(约定到访线下)、`deal`(约定成交/下定/签约)、`payment`(约定付款/打款)\n" + + " - 字段:title、detail、due_at 规则同 remind;凡涉及到访/成交/付款,归入 lead,不要放进 remind\n" + + " - 没有明确约定时**整个 lead 字段省略不输出**\n\n" + + "**[remind/lead 重要原则]** 这两个字段宁缺毋滥:绝大多数对话都不应出现;只有时间明确、动作明确、双方已达成一致时才写入;不满足则输出中不出现对应键,切勿为凑字段而编造时间或约定。\n\n" + "**[示例]**\n" + - "{\n\"result\": \"需要我给您安排时间吗?\",\n\"mission\": \"邀请客户到售楼部\",\n\"mission_status\": \"in_progress\",\n\"mission_complete_desc\": \"需要用户确认什么时候到售楼部\"\n}\n\n" + - "{\n\"result\": \"行的,那就周日下午两点吧\\n我先把位置给您发一下,导航搜「翡翠湾售楼部」就到了\\n到了给我打电话哈\",\n\"mission\": \"邀请客户到售楼部\",\n\"mission_status\": \"completed\",\n\"mission_complete_desc\": \"客户确认周日下午两点到售楼部\"\n}\n\n" + + "【例1:日常闲聊,不碰任务,无提醒无线索】客户:\"今晚又加班到九点,累死了\"\n{\n\"result\": \"这么晚啊,太拼了吧\\n周末可得好好补一觉\",\n\"mission\": \"邀约客户到访售楼部\",\n\"mission_status\": \"in_progress\",\n\"mission_complete_desc\": \"本轮先关心客户状态,未提及到访\"}\n\n" + + "【例2:达成到访约定→输出 lead】客户:\"那周日下午两点我去看看\"\n{\n\"result\": \"好嘞\\n那就周日下午两点\\n我把位置发您,到了给我打电话\",\n\"mission\": \"邀约客户到访售楼部\",\n\"mission_status\": \"completed\",\n\"mission_complete_desc\": \"客户确认周日下午两点到访\",\n\"lead\": {\"type\":\"visit\",\"title\":\"到访售楼部\",\"detail\":\"约定周日下午两点到售楼部看房\",\"due_at\":\"2026-10-04 14:00\"}}\n\n" + + "【例3:约定回电→输出 remind】客户:\"明天上午十点你再打给我吧\"\n{\n\"result\": \"行\\n那明天上午十点我给您打电话\\n不打扰您休息了\",\n\"mission\": \"邀约客户到访售楼部\",\n\"mission_status\": \"in_progress\",\n\"mission_complete_desc\": \"约定明日上午十点回电\",\n\"remind\": {\"title\":\"回访电话\",\"detail\":\"客户要求明天上午10点回电话\",\"due_at\":\"2026-09-29 10:00\"}}\n\n" + "**[强制要求]**\n" + "1. 必须输出完整、有效的JSON对象\n" + - "2. 所有字段均为必需字段,不可省略\n" + + "2. result / mission / mission_status / mission_complete_desc 为必需字段;remind / lead 为可选字段,无则整体省略\n" + "3. JSON格式必须严格正确,无语法错误\n" + - "4. `mission_status`只能使用指定的三个值:completed / in_progress / fail\n" + - "5. `result`字段内容需符合对话语境,像真人微信聊天一样口语化\n" + - "6. result 拆分多条消息时必须使用 \\n 转义符,禁止在JSON字符串里直接换行" + "4. `mission_status`只能用:completed / in_progress / fail\n" + + "5. result 拆分多条消息时必须使用 \\n 转义符,禁止在JSON字符串里直接换行" // ==================== 批次B:智能策略 / 主动对话 / 自我迭代 ==================== @@ -87,24 +91,32 @@ const StrategyProactiveActivityMission = "项目推出了新活动,请主动 const StrategyFollowupMission = "这位客户有一段时间没有互动了。请以顾问本人的身份主动发起一次简短、自然的问候/跟进," + "尝试了解他最近的进展与顾虑(可以关心决策进度),不要推销感太强,也不要群发口吻。" -// ClientIteratePrompt 画像迭代系统提示(对话完成后分析聊天记录,输出画像增量 JSON) -const ClientIteratePrompt = `**[角色]**你是资深的销售数据分析师,负责根据销售与客户的真实聊天记录,提取并更新客户画像。 -**[任务]**分析对话内容,找出:客户个人信息(姓名/性别/居住地/是否首套/家庭情况/生日)、购房目的、核心需求(预算/户型/述求)、关注点与顾虑、决策特征。 +// ClientIteratePrompt 画像生成/迭代系统提示(分析聊天记录,输出完整客户画像栏目 JSON) +const ClientIteratePrompt = `**[角色]**你是资深的销售数据分析师,负责根据销售与客户的真实聊天记录,构建并更新客户画像。 +**[任务]**分析对话内容,输出一份**完整的客户画像栏目**(栏目名 → 栏目内容),栏目围绕:客户身份(姓名/性别/居住地/家庭结构/生日等)、购买目的、核心需求(预算/偏好/核心诉求)、关注点与顾虑、决策链与决策风格等。栏目名用简洁中文(如:姓名、性别、生日、居住地、家庭结构、购买目的、预算、偏好、核心诉求、关注顾虑、决策风格),可按需增减。 **[要求]** -1. 只提取聊天记录中真实出现或可以明确推断的信息,严禁编造; -2. 只输出「新发现」或「需要修正」的字段,未涉及或无法确认的字段直接省略,不要输出空值; -3. 生日只输出出现过的月日或完整日期(如 "05-20" 或 "1995-05-20"); -4. concerns/decisionProfile 只输出新增条目,不要重复已有内容。 +1. 只输出聊天记录中真实出现或可明确推断的内容,严禁编造;没有依据的栏目直接省略,不要输出空栏目; +2. 若提供了[客户现有画像],则沿用其中未被聊天记录否定的栏目,并合并新发现/修正的栏目,最终输出**全量**结果; +3. 生日只输出出现过的月日或完整日期(如 "05-20" 或 "1995-05-20"),并尽量写入「生日」栏目;客户称呼/姓氏尽量写入「姓名」栏目; +4. 栏目内容可用:字符串(简短描述)、字符串数组(多条并列,如关注顾虑)、或键值对象(多项信息,如决策链); +5. [人工备注]/[微信备注名] 是销售对客户的既有认知,可作为画像补全与消歧的高置信线索;但当备注与聊天记录事实冲突时以聊天为准,且不得把备注中的敏感/风控内容当作对客户已做的承诺写入画像。 **[输出格式]**严格输出 json,不要任何其他内容: { - "personalInfo": {"name": "", "gender": "", "location": "", "isFirstHome": true, "familyOrganize": "", "birthday": ""}, - "purchasePurpose": {"primaryPurpose": "", "secondaryPurpose": "", "decisionMakers": ""}, - "coreDemands": {"totalBudget": "", "preferredLayout": "", "coreAppeal": ""}, - "concerns": ["新增关注点"], - "decisionProfile": ["新增决策特征"], - "summary": "本次迭代的简要说明" + "sections": { + "姓名": "唐", + "性别": "男", + "生日": "05-20", + "购买目的": "首次置业,解决自住", + "预算": "350-400万", + "关注顾虑": ["总价超预算风险", "板块保值能力"], + "决策链": {"决策人": "夫妻双方", "影响人": "父母"} + }, + "summary": "本次生成/迭代的简要说明" }` +// FollowRemindMission 到点提醒客户场景任务说明(跟进线索 remind_customer 执行时使用) +const FollowRemindMission = "现在是之前与客户约定好的时间点,请以顾问本人身份,自然、简短地发一条提醒/回访消息(说清约定的事,语气亲切不生硬,不确定的信息不编造)。" + // ClientEvaluatePrompt 客户等级评估提示词(每日 0 点定时评估 / 手动评估,LLM 为主) const ClientEvaluatePrompt = `你是资深销售管理专家。请根据【客户画像】【互动统计】【最近聊天记录】,评估该客户当前所处的客户阶段。 diff --git a/internal/data/impl/advice_advicer_impl.go b/internal/data/impl/advice_advicer_impl.go index c2698f4..9c4c0c1 100644 --- a/internal/data/impl/advice_advicer_impl.go +++ b/internal/data/impl/advice_advicer_impl.go @@ -25,3 +25,12 @@ func (a *AdviceAdvicerImpl) FindByWxDeviceId(ctx context.Context, wxDeviceId str First(&record).Error return record, err } + +// FindByWxId 通过登录微信 wxid 查询销售记录 +func (a *AdviceAdvicerImpl) FindByWxId(ctx context.Context, wxId string) (model.AiAdviceAdvicer, error) { + var record model.AiAdviceAdvicer + err := a.Db.WithContext(ctx). + Where("wx_id = ?", wxId). + 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 dace191..ac11bab 100644 --- a/internal/data/impl/advice_customer_impl.go +++ b/internal/data/impl/advice_customer_impl.go @@ -5,6 +5,7 @@ import ( "ai_scheduler/tmpl/dataTemp" "ai_scheduler/utils" "context" + "strings" ) type AdviceCustomerImpl struct { @@ -62,6 +63,31 @@ func (a *AdviceCustomerImpl) UpdateLabelList(ctx context.Context, selfWxid, user Update("label_list", labelList).Error } +// FindByCustomerId 按主键查客户记录 +func (a *AdviceCustomerImpl) FindByCustomerId(ctx context.Context, customerId int32) (model.AiAdviceCustomer, error) { + var record model.AiAdviceCustomer + err := a.Db.WithContext(ctx). + Where("customer_id = ?", customerId). + First(&record).Error + return record, err +} + +// UpdateNote 更新客户备注(description 列) +func (a *AdviceCustomerImpl) UpdateNote(ctx context.Context, customerId int32, description string) error { + return a.Db.WithContext(ctx). + Model(&model.AiAdviceCustomer{}). + Where("customer_id = ?", customerId). + Update("description", description).Error +} + +// UpdateLabelListById 按主键更新 label_list +func (a *AdviceCustomerImpl) UpdateLabelListById(ctx context.Context, customerId int32, labelList string) error { + return a.Db.WithContext(ctx). + Model(&model.AiAdviceCustomer{}). + Where("customer_id = ?", customerId). + Update("label_list", labelList).Error +} + // FindBySelfWxidAndUserName 查询单个客户记录 func (a *AdviceCustomerImpl) FindBySelfWxidAndUserName(ctx context.Context, selfWxid, userName string) (model.AiAdviceCustomer, error) { var record model.AiAdviceCustomer @@ -92,6 +118,39 @@ func (a *AdviceCustomerImpl) FindBySelfWxids(ctx context.Context, selfWxids []st return list, err } +// FindBySelfWxidHostingLabels 在 SQL 层用 FIND_IN_SET 直接筛选出各 selfWxid 下命中其托管标签的客户记录。 +// hostingLabelsMap: selfWxid -> 该销售已开启托管(hosting=1)的标签 ID 列表;每个销售用各自的托管标签过滤。 +func (a *AdviceCustomerImpl) FindBySelfWxidHostingLabels(ctx context.Context, hostingLabelsMap map[string][]int) ([]model.AiAdviceCustomer, error) { + if len(hostingLabelsMap) == 0 { + return nil, nil + } + var conditions []string + var args []interface{} + for selfWxid, labelIds := range hostingLabelsMap { + if len(labelIds) == 0 { + continue + } + findInSet := make([]string, 0, len(labelIds)) + labelArgs := make([]interface{}, 0, len(labelIds)) + for _, id := range labelIds { + findInSet = append(findInSet, "FIND_IN_SET(?, label_list)") + labelArgs = append(labelArgs, id) + } + conditions = append(conditions, "(self_wxid = ? AND ("+strings.Join(findInSet, " OR ")+"))") + args = append(args, selfWxid) + args = append(args, labelArgs...) + } + if len(conditions) == 0 { + return nil, nil + } + var list []model.AiAdviceCustomer + err := a.Db.WithContext(ctx). + Where("("+strings.Join(conditions, " OR ")+")", args...). + Order("create_at desc"). + Find(&list).Error + return list, err +} + // UpdateFromWxDetail 根据微信好友详情更新客户记录(回调时保持客户信息最新) func (a *AdviceCustomerImpl) UpdateFromWxDetail(ctx context.Context, selfWxid, userName string, updates map[string]interface{}) error { if len(updates) == 0 { diff --git a/internal/data/impl/advice_follow_impl.go b/internal/data/impl/advice_follow_impl.go new file mode 100644 index 0000000..24bb24c --- /dev/null +++ b/internal/data/impl/advice_follow_impl.go @@ -0,0 +1,174 @@ +package impl + +import ( + "ai_scheduler/internal/data/model" + "ai_scheduler/tmpl/dataTemp" + "ai_scheduler/utils" + "context" + "time" +) + +// AdviceFollowImpl 统管两张表的数据访问: +// - ai_advice_remind_task(定时提醒) +// - ai_advice_deal_lead(成交线索) +// DataTemp 基础模型仅占位,所有方法显式 Model(&具体结构体{})。 +type AdviceFollowImpl struct { + dataTemp.DataTemp +} + +func NewAdviceFollowImpl(db *utils.Db) *AdviceFollowImpl { + return &AdviceFollowImpl{ + DataTemp: *dataTemp.NewDataTemp(db, new(model.AiAdviceRemindTask)), + } +} + +// ================= 定时提醒 ai_advice_remind_task ================= + +// RemindAdd 新增一条定时提醒。 +func (a *AdviceFollowImpl) RemindAdd(ctx context.Context, record *model.AiAdviceRemindTask) (int64, error) { + if err := a.Db.WithContext(ctx).Create(record).Error; err != nil { + return 0, err + } + return record.Id, nil +} + +// RemindExists 去重:同客户 + 到点时间 + 标题 是否已存在。 +func (a *AdviceFollowImpl) RemindExists(ctx context.Context, clientId string, dueAt time.Time, title string) (bool, error) { + var count int64 + err := a.Db.WithContext(ctx).Model(&model.AiAdviceRemindTask{}). + Where("client_id = ? AND due_at = ? AND title = ?", clientId, dueAt, title). + Count(&count).Error + return count > 0, err +} + +// FindDueRemind 取到点且待执行的提醒(供 cron 每分钟执行)。 +func (a *AdviceFollowImpl) FindDueRemind(ctx context.Context, now time.Time) ([]model.AiAdviceRemindTask, error) { + var list []model.AiAdviceRemindTask + err := a.Db.WithContext(ctx). + Where("status = ? AND due_at <= ?", model.FollowStatusPending, now). + Order("due_at asc").Limit(200). + Find(&list).Error + return list, err +} + +// RemindList 定时提醒列表(按到点时间倒序,支持项目/客户/状态过滤 + 分页)。 +func (a *AdviceFollowImpl) RemindList(ctx context.Context, projectId int32, clientId, wxid, status string, page, pageSize int) ([]model.AiAdviceRemindTask, error) { + query := a.Db.WithContext(ctx).Model(&model.AiAdviceRemindTask{}) + if projectId != 0 { + query = query.Where("project_id = ?", projectId) + } + if clientId != "" { + query = query.Where("client_id = ?", clientId) + } + if wxid != "" { + query = query.Where("wxid = ?", wxid) + } + if status != "" { + query = query.Where("status = ?", status) + } + query = query.Order("due_at desc") + if pageSize > 0 { + if page < 1 { + page = 1 + } + query = query.Offset((page - 1) * pageSize).Limit(pageSize) + } else { + query = query.Limit(200) + } + var list []model.AiAdviceRemindTask + err := query.Find(&list).Error + return list, err +} + +// RemindUpdateStatusIfPending 仅当提醒仍为 pending 时更新状态(幂等),返回受影响行数。 +func (a *AdviceFollowImpl) RemindUpdateStatusIfPending(ctx context.Context, id int64, status, result, errMsg string) (int64, error) { + now := time.Now() + set := map[string]interface{}{"status": status, "done_at": now, "update_at": now} + if result != "" { + set["result"] = result + } + if errMsg != "" { + set["err_msg"] = errMsg + } + res := a.Db.WithContext(ctx).Model(&model.AiAdviceRemindTask{}). + Where("id = ? AND status = ?", id, model.FollowStatusPending). + Updates(set) + return res.RowsAffected, res.Error +} + +// ================= 成交线索 ai_advice_deal_lead ================= + +// LeadAdd 新增一条成交线索。 +func (a *AdviceFollowImpl) LeadAdd(ctx context.Context, record *model.AiAdviceDealLead) (int64, error) { + if err := a.Db.WithContext(ctx).Create(record).Error; err != nil { + return 0, err + } + return record.Id, nil +} + +// LeadExists 去重:同客户 + 线索类型 + 期望时间 + 标题 是否已存在。 +func (a *AdviceFollowImpl) LeadExists(ctx context.Context, clientId, leadType string, expectAt time.Time, title string) (bool, error) { + var count int64 + err := a.Db.WithContext(ctx).Model(&model.AiAdviceDealLead{}). + Where("client_id = ? AND lead_type = ? AND expect_at = ? AND title = ?", clientId, leadType, expectAt, title). + Count(&count).Error + return count > 0, err +} + +// FindActiveLeadsByWxid 取某客户尚未闭环的成交线索(pending),供对话注入。 +func (a *AdviceFollowImpl) FindActiveLeadsByWxid(ctx context.Context, wxid string) ([]model.AiAdviceDealLead, error) { + var list []model.AiAdviceDealLead + if wxid == "" { + return list, nil + } + err := a.Db.WithContext(ctx). + Where("wxid = ? AND status = ?", wxid, model.FollowStatusPending). + Order("expect_at asc").Limit(20). + Find(&list).Error + return list, err +} + +// LeadList 成交线索列表(按期望时间倒序,支持项目/客户/状态/线索类型过滤 + 分页)。 +func (a *AdviceFollowImpl) LeadList(ctx context.Context, projectId int32, clientId, wxid, status, leadType string, page, pageSize int) ([]model.AiAdviceDealLead, error) { + query := a.Db.WithContext(ctx).Model(&model.AiAdviceDealLead{}) + if projectId != 0 { + query = query.Where("project_id = ?", projectId) + } + if clientId != "" { + query = query.Where("client_id = ?", clientId) + } + if wxid != "" { + query = query.Where("wxid = ?", wxid) + } + if status != "" { + query = query.Where("status = ?", status) + } + if leadType != "" { + query = query.Where("lead_type = ?", leadType) + } + query = query.Order("expect_at desc") + if pageSize > 0 { + if page < 1 { + page = 1 + } + query = query.Offset((page - 1) * pageSize).Limit(pageSize) + } else { + query = query.Limit(200) + } + var list []model.AiAdviceDealLead + err := query.Find(&list).Error + return list, err +} + +// LeadUpdateStatusIfPending 仅当线索仍为 pending 时更新状态/跟进结果(幂等),返回受影响行数。 +func (a *AdviceFollowImpl) LeadUpdateStatusIfPending(ctx context.Context, id int64, status, result string) (int64, error) { + now := time.Now() + set := map[string]interface{}{"status": status, "done_at": now, "update_at": now} + if result != "" { + set["result"] = result + } + res := a.Db.WithContext(ctx).Model(&model.AiAdviceDealLead{}). + Where("id = ? AND status = ?", id, model.FollowStatusPending). + Updates(set) + return res.RowsAffected, res.Error +} diff --git a/internal/data/impl/provider_set.go b/internal/data/impl/provider_set.go index 04eb98a..3b17c01 100644 --- a/internal/data/impl/provider_set.go +++ b/internal/data/impl/provider_set.go @@ -17,4 +17,5 @@ var ProviderImpl = wire.NewSet( NewAdviceAdminImpl, NewAdviceCustomerImpl, NewAdviceLabelImpl, + NewAdviceFollowImpl, ) diff --git a/internal/data/model/ai_advice_follow.go b/internal/data/model/ai_advice_follow.go new file mode 100644 index 0000000..d419633 --- /dev/null +++ b/internal/data/model/ai_advice_follow.go @@ -0,0 +1,112 @@ +package model + +import "time" + +// ===== 共享枚举(定时提醒 + 成交线索 分类,用于复盘抽取时的类型判定)===== + +// 抽取分类 type:remind 单纯提醒(到点 AI 主动发);visit/deal/payment 成交线索。 +const ( + FollowTypeRemind = "remind" // 单纯提醒:到点 AI 主动发消息 + FollowTypeVisit = "visit" // 约定到访(成交线索) + FollowTypeDeal = "deal" // 约定成交(成交线索) + FollowTypePayment = "payment" // 约定付款/给钱(成交线索) +) + +// 状态(两表共用):pending 待处理;done 已完成;cancelled 已取消;failed 执行失败(仅提醒)。 +const ( + FollowStatusPending = "pending" + FollowStatusDone = "done" + FollowStatusCancelled = "cancelled" + FollowStatusFailed = "failed" +) + +// 来源(两表共用):ai 复盘抽取;manual 手动补录。 +const ( + FollowSourceAi = "ai" + FollowSourceManual = "manual" +) + +// IsValidFollowType 是否为合法的抽取分类。 +func IsValidFollowType(t string) bool { + switch t { + case FollowTypeRemind, FollowTypeVisit, FollowTypeDeal, FollowTypePayment: + return true + } + return false +} + +// IsLeadType 是否为成交线索类(到访/成交/付款)。 +func IsLeadType(t string) bool { + return t == FollowTypeVisit || t == FollowTypeDeal || t == FollowTypePayment +} + +// LeadTypeLabel 类型中文标签(供对话注入与提示使用)。 +func LeadTypeLabel(t string) string { + switch t { + case FollowTypeVisit: + return "约定到访" + case FollowTypeDeal: + return "约定成交" + case FollowTypePayment: + return "约定付款" + case FollowTypeRemind: + return "提醒回访" + } + return "跟进事项" +} + +// ===== 定时提醒表 ===== + +const TableNameAiAdviceRemindTask = "ai_advice_remind_task" + +// AiAdviceRemindTask 定时提醒(到点由 AI 主动发消息提醒客户)。mapped from +type AiAdviceRemindTask struct { + Id int64 `gorm:"column:id;primaryKey;autoIncrement:true" json:"id"` + ProjectId int32 `gorm:"column:project_id;not null;comment:项目ID" json:"project_id"` + AdvicerId int32 `gorm:"column:advicer_id;not null;comment:所属销售ID" json:"advicer_id"` + ClientId string `gorm:"column:client_id;not null;comment:客户画像ID(Mongo _id hex)" json:"client_id"` + Wxid string `gorm:"column:wxid;not null;comment:客户微信ID" json:"wxid"` + SelfWxid string `gorm:"column:self_wxid;not null;comment:销售本人微信ID" json:"self_wxid"` + AppId string `gorm:"column:app_id;not null;comment:销售登录设备appid" json:"app_id"` + Title string `gorm:"column:title;not null;comment:提醒标题" json:"title"` + Detail string `gorm:"column:detail;comment:提醒详情" json:"detail"` + DueAt time.Time `gorm:"column:due_at;not null;comment:到点时间" json:"due_at"` + Status string `gorm:"column:status;not null;default:pending;comment:状态" json:"status"` + Source string `gorm:"column:source;not null;default:ai;comment:来源 ai/manual" json:"source"` + SourceMsgIds string `gorm:"column:source_msg_ids;comment:来源消息ID(逗号分隔)" json:"source_msg_ids"` + Result string `gorm:"column:result;comment:实际发送内容" json:"result"` + ErrMsg string `gorm:"column:err_msg;not null;comment:失败原因" json:"err_msg"` + DoneAt *time.Time `gorm:"column:done_at;comment:完成/取消时间" json:"done_at"` + 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"` +} + +func (*AiAdviceRemindTask) TableName() string { return TableNameAiAdviceRemindTask } + +// ===== 成交线索表 ===== + +const TableNameAiAdviceDealLead = "ai_advice_deal_lead" + +// AiAdviceDealLead 成交线索(约定到访/成交/付款,供销售跟进并注入后续对话)。mapped from +type AiAdviceDealLead struct { + Id int64 `gorm:"column:id;primaryKey;autoIncrement:true" json:"id"` + ProjectId int32 `gorm:"column:project_id;not null;comment:项目ID" json:"project_id"` + AdvicerId int32 `gorm:"column:advicer_id;not null;comment:所属销售ID" json:"advicer_id"` + ClientId string `gorm:"column:client_id;not null;comment:客户画像ID(Mongo _id hex)" json:"client_id"` + Wxid string `gorm:"column:wxid;not null;comment:客户微信ID" json:"wxid"` + SelfWxid string `gorm:"column:self_wxid;not null;comment:销售本人微信ID" json:"self_wxid"` + AppId string `gorm:"column:app_id;not null;comment:销售登录设备appid" json:"app_id"` + LeadType string `gorm:"column:lead_type;not null;comment:线索类型 visit/deal/payment" json:"lead_type"` + Title string `gorm:"column:title;not null;comment:线索标题" json:"title"` + Detail string `gorm:"column:detail;comment:线索详情" json:"detail"` + ExpectAt time.Time `gorm:"column:expect_at;not null;comment:期望/约定时间" json:"expect_at"` + Status string `gorm:"column:status;not null;default:pending;comment:状态 pending/done/cancelled" json:"status"` + Source string `gorm:"column:source;not null;default:ai;comment:来源 ai/manual" json:"source"` + SourceMsgIds string `gorm:"column:source_msg_ids;comment:来源消息ID(逗号分隔)" json:"source_msg_ids"` + Result string `gorm:"column:result;comment:跟进结果" json:"result"` + DoneAt *time.Time `gorm:"column:done_at;comment:完成/取消时间" json:"done_at"` + 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"` +} + +func (*AiAdviceDealLead) TableName() string { return TableNameAiAdviceDealLead } diff --git a/internal/data/mongo_model/advicer_chat_his.go b/internal/data/mongo_model/advicer_chat_his.go index 79a4560..c026559 100644 --- a/internal/data/mongo_model/advicer_chat_his.go +++ b/internal/data/mongo_model/advicer_chat_his.go @@ -40,7 +40,19 @@ func (a *AdvicerChatHisMongo) Entity() AdvicerChatHisMongoEntity { } type Assistant struct { - Result string `json:"result"` - MissionStatus string `json:"mission_status"` - MissionCompleteDesc string `json:"mission_complete_desc"` + Result string `json:"result"` + Mission string `json:"mission,omitempty"` + MissionStatus string `json:"mission_status"` + MissionCompleteDesc string `json:"mission_complete_desc"` + // Remind / Lead 为对话内联识别的可选结果(宁缺毋滥,未命中时模型不输出,解析后为 nil)。 + Remind *FollowHint `json:"remind,omitempty"` // 到点提醒/回访约定(入库后由 cron RunDue 到点执行) + Lead *FollowHint `json:"lead,omitempty"` // 成交线索(到访/成交/付款约定,供销售跟进) +} + +// FollowHint 对话内联抽取的时间约定单条(remind 忽略 Type;lead 的 Type 取 visit/deal/payment)。 +type FollowHint struct { + Type string `json:"type,omitempty"` + Title string `json:"title,omitempty"` + Detail string `json:"detail,omitempty"` + DueAt string `json:"due_at,omitempty"` // 约定时间,格式 2006-01-02 15:04 } diff --git a/internal/data/mongo_model/advicer_client.go b/internal/data/mongo_model/advicer_client.go index 665cc39..4378163 100644 --- a/internal/data/mongo_model/advicer_client.go +++ b/internal/data/mongo_model/advicer_client.go @@ -1,9 +1,17 @@ package mongo_model import ( + "encoding/json" + "strings" "time" ) +// 画像动态栏目约定键(后端按栏目名读取,销售在新编辑器中以同名栏目维护) +const ( + SectionKeyClientName = "姓名" + SectionKeyBirthday = "生日" +) + // 客户等级 const ( ClientLevelUnknown = "" // 未评估 @@ -30,20 +38,18 @@ type AdvicerClientMongo struct { Wxid string `json:"wxid" bson:"wxid"` // 客户微信 wxid(回调唯一标识) AppId string `json:"appId" bson:"appId"` // 绑定的登录实例 appId ClientLevel string `json:"clientLevel" bson:"clientLevel"` // 客户等级:regular/intent/sleeping/non + Mission string `json:"mission" bson:"mission"` // 当前对话任务(由对话 AI 回传并回写,展示在客户列表) LevelReason string `json:"levelReason" bson:"levelReason"` // 等级判定理由(评估产出) LevelUpdateAt time.Time `json:"levelUpdateAt" bson:"levelUpdateAt"` // 等级最后评估时间 LastContactAt time.Time `json:"lastContactAt" bson:"lastContactAt"` // 最后互动时间(任意方向) LastClientMsgAt time.Time `json:"lastClientMsgAt" bson:"lastClientMsgAt"` // 最后客户消息时间 LastReplyAt time.Time `json:"lastReplyAt" bson:"lastReplyAt"` // 最后回复客户时间 - MsgCount int `json:"msgCount" bson:"msgCount"` // 累计互动消息数 - AiEnabled bool `json:"aiEnabled" bson:"aiEnabled"` // 是否允许 AI 参与对话 - LastIterateAt time.Time `json:"lastIterateAt" bson:"lastIterateAt"` // 画像最后迭代时间(对话完成自我升级游标) - PersonalInfo PersonalInfo `json:"personalInfo" bson:"personalInfo"` - PurchasePurpose PurchasePurpose `json:"purchasePurpose" bson:"purchasePurpose"` - CoreDemands CoreDemands `json:"coreDemands" bson:"coreDemands"` - Concerns []string `json:"concerns" bson:"concerns"` - DecisionProfile []string `json:"decisionProfile" bson:"decisionProfile"` - LastUpdateTime time.Time `json:"lastUpdateTime" bson:"lastUpdateTime"` + MsgCount int `json:"msgCount" bson:"msgCount"` // 累计互动消息数 + AiEnabled bool `json:"aiEnabled" bson:"aiEnabled"` // 是否允许 AI 参与对话 + LastIterateAt time.Time `json:"lastIterateAt" bson:"lastIterateAt"` + // Profile 客户画像动态栏目(栏目名 → 内容,内容为 string/[]interface{}/map,与 advicer_project_data 同构) + Profile map[string]interface{} `json:"profile" bson:"profile"` + LastUpdateTime time.Time `json:"lastUpdateTime" bson:"lastUpdateTime"` } func NewAdvicerClientMongo() *AdvicerClientMongo { @@ -54,21 +60,53 @@ func (a *AdvicerClientMongo) MongoTableName() string { return "advicer_client" } -type AdvicerClientMongoEntity struct { - PersonalInfo PersonalInfo `json:"personalInfo"` - PurchasePurpose PurchasePurpose `json:"purchasePurpose"` - CoreDemands CoreDemands `json:"coreDemands"` - Concerns []string `json:"concerns"` - DecisionProfile []string `json:"decisionProfile"` +// AdvicerClientMongoEntity 客户画像的统一表示:动态栏目 map(栏目名 → 内容)。 +// 采用类型别名,使既有消费点(JSON 序列化注入 prompt / ChatData)无需改动。 +type AdvicerClientMongoEntity = map[string]interface{} + +// Entity 返回画像栏目(供 AI 注入与提示词拼装);nil 时返回空 map。 +func (a *AdvicerClientMongo) Entity() AdvicerClientMongoEntity { + if a.Profile == nil { + return AdvicerClientMongoEntity{} + } + return a.Profile } -func (a *AdvicerClientMongo) Entity() *AdvicerClientMongoEntity { - return &AdvicerClientMongoEntity{ - PersonalInfo: a.PersonalInfo, - PurchasePurpose: a.PurchasePurpose, - CoreDemands: a.CoreDemands, - Concerns: a.Concerns, - DecisionProfile: a.DecisionProfile, +// SectionValue 按栏目名读取栏目内容:先精确匹配,再做去空格/包含式模糊匹配。 +func (a *AdvicerClientMongo) SectionValue(name string) interface{} { + name = strings.TrimSpace(name) + if a.Profile == nil || name == "" { + return nil + } + if v, ok := a.Profile[name]; ok { + return v + } + for k, v := range a.Profile { + key := strings.TrimSpace(k) + if key == "" { + continue + } + if strings.Contains(key, name) || strings.Contains(name, key) { + return v + } + } + return nil +} + +// SectionString 按栏目名读取字符串值;非字符串值返回其 JSON 串(供展示)。 +func (a *AdvicerClientMongo) SectionString(name string) string { + v := a.SectionValue(name) + if v == nil { + return "" + } + switch x := v.(type) { + case string: + return strings.TrimSpace(x) + default: + if b, e := json.Marshal(x); e == nil { + return string(b) + } + return "" } } diff --git a/internal/entitys/advicer.go b/internal/entitys/advicer.go index 93e0402..a60201c 100644 --- a/internal/entitys/advicer.go +++ b/internal/entitys/advicer.go @@ -6,7 +6,8 @@ import ( ) type ChatData struct { - ClientInfo *mongo_model.AdvicerClientMongoEntity `json:"clientInfo"` + ClientInfo mongo_model.AdvicerClientMongoEntity `json:"clientInfo"` // 客户画像动态栏目 + ClientWxid string `json:"clientWxid"` // 客户微信 wxid(用于查询并注入未闭环的成交线索) TalkSkill *mongo_model.AdvicerTalkSkillMongoEntity `json:"talkSkill"` ProjectInfo *mongo_model.AdvicerProjectMongoEntity `json:"projectInfo"` ProjectData map[string]interface{} `json:"projectData"` // advicer_project_data 扁平栏目数据 diff --git a/internal/entitys/advicer_data.go b/internal/entitys/advicer_data.go index ec536a7..3694ea3 100644 --- a/internal/entitys/advicer_data.go +++ b/internal/entitys/advicer_data.go @@ -219,27 +219,20 @@ type AdvicerProjectDataGenerateReq struct { } type AdvicerClientAddReq struct { - ProjectId int32 `json:"projectId" bson:"projectId"` - AdvicerId int32 `json:"advicerId" bson:"advicerId"` - Wxid string `json:"wxid" bson:"wxid"` // 客户微信 wxid(可选,用于关联回调消息) - AiEnabled *bool `json:"aiEnabled" bson:"aiEnabled"` // 是否允许 AI 参与对话(不传默认开启) - PersonalInfo mongo_model.PersonalInfo `json:"personalInfo" bson:"personalInfo"` - PurchasePurpose mongo_model.PurchasePurpose `json:"purchasePurpose" bson:"purchasePurpose"` - CoreDemands mongo_model.CoreDemands `json:"coreDemands" bson:"coreDemands"` - Concerns []string `json:"concerns" bson:"concerns"` - DecisionProfile []string `json:"decisionProfile" bson:"decisionProfile"` + ProjectId int32 `json:"projectId" bson:"projectId"` + AdvicerId int32 `json:"advicerId" bson:"advicerId"` + Wxid string `json:"wxid" bson:"wxid"` // 客户微信 wxid(可选,用于关联回调消息) + AppId string `json:"appId" bson:"appId"` // 登录实例 appId(按 wxid 建档时携带) + AiEnabled *bool `json:"aiEnabled" bson:"aiEnabled"` // 是否允许 AI 参与对话(不传默认开启) + Profile map[string]interface{} `json:"profile" bson:"profile"` // 客户画像动态栏目 } type AdvicerrClientUpdateReq struct { - Id string `json:"id"` - ProjectId int32 `json:"projectId" bson:"projectId"` - AdvicerId int32 `json:"advicerId" bson:"advicerId"` - AiEnabled *bool `json:"aiEnabled" bson:"aiEnabled"` // 是否允许 AI 参与对话(不传保持原值) - PersonalInfo mongo_model.PersonalInfo `json:"personalInfo" bson:"personalInfo"` - PurchasePurpose mongo_model.PurchasePurpose `json:"purchasePurpose" bson:"purchasePurpose"` - CoreDemands mongo_model.CoreDemands `json:"coreDemands" bson:"coreDemands"` - Concerns []string `json:"concerns" bson:"concerns"` - DecisionProfile []string `json:"decisionProfile" bson:"decisionProfile"` + Id string `json:"id"` + ProjectId int32 `json:"projectId" bson:"projectId"` + AdvicerId int32 `json:"advicerId" bson:"advicerId"` + AiEnabled *bool `json:"aiEnabled" bson:"aiEnabled"` // 是否允许 AI 参与对话(不传保持原值) + Profile map[string]interface{} `json:"profile" bson:"profile"` // 客户画像动态栏目(全量覆盖) } type AdvicerClientListReq struct { @@ -271,6 +264,9 @@ type AdvicerChatRegistRes struct { type AdvicerChatReq struct { SessionId string `json:"sessionId"` Content string `json:"content"` + // ClientWxid 客户微信 wxid:托管链路每轮必传,作为 chatdata 快照(Redis 1h TTL)过期后 + // remind/lead 落库与成交线索注入的兜底数据源;web 对话靠 Regis 新鲜快照可不传。 + ClientWxid string `json:"clientWxid"` } type AdvicerChatRes struct { @@ -393,6 +389,14 @@ type AdvicerIterateRunReq struct { Force bool `json:"force"` // 忽略对话完成后闲置等待(手动触发立即迭代) } +// AdvicerProfileRegenerateReq 画像更新(基于旧画像+聊天记录重新生成动态栏目;clientId/wxid 至少传一) +type AdvicerProfileRegenerateReq struct { + ClientId string `json:"clientId"` + Wxid string `json:"wxid"` + ProjectId int32 `json:"projectId"` + AppId string `json:"appId"` +} + // AdvicerIterateRunRes 画像迭代结果 type AdvicerIterateRunRes struct { Scanned int `json:"scanned"` // 扫描到的未分析消息数 @@ -473,6 +477,57 @@ type AdvicerProactiveLogListReq struct { PageSize int `json:"page_size"` } +// ==================== 跟进线索(时间约定) ==================== + +// AdvicerRemindListReq 定时提醒列表查询 +type AdvicerRemindListReq struct { + ProjectId int32 `json:"projectId"` // 项目隔离 + ClientId string `json:"clientId"` + Wxid string `json:"wxid"` + Status string `json:"status"` // pending/done/cancelled/failed,为空则全部 + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +// AdvicerRemindCreateReq 手动新增定时提醒(客户可单选:传 wxid(托管客户)或 clientId) +type AdvicerRemindCreateReq struct { + ClientId string `json:"clientId" zh:"客户ID"` + Wxid string `json:"wxid" zh:"客户微信ID"` + ProjectId int32 `json:"projectId" zh:"项目ID"` + AppId string `json:"appId" zh:"登录实例ID"` + Title string `json:"title" zh:"标题"` + Detail string `json:"detail" zh:"详情"` + DueAt string `json:"dueAt" validate:"required" zh:"到点时间"` // 2006-01-02 15:04 +} + +// AdvicerDealLeadListReq 成交线索列表查询 +type AdvicerDealLeadListReq struct { + ProjectId int32 `json:"projectId"` // 项目隔离 + ClientId string `json:"clientId"` + Wxid string `json:"wxid"` + Status string `json:"status"` // pending/done/cancelled,为空则全部 + LeadType string `json:"leadType"` // visit/deal/payment,为空则全部 + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +// AdvicerDealLeadCreateReq 手动新增成交线索(客户可单选:传 wxid(托管客户)或 clientId) +type AdvicerDealLeadCreateReq struct { + ClientId string `json:"clientId" zh:"客户ID"` + Wxid string `json:"wxid" zh:"客户微信ID"` + ProjectId int32 `json:"projectId" zh:"项目ID"` + AppId string `json:"appId" zh:"登录实例ID"` + LeadType string `json:"leadType" validate:"required" zh:"线索类型"` // visit/deal/payment + Title string `json:"title" zh:"标题"` + Detail string `json:"detail" zh:"详情"` + ExpectAt string `json:"expectAt" validate:"required" zh:"约定时间"` // 2006-01-02 15:04 +} + +// AdvicerFollowIdReq 提醒/线索 取消、完成通用 ID 请求 +type AdvicerFollowIdReq struct { + Id int64 `json:"id" validate:"required" zh:"ID"` +} + // ==================== 微信好友(ai_advice_customer) ==================== // AdvicerCustomerAddReq 新增微信好友 @@ -546,11 +601,25 @@ type AdvicerCustomerHostedListReq struct { ProjectId int32 `json:"projectId" validate:"required" zh:"项目ID"` } -// HostedCustomerItem 托管客户条目(包含客户信息 + 标签名 + 销售名) +// AdvicerCustomerUpdateNoteReq 更新客户备注(存入 description 字段,不会被微信同步覆盖) +type AdvicerCustomerUpdateNoteReq struct { + CustomerId int32 `json:"customerId" validate:"required" zh:"客户ID"` + Description string `json:"description" zh:"客户备注"` +} + +// AdvicerCustomerRemoveLabelReq 将客户从托管标签中移除(微信 API + DB 同步) +type AdvicerCustomerRemoveLabelReq struct { + ProjectId int32 `json:"projectId" validate:"required" zh:"项目ID"` + CustomerId int32 `json:"customerId" validate:"required" zh:"客户ID"` +} + +// HostedCustomerItem 托管客户条目(包含客户信息 + 标签名 + 销售名 + 归属销售ID) type HostedCustomerItem struct { model.AiAdviceCustomer - LabelNames string `json:"label_names"` // 托管标签名称(逗号分隔) - AdvicerName string `json:"advicer_name"` // 归属销售姓名 + LabelNames string `json:"label_names"` // 托管标签名称(逗号分隔) + AdvicerName string `json:"advicer_name"` // 归属销售姓名 + AdvicerWxDeviceId string `json:"advicer_wx_device_id"` // 归属销售的微信设备 appId + AdvicerId int32 `json:"advicer_id"` // 归属销售数字ID(客户画像自动归属) } // ==================== 标签同步 ==================== diff --git a/internal/jobs/advice_task.go b/internal/jobs/advice_task.go index a13dff4..41be43a 100644 --- a/internal/jobs/advice_task.go +++ b/internal/jobs/advice_task.go @@ -14,14 +14,16 @@ import ( // AdviceTaskJob 数字销售定时任务 // 1. 客户等级评估:每日 daily_evaluate_hour 点(默认 0 点)评估熟客/意向/沉睡/非客户; // 2. 熟客超时兜底:每 2 分钟扫描「人工超时未回复」的熟客,执行礼貌性回复与简单解答; -// 3. 对话完成画像迭代:每 10 分钟扫描闲置结束的对话,分析聊天记录完善客户画像; -// 4. 主动触达:每小时扫描生日/新活动,AI 主动发起对话(受活跃时段与总开关限制)。 +// 3. 对话完成画像迭代:每 10 分钟扫描闲置结束的对话,分析聊天记录完善客户画像(同时抽取时间约定→跟进线索); +// 4. 主动触达:每小时扫描生日/新活动,AI 主动发起对话(受活跃时段与总开关限制); +// 5. 跟进线索到点执行:每分钟扫描到期约定,remind_customer 由 AI 提醒客户、offline_arrival 转后台待办。 type AdviceTaskJob struct { cfg *config.Config evaluateBiz *biz.AdviceEvaluateBiz strategyBiz *biz.AdviceStrategyBiz proactiveBiz *biz.AdviceProactiveBiz iterateBiz *biz.AdviceIterateBiz + followBiz *biz.AdviceFollowBiz cron *cron.Cron } @@ -31,6 +33,7 @@ func NewAdviceTaskJob( strategyBiz *biz.AdviceStrategyBiz, proactiveBiz *biz.AdviceProactiveBiz, iterateBiz *biz.AdviceIterateBiz, + followBiz *biz.AdviceFollowBiz, ) *AdviceTaskJob { return &AdviceTaskJob{ cfg: cfg, @@ -38,6 +41,7 @@ func NewAdviceTaskJob( strategyBiz: strategyBiz, proactiveBiz: proactiveBiz, iterateBiz: iterateBiz, + followBiz: followBiz, // SkipIfStillRunning:上一轮未完成时跳过本轮,避免任务堆积 cron: cron.New(cron.WithChain(cron.SkipIfStillRunning(cron.DefaultLogger))), } @@ -87,6 +91,17 @@ func (j *AdviceTaskJob) Start() { return } + // 5. 跟进线索到点执行:每分钟扫描到期约定(AI 提醒客户 / 线下转后台待办) + if j.followBiz != nil { + if _, err := j.cron.AddFunc("* * * * *", func() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + _, _ = j.followBiz.RunDue(ctx) + }); err != nil { + return + } + } + j.cron.Start() } diff --git a/internal/pkg/rds.go b/internal/pkg/rds.go index 9bfd9cc..1f327b4 100644 --- a/internal/pkg/rds.go +++ b/internal/pkg/rds.go @@ -5,6 +5,7 @@ import ( "time" "github.com/redis/go-redis/v9" + "github.com/redis/go-redis/v9/maintnotifications" ) type Rdb struct { @@ -43,6 +44,9 @@ func buildRdb(c config.Redis) *redis.Client { MinIdleConns: int(c.MaxIdle), ConnMaxIdleTime: time.Duration(c.MaxIdleTime) * time.Second, DB: int(c.Db), + // 显式关闭维护通知探测:兼容不支持 CLIENT MAINT_NOTIFICATIONS 的 Redis/代理, + // 避免启动时刷 "auto mode fallback: maintnotifications disabled" 告警 + MaintNotificationsConfig: &maintnotifications.Config{Mode: maintnotifications.ModeDisabled}, }) return rdb } diff --git a/internal/server/router/advicer.go b/internal/server/router/advicer.go index 289d17a..3166425 100644 --- a/internal/server/router/advicer.go +++ b/internal/server/router/advicer.go @@ -98,12 +98,22 @@ func AdvicerRouterRegist( advicer.Post("smart/reply/test", smart.ReplyTest) // 策略干跑预览(send=true 实际发送) advicer.Post("smart/reply", smart.Reply) // 对客户最新消息手动执行策略回复 advicer.Post("smart/regular/scan", smart.RegularScan) // 熟客超时兜底扫描 - advicer.Post("smart/iterate/run", smart.IterateRun) // 手动触发画像迭代 + advicer.Post("smart/iterate/run", smart.IterateRun) // 手动触发画像迭代 + advicer.Post("smart/profile/regenerate", smart.ProfileRegenerate) // 画像更新(旧画像+聊天记录重新生成动态栏目) advicer.Post("smart/proactive/scan", smart.ProactiveScan) // 主动触达扫描(生日/活动) advicer.Post("smart/proactive/send", smart.ProactiveSend) // 手动推送给指定客户 advicer.Post("smart/activity/push", smart.ActivityPush) // 活动立即群推 advicer.Post("smart/proactive/log", smart.ProactiveLog) // 主动触达记录查询 + // 时间约定(复盘抽取,两表分开存):定时提醒 + 成交线索 + advicer.Post("smart/remind/list", smart.RemindList) // 定时提醒列表 + advicer.Post("smart/remind/create", smart.RemindCreate) // 新增定时提醒 + advicer.Post("smart/remind/cancel", smart.RemindCancel) // 取消定时提醒 + advicer.Post("smart/lead/list", smart.LeadList) // 成交线索列表 + advicer.Post("smart/lead/create", smart.LeadCreate) // 新增成交线索 + advicer.Post("smart/lead/cancel", smart.LeadCancel) // 取消成交线索 + advicer.Post("smart/lead/complete", smart.LeadComplete) // 成交线索标记完成 + //会话 advicer.Post("chat/regis", adviceChat.Regis) advicer.Post("chat/chat", adviceChat.Chat) @@ -133,6 +143,8 @@ func AdvicerRouterRegist( advicer.Post("advicer/customer/batch_add", adviceCustomer.BatchAdd) advicer.Post("advicer/customer/batch_update", adviceCustomer.BatchUpdate) advicer.Post("advicer/customer/update_labels", adviceCustomer.UpdateLabels) + advicer.Post("advicer/customer/update_note", adviceCustomer.UpdateNote) + advicer.Post("advicer/customer/remove_label", adviceCustomer.RemoveLabel) advicer.Post("advicer/customer/hosted", adviceCustomer.HostedList) // 微信标签(ai_advice_label) diff --git a/internal/services/advice/chat.go b/internal/services/advice/chat.go index 54d9863..1f025ca 100644 --- a/internal/services/advice/chat.go +++ b/internal/services/advice/chat.go @@ -109,6 +109,7 @@ func (a *ChatService) Regis(c *fiber.Ctx) error { } chat := entitys.ChatData{ ClientInfo: clientInfo.Entity(), + ClientWxid: clientInfo.Wxid, TalkSkill: talkSkill.Entity(), ProjectInfo: projectInfo.ConfigInfo.Entity(), AdvicerInfo: advicerInfo.Entity(), diff --git a/internal/services/advice/customer_new.go b/internal/services/advice/customer_new.go index 796343e..db894f4 100644 --- a/internal/services/advice/customer_new.go +++ b/internal/services/advice/customer_new.go @@ -151,15 +151,20 @@ func (s *CustomerService) HostedList(c *fiber.Ctx) error { return pkg.HandleResponse(c, nil, err) } - // 构建 selfWxid -> advicerName 映射,收集所有 selfWxid + // 构建 selfWxid -> advicerName / wx_device_id / advicer_id 映射,收集所有 selfWxid // 关联逻辑:advicer.wx_id = ai_advice_customer.self_wxid advicerMap := make(map[string]string) + wxDeviceMap := make(map[string]string) + advicerIdMap := make(map[string]int32) var selfWxids []string for _, adv := range advicers { wxId, _ := adv["wx_id"].(string) name, _ := adv["name"].(string) + deviceId, _ := adv["wx_device_id"].(string) if wxId != "" { advicerMap[wxId] = name + wxDeviceMap[wxId] = deviceId + advicerIdMap[wxId] = toAdvicerId(adv["advicer_id"]) selfWxids = append(selfWxids, wxId) } } @@ -169,6 +174,7 @@ func (s *CustomerService) HostedList(c *fiber.Ctx) error { // 2. 获取每个销售的托管标签 ID,构建 selfWxid -> hostingLabelIds 和 labelId -> labelName 映射 hostingLabelsMap := make(map[string]map[int]bool) + hostingLabelIdsMap := make(map[string][]int) labelNameMap := make(map[int]string) for _, selfWxid := range selfWxids { hostIds, _ := s.labelBiz.GetHostingLabelIds(ctx, selfWxid) @@ -179,6 +185,7 @@ func (s *CustomerService) HostedList(c *fiber.Ctx) error { for _, id := range hostIds { hostingLabelsMap[selfWxid][id] = true } + hostingLabelIdsMap[selfWxid] = hostIds labels, _ := s.labelBiz.List(ctx, &entitys.AdvicerLabelListReq{SelfWxid: selfWxid}) for _, l := range labels { labelNameMap[int(l.LabelId)] = l.LabelName @@ -188,19 +195,19 @@ func (s *CustomerService) HostedList(c *fiber.Ctx) error { return pkg.HandleResponse(c, []entitys.HostedCustomerItem{}, nil) } - // 3. 查询这些 selfWxid 下的所有客户,筛选出在托管标签下的 - allCustomers, err := s.customerBiz.FindBySelfWxids(ctx, selfWxids) + // 3. 在 SQL 层用 FIND_IN_SET 直接查出命中各销售托管标签的客户(不再拉全量到内存过滤) + hostedCustomers, err := s.customerBiz.FindBySelfWxidHostingLabels(ctx, hostingLabelIdsMap) if err != nil { return pkg.HandleResponse(c, nil, err) } - var result []entitys.HostedCustomerItem - for _, cust := range allCustomers { + result := make([]entitys.HostedCustomerItem, 0, len(hostedCustomers)) + for _, cust := range hostedCustomers { hostLabels, ok := hostingLabelsMap[cust.SelfWxid] if !ok { continue } - // 判断客户的 label_list 与托管标签是否有交集 + // 托管标签列只展示命中该销售托管开关的标签名 var matchedNames []string for _, ls := range strings.Split(cust.LabelList, ",") { id, e := strconv.Atoi(strings.TrimSpace(ls)) @@ -214,11 +221,107 @@ func (s *CustomerService) HostedList(c *fiber.Ctx) error { continue } result = append(result, entitys.HostedCustomerItem{ - AiAdviceCustomer: cust, - LabelNames: strings.Join(matchedNames, ","), - AdvicerName: advicerMap[cust.SelfWxid], + AiAdviceCustomer: cust, + LabelNames: strings.Join(matchedNames, ","), + AdvicerName: advicerMap[cust.SelfWxid], + AdvicerWxDeviceId: wxDeviceMap[cust.SelfWxid], + AdvicerId: advicerIdMap[cust.SelfWxid], }) } return pkg.HandleResponse(c, result, nil) } + +// UpdateNote 更新客户备注(写入 description 字段,不会被微信同步覆盖) +func (s *CustomerService) UpdateNote(c *fiber.Ctx) error { + req := &entitys.AdvicerCustomerUpdateNoteReq{} + if err := c.BodyParser(req); err != nil { + return err + } + err := s.customerBiz.UpdateNote(c.UserContext(), req) + return pkg.HandleResponse(c, nil, err) +} + +// RemoveLabel 将客户从托管标签中移除(微信 API 全量替换 + DB 同步) +// 移除后客户不再命中托管标签交集,即退出托管自动回复范围 +func (s *CustomerService) RemoveLabel(c *fiber.Ctx) error { + req := &entitys.AdvicerCustomerRemoveLabelReq{} + if err := c.BodyParser(req); err != nil { + return err + } + ctx := c.UserContext() + + // 1. 查客户记录 + cust, err := s.customerBiz.FindByCustomerId(ctx, req.CustomerId) + if err != nil { + return pkg.HandleResponse(c, nil, fiber.NewError(fiber.StatusBadRequest, "客户记录不存在")) + } + + // 2. 取该销售的托管标签 ID,从客户 label_list 中剔除 + hostIds, err := s.labelBiz.GetHostingLabelIds(ctx, cust.SelfWxid) + if err != nil { + return pkg.HandleResponse(c, nil, err) + } + hostSet := make(map[int]bool, len(hostIds)) + for _, id := range hostIds { + hostSet[id] = true + } + remain := make([]string, 0) + for _, ls := range strings.Split(cust.LabelList, ",") { + s2 := strings.TrimSpace(ls) + if s2 == "" { + continue + } + if id, e := strconv.Atoi(s2); e == nil && hostSet[id] { + continue + } + remain = append(remain, s2) + } + newLabelList := strings.Join(remain, ",") + + // 3. 取销售的微信设备 appId 与项目 wx_token,调微信 API 全量替换好友标签 + adv, err := s.advicerBiz.FindByWxId(ctx, cust.SelfWxid) + if err != nil || adv.WxDeviceId == "" { + return pkg.HandleResponse(c, nil, fiber.NewError(fiber.StatusBadRequest, "未找到该客户归属销售的微信设备")) + } + 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")) + } + wxReq := wx.ModifyMemberListReq{ + AppId: adv.WxDeviceId, + LabelIds: newLabelList, + WxIds: []string{cust.UserName}, + } + var wxRes wx.ModifyMemberListResData + if err := wx.Request(ctx, project.WxToken, wx.ModifyMemberList, wxReq, &wxRes); err != nil { + return pkg.HandleResponse(c, nil, fmt.Errorf("微信标签修改失败: %w", err)) + } + + // 4. 同步 DB + if err := s.customerBiz.UpdateLabelListById(ctx, req.CustomerId, newLabelList); err != nil { + return pkg.HandleResponse(c, nil, fmt.Errorf("数据库更新失败: %w", err)) + } + return pkg.HandleResponse(c, fiber.Map{"label_list": newLabelList}, nil) +} + +// toAdvicerId 将 GetRange 返回 map 中的 advicer_id(主键,驱动可能扫为 int64/int32/int/float64)统一转为 int32。 +func toAdvicerId(v interface{}) int32 { + switch n := v.(type) { + case int32: + return n + case int64: + return int32(n) + case int: + return int32(n) + case uint32: + return int32(n) + case float64: + return int32(n) + default: + return 0 + } +} diff --git a/internal/services/advice/smart.go b/internal/services/advice/smart.go index fd4fa2d..c3bc936 100644 --- a/internal/services/advice/smart.go +++ b/internal/services/advice/smart.go @@ -14,6 +14,7 @@ type SmartService struct { StrategyBiz *biz.AdviceStrategyBiz ProactiveBiz *biz.AdviceProactiveBiz IterateBiz *biz.AdviceIterateBiz + FollowBiz *biz.AdviceFollowBiz cfg *config.Config } @@ -21,12 +22,14 @@ func NewSmartService( strategyBiz *biz.AdviceStrategyBiz, proactiveBiz *biz.AdviceProactiveBiz, iterateBiz *biz.AdviceIterateBiz, + followBiz *biz.AdviceFollowBiz, cfg *config.Config, ) *SmartService { return &SmartService{ StrategyBiz: strategyBiz, ProactiveBiz: proactiveBiz, IterateBiz: iterateBiz, + FollowBiz: followBiz, cfg: cfg, } } @@ -67,6 +70,16 @@ func (s *SmartService) IterateRun(c *fiber.Ctx) error { return pkg.HandleResponse(c, res, err) } +// ProfileRegenerate 画像更新(基于旧画像+聊天记录重新生成动态栏目;clientId/wxid 至少传一) +func (s *SmartService) ProfileRegenerate(c *fiber.Ctx) error { + req := &entitys.AdvicerProfileRegenerateReq{} + if err := c.BodyParser(req); err != nil { + return err + } + res, err := s.IterateBiz.RegenerateProfile(c.UserContext(), req) + return pkg.HandleResponse(c, res, err) +} + // ProactiveScan 主动触达扫描(生日/活动;force=true 时忽略总开关与活跃时段) func (s *SmartService) ProactiveScan(c *fiber.Ctx) error { req := &entitys.AdvicerProactiveRunReq{} @@ -106,3 +119,73 @@ func (s *SmartService) ProactiveLog(c *fiber.Ctx) error { list, err := s.ProactiveBiz.LogList(c.UserContext(), req) return pkg.HandleResponse(c, list, err) } + +// RemindList 定时提醒列表查询 +func (s *SmartService) RemindList(c *fiber.Ctx) error { + req := &entitys.AdvicerRemindListReq{} + if err := c.BodyParser(req); err != nil { + return err + } + list, err := s.FollowBiz.RemindList(c.UserContext(), req) + return pkg.HandleResponse(c, list, err) +} + +// RemindCreate 手动新增定时提醒 +func (s *SmartService) RemindCreate(c *fiber.Ctx) error { + req := &entitys.AdvicerRemindCreateReq{} + if err := c.BodyParser(req); err != nil { + return err + } + id, err := s.FollowBiz.RemindCreate(c.UserContext(), req) + return pkg.HandleResponse(c, fiber.Map{"id": id}, err) +} + +// RemindCancel 取消定时提醒 +func (s *SmartService) RemindCancel(c *fiber.Ctx) error { + req := &entitys.AdvicerFollowIdReq{} + if err := c.BodyParser(req); err != nil { + return err + } + err := s.FollowBiz.RemindCancel(c.UserContext(), req.Id) + return pkg.HandleResponse(c, nil, err) +} + +// LeadList 成交线索列表查询 +func (s *SmartService) LeadList(c *fiber.Ctx) error { + req := &entitys.AdvicerDealLeadListReq{} + if err := c.BodyParser(req); err != nil { + return err + } + list, err := s.FollowBiz.LeadList(c.UserContext(), req) + return pkg.HandleResponse(c, list, err) +} + +// LeadCreate 手动新增成交线索 +func (s *SmartService) LeadCreate(c *fiber.Ctx) error { + req := &entitys.AdvicerDealLeadCreateReq{} + if err := c.BodyParser(req); err != nil { + return err + } + id, err := s.FollowBiz.LeadCreate(c.UserContext(), req) + return pkg.HandleResponse(c, fiber.Map{"id": id}, err) +} + +// LeadCancel 取消成交线索 +func (s *SmartService) LeadCancel(c *fiber.Ctx) error { + req := &entitys.AdvicerFollowIdReq{} + if err := c.BodyParser(req); err != nil { + return err + } + err := s.FollowBiz.LeadCancel(c.UserContext(), req.Id) + return pkg.HandleResponse(c, nil, err) +} + +// LeadComplete 将成交线索标记为已完成(销售手动闭环) +func (s *SmartService) LeadComplete(c *fiber.Ctx) error { + req := &entitys.AdvicerFollowIdReq{} + if err := c.BodyParser(req); err != nil { + return err + } + err := s.FollowBiz.LeadComplete(c.UserContext(), req.Id) + return pkg.HandleResponse(c, nil, err) +} diff --git a/server b/server index 68c693c..a38f82d 100755 Binary files a/server and b/server differ diff --git a/sql/09_ai_advice_remind_task.sql b/sql/09_ai_advice_remind_task.sql new file mode 100644 index 0000000..c4fc535 --- /dev/null +++ b/sql/09_ai_advice_remind_task.sql @@ -0,0 +1,25 @@ +CREATE TABLE `ai_advice_remind_task` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '自增主键', + `project_id` int NOT NULL DEFAULT '0' COMMENT '项目ID', + `advicer_id` int NOT NULL DEFAULT '0' COMMENT '所属销售ID', + `client_id` varchar(64) NOT NULL DEFAULT '' COMMENT '客户画像ID(Mongo advicer_client _id hex)', + `wxid` varchar(64) NOT NULL DEFAULT '' COMMENT '客户微信ID', + `self_wxid` varchar(64) NOT NULL DEFAULT '' COMMENT '销售本人微信ID', + `app_id` varchar(64) NOT NULL DEFAULT '' COMMENT '销售登录设备 appid(发消息用)', + `title` varchar(255) NOT NULL DEFAULT '' COMMENT '提醒标题', + `detail` text COMMENT '提醒详情/原文摘录', + `due_at` datetime NOT NULL COMMENT '到点时间(绝对时间)', + `status` varchar(20) NOT NULL DEFAULT 'pending' COMMENT '状态:pending 待执行 / done 已发送 / cancelled 已取消 / failed 执行失败', + `source` varchar(20) NOT NULL DEFAULT 'ai' COMMENT '来源:ai 复盘抽取 / manual 手动补录', + `source_msg_ids` text COMMENT '来源消息ID列表(逗号分隔,便于追溯)', + `result` text COMMENT '实际发送给客户的内容', + `err_msg` varchar(500) NOT NULL DEFAULT '' COMMENT '失败原因', + `done_at` datetime DEFAULT NULL COMMENT '完成/取消时间', + `create_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_status_due` (`status`,`due_at`), + KEY `idx_wxid` (`wxid`), + KEY `idx_client` (`client_id`), + KEY `idx_project` (`project_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='定时提醒(到点由 AI 主动发消息提醒客户)'; diff --git a/sql/10_ai_advice_deal_lead.sql b/sql/10_ai_advice_deal_lead.sql new file mode 100644 index 0000000..229fd8d --- /dev/null +++ b/sql/10_ai_advice_deal_lead.sql @@ -0,0 +1,25 @@ +CREATE TABLE `ai_advice_deal_lead` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '自增主键', + `project_id` int NOT NULL DEFAULT '0' COMMENT '项目ID', + `advicer_id` int NOT NULL DEFAULT '0' COMMENT '所属销售ID', + `client_id` varchar(64) NOT NULL DEFAULT '' COMMENT '客户画像ID(Mongo advicer_client _id hex)', + `wxid` varchar(64) NOT NULL DEFAULT '' COMMENT '客户微信ID', + `self_wxid` varchar(64) NOT NULL DEFAULT '' COMMENT '销售本人微信ID', + `app_id` varchar(64) NOT NULL DEFAULT '' COMMENT '销售登录设备 appid', + `lead_type` varchar(20) NOT NULL DEFAULT '' COMMENT '线索类型:visit 约定到访 / deal 约定成交 / payment 约定付款', + `title` varchar(255) NOT NULL DEFAULT '' COMMENT '线索标题', + `detail` text COMMENT '线索详情/原文摘录', + `expect_at` datetime NOT NULL COMMENT '期望/约定时间(绝对时间)', + `status` varchar(20) NOT NULL DEFAULT 'pending' COMMENT '状态:pending 待跟进 / done 已完成 / cancelled 已取消', + `source` varchar(20) NOT NULL DEFAULT 'ai' COMMENT '来源:ai 复盘抽取 / manual 手动补录', + `source_msg_ids` text COMMENT '来源消息ID列表(逗号分隔,便于追溯)', + `result` text COMMENT '跟进结果', + `done_at` datetime DEFAULT NULL COMMENT '完成/取消时间', + `create_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_status_expect` (`status`,`expect_at`), + KEY `idx_wxid` (`wxid`), + KEY `idx_client` (`client_id`), + KEY `idx_project` (`project_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='成交线索(约定到访/成交/付款,供销售跟进并注入后续对话)'; diff --git a/tmp_full_inject.go b/tmp_full_inject.go deleted file mode 100644 index 4fd66cd..0000000 --- a/tmp_full_inject.go +++ /dev/null @@ -1,196 +0,0 @@ -//go:build ignore - -package main - -import ( - "os" - "strings" -) - -func main() { - f := "internal/biz/advice_chat.go" - b, _ := os.ReadFile(f) - s := string(b) - ok := true - - // === 1. Regis: 存 ChatData 到 Redis === - old1 := ` err = a.rdb.Rdb.SetEx(ctx, sessionId, promptJson, 3600*time.Second).Err() - - return sessionId, err -}` - new1 := ` err = a.rdb.Rdb.SetEx(ctx, sessionId, promptJson, 3600*time.Second).Err() - // 存储 ChatData 供后续 Chat 全量注入提示词 - if chatDataJson, e := json.Marshal(chatData); e == nil { - _ = a.rdb.Rdb.SetEx(ctx, "chatdata:"+sessionId, string(chatDataJson), 3600*time.Second).Err() - } - return sessionId, err -}` - if strings.Contains(s, old1) { - s = strings.Replace(s, old1, new1, 1) - println("1. Regis: ChatData store added") - } else { - println("1. ERROR: Regis block not found") - ok = false - } - - // === 2. Chat: 从 Redis 取 ChatData,传给 buildChatPromptResponse === - old2 := ` prompt, err := a.buildChatPromptResponse(ctx, chat, &session, chatHis) - if err != nil { - return assistant, err - } - log.Infof("[提示词] sessionId=%s prompt=%s", session.SessionID, pkg.JsonStringIgonErr(prompt)) - resContent, err := a.callLlmResponse(ctx, prompt, modelInfo.Key, modelInfo.URL, modelInfo.ChatModel, session.ContextCache)` - new2 := ` var chatData entitys.ChatData - if cdStr, e := a.rdb.Rdb.Get(ctx, "chatdata:"+session.SessionID).Result(); e == nil { - _ = json.Unmarshal([]byte(cdStr), &chatData) - } - prompt, err := a.buildChatPromptResponse(ctx, chat, &session, chatHis, &chatData) - if err != nil { - return assistant, err - } - log.Infof("[提示词] sessionId=%s prompt=%s", session.SessionID, pkg.JsonStringIgonErr(prompt)) - resContent, err := a.callLlmResponse(ctx, prompt, modelInfo.Key, modelInfo.URL, modelInfo.ChatModel, session.ContextCache)` - if strings.Contains(s, old2) { - s = strings.Replace(s, old2, new2, 1) - println("2. Chat: ChatData load + pass added") - } else { - println("2. ERROR: Chat block not found") - ok = false - } - - // === 3. ChatWithSession: 同样从 Redis 取 ChatData === - old3 := ` prompt, err := a.buildChatPromptResponse(ctx, chat, session, chatHis) - if err != nil { - return assistant, err - } - log.Infof("[提示词] sessionId=%s prompt=%s", session.SessionID, pkg.JsonStringIgonErr(prompt)) - resContent, err := a.callLlmResponse(ctx, prompt, modelInfo.Key, modelInfo.URL, modelInfo.ChatModel, session.ContextCache)` - new3 := ` var chatData entitys.ChatData - if cdStr, e := a.rdb.Rdb.Get(ctx, "chatdata:"+session.SessionID).Result(); e == nil { - _ = json.Unmarshal([]byte(cdStr), &chatData) - } - prompt, err := a.buildChatPromptResponse(ctx, chat, session, chatHis, &chatData) - if err != nil { - return assistant, err - } - log.Infof("[提示词] sessionId=%s prompt=%s", session.SessionID, pkg.JsonStringIgonErr(prompt)) - resContent, err := a.callLlmResponse(ctx, prompt, modelInfo.Key, modelInfo.URL, modelInfo.ChatModel, session.ContextCache)` - if strings.Contains(s, old3) { - s = strings.Replace(s, old3, new3, 1) - println("3. ChatWithSession: ChatData load + pass added") - } else { - println("3. ERROR: ChatWithSession block not found") - ok = false - } - - // === 4. buildChatPromptResponse: 全量数据注入 === - old4 := `func (a *AdviceChatBiz) buildChatPromptResponse(ctx context.Context, chat *entitys.AdvicerChatReq, session *dbmodel.AiAdviceSession, chatList []mongo_model.AdvicerChatHisMongoEntity) ([]openai.ResponseInputMessage, error) { - // 输出格式指令:模型使用 response_format=json_object 时,prompt 必须包含 "json" 关键词, - // 同时明确告诉模型返回 JSON 结构,便于下游 json.Unmarshal 解析为 Assistant。 - const outputFormatHint = "\n[输出格式]请用 json 格式返回你的回复,结构为 {\"result\":\"回复内容\",\"mission_status\":\"in_progress|completed|fail\",\"mission_complete_desc\":\"本轮进展小结\"}。" - message := []openai.ResponseInputMessage{ - { - Role: openai.ChatMessageRoleSystem, - Content: a.taskPrompt(session) + outputFormatHint, - }, - { - Role: openai.ChatMessageRoleSystem, - Content: formatChatHis(chatList), - }, - { - Role: openai.ChatMessageRoleUser, - Content: chat.Content, - }, - } - return message, nil -}` - new4 := `func (a *AdviceChatBiz) buildChatPromptResponse(ctx context.Context, chat *entitys.AdvicerChatReq, session *dbmodel.AiAdviceSession, chatList []mongo_model.AdvicerChatHisMongoEntity, chatData *entitys.ChatData) ([]openai.ResponseInputMessage, error) { - const outputFormatHint = "\n[输出格式]请用 json 格式返回你的回复,结构为 {\"result\":\"回复内容\",\"mission_status\":\"in_progress|completed|fail\",\"mission_complete_desc\":\"本轮进展小结\"}。" - var sys strings.Builder - // 基础规则 + 任务 + 风控红线 - sys.WriteString(constants.BasePrompt) - if session.Mission != "" { - sys.WriteString(session.Mission) - sys.WriteString("\n") - } - if chatData != nil && chatData.RuleDimension != "" { - sys.WriteString(constants.RulePromptPrefix) - sys.WriteString(chatData.RuleDimension) - sys.WriteString("\n") - } - sys.WriteString(constants.BasePrompt2) - // 全量结构化数据注入 - if chatData != nil { - if chatData.ProjectInfo != nil { - sys.WriteString("\n[项目信息]\n") - sys.WriteString(pkg.JsonStringIgonErr(chatData.ProjectInfo)) - } - if chatData.AdvicerInfo != nil { - sys.WriteString("\n[销售信息]\n") - sys.WriteString(pkg.JsonStringIgonErr(chatData.AdvicerInfo)) - } - if chatData.AdvicerVersion != nil && len(chatData.AdvicerVersion) > 0 { - sys.WriteString("\n[销售人设风格]\n") - sys.WriteString(pkg.JsonStringIgonErr(chatData.AdvicerVersion)) - } - if chatData.ClientInfo != nil { - sys.WriteString("\n[客户信息]\n") - sys.WriteString(pkg.JsonStringIgonErr(chatData.ClientInfo)) - } - if chatData.TalkSkill != nil { - sys.WriteString("\n[销售技巧]\n") - sys.WriteString(pkg.JsonStringIgonErr(chatData.TalkSkill)) - } - } - sys.WriteString(outputFormatHint) - message := []openai.ResponseInputMessage{ - { - Role: openai.ChatMessageRoleSystem, - Content: sys.String(), - }, - { - Role: openai.ChatMessageRoleSystem, - Content: formatChatHis(chatList), - }, - { - Role: openai.ChatMessageRoleUser, - Content: chat.Content, - }, - } - return message, nil -}` - if strings.Contains(s, old4) { - s = strings.Replace(s, old4, new4, 1) - println("4. buildChatPromptResponse: full data injection done") - } else { - println("4. ERROR: buildChatPromptResponse block not found") - ok = false - } - - // === 5. 删掉 taskPrompt 中的 session.Mission 注入(避免重复,已在 buildChatPromptResponse 中处理)=== - old5 := ` // 注入 mission(含人格 + 项目信息),确保每次 Chat 模型都能看到 - if session.Mission != "" { - b.WriteString("\n" + session.Mission) - } - return b.String() -} - -func (a *AdviceChatBiz) buildBasePromptResponse` - new5 := ` return b.String() -} - -func (a *AdviceChatBiz) buildBasePromptResponse` - if strings.Contains(s, old5) { - s = strings.Replace(s, old5, new5, 1) - println("5. taskPrompt: removed redundant mission injection") - } else { - println("5. SKIP: taskPrompt already clean") - } - - if ok { - os.WriteFile(f, []byte(s), 0644) - println("\nALL DONE - advice_chat.go updated") - } else { - println("\nFAILED - some blocks not found") - } -} diff --git a/utils/rds.go b/utils/rds.go index 874eb3e..e734fa2 100644 --- a/utils/rds.go +++ b/utils/rds.go @@ -5,6 +5,7 @@ import ( "time" "github.com/redis/go-redis/v9" + "github.com/redis/go-redis/v9/maintnotifications" ) type Rdb struct { @@ -43,6 +44,9 @@ func buildRdb(c *config.Redis) *redis.Client { MinIdleConns: int(c.MaxIdle), ConnMaxIdleTime: time.Duration(c.MaxIdleTime) * time.Second, DB: int(c.Db), + // 显式关闭维护通知探测:兼容不支持 CLIENT MAINT_NOTIFICATIONS 的 Redis/代理, + // 避免启动时刷 "auto mode fallback: maintnotifications disabled" 告警 + MaintNotificationsConfig: &maintnotifications.Config{Mode: maintnotifications.ModeDisabled}, }) return rdb } diff --git a/web/assets/js/project.js b/web/assets/js/project.js index 3028405..a8c4b1a 100644 --- a/web/assets/js/project.js +++ b/web/assets/js/project.js @@ -86,6 +86,8 @@ document.addEventListener("DOMContentLoaded", function () { if (VIEWS[savedView] && VIEWS[savedView].needProject && (!state.project || !state.project.projectId)) { state.view = "projectDetail"; } + // 「聊天记录分析」已从侧边栏隐藏:历史残留视图回退到数据中心 + if (state.view === "analysis") state.view = "data"; refreshProjectBadge(); updateNavVisibility(); if (Core.store(Core.KEYS.token)) showApp(); else showLogin(); diff --git a/web/assets/js/project_data.js b/web/assets/js/project_data.js index bc1deb3..08321e1 100644 --- a/web/assets/js/project_data.js +++ b/web/assets/js/project_data.js @@ -3,7 +3,9 @@ var DATA_TABS = [ ["advicers", "\u9500\u552e\u4eba\u5458"], ["versions", "\u6570\u5b57\u4eba\u7ba1\u7406"], ["skills", "\u804a\u5929\u6280\u5de7"], - ["clients", "\u5ba2\u6237"] + ["clients", "客户"], + ["leads", "成交线索"], + ["reminds", "定时提醒"] ]; function renderData() { @@ -27,7 +29,7 @@ function renderData() { i.classList.toggle("active", match); }); // 更新顶栏标题 - var tabMeta = { advicers: "销售管理", versions: "数字人管理", skills: "聊天技巧", clients: "客户管理" }; + var tabMeta = { advicers: "销售管理", versions: "数字人管理", skills: "聊天技巧", clients: "客户管理", leads: "成交线索", reminds: "定时提醒" }; $("#pageTitle").textContent = tabMeta[st.tab] || "数据中心"; $("#pageSub").textContent = "管理当前项目下的" + (tabMeta[st.tab] || "数据"); } @@ -47,6 +49,8 @@ function renderDataBody() { if (tab === "versions") return renderTabVersions(body); if (tab === "skills") return renderTabSkills(body); if (tab === "clients") return renderTabClients(body); + if (tab === "leads") return renderTabLeads(body); + if (tab === "reminds") return renderTabReminds(body); console.warn("[renderDataBody] unknown tab: " + tab); } @@ -972,106 +976,33 @@ function skillAiGenerateDialog(parentModal, skDims, skItems) { } /* ---------- Tab 4:客户 ---------- */ -var CLIENT_SUB_TABS = [["profiles", "\u5ba2\u6237\u753b\u50cf"], ["hosted", "\u6258\u7ba1\u5ba2\u6237"]]; - function renderTabClients(body) { - console.log("[renderTabClients] called, subTab=" + (state.data.clientSubTab || "profiles")); - var subTab = state.data.clientSubTab || "profiles"; + // 客户 tab 现仅展示「托管客户」;客户画像改为在托管客户行内「查看画像」按钮进入 body.innerHTML = - '
\u5ba2\u6237' + - '\u5ba2\u6237\u753b\u50cf\u4e0e\u6258\u7ba1\u5ba2\u6237\u7ba1\u7406' + - '
' + - '
' + - CLIENT_SUB_TABS.map(function (t) { - return '
' + t[1] + '
'; - }).join('') + '
' + + '
托管客户' + + '处于托管标签分组内的微信好友;点击「查看画像」查看并维护该客户的 AI 客户画像
' + '
'; - // 使用 body 内部查询(body 可能脱离 document,document.querySelector 找不到) - var addBtn = body.querySelector("#cli-add"); - var tabsEl = body.querySelector("#cli-sub-tabs"); - if (tabsEl) { - Array.prototype.slice.call(tabsEl.children).forEach(function (t) { - t.onclick = function () { - state.data.clientSubTab = t.dataset.sub; - Array.prototype.slice.call(tabsEl.children).forEach(function (x) { x.classList.toggle("active", x === t); }); - if (addBtn) addBtn.style.display = (t.dataset.sub === "profiles") ? "" : "none"; - if (t.dataset.sub === "profiles") { loadClients(listBox); } - else { loadHostedCustomers(listBox); } - }; - }); - } - if (addBtn) addBtn.onclick = function () { clientForm(null); }; - var listBox = body.querySelector("#cli-list"); - if (subTab === "profiles") { loadClients(listBox); } - else { if (addBtn) addBtn.style.display = "none"; loadHostedCustomers(listBox); } + loadHostedCustomers(body.querySelector("#cli-list")); } -function loadClients(box) { - if (!box) box = document.querySelector("#cli-list"); - if (!box) return; - box.innerHTML = '
'; +/* ---------- 查看/维护托管客户的 AI 客户画像(client 表按 wxid 关联,动态栏目编辑器) ---------- */ +function viewClientProfile(c) { + if (!c) return; + var wxid = c.user_name || ""; + var name = c.remark || c.nick_name || c.user_name || "客户"; + var appId = c.advicer_wx_device_id || ""; + var advicerId = c.advicer_id || 0; + var advicerName = c.advicer_name || ""; + if (!wxid) { Core.toast("该客户缺少微信ID,无法关联画像", "err"); return; } Core.api("client/list", { projectId: state.project.projectId }).then(function (list) { list = list || []; - state.data.clients = list; - if (!list.length) { - box.innerHTML = '
◉
暂无客户,可手动新增或从「数据分析 → 客户维度」保存
'; - return; - } - box.innerHTML = '
' + - "" + - list.map(function (c) { - var pi = c.personalInfo || {}; - var cd = c.coreDemands || {}; - var lv = c.clientLevel || ""; - var lvDesc = CLIENT_LEVELS[lv] || "未评估"; - var lvCls = CLIENT_LEVEL_CLS[lv] || "gray"; - var lvTip = c.levelReason || "暂无评估理由(每晚 0 点自动评估)"; - if (!isZeroTime(c.levelUpdateAt)) lvTip += "\n更新:" + Core.fmtTime(c.levelUpdateAt); - var wxCell = c.wxid - ? '' + Core.esc(c.wxid) + "" - : ''; - return "" + - '" + - '" + - '" + - "" + - '" + - '" + - '" + - ''; - }).join("") + "
ID姓名客户等级微信预算偏好销售操作
' + Core.esc(String(c.id).slice(-8)) + "' + Core.esc(pi.name || "-") + "" + - (pi.gender ? '' + Core.esc(pi.gender) + "" : "") + "' + Core.esc(lvDesc) + "" + wxCell + "' + Core.esc(cd.totalBudget || "-") + "' + Core.esc(cd.preferredLayout || "-") + "#' + c.advicerId + "' + - '' + - (c.wxid ? '' : "") + - '
"; - $$(".cli-edit").forEach(function (b) { - b.onclick = function () { - var item = list.filter(function (x) { return String(x.id) === b.dataset.id; })[0]; - clientForm(item); - }; + var profile = list.filter(function (x) { return x.wxid === wxid; })[0] || null; + if (profile) { clientProfileForm(profile, { name: name, wxid: wxid, appId: appId, advicerId: advicerId, advicerName: advicerName }); return; } + Core.confirm("客户「" + name + "」暂无 AI 画像,是否现在手动新建一份?(也可直接点「画像更新」由聊天记录自动生成)").then(function (ok) { + if (!ok) return; + clientProfileForm(null, { name: name, wxid: wxid, appId: appId, advicerId: advicerId, advicerName: advicerName }); }); - $$(".cli-del").forEach(function (b) { - b.onclick = function () { - Core.confirm("确定删除该客户?").then(function (ok) { - if (!ok) return; - Core.api("client/del", { id: b.dataset.id }) - .then(function () { Core.toast("已删除", "ok"); loadClients(); }) - .catch(function (e) { Core.toast(e.message, "err"); }); - }); - }; - }); - $$(".cli-bind").forEach(function (b) { - b.onclick = function () { bindClientDialog(b.dataset.id, b.dataset.name); }; - }); - $$(".cli-eval").forEach(function (b) { - b.onclick = function () { evaluateClient(b.dataset.id, b.dataset.name, b); }; - }); - $$(".cli-iterate").forEach(function (b) { - b.onclick = function () { iterateClient(b.dataset.id, b.dataset.name, b); }; - }); - }).catch(function (e) { - box.innerHTML = '
⚠
' + Core.esc(e.message) + "
"; - }); + }).catch(function (e) { Core.toast(e.message, "err"); }); } /* ---------- 客户等级常量 / 微信绑定 / 等级评估 ---------- */ @@ -1081,15 +1012,22 @@ function loadHostedCustomers(box) { if (!box) box = document.querySelector("#cli-list"); if (!box) return; box.innerHTML = '
'; - Core.api("advicer/customer/hosted", { projectId: state.project.projectId }).then(function (list) { - list = list || []; + var pid = state.project.projectId; + Promise.all([ + Core.api("advicer/customer/hosted", { projectId: pid }), + Core.api("client/list", { projectId: pid }).catch(function () { return []; }) + ]).then(function (arr) { + var list = arr[0] || []; + // 客户画像按 wxid 建索引,用于展示等级评估结果 + var profMap = {}; + (arr[1] || []).forEach(function (x) { if (x.wxid) profMap[x.wxid] = x; }); if (!list.length) { box.innerHTML = '
\u25c9
\u5c1a\u65e0\u6258\u7ba1\u5ba2\u6237\uff0c\u5f00\u542f\u6258\u7ba1\u6807\u7b7e\u540e\u6d88\u606f\u56de\u8c03\u65f6\u81ea\u52a8\u540c\u6b65
'; return; } box.innerHTML = '
\u5171 ' + list.length + ' \u4f4d\u6258\u7ba1\u5ba2\u6237\uff08\u5904\u4e8e\u6258\u7ba1\u6807\u7b7e\u5206\u7ec4\u5185\u7684\u5fae\u4fe1\u597d\u53cb\uff0c\u6bcf\u6b21\u6d88\u606f\u56de\u8c03\u65f6\u81ea\u52a8\u540c\u6b65\u6700\u65b0\u4fe1\u606f\uff09
' + '
' + - '' + + '' + list.map(function (c) { var displayName = c.remark || c.nick_name || c.user_name || "-"; var avatar = c.big_head_img_url || c.small_head_img_url || ""; @@ -1105,6 +1043,25 @@ function loadHostedCustomers(box) { var labelHtml = labels.map(function (l) { return '' + Core.esc(l) + ''; }).join(" "); + var prof = profMap[c.user_name]; + var levelCell = '-'; + if (prof) { + if (prof.clientLevel) { + var lvDesc = CLIENT_LEVELS[prof.clientLevel] || prof.clientLevel; + var lvCls = CLIENT_LEVEL_CLS[prof.clientLevel] || "gray"; + var lvTip = prof.levelReason || ""; + if (!isZeroTime(prof.levelUpdateAt)) lvTip += (lvTip ? "\n" : "") + "更新:" + Core.fmtTime(prof.levelUpdateAt); + levelCell = '' + Core.esc(lvDesc) + ''; + } else { + levelCell = '未评估'; + } + } + // 当前任务:对话 AI 回写的简洁 mission,展示在客户列表 + var missionCell = '-'; + if (prof && prof.mission) { + var ms = String(prof.mission); + missionCell = '' + Core.esc(ms.length > 14 ? ms.slice(0, 14) + "…" : ms) + ''; + } return "" + '' + '' + '' + '' + + '' + + '' + '' + - ''; + '' + + ''; }).join("") + "
\u5934\u50cf\u6635\u79f0\u5907\u6ce8\u6258\u7ba1\u6807\u7b7e\u6027\u522b\u5730\u533a\u5f52\u5c5e\u9500\u552e\u4f1a\u8bdd\u72b6\u6001\u66f4\u65b0\u65f6\u95f4
头像昵称微信备注托管标签性别地区归属销售客户等级当前任务会话状态更新时间操作
' + avatarCell + '' + Core.esc(displayName) + '' + @@ -1114,13 +1071,87 @@ function loadHostedCustomers(box) { '' + sex + '' + Core.esc(area) + '' + Core.esc(c.advicer_name || "-") + '' + levelCell + '' + missionCell + '' + sessionStatus + '' + Core.fmtTime(c.update_at) + '
' + Core.fmtTime(c.update_at) + '' + + '' + + '' + + '' + + '
"; + var byId = {}; + list.forEach(function (c) { byId[c.customer_id] = c; }); + $$('.hc-profile', box).forEach(function (b) { b.onclick = function () { viewClientProfile(byId[b.dataset.cid]); }; }); + $$('.hc-chat', box).forEach(function (b) { b.onclick = function () { viewHostedChat(byId[b.dataset.cid]); }; }); + $$('.hc-remove', box).forEach(function (b) { b.onclick = function () { removeHostedCustomer(byId[b.dataset.cid]); }; }); }).catch(function (e) { box.innerHTML = '
\u26a0
' + Core.esc(e.message) + '
'; }); } + +/* ---------- 托管客户操作:聊天记录 / 移除 ---------- */ + +// 将客户从托管标签中移除(微信 API + DB 同步) +function removeHostedCustomer(c) { + if (!c) return; + var name = c.remark || c.nick_name || c.user_name || "该客户"; + Core.confirm("将客户「" + name + "」从托管标签中移除?移除后不再命中托管分组,也不会触发 AI 自动回复。").then(function (ok) { + if (!ok) return; + Core.api("advicer/customer/remove_label", { projectId: state.project.projectId, customerId: c.customer_id }) + .then(function () { Core.toast("已从托管标签移除", "ok"); loadHostedCustomers(); }) + .catch(function (e) { Core.toast(e.message, "err"); }); + }); +} + +// 查看客户聊天记录(只读,来自微信消息流水) +function viewHostedChat(c) { + if (!c) return; + var name = c.remark || c.nick_name || c.user_name || "客户"; + var m = Core.modal({ + title: "聊天记录 · " + name, + width: "wide", + body: '
' + }); + var box = $("#hc-chat", m.root); + Core.api("wx/conversation/msgs", { selfWxid: c.self_wxid, wxid: c.user_name, page_size: 200 }) + .then(function (list) { + list = list || []; + if (!list.length) { + box.innerHTML = '
\u2726
与「' + Core.esc(name) + '」暂无聊天记录
'; + return; + } + box.innerHTML = list.map(function (msg) { + var isSelf = msg.direction === "self" || msg.direction === "ai"; + var tag = msg.direction === "ai" ? "AI" : msg.direction === "self" ? "销售" : "客户"; + var avatarHtml = msg.direction === "ai" + ? '
\u2726
' + : '
' + Core.esc(Core.initial(isSelf ? tag : name)) + '
'; + var bubbleClass = isSelf ? "msg-bubble conv-msg-self" : "msg-bubble conv-msg-other"; + return '
' + avatarHtml + + '
' + hostedChatContent(msg) + '
' + + '
' + tag + ' · ' + Core.esc(Core.fmtTime(msg.createAt || msg.create_at)) + '
'; + }).join(""); + box.scrollTop = box.scrollHeight; + }) + .catch(function (e) { box.innerHTML = '
\u26a0
' + Core.esc(e.message) + '
'; }); +} + +function hostedChatContent(msg) { + var content = msg.content || ""; + switch (msg.msgType) { + case "text": return Core.esc(content); + case "image": return '\ud83d\udcf7 [图片]'; + case "emoji": return '\ud83d\ude0a [表情]'; + case "voice": return '\ud83c\udfa4 [语音]'; + case "video": return '\ud83c\udfac [视频]'; + case "location": return '\ud83d\udccd [位置]'; + case "namecard": return '\ud83d\udc64 [名片]'; + case "app_msg": return '\ud83d\udd17 [链接/小程序]'; + default: return '[' + Core.esc(msg.msgType || "消息") + ']'; + } +} var CLIENT_LEVELS = { regular: "熟客", intent: "意向客户", sleeping: "沉睡客户", non: "非客户" }; var CLIENT_LEVEL_CLS = { regular: "purple", intent: "green", sleeping: "yellow", non: "gray" }; @@ -1128,28 +1159,7 @@ function isZeroTime(t) { return !t || String(t).indexOf("0001-01-01") === 0; } -function bindClientDialog(id, name) { - var m = Core.modal({ - title: "绑定微信" + (name ? " · " + name : ""), - body: - '
' + - '
' + - '
' + - '
' + - '
绑定后该微信发来的消息自动关联本客户;客户画像迭代、每晚等级评估与 AI 主动对话均依赖绑定关系。
', - footer: '' - }); - $(".mf-cancel", m.root).onclick = m.close; - $(".mf-save", m.root).onclick = function () { - var wxid = $("#cb-wxid", m.root).value.trim(); - if (!wxid) { Core.toast("请输入 wxid", "err"); return; } - Core.api("client/bind", { id: id, wxid: wxid, appId: $("#cb-appid", m.root).value.trim() }) - .then(function () { m.close(); Core.toast("绑定成功", "ok"); loadClients(); }) - .catch(function (e) { Core.toast(e.message, "err"); }); - }; -} - -function evaluateClient(id, name, btn) { +function evaluateClient(id, name, btn, onDone) { Core.confirm("对客户「" + (name || "未命名") + "」执行等级评估?(AI 将分析近期聊天记录判定等级)").then(function (ok) { if (!ok) return; var oldText = btn ? btn.textContent : ""; @@ -1160,7 +1170,7 @@ function evaluateClient(id, name, btn) { if (res && res.async) { Core.toast(res.msg || "评估任务已启动,请稍后刷新", "ok"); return; } var d = res && res.details && res.details[0]; Core.toast(d ? "评估完成:" + (d.levelDesc || d.level) + " · " + (d.reason || "") : "评估完成", "ok"); - loadClients(); + if (onDone) onDone(); }) .catch(function (e) { if (btn) { btn.textContent = oldText; btn.disabled = false; } @@ -1169,7 +1179,7 @@ function evaluateClient(id, name, btn) { }); } -function iterateClient(id, name, btn) { +function iterateClient(id, name, btn, onDone) { Core.confirm("对客户「" + (name || "未命名") + "」立即执行画像迭代?(AI 将再次分析近期聊天记录,自动新建/完善客户画像)").then(function (ok) { if (!ok) return; var oldText = btn ? btn.textContent : ""; @@ -1180,7 +1190,7 @@ function iterateClient(id, name, btn) { res = res || {}; var detail = (res.details || [])[0] || ""; Core.toast("迭代完成:扫描 " + (res.scanned || 0) + " 条未分析消息·" + (detail || "画像无新增信息"), "ok"); - loadClients(); + if (onDone) onDone(); }) .catch(function (e) { if (btn) { btn.textContent = oldText; btn.disabled = false; } @@ -1189,81 +1199,139 @@ function iterateClient(id, name, btn) { }); } -function clientForm(item) { +/* 客户画像动态栏目编辑器(对齐「项目资料」编辑方式);支持画像更新(旧画像+聊天记录重生成) */ +function clientProfileForm(item, ctx) { var d = item || {}; + ctx = ctx || {}; var isEdit = !!d.id; - var pi = d.personalInfo || {}; - var pp = d.purchasePurpose || {}; - var cd = d.coreDemands || {}; - var concerns = (d.concerns || []).join("\n"); - var decisions = (d.decisionProfile || []).join("\n"); + var wxid = d.wxid || ctx.wxid || ""; + var appId = d.appId || ctx.appId || ""; + // 归属销售:画像自动归属于客户来源的销售(编辑态取已存 advicerId,新建取来源托管客户的 advicer_id) + var advicerId = Number(d.advicerId || ctx.advicerId || 0); + + // 动态栏目数据源:从画像 profile(栏目名→内容)还原 + state._cpSections = buildSectionsFromData(d.profile || {}); + var m = Core.modal({ - title: isEdit ? "编辑客户" : "新增客户", + title: isEdit ? ("客户画像 · " + (ctx.name || "")) : ("新建客户画像" + (ctx.name ? " · " + ctx.name : "")), width: "wide", body: - '
' + - '
' + - '
' + - '
' + - "
" + - '
' + - '
' + - '
' + - '
" + - "
" + - '
' + - '
' + - '
' + - '
' + - "
" + - '
' + - '
' + - '
' + - '
' + - "
" + - '
' + - '
" + - '
" + - "
" + - '
', - footer: '' + '
归属销售:' + Core.esc(ctx.advicerName || (advicerId ? ("#" + advicerId) : "未关联")) + '(画像自动归属于该客户来源的销售,无需手动选择)
' + + '
' + + '按「项目资料」方式维护客户画像栏目;后端按约定栏目名「姓名」「生日」读取,用于称呼与生日关怀。' + + '' + + '' + + '
' + + '
', + footer: + '' + + (isEdit ? '' : '') + + '' }); - loadAdvicerOptions($("#cf-advicer", m.root), d.advicerId || state.data.advicerId); - initAllDatePickers(m.root); - $(".mf-cancel", m.root).onclick = m.close; + + // 将共享编辑器指向本弹窗(数据源=state._cpSections,容器=弹窗内 #pji-sections) + setPjiEditorTarget(m.root, function () { return state._cpSections; }, function (v) { state._cpSections = v; }); + drawPjiSections(); + + var closeProfile = function () { setPjiEditorTarget(null); m.close(); loadHostedCustomers(); }; + + $("#cp-add-section", m.root).onclick = function () { addPjiSectionDialog(); }; + $(".mf-cancel", m.root).onclick = closeProfile; + var mask = m.root.closest(".modal-mask"); + if (mask) mask.querySelector(".x").onclick = closeProfile; + + // 绑定 等级评估/删除(仅编辑态或后端建档后补挂) + function bindEditActions() { + var be = $(".cp-eval", m.root); + if (be && !be._bound) { be._bound = true; be.onclick = function () { evaluateClient(d.id, ctx.name || "", be, closeProfile); }; } + var bd = $(".mf-del", m.root); + if (bd && !bd._bound) { bd._bound = true; bd.onclick = function () { + Core.confirm("确定删除该客户画像?").then(function (ok) { + if (!ok) return; + Core.api("client/del", { id: d.id }).then(function () { closeProfile(); Core.toast("已删除画像", "ok"); }) + .catch(function (e) { Core.toast(e.message, "err"); }); + }); + }; } + } + if (isEdit) bindEditActions(); + + // 新建/重生成后画像变为已存在:补齐 等级评估/删除 按钮 + function refreshEditActions() { + if (!isEdit) return; + var foot = m.root.querySelector(".modal-foot"); + if (foot && !foot.querySelector(".cp-eval")) { + var cancelBtn = foot.querySelector(".mf-cancel"); + var evalBtn = document.createElement("button"); + evalBtn.className = "btn ghost cp-eval"; evalBtn.textContent = "等级评估"; + var delBtn = document.createElement("button"); + delBtn.className = "btn danger mf-del"; delBtn.textContent = "删除画像"; + if (cancelBtn) { foot.insertBefore(evalBtn, cancelBtn); foot.insertBefore(delBtn, cancelBtn); } + else { foot.appendChild(evalBtn); foot.appendChild(delBtn); } + } + bindEditActions(); + } + + // 重新按 id/wxid 取回最新画像并就地重绘(不关闭弹窗) + function reloadProfileIntoDialog() { + Core.api("client/list", { projectId: state.project.projectId }).then(function (list) { + list = list || []; + var found = list.filter(function (x) { + return (d.id && String(x.id) === String(d.id)) || (wxid && x.wxid === wxid); + })[0]; + if (!found) { Core.toast("未取回最新画像,请重新打开", "err"); return; } + d = found; isEdit = !!d.id; wxid = d.wxid || wxid; appId = d.appId || appId; + advicerId = Number(d.advicerId || advicerId || 0); + state._cpSections = buildSectionsFromData(d.profile || {}); + drawPjiSections(); + refreshEditActions(); + Core.toast("已载入最新画像", "ok"); + }).catch(function (e) { Core.toast(e.message, "err"); }); + } + + // 画像更新:基于旧画像 + 聊天记录重新生成(clientId 优先,否则按 wxid 自动建档) + var regenBtn = $(".cp-regen", m.root); + if (regenBtn) regenBtn.onclick = function () { + if (!d.id && !wxid) { Core.toast("请先保存画像后再更新", "err"); return; } + Core.confirm("将结合客户旧画像与最近聊天记录重新生成画像栏目(会覆盖当前编辑内容),是否继续?").then(function (ok) { + if (!ok) return; + syncPjiSectionsFromDom(); + var req = d.id + ? { clientId: d.id } + : { wxid: wxid, appId: appId, projectId: state.project.projectId }; + var oldText = regenBtn.textContent; + regenBtn.disabled = true; regenBtn.textContent = "更新中…"; + Core.api("smart/profile/regenerate", req).then(function (res) { + regenBtn.disabled = false; regenBtn.textContent = oldText; + res = res || {}; + Core.toast((res.details && res.details[0]) || "画像更新完成", "ok"); + reloadProfileIntoDialog(); + }).catch(function (e) { + regenBtn.disabled = false; regenBtn.textContent = oldText; + Core.toast(e.message, "err"); + }); + }); + }; + + // 保存:将栏目展平为 {栏目名: content} 的 profile 全量覆盖 $(".mf-save", m.root).onclick = function () { - var advicerId = Number($("#cf-advicer", m.root).value); - if (!advicerId) { Core.toast("请选择归属销售", "err"); return; } - var toLines = function (id) { - return $(id, m.root).value.split("\n").map(function (s) { return s.trim(); }).filter(function (s) { return s; }); - }; - var payload = { - projectId: state.project.projectId, - advicerId: advicerId, - personalInfo: { - name: $("#cf-name", m.root).value.trim(), - gender: $("#cf-gender", m.root).value.trim(), - location: $("#cf-loc", m.root).value.trim(), - isFirstHome: $("#cf-first", m.root).checked, - familyOrganize: $("#cf-family", m.root).value.trim(), - birthday: $("#cf-birthday", m.root).value - }, - purchasePurpose: { - primaryPurpose: $("#cf-p1", m.root).value.trim(), - secondaryPurpose: $("#cf-p2", m.root).value.trim(), - decisionMakers: $("#cf-dm", m.root).value.trim() - }, - coreDemands: { - totalBudget: $("#cf-budget", m.root).value.trim(), - preferredLayout: $("#cf-layout", m.root).value.trim(), - coreAppeal: $("#cf-appeal", m.root).value.trim() - }, - concerns: toLines("#cf-concerns"), - decisionProfile: toLines("#cf-decisions") - }; + if (!advicerId) { Core.toast("未能确定归属销售(该客户未关联销售),无法保存画像", "err"); return; } + var sections = collectPjiSectionsData(); + var profile = {}; + sections.forEach(function (s) { + var nm = (s.label || s.key || "").trim(); + if (!nm) return; + var c = s.content; + // 跳过空白栏目 + if (c === "" || c == null) return; + if (Array.isArray(c) && !c.length) return; + if (c && typeof c === "object" && !Array.isArray(c) && !Object.keys(c).length) return; + profile[nm] = c; + }); + var payload = { projectId: state.project.projectId, advicerId: advicerId, profile: profile }; if (isEdit) payload.id = d.id; + else { payload.wxid = wxid; payload.appId = appId; } Core.api(isEdit ? "client/update" : "client/add", payload) - .then(function () { m.close(); Core.toast("保存成功", "ok"); loadClients(); }) + .then(function () { closeProfile(); Core.toast("保存成功", "ok"); }) .catch(function (e) { Core.toast(e.message, "err"); }); }; } @@ -1322,3 +1390,234 @@ function enterWxWorkbench(advicerId, advicerName) { switchView('wx'); } +/* ---------- Tab 6:成交线索(约定到访/成交/付款) ---------- */ +var LEAD_TYPE_META = { + visit: "约定到访", deal: "约定成交", payment: "约定付款" +}; +var LEAD_STATUS_META = { + pending: "待跟进", done: "已完成", cancelled: "已取消" +}; + +function renderTabLeads(body) { + state.data.leadStatus = state.data.leadStatus || ""; + var load = function () { + Core.api("smart/lead/list", { projectId: state.project.projectId, status: state.data.leadStatus }).then(function (list) { + list = list || []; + var chips = [["", "全部"], ["pending", "待跟进"], ["done", "已完成"]].map(function (c) { + return ''; + }).join(" "); + var html = '
成交线索' + + '共 ' + list.length + ' 条·对话复盘自动抽取到访/成交/付款约定,供销售跟进并注入后续 AI 对话' + + chips + '
'; + if (!list.length) { + html += '
◉
暂无成交线索
'; + } else { + html += '
' + + "" + + list.map(function (t) { + var isPending = t.status === "pending"; + var result = (t.result || "").slice(0, 40); + var ops = "-"; + if (isPending) { + ops = '' + + ''; + } + return "" + + '" + + "" + + '" + + '" + + '" + + '" + + '" + + '"; + }).join("") + "
客户线索类型约定约定时间状态来源跟进结果操作
' + Core.esc((t.wxid || "").slice(0, 12) || "-") + "" + (LEAD_TYPE_META[t.lead_type] || t.lead_type || "-") + "' + Core.esc(t.title || "-") + "" + (t.detail ? '
' + Core.esc(t.detail) + "
" : "") + "
' + Core.fmtTime(t.expect_at) + "' + (LEAD_STATUS_META[t.status] || t.status) + "' + (t.source === "manual" ? "手动" : "AI") + "' + Core.esc(result) + "' + ops + "
"; + } + body.innerHTML = html; + $("#ld-add").onclick = function () { leadForm(); }; + $$(".ld-chip").forEach(function (b) { + b.onclick = function () { state.data.leadStatus = b.dataset.status; renderDataBody(); }; + }); + $$(".ld-cancel").forEach(function (b) { + b.onclick = function () { cancelLead(b.dataset.id, b.dataset.title); }; + }); + $$(".ld-done").forEach(function (b) { + b.onclick = function () { completeLead(b.dataset.id, b.dataset.title); }; + }); + }).catch(function (e) { + body.innerHTML = '
⚠
' + Core.esc(e.message) + "
"; + }); + }; + load(); +} + +/* 托管客户单选下拉(数据源=advicer/customer/hosted;value=wxid,data-appid=登录实例) */ +function loadHostedCustomerOptions(sel, currentWxid) { + if (!sel) return; + sel.innerHTML = ''; + Core.api("advicer/customer/hosted", { projectId: state.project.projectId }).then(function (list) { + list = list || []; + if (!list.length) { sel.innerHTML = ''; return; } + sel.innerHTML = '' + list.map(function (c) { + var label = c.remark || c.nick_name || c.user_name || "客户"; + var selected = (currentWxid && c.user_name === currentWxid) ? " selected" : ""; + return ''; + }).join(""); + }).catch(function (e) { + sel.innerHTML = ''; + }); +} + +function leadForm() { + var m = Core.modal({ + title: "新增成交线索", + body: + '
' + + '
' + + '
' + + '
' + + "
" + + '
' + + '
', + footer: '' + }); + loadHostedCustomerOptions($("#ld-client", m.root)); + $(".mf-cancel", m.root).onclick = m.close; + $(".mf-save", m.root).onclick = function () { + var sel = $("#ld-client", m.root); + var wxid = sel.value; + var appId = sel.options[sel.selectedIndex] ? (sel.options[sel.selectedIndex].dataset.appid || "") : ""; + var expectAt = $("#ld-expect", m.root).value.trim(); + if (!wxid) { Core.toast("请选择客户", "err"); return; } + if (!/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}/.test(expectAt)) { Core.toast("约定时间格式应形如 2026-09-29 14:00", "err"); return; } + Core.api("smart/lead/create", { + wxid: wxid, + appId: appId, + projectId: state.project.projectId, + leadType: $("#ld-type", m.root).value, + title: $("#ld-title", m.root).value.trim(), + detail: $("#ld-detail", m.root).value.trim(), + expectAt: expectAt.replace("T", " ").slice(0, 16) + }).then(function () { m.close(); Core.toast("已新增成交线索", "ok"); renderDataBody(); }) + .catch(function (e) { Core.toast(e.message, "err"); }); + }; +} + +function cancelLead(id, title) { + Core.confirm("确定取消成交线索「" + (title || "未命名") + "」?").then(function (ok) { + if (!ok) return; + Core.api("smart/lead/cancel", { id: Number(id) }).then(function () { + Core.toast("已取消", "ok"); + renderDataBody(); + }).catch(function (e) { Core.toast(e.message, "err"); }); + }); +} + +function completeLead(id, title) { + Core.confirm("将成交线索「" + (title || "未命名") + "」标记为已完成?").then(function (ok) { + if (!ok) return; + Core.api("smart/lead/complete", { id: Number(id) }).then(function () { + Core.toast("已标记完成", "ok"); + renderDataBody(); + }).catch(function (e) { Core.toast(e.message, "err"); }); + }); +} + +/* ---------- Tab 7:定时提醒(到点由 AI 主动发消息) ---------- */ +var REMIND_STATUS_META = { + pending: "待执行", done: "已发送", cancelled: "已取消", failed: "失败" +}; + +function renderTabReminds(body) { + state.data.remindStatus = state.data.remindStatus || ""; + var load = function () { + Core.api("smart/remind/list", { projectId: state.project.projectId, status: state.data.remindStatus }).then(function (list) { + list = list || []; + var chips = [["", "全部"], ["pending", "待执行"], ["done", "已发送"], ["failed", "失败"]].map(function (c) { + return ''; + }).join(" "); + var html = '
定时提醒' + + '共 ' + list.length + ' 条·到点由 AI 主动发消息提醒客户' + + chips + '
'; + if (!list.length) { + html += '
◉
暂无定时提醒
'; + } else { + html += '
' + + "" + + list.map(function (t) { + var isPending = t.status === "pending"; + var result = (t.result || t.err_msg || "").slice(0, 40); + var ops = "-"; + if (isPending) { + ops = ''; + } + return "" + + '" + + '" + + '" + + '" + + '" + + '" + + '"; + }).join("") + "
客户提醒到点时间状态来源结果/失败操作
' + Core.esc((t.wxid || "").slice(0, 12) || "-") + "' + Core.esc(t.title || "-") + "" + (t.detail ? '
' + Core.esc(t.detail) + "
" : "") + "
' + Core.fmtTime(t.due_at) + "' + (REMIND_STATUS_META[t.status] || t.status) + "' + (t.source === "manual" ? "手动" : "AI") + "' + Core.esc(result) + "' + ops + "
"; + } + body.innerHTML = html; + $("#rm-add").onclick = function () { remindForm(); }; + $$(".rm-chip").forEach(function (b) { + b.onclick = function () { state.data.remindStatus = b.dataset.status; renderDataBody(); }; + }); + $$(".rm-cancel").forEach(function (b) { + b.onclick = function () { cancelRemind(b.dataset.id, b.dataset.title); }; + }); + }).catch(function (e) { + body.innerHTML = '
⚠
' + Core.esc(e.message) + "
"; + }); + }; + load(); +} + +function remindForm() { + var m = Core.modal({ + title: "新增定时提醒", + body: + '
' + + '
' + + '
' + + '
', + footer: '' + }); + loadHostedCustomerOptions($("#rm-client", m.root)); + $(".mf-cancel", m.root).onclick = m.close; + $(".mf-save", m.root).onclick = function () { + var sel = $("#rm-client", m.root); + var wxid = sel.value; + var appId = sel.options[sel.selectedIndex] ? (sel.options[sel.selectedIndex].dataset.appid || "") : ""; + var dueAt = $("#rm-due", m.root).value.trim(); + if (!wxid) { Core.toast("请选择客户", "err"); return; } + if (!/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}/.test(dueAt)) { Core.toast("到点时间格式应形如 2026-09-29 14:00", "err"); return; } + Core.api("smart/remind/create", { + wxid: wxid, + appId: appId, + projectId: state.project.projectId, + title: $("#rm-title", m.root).value.trim(), + detail: $("#rm-detail", m.root).value.trim(), + dueAt: dueAt.replace("T", " ").slice(0, 16) + }).then(function () { m.close(); Core.toast("已新增定时提醒", "ok"); renderDataBody(); }) + .catch(function (e) { Core.toast(e.message, "err"); }); + }; +} + +function cancelRemind(id, title) { + Core.confirm("确定取消定时提醒「" + (title || "未命名") + "」?").then(function (ok) { + if (!ok) return; + Core.api("smart/remind/cancel", { id: Number(id) }).then(function () { + Core.toast("已取消", "ok"); + renderDataBody(); + }).catch(function (e) { Core.toast(e.message, "err"); }); + }); +} + diff --git a/web/assets/js/project_detail.js b/web/assets/js/project_detail.js index 4240728..5d191ef 100644 --- a/web/assets/js/project_detail.js +++ b/web/assets/js/project_detail.js @@ -145,6 +145,8 @@ function renderProjectDetailForm() { loadIndustryOptions($("#pd-industry"), base.industry_id); // ===== 渲染项目资料动态栏目 ===== + // 复位编辑器上下文为项目资料默认(防止客户画像弹窗遗留的目标污染本页) + setPjiEditorTarget(null); drawPjiSections(); // ===== 主 Tab 切换 ===== @@ -335,6 +337,28 @@ function renderProjectDetailForm() { /* ==================== 项目资料动态栏目(维度格式) ==================== */ +/* + * 栏目编辑器上下文:默认读写项目资料页的 state.pjiSections + #pji-sections。 + * 通过 setPjiEditorTarget 可将「数据源 + 容器」指向别处(如客户画像弹窗内的编辑器), + * 复用同一套渲染/绑定/收集逻辑;传 null 复位为项目资料默认。 + */ +var __pjiTarget = null; +function setPjiEditorTarget(root, get, set) { + __pjiTarget = root ? { root: root, get: get, set: set } : null; +} +function pjiContainer() { + if (__pjiTarget && __pjiTarget.root) return __pjiTarget.root.querySelector("#pji-sections"); + return document.querySelector("#pji-sections"); +} +function pjiGetSections() { + if (__pjiTarget && __pjiTarget.get) return __pjiTarget.get() || []; + return state.pjiSections || []; +} +function pjiSetSections(v) { + if (__pjiTarget && __pjiTarget.set) { __pjiTarget.set(v); return; } + state.pjiSections = v; +} + /** 将 MongoDB 返回的 [{Key,Value}] 或其他格式统一转为 [{k,v}] */ function normalizeKvPairs(data) { if (!data) return []; @@ -521,10 +545,11 @@ function readCardContentFromDom(card, type) { return type === "string" ? "" : []; } -/** 从 DOM 同步栏目内容到 state(防止重绘丢失数据) */ +/** 从 DOM 同步栏目内容到当前数据源(防止重绘丢失数据) */ function syncPjiSectionsFromDom() { - var sections = state.pjiSections || []; - var cards = $$(".pji-section-card"); + var sections = pjiGetSections(); + var cont = pjiContainer(); + var cards = $$(".pji-section-card", cont || document); if (!cards.length || !sections.length) return; cards.forEach(function (card) { var idx = Number(card.dataset.idx); @@ -564,13 +589,13 @@ function syncPjiSectionsFromDom() { }); } -/** 绘制所有栏目 */ +/** 绘制所有栏目(渲染到当前上下文容器) */ function drawPjiSections() { - var container = $("#pji-sections"); + var container = pjiContainer(); if (!container) return; // 重绘前先从 DOM 同步数据,防止已填内容丢失 syncPjiSectionsFromDom(); - var sections = state.pjiSections || []; + var sections = pjiGetSections(); if (!sections.length) { container.innerHTML = '
\u2726
\u6682\u65e0\u680f\u76ee\uff0c\u70b9\u51fb\u4e0a\u65b9\u300c+ \u65b0\u589e\u680f\u76ee\u300d\u6dfb\u52a0
'; return; @@ -601,9 +626,9 @@ function drawPjiSections() { /** 绑定栏目内事件(增删改) */ function bindPjiSectionEvents() { - var container = $("#pji-sections"); + var container = pjiContainer(); if (!container) return; - var sections = state.pjiSections || []; + var sections = pjiGetSections(); // 点击栏目名称 → 内联编辑 $$(".pji-section-label", container).forEach(function (span) { @@ -627,11 +652,11 @@ function bindPjiSectionEvents() { // 同步更新 key(存储字段名) sec.key = newLabel.replace(/[^a-zA-Z0-9\u4e00-\u9fff]/g, "").replace(/^[0-9]+/, "") || ("sec_" + Date.now()); } - state.pjiSections = sections; + pjiSetSections(sections); drawPjiSections(); }; input.onblur = finish; - input.onkeydown = function (e) { if (e.key === "Enter") { e.preventDefault(); finish(); } if (e.key === "Escape") { state.pjiSections = sections; drawPjiSections(); } }; + input.onkeydown = function (e) { if (e.key === "Enter") { e.preventDefault(); finish(); } if (e.key === "Escape") { pjiSetSections(sections); drawPjiSections(); } }; }; }); @@ -642,7 +667,7 @@ function bindPjiSectionEvents() { Core.confirm("\u786e\u5b9a\u5220\u9664\u680f\u76ee\u300c" + sections[idx].label + "\u300d\uff1f").then(function (ok) { if (!ok) return; sections.splice(idx, 1); - state.pjiSections = sections; + pjiSetSections(sections); drawPjiSections(); }); }; @@ -663,7 +688,7 @@ function bindPjiSectionEvents() { // 转换内容格式 sec.content = convertSectionContent(domContent, oldType, newType); sec.type = newType; - state.pjiSections = sections; + pjiSetSections(sections); drawPjiSections(); }; }); @@ -756,9 +781,10 @@ function bindPjiSectionEvents() { /** 收集所有栏目数据用于保存 */ function collectPjiSectionsData() { - var sections = state.pjiSections || []; + var sections = pjiGetSections(); + var cont = pjiContainer(); var result = []; - $$(".pji-section-card").forEach(function (card) { + $$(".pji-section-card", cont || document).forEach(function (card) { var idx = Number(card.dataset.idx); var sec = sections[idx]; if (!sec) return; @@ -815,9 +841,9 @@ function addPjiSectionDialog() { if (!label) { Core.toast("\u8bf7\u8f93\u5165\u680f\u76ee\u540d\u79f0", "err"); return; } var type = $("#ns-type", m.root).value; var key = label.replace(/[^a-zA-Z0-9\u4e00-\u9fff]/g, "").replace(/^[0-9]+/, "") || ("sec_" + Date.now()); - var sections = state.pjiSections || []; + var sections = pjiGetSections(); sections.push({ key: key, label: label, type: type, content: type === "string" ? "" : [] }); - state.pjiSections = sections; + pjiSetSections(sections); m.close(); drawPjiSections(); Core.toast("\u680f\u76ee\u300c" + label + "\u300d\u5df2\u6dfb\u52a0", "ok"); diff --git a/web/assets/js/project_list.js b/web/assets/js/project_list.js index 534ea70..1dda794 100644 --- a/web/assets/js/project_list.js +++ b/web/assets/js/project_list.js @@ -75,7 +75,7 @@ function renderProjectsTable() { console.log("[enterProject] found project:", p.project_id, p.name); setActiveProject(p); Core.toast("已进入项目:" + p.name, "ok"); - setTimeout(function () { switchView("analysis"); }, 50); + setTimeout(function () { switchView("data"); }, 50); } $$("#pj-table .pj-enter").forEach(function (b) { b.onclick = function (e) { e.stopPropagation(); enterProject(b.dataset.id); }; diff --git a/web/project.html b/web/project.html index 17910f5..3419b09 100644 --- a/web/project.html +++ b/web/project.html @@ -43,7 +43,6 @@