fix: 修改服务调用和测试用例

This commit is contained in:
renzhiyuan 2026-09-28 18:11:38 +08:00
parent 7b96c79ab4
commit 389cad8d8a
38 changed files with 2160 additions and 635 deletions

View File

@ -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})

View File

@ -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)
}

View File

@ -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) {

View File

@ -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)

View File

@ -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,

View File

@ -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
}

View File

@ -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")

View File

@ -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 {

View File

@ -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)

View File

@ -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,

View File

@ -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",

View File

@ -24,6 +24,7 @@ var ProviderSetBiz = wire.NewSet(
NewAdviceStrategyBiz,
NewAdviceProactiveBiz,
NewAdviceIterateBiz,
NewAdviceFollowBiz,
NewAdviceCustomerBiz,
NewAdviceLabelBiz,
)

View File

@ -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 = `你是资深销售管理专家。请根据【客户画像】【互动统计】【最近聊天记录】,评估该客户当前所处的客户阶段。

View File

@ -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
}

View File

@ -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 {

View File

@ -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
}

View File

@ -17,4 +17,5 @@ var ProviderImpl = wire.NewSet(
NewAdviceAdminImpl,
NewAdviceCustomerImpl,
NewAdviceLabelImpl,
NewAdviceFollowImpl,
)

View File

@ -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 <ai_advice_remind_task>
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 <ai_advice_deal_lead>
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 }

View File

@ -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
}

View File

@ -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 ""
}
}

View File

@ -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 扁平栏目数据

View File

@ -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(客户画像自动归属)
}
// ==================== 标签同步 ====================

View File

@ -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()
}

View File

@ -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
}

View File

@ -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)

View File

@ -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(),

View File

@ -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
}
}

View File

@ -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)
}

BIN
server

Binary file not shown.

View File

@ -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 主动发消息提醒客户)';

View File

@ -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='成交线索(约定到访/成交/付款,供销售跟进并注入后续对话)';

View File

@ -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")
}
}

View File

@ -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
}

View File

@ -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();

View File

@ -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 =
'<div class="row mb-2"><span class="fs-13 c-main" style="font-weight:700">\u5ba2\u6237</span>' +
'<span class="fs-12 c-dim">\u5ba2\u6237\u753b\u50cf\u4e0e\u6258\u7ba1\u5ba2\u6237\u7ba1\u7406</span><span class="spacer"></span>' +
'<button class="btn primary sm" id="cli-add">+ \u65b0\u589e\u5ba2\u6237</button></div>' +
'<div class="tabs" id="cli-sub-tabs" style="margin-bottom:10px">' +
CLIENT_SUB_TABS.map(function (t) {
return '<div class="tab' + (subTab === t[0] ? " active" : "") + '" data-sub="' + t[0] + '">' + t[1] + '</div>';
}).join('') + '</div>' +
'<div class="row mb-2"><span class="fs-13 c-main" style="font-weight:700">托管客户</span>' +
'<span class="fs-12 c-dim">处于托管标签分组内的微信好友;点击「查看画像」查看并维护该客户的 AI 客户画像</span></div>' +
'<div id="cli-list"></div>';
// 使用 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 = '<div class="empty"><span class="spin"></span></div>';
/* ---------- 查看/维护托管客户的 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 = '<div class="empty"><div class="big">◉</div>暂无客户,可手动新增或从「数据分析 → 客户维度」保存</div>';
return;
}
box.innerHTML = '<div class="table-wrap"><table class="table"><thead><tr>' +
"<th>ID</th><th>姓名</th><th>客户等级</th><th>微信</th><th>预算</th><th>偏好</th><th>销售</th><th>操作</th></tr></thead><tbody>" +
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
? '<span class="mono fs-12">' + Core.esc(c.wxid) + "</span>"
: '<button class="btn ghost sm cli-bind" data-id="' + c.id + '" data-name="' + Core.esc(pi.name || "") + '">绑定微信</button>';
return "<tr>" +
'<td class="mono fs-12">' + Core.esc(String(c.id).slice(-8)) + "</td>" +
'<td><span class="name">' + Core.esc(pi.name || "-") + "</span>" +
(pi.gender ? '<span class="fs-12 c-dim" style="margin-left:6px">' + Core.esc(pi.gender) + "</span>" : "") + "</td>" +
'<td><span class="badge ' + lvCls + '" title="' + Core.esc(lvTip) + '">' + Core.esc(lvDesc) + "</span></td>" +
"<td>" + wxCell + "</td>" +
'<td class="fs-12">' + Core.esc(cd.totalBudget || "-") + "</td>" +
'<td class="fs-12 cell-clip">' + Core.esc(cd.preferredLayout || "-") + "</td>" +
'<td class="mono">#' + c.advicerId + "</td>" +
'<td class="ops"><button class="btn ghost sm cli-edit" data-id="' + c.id + '">编辑</button>' +
'<button class="btn ghost sm cli-eval" data-id="' + c.id + '" data-name="' + Core.esc(pi.name || "") + '">评估</button>' +
(c.wxid ? '<button class="btn ghost sm cli-iterate" data-id="' + c.id + '" data-name="' + Core.esc(pi.name || "") + '">迭代</button>' : "") +
'<button class="btn danger sm cli-del" data-id="' + c.id + '">删除</button></td></tr>';
}).join("") + "</tbody></table></div>";
$$(".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 = '<div class="empty"><div class="big">⚠</div>' + Core.esc(e.message) + "</div>";
});
}).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 = '<div class="empty"><span class="spin"></span></div>';
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 = '<div class="empty"><div class="big">\u25c9</div>\u5c1a\u65e0\u6258\u7ba1\u5ba2\u6237\uff0c\u5f00\u542f\u6258\u7ba1\u6807\u7b7e\u540e\u6d88\u606f\u56de\u8c03\u65f6\u81ea\u52a8\u540c\u6b65</div>';
return;
}
box.innerHTML = '<div class="fs-12 c-dim" style="margin-bottom:8px">\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</div>' +
'<div class="table-wrap"><table class="table"><thead><tr>' +
'<th>\u5934\u50cf</th><th>\u6635\u79f0</th><th>\u5907\u6ce8</th><th>\u6258\u7ba1\u6807\u7b7e</th><th>\u6027\u522b</th><th>\u5730\u533a</th><th>\u5f52\u5c5e\u9500\u552e</th><th>\u4f1a\u8bdd\u72b6\u6001</th><th>\u66f4\u65b0\u65f6\u95f4</th></tr></thead><tbody>' +
'<th>头像</th><th>昵称</th><th>微信备注</th><th>托管标签</th><th>性别</th><th>地区</th><th>归属销售</th><th>客户等级</th><th>当前任务</th><th>会话状态</th><th>更新时间</th><th>操作</th></tr></thead><tbody>' +
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 '<span class="badge blue fs-11">' + Core.esc(l) + '</span>';
}).join(" ");
var prof = profMap[c.user_name];
var levelCell = '<span class="c-dim">-</span>';
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 = '<span class="badge ' + lvCls + '" title="' + Core.esc(lvTip) + '">' + Core.esc(lvDesc) + '</span>';
} else {
levelCell = '<span class="badge gray" title="尚未进行等级评估">未评估</span>';
}
}
// 当前任务:对话 AI 回写的简洁 mission,展示在客户列表
var missionCell = '<span class="c-dim">-</span>';
if (prof && prof.mission) {
var ms = String(prof.mission);
missionCell = '<span class="fs-12" title="' + Core.esc(ms) + '">' + Core.esc(ms.length > 14 ? ms.slice(0, 14) + "…" : ms) + '</span>';
}
return "<tr>" +
'<td>' + avatarCell + '</td>' +
'<td><span class="name">' + Core.esc(displayName) + '</span>' +
@ -1114,13 +1071,87 @@ function loadHostedCustomers(box) {
'<td>' + sex + '</td>' +
'<td class="fs-12">' + Core.esc(area) + '</td>' +
'<td class="fs-12">' + Core.esc(c.advicer_name || "-") + '</td>' +
'<td>' + levelCell + '</td>' +
'<td>' + missionCell + '</td>' +
'<td>' + sessionStatus + '</td>' +
'<td class="fs-12">' + Core.fmtTime(c.update_at) + '</td></tr>';
'<td class="fs-12">' + Core.fmtTime(c.update_at) + '</td>' +
'<td class="ops">' +
'<button class="btn ghost sm hc-profile" data-cid="' + c.customer_id + '">查看画像</button>' +
'<button class="btn ghost sm hc-chat" data-cid="' + c.customer_id + '">\u804a\u5929\u8bb0\u5f55</button>' +
'<button class="btn ghost sm danger hc-remove" data-cid="' + c.customer_id + '">\u79fb\u9664</button>' +
'</td></tr>';
}).join("") + "</tbody></table></div>";
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 = '<div class="empty"><div class="big">\u26a0</div>' + Core.esc(e.message) + '</div>';
});
}
/* ---------- 托管客户操作:聊天记录 / 移除 ---------- */
// 将客户从托管标签中移除(微信 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: '<div id="hc-chat" style="max-height:60vh;overflow:auto;display:flex;flex-direction:column;gap:12px"><div class="empty" style="padding:20px"><span class="spin"></span></div></div>'
});
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 = '<div class="empty"><div class="big">\u2726</div>与「' + Core.esc(name) + '」暂无聊天记录</div>';
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"
? '<div class="msg-avatar" style="background:rgba(45,224,255,0.15)">\u2726</div>'
: '<div class="msg-avatar">' + Core.esc(Core.initial(isSelf ? tag : name)) + '</div>';
var bubbleClass = isSelf ? "msg-bubble conv-msg-self" : "msg-bubble conv-msg-other";
return '<div class="msg-row' + (isSelf ? " self" : "") + '">' + avatarHtml +
'<div><div class="' + bubbleClass + '">' + hostedChatContent(msg) + '</div>' +
'<div class="msg-time">' + tag + ' · ' + Core.esc(Core.fmtTime(msg.createAt || msg.create_at)) + '</div></div></div>';
}).join("");
box.scrollTop = box.scrollHeight;
})
.catch(function (e) { box.innerHTML = '<div class="empty"><div class="big">\u26a0</div>' + Core.esc(e.message) + '</div>'; });
}
function hostedChatContent(msg) {
var content = msg.content || "";
switch (msg.msgType) {
case "text": return Core.esc(content);
case "image": return '<span class="conv-media">\ud83d\udcf7 [图片]</span>';
case "emoji": return '<span class="conv-media">\ud83d\ude0a [表情]</span>';
case "voice": return '<span class="conv-media">\ud83c\udfa4 [语音]</span>';
case "video": return '<span class="conv-media">\ud83c\udfac [视频]</span>';
case "location": return '<span class="conv-media">\ud83d\udccd [位置]</span>';
case "namecard": return '<span class="conv-media">\ud83d\udc64 [名片]</span>';
case "app_msg": return '<span class="conv-media">\ud83d\udd17 [链接/小程序]</span>';
default: return '<span class="conv-media">[' + Core.esc(msg.msgType || "消息") + ']</span>';
}
}
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:
'<div class="field"><label class="label">客户微信 wxid<span class="req">*</span></label>' +
'<input id="cb-wxid" class="input mono" placeholder="如:wxid_xxxxxxxx"></div>' +
'<div class="field"><label class="label">登录实例 appId(可选)</label>' +
'<input id="cb-appid" class="input mono" placeholder="多实例时指定,留空=默认实例"></div>' +
'<div class="fs-12 c-dim" style="line-height:1.9">绑定后该微信发来的消息自动关联本客户;客户画像迭代、每晚等级评估与 AI 主动对话均依赖绑定关系。</div>',
footer: '<button class="btn ghost mf-cancel">取消</button><button class="btn primary mf-save">绑定</button>'
});
$(".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:
'<div class="grid cols-3">' +
'<div class="field"><label class="label">姓氏/称呼</label><input id="cf-name" class="input" value="' + Core.esc(pi.name || "") + '"></div>' +
'<div class="field"><label class="label">性别</label><input id="cf-gender" class="input" value="' + Core.esc(pi.gender || "") + '" placeholder="男/女"></div>' +
'<div class="field"><label class="label">来源地</label><input id="cf-loc" class="input" value="' + Core.esc(pi.location || "") + '"></div>' +
"</div>" +
'<div class="grid cols-3">' +
'<div class="field"><label class="label">家庭结构</label><input id="cf-family" class="input" value="' + Core.esc(pi.familyOrganize || "") + '" placeholder="如:夫妻+1孩"></div>' +
'<div class="field"><label class="label">\u751f\u65e5\uff08\u7528\u4e8e\u751f\u65e5\u5173\u6000\uff09</label><input id="cf-birthday" class="input dp-date" placeholder="\u70b9\u51fb\u9009\u62e9\u65e5\u671f" value="' + Core.esc(pi.birthday || "") + '"></div>' +
'<div class="field" style="display:flex;align-items:flex-end"><label class="checkbox-row"><input type="checkbox" id="cf-first" ' + (pi.isFirstHome ? "checked" : "") + "> 首次置业</label></div>" +
"</div>" +
'<div class="grid cols-3">' +
'<div class="field"><label class="label">主要目的</label><input id="cf-p1" class="input" value="' + Core.esc(pp.primaryPurpose || "") + '"></div>' +
'<div class="field"><label class="label">次要目的</label><input id="cf-p2" class="input" value="' + Core.esc(pp.secondaryPurpose || "") + '"></div>' +
'<div class="field"><label class="label">决策人</label><input id="cf-dm" class="input" value="' + Core.esc(pp.decisionMakers || "") + '"></div>' +
"</div>" +
'<div class="grid cols-3">' +
'<div class="field"><label class="label">预算范围</label><input id="cf-budget" class="input" value="' + Core.esc(cd.totalBudget || "") + '" placeholder="如:350-400万"></div>' +
'<div class="field"><label class="label">偏好户型</label><input id="cf-layout" class="input" value="' + Core.esc(cd.preferredLayout || "") + '"></div>' +
'<div class="field"><label class="label">核心诉求</label><input id="cf-appeal" class="input" value="' + Core.esc(cd.coreAppeal || "") + '"></div>' +
"</div>" +
'<div class="grid cols-2">' +
'<div class="field"><label class="label">顾虑(每行一条)</label><textarea id="cf-concerns" class="textarea" style="min-height:70px">' + Core.esc(concerns) + "</textarea></div>" +
'<div class="field"><label class="label">决策建议(每行一条)</label><textarea id="cf-decisions" class="textarea" style="min-height:70px">' + Core.esc(decisions) + "</textarea></div>" +
"</div>" +
'<div class="field"><label class="label">归属销售<span class="req">*</span></label><select id="cf-advicer" class="select"></select></div>',
footer: '<button class="btn ghost mf-cancel">取消</button><button class="btn primary mf-save">保存</button>'
'<div class="fs-12 c-dim" style="margin-bottom:8px">归属销售:' + Core.esc(ctx.advicerName || (advicerId ? ("#" + advicerId) : "未关联")) + '(画像自动归属于该客户来源的销售,无需手动选择)</div>' +
'<div class="row mb-2" style="align-items:center">' +
'<span class="fs-12 c-dim">按「项目资料」方式维护客户画像栏目;后端按约定栏目名「姓名」「生日」读取,用于称呼与生日关怀。</span>' +
'<span class="spacer"></span>' +
'<button type="button" class="btn ghost sm" id="cp-add-section">+ 新增栏目</button>' +
'</div>' +
'<div id="pji-sections" style="max-height:56vh;overflow:auto"></div>',
footer:
'<button class="btn ghost cp-regen">画像更新</button>' +
(isEdit ? '<button class="btn ghost cp-eval">等级评估</button><button class="btn danger mf-del">删除画像</button>' : '') +
'<button class="btn ghost mf-cancel">取消</button><button class="btn primary mf-save">保存</button>'
});
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 '<button class="btn ghost sm ld-chip' + (state.data.leadStatus === c[0] ? " primary" : "") + '" data-status="' + c[0] + '">' + c[1] + '</button>';
}).join(" ");
var html = '<div class="row mb-2"><span class="fs-13 c-main" style="font-weight:700">成交线索</span>' +
'<span class="fs-12 c-dim">共 ' + list.length + ' 条·对话复盘自动抽取到访/成交/付款约定,供销售跟进并注入后续 AI 对话</span><span class="spacer"></span>' +
chips + ' <button class="btn primary sm" id="ld-add">+ 新增线索</button></div>';
if (!list.length) {
html += '<div class="empty"><div class="big">◉</div>暂无成交线索</div>';
} else {
html += '<div class="table-wrap"><table class="table"><thead><tr>' +
"<th>客户</th><th>线索类型</th><th>约定</th><th>约定时间</th><th>状态</th><th>来源</th><th>跟进结果</th><th>操作</th></tr></thead><tbody>" +
list.map(function (t) {
var isPending = t.status === "pending";
var result = (t.result || "").slice(0, 40);
var ops = "-";
if (isPending) {
ops = '<button class="btn ghost sm ld-done" data-id="' + t.id + '" data-title="' + Core.esc(t.title || "") + '">完成</button>' +
'<button class="btn ghost sm ld-cancel" data-id="' + t.id + '" data-title="' + Core.esc(t.title || "") + '">取消</button>';
}
return "<tr>" +
'<td class="mono fs-12">' + Core.esc((t.wxid || "").slice(0, 12) || "-") + "</td>" +
"<td>" + (LEAD_TYPE_META[t.lead_type] || t.lead_type || "-") + "</td>" +
'<td><span class="name">' + Core.esc(t.title || "-") + "</span>" + (t.detail ? '<div class="fs-12 c-dim">' + Core.esc(t.detail) + "</div>" : "") + "</td>" +
'<td class="fs-12">' + Core.fmtTime(t.expect_at) + "</td>" +
'<td>' + (LEAD_STATUS_META[t.status] || t.status) + "</td>" +
'<td class="fs-12 c-dim">' + (t.source === "manual" ? "手动" : "AI") + "</td>" +
'<td class="fs-12 c-dim">' + Core.esc(result) + "</td>" +
'<td class="ops">' + ops + "</td></tr>";
}).join("") + "</tbody></table></div>";
}
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 = '<div class="empty"><div class="big">⚠</div>' + Core.esc(e.message) + "</div>";
});
};
load();
}
/* 托管客户单选下拉(数据源=advicer/customer/hosted;value=wxid,data-appid=登录实例) */
function loadHostedCustomerOptions(sel, currentWxid) {
if (!sel) return;
sel.innerHTML = '<option value="">加载中…</option>';
Core.api("advicer/customer/hosted", { projectId: state.project.projectId }).then(function (list) {
list = list || [];
if (!list.length) { sel.innerHTML = '<option value="">暂无托管客户</option>'; return; }
sel.innerHTML = '<option value="">请选择客户</option>' + list.map(function (c) {
var label = c.remark || c.nick_name || c.user_name || "客户";
var selected = (currentWxid && c.user_name === currentWxid) ? " selected" : "";
return '<option value="' + Core.esc(c.user_name || "") + '"' + selected +
' data-appid="' + Core.esc(c.advicer_wx_device_id || "") + '">' + Core.esc(label) + '</option>';
}).join("");
}).catch(function (e) {
sel.innerHTML = '<option value="">加载失败:' + Core.esc(e.message) + '</option>';
});
}
function leadForm() {
var m = Core.modal({
title: "新增成交线索",
body:
'<div class="field"><label class="label">客户<span class="req">*</span></label><select id="ld-client" class="select"></select></div>' +
'<div class="grid cols-2">' +
'<div class="field"><label class="label">线索类型<span class="req">*</span></label><select id="ld-type" class="select">' +
'<option value="visit">约定到访</option>' +
'<option value="deal">约定成交</option>' +
'<option value="payment">约定付款</option></select></div>' +
'<div class="field"><label class="label">约定时间<span class="req">*</span></label><input id="ld-expect" class="input" placeholder="YYYY-MM-DD HH:MM"></div>' +
"</div>" +
'<div class="field"><label class="label">标题</label><input id="ld-title" class="input" placeholder="如:到访售楼部 / 交付定金"></div>' +
'<div class="field"><label class="label">详情</label><textarea id="ld-detail" class="textarea" style="min-height:60px" placeholder="约定原文/关键信息"></textarea></div>',
footer: '<button class="btn ghost mf-cancel">取消</button><button class="btn primary mf-save">保存</button>'
});
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 '<button class="btn ghost sm rm-chip' + (state.data.remindStatus === c[0] ? " primary" : "") + '" data-status="' + c[0] + '">' + c[1] + '</button>';
}).join(" ");
var html = '<div class="row mb-2"><span class="fs-13 c-main" style="font-weight:700">定时提醒</span>' +
'<span class="fs-12 c-dim">共 ' + list.length + ' 条·到点由 AI 主动发消息提醒客户</span><span class="spacer"></span>' +
chips + ' <button class="btn primary sm" id="rm-add">+ 新增提醒</button></div>';
if (!list.length) {
html += '<div class="empty"><div class="big">◉</div>暂无定时提醒</div>';
} else {
html += '<div class="table-wrap"><table class="table"><thead><tr>' +
"<th>客户</th><th>提醒</th><th>到点时间</th><th>状态</th><th>来源</th><th>结果/失败</th><th>操作</th></tr></thead><tbody>" +
list.map(function (t) {
var isPending = t.status === "pending";
var result = (t.result || t.err_msg || "").slice(0, 40);
var ops = "-";
if (isPending) {
ops = '<button class="btn ghost sm rm-cancel" data-id="' + t.id + '" data-title="' + Core.esc(t.title || "") + '">取消</button>';
}
return "<tr>" +
'<td class="mono fs-12">' + Core.esc((t.wxid || "").slice(0, 12) || "-") + "</td>" +
'<td><span class="name">' + Core.esc(t.title || "-") + "</span>" + (t.detail ? '<div class="fs-12 c-dim">' + Core.esc(t.detail) + "</div>" : "") + "</td>" +
'<td class="fs-12">' + Core.fmtTime(t.due_at) + "</td>" +
'<td>' + (REMIND_STATUS_META[t.status] || t.status) + "</td>" +
'<td class="fs-12 c-dim">' + (t.source === "manual" ? "手动" : "AI") + "</td>" +
'<td class="fs-12 c-dim">' + Core.esc(result) + "</td>" +
'<td class="ops">' + ops + "</td></tr>";
}).join("") + "</tbody></table></div>";
}
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 = '<div class="empty"><div class="big">⚠</div>' + Core.esc(e.message) + "</div>";
});
};
load();
}
function remindForm() {
var m = Core.modal({
title: "新增定时提醒",
body:
'<div class="field"><label class="label">客户<span class="req">*</span></label><select id="rm-client" class="select"></select></div>' +
'<div class="field"><label class="label">到点时间<span class="req">*</span></label><input id="rm-due" class="input" placeholder="YYYY-MM-DD HH:MM"></div>' +
'<div class="field"><label class="label">标题</label><input id="rm-title" class="input" placeholder="如:回访电话"></div>' +
'<div class="field"><label class="label">详情</label><textarea id="rm-detail" class="textarea" style="min-height:60px" placeholder="提醒原文/关键信息"></textarea></div>',
footer: '<button class="btn ghost mf-cancel">取消</button><button class="btn primary mf-save">保存</button>'
});
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"); });
});
}

View File

@ -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 = '<div class="empty" style="padding:30px 0"><div class="big">\u2726</div>\u6682\u65e0\u680f\u76ee\uff0c\u70b9\u51fb\u4e0a\u65b9\u300c+ \u65b0\u589e\u680f\u76ee\u300d\u6dfb\u52a0</div>';
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");

View File

@ -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); };

View File

@ -43,7 +43,6 @@
<div class="nav-group">
<div class="nav-group-title">项目工作台</div>
<div class="nav-item nav-project-only hidden" data-view="projectDetail"><span class="ico">⚙</span>项目设置</div>
<div class="nav-item nav-project-only hidden" data-view="analysis"><span class="ico">✦</span>聊天记录分析</div>
<div class="nav-item nav-project-only hidden" data-view="data" data-tab="advicers"><span class="ico">◉</span>销售管理</div>
<div class="nav-item nav-project-only hidden" data-view="data" data-tab="skills"><span class="ico">◫</span>聊天技巧</div>
<div class="nav-item nav-project-only hidden" data-view="data" data-tab="clients"><span class="ico">◈</span>客户管理</div>
@ -95,11 +94,11 @@
<script src="assets/js/wx.js?v=68"></script>
<script src="assets/js/wx_login.js?v=46"></script>
<script src="assets/js/wx_chat.js?v=46"></script>
<script src="assets/js/project.js?v=21"></script>
<script src="assets/js/project_list.js?v=18"></script>
<script src="assets/js/project_detail.js?v=27"></script>
<script src="assets/js/project.js?v=22"></script>
<script src="assets/js/project_list.js?v=19"></script>
<script src="assets/js/project_detail.js?v=28"></script>
<script src="assets/js/project_analysis.js?v=21"></script>
<script src="assets/js/project_data.js?v=40"></script>
<script src="assets/js/project_data.js?v=49"></script>
<script src="assets/js/project_activity.js?v=18"></script>
<script src="assets/js/project_drill.js?v=19"></script>
<script src="assets/js/project_smart.js?v=18"></script>