fix: 修改服务调用和测试用例
This commit is contained in:
parent
7b96c79ab4
commit
389cad8d8a
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ var ProviderSetBiz = wire.NewSet(
|
|||
NewAdviceStrategyBiz,
|
||||
NewAdviceProactiveBiz,
|
||||
NewAdviceIterateBiz,
|
||||
NewAdviceFollowBiz,
|
||||
NewAdviceCustomerBiz,
|
||||
NewAdviceLabelBiz,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 = `你是资深销售管理专家。请根据【客户画像】【互动统计】【最近聊天记录】,评估该客户当前所处的客户阶段。
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -17,4 +17,5 @@ var ProviderImpl = wire.NewSet(
|
|||
NewAdviceAdminImpl,
|
||||
NewAdviceCustomerImpl,
|
||||
NewAdviceLabelImpl,
|
||||
NewAdviceFollowImpl,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 扁平栏目数据
|
||||
|
|
|
|||
|
|
@ -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(客户画像自动归属)
|
||||
}
|
||||
|
||||
// ==================== 标签同步 ====================
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 主动发消息提醒客户)';
|
||||
|
|
@ -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='成交线索(约定到访/成交/付款,供销售跟进并注入后续对话)';
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"); });
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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); };
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue