427 lines
15 KiB
Go
427 lines
15 KiB
Go
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
|
||
}
|