ai_scheduler/internal/biz/advice_follow.go

427 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}